Files
IdeA/frontend/src/features/terminals/TerminalView.tsx
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

246 lines
8.4 KiB
TypeScript

/**
* xterm.js wrapper (L3). Mounts a `Terminal`, wires it bidirectionally to the
* {@link TerminalGateway} port (or a custom opener), and fits it to its container:
*
* - PTY output (gateway `onData`) → `term.write(bytes)`.
* - xterm `onData` (keystrokes) → `handle.write(bytes)`.
* - container resize (fit addon) → `handle.resize(rows, cols)`.
*
* Pure presentation: it only knows the port, never `invoke()`/`Channel`
* (ARCHITECTURE §1.3). The cwd it opens in is supplied by the caller (the
* project tab passes the project root).
*
* An optional `open` prop can override the default `terminal.openTerminal` call,
* enabling the agent terminal to reuse this component with `agent.launchAgent`.
*
* **PTY lifecycle is decoupled from the view lifecycle.** Navigating (switching
* layout or project tab) tears this view down but must NEVER kill the backend
* PTY — otherwise running AIs would be cut off. So:
* - On unmount the cleanup only `detach`es (drops the local output subscription)
* and disposes xterm; it never calls `handle.close()`. Killing a PTY is an
* explicit user action handled elsewhere (the terminal's close button).
* - On mount, if a `sessionId` already exists for this cell (persisted by the
* caller via `onSessionId`), the view **re-attaches** to the still-running PTY
* — repainting its scrollback and resuming its output — instead of opening a
* fresh one. If the session is gone (was explicitly closed), it opens fresh.
*/
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import { useGateways } from "@/app/di";
import type {
OpenTerminalOptions,
ReattachResult,
TerminalHandle,
} from "@/ports";
interface TerminalViewProps {
/** Working directory the shell opens in (typically the project root). */
cwd: string;
/**
* Optional custom opener. When provided, it is used instead of the terminal
* gateway's `openTerminal`. This lets agent terminals reuse the same xterm
* wrapper with a different backend opener (e.g. `launchAgent`).
* When absent, falls back to `terminal.openTerminal` from the DI context.
*/
open?: (
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
) => Promise<TerminalHandle>;
/**
* Optional re-attach opener. When provided together with a {@link sessionId},
* the view re-binds to the existing live PTY instead of opening a new one.
* When absent, falls back to the terminal gateway's `reattach`.
*/
reattach?: (
sessionId: string,
onData: (bytes: Uint8Array) => void,
) => Promise<ReattachResult>;
/**
* Persisted session id for this cell, if a PTY is already running for it.
* Drives the reattach-vs-open decision at mount.
*/
sessionId?: string | null;
/**
* Called once a session is established (opened) so the caller can persist its
* id for this cell and re-attach to it on the next mount. Not called on
* reattach (the id is already known).
*/
onSessionId?: (sessionId: string) => void;
}
export function TerminalView({
cwd,
open,
reattach,
sessionId,
onSessionId,
}: TerminalViewProps) {
const { terminal } = useGateways();
const containerRef = useRef<HTMLDivElement | null>(null);
// The opener (`open` or the terminal gateway) is read through a ref so the
// effect does NOT depend on its identity. Otherwise every parent re-render
// (e.g. App's domain-event counter bumping on `AgentLaunched`) would create a
// fresh `open` closure, re-run the effect, close + relaunch the PTY, emit
// another event, and so on — an infinite launch loop (black terminal, events
// skyrocketing). The terminal is re-mounted by a `key` when the agent changes,
// so the correct opener is always captured at mount.
const openRef = useRef(open);
openRef.current = open;
const reattachRef = useRef(reattach);
reattachRef.current = reattach;
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
const onSessionIdRef = useRef(onSessionId);
onSessionIdRef.current = onSessionId;
const terminalRef = useRef(terminal);
terminalRef.current = terminal;
useEffect(() => {
const container = containerRef.current;
const tgw = terminalRef.current;
const opener = openRef.current ?? tgw?.openTerminal.bind(tgw);
const reattacher = reattachRef.current ?? tgw?.reattach.bind(tgw);
if (!container || !opener) return;
const term = new Terminal({
convertEol: false,
cursorBlink: true,
fontSize: 13,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
});
const fit = new FitAddon();
term.loadAddon(fit);
// xterm needs a real layout engine; in non-DOM environments `open` throws.
// Bail gracefully so a headless render (jsdom tests) doesn't break the view.
try {
term.open(container);
} catch {
term.dispose();
return;
}
try {
fit.fit();
} catch {
/* container not laid out yet; a resize will retry */
}
let disposed = false;
let handle: TerminalHandle | null = null;
const encoder = new TextEncoder();
// Buffer keystrokes that arrive before the PTY finished opening.
let pending = "";
const onKey = term.onData((data) => {
if (handle) void handle.write(encoder.encode(data));
else pending += data;
});
const onData = (bytes: Uint8Array) => {
if (!disposed) term.write(bytes);
};
// Adopt a freshly-established handle: flush buffered keystrokes. If the view
// was disposed before the promise resolved, just detach (NEVER close — the
// PTY must survive a transient mount/unmount).
const adopt = (h: TerminalHandle) => {
if (disposed) {
h.detach();
return;
}
handle = h;
if (pending) {
void h.write(encoder.encode(pending));
pending = "";
}
};
const onOpenError = (e: unknown) => {
if (!disposed) {
term.write(
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
);
}
};
// Re-attach to an existing live PTY when this cell already has a session;
// otherwise open a fresh one and persist its id for the next mount.
const existingSession = sessionIdRef.current;
if (existingSession && reattacher) {
reattacher(existingSession, onData)
.then(({ handle: h, scrollback }) => {
if (disposed) {
h.detach();
return;
}
if (scrollback.length > 0) term.write(scrollback);
adopt(h);
})
.catch(() => {
// The session is gone (explicitly closed / exited): fall back to a
// fresh terminal so the cell still works.
if (disposed) return;
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
.then((h) => {
onSessionIdRef.current?.(h.sessionId);
adopt(h);
})
.catch(onOpenError);
});
} else {
opener({ cwd, rows: term.rows, cols: term.cols }, onData)
.then((h) => {
onSessionIdRef.current?.(h.sessionId);
adopt(h);
})
.catch(onOpenError);
}
// Refit + propagate size to the PTY on container resize.
const ro = new ResizeObserver(() => {
try {
fit.fit();
} catch {
return;
}
if (handle) void handle.resize(term.rows, term.cols);
});
ro.observe(container);
return () => {
disposed = true;
ro.disconnect();
onKey.dispose();
// DETACH, never close: tearing the view down (navigation / layout change)
// must leave the backend PTY running so the AI isn't cut off. Killing the
// PTY is an explicit user action handled elsewhere.
if (handle) handle.detach();
term.dispose();
};
// Only re-open on cwd change (or mount). The opener is read from a ref, and
// agent switches re-mount via `key`, so we must NOT depend on `open`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cwd]);
return (
<div
ref={containerRef}
data-testid="terminal-view"
style={{ width: "100%", height: "100%", minHeight: "16rem" }}
/>
);
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}