From 800eb3b547aa5e073248f1db9272efe556df6928 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Feb 2026 20:13:10 -0800 Subject: [PATCH] Add per-thread multi-terminal sessions and UI state - Support multiple terminal IDs per thread across contracts, server manager, and WS handling - Persist terminal history per terminal ID and allow closing all terminals for a thread - Update web thread state and terminal drawer to create, split, and switch active terminals - Expand tests for multi-terminal behavior and terminal ID propagation --- apps/server/src/terminalManager.test.ts | 80 ++- apps/server/src/terminalManager.ts | 254 +++++++--- apps/server/src/wsServer.test.ts | 47 +- apps/web/src/App.tsx | 18 +- apps/web/src/components/ChatView.tsx | 44 +- apps/web/src/components/Sidebar.tsx | 11 +- .../src/components/ThreadTerminalDrawer.tsx | 462 ++++++++++++------ apps/web/src/persistenceSchema.test.ts | 29 +- apps/web/src/persistenceSchema.ts | 40 ++ apps/web/src/store.test.ts | 51 +- apps/web/src/store.ts | 138 +++++- apps/web/src/types.ts | 6 + packages/contracts/src/ipc.ts | 4 +- packages/contracts/src/terminal.test.ts | 24 + packages/contracts/src/terminal.ts | 20 +- 15 files changed, 948 insertions(+), 280 deletions(-) diff --git a/apps/server/src/terminalManager.test.ts b/apps/server/src/terminalManager.test.ts index 906c3d0fe48cdb5a7e9f4d02094983c3f8d7a72a..b8257e07c649ab9d65b64e60c6313562a83fa83e 100644 --- a/apps/server/src/terminalManager.test.ts +++ b/apps/server/src/terminalManager.test.ts @@ -2,10 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { - TerminalEvent, - TerminalOpenInput, -} from "@t3tools/contracts"; +import { DEFAULT_TERMINAL_ID, type TerminalEvent, type TerminalOpenInput } from "@t3tools/contracts"; import { afterEach, describe, expect, it } from "vitest"; import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; @@ -109,10 +106,26 @@ function historyLogName(threadId: string): string { return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}.log`; } +function multiTerminalHistoryLogName(threadId: string, terminalId: string): string { + const threadPart = `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`; + if (terminalId === DEFAULT_TERMINAL_ID) { + return `${threadPart}.log`; + } + return `${threadPart}_${Buffer.from(terminalId, "utf8").toString("base64url")}.log`; +} + function historyLogPath(logsDir: string, threadId = "thread-1"): string { return path.join(logsDir, historyLogName(threadId)); } +function multiTerminalHistoryLogPath( + logsDir: string, + threadId = "thread-1", + terminalId = "default", +): string { + return path.join(logsDir, multiTerminalHistoryLogName(threadId, terminalId)); +} + describe("TerminalManager", () => { const tempDirs: string[] = []; @@ -144,6 +157,7 @@ describe("TerminalManager", () => { const third = await manager.open(openInput()); expect(first.threadId).toBe("thread-1"); + expect(first.terminalId).toBe("default"); expect(second.threadId).toBe("thread-1"); expect(third.threadId).toBe("thread-1"); expect(ptyAdapter.spawnInputs).toHaveLength(1); @@ -167,6 +181,27 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("supports multiple terminals per thread with isolated sessions", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput({ terminalId: "default" })); + await manager.open(openInput({ terminalId: "term-2" })); + + const first = ptyAdapter.processes[0]; + const second = ptyAdapter.processes[1]; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + if (!first || !second) return; + + await manager.write({ threadId: "thread-1", terminalId: "default", data: "pwd\n" }); + await manager.write({ threadId: "thread-1", terminalId: "term-2", data: "ls\n" }); + + expect(first.writes).toEqual(["pwd\n"]); + expect(second.writes).toEqual(["ls\n"]); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + + manager.dispose(); + }); + it("clears transcript and emits cleared event", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); const events: TerminalEvent[] = []; @@ -184,6 +219,14 @@ describe("TerminalManager", () => { await waitFor(() => fs.readFileSync(historyLogPath(logsDir), "utf8") === ""); expect(events.some((event) => event.type === "cleared")).toBe(true); + expect( + events.some( + (event) => + event.type === "cleared" && + event.threadId === "thread-1" && + event.terminalId === "default", + ), + ).toBe(true); manager.dispose(); }); @@ -262,6 +305,35 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("closes all terminals for a thread when close omits terminalId", async () => { + const { manager, ptyAdapter, logsDir } = makeManager(); + await manager.open(openInput({ terminalId: "default" })); + await manager.open(openInput({ terminalId: "sidecar" })); + const defaultProcess = ptyAdapter.processes[0]; + const sidecarProcess = ptyAdapter.processes[1]; + expect(defaultProcess).toBeDefined(); + expect(sidecarProcess).toBeDefined(); + if (!defaultProcess || !sidecarProcess) return; + + defaultProcess.emitData("default\n"); + sidecarProcess.emitData("sidecar\n"); + await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))); + await waitFor(() => fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))); + + await manager.close({ threadId: "thread-1", deleteHistory: true }); + + expect(defaultProcess.killed).toBe(true); + expect(sidecarProcess.killed).toBe(true); + expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "default"))).toBe( + false, + ); + expect(fs.existsSync(multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"))).toBe( + false, + ); + + manager.dispose(); + }); + it("loads existing legacy transcript filenames and keeps new naming for writes", async () => { const { manager, logsDir } = makeManager(); const legacyPath = path.join(logsDir, "thread-1.log"); diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index 0e4e130e02a180cd09e8c82aa3c859790135083c..8c8d18cdf55815461ff82e342bfb4bb13b7b07fa 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -3,17 +3,19 @@ import fs from "node:fs"; import path from "node:path"; import { + DEFAULT_TERMINAL_ID, + type TerminalClearInput, type TerminalCloseInput, type TerminalEvent, type TerminalOpenInput, + type TerminalResizeInput, type TerminalSessionSnapshot, type TerminalSessionStatus, - type TerminalThreadInput, type TerminalWriteInput, + terminalClearInputSchema, terminalCloseInputSchema, terminalOpenInputSchema, terminalResizeInputSchema, - terminalThreadInputSchema, terminalWriteInputSchema, } from "@t3tools/contracts"; @@ -36,6 +38,7 @@ export interface TerminalManagerOptions { interface TerminalSessionState { threadId: string; + terminalId: string; cwd: string; status: TerminalSessionStatus; pid: number | null; @@ -139,6 +142,14 @@ function toSafeThreadId(threadId: string): string { return `terminal_${Buffer.from(threadId, "utf8").toString("base64url")}`; } +function toSafeTerminalId(terminalId: string): string { + return Buffer.from(terminalId, "utf8").toString("base64url"); +} + +function toSessionKey(threadId: string, terminalId: string): string { + return `${threadId}\u0000${terminalId}`; +} + export class TerminalManager extends EventEmitter { private readonly sessions = new Map(); private readonly logsDir: string; @@ -167,12 +178,14 @@ export class TerminalManager extends EventEmitter { return this.runWithThreadLock(input.threadId, async () => { await this.assertValidCwd(input.cwd); - const existing = this.sessions.get(input.threadId); + const sessionKey = toSessionKey(input.threadId, input.terminalId); + const existing = this.sessions.get(sessionKey); if (!existing) { - await this.flushPersistQueue(input.threadId); - const history = await this.readHistory(input.threadId); + await this.flushPersistQueue(input.threadId, input.terminalId); + const history = await this.readHistory(input.threadId, input.terminalId); const session: TerminalSessionState = { threadId: input.threadId, + terminalId: input.terminalId, cwd: input.cwd, status: "starting", pid: null, @@ -186,7 +199,7 @@ export class TerminalManager extends EventEmitter { unsubscribeData: null, unsubscribeExit: null, }; - this.sessions.set(input.threadId, session); + this.sessions.set(sessionKey, session); this.startSession(session, input, "started"); return this.snapshot(session); } @@ -195,10 +208,10 @@ export class TerminalManager extends EventEmitter { this.stopProcess(existing); existing.cwd = input.cwd; existing.history = ""; - await this.persistHistory(existing.threadId, existing.history); + await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } else if (existing.status === "exited" || existing.status === "error") { existing.history = ""; - await this.persistHistory(existing.threadId, existing.history); + await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } if (!existing.process) { @@ -219,18 +232,22 @@ export class TerminalManager extends EventEmitter { async write(raw: TerminalWriteInput): Promise { const input = terminalWriteInputSchema.parse(raw); - const session = this.requireSession(input.threadId); + const session = this.requireSession(input.threadId, input.terminalId); if (!session.process || session.status !== "running") { - throw new Error(`Terminal is not running for thread: ${input.threadId}`); + throw new Error( + `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, + ); } session.process.write(input.data); } - async resize(raw: TerminalThreadInput & { cols: number; rows: number }): Promise { + async resize(raw: TerminalResizeInput): Promise { const input = terminalResizeInputSchema.parse(raw); - const session = this.requireSession(input.threadId); + const session = this.requireSession(input.threadId, input.terminalId); if (!session.process || session.status !== "running") { - throw new Error(`Terminal is not running for thread: ${input.threadId}`); + throw new Error( + `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, + ); } session.cols = input.cols; session.rows = input.rows; @@ -238,16 +255,17 @@ export class TerminalManager extends EventEmitter { session.process.resize(input.cols, input.rows); } - async clear(raw: TerminalThreadInput): Promise { - const input = terminalThreadInputSchema.parse(raw); + async clear(raw: TerminalClearInput): Promise { + const input = terminalClearInputSchema.parse(raw); await this.runWithThreadLock(input.threadId, async () => { - const session = this.requireSession(input.threadId); + const session = this.requireSession(input.threadId, input.terminalId); session.history = ""; session.updatedAt = new Date().toISOString(); - await this.persistHistory(input.threadId, session.history); + await this.persistHistory(input.threadId, input.terminalId, session.history); this.emitEvent({ type: "cleared", threadId: input.threadId, + terminalId: input.terminalId, createdAt: new Date().toISOString(), }); }); @@ -258,10 +276,12 @@ export class TerminalManager extends EventEmitter { return this.runWithThreadLock(input.threadId, async () => { await this.assertValidCwd(input.cwd); - let session = this.sessions.get(input.threadId); + const sessionKey = toSessionKey(input.threadId, input.terminalId); + let session = this.sessions.get(sessionKey); if (!session) { session = { threadId: input.threadId, + terminalId: input.terminalId, cwd: input.cwd, status: "starting", pid: null, @@ -275,14 +295,14 @@ export class TerminalManager extends EventEmitter { unsubscribeData: null, unsubscribeExit: null, }; - this.sessions.set(input.threadId, session); + this.sessions.set(sessionKey, session); } else { this.stopProcess(session); session.cwd = input.cwd; } session.history = ""; - await this.persistHistory(input.threadId, session.history); + await this.persistHistory(input.threadId, input.terminalId, session.history); this.startSession(session, input, "restarted"); return this.snapshot(session); }); @@ -291,14 +311,28 @@ export class TerminalManager extends EventEmitter { async close(raw: TerminalCloseInput): Promise { const input = terminalCloseInputSchema.parse(raw); await this.runWithThreadLock(input.threadId, async () => { - const session = this.sessions.get(input.threadId); - if (session) { + if (input.terminalId) { + await this.closeSession( + input.threadId, + input.terminalId, + input.deleteHistory === true, + ); + return; + } + + const threadSessions = this.sessionsForThread(input.threadId); + for (const session of threadSessions) { this.stopProcess(session); - this.sessions.delete(input.threadId); + this.sessions.delete(toSessionKey(session.threadId, session.terminalId)); } - await this.flushPersistQueue(input.threadId); + await Promise.all( + threadSessions.map((session) => + this.flushPersistQueue(session.threadId, session.terminalId), + ), + ); + if (input.deleteHistory) { - await this.deleteHistory(input.threadId); + await this.deleteAllHistoryForThread(input.threadId); } }); } @@ -382,6 +416,7 @@ export class TerminalManager extends EventEmitter { this.emitEvent({ type: eventType, threadId: session.threadId, + terminalId: session.terminalId, createdAt: new Date().toISOString(), snapshot: this.snapshot(session), }); @@ -401,11 +436,13 @@ export class TerminalManager extends EventEmitter { this.emitEvent({ type: "error", threadId: session.threadId, + terminalId: session.terminalId, createdAt: new Date().toISOString(), message, }); this.logger.error("failed to start terminal", { threadId: session.threadId, + terminalId: session.terminalId, error: message, ...(startedShell ? { shell: startedShell } : {}), }); @@ -415,10 +452,11 @@ export class TerminalManager extends EventEmitter { private onProcessData(session: TerminalSessionState, data: string): void { session.history = capHistory(`${session.history}${data}`, this.historyLineLimit); session.updatedAt = new Date().toISOString(); - this.queuePersist(session.threadId, session.history); + this.queuePersist(session.threadId, session.terminalId, session.history); this.emitEvent({ type: "output", threadId: session.threadId, + terminalId: session.terminalId, createdAt: new Date().toISOString(), data, }); @@ -435,6 +473,7 @@ export class TerminalManager extends EventEmitter { this.emitEvent({ type: "exited", threadId: session.threadId, + terminalId: session.terminalId, createdAt: new Date().toISOString(), exitCode: session.exitCode, exitSignal: session.exitSignal, @@ -455,6 +494,7 @@ export class TerminalManager extends EventEmitter { const message = error instanceof Error ? error.message : String(error); this.logger.warn("failed to kill terminal process", { threadId: session.threadId, + terminalId: session.terminalId, error: message, }); } @@ -467,69 +507,87 @@ export class TerminalManager extends EventEmitter { session.unsubscribeExit = null; } - private queuePersist(threadId: string, history: string): void { - this.pendingPersistHistory.set(threadId, history); - this.schedulePersist(threadId); + private queuePersist(threadId: string, terminalId: string, history: string): void { + const persistenceKey = toSessionKey(threadId, terminalId); + this.pendingPersistHistory.set(persistenceKey, history); + this.schedulePersist(threadId, terminalId); } - private async persistHistory(threadId: string, history: string): Promise { - this.clearPersistTimer(threadId); - this.pendingPersistHistory.delete(threadId); - await this.enqueuePersistWrite(threadId, history); + private async persistHistory( + threadId: string, + terminalId: string, + history: string, + ): Promise { + const persistenceKey = toSessionKey(threadId, terminalId); + this.clearPersistTimer(threadId, terminalId); + this.pendingPersistHistory.delete(persistenceKey); + await this.enqueuePersistWrite(threadId, terminalId, history); } - private enqueuePersistWrite(threadId: string, history: string): Promise { + private enqueuePersistWrite( + threadId: string, + terminalId: string, + history: string, + ): Promise { + const persistenceKey = toSessionKey(threadId, terminalId); const task = async () => { - await fs.promises.writeFile(this.historyPath(threadId), history, "utf8"); + await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8"); }; - const previous = this.persistQueues.get(threadId) ?? Promise.resolve(); + const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve(); const next = previous .catch(() => undefined) .then(task) .catch((error) => { this.logger.warn("failed to persist terminal history", { threadId, + terminalId, error: error instanceof Error ? error.message : String(error), }); }); - this.persistQueues.set(threadId, next); + this.persistQueues.set(persistenceKey, next); const finalized = next.finally(() => { - if (this.persistQueues.get(threadId) === next) { - this.persistQueues.delete(threadId); + if (this.persistQueues.get(persistenceKey) === next) { + this.persistQueues.delete(persistenceKey); } - if (this.pendingPersistHistory.has(threadId) && !this.persistTimers.has(threadId)) { - this.schedulePersist(threadId); + if ( + this.pendingPersistHistory.has(persistenceKey) && + !this.persistTimers.has(persistenceKey) + ) { + this.schedulePersist(threadId, terminalId); } }); void finalized.catch(() => undefined); return finalized; } - private schedulePersist(threadId: string): void { - if (this.persistTimers.has(threadId)) return; + private schedulePersist(threadId: string, terminalId: string): void { + const persistenceKey = toSessionKey(threadId, terminalId); + if (this.persistTimers.has(persistenceKey)) return; const timer = setTimeout(() => { - this.persistTimers.delete(threadId); - const pendingHistory = this.pendingPersistHistory.get(threadId); + this.persistTimers.delete(persistenceKey); + const pendingHistory = this.pendingPersistHistory.get(persistenceKey); if (pendingHistory === undefined) return; - this.pendingPersistHistory.delete(threadId); - void this.enqueuePersistWrite(threadId, pendingHistory); + this.pendingPersistHistory.delete(persistenceKey); + void this.enqueuePersistWrite(threadId, terminalId, pendingHistory); }, this.persistDebounceMs); - this.persistTimers.set(threadId, timer); + this.persistTimers.set(persistenceKey, timer); } - private clearPersistTimer(threadId: string): void { - const timer = this.persistTimers.get(threadId); + private clearPersistTimer(threadId: string, terminalId: string): void { + const persistenceKey = toSessionKey(threadId, terminalId); + const timer = this.persistTimers.get(persistenceKey); if (!timer) return; clearTimeout(timer); - this.persistTimers.delete(threadId); + this.persistTimers.delete(persistenceKey); } - private async readHistory(threadId: string): Promise { + private async readHistory(threadId: string, terminalId: string): Promise { + const historyPath = this.historyPath(threadId, terminalId); try { - const raw = await fs.promises.readFile(this.historyPath(threadId), "utf8"); + const raw = await fs.promises.readFile(historyPath, "utf8"); const capped = capHistory(raw, this.historyLineLimit); if (capped !== raw) { - await fs.promises.writeFile(this.historyPath(threadId), capped, "utf8"); + await fs.promises.writeFile(historyPath, capped, "utf8"); } return capped; } catch (error) { @@ -538,6 +596,10 @@ export class TerminalManager extends EventEmitter { } } + if (terminalId !== DEFAULT_TERMINAL_ID) { + return ""; + } + try { const raw = await fs.promises.readFile(this.legacyHistoryPath(threadId), "utf8"); const capped = capHistory(raw, this.historyLineLimit); @@ -553,31 +615,34 @@ export class TerminalManager extends EventEmitter { } } - private async deleteHistory(threadId: string): Promise { + private async deleteHistory(threadId: string, terminalId: string): Promise { + const deletions = [fs.promises.rm(this.historyPath(threadId, terminalId), { force: true })]; + if (terminalId === DEFAULT_TERMINAL_ID) { + deletions.push(fs.promises.rm(this.legacyHistoryPath(threadId), { force: true })); + } try { - await Promise.all([ - fs.promises.rm(this.historyPath(threadId), { force: true }), - fs.promises.rm(this.legacyHistoryPath(threadId), { force: true }), - ]); + await Promise.all(deletions); } catch (error) { this.logger.warn("failed to delete terminal history", { threadId, + terminalId, error: error instanceof Error ? error.message : String(error), }); } } - private async flushPersistQueue(threadId: string): Promise { - this.clearPersistTimer(threadId); + private async flushPersistQueue(threadId: string, terminalId: string): Promise { + const persistenceKey = toSessionKey(threadId, terminalId); + this.clearPersistTimer(threadId, terminalId); while (true) { - const pendingHistory = this.pendingPersistHistory.get(threadId); + const pendingHistory = this.pendingPersistHistory.get(persistenceKey); if (pendingHistory !== undefined) { - this.pendingPersistHistory.delete(threadId); - await this.enqueuePersistWrite(threadId, pendingHistory); + this.pendingPersistHistory.delete(persistenceKey); + await this.enqueuePersistWrite(threadId, terminalId, pendingHistory); } - const pending = this.persistQueues.get(threadId); + const pending = this.persistQueues.get(persistenceKey); if (!pending) { return; } @@ -600,10 +665,54 @@ export class TerminalManager extends EventEmitter { } } - private requireSession(threadId: string): TerminalSessionState { - const session = this.sessions.get(threadId); + private async closeSession( + threadId: string, + terminalId: string, + deleteHistory: boolean, + ): Promise { + const key = toSessionKey(threadId, terminalId); + const session = this.sessions.get(key); + if (session) { + this.stopProcess(session); + this.sessions.delete(key); + } + await this.flushPersistQueue(threadId, terminalId); + if (deleteHistory) { + await this.deleteHistory(threadId, terminalId); + } + } + + private sessionsForThread(threadId: string): TerminalSessionState[] { + return [...this.sessions.values()].filter((session) => session.threadId === threadId); + } + + private async deleteAllHistoryForThread(threadId: string): Promise { + const threadPrefix = `${toSafeThreadId(threadId)}_`; + try { + const entries = await fs.promises.readdir(this.logsDir, { withFileTypes: true }); + const removals = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter( + (name) => + name === `${toSafeThreadId(threadId)}.log` || + name === `${legacySafeThreadId(threadId)}.log` || + name.startsWith(threadPrefix), + ) + .map((name) => fs.promises.rm(path.join(this.logsDir, name), { force: true })); + await Promise.all(removals); + } catch (error) { + this.logger.warn("failed to delete terminal histories for thread", { + threadId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + private requireSession(threadId: string, terminalId: string): TerminalSessionState { + const session = this.sessions.get(toSessionKey(threadId, terminalId)); if (!session) { - throw new Error(`Unknown terminal thread: ${threadId}`); + throw new Error(`Unknown terminal thread: ${threadId}, terminal: ${terminalId}`); } return session; } @@ -611,6 +720,7 @@ export class TerminalManager extends EventEmitter { private snapshot(session: TerminalSessionState): TerminalSessionSnapshot { return { threadId: session.threadId, + terminalId: session.terminalId, cwd: session.cwd, status: session.status, pid: session.pid, @@ -625,8 +735,12 @@ export class TerminalManager extends EventEmitter { this.emit("event", event); } - private historyPath(threadId: string): string { - return path.join(this.logsDir, `${toSafeThreadId(threadId)}.log`); + private historyPath(threadId: string, terminalId: string): string { + const threadPart = toSafeThreadId(threadId); + if (terminalId === DEFAULT_TERMINAL_ID) { + return path.join(this.logsDir, `${threadPart}.log`); + } + return path.join(this.logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); } private legacyHistoryPath(threadId: string): string { diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index e0a73a943d64b06b9675f6b77de572b53671d7ae..6cf6bdcd185b895aa25942ea667e815a35732d24 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -7,14 +7,21 @@ import { describe, expect, it, afterEach, vi } from "vitest"; import { createServer } from "./wsServer"; import WebSocket from "ws"; -import { WS_CHANNELS, WS_METHODS, type WsPush, type WsResponse } from "@t3tools/contracts"; +import { + DEFAULT_TERMINAL_ID, + WS_CHANNELS, + WS_METHODS, + type WsPush, + type WsResponse, +} from "@t3tools/contracts"; import { ProjectRegistry } from "./projectRegistry"; import type { + TerminalClearInput, TerminalCloseInput, TerminalEvent, TerminalOpenInput, + TerminalResizeInput, TerminalSessionSnapshot, - TerminalThreadInput, TerminalWriteInput, } from "@t3tools/contracts"; import type { TerminalManager } from "./terminalManager"; @@ -29,10 +36,16 @@ const pendingBySocket = new WeakMap(); class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { private readonly sessions = new Map(); + private key(threadId: string, terminalId: string): string { + return `${threadId}\u0000${terminalId}`; + } + async open(input: TerminalOpenInput): Promise { const now = new Date().toISOString(); + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; const snapshot: TerminalSessionSnapshot = { threadId: input.threadId, + terminalId, cwd: input.cwd, status: "running", pid: 4242, @@ -41,11 +54,12 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } exitSignal: null, updatedAt: now, }; - this.sessions.set(input.threadId, snapshot); + this.sessions.set(this.key(input.threadId, terminalId), snapshot); queueMicrotask(() => { this.emit("event", { type: "started", threadId: input.threadId, + terminalId, createdAt: now, snapshot, }); @@ -54,7 +68,8 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } } async write(input: TerminalWriteInput): Promise { - const existing = this.sessions.get(input.threadId); + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const existing = this.sessions.get(this.key(input.threadId, terminalId)); if (!existing) { throw new Error(`Unknown terminal thread: ${input.threadId}`); } @@ -62,19 +77,22 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } this.emit("event", { type: "output", threadId: input.threadId, + terminalId, createdAt: new Date().toISOString(), data: input.data, }); }); } - async resize(_input: TerminalThreadInput & { cols: number; rows: number }): Promise {} + async resize(_input: TerminalResizeInput): Promise {} - async clear(input: TerminalThreadInput): Promise { + async clear(input: TerminalClearInput): Promise { + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; queueMicrotask(() => { this.emit("event", { type: "cleared", threadId: input.threadId, + terminalId, createdAt: new Date().toISOString(), }); }); @@ -82,8 +100,10 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } async restart(input: TerminalOpenInput): Promise { const now = new Date().toISOString(); + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; const snapshot: TerminalSessionSnapshot = { threadId: input.threadId, + terminalId, cwd: input.cwd, status: "running", pid: 5252, @@ -92,11 +112,12 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } exitSignal: null, updatedAt: now, }; - this.sessions.set(input.threadId, snapshot); + this.sessions.set(this.key(input.threadId, terminalId), snapshot); queueMicrotask(() => { this.emit("event", { type: "restarted", threadId: input.threadId, + terminalId, createdAt: now, snapshot, }); @@ -105,7 +126,15 @@ class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] } } async close(input: TerminalCloseInput): Promise { - this.sessions.delete(input.threadId); + if (input.terminalId) { + this.sessions.delete(this.key(input.threadId, input.terminalId)); + return; + } + for (const key of [...this.sessions.keys()]) { + if (key.startsWith(`${input.threadId}\u0000`)) { + this.sessions.delete(key); + } + } } dispose(): void {} @@ -340,6 +369,7 @@ describe("WebSocket Server", () => { }); expect(open.error).toBeUndefined(); expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1"); + expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID); const write = await sendRequest(ws, WS_METHODS.terminalWrite, { threadId: "thread-1", @@ -376,6 +406,7 @@ describe("WebSocket Server", () => { const manualEvent: TerminalEvent = { type: "output", threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), data: "manual test output\n", }; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 88d2b233e85dc86e14468cfc5bf50914be1d845c..bcb5ac6912daede55e0dc8f56de21eac8840332e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,7 +7,7 @@ import Sidebar from "./components/Sidebar"; import { isElectron } from "./env"; import { DEFAULT_MODEL } from "./model-logic"; import { StoreProvider, useStore } from "./store"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types"; +import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; import { onServerWelcome } from "./wsNativeApi"; import { useNativeApi } from "./hooks/useNativeApi"; @@ -27,18 +27,6 @@ function EventRouter() { }); }, [api, dispatch]); - useEffect(() => { - if (!api) return; - return api.terminal.onEvent((event) => { - if (event.type !== "exited") return; - dispatch({ - type: "SET_THREAD_TERMINAL_OPEN", - threadId: event.threadId, - open: false, - }); - }); - }, [api, dispatch]); - return null; } @@ -93,6 +81,10 @@ function AutoProjectBootstrap() { model: DEFAULT_MODEL, terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalLayout: "single", + splitTerminalIds: [], session: null, messages: [], events: [], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e27d5b5cf7269b98bea0819a76a608a7fdc770cc..c57c6d227d033afba211cace3b9c25828bfe63a8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -269,6 +269,36 @@ export default function ChatView() { open: !isOpen, }); }, [activeThread?.terminalOpen, activeThreadId, dispatch]); + const splitTerminal = useCallback(() => { + if (!activeThreadId) return; + dispatch({ + type: "SPLIT_THREAD_TERMINAL", + threadId: activeThreadId, + terminalId: `terminal-${crypto.randomUUID()}`, + }); + setTerminalFocusRequestId((value) => value + 1); + }, [activeThreadId, dispatch]); + const createNewTerminal = useCallback(() => { + if (!activeThreadId) return; + dispatch({ + type: "NEW_THREAD_TERMINAL", + threadId: activeThreadId, + terminalId: `terminal-${crypto.randomUUID()}`, + }); + setTerminalFocusRequestId((value) => value + 1); + }, [activeThreadId, dispatch]); + const activateTerminal = useCallback( + (terminalId: string) => { + if (!activeThreadId) return; + dispatch({ + type: "SET_THREAD_ACTIVE_TERMINAL", + threadId: activeThreadId, + terminalId, + }); + setTerminalFocusRequestId((value) => value + 1); + }, + [activeThreadId, dispatch], + ); const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { if (mode === state.runtimeMode) return; @@ -1451,7 +1481,14 @@ export default function ChatView() { threadId={activeThread.id} cwd={activeProject.cwd} height={activeThread.terminalHeight} + terminalIds={activeThread.terminalIds} + activeTerminalId={activeThread.activeTerminalId} + terminalLayout={activeThread.terminalLayout} + splitTerminalIds={activeThread.splitTerminalIds} focusRequestId={terminalFocusRequestId} + onSplitTerminal={splitTerminal} + onNewTerminal={createNewTerminal} + onActiveTerminalChange={activateTerminal} onHeightChange={(height) => dispatch({ type: "SET_THREAD_TERMINAL_HEIGHT", @@ -1459,13 +1496,6 @@ export default function ChatView() { height, }) } - onThreadExited={() => - dispatch({ - type: "SET_THREAD_TERMINAL_OPEN", - threadId: activeThread.id, - open: false, - }) - } /> )} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4e0eed7b0ee1ec9f52b6306aca62cfb6c5a07a4a..f5634044fd6b6a1476e39d6069ef0cd3f6bdabfe 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,7 +5,12 @@ import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT, type Project, type Thread } from "../types"; +import { + DEFAULT_THREAD_TERMINAL_HEIGHT, + DEFAULT_THREAD_TERMINAL_ID, + type Project, + type Thread, +} from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; @@ -112,6 +117,10 @@ export default function Sidebar() { model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL, terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalLayout: "single", + splitTerminalIds: [], session: null, messages: [], events: [], diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 311a932a139c54e2b31482b79cdc128cce17513d..fd9dc07786025b86bdd89785eb0658e684c2f25b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -5,6 +5,7 @@ import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; @@ -15,7 +16,11 @@ import { resolvePathLinkTarget, } from "../terminal-links"; import { isTerminalClearShortcut } from "../terminal-shortcuts"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "../types"; +import { + DEFAULT_THREAD_TERMINAL_HEIGHT, + DEFAULT_THREAD_TERMINAL_ID, + type ThreadTerminalLayout, +} from "../types"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -103,162 +108,30 @@ function terminalThemeFromApp(): ITheme { }; } -interface ThreadTerminalDrawerProps { +interface TerminalViewportProps { api: NativeApi; threadId: string; + terminalId: string; cwd: string; - height: number; focusRequestId: number; - onHeightChange: (height: number) => void; - onThreadExited: () => void; + autoFocus: boolean; + resizeEpoch: number; + drawerHeight: number; } -export default function ThreadTerminalDrawer({ +function TerminalViewport({ api, threadId, + terminalId, cwd, - height, focusRequestId, - onHeightChange, - onThreadExited, -}: ThreadTerminalDrawerProps) { - const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); + autoFocus, + resizeEpoch, + drawerHeight, +}: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); - const drawerHeightRef = useRef(drawerHeight); - const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); - const onHeightChangeRef = useRef(onHeightChange); - const onThreadExitedRef = useRef(onThreadExited); - const resizeStateRef = useRef<{ - pointerId: number; - startY: number; - startHeight: number; - } | null>(null); - const didResizeDuringDragRef = useRef(false); - - useEffect(() => { - onHeightChangeRef.current = onHeightChange; - }, [onHeightChange]); - - useEffect(() => { - onThreadExitedRef.current = onThreadExited; - }, [onThreadExited]); - - useEffect(() => { - drawerHeightRef.current = drawerHeight; - }, [drawerHeight]); - - const syncHeight = useCallback( - (nextHeight: number) => { - const clampedHeight = clampDrawerHeight(nextHeight); - if (lastSyncedHeightRef.current === clampedHeight) return; - lastSyncedHeightRef.current = clampedHeight; - onHeightChangeRef.current(clampedHeight); - }, - [], - ); - - const fitAndResizeTerminal = useCallback((preserveBottom = false) => { - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - const wasAtBottom = - preserveBottom && - activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; - activeFitAddon.fit(); - if (wasAtBottom) { - activeTerminal.scrollToBottom(); - } - void api.terminal - .resize({ - threadId, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - }) - .catch(() => undefined); - }, [api, threadId]); - - useEffect(() => { - const clampedHeight = clampDrawerHeight(height); - setDrawerHeight(clampedHeight); - lastSyncedHeightRef.current = clampedHeight; - }, [height, threadId]); - - const handleResizePointerDown = useCallback( - (event: ReactPointerEvent) => { - if (event.button !== 0) return; - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - didResizeDuringDragRef.current = false; - resizeStateRef.current = { - pointerId: event.pointerId, - startY: event.clientY, - startHeight: drawerHeightRef.current, - }; - }, - [], - ); - - const handleResizePointerMove = useCallback( - (event: ReactPointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - event.preventDefault(); - const clampedHeight = clampDrawerHeight( - resizeState.startHeight + (resizeState.startY - event.clientY), - ); - if (clampedHeight === drawerHeightRef.current) { - return; - } - didResizeDuringDragRef.current = true; - drawerHeightRef.current = clampedHeight; - setDrawerHeight(clampedHeight); - }, - [], - ); - - const handleResizePointerEnd = useCallback( - (event: ReactPointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - resizeStateRef.current = null; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - if (!didResizeDuringDragRef.current) { - return; - } - syncHeight(drawerHeightRef.current); - fitAndResizeTerminal(true); - }, - [fitAndResizeTerminal, syncHeight], - ); - - useEffect(() => { - const onWindowResize = () => { - const clampedHeight = clampDrawerHeight(drawerHeightRef.current); - const changed = clampedHeight !== drawerHeightRef.current; - if (changed) { - setDrawerHeight(clampedHeight); - } else { - fitAndResizeTerminal(true); - } - if (!resizeStateRef.current) { - syncHeight(clampedHeight); - } - }; - window.addEventListener("resize", onWindowResize); - return () => { - window.removeEventListener("resize", onWindowResize); - }; - }, [fitAndResizeTerminal, syncHeight]); - - useEffect(() => { - return () => { - syncHeight(drawerHeightRef.current); - }; - }, [syncHeight]); useEffect(() => { const mount = containerRef.current; @@ -287,7 +160,7 @@ export default function ThreadTerminalDrawer({ const activeTerminal = terminalRef.current; if (!activeTerminal) return; try { - await api.terminal.write({ threadId, data: "\u000c" }); + await api.terminal.write({ threadId, terminalId, data: "\u000c" }); } catch (error) { writeSystemMessage( activeTerminal, @@ -367,7 +240,7 @@ export default function ThreadTerminalDrawer({ const inputDisposable = terminal.onData((data) => { void api.terminal - .write({ threadId, data }) + .write({ threadId, terminalId, data }) .catch((err) => writeSystemMessage( terminal, @@ -395,6 +268,7 @@ export default function ThreadTerminalDrawer({ activeFitAddon.fit(); const snapshot = await api.terminal.open({ threadId, + terminalId, cwd, cols: activeTerminal.cols, rows: activeTerminal.rows, @@ -404,9 +278,11 @@ export default function ThreadTerminalDrawer({ if (snapshot.history.length > 0) { activeTerminal.write(snapshot.history); } - window.requestAnimationFrame(() => { - activeTerminal.focus(); - }); + if (autoFocus) { + window.requestAnimationFrame(() => { + activeTerminal.focus(); + }); + } } catch (err) { if (disposed) return; writeSystemMessage( @@ -417,7 +293,7 @@ export default function ThreadTerminalDrawer({ }; const unsubscribe = api.terminal.onEvent((event) => { - if (event.threadId !== threadId) return; + if (event.threadId !== threadId || event.terminalId !== terminalId) return; const activeTerminal = terminalRef.current; if (!activeTerminal) return; @@ -446,12 +322,37 @@ export default function ThreadTerminalDrawer({ } if (event.type === "exited") { - onThreadExitedRef.current(); + const details = [ + typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, + typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, + ] + .filter((value): value is string => value !== null) + .join(", "); + writeSystemMessage( + activeTerminal, + details.length > 0 ? `Process exited (${details})` : "Process exited", + ); } }); const fitTimer = window.setTimeout(() => { - fitAndResizeTerminal(true); + const activeTerminal = terminalRef.current; + const activeFitAddon = fitAddonRef.current; + if (!activeTerminal || !activeFitAddon) return; + const wasAtBottom = + activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; + activeFitAddon.fit(); + if (wasAtBottom) { + activeTerminal.scrollToBottom(); + } + void api.terminal + .resize({ + threadId, + terminalId, + cols: activeTerminal.cols, + rows: activeTerminal.rows, + }) + .catch(() => undefined); }, 30); void openTerminal(); @@ -466,9 +367,10 @@ export default function ThreadTerminalDrawer({ fitAddonRef.current = null; terminal.dispose(); }; - }, [api, cwd, fitAndResizeTerminal, threadId]); + }, [api, autoFocus, cwd, terminalId, threadId]); useEffect(() => { + if (!autoFocus) return; const terminal = terminalRef.current; if (!terminal) return; const frame = window.requestAnimationFrame(() => { @@ -477,7 +379,7 @@ export default function ThreadTerminalDrawer({ return () => { window.cancelAnimationFrame(frame); }; - }, [focusRequestId]); + }, [autoFocus, focusRequestId]); useEffect(() => { const terminal = terminalRef.current; @@ -492,6 +394,7 @@ export default function ThreadTerminalDrawer({ void api.terminal .resize({ threadId, + terminalId, cols: terminal.cols, rows: terminal.rows, }) @@ -500,7 +403,176 @@ export default function ThreadTerminalDrawer({ return () => { window.cancelAnimationFrame(frame); }; - }, [api, cwd, drawerHeight, threadId]); + }, [api, drawerHeight, resizeEpoch, terminalId, threadId]); + + return
; +} + +interface ThreadTerminalDrawerProps { + api: NativeApi; + threadId: string; + cwd: string; + height: number; + terminalIds: string[]; + activeTerminalId: string; + terminalLayout: ThreadTerminalLayout; + splitTerminalIds: string[]; + focusRequestId: number; + onSplitTerminal: () => void; + onNewTerminal: () => void; + onActiveTerminalChange: (terminalId: string) => void; + onHeightChange: (height: number) => void; +} + +export default function ThreadTerminalDrawer({ + api, + threadId, + cwd, + height, + terminalIds, + activeTerminalId, + terminalLayout, + splitTerminalIds, + focusRequestId, + onSplitTerminal, + onNewTerminal, + onActiveTerminalChange, + onHeightChange, +}: ThreadTerminalDrawerProps) { + const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); + const [resizeEpoch, setResizeEpoch] = useState(0); + const drawerHeightRef = useRef(drawerHeight); + const lastSyncedHeightRef = useRef(clampDrawerHeight(height)); + const onHeightChangeRef = useRef(onHeightChange); + const resizeStateRef = useRef<{ + pointerId: number; + startY: number; + startHeight: number; + } | null>(null); + const didResizeDuringDragRef = useRef(false); + + const normalizedTerminalIds = useMemo(() => { + const cleaned = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; + return cleaned.length > 0 ? cleaned : [DEFAULT_THREAD_TERMINAL_ID]; + }, [terminalIds]); + + const resolvedActiveTerminalId = normalizedTerminalIds.includes(activeTerminalId) + ? activeTerminalId + : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + + const resolvedSplitTerminalIds = useMemo(() => { + if (terminalLayout !== "split") return []; + const validSplitIds = [...new Set(splitTerminalIds)].filter((id) => + normalizedTerminalIds.includes(id), + ); + if (validSplitIds.length >= 2) { + return validSplitIds.slice(0, 2); + } + + const fallbackPartner = normalizedTerminalIds.find((id) => id !== resolvedActiveTerminalId); + if (!fallbackPartner) { + return []; + } + return [resolvedActiveTerminalId, fallbackPartner]; + }, [normalizedTerminalIds, resolvedActiveTerminalId, splitTerminalIds, terminalLayout]); + + const visibleTerminalIds = + terminalLayout === "split" && resolvedSplitTerminalIds.length === 2 + ? resolvedSplitTerminalIds + : [resolvedActiveTerminalId]; + + const showTabBar = terminalLayout === "tabs" && normalizedTerminalIds.length > 1; + + useEffect(() => { + onHeightChangeRef.current = onHeightChange; + }, [onHeightChange]); + + useEffect(() => { + drawerHeightRef.current = drawerHeight; + }, [drawerHeight]); + + const syncHeight = useCallback((nextHeight: number) => { + const clampedHeight = clampDrawerHeight(nextHeight); + if (lastSyncedHeightRef.current === clampedHeight) return; + lastSyncedHeightRef.current = clampedHeight; + onHeightChangeRef.current(clampedHeight); + }, []); + + useEffect(() => { + const clampedHeight = clampDrawerHeight(height); + setDrawerHeight(clampedHeight); + drawerHeightRef.current = clampedHeight; + lastSyncedHeightRef.current = clampedHeight; + }, [height, threadId]); + + const handleResizePointerDown = useCallback((event: ReactPointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + didResizeDuringDragRef.current = false; + resizeStateRef.current = { + pointerId: event.pointerId, + startY: event.clientY, + startHeight: drawerHeightRef.current, + }; + }, []); + + const handleResizePointerMove = useCallback((event: ReactPointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + event.preventDefault(); + const clampedHeight = clampDrawerHeight( + resizeState.startHeight + (resizeState.startY - event.clientY), + ); + if (clampedHeight === drawerHeightRef.current) { + return; + } + didResizeDuringDragRef.current = true; + drawerHeightRef.current = clampedHeight; + setDrawerHeight(clampedHeight); + }, []); + + const handleResizePointerEnd = useCallback( + (event: ReactPointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + resizeStateRef.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (!didResizeDuringDragRef.current) { + return; + } + syncHeight(drawerHeightRef.current); + setResizeEpoch((value) => value + 1); + }, + [syncHeight], + ); + + useEffect(() => { + const onWindowResize = () => { + const clampedHeight = clampDrawerHeight(drawerHeightRef.current); + const changed = clampedHeight !== drawerHeightRef.current; + if (changed) { + setDrawerHeight(clampedHeight); + drawerHeightRef.current = clampedHeight; + } + if (!resizeStateRef.current) { + syncHeight(clampedHeight); + } + setResizeEpoch((value) => value + 1); + }; + window.addEventListener("resize", onWindowResize); + return () => { + window.removeEventListener("resize", onWindowResize); + }; + }, [syncHeight]); + + useEffect(() => { + return () => { + syncHeight(drawerHeightRef.current); + }; + }, [syncHeight]); return ( ); diff --git a/apps/web/src/persistenceSchema.test.ts b/apps/web/src/persistenceSchema.test.ts index 0081ef90b81854829d37d4c1d0d6fdc06035b853..13d934c7a8d8c44d0de53d96b6c9349e29b62a8b 100644 --- a/apps/web/src/persistenceSchema.test.ts +++ b/apps/web/src/persistenceSchema.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_MODEL } from "./model-logic"; import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types"; +import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; import type { Thread } from "./types"; describe("hydratePersistedState", () => { @@ -50,6 +50,10 @@ describe("hydratePersistedState", () => { expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); expect(hydrated?.threads[0]?.terminalOpen).toBe(false); expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); + expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); + expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); + expect(hydrated?.threads[0]?.terminalLayout).toBe("single"); + expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]); expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); expect(hydrated?.runtimeMode).toBe("full-access"); }); @@ -92,6 +96,10 @@ describe("hydratePersistedState", () => { expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); expect(hydrated?.threads[0]?.terminalOpen).toBe(false); expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); + expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); + expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); + expect(hydrated?.threads[0]?.terminalLayout).toBe("single"); + expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]); expect(hydrated?.activeThreadId).toBe("t-1"); expect(hydrated?.runtimeMode).toBe("full-access"); }); @@ -139,6 +147,10 @@ describe("hydratePersistedState", () => { model: "gpt-5.3-codex", terminalOpen: true, terminalHeight: 360, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + activeTerminalId: "term-2", + terminalLayout: "tabs", + splitTerminalIds: [], messages: [], createdAt: "2026-02-08T10:00:00.000Z", lastVisitedAt: "2026-02-08T10:01:00.000Z", @@ -150,6 +162,10 @@ describe("hydratePersistedState", () => { const hydrated = hydratePersistedState(payload, false); expect(hydrated?.threads[0]?.terminalOpen).toBe(true); expect(hydrated?.threads[0]?.terminalHeight).toBe(360); + expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); + expect(hydrated?.threads[0]?.activeTerminalId).toBe("term-2"); + expect(hydrated?.threads[0]?.terminalLayout).toBe("tabs"); + expect(hydrated?.threads[0]?.splitTerminalIds).toEqual([]); expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z"); }); @@ -183,6 +199,9 @@ describe("hydratePersistedState", () => { const hydrated = hydratePersistedState(payload, false); expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); + expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); + expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); + expect(hydrated?.threads[0]?.terminalLayout).toBe("single"); }); }); @@ -196,6 +215,10 @@ describe("toPersistedState", () => { model: "gpt-5.3-codex", terminalOpen: true, terminalHeight: 320, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + activeTerminalId: "term-2", + terminalLayout: "tabs", + splitTerminalIds: [], session: null, messages: [ { @@ -249,6 +272,10 @@ describe("toPersistedState", () => { model: "gpt-5.3-codex", terminalOpen: true, terminalHeight: 320, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + activeTerminalId: "term-2", + terminalLayout: "tabs", + splitTerminalIds: [], messages: [ { id: "m-1", diff --git a/apps/web/src/persistenceSchema.ts b/apps/web/src/persistenceSchema.ts index 112d2e3c61a1fe181e7a2560603f628421923453..1b24086b375e2e31a9b041364b364741711221af 100644 --- a/apps/web/src/persistenceSchema.ts +++ b/apps/web/src/persistenceSchema.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, + DEFAULT_THREAD_TERMINAL_ID, DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, @@ -48,6 +49,10 @@ const persistedThreadSchema = z.object({ terminalHeight: z.number().int().min(120).max(4_096).default( DEFAULT_THREAD_TERMINAL_HEIGHT, ), + terminalIds: z.array(z.string().trim().min(1)).default([DEFAULT_THREAD_TERMINAL_ID]), + activeTerminalId: z.string().trim().min(1).default(DEFAULT_THREAD_TERMINAL_ID), + terminalLayout: z.enum(["single", "split", "tabs"]).default("single"), + splitTerminalIds: z.array(z.string().trim().min(1)).default([]), messages: z.array(persistedMessageSchema), createdAt: z.string().min(1), lastVisitedAt: z.string().min(1).optional(), @@ -129,6 +134,33 @@ function hydrateThread( thread: z.infer, isLegacyPayload: boolean, ): Thread { + const terminalIds = [...new Set(thread.terminalIds)] + .map((id) => id.trim()) + .filter((id) => id.length > 0); + const safeTerminalIds = + terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; + const activeTerminalId = safeTerminalIds.includes(thread.activeTerminalId) + ? thread.activeTerminalId + : (safeTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + let terminalLayout = thread.terminalLayout; + let splitTerminalIds = [...new Set(thread.splitTerminalIds)].filter((id) => + safeTerminalIds.includes(id), + ); + if (terminalLayout === "split") { + splitTerminalIds = splitTerminalIds.slice(0, 2); + if (splitTerminalIds.length < 2) { + terminalLayout = safeTerminalIds.length > 1 ? "tabs" : "single"; + splitTerminalIds = []; + } + } else { + splitTerminalIds = []; + } + if (terminalLayout === "single" && safeTerminalIds.length > 1) { + terminalLayout = "tabs"; + } else if (terminalLayout === "tabs" && safeTerminalIds.length < 2) { + terminalLayout = "single"; + } + return { id: thread.id, codexThreadId: thread.codexThreadId, @@ -137,6 +169,10 @@ function hydrateThread( model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)), terminalOpen: thread.terminalOpen ?? false, terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: safeTerminalIds, + activeTerminalId, + terminalLayout, + splitTerminalIds, session: null, messages: thread.messages.map((message) => { const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment })); @@ -211,6 +247,10 @@ export function toPersistedState( model: thread.model, terminalOpen: thread.terminalOpen, terminalHeight: thread.terminalHeight, + terminalIds: thread.terminalIds, + activeTerminalId: thread.activeTerminalId, + terminalLayout: thread.terminalLayout, + splitTerminalIds: thread.splitTerminalIds, messages: thread.messages.map((message) => ({ id: message.id, role: message.role, diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index c33f1c077a76354e5cce7b2a67da08d4c07ea1c9..a06efcd063f0e6c6d91585aa25608d261b5b24b3 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -2,7 +2,7 @@ import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { type AppState, reducer } from "./store"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT } from "./types"; +import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; import type { Thread } from "./types"; function makeSession(overrides: Partial = {}): ProviderSession { @@ -37,6 +37,10 @@ function makeThread(overrides: Partial = {}): Thread { model: "gpt-5.3-codex", terminalOpen: false, terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalLayout: "single", + splitTerminalIds: [], session: makeSession(), messages: [], events: [], @@ -111,6 +115,51 @@ describe("store reducer thread continuity", () => { expect(next.threads[0]?.terminalHeight).toBe(360); }); + it("splits the active terminal into side-by-side mode", () => { + const state = makeState(makeThread()); + const next = reducer(state, { + type: "SPLIT_THREAD_TERMINAL", + threadId: "thread-local-1", + terminalId: "term-2", + }); + + expect(next.threads[0]?.terminalLayout).toBe("split"); + expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); + expect(next.threads[0]?.splitTerminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); + expect(next.threads[0]?.activeTerminalId).toBe("term-2"); + }); + + it("creates a new full-width terminal and switches to tab mode", () => { + const state = makeState(makeThread()); + const next = reducer(state, { + type: "NEW_THREAD_TERMINAL", + threadId: "thread-local-1", + terminalId: "term-2", + }); + + expect(next.threads[0]?.terminalLayout).toBe("tabs"); + expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); + expect(next.threads[0]?.activeTerminalId).toBe("term-2"); + expect(next.threads[0]?.splitTerminalIds).toEqual([]); + }); + + it("switches the active tab terminal", () => { + const state = makeState( + makeThread({ + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalLayout: "tabs", + }), + ); + const next = reducer(state, { + type: "SET_THREAD_ACTIVE_TERMINAL", + threadId: "thread-local-1", + terminalId: "term-2", + }); + + expect(next.threads[0]?.activeTerminalId).toBe("term-2"); + }); + it("backfills codexThreadId from routed provider events", () => { const state = makeState(makeThread({ codexThreadId: null })); const next = reducer(state, { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index c8b457bb1c684cada11c03cc0bae449c567f8b65..039cf3636987ae321bdd729067df1606da12db53 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -14,6 +14,7 @@ import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; import { type ChatAttachment, + DEFAULT_THREAD_TERMINAL_ID, DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, @@ -31,6 +32,9 @@ type Action = | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } + | { type: "SPLIT_THREAD_TERMINAL"; threadId: string; terminalId: string } + | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } + | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } | { type: "TOGGLE_DIFF" } | { type: "APPLY_EVENT"; @@ -133,6 +137,58 @@ function updateThread( return threads.map((t) => (t.id === threadId ? updater(t) : t)); } +function normalizeTerminalIds(terminalIds: string[]): string[] { + const ids = terminalIds + .map((id) => id.trim()) + .filter((id) => id.length > 0); + const unique = [...new Set(ids)]; + if (unique.length > 0) { + return unique; + } + return [DEFAULT_THREAD_TERMINAL_ID]; +} + +function normalizeThreadTerminals(thread: Thread): Thread { + const terminalIds = normalizeTerminalIds(thread.terminalIds); + const activeTerminalId = terminalIds.includes(thread.activeTerminalId) + ? thread.activeTerminalId + : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + + if (thread.terminalLayout === "split") { + const validSplitIds = [...new Set(thread.splitTerminalIds)].filter((id) => + terminalIds.includes(id), + ); + if (validSplitIds.length >= 2) { + const splitTerminalIds = validSplitIds.slice(0, 2); + const splitActiveTerminalId = splitTerminalIds.includes(activeTerminalId) + ? activeTerminalId + : (splitTerminalIds[1] ?? splitTerminalIds[0] ?? activeTerminalId); + return { + ...thread, + terminalIds, + activeTerminalId: splitActiveTerminalId, + terminalLayout: "split", + splitTerminalIds, + }; + } + } + + const terminalLayout = + terminalIds.length > 1 && thread.terminalLayout === "single" + ? "tabs" + : (thread.terminalLayout === "tabs" && terminalIds.length < 2 + ? "single" + : thread.terminalLayout); + + return { + ...thread, + terminalIds, + activeTerminalId, + terminalLayout, + splitTerminalIds: [], + }; +} + function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { return threads.find((t) => t.session?.sessionId === sessionId); } @@ -259,10 +315,10 @@ export function reducer(state: AppState, action: Action): AppState { if (!previousProject) return null; const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd); if (!mappedProjectId) return null; - return { + return normalizeThreadTerminals({ ...thread, projectId: mappedProjectId, - }; + }); }) .filter((thread): thread is Thread => thread !== null); const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId) @@ -285,19 +341,18 @@ export function reducer(state: AppState, action: Action): AppState { ), }; - case "ADD_THREAD": + case "ADD_THREAD": { + const nextThread = normalizeThreadTerminals({ + ...action.thread, + model: resolveModelSlug(action.thread.model), + lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, + }); return { ...state, - threads: [ - ...state.threads, - { - ...action.thread, - model: resolveModelSlug(action.thread.model), - lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, - }, - ], + threads: [...state.threads, nextThread], activeThreadId: action.thread.id, }; + } case "SET_ACTIVE_THREAD": { const visitedAt = new Date().toISOString(); @@ -338,6 +393,67 @@ export function reducer(state: AppState, action: Action): AppState { })), }; + case "SPLIT_THREAD_TERMINAL": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => { + const terminalIds = thread.terminalIds.includes(action.terminalId) + ? thread.terminalIds + : [...thread.terminalIds, action.terminalId]; + const firstTerminalId = + terminalIds.includes(thread.activeTerminalId) + ? thread.activeTerminalId + : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + const secondTerminalId = + action.terminalId === firstTerminalId + ? (terminalIds.find((id) => id !== firstTerminalId) ?? firstTerminalId) + : action.terminalId; + const splitTerminalIds = + firstTerminalId === secondTerminalId + ? [] + : [firstTerminalId, secondTerminalId]; + + return normalizeThreadTerminals({ + ...thread, + terminalIds, + activeTerminalId: secondTerminalId, + terminalLayout: splitTerminalIds.length === 2 ? "split" : "single", + splitTerminalIds, + }); + }), + }; + + case "NEW_THREAD_TERMINAL": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => { + const terminalIds = thread.terminalIds.includes(action.terminalId) + ? thread.terminalIds + : [...thread.terminalIds, action.terminalId]; + return normalizeThreadTerminals({ + ...thread, + terminalIds, + activeTerminalId: action.terminalId, + terminalLayout: "tabs", + splitTerminalIds: [], + }); + }), + }; + + case "SET_THREAD_ACTIVE_TERMINAL": + return { + ...state, + threads: updateThread(state.threads, action.threadId, (thread) => { + if (!thread.terminalIds.includes(action.terminalId)) { + return thread; + } + return normalizeThreadTerminals({ + ...thread, + activeTerminalId: action.terminalId, + }); + }), + }; + case "TOGGLE_DIFF": return { ...state, diffOpen: !state.diffOpen }; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 16a51fc87e4186938d26f1a05ca0a7e4bb146421..8091557f78ebd7fa2299f0838f05dcb1b0a65027 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -4,6 +4,8 @@ export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; export type RuntimeMode = "approval-required" | "full-access"; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280; +export const DEFAULT_THREAD_TERMINAL_ID = "default"; +export type ThreadTerminalLayout = "single" | "split" | "tabs"; export interface ChatImageAttachment { type: "image"; @@ -41,6 +43,10 @@ export interface Thread { model: string; terminalOpen: boolean; terminalHeight: number; + terminalIds: string[]; + activeTerminalId: string; + terminalLayout: ThreadTerminalLayout; + splitTerminalIds: string[]; session: ProviderSession | null; messages: ChatMessage[]; events: ProviderEvent[]; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 53573d5ff32d3a9b752b32c17dafcf9fdd55f638..a3f67ced9aee637247096261550663419d79be78 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -30,12 +30,12 @@ import type { ProjectRemoveInput, } from "./project"; import type { + TerminalClearInput, TerminalCloseInput, TerminalEvent, TerminalOpenInput, TerminalResizeInput, TerminalSessionSnapshot, - TerminalThreadInput, TerminalWriteInput, } from "./terminal"; import type { NewTodoInput, Todo } from "./todo"; @@ -61,7 +61,7 @@ export interface NativeApi { open: (input: TerminalOpenInput) => Promise; write: (input: TerminalWriteInput) => Promise; resize: (input: TerminalResizeInput) => Promise; - clear: (input: TerminalThreadInput) => Promise; + clear: (input: TerminalClearInput) => Promise; restart: (input: TerminalOpenInput) => Promise; close: (input: TerminalCloseInput) => Promise; onEvent: (callback: (event: TerminalEvent) => void) => () => void; diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 5f340a10074c4a695fabfe2896d5ea60924ca129..336a0476265b7b45dbf917e654791839f35902b2 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_TERMINAL_ID, + terminalClearInputSchema, terminalCloseInputSchema, terminalEventSchema, terminalOpenInputSchema, @@ -30,6 +32,16 @@ describe("terminalOpenInputSchema", () => { }); expect(result.success).toBe(false); }); + + it("defaults terminalId when missing", () => { + const parsed = terminalOpenInputSchema.parse({ + threadId: "thread-1", + cwd: "/tmp/project", + cols: 100, + rows: 24, + }); + expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); + }); }); describe("terminalWriteInputSchema", () => { @@ -68,6 +80,15 @@ describe("terminalResizeInputSchema", () => { }); }); +describe("terminalClearInputSchema", () => { + it("defaults terminal id", () => { + const parsed = terminalClearInputSchema.parse({ + threadId: "thread-1", + }); + expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID); + }); +}); + describe("terminalCloseInputSchema", () => { it("accepts optional deleteHistory", () => { const result = terminalCloseInputSchema.safeParse({ @@ -82,6 +103,7 @@ describe("terminalSessionSnapshotSchema", () => { it("accepts running snapshots", () => { const result = terminalSessionSnapshotSchema.safeParse({ threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, cwd: "/tmp/project", status: "running", pid: 1234, @@ -99,6 +121,7 @@ describe("terminalEventSchema", () => { const result = terminalEventSchema.safeParse({ type: "output", threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), data: "line\n", }); @@ -109,6 +132,7 @@ describe("terminalEventSchema", () => { const result = terminalEventSchema.safeParse({ type: "exited", threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, createdAt: new Date().toISOString(), exitCode: 0, exitSignal: null, diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index ed79937ab9154391ea51105e4e4cdcebb98096fe..cec35f5956f2f7172a82902c54ed5da9deb0f498 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,28 +1,38 @@ import { z } from "zod"; +export const DEFAULT_TERMINAL_ID = "default"; + const terminalColsSchema = z.number().int().min(20).max(400); const terminalRowsSchema = z.number().int().min(5).max(200); +const terminalIdSchema = z.string().trim().min(1).max(128); export const terminalThreadInputSchema = z.object({ threadId: z.string().trim().min(1), }); -export const terminalOpenInputSchema = terminalThreadInputSchema.extend({ +export const terminalSessionInputSchema = terminalThreadInputSchema.extend({ + terminalId: terminalIdSchema.default(DEFAULT_TERMINAL_ID), +}); + +export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ cwd: z.string().trim().min(1), cols: terminalColsSchema, rows: terminalRowsSchema, }); -export const terminalWriteInputSchema = terminalThreadInputSchema.extend({ +export const terminalWriteInputSchema = terminalSessionInputSchema.extend({ data: z.string().min(1).max(65_536), }); -export const terminalResizeInputSchema = terminalThreadInputSchema.extend({ +export const terminalResizeInputSchema = terminalSessionInputSchema.extend({ cols: terminalColsSchema, rows: terminalRowsSchema, }); +export const terminalClearInputSchema = terminalSessionInputSchema; + export const terminalCloseInputSchema = terminalThreadInputSchema.extend({ + terminalId: terminalIdSchema.optional(), deleteHistory: z.boolean().optional(), }); @@ -35,6 +45,7 @@ export const terminalSessionStatusSchema = z.enum([ export const terminalSessionSnapshotSchema = z.object({ threadId: z.string().min(1), + terminalId: z.string().min(1), cwd: z.string().min(1), status: terminalSessionStatusSchema, pid: z.number().int().positive().nullable(), @@ -46,6 +57,7 @@ export const terminalSessionSnapshotSchema = z.object({ const terminalEventBaseSchema = z.object({ threadId: z.string().min(1), + terminalId: z.string().min(1), createdAt: z.string().datetime(), }); @@ -89,9 +101,11 @@ export const terminalEventSchema = z.discriminatedUnion("type", [ ]); export type TerminalThreadInput = z.input; +export type TerminalSessionInput = z.input; export type TerminalOpenInput = z.input; export type TerminalWriteInput = z.input; export type TerminalResizeInput = z.input; +export type TerminalClearInput = z.input; export type TerminalCloseInput = z.input; export type TerminalSessionStatus = z.infer; export type TerminalSessionSnapshot = z.infer; -- 2.54.0