/** * 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, useState } 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 { ResolvedAgentSystemPermissions } from "@/domain"; 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; /** * Called once, when xterm has mounted, with an imperative * {@link TerminalInputApi} for this cell (#69). Optional and inert when * absent, so the desktop path is unchanged; the web client uses it to drive * the mobile key toolbar. Not called when xterm fails to mount (headless). */ onReady?: (api: TerminalInputApi) => void; /** * Bumped by the caller after a structural layout mutation (split/merge, * ticket #61) so surviving terminals refit even when their container's * `ResizeObserver` doesn't fire a useful event for the mutation (e.g. a * sibling appearing/disappearing without this cell's own box changing size * in a way the observer flags in time). Any value change schedules exactly * one coalesced refit on the next animation frame, reusing the same * zero-size guard and rows/cols-changed check as the resize-observer path — * it never remounts/reopens the terminal. */ refitSignal?: number; /** Optional resolved system permissions for this agent/cell. */ systemPermissions?: ResolvedAgentSystemPermissions | null; } /** * Imperative handle over a mounted terminal (#69), handed to the caller by * {@link TerminalViewProps.onReady}. * * It exists because a phone's virtual keyboard has no Esc, Tab, Ctrl or arrow * keys, so the web client renders a key toolbar that needs to inject them. Both * methods deliberately go through xterm rather than the PTY handle: * `send` routes through `term.input()` — the *same* path a real keystroke takes * — so the PTY relay, the write-portal's line counting and its suspension gate * all keep applying. Writing to the handle directly would bypass the portal and * let an injected key race a delegation. */ export interface TerminalInputApi { /** Inject `data` exactly as if the user had typed it. */ send: (data: string) => void; /** Focus the terminal — on a phone this summons the virtual keyboard. */ focus: () => void; } export function TerminalView({ cwd, open, reattach, sessionId, onSessionId, agentMode = false, portal, onReady, refitSignal, systemPermissions, }: TerminalViewProps) { const { terminal } = useGateways(); const containerRef = useRef(null); // A user-visible launch failure (e.g. an OpenAI-compatible endpoint that is // unreachable). Rendered as a DOM `role="alert"` banner over the cell so the // failure is always visible and accessible — not only painted into the xterm // buffer (which is invisible to assistive tech and absent when xterm can't // mount). `null` ⇒ no error. The cell stays mounted and IdeA stays usable. const [openError, setOpenError] = useState(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; const onReadyRef = useRef(onReady); onReadyRef.current = onReady; // Holds the mounted instance's `refit` closure so the `refitSignal` effect // below (a separate effect, since it must NOT re-run/reopen the terminal on // every parent render) can trigger it without depending on `cwd`'s effect. const refitRef = useRef<(() => void) | null>(null); 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; // Fresh (re)mount: clear any prior failure banner before we try to open. setOpenError(null); 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; }); // Publish the input API now that the keystroke relay above is live, so an // injected key is handled exactly like a typed one. Guarded on `disposed`: // the caller may still hold this object after the cell unmounts, and driving // a disposed xterm throws. onReadyRef.current?.({ send: (data) => { if (!disposed) term.input(data); }, focus: () => { if (!disposed) term.focus(); }, }); 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; } // A genuine launch failure (unreachable endpoint, model missing, network // drop…). Surface it as an accessible DOM banner AND as a red line in the // buffer. The cell renders as failed; IdeA stays usable (other cells work). setOpenError(`Échec du lancement de l'agent : ${describe(e)}`); 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; // A refit can land on a transient 0x0 container (mount, or a structural // layout mutation, before the box has actually settled). Previously this // just gave up — fine on desktop, where a later window resize always // re-triggers the ResizeObserver and retries, but on mobile (no window // resize possible) the cell was then stuck unfit forever. So a zero size // now reschedules on the next few frames instead of abandoning — bounded, // so a container that is genuinely never laid out (e.g. headless tests) // doesn't spin forever. const MAX_ZERO_SIZE_RETRIES = 8; let zeroSizeRetries = 0; const refit = () => { rafId = 0; if (disposed) return; if (container.clientWidth === 0 || container.clientHeight === 0) { if (zeroSizeRetries < MAX_ZERO_SIZE_RETRIES) { zeroSizeRetries += 1; rafId = requestAnimationFrame(refit); } return; } zeroSizeRetries = 0; 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 scheduleRefit = () => { if (rafId) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(refit); }; const ro = new ResizeObserver(scheduleRefit); ro.observe(container); // Let the `refitSignal` effect below trigger the SAME coalesced refit after // a structural layout mutation (split/merge, ticket #61) — surviving cells // don't always get a timely useful ResizeObserver event from a sibling // appearing/disappearing. refitRef.current = scheduleRefit; return () => { disposed = true; refitRef.current = null; 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]); // Ticket #61 — explicit refit after a structural layout mutation (split / // merge). A survivor's own container may not fire a timely, useful // ResizeObserver event when a SIBLING cell appears/disappears, so the caller // (LayoutGrid, via `useLayout`'s layout-tree version) bumps `refitSignal` on // every successful mutation and we reuse the mounted instance's coalesced // `refit` (same zero-size guard, same rows/cols-changed check) instead of // remounting/reopening the terminal. Deliberately a SEPARATE effect from the // `[cwd]` one above so bumping the signal never re-runs the open/reattach // logic. useEffect(() => { if (refitSignal === undefined) return; refitRef.current?.(); }, [refitSignal]); const showNetworkBanner = systemPermissions != null && (systemPermissions.runtimeLock.state === "locked" || systemPermissions.effective === "deny"); const networkReason = systemPermissions?.runtimeLock.reason ?? systemPermissions?.control.reason ?? "Le réseau est interdit pour cette cellule."; return (
{/* xterm mounts into this inner node; the error banner is a sibling so React never fights xterm over the same subtree. */}
{showNetworkBanner && (
{systemPermissions.runtimeLock.state === "locked" ? "Réseau verrouillé par le runtime." : "Réseau interdit pour cet agent."}{" "} {networkReason}
)} {openError && (
{openError}
)}
); } 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; }