agent-orchestrator/frontend/src/renderer/components/XtermTerminal.tsx

528 lines
20 KiB
TypeScript

// Self-contained xterm.js surface, ported from yyork's terminal architecture.
//
// Design rules (the reason this component exists):
// - The mount effect is dependency-free: the terminal instance is created once
// per mount and NEVER torn down because a callback identity changed.
// TerminalPane chooses the mount lifetime; it keys mounts by terminal handle
// so session switches get a clean surface, while same-handle reconnects reuse
// the mounted renderer.
// - Nothing writes into the buffer at mount. Status/empty-state belongs to DOM
// chrome around the terminal, not inside it. Writing before layout settles
// is what crashed xterm's Viewport (`dimensions` of a zero-sized renderer).
// - Fitting runs on several triggers, not one: FitAddon derives the grid from
// the measured cell box, and if it measures before the monospace font's real
// metrics (and the post-open renderer) are resolved it mis-counts cols/rows
// and the grid clips inside the panel. So: next frame, two settle timeouts,
// fonts.ready, a ResizeObserver, AND an onRender convergence loop that
// re-fits until the proposed grid stops changing (the last is the only
// trigger that recovers a clipped grid without the host box resizing). xterm
// itself only fires onResize when the grid actually changed, so repeated
// fits don't spam the PTY.
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import type { AttachableTerminal, TerminalUserInputSource } from "../hooks/useTerminalSession";
import { aoBridge } from "../lib/bridge";
import { buildTerminalThemes } from "../lib/terminal-themes";
import type { Theme } from "../stores/ui-store";
export type XtermTerminalProps = {
ariaLabel?: string;
className?: string;
fontSize?: number;
theme: Theme;
/** Terminal construction failed; the owner decides how to surface it. */
onError?: (error: unknown) => void;
/**
* The terminal is open in the DOM and ready to be attached to a PTY. The
* handle stays valid until unmount; cols/rows are live getters.
*/
onReady?: (terminal: AttachableTerminal) => void;
};
// Prefer the WebGL renderer, fall back to 2D canvas. Both rasterize box-drawing
// glyphs themselves onto a fixed cell grid; the DOM renderer does not, so TUI
// borders would drift. Loaded after open().
function loadRenderer(term: Terminal): void {
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
term.loadAddon(webgl);
return;
} catch {
// WebGL context unavailable — fall through to the canvas renderer.
}
try {
term.loadAddon(new CanvasAddon());
} catch (error) {
console.warn("xterm: WebGL and canvas renderers unavailable; box-drawing may drift", error);
}
}
// xterm palette tracks the app theme (see lib/terminal-themes.ts + --term-* in
// styles.css). The PTY content is still the agent's own ANSI output.
const terminalThemes = buildTerminalThemes();
// Erase scrollback (3J) + display (2J) and home the cursor — yyork's
// terminalResetSequence. Deliberately NOT term.reset(): every pane PTY is a
// fresh per-client `zellij attach` whose handshake re-asserts terminal modes
// anyway, but a full RIS would drop them for the window until that handshake
// arrives. The clear only wipes pixels; modes stay up.
const CLEAR_SEQUENCE = "\x1b[3J\x1b[2J\x1b[H";
function preparePastedText(text: string): string {
return text.replace(/\r?\n/g, "\r");
}
function bracketPastedText(text: string, bracketedPasteMode: boolean): string {
return bracketedPasteMode ? `\x1b[200~${text}\x1b[201~` : text;
}
function isTerminalCopyShortcut(event: KeyboardEvent): boolean {
if (event.key === "Insert") return event.ctrlKey && !event.altKey && !event.metaKey;
if (event.key.toLowerCase() !== "c") return false;
if (event.metaKey) return true;
if (event.ctrlKey && event.shiftKey && !event.altKey) return true;
return isWindowsPlatform() && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey;
}
function isWindowsPlatform(): boolean {
const platform =
(navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? navigator.platform;
return platform.toLowerCase().startsWith("win");
}
function isTerminalPasteShortcut(event: KeyboardEvent): boolean {
if (event.key === "Insert") return event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey;
if (event.key.toLowerCase() !== "v") return false;
if (event.metaKey) return true;
return event.ctrlKey && event.shiftKey;
}
function consumeTerminalShortcut(event: KeyboardEvent): void {
event.preventDefault();
event.stopPropagation();
}
function normalizedTerminalShortcut(event: KeyboardEvent): string | null {
if (event.metaKey || event.shiftKey) return null;
if (event.altKey && !event.ctrlKey) {
switch (event.key) {
case "ArrowLeft":
return "\x1bb";
case "ArrowRight":
return "\x1bf";
case "Backspace":
return "\x1b\x7f";
case "Delete":
return "\x1bd";
default:
return null;
}
}
if (event.ctrlKey && !event.altKey) {
switch (event.key) {
case "ArrowLeft":
return "\x1b[1;5D";
case "ArrowRight":
return "\x1b[1;5C";
case "Backspace":
return "\x1b\x7f";
case "Delete":
return "\x1bd";
default:
return null;
}
}
return null;
}
function terminalHasFocus(host: HTMLElement): boolean {
const activeElement = document.activeElement;
return !!activeElement && host.contains(activeElement);
}
type XtermInternal = Terminal & {
_core?: {
element?: HTMLElement;
_selectionService?: {
enable: () => void;
shouldForceSelection: (event: MouseEvent) => boolean;
};
};
};
// zellij (started with `--mouse-mode true`, see backend embeddedClientOptions)
// acts on SGR mouse-wheel reports written to its stdin and scrolls the focused
// pane, but it does NOT enable host mouse reporting, so xterm's own mouse
// protocol stays NONE and it never reports the wheel itself. With scrollback:0
// xterm would instead convert the wheel into cursor-arrow keys (its alt-buffer
// fallback), which move the agent's cursor/history rather than scrolling. So we
// synthesize the SGR wheel reports here. SGR button 64 = wheel up, 65 = down;
// reports are 1-based and a single cell is enough for a borderless single pane.
const SGR_WHEEL_UP = 64;
const SGR_WHEEL_DOWN = 65;
function sgrWheelReport(button: number, count: number): string {
return `\x1b[<${button};1;1M`.repeat(count);
}
function forceSelectionMode(term: Terminal): void {
const internal = term as XtermInternal;
const selectionService = internal._core?._selectionService;
const element = internal._core?.element;
if (!selectionService || !element) return;
selectionService.shouldForceSelection = () => true;
selectionService.enable();
element.classList.remove("enable-mouse-events");
}
export function XtermTerminal(props: XtermTerminalProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<(() => void) | null>(null);
// Latest callbacks in a ref so the mount effect stays dependency-free — we
// never tear down and recreate the terminal because a handler identity
// changed between renders.
const callbacksRef = useRef(props);
useEffect(() => {
callbacksRef.current = props;
});
useEffect(() => {
const term = termRef.current;
if (!term) return;
term.options.theme = props.theme === "dark" ? terminalThemes.dark : terminalThemes.light;
}, [props.theme]);
useEffect(() => {
const term = termRef.current;
if (!term || !props.fontSize) return undefined;
term.options.fontSize = props.fontSize;
fitRef.current?.();
const timer = window.setTimeout(() => fitRef.current?.(), 50);
return () => window.clearTimeout(timer);
}, [props.fontSize]);
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
let term: Terminal;
try {
term = new Terminal({
// Required for the Unicode 11 width addon below.
allowProposedApi: true,
cursorBlink: true,
// Resolve the Nerd Font stack from --font-mono (styles.css) at
// construction so terminal glyphs follow the app's font tokens. The
// box-drawing grid is rasterized by the WebGL/canvas renderer itself,
// but powerline separators and file-type icons are real PUA codepoints
// that must come from a system-installed Nerd Font.
fontFamily:
getComputedStyle(host).getPropertyValue("--font-mono").trim() ||
'ui-monospace, Menlo, Monaco, "Courier New", monospace',
fontSize: props.fontSize ?? 12,
lineHeight: 1.35,
// Agent TUIs leave SGR bold active while using ANSI black for
// separators; keep bold weight-only so black stays black.
drawBoldTextInBrightColors: false,
// Auto-adjust glyph colors that don't clear WCAG AA against their cell
// background, the way VS Code's terminal does; without it dim colors
// render washed out.
minimumContrastRatio: 4.5,
// The mux PTY runs `zellij attach` (backend AttachCommand), a
// full-screen alt-buffer app that owns scrollback itself — same as
// yyork. xterm's own buffer never accumulates history (the alt screen
// doesn't feed scrollback), and wheel events reach zellij as mouse
// reports instead of scrolling locally. 0 also stops FitAddon
// reserving ~14px on the right for a scrollbar that can never appear.
scrollback: 0,
theme: props.theme === "dark" ? terminalThemes.dark : terminalThemes.light,
});
} catch (error) {
callbacksRef.current.onError?.(error);
return undefined;
}
termRef.current = term;
const fit = new FitAddon();
term.loadAddon(fit);
const unicode = new Unicode11Addon();
term.loadAddon(unicode);
term.unicode.activeVersion = "11";
term.loadAddon(new WebLinksAddon());
term.loadAddon(new SearchAddon());
term.open(host);
loadRenderer(term);
term.options.macOptionClickForcesSelection = true;
forceSelectionMode(term);
let lastCopiedSelection = "";
const copySelection = (options?: { clipboardData?: DataTransfer | null; dedupe?: boolean }) => {
const selection = term.getSelection();
if (!selection || (options?.dedupe && selection === lastCopiedSelection)) return false;
options?.clipboardData?.setData("text/plain", selection);
void aoBridge.clipboard
.writeText(selection)
.then(() => {
lastCopiedSelection = selection;
})
.catch((error) => {
console.warn("Unable to copy terminal selection", error);
});
return true;
};
const clearCopiedSelection = () => {
lastCopiedSelection = "";
};
const userInputListeners = new Set<(data: string, source: TerminalUserInputSource) => void>();
const emitUserInput = (data: string, source: TerminalUserInputSource) => {
if (data.length === 0) return;
userInputListeners.forEach((listener) => listener(data, source));
};
const pasteText = (text: string) => {
const prepared = preparePastedText(text);
const bracketed = term.modes.bracketedPasteMode && term.options.ignoreBracketedPasteMode !== true;
emitUserInput(bracketPastedText(prepared, bracketed), "paste");
};
const pasteFromClipboard = () => {
void aoBridge.clipboard
.readText()
.then(pasteText)
.catch((error) => {
console.warn("Unable to paste terminal clipboard text", error);
});
};
term.attachCustomKeyEventHandler((event) => {
if (isTerminalCopyShortcut(event)) {
if (copySelection()) {
consumeTerminalShortcut(event);
return false;
}
if ((event.ctrlKey && event.shiftKey) || (event.key === "Insert" && event.ctrlKey)) {
consumeTerminalShortcut(event);
return false;
}
return true;
}
if (isTerminalPasteShortcut(event)) {
consumeTerminalShortcut(event);
pasteFromClipboard();
return false;
}
const normalized = normalizedTerminalShortcut(event);
if (!normalized) return true;
consumeTerminalShortcut(event);
emitUserInput(normalized, "terminal");
return false;
});
const copyInput = (event: ClipboardEvent) => {
if (!copySelection({ clipboardData: event.clipboardData })) return;
event.preventDefault();
};
const copyShortcut = (event: KeyboardEvent) => {
if (!isTerminalCopyShortcut(event) || !terminalHasFocus(host) || !copySelection()) return;
event.preventDefault();
event.stopPropagation();
};
host.addEventListener("copy", copyInput);
window.addEventListener("keydown", copyShortcut, true);
const selectionChange = term.onSelectionChange(() => {
if (!term.hasSelection()) {
clearCopiedSelection();
return;
}
window.setTimeout(() => copySelection({ dedupe: true }), 0);
});
const fitTerminal = () => {
try {
fit.fit();
} catch {
// Container momentarily has no size (hidden/unmounting) — a later
// trigger retries.
}
};
fitRef.current = fitTerminal;
const raf = requestAnimationFrame(fitTerminal);
// 50/250ms catch the common settle; 600/1200ms are a session-bounded
// backstop. By 600ms the WebGL atlas and font metrics are unambiguously
// warm, so even if the convergence loop below detached at a briefly-stable
// wrong measurement, this re-measures the real cell box and corrects —
// which fires the PTY resize that makes zellij repaint cleanly (clearing
// any ghost frame). fit() is idempotent: a no-op when the grid is already
// right, so a correct terminal never reflows.
const settleTimers = [50, 250, 600, 1200].map((ms) => window.setTimeout(fitTerminal, ms));
if (document.fonts?.ready) {
void document.fonts.ready.then(fitTerminal);
}
const observer = new ResizeObserver(fitTerminal);
observer.observe(host);
// Recovery re-fit that does NOT depend on the host box changing size.
//
// FitAddon derives the grid by dividing the pane box by the renderer's
// measured cell box. That box is measured asynchronously: the WebGL
// renderer loads after open() and the monospace font's real metrics
// resolve a frame or more later, so the early fits above can divide by a
// not-yet-final cell box, mis-count cols/rows, and clip the grid inside the
// pane. The fixed settle window (rAF, timeouts, fonts.ready) may all run
// before the cell box is final, and the ResizeObserver never fires to
// correct it because the host's pixel box is a stable height:100%, so a
// wrong grid would otherwise freeze for the whole session.
//
// onRender fires on every renderer repaint, including the repaint after
// the metrics settle. Each fire re-proposes dimensions from the *current*
// measured cell box. Crucially we never re-fit straight off a single
// frame's proposal: the WebGL atlas warm-up can emit a one-frame transient
// cell box (e.g. a doubled box on a HiDPI display) that halves the grid,
// and committing it would lock the terminal at half size and detach (the
// #313 ghost). So a differing proposal must REPEAT identically across two
// consecutive renders — proving the measurement settled — before we apply
// it. proposeDimensions returns undefined until the cell box is non-zero,
// so a fit is never accepted from an unmeasured cell. Once the proposal
// holds at the live grid for a few frames (or a hard re-fit cap is hit) the
// listener detaches, so steady-state content renders cost nothing.
const STABLE_FRAMES_TARGET = 3;
const MAX_REFITS = 20;
let stableFrames = 0;
let refits = 0;
let pending: { cols: number; rows: number } | null = null;
const stabilizer = term.onRender(() => {
const proposed = fit.proposeDimensions();
if (!proposed || !proposed.cols || !proposed.rows) return;
if (proposed.cols !== term.cols || proposed.rows !== term.rows) {
stableFrames = 0;
// Only act once the same differing proposal repeats — a single-frame
// transient never gets committed, it just updates `pending`.
if (pending && pending.cols === proposed.cols && pending.rows === proposed.rows) {
pending = null;
if (refits++ >= MAX_REFITS) {
stabilizer.dispose();
return;
}
fitTerminal();
return;
}
pending = { cols: proposed.cols, rows: proposed.rows };
return;
}
pending = null;
if (++stableFrames >= STABLE_FRAMES_TARGET) stabilizer.dispose();
});
// OS window resize and monitor/DPR changes also alter the true cell box
// without touching the host's height:100% box, so the ResizeObserver above
// misses them. Listen on window directly as a session-long recovery path.
window.addEventListener("resize", fitTerminal);
const terminalInput = term.onData((data) => emitUserInput(data, "terminal"));
// Translate wheel motion into SGR wheel reports for zellij (see
// sgrWheelReport), one report per scrolled line. WheelEvent.deltaMode
// varies by platform/device: trackpads and normalized wheels report
// pixels (mode 0, the macOS case), while many Linux/Windows mouse wheels
// report whole lines (mode 1) or pages (mode 2). Mirror xterm's native
// getLinesScrolled across all three so scroll works everywhere; pixel
// deltas accumulate so a full cell-height emits one line. Returning false
// suppresses xterm's arrow-key wheel fallback. Ctrl/Cmd wheel is the
// font-size zoom (CenterPane), so leave it for that handler.
let wheelAccumPx = 0;
term.attachCustomWheelEventHandler((event) => {
if (event.ctrlKey || event.metaKey) return false;
let lines: number;
if (event.deltaMode === 1 /* DOM_DELTA_LINE */) {
lines = Math.trunc(event.deltaY) || Math.sign(event.deltaY);
} else if (event.deltaMode === 2 /* DOM_DELTA_PAGE */) {
lines = (Math.trunc(event.deltaY) || Math.sign(event.deltaY)) * term.rows;
} else {
const rowHeight = (term.options.fontSize ?? 12) * (term.options.lineHeight ?? 1);
wheelAccumPx += event.deltaY;
lines = Math.trunc(wheelAccumPx / rowHeight);
wheelAccumPx -= lines * rowHeight;
}
if (lines === 0) return false;
const button = lines < 0 ? SGR_WHEEL_UP : SGR_WHEEL_DOWN;
emitUserInput(sgrWheelReport(button, Math.abs(lines)), "terminal");
return false;
});
const pasteInput = (event: ClipboardEvent) => {
event.preventDefault();
event.stopPropagation();
const text = event.clipboardData?.getData("text/plain") ?? "";
pasteText(text);
};
const compositionInput = (event: CompositionEvent) => {
emitUserInput(event.data, "composition");
};
host.addEventListener("paste", pasteInput, true);
host.addEventListener("compositionend", compositionInput, true);
// Live cols/rows getters: the owner reads the current grid at attach time,
// not a snapshot taken at ready time (the first fit may not have run yet).
const handle: AttachableTerminal = {
get cols() {
return term.cols;
},
get rows() {
return term.rows;
},
write: (data) => term.write(data),
writeln: (line) => term.writeln(line),
clear: () => term.write(CLEAR_SEQUENCE),
onUserInput: (listener) => {
userInputListeners.add(listener);
return { dispose: () => userInputListeners.delete(listener) };
},
onResize: (listener) => term.onResize(listener),
};
callbacksRef.current.onReady?.(handle);
return () => {
termRef.current = null;
fitRef.current = null;
cancelAnimationFrame(raf);
for (const timer of settleTimers) window.clearTimeout(timer);
observer.disconnect();
stabilizer.dispose();
window.removeEventListener("resize", fitTerminal);
host.removeEventListener("copy", copyInput);
window.removeEventListener("keydown", copyShortcut, true);
selectionChange.dispose();
host.removeEventListener("paste", pasteInput, true);
host.removeEventListener("compositionend", compositionInput, true);
terminalInput.dispose();
userInputListeners.clear();
try {
term.dispose();
} catch {
// Some renderer addons can throw during dispose in certain GPU
// environments; the terminal is being torn down regardless.
}
};
}, []);
return (
<div
ref={hostRef}
aria-label={props.ariaLabel}
className={props.className}
style={{ height: "100%", overflow: "hidden", width: "100%" }}
/>
);
}