/** * 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)` in **both** modes: the * agent cell is a **native terminal** (ARCHITECTURE §20), so human keystrokes * (Enter included) reach the PTY unconditionally, exactly like a raw shell. * In agent mode the keystrokes are additionally reported to the write-portal * (a {@link WritePortal} supplied via `portal`) which (a) keeps a *human line* * counter (+1 per printable, reset on Enter/Ctrl-C) and (b) can momentarily * **suspend** the keystroke relay while it injects a delegation. The PTY * **output** path stays live and unchanged in both modes. * - 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, WritePortal, } 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; /** * Agent mode (ARCHITECTURE §20). When `true` the cell hosts an agent and the * terminal is **native**: keystrokes (`term.onData`) reach the PTY exactly * like a plain shell, AND are reported to the {@link WritePortal} (`portal`) * for line counting / suspension. When `false`/absent the cell is a plain * shell with no portal. Defaults to `false`. */ agentMode?: boolean; /** * The write-portal for this agent cell (ARCHITECTURE §20). Only meaningful in * agent mode: it receives keystroke reports, gates the relay via * {@link WritePortal.isSuspended}, and is given the live handle so it can * inject delegations through the same single PTY writer. Absent for plain * cells. */ portal?: WritePortal; } export function TerminalView({ cwd, open, reattach, sessionId, onSessionId, agentMode = false, portal, }: 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; const agentModeRef = useRef(agentMode); agentModeRef.current = agentMode; const portalRef = useRef(portal); portalRef.current = portal; 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(); // Keystroke → PTY path. The agent cell is a **native terminal** // (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell. // In agent mode we additionally (1) report the keystroke to the write-portal // for line counting, and (2) honour the portal's `isSuspended()` flag, which // is raised while the portal injects a delegation so the human keystroke does // not race the injected text. Keystrokes arriving before the PTY opened are // buffered. The output path below is unchanged in both modes. let pending = ""; const onKey = term.onData((data) => { const portal = portalRef.current; if (agentModeRef.current && portal) { // Always report for counting (even while suspended — the portal decides // what counts), then drop the relay while the portal is injecting. portal.onHumanData(data); if (portal.isSuspended()) return; } 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; // Hand the live handle to the write-portal so it can inject delegations // through the SAME single PTY writer (no second physical writer). if (agentModeRef.current) portalRef.current?.bindHandle(h); if (pending) { void h.write(encoder.encode(pending)); pending = ""; } }; const onOpenError = (e: unknown) => { if (disposed) return; // The agent is already running in another cell (singleton invariant): this // is NOT an error — the cell is simply available. Show a calm, muted notice // (not the red failure line) so the user can pick another agent. if (errorCode(e) === "AGENT_ALREADY_RUNNING") { term.write( `\r\n\x1b[2mCellule disponible — l'agent est en cours dans une autre cellule.\x1b[0m\r\n`, ); return; } 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. ResizeObserver can // fire many times per frame, and during layout/tab transitions the container // momentarily reports a zero or transient size — fitting then would size // xterm's grid (and the PTY) to a stale value, leaving the repainted content // shifted/misaligned once the real size settles. So we: (1) coalesce bursts // into a single `requestAnimationFrame` that runs after layout settles, // (2) skip fitting while the container has no real size, and (3) push a PTY // resize only when rows/cols actually change (avoids redundant reflows). let rafId = 0; let lastRows = term.rows; let lastCols = term.cols; const refit = () => { rafId = 0; if (disposed) return; if (container.clientWidth === 0 || container.clientHeight === 0) return; try { fit.fit(); } catch { return; } if (handle && (term.rows !== lastRows || term.cols !== lastCols)) { lastRows = term.rows; lastCols = term.cols; void handle.resize(term.rows, term.cols); } }; const ro = new ResizeObserver(() => { if (rafId) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(refit); }); ro.observe(container); return () => { disposed = true; if (rafId) cancelAnimationFrame(rafId); ro.disconnect(); onKey.dispose(); portalRef.current?.unbindHandle(); // 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); } /** Extracts a gateway error `code` when present (the Tauri/mock error shape). */ function errorCode(e: unknown): string | undefined { if (e && typeof e === "object" && "code" in e) { return String((e as { code: unknown }).code); } return undefined; }