TypeScript · 11171 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 logFrontend,
26 onPtyData,
27 resizePty,
28 spawnPty,
29 writePty,
30 type PtyDataEvent,
31 } from "@/lib/ipc/client";
32 import { useSessionStore } from "@/lib/store/sessions";
33
34 import { FitAddon } from "@xterm/addon-fit";
35 import { Unicode11Addon } from "@xterm/addon-unicode11";
36 import { WebLinksAddon } from "@xterm/addon-web-links";
37 import { Terminal } from "@xterm/xterm";
38
39 // NOTE: WebglAddon is intentionally NOT loaded. The macOS webview
40 // has a very low limit on live WebGL contexts (~8) and rapid
41 // session switching blows past that limit every few clicks,
42 // stalling the main thread for seconds while the GPU driver
43 // tears contexts down. The default DOM renderer is fast enough
44 // for our single-terminal use case and has zero init cost per
45 // mount. Revisit if we ever want to render multi-pane tmux.
46
47 const trace = (msg: string, extra?: Record<string, unknown>) => {
48 const payload = extra ? `${msg} ${JSON.stringify(extra)}` : msg;
49 // eslint-disable-next-line no-console
50 console.debug("[TerminalPane]", payload);
51 void logFrontend("debug", "TerminalPane", payload);
52 };
53
54 /** Module-level dedup lock for concurrent spawn attempts on the
55 * same `sessionId`. React 19 StrictMode double-invokes effects
56 * (mount → cleanup → mount), and both invocations used to race
57 * into `spawn_pty`, producing TWO claude subprocesses per click.
58 * The first one was then orphaned — its ptyId never landed in
59 * the store so the frontend lost track of it, but the backend
60 * kept the subprocess alive until window-destroy.
61 *
62 * With this map, the second mount awaits the first mount's
63 * promise and reuses the same ptyId. Exactly one subprocess per
64 * session, StrictMode-safe. */
65 const spawnLocks = new Map<string, Promise<string>>();
66
67 async function getOrSpawnPty(
68 sessionId: string,
69 cwd: string,
70 claudeArgs: string[],
71 cols: number,
72 rows: number,
73 ): Promise<string> {
74 const existing = useSessionStore.getState().ptyIds.get(sessionId);
75 if (existing) return existing;
76
77 const inflight = spawnLocks.get(sessionId);
78 if (inflight) return inflight;
79
80 const promise = (async () => {
81 const newId = crypto.randomUUID();
82 trace("spawn path", { ptyId: newId, cols, rows });
83 await spawnPty({
84 ptyId: newId,
85 sessionId,
86 cwd,
87 args: claudeArgs,
88 cols,
89 rows,
90 });
91 // Register in the store before returning so any second mount
92 // awaiting the same promise can look up the ptyId via the
93 // `existing` check above on its next call.
94 useSessionStore.getState().registerPty(sessionId, {
95 ptyId: newId,
96 sessionId,
97 cwd,
98 startedAt: new Date().toISOString(),
99 });
100 trace("spawn_pty ok", { ptyId: newId });
101 return newId;
102 })();
103
104 spawnLocks.set(sessionId, promise);
105 try {
106 return await promise;
107 } finally {
108 spawnLocks.delete(sessionId);
109 }
110 }
111
112 /** Write a large base64 payload into xterm in paced chunks so we
113 * don't block the UI thread. xterm.write accepts a callback that
114 * fires once the write has been parsed and rendered, which we use
115 * to schedule the next chunk. For a fresh mount with a 200 KB
116 * ring buffer replay this keeps the main thread responsive
117 * (keystrokes still register) instead of dropping a multi-second
118 * stall while xterm parses the whole blob. */
119 const REPLAY_CHUNK_BYTES = 8 * 1024;
120
121 function writeBase64Chunked(term: Terminal, b64: string): void {
122 const binary = atob(b64);
123 const total = binary.length;
124 if (total === 0) return;
125 if (total <= REPLAY_CHUNK_BYTES) {
126 term.write(decodeBinary(binary));
127 return;
128 }
129 let offset = 0;
130 const step = () => {
131 const end = Math.min(offset + REPLAY_CHUNK_BYTES, total);
132 const slice = decodeBinary(binary.slice(offset, end));
133 offset = end;
134 if (offset >= total) {
135 term.write(slice);
136 } else {
137 term.write(slice, step);
138 }
139 };
140 step();
141 }
142
143 function decodeBinary(binary: string): Uint8Array {
144 const bytes = new Uint8Array(binary.length);
145 for (let i = 0; i < binary.length; i++) {
146 bytes[i] = binary.charCodeAt(i);
147 }
148 return bytes;
149 }
150
151 /** Theme matching the claudex design tokens in `src/index.css`. */
152 const TERMINAL_THEME = {
153 foreground: "#d4d4d8",
154 background: "#0a0a0b",
155 cursor: "#f97316",
156 cursorAccent: "#0a0a0b",
157 selectionBackground: "rgba(249, 115, 22, 0.3)",
158 black: "#1a1a1a",
159 red: "#ef4444",
160 green: "#22c55e",
161 yellow: "#eab308",
162 blue: "#3b82f6",
163 magenta: "#a855f7",
164 cyan: "#06b6d4",
165 white: "#e5e7eb",
166 brightBlack: "#4b5563",
167 brightRed: "#fca5a5",
168 brightGreen: "#86efac",
169 brightYellow: "#fef08a",
170 brightBlue: "#93c5fd",
171 brightMagenta: "#e9d5ff",
172 brightCyan: "#a5f3fc",
173 brightWhite: "#f9fafb",
174 };
175
176 export interface TerminalPaneProps {
177 /** Claudex store key for this session (may carry the
178 * `pending-` prefix for in-flight new sessions). Used as the
179 * binding key in `store.ptyIds`. */
180 sessionId: string;
181 /** Absolute working directory for the spawned claude subprocess.
182 * Must exist on disk. */
183 cwd: string;
184 /** Claude CLI args — usually `["--resume", <id>]` or
185 * `["--session-id", <uuid>]`. Baked in at mount time; respawning
186 * with different args requires remounting the component. */
187 claudeArgs: string[];
188 }
189
190 export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) {
191 const containerRef = useRef<HTMLDivElement | null>(null);
192
193 useEffect(() => {
194 const container = containerRef.current;
195 if (!container) return;
196 // Clear any stale xterm DOM left behind by a previous mount.
197 container.replaceChildren();
198
199 const term = new Terminal({
200 theme: TERMINAL_THEME,
201 fontFamily:
202 '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
203 fontSize: 13,
204 lineHeight: 1.2,
205 cursorBlink: true,
206 scrollback: 10000,
207 // Required before we can set `term.unicode.activeVersion`.
208 allowProposedApi: true,
209 });
210
211 const fit = new FitAddon();
212 term.loadAddon(fit);
213 term.loadAddon(new WebLinksAddon());
214 term.loadAddon(new Unicode11Addon());
215
216 term.open(container);
217 try {
218 term.unicode.activeVersion = "11";
219 } catch {
220 // Older xterm builds may not have unicode registered — ignore.
221 }
222
223 try {
224 fit.fit();
225 } catch {
226 // No layout yet — ResizeObserver below will retry on first
227 // real box update.
228 }
229
230 let ptyId: string | null = null;
231 let unlistenData: (() => void) | null = null;
232 let cancelled = false;
233 let disposeListeners: Array<() => void> = [];
234
235 const attach = async () => {
236 // Resolve a ptyId — either reattach to an already-running
237 // subprocess or dedupe-safely spawn a new one. The spawn
238 // lock guarantees at most one spawn per sessionId.
239 const existing = useSessionStore.getState().ptyIds.get(sessionId);
240 const reattach = !!existing;
241 let resolved: string;
242 try {
243 resolved = await getOrSpawnPty(
244 sessionId,
245 cwd,
246 claudeArgs,
247 term.cols,
248 term.rows,
249 );
250 } catch (err) {
251 if (!cancelled) {
252 term.write(
253 `\r\n\x1b[31m[claudex] spawn_pty failed: ${formatErr(err)}\x1b[0m\r\n\x1b[90m${cwd}\x1b[0m\r\n`,
254 );
255 }
256 return;
257 }
258 if (cancelled) return;
259 ptyId = resolved;
260
261 // Replay on reattach. Chunked inside `writeBase64Chunked`
262 // so a ~200 KB scrollback doesn't stall the main thread.
263 if (reattach) {
264 try {
265 const snapshot = await getPtyBuffer(resolved);
266 if (cancelled) return;
267 if (snapshot.length > 0) writeBase64Chunked(term, snapshot);
268 } catch (err) {
269 trace("reattach replay failed", { error: String(err) });
270 }
271 }
272
273 // Per-pty event listener. Only wakes up for our own bytes.
274 try {
275 const pid = resolved;
276 const un = await onPtyData(pid, (ev: PtyDataEvent) => {
277 writeBase64Chunked(term, ev.base64);
278 });
279 if (cancelled) {
280 un();
281 } else {
282 unlistenData = un;
283 }
284 } catch (err) {
285 trace("onPtyData listen failed", { error: String(err) });
286 }
287
288 const dataDispose = term.onData((data) => {
289 if (!ptyId) return;
290 void writePty(ptyId, data).catch(() => {
291 /* silent — the next keystroke will retry */
292 });
293 });
294 disposeListeners.push(() => dataDispose.dispose());
295
296 const resizeDispose = term.onResize(({ cols, rows }) => {
297 if (!ptyId) return;
298 void resizePty(ptyId, cols, rows).catch(() => {
299 /* silent — harmless if the backend dropped the pty */
300 });
301 });
302 disposeListeners.push(() => resizeDispose.dispose());
303 };
304
305 void attach();
306
307 // Keep the terminal in sync with its container's box. ResizeObserver
308 // fires whenever the parent pane grows or shrinks (window resize,
309 // splitter drag, etc.).
310 const ro = new ResizeObserver(() => {
311 try {
312 fit.fit();
313 } catch {
314 // Container has no layout — harmless to ignore.
315 }
316 });
317 ro.observe(container);
318
319 return () => {
320 cancelled = true;
321 ro.disconnect();
322 if (unlistenData) unlistenData();
323 for (const d of disposeListeners) d();
324 disposeListeners = [];
325 // Do NOT close the PTY — it must survive unmount so switching
326 // back to this session reattaches. Only closeSessionPty and
327 // the window-destroy reaper kill PTYs.
328 try {
329 term.dispose();
330 } catch {
331 /* disposed twice — harmless */
332 }
333 container.replaceChildren();
334 };
335 // eslint-disable-next-line react-hooks/exhaustive-deps
336 }, [sessionId]);
337
338 return (
339 <div className="flex h-full w-full flex-col bg-bg-0">
340 <div ref={containerRef} className="min-h-0 flex-1 overflow-hidden" />
341 </div>
342 );
343 }
344
345 function formatErr(err: unknown): string {
346 if (err instanceof Error) return err.message;
347 if (typeof err === "string") return err;
348 return JSON.stringify(err);
349 }