fix(terminals): decouple PTY lifecycle from view lifecycle (no kill on navigation)

Navigating (layout/tab switch) tore the xterm view down and called
handle.close(), killing the backend PTY and cutting off running AIs. Now
the view's cleanup only detaches; only an explicit user action kills a PTY.

Backend:
- PortablePtyAdapter: per-session scrollback ring buffer (~100KB, most
  recent) + re-subscribable fan-out broadcast replacing the single-take
  output_rx. Reader thread feeds both the ring buffer and current
  subscribers; on EOF it closes subscribers (streams end) while keeping
  scrollback for late re-attach.
- PtyPort: new scrollback() method; subscribe_output is now re-subscribable
  (all impls + test fakes updated).
- reattach_terminal IPC command: returns scrollback and re-wires a fresh
  output channel on the live session without re-spawning.
- CloseRequested hook kills all live PTYs cleanly on app shutdown.
- TerminalSessions::handles() to enumerate live sessions at shutdown.

Frontend:
- TerminalHandle.detach(); TerminalGateway/AgentGateway.reattach() + mocks.
- TerminalView cleanup detaches (never close); on mount it re-attaches to a
  persisted session (repainting scrollback) instead of opening a new PTY.
- LayoutGrid persists the cell's session id via setSession; AgentsPanel
  tracks per-agent session ids — both drive reattach-vs-open.

Tests: ring buffer bounds to 100KB keeping newest bytes; scrollback retained;
re-subscription delivers post-reattach output; TerminalView detaches (not
closes) on unmount and reattaches with a known session; mock detach/reattach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 12:24:48 +02:00
parent 307ae71857
commit 0660f52e2b
19 changed files with 879 additions and 150 deletions

View File

@ -72,6 +72,16 @@ export interface AgentGateway {
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle>;
/**
* Re-attaches to an agent's already-running PTY (same backend mechanism as
* {@link TerminalGateway.reattach}; agent sessions share the session-based
* terminal commands). Used when an agent cell's view re-mounts after a
* navigation/layout change, so the agent is never killed.
*/
reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult>;
}
/** Options for opening a terminal. */
@ -98,7 +108,19 @@ export interface TerminalHandle {
write(data: Uint8Array): Promise<void>;
/** Resizes the PTY. */
resize(rows: number, cols: number): Promise<void>;
/** Kills the PTY and stops the output stream. */
/**
* Detaches the **view** from the PTY without killing it: stops the local
* output subscription so a torn-down view (navigation / layout change) stops
* receiving bytes, while the backend PTY keeps running. The session can later
* be re-attached via {@link TerminalGateway.reattach}.
*
* This is the lifecycle the view's cleanup must use — never {@link close}.
*/
detach(): void;
/**
* Kills the PTY and stops the output stream. Reserved for an **explicit** user
* action (closing the terminal); navigation must never call this.
*/
close(): Promise<void>;
}
@ -115,6 +137,27 @@ export interface TerminalGateway {
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle>;
/**
* Re-attaches to an **already-running** PTY identified by `sessionId` (after a
* view was torn down by navigation/layout change). Returns the live handle and
* the retained scrollback, which the caller repaints into xterm before the new
* output stream (`onData`) starts delivering subsequent bytes. Does NOT
* re-spawn the process.
*
* Rejects if the session is no longer alive (the caller then opens fresh).
*/
reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult>;
}
/** The outcome of {@link TerminalGateway.reattach}. */
export interface ReattachResult {
/** The live terminal handle for the re-attached session. */
handle: TerminalHandle;
/** The retained scrollback bytes to repaint before the live stream resumes. */
scrollback: Uint8Array;
}
/** Projects: create/open/close/list (L2). */