@@ -22,7 +22,6 @@ import { useEffect, useRef } from "react"; |
| 22 | 22 | |
| 23 | 23 | import { |
| 24 | 24 | getPtyBuffer, |
| 25 | | - logFrontend, |
| 26 | 25 | onPtyData, |
| 27 | 26 | resizePty, |
| 28 | 27 | spawnPty, |
@@ -34,21 +33,18 @@ import { useSessionStore } from "@/lib/store/sessions"; |
| 34 | 33 | import { FitAddon } from "@xterm/addon-fit"; |
| 35 | 34 | import { Unicode11Addon } from "@xterm/addon-unicode11"; |
| 36 | 35 | import { WebLinksAddon } from "@xterm/addon-web-links"; |
| 36 | +import { WebglAddon } from "@xterm/addon-webgl"; |
| 37 | 37 | import { Terminal } from "@xterm/xterm"; |
| 38 | 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 | | - |
| 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. |
| 47 | 43 | 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); |
| 44 | + if (import.meta.env.DEV) { |
| 45 | + // eslint-disable-next-line no-console |
| 46 | + console.debug("[TerminalPane]", msg, extra ?? ""); |
| 47 | + } |
| 52 | 48 | }; |
| 53 | 49 | |
| 54 | 50 | /** Module-level dedup lock for concurrent spawn attempts on the |
@@ -217,14 +213,30 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 217 | 213 | try { |
| 218 | 214 | term.unicode.activeVersion = "11"; |
| 219 | 215 | } catch { |
| 220 | | - // Older xterm builds may not have unicode registered — ignore. |
| 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 | + }); |
| 221 | 234 | } |
| 222 | 235 | |
| 223 | 236 | try { |
| 224 | 237 | fit.fit(); |
| 225 | 238 | } catch { |
| 226 | | - // No layout yet — ResizeObserver below will retry on first |
| 227 | | - // real box update. |
| 239 | + /* no layout yet — ResizeObserver below will retry */ |
| 228 | 240 | } |
| 229 | 241 | |
| 230 | 242 | let ptyId: string | null = null; |
@@ -304,21 +316,30 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) |
| 304 | 316 | |
| 305 | 317 | void attach(); |
| 306 | 318 | |
| 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.). |
| 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; |
| 310 | 326 | const ro = new ResizeObserver(() => { |
| 311 | | - try { |
| 312 | | - fit.fit(); |
| 313 | | - } catch { |
| 314 | | - // Container has no layout — harmless to ignore. |
| 315 | | - } |
| 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 | + }); |
| 316 | 336 | }); |
| 317 | 337 | ro.observe(container); |
| 318 | 338 | |
| 319 | 339 | return () => { |
| 320 | 340 | cancelled = true; |
| 321 | 341 | ro.disconnect(); |
| 342 | + if (rafHandle !== null) cancelAnimationFrame(rafHandle); |
| 322 | 343 | if (unlistenData) unlistenData(); |
| 323 | 344 | for (const d of disposeListeners) d(); |
| 324 | 345 | disposeListeners = []; |