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