/** * 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; /** * 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; /** * 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(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 (
); } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as { message: unknown }).message); } return String(e); }