authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 23:19:28-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 00:59:06-08:00
log1a94fcbd294ab69709a3895b30941537d415764a
tree5f5f9f63e636362739ccc0288b886f02f3d1a9ed
parented40fd3364b7bb575f55ddf07475e7689a037a32

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

4 files changed, 92 insertions(+), 16 deletions(-)

apps/server/src/terminalManager.test.ts+59
......@@ -196,6 +196,65 @@ describe("TerminalManager", () => {
196196 manager.dispose();
197197 });
198198
199 it("resizes running terminal on open when a different size is requested", async () => {
200 const { manager, ptyAdapter } = makeManager();
201 await manager.open(openInput({ cols: 100, rows: 24 }));
202 const process = ptyAdapter.processes[0];
203 expect(process).toBeDefined();
204 if (!process) return;
205
206 await manager.open(openInput({ cols: 140, rows: 40 }));
207
208 expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]);
209
210 manager.dispose();
211 });
212
213 it("preserves existing terminal size on open when size is omitted", async () => {
214 const { manager, ptyAdapter } = makeManager();
215 await manager.open(openInput({ cols: 100, rows: 24 }));
216 const ptyProcess = ptyAdapter.processes[0];
217 expect(ptyProcess).toBeDefined();
218 if (!ptyProcess) return;
219
220 await manager.open({
221 threadId: "thread-1",
222 cwd: globalThis.process.cwd(),
223 });
224
225 expect(ptyProcess.resizeCalls).toEqual([]);
226
227 ptyProcess.emitExit({ exitCode: 0, signal: 0 });
228 await manager.open({
229 threadId: "thread-1",
230 cwd: globalThis.process.cwd(),
231 });
232
233 const resumedSpawn = ptyAdapter.spawnInputs[1];
234 expect(resumedSpawn).toBeDefined();
235 if (!resumedSpawn) return;
236 expect(resumedSpawn.cols).toBe(100);
237 expect(resumedSpawn.rows).toBe(24);
238
239 manager.dispose();
240 });
241
242 it("uses default dimensions when opening a new terminal without size hints", async () => {
243 const { manager, ptyAdapter } = makeManager();
244 await manager.open({
245 threadId: "thread-1",
246 cwd: process.cwd(),
247 });
248
249 const spawned = ptyAdapter.spawnInputs[0];
250 expect(spawned).toBeDefined();
251 if (!spawned) return;
252 expect(spawned.cols).toBe(120);
253 expect(spawned.rows).toBe(30);
254
255 manager.dispose();
256 });
257
199258 it("supports multiple terminals per thread with isolated sessions", async () => {
200259 const { manager, ptyAdapter } = makeManager();
201260 await manager.open(openInput({ terminalId: "default" }));
apps/server/src/terminalManager.ts+25-12
......@@ -26,6 +26,8 @@ import { runProcess } from "./processRunner";
2626const DEFAULT_HISTORY_LINE_LIMIT = 5_000;
2727const DEFAULT_PERSIST_DEBOUNCE_MS = 40;
2828const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;
29const DEFAULT_OPEN_COLS = 120;
30const DEFAULT_OPEN_ROWS = 30;
2931const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]);
3032
3133type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;
......@@ -67,6 +69,8 @@ interface ShellCandidate {
6769 args?: string[];
6870}
6971
72type TerminalStartInput = TerminalOpenInput & { cols: number; rows: number };
73
7074function defaultShellResolver(): string {
7175 if (process.platform === "win32") {
7276 return process.env.ComSpec ?? "cmd.exe";
......@@ -334,6 +338,8 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
334338 if (!existing) {
335339 await this.flushPersistQueue(input.threadId, input.terminalId);
336340 const history = await this.readHistory(input.threadId, input.terminalId);
341 const cols = input.cols ?? DEFAULT_OPEN_COLS;
342 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
337343 const session: TerminalSessionState = {
338344 threadId: input.threadId,
339345 terminalId: input.terminalId,
......@@ -344,8 +350,8 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
344350 exitCode: null,
345351 exitSignal: null,
346352 updatedAt: new Date().toISOString(),
347 cols: input.cols,
348 rows: input.rows,
353 cols,
354 rows,
349355 process: null,
350356 unsubscribeData: null,
351357 unsubscribeExit: null,
......@@ -353,12 +359,14 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
353359 runtimeEnv: normalizedRuntimeEnv(input.env),
354360 };
355361 this.sessions.set(sessionKey, session);
356 this.startSession(session, input, "started");
362 this.startSession(session, { ...input, cols, rows }, "started");
357363 return this.snapshot(session);
358364 }
359365
360366 const nextRuntimeEnv = normalizedRuntimeEnv(input.env);
361367 const currentRuntimeEnv = existing.runtimeEnv;
368 const targetCols = input.cols ?? existing.cols;
369 const targetRows = input.rows ?? existing.rows;
362370 const runtimeEnvChanged =
363371 JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv);
364372
......@@ -377,14 +385,14 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
377385 }
378386
379387 if (!existing.process) {
380 this.startSession(existing, input, "started");
388 this.startSession(existing, { ...input, cols: targetCols, rows: targetRows }, "started");
381389 return this.snapshot(existing);
382390 }
383391
384 if (existing.cols !== input.cols || existing.rows !== input.rows) {
385 existing.cols = input.cols;
386 existing.rows = input.rows;
387 existing.process.resize(input.cols, input.rows);
392 if (existing.cols !== targetCols || existing.rows !== targetRows) {
393 existing.cols = targetCols;
394 existing.rows = targetRows;
395 existing.process.resize(targetCols, targetRows);
388396 existing.updatedAt = new Date().toISOString();
389397 }
390398
......@@ -441,6 +449,8 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
441449 const sessionKey = toSessionKey(input.threadId, input.terminalId);
442450 let session = this.sessions.get(sessionKey);
443451 if (!session) {
452 const cols = input.cols ?? DEFAULT_OPEN_COLS;
453 const rows = input.rows ?? DEFAULT_OPEN_ROWS;
444454 session = {
445455 threadId: input.threadId,
446456 terminalId: input.terminalId,
......@@ -451,8 +461,8 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
451461 exitCode: null,
452462 exitSignal: null,
453463 updatedAt: new Date().toISOString(),
454 cols: input.cols,
455 rows: input.rows,
464 cols,
465 rows,
456466 process: null,
457467 unsubscribeData: null,
458468 unsubscribeExit: null,
......@@ -466,9 +476,12 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
466476 session.runtimeEnv = normalizedRuntimeEnv(input.env);
467477 }
468478
479 const cols = input.cols ?? session.cols;
480 const rows = input.rows ?? session.rows;
481
469482 session.history = "";
470483 await this.persistHistory(input.threadId, input.terminalId, session.history);
471 this.startSession(session, input, "restarted");
484 this.startSession(session, { ...input, cols, rows }, "restarted");
472485 return this.snapshot(session);
473486 });
474487 }
......@@ -517,7 +530,7 @@ export class TerminalManager extends EventEmitter<TerminalManagerEvents> {
517530
518531 private startSession(
519532 session: TerminalSessionState,
520 input: TerminalOpenInput,
533 input: TerminalStartInput,
521534 eventType: "started" | "restarted",
522535 ): void {
523536 this.stopProcess(session);
apps/web/src/components/ChatView.tsx+6-2
......@@ -725,9 +725,13 @@ export default function ChatView() {
725725 threadId: activeThreadId,
726726 terminalId: targetTerminalId,
727727 cwd: targetCwd,
728 cols: SCRIPT_TERMINAL_COLS,
729 rows: SCRIPT_TERMINAL_ROWS,
730728 env: runtimeEnv,
729 ...(shouldCreateNewTerminal
730 ? {
731 cols: SCRIPT_TERMINAL_COLS,
732 rows: SCRIPT_TERMINAL_ROWS,
733 }
734 : {}),
731735 });
732736 await api.terminal.write({
733737 threadId: activeThreadId,
packages/contracts/src/terminal.ts+2-2
......@@ -21,8 +21,8 @@ export const terminalSessionInputSchema = terminalThreadInputSchema.extend({
2121
2222export const terminalOpenInputSchema = terminalSessionInputSchema.extend({
2323 cwd: z.string().trim().min(1),
24 cols: terminalColsSchema,
25 rows: terminalRowsSchema,
24 cols: terminalColsSchema.optional(),
25 rows: terminalRowsSchema.optional(),
2626 env: terminalEnvSchema.optional(),
2727});
2828