From 80498f1709e05caa744716b740fdcaa884bef8e0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Feb 2026 17:55:47 -0800 Subject: [PATCH] Handle terminal exit writes and auto-close exited terminal view - Ignore late write calls after a terminal session has already exited - Add regression test for trailing writes after exit - Close terminal drawer session once an exit event is handled --- .../server/src/terminal/Layers/Manager.test.ts | 15 +++++++++++++++ apps/server/src/terminal/Layers/Manager.ts | 3 +++ .../src/components/ThreadTerminalDrawer.tsx | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 304b49d42b7905ca381154b1c5a97e878ec42b97..0d45bfcb294b9c7d24db8cd73f9845c98398ac1c 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -394,6 +394,21 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("ignores trailing writes after terminal exit", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitExit({ exitCode: 0, signal: 0 }); + + await expect(manager.write({ threadId: "thread-1", data: "\r" })).resolves.toBeUndefined(); + expect(process.writes).toEqual([]); + + manager.dispose(); + }); + it("emits subprocess activity events when child-process state changes", async () => { let hasRunningSubprocess = false; const { manager } = makeManager(5, { diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index fa766da570f1f1146f8acb5bc32bbc8e5b2fcffe..a0e4ad705ccf44a3838a7d936c84570541e46b24 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -438,6 +438,9 @@ export class TerminalManagerRuntime extends EventEmitter const input = decodeTerminalWriteInput(raw); const session = this.requireSession(input.threadId, input.terminalId); if (!session.process || session.status !== "running") { + if (session.status === "exited") { + return; + } throw new Error( `Terminal is not running for thread: ${input.threadId}, terminal: ${input.terminalId}`, ); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index ab86780487a8b861f2e0023556230b65916adf48..494adf36b4d0ea6a44b5ea15ca20366ad01ec252 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -112,6 +112,7 @@ interface TerminalViewportProps { terminalId: string; cwd: string; runtimeEnv?: Record; + onSessionExited: () => void; focusRequestId: number; autoFocus: boolean; resizeEpoch: number; @@ -123,6 +124,7 @@ function TerminalViewport({ terminalId, cwd, runtimeEnv, + onSessionExited, focusRequestId, autoFocus, resizeEpoch, @@ -131,6 +133,12 @@ function TerminalViewport({ const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); + const onSessionExitedRef = useRef(onSessionExited); + const hasHandledExitRef = useRef(false); + + useEffect(() => { + onSessionExitedRef.current = onSessionExited; + }, [onSessionExited]); useEffect(() => { const mount = containerRef.current; @@ -306,6 +314,7 @@ function TerminalViewport({ } if (event.type === "started" || event.type === "restarted") { + hasHandledExitRef.current = false; activeTerminal.write("\u001bc"); if (event.snapshot.history.length > 0) { activeTerminal.write(event.snapshot.history); @@ -335,6 +344,13 @@ function TerminalViewport({ activeTerminal, details.length > 0 ? `Process exited (${details})` : "Process exited", ); + if (hasHandledExitRef.current) { + return; + } + hasHandledExitRef.current = true; + window.setTimeout(() => { + onSessionExitedRef.current(); + }, 0); } }); @@ -783,6 +799,7 @@ export default function ThreadTerminalDrawer({ terminalId={terminalId} cwd={cwd} {...(runtimeEnv ? { runtimeEnv } : {})} + onSessionExited={() => onCloseTerminal(terminalId)} focusRequestId={focusRequestId} autoFocus={terminalId === resolvedActiveTerminalId} resizeEpoch={resizeEpoch} @@ -800,6 +817,7 @@ export default function ThreadTerminalDrawer({ terminalId={resolvedActiveTerminalId} cwd={cwd} {...(runtimeEnv ? { runtimeEnv } : {})} + onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} focusRequestId={focusRequestId} autoFocus resizeEpoch={resizeEpoch} -- 2.54.0