Sauvegarde de l'arbre de travail en cours (persistance P8, conversations C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le support de la délégation inter-agents pour les profils Codex. Le round-trip inter-agent question/réponse est couvert sans tokens par les tests loopback existants (state::mcp_e2e_loopback_tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
331 lines
13 KiB
TypeScript
331 lines
13 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 } 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<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;
|
|
}
|
|
|
|
export function TerminalView({
|
|
cwd,
|
|
open,
|
|
reattach,
|
|
sessionId,
|
|
onSessionId,
|
|
agentMode = false,
|
|
portal,
|
|
}: 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;
|
|
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 (
|
|
<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);
|
|
}
|
|
|
|
/** 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;
|
|
}
|