TypeScript · 12086 bytes Raw Blame History
1 // Embedded xterm.js terminal pane for v1.1 terminal mode.
2 //
3 // Renders a single live PTY bound to a claudex session. Handles:
4 // 1. Initial mount: reattach to an existing PTY if one is
5 // registered in the store, otherwise spawn a fresh one.
6 // 2. Ring buffer replay on reattach so the xterm picks up the
7 // recent stdout history.
8 // 3. Bi-directional I/O: stdout chunks arrive via `pty:data`
9 // events and get written to the terminal; keystrokes flow out
10 // via `onData` → `writePty`.
11 // 4. Resize propagation: xterm's `onResize` (driven by a
12 // `ResizeObserver` on the container) pushes new dimensions
13 // down to the PTY master.
14 //
15 // **Critical lifecycle rule**: unmount does NOT close the PTY.
16 // The whole codex-parallel-threads goal is that terminals survive
17 // session switches, mode toggles, and anything short of an explicit
18 // user teardown. The only paths that kill a PTY are
19 // `store.closeSessionPty` and the window-destroy reaper in Rust.
20
21 import { useEffect, useRef } from "react";
22
23 import {
24 getPtyBuffer,
25 onPtyData,
26 resizePty,
27 spawnPty,
28 writePty,
29 type PtyDataEvent,
30 } from "@/lib/ipc/client";
31 import { useSessionStore } from "@/lib/store/sessions";
32
33 import { FitAddon } from "@xterm/addon-fit";
34 import { Unicode11Addon } from "@xterm/addon-unicode11";
35 import { WebLinksAddon } from "@xterm/addon-web-links";
36 import { WebglAddon } from "@xterm/addon-webgl";
37 import { Terminal } from "@xterm/xterm";
38
39 // Dev-only trace. Writes to the devtools console; does NOT forward
40 // to Rust tracing because each logFrontend call is an IPC round
41 // trip on the UI thread, and per-mount chatter is exactly the
42 // kind of noise that piles up on rapid interactions.
43 const trace = (msg: string, extra?: Record<string, unknown>) => {
44 if (import.meta.env.DEV) {
45 // eslint-disable-next-line no-console
46 console.debug("[TerminalPane]", msg, extra ?? "");
47 }
48 };
49
50 /** Module-level dedup lock for concurrent spawn attempts on the
51 * same `sessionId`. React 19 StrictMode double-invokes effects
52 * (mount → cleanup → mount), and both invocations used to race
53 * into `spawn_pty`, producing TWO claude subprocesses per click.
54 * The first one was then orphaned — its ptyId never landed in
55 * the store so the frontend lost track of it, but the backend
56 * kept the subprocess alive until window-destroy.
57 *
58 * With this map, the second mount awaits the first mount's
59 * promise and reuses the same ptyId. Exactly one subprocess per
60 * session, StrictMode-safe. */
61 const spawnLocks = new Map<string, Promise<string>>();
62
63 async function getOrSpawnPty(
64 sessionId: string,
65 cwd: string,
66 claudeArgs: string[],
67 cols: number,
68 rows: number,
69 ): Promise<string> {
70 const existing = useSessionStore.getState().ptyIds.get(sessionId);
71 if (existing) return existing;
72
73 const inflight = spawnLocks.get(sessionId);
74 if (inflight) return inflight;
75
76 const promise = (async () => {
77 const newId = crypto.randomUUID();
78 trace("spawn path", { ptyId: newId, cols, rows });
79 await spawnPty({
80 ptyId: newId,
81 sessionId,
82 cwd,
83 args: claudeArgs,
84 cols,
85 rows,
86 });
87 // Register in the store before returning so any second mount
88 // awaiting the same promise can look up the ptyId via the
89 // `existing` check above on its next call.
90 useSessionStore.getState().registerPty(sessionId, {
91 ptyId: newId,
92 sessionId,
93 cwd,
94 startedAt: new Date().toISOString(),
95 });
96 trace("spawn_pty ok", { ptyId: newId });
97 return newId;
98 })();
99
100 spawnLocks.set(sessionId, promise);
101 try {
102 return await promise;
103 } finally {
104 spawnLocks.delete(sessionId);
105 }
106 }
107
108 /** Write a large base64 payload into xterm in paced chunks so we
109 * don't block the UI thread. xterm.write accepts a callback that
110 * fires once the write has been parsed and rendered, which we use
111 * to schedule the next chunk. For a fresh mount with a 200 KB
112 * ring buffer replay this keeps the main thread responsive
113 * (keystrokes still register) instead of dropping a multi-second
114 * stall while xterm parses the whole blob. */
115 const REPLAY_CHUNK_BYTES = 8 * 1024;
116
117 function writeBase64Chunked(term: Terminal, b64: string): void {
118 const binary = atob(b64);
119 const total = binary.length;
120 if (total === 0) return;
121 if (total <= REPLAY_CHUNK_BYTES) {
122 term.write(decodeBinary(binary));
123 return;
124 }
125 let offset = 0;
126 const step = () => {
127 const end = Math.min(offset + REPLAY_CHUNK_BYTES, total);
128 const slice = decodeBinary(binary.slice(offset, end));
129 offset = end;
130 if (offset >= total) {
131 term.write(slice);
132 } else {
133 term.write(slice, step);
134 }
135 };
136 step();
137 }
138
139 function decodeBinary(binary: string): Uint8Array {
140 const bytes = new Uint8Array(binary.length);
141 for (let i = 0; i < binary.length; i++) {
142 bytes[i] = binary.charCodeAt(i);
143 }
144 return bytes;
145 }
146
147 /** Theme matching the claudex design tokens in `src/index.css`. */
148 const TERMINAL_THEME = {
149 foreground: "#d4d4d8",
150 background: "#0a0a0b",
151 cursor: "#f97316",
152 cursorAccent: "#0a0a0b",
153 selectionBackground: "rgba(249, 115, 22, 0.3)",
154 black: "#1a1a1a",
155 red: "#ef4444",
156 green: "#22c55e",
157 yellow: "#eab308",
158 blue: "#3b82f6",
159 magenta: "#a855f7",
160 cyan: "#06b6d4",
161 white: "#e5e7eb",
162 brightBlack: "#4b5563",
163 brightRed: "#fca5a5",
164 brightGreen: "#86efac",
165 brightYellow: "#fef08a",
166 brightBlue: "#93c5fd",
167 brightMagenta: "#e9d5ff",
168 brightCyan: "#a5f3fc",
169 brightWhite: "#f9fafb",
170 };
171
172 export interface TerminalPaneProps {
173 /** Claudex store key for this session (may carry the
174 * `pending-` prefix for in-flight new sessions). Used as the
175 * binding key in `store.ptyIds`. */
176 sessionId: string;
177 /** Absolute working directory for the spawned claude subprocess.
178 * Must exist on disk. */
179 cwd: string;
180 /** Claude CLI args — usually `["--resume", <id>]` or
181 * `["--session-id", <uuid>]`. Baked in at mount time; respawning
182 * with different args requires remounting the component. */
183 claudeArgs: string[];
184 }
185
186 export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) {
187 const containerRef = useRef<HTMLDivElement | null>(null);
188
189 useEffect(() => {
190 const container = containerRef.current;
191 if (!container) return;
192 // Clear any stale xterm DOM left behind by a previous mount.
193 container.replaceChildren();
194
195 const term = new Terminal({
196 theme: TERMINAL_THEME,
197 fontFamily:
198 '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
199 fontSize: 13,
200 lineHeight: 1.2,
201 cursorBlink: true,
202 scrollback: 10000,
203 // Required before we can set `term.unicode.activeVersion`.
204 allowProposedApi: true,
205 });
206
207 const fit = new FitAddon();
208 term.loadAddon(fit);
209 term.loadAddon(new WebLinksAddon());
210 term.loadAddon(new Unicode11Addon());
211
212 term.open(container);
213 try {
214 term.unicode.activeVersion = "11";
215 } catch {
216 /* unicode not registered — ignore */
217 }
218
219 // WebGL renderer. Must be loaded AFTER open(). The DOM renderer
220 // fallback creates/updates a span per terminal cell on every
221 // redraw, which chokes the main thread during claude's TUI
222 // spinner animations and token streaming — that's what was
223 // producing the mouse-move beachballs. Earlier we removed this
224 // because React StrictMode's double-invoke created two WebGL
225 // contexts per mount and blew past the webview's context
226 // cap; with StrictMode off, exactly one context per mount,
227 // disposed cleanly on unmount.
228 try {
229 term.loadAddon(new WebglAddon());
230 } catch (err) {
231 trace("webgl addon failed, falling back to DOM renderer", {
232 error: String(err),
233 });
234 }
235
236 try {
237 fit.fit();
238 } catch {
239 /* no layout yet — ResizeObserver below will retry */
240 }
241
242 let ptyId: string | null = null;
243 let unlistenData: (() => void) | null = null;
244 let cancelled = false;
245 let disposeListeners: Array<() => void> = [];
246
247 const attach = async () => {
248 // Resolve a ptyId — either reattach to an already-running
249 // subprocess or dedupe-safely spawn a new one. The spawn
250 // lock guarantees at most one spawn per sessionId.
251 const existing = useSessionStore.getState().ptyIds.get(sessionId);
252 const reattach = !!existing;
253 let resolved: string;
254 try {
255 resolved = await getOrSpawnPty(
256 sessionId,
257 cwd,
258 claudeArgs,
259 term.cols,
260 term.rows,
261 );
262 } catch (err) {
263 if (!cancelled) {
264 term.write(
265 `\r\n\x1b[31m[claudex] spawn_pty failed: ${formatErr(err)}\x1b[0m\r\n\x1b[90m${cwd}\x1b[0m\r\n`,
266 );
267 }
268 return;
269 }
270 if (cancelled) return;
271 ptyId = resolved;
272
273 // Replay on reattach. Chunked inside `writeBase64Chunked`
274 // so a ~200 KB scrollback doesn't stall the main thread.
275 if (reattach) {
276 try {
277 const snapshot = await getPtyBuffer(resolved);
278 if (cancelled) return;
279 if (snapshot.length > 0) writeBase64Chunked(term, snapshot);
280 } catch (err) {
281 trace("reattach replay failed", { error: String(err) });
282 }
283 }
284
285 // Per-pty event listener. Only wakes up for our own bytes.
286 try {
287 const pid = resolved;
288 const un = await onPtyData(pid, (ev: PtyDataEvent) => {
289 writeBase64Chunked(term, ev.base64);
290 });
291 if (cancelled) {
292 un();
293 } else {
294 unlistenData = un;
295 }
296 } catch (err) {
297 trace("onPtyData listen failed", { error: String(err) });
298 }
299
300 const dataDispose = term.onData((data) => {
301 if (!ptyId) return;
302 void writePty(ptyId, data).catch(() => {
303 /* silent — the next keystroke will retry */
304 });
305 });
306 disposeListeners.push(() => dataDispose.dispose());
307
308 const resizeDispose = term.onResize(({ cols, rows }) => {
309 if (!ptyId) return;
310 void resizePty(ptyId, cols, rows).catch(() => {
311 /* silent — harmless if the backend dropped the pty */
312 });
313 });
314 disposeListeners.push(() => resizeDispose.dispose());
315 };
316
317 void attach();
318
319 // rAF-debounce the resize cascade. ResizeObserver can fire
320 // many times per frame during a panel drag; xterm's fit.fit()
321 // is a moderately expensive layout pass and each successful
322 // fit emits an `onResize` event which triggers a `resizePty`
323 // IPC call → Rust ioctl. Coalescing to at most once per frame
324 // is free correctness AND a big main-thread win under drag.
325 let rafHandle: number | null = null;
326 const ro = new ResizeObserver(() => {
327 if (rafHandle !== null) return;
328 rafHandle = requestAnimationFrame(() => {
329 rafHandle = null;
330 try {
331 fit.fit();
332 } catch {
333 /* container has no layout — retry on next resize */
334 }
335 });
336 });
337 ro.observe(container);
338
339 return () => {
340 cancelled = true;
341 ro.disconnect();
342 if (rafHandle !== null) cancelAnimationFrame(rafHandle);
343 if (unlistenData) unlistenData();
344 for (const d of disposeListeners) d();
345 disposeListeners = [];
346 // Do NOT close the PTY — it must survive unmount so switching
347 // back to this session reattaches. Only closeSessionPty and
348 // the window-destroy reaper kill PTYs.
349 try {
350 term.dispose();
351 } catch {
352 /* disposed twice — harmless */
353 }
354 container.replaceChildren();
355 };
356 // eslint-disable-next-line react-hooks/exhaustive-deps
357 }, [sessionId]);
358
359 return (
360 <div className="flex h-full w-full flex-col bg-bg-0">
361 <div ref={containerRef} className="min-h-0 flex-1 overflow-hidden" />
362 </div>
363 );
364 }
365
366 function formatErr(err: unknown): string {
367 if (err instanceof Error) return err.message;
368 if (typeof err === "string") return err;
369 return JSON.stringify(err);
370 }