Files
IdeA/frontend/src/adapters/terminal.ts
Blomios 0660f52e2b 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>
2026-06-06 12:24:48 +02:00

109 lines
3.3 KiB
TypeScript

/**
* Tauri adapter for {@link TerminalGateway} (L3). The single place that uses a
* {@link Channel} for the high-frequency PTY byte stream and `invoke()` for the
* control commands. Components reach it exclusively through the port.
*
* Flow (ARCHITECTURE §2 "Tauri Channels"):
* - `openTerminal` creates a `Channel<number[]>`, passes it to the
* `open_terminal` command, and forwards every chunk to `onData` as a
* `Uint8Array`. The backend pumps PTY output into that channel via the
* `PtyBridge`.
* - keystrokes go out through `write_terminal`, resize through
* `resize_terminal`, teardown through `close_terminal`.
*
* Commands and payload keys are camelCase, matching the backend DTO convention.
*/
import { Channel, invoke } from "@tauri-apps/api/core";
import type {
OpenTerminalOptions,
ReattachResult,
TerminalGateway,
TerminalHandle,
} from "@/ports";
/** Wire shape returned by the `open_terminal` command. */
interface OpenTerminalResponse {
sessionId: string;
cwd: string;
rows: number;
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,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
// Per-session output channel. The backend serialises chunks as byte arrays.
const channel = new Channel<number[]>();
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
const res = await invoke<OpenTerminalResponse>("open_terminal", {
request: { cwd: options.cwd, rows: options.rows, cols: options.cols },
onOutput: channel,
});
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,
onOutput: channel,
});
return {
handle: makeTerminalHandle(res.sessionId, channel),
scrollback: Uint8Array.from(res.scrollback),
};
}
}