// Embedded xterm.js terminal pane for v1.1 terminal mode. // // Renders a single live PTY bound to a claudex session. Handles: // 1. Initial mount: reattach to an existing PTY if one is // registered in the store, otherwise spawn a fresh one. // 2. Ring buffer replay on reattach so the xterm picks up the // recent stdout history. // 3. Bi-directional I/O: stdout chunks arrive via `pty:data` // events and get written to the terminal; keystrokes flow out // via `onData` → `writePty`. // 4. Resize propagation: xterm's `onResize` (driven by a // `ResizeObserver` on the container) pushes new dimensions // down to the PTY master. // // **Critical lifecycle rule**: unmount does NOT close the PTY. // The whole codex-parallel-threads goal is that terminals survive // session switches, mode toggles, and anything short of an explicit // user teardown. The only paths that kill a PTY are // `store.closeSessionPty` and the window-destroy reaper in Rust. import { useEffect, useRef } from "react"; import { getPtyBuffer, logFrontend, onPtyData, resizePty, spawnPty, writePty, type PtyDataEvent, } from "@/lib/ipc/client"; import { useSessionStore } from "@/lib/store/sessions"; import { FitAddon } from "@xterm/addon-fit"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { Terminal } from "@xterm/xterm"; // NOTE: WebglAddon is intentionally NOT loaded. The macOS webview // has a very low limit on live WebGL contexts (~8) and rapid // session switching blows past that limit every few clicks, // stalling the main thread for seconds while the GPU driver // tears contexts down. The default DOM renderer is fast enough // for our single-terminal use case and has zero init cost per // mount. Revisit if we ever want to render multi-pane tmux. const trace = (msg: string, extra?: Record) => { const payload = extra ? `${msg} ${JSON.stringify(extra)}` : msg; // eslint-disable-next-line no-console console.debug("[TerminalPane]", payload); void logFrontend("debug", "TerminalPane", payload); }; /** Module-level dedup lock for concurrent spawn attempts on the * same `sessionId`. React 19 StrictMode double-invokes effects * (mount → cleanup → mount), and both invocations used to race * into `spawn_pty`, producing TWO claude subprocesses per click. * The first one was then orphaned — its ptyId never landed in * the store so the frontend lost track of it, but the backend * kept the subprocess alive until window-destroy. * * With this map, the second mount awaits the first mount's * promise and reuses the same ptyId. Exactly one subprocess per * session, StrictMode-safe. */ const spawnLocks = new Map>(); async function getOrSpawnPty( sessionId: string, cwd: string, claudeArgs: string[], cols: number, rows: number, ): Promise { const existing = useSessionStore.getState().ptyIds.get(sessionId); if (existing) return existing; const inflight = spawnLocks.get(sessionId); if (inflight) return inflight; const promise = (async () => { const newId = crypto.randomUUID(); trace("spawn path", { ptyId: newId, cols, rows }); await spawnPty({ ptyId: newId, sessionId, cwd, args: claudeArgs, cols, rows, }); // Register in the store before returning so any second mount // awaiting the same promise can look up the ptyId via the // `existing` check above on its next call. useSessionStore.getState().registerPty(sessionId, { ptyId: newId, sessionId, cwd, startedAt: new Date().toISOString(), }); trace("spawn_pty ok", { ptyId: newId }); return newId; })(); spawnLocks.set(sessionId, promise); try { return await promise; } finally { spawnLocks.delete(sessionId); } } /** Write a large base64 payload into xterm in paced chunks so we * don't block the UI thread. xterm.write accepts a callback that * fires once the write has been parsed and rendered, which we use * to schedule the next chunk. For a fresh mount with a 200 KB * ring buffer replay this keeps the main thread responsive * (keystrokes still register) instead of dropping a multi-second * stall while xterm parses the whole blob. */ const REPLAY_CHUNK_BYTES = 8 * 1024; function writeBase64Chunked(term: Terminal, b64: string): void { const binary = atob(b64); const total = binary.length; if (total === 0) return; if (total <= REPLAY_CHUNK_BYTES) { term.write(decodeBinary(binary)); return; } let offset = 0; const step = () => { const end = Math.min(offset + REPLAY_CHUNK_BYTES, total); const slice = decodeBinary(binary.slice(offset, end)); offset = end; if (offset >= total) { term.write(slice); } else { term.write(slice, step); } }; step(); } function decodeBinary(binary: string): Uint8Array { const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes; } /** Theme matching the claudex design tokens in `src/index.css`. */ const TERMINAL_THEME = { foreground: "#d4d4d8", background: "#0a0a0b", cursor: "#f97316", cursorAccent: "#0a0a0b", selectionBackground: "rgba(249, 115, 22, 0.3)", black: "#1a1a1a", red: "#ef4444", green: "#22c55e", yellow: "#eab308", blue: "#3b82f6", magenta: "#a855f7", cyan: "#06b6d4", white: "#e5e7eb", brightBlack: "#4b5563", brightRed: "#fca5a5", brightGreen: "#86efac", brightYellow: "#fef08a", brightBlue: "#93c5fd", brightMagenta: "#e9d5ff", brightCyan: "#a5f3fc", brightWhite: "#f9fafb", }; export interface TerminalPaneProps { /** Claudex store key for this session (may carry the * `pending-` prefix for in-flight new sessions). Used as the * binding key in `store.ptyIds`. */ sessionId: string; /** Absolute working directory for the spawned claude subprocess. * Must exist on disk. */ cwd: string; /** Claude CLI args — usually `["--resume", ]` or * `["--session-id", ]`. Baked in at mount time; respawning * with different args requires remounting the component. */ claudeArgs: string[]; } export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) { const containerRef = useRef(null); useEffect(() => { const container = containerRef.current; if (!container) return; // Clear any stale xterm DOM left behind by a previous mount. container.replaceChildren(); const term = new Terminal({ theme: TERMINAL_THEME, fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', fontSize: 13, lineHeight: 1.2, cursorBlink: true, scrollback: 10000, // Required before we can set `term.unicode.activeVersion`. allowProposedApi: true, }); const fit = new FitAddon(); term.loadAddon(fit); term.loadAddon(new WebLinksAddon()); term.loadAddon(new Unicode11Addon()); term.open(container); try { term.unicode.activeVersion = "11"; } catch { // Older xterm builds may not have unicode registered — ignore. } try { fit.fit(); } catch { // No layout yet — ResizeObserver below will retry on first // real box update. } let ptyId: string | null = null; let unlistenData: (() => void) | null = null; let cancelled = false; let disposeListeners: Array<() => void> = []; const attach = async () => { // Resolve a ptyId — either reattach to an already-running // subprocess or dedupe-safely spawn a new one. The spawn // lock guarantees at most one spawn per sessionId. const existing = useSessionStore.getState().ptyIds.get(sessionId); const reattach = !!existing; let resolved: string; try { resolved = await getOrSpawnPty( sessionId, cwd, claudeArgs, term.cols, term.rows, ); } catch (err) { if (!cancelled) { term.write( `\r\n\x1b[31m[claudex] spawn_pty failed: ${formatErr(err)}\x1b[0m\r\n\x1b[90m${cwd}\x1b[0m\r\n`, ); } return; } if (cancelled) return; ptyId = resolved; // Replay on reattach. Chunked inside `writeBase64Chunked` // so a ~200 KB scrollback doesn't stall the main thread. if (reattach) { try { const snapshot = await getPtyBuffer(resolved); if (cancelled) return; if (snapshot.length > 0) writeBase64Chunked(term, snapshot); } catch (err) { trace("reattach replay failed", { error: String(err) }); } } // Per-pty event listener. Only wakes up for our own bytes. try { const pid = resolved; const un = await onPtyData(pid, (ev: PtyDataEvent) => { writeBase64Chunked(term, ev.base64); }); if (cancelled) { un(); } else { unlistenData = un; } } catch (err) { trace("onPtyData listen failed", { error: String(err) }); } const dataDispose = term.onData((data) => { if (!ptyId) return; void writePty(ptyId, data).catch(() => { /* silent — the next keystroke will retry */ }); }); disposeListeners.push(() => dataDispose.dispose()); const resizeDispose = term.onResize(({ cols, rows }) => { if (!ptyId) return; void resizePty(ptyId, cols, rows).catch(() => { /* silent — harmless if the backend dropped the pty */ }); }); disposeListeners.push(() => resizeDispose.dispose()); }; void attach(); // Keep the terminal in sync with its container's box. ResizeObserver // fires whenever the parent pane grows or shrinks (window resize, // splitter drag, etc.). const ro = new ResizeObserver(() => { try { fit.fit(); } catch { // Container has no layout — harmless to ignore. } }); ro.observe(container); return () => { cancelled = true; ro.disconnect(); if (unlistenData) unlistenData(); for (const d of disposeListeners) d(); disposeListeners = []; // Do NOT close the PTY — it must survive unmount so switching // back to this session reattaches. Only closeSessionPty and // the window-destroy reaper kill PTYs. try { term.dispose(); } catch { /* disposed twice — harmless */ } container.replaceChildren(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]); return (
); } function formatErr(err: unknown): string { if (err instanceof Error) return err.message; if (typeof err === "string") return err; return JSON.stringify(err); }