| 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 | } |