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:
@ -18,6 +18,7 @@ import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
OpenTerminalOptions,
|
||||
ReattachResult,
|
||||
TerminalGateway,
|
||||
TerminalHandle,
|
||||
} from "@/ports";
|
||||
@ -30,6 +31,46 @@ interface OpenTerminalResponse {
|
||||
cols: number;
|
||||
}
|
||||
|
||||
/** Wire shape returned by the `reattach_terminal` command. */
|
||||
interface ReattachResponse {
|
||||
sessionId: string;
|
||||
scrollback: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link TerminalHandle} over a session and its local output
|
||||
* {@link Channel}. `detach` stops the channel from delivering further bytes (the
|
||||
* view is gone) without touching the backend PTY; `close` kills the PTY.
|
||||
*
|
||||
* Shared by `openTerminal` and `reattach` so both produce identical handles.
|
||||
*/
|
||||
export function makeTerminalHandle(
|
||||
sessionId: string,
|
||||
channel: Channel<number[]>,
|
||||
): TerminalHandle {
|
||||
return {
|
||||
sessionId,
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
await invoke("write_terminal", {
|
||||
request: { sessionId, data: Array.from(data) },
|
||||
});
|
||||
},
|
||||
async resize(rows: number, cols: number): Promise<void> {
|
||||
await invoke("resize_terminal", {
|
||||
request: { sessionId, rows, cols },
|
||||
});
|
||||
},
|
||||
detach(): void {
|
||||
// Drop the local subscription: the backend PTY keeps running, but this
|
||||
// view stops receiving output. A later `reattach` re-wires a fresh channel.
|
||||
channel.onmessage = () => {};
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
await invoke("close_terminal", { sessionId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class TauriTerminalGateway implements TerminalGateway {
|
||||
async openTerminal(
|
||||
options: OpenTerminalOptions,
|
||||
@ -44,22 +85,24 @@ export class TauriTerminalGateway implements TerminalGateway {
|
||||
onOutput: channel,
|
||||
});
|
||||
|
||||
const sessionId = res.sessionId;
|
||||
return {
|
||||
return makeTerminalHandle(res.sessionId, channel);
|
||||
}
|
||||
|
||||
async reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<ReattachResult> {
|
||||
const channel = new Channel<number[]>();
|
||||
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
|
||||
|
||||
const res = await invoke<ReattachResponse>("reattach_terminal", {
|
||||
sessionId,
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
await invoke("write_terminal", {
|
||||
request: { sessionId, data: Array.from(data) },
|
||||
});
|
||||
},
|
||||
async resize(rows: number, cols: number): Promise<void> {
|
||||
await invoke("resize_terminal", {
|
||||
request: { sessionId, rows, cols },
|
||||
});
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
await invoke("close_terminal", { sessionId });
|
||||
},
|
||||
onOutput: channel,
|
||||
});
|
||||
|
||||
return {
|
||||
handle: makeTerminalHandle(res.sessionId, channel),
|
||||
scrollback: Uint8Array.from(res.scrollback),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user