Files
IdeA/frontend/src/features/terminals/TerminalView.tsx

512 lines
20 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)` 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<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;
/**
* 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<HTMLDivElement | null>(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<string | 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;
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 (
<div
data-testid="terminal-view"
style={{
position: "relative",
width: "100%",
height: "100%",
minHeight: "16rem",
}}
>
{/* xterm mounts into this inner node; the error banner is a sibling so
React never fights xterm over the same subtree. */}
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
{showNetworkBanner && (
<div
role="status"
data-testid="terminal-network-banner"
style={{
position: "absolute",
top: 8,
left: 8,
right: 8,
padding: "0.5rem 0.75rem",
border: "1px solid rgba(245, 158, 11, 0.45)",
borderRadius: 6,
background: "rgba(24, 24, 27, 0.94)",
color: "var(--color-warning, #f59e0b)",
fontSize: 12,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
whiteSpace: "pre-wrap",
wordBreak: "break-word",
zIndex: 2,
}}
>
{systemPermissions.runtimeLock.state === "locked"
? "Réseau verrouillé par le runtime."
: "Réseau interdit pour cet agent."}{" "}
{networkReason}
</div>
)}
{openError && (
<div
role="alert"
data-testid="terminal-error"
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
padding: "0.5rem 0.75rem",
background: "rgba(120, 20, 20, 0.92)",
color: "#fff",
fontSize: 13,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
whiteSpace: "pre-wrap",
wordBreak: "break-word",
zIndex: 3,
}}
>
{openError}
</div>
)}
</div>
);
}
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;
}