From 1a94fcbd294ab69709a3895b30941537d415764a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 17 Feb 2026 23:19:28 -0800 Subject: [PATCH] Handle optional terminal dimensions and preserve active terminal size - make `cols`/`rows` optional in terminal open contracts - default new/restarted sessions to 120x30 when size hints are missing - reuse existing terminal dimensions unless a new size is explicitly requested - only send script terminal size hints when creating a new terminal --- apps/server/src/terminalManager.test.ts | 59 +++++++++++++++++++++++++ apps/server/src/terminalManager.ts | 37 +++++++++++----- apps/web/src/components/ChatView.tsx | 8 +++- packages/contracts/src/terminal.ts | 4 +- 4 files changed, 92 insertions(+), 16 deletions(-) diff --git a/apps/server/src/terminalManager.test.ts b/apps/server/src/terminalManager.test.ts index 7c0169cb1721d0a2209647300bcfec65645a2e37..c98b0b3a782b4811bad75e55b158e7d018adf5dc 100644 --- a/apps/server/src/terminalManager.test.ts +++ b/apps/server/src/terminalManager.test.ts @@ -196,6 +196,65 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("resizes running terminal on open when a different size is requested", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput({ cols: 100, rows: 24 })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + await manager.open(openInput({ cols: 140, rows: 40 })); + + expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]); + + manager.dispose(); + }); + + it("preserves existing terminal size on open when size is omitted", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput({ cols: 100, rows: 24 })); + const ptyProcess = ptyAdapter.processes[0]; + expect(ptyProcess).toBeDefined(); + if (!ptyProcess) return; + + await manager.open({ + threadId: "thread-1", + cwd: globalThis.process.cwd(), + }); + + expect(ptyProcess.resizeCalls).toEqual([]); + + ptyProcess.emitExit({ exitCode: 0, signal: 0 }); + await manager.open({ + threadId: "thread-1", + cwd: globalThis.process.cwd(), + }); + + const resumedSpawn = ptyAdapter.spawnInputs[1]; + expect(resumedSpawn).toBeDefined(); + if (!resumedSpawn) return; + expect(resumedSpawn.cols).toBe(100); + expect(resumedSpawn.rows).toBe(24); + + manager.dispose(); + }); + + it("uses default dimensions when opening a new terminal without size hints", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open({ + threadId: "thread-1", + cwd: process.cwd(), + }); + + const spawned = ptyAdapter.spawnInputs[0]; + expect(spawned).toBeDefined(); + if (!spawned) return; + expect(spawned.cols).toBe(120); + expect(spawned.rows).toBe(30); + + manager.dispose(); + }); + it("supports multiple terminals per thread with isolated sessions", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput({ terminalId: "default" })); diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index 291a0f27c477a609dbffa4280e1a217b740e320f..dcdf78615df5df4a862a4eb0f1d4a76ea2fe2ac4 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -26,6 +26,8 @@ import { runProcess } from "./processRunner"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; +const DEFAULT_OPEN_COLS = 120; +const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); type TerminalSubprocessChecker = (terminalPid: number) => Promise; @@ -67,6 +69,8 @@ interface ShellCandidate { args?: string[]; } +type TerminalStartInput = TerminalOpenInput & { cols: number; rows: number }; + function defaultShellResolver(): string { if (process.platform === "win32") { return process.env.ComSpec ?? "cmd.exe"; @@ -334,6 +338,8 @@ export class TerminalManager extends EventEmitter { if (!existing) { await this.flushPersistQueue(input.threadId, input.terminalId); const history = await this.readHistory(input.threadId, input.terminalId); + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; const session: TerminalSessionState = { threadId: input.threadId, terminalId: input.terminalId, @@ -344,8 +350,8 @@ export class TerminalManager extends EventEmitter { exitCode: null, exitSignal: null, updatedAt: new Date().toISOString(), - cols: input.cols, - rows: input.rows, + cols, + rows, process: null, unsubscribeData: null, unsubscribeExit: null, @@ -353,12 +359,14 @@ export class TerminalManager extends EventEmitter { runtimeEnv: normalizedRuntimeEnv(input.env), }; this.sessions.set(sessionKey, session); - this.startSession(session, input, "started"); + this.startSession(session, { ...input, cols, rows }, "started"); return this.snapshot(session); } const nextRuntimeEnv = normalizedRuntimeEnv(input.env); const currentRuntimeEnv = existing.runtimeEnv; + const targetCols = input.cols ?? existing.cols; + const targetRows = input.rows ?? existing.rows; const runtimeEnvChanged = JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); @@ -377,14 +385,14 @@ export class TerminalManager extends EventEmitter { } if (!existing.process) { - this.startSession(existing, input, "started"); + this.startSession(existing, { ...input, cols: targetCols, rows: targetRows }, "started"); return this.snapshot(existing); } - if (existing.cols !== input.cols || existing.rows !== input.rows) { - existing.cols = input.cols; - existing.rows = input.rows; - existing.process.resize(input.cols, input.rows); + if (existing.cols !== targetCols || existing.rows !== targetRows) { + existing.cols = targetCols; + existing.rows = targetRows; + existing.process.resize(targetCols, targetRows); existing.updatedAt = new Date().toISOString(); } @@ -441,6 +449,8 @@ export class TerminalManager extends EventEmitter { const sessionKey = toSessionKey(input.threadId, input.terminalId); let session = this.sessions.get(sessionKey); if (!session) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; session = { threadId: input.threadId, terminalId: input.terminalId, @@ -451,8 +461,8 @@ export class TerminalManager extends EventEmitter { exitCode: null, exitSignal: null, updatedAt: new Date().toISOString(), - cols: input.cols, - rows: input.rows, + cols, + rows, process: null, unsubscribeData: null, unsubscribeExit: null, @@ -466,9 +476,12 @@ export class TerminalManager extends EventEmitter { session.runtimeEnv = normalizedRuntimeEnv(input.env); } + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + session.history = ""; await this.persistHistory(input.threadId, input.terminalId, session.history); - this.startSession(session, input, "restarted"); + this.startSession(session, { ...input, cols, rows }, "restarted"); return this.snapshot(session); }); } @@ -517,7 +530,7 @@ export class TerminalManager extends EventEmitter { private startSession( session: TerminalSessionState, - input: TerminalOpenInput, + input: TerminalStartInput, eventType: "started" | "restarted", ): void { this.stopProcess(session); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4da86ceba2e6f12f29e1a0f142ed5c17d00cd83b..1fd660c88f30f89af30a2078dd0b8190187ffc9c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -725,9 +725,13 @@ export default function ChatView() { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, - cols: SCRIPT_TERMINAL_COLS, - rows: SCRIPT_TERMINAL_ROWS, env: runtimeEnv, + ...(shouldCreateNewTerminal + ? { + cols: SCRIPT_TERMINAL_COLS, + rows: SCRIPT_TERMINAL_ROWS, + } + : {}), }); await api.terminal.write({ threadId: activeThreadId, diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index b4f478dcc77f1bda95e769d6755b7f2aa56b104c..d173b7d0a2179c264bf5f4d6cd5c4cfea8e9cb5a 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -21,8 +21,8 @@ export const terminalSessionInputSchema = terminalThreadInputSchema.extend({ export const terminalOpenInputSchema = terminalSessionInputSchema.extend({ cwd: z.string().trim().min(1), - cols: terminalColsSchema, - rows: terminalRowsSchema, + cols: terminalColsSchema.optional(), + rows: terminalRowsSchema.optional(), env: terminalEnvSchema.optional(), }); -- 2.54.0