@@ -34,9 +34,16 @@ import { useSessionStore } from "@/lib/store/sessions"; |
| 34 | 34 | import { FitAddon } from "@xterm/addon-fit"; |
| 35 | 35 | import { Unicode11Addon } from "@xterm/addon-unicode11"; |
| 36 | 36 | import { WebLinksAddon } from "@xterm/addon-web-links"; |
| 37 | | -import { WebglAddon } from "@xterm/addon-webgl"; |
| 38 | 37 | import { Terminal } from "@xterm/xterm"; |
| 39 | 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 | + |
| 40 | 47 | const trace = (msg: string, extra?: Record<string, unknown>) => { |
| 41 | 48 | const payload = extra ? `${msg} ${JSON.stringify(extra)}` : msg; |
| 42 | 49 | // eslint-disable-next-line no-console |
@@ -185,21 +192,11 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 185 | 192 | |
| 186 | 193 | useEffect(() => { |
| 187 | 194 | const container = containerRef.current; |
| 188 | | - if (!container) { |
| 189 | | - trace("mount aborted: no container ref"); |
| 190 | | - return; |
| 191 | | - } |
| 192 | | - trace("mount begin", { sessionId, cwd, claudeArgs }); |
| 193 | | - |
| 194 | | - // React 19 StrictMode double-invokes effects (mount → cleanup → |
| 195 | | - // mount). If a previous effect already appended xterm DOM to |
| 196 | | - // this container, wipe it before opening a fresh Terminal so |
| 197 | | - // we don't stack renderer layers and confuse layout math. |
| 195 | + if (!container) return; |
| 196 | + // Clear any stale xterm DOM left behind by a previous mount. |
| 198 | 197 | container.replaceChildren(); |
| 199 | 198 | |
| 200 | | - let term: Terminal; |
| 201 | | - try { |
| 202 | | - term = new Terminal({ |
| 199 | + const term = new Terminal({ |
| 203 | 200 | theme: TERMINAL_THEME, |
| 204 | 201 | fontFamily: |
| 205 | 202 | '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', |
@@ -210,51 +207,24 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 210 | 207 | // Required before we can set `term.unicode.activeVersion`. |
| 211 | 208 | allowProposedApi: true, |
| 212 | 209 | }); |
| 213 | | - } catch (err) { |
| 214 | | - trace("new Terminal threw", { error: String(err) }); |
| 215 | | - throw err; |
| 216 | | - } |
| 217 | 210 | |
| 218 | 211 | const fit = new FitAddon(); |
| 219 | 212 | term.loadAddon(fit); |
| 220 | 213 | term.loadAddon(new WebLinksAddon()); |
| 221 | 214 | term.loadAddon(new Unicode11Addon()); |
| 222 | 215 | |
| 223 | | - try { |
| 224 | 216 | term.open(container); |
| 225 | | - trace("term.open ok", { cols: term.cols, rows: term.rows }); |
| 226 | | - } catch (err) { |
| 227 | | - trace("term.open threw", { error: String(err) }); |
| 228 | | - throw err; |
| 229 | | - } |
| 230 | | - |
| 231 | | - // Set activeVersion AFTER open() — the unicode subsystem walks |
| 232 | | - // through `term.unicode` which requires the core to be mounted. |
| 233 | | - // Swallow errors so a bad unicode activation never crashes the |
| 234 | | - // whole pane. |
| 235 | 217 | try { |
| 236 | 218 | term.unicode.activeVersion = "11"; |
| 237 | | - } catch (err) { |
| 238 | | - trace("unicode activeVersion failed", { error: String(err) }); |
| 239 | | - } |
| 240 | | - |
| 241 | | - // WebGL must be loaded AFTER open(). If WebGL init throws (e.g. |
| 242 | | - // headless / no GPU context), swallow it — xterm falls back to |
| 243 | | - // canvas rendering automatically. |
| 244 | | - try { |
| 245 | | - term.loadAddon(new WebglAddon()); |
| 246 | | - trace("webgl addon loaded"); |
| 247 | | - } catch (err) { |
| 248 | | - trace("webgl addon failed, using canvas fallback", { error: String(err) }); |
| 219 | + } catch { |
| 220 | + // Older xterm builds may not have unicode registered — ignore. |
| 249 | 221 | } |
| 250 | 222 | |
| 251 | 223 | try { |
| 252 | 224 | fit.fit(); |
| 253 | | - trace("fit.fit ok", { cols: term.cols, rows: term.rows }); |
| 254 | | - } catch (err) { |
| 255 | | - trace("fit.fit failed (will retry via ResizeObserver)", { |
| 256 | | - error: String(err), |
| 257 | | - }); |
| 225 | + } catch { |
| 226 | + // No layout yet — ResizeObserver below will retry on first |
| 227 | + // real box update. |
| 258 | 228 | } |
| 259 | 229 | |
| 260 | 230 | let ptyId: string | null = null; |
@@ -265,8 +235,7 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 265 | 235 | const attach = async () => { |
| 266 | 236 | // Resolve a ptyId — either reattach to an already-running |
| 267 | 237 | // subprocess or dedupe-safely spawn a new one. The spawn |
| 268 | | - // lock guarantees at most one spawn per sessionId even under |
| 269 | | - // React 19 StrictMode's double-mount. |
| 238 | + // lock guarantees at most one spawn per sessionId. |
| 270 | 239 | const existing = useSessionStore.getState().ptyIds.get(sessionId); |
| 271 | 240 | const reattach = !!existing; |
| 272 | 241 | let resolved: string; |
@@ -279,10 +248,9 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 279 | 248 | term.rows, |
| 280 | 249 | ); |
| 281 | 250 | } catch (err) { |
| 282 | | - trace("spawn_pty failed", { error: String(err) }); |
| 283 | 251 | if (!cancelled) { |
| 284 | 252 | term.write( |
| 285 | | - `\r\n\x1b[31m[claudex] spawn_pty failed: ${formatErr(err)}\x1b[0m\r\n`, |
| 253 | + `\r\n\x1b[31m[claudex] spawn_pty failed: ${formatErr(err)}\x1b[0m\r\n\x1b[90m${cwd}\x1b[0m\r\n`, |
| 286 | 254 | ); |
| 287 | 255 | } |
| 288 | 256 | return; |
@@ -290,25 +258,19 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 290 | 258 | if (cancelled) return; |
| 291 | 259 | ptyId = resolved; |
| 292 | 260 | |
| 293 | | - // On reattach, replay the ring buffer into xterm in paced |
| 294 | | - // chunks so a 200 KB scrollback doesn't stall the main |
| 295 | | - // thread for multiple seconds. |
| 261 | + // Replay on reattach. Chunked inside `writeBase64Chunked` |
| 262 | + // so a ~200 KB scrollback doesn't stall the main thread. |
| 296 | 263 | if (reattach) { |
| 297 | | - trace("reattach path", { ptyId: resolved }); |
| 298 | 264 | try { |
| 299 | 265 | const snapshot = await getPtyBuffer(resolved); |
| 300 | 266 | if (cancelled) return; |
| 301 | 267 | if (snapshot.length > 0) writeBase64Chunked(term, snapshot); |
| 302 | | - trace("ring buffer replayed", { bytes: snapshot.length }); |
| 303 | 268 | } catch (err) { |
| 304 | | - trace("get_pty_buffer failed during reattach", { |
| 305 | | - error: String(err), |
| 306 | | - }); |
| 269 | + trace("reattach replay failed", { error: String(err) }); |
| 307 | 270 | } |
| 308 | 271 | } |
| 309 | 272 | |
| 310 | | - // Listen for stdout — per-pty event name, so this listener |
| 311 | | - // only wakes up for our own terminal's bytes. |
| 273 | + // Per-pty event listener. Only wakes up for our own bytes. |
| 312 | 274 | try { |
| 313 | 275 | const pid = resolved; |
| 314 | 276 | const un = await onPtyData(pid, (ev: PtyDataEvent) => { |
@@ -323,22 +285,18 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 323 | 285 | trace("onPtyData listen failed", { error: String(err) }); |
| 324 | 286 | } |
| 325 | 287 | |
| 326 | | - // Wire keystrokes → PTY writer. |
| 327 | 288 | const dataDispose = term.onData((data) => { |
| 328 | 289 | if (!ptyId) return; |
| 329 | | - void writePty(ptyId, data).catch((err) => { |
| 330 | | - trace("write_pty failed", { error: String(err) }); |
| 290 | + void writePty(ptyId, data).catch(() => { |
| 291 | + /* silent — the next keystroke will retry */ |
| 331 | 292 | }); |
| 332 | 293 | }); |
| 333 | 294 | disposeListeners.push(() => dataDispose.dispose()); |
| 334 | 295 | |
| 335 | | - // Propagate xterm resize events (triggered by fit.fit() or |
| 336 | | - // container ResizeObserver) down to the PTY master so |
| 337 | | - // claude's TUI redraws properly. |
| 338 | 296 | const resizeDispose = term.onResize(({ cols, rows }) => { |
| 339 | 297 | if (!ptyId) return; |
| 340 | | - void resizePty(ptyId, cols, rows).catch((err) => { |
| 341 | | - trace("resize_pty failed", { error: String(err) }); |
| 298 | + void resizePty(ptyId, cols, rows).catch(() => { |
| 299 | + /* silent — harmless if the backend dropped the pty */ |
| 342 | 300 | }); |
| 343 | 301 | }); |
| 344 | 302 | disposeListeners.push(() => resizeDispose.dispose()); |
@@ -359,28 +317,21 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 359 | 317 | ro.observe(container); |
| 360 | 318 | |
| 361 | 319 | return () => { |
| 362 | | - trace("unmount", { sessionId, ptyId }); |
| 363 | 320 | cancelled = true; |
| 364 | 321 | ro.disconnect(); |
| 365 | 322 | if (unlistenData) unlistenData(); |
| 366 | 323 | for (const d of disposeListeners) d(); |
| 367 | 324 | disposeListeners = []; |
| 368 | | - // NOTE: intentionally NOT calling closePty — the PTY must |
| 369 | | - // survive this unmount so reattaching later still works. The |
| 370 | | - // only teardown paths are `store.closeSessionPty` and the |
| 371 | | - // window-destroy reaper in Rust. |
| 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. |
| 372 | 328 | try { |
| 373 | 329 | term.dispose(); |
| 374 | | - } catch (err) { |
| 375 | | - trace("term.dispose threw", { error: String(err) }); |
| 330 | + } catch { |
| 331 | + /* disposed twice — harmless */ |
| 376 | 332 | } |
| 377 | | - // Clear container so a re-mount on the same ref (React 19 |
| 378 | | - // StrictMode) starts from a clean slate. |
| 379 | 333 | container.replaceChildren(); |
| 380 | 334 | }; |
| 381 | | - // Mount-once per sessionId; claudeArgs and cwd are baked in |
| 382 | | - // at spawn time and don't change while mounted. Remount via |
| 383 | | - // `key={sessionId}` if the caller needs a fresh terminal. |
| 384 | 335 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 385 | 336 | }, [sessionId]); |
| 386 | 337 | |