tenseleyflow/claudex / 71832a5

Browse files

perf: drop webgl addon + trim per-mount tracing

Authored by espadonne
SHA
71832a5b6bf24bea95cbd2c5e1d5c15788dd9bce
Parents
d88a74d
Tree
3da696c

1 changed file

StatusFile+-
M src/components/TerminalPane.tsx 42 91
src/components/TerminalPane.tsxmodified
@@ -34,9 +34,16 @@ import { useSessionStore } from "@/lib/store/sessions";
3434
 import { FitAddon } from "@xterm/addon-fit";
3535
 import { Unicode11Addon } from "@xterm/addon-unicode11";
3636
 import { WebLinksAddon } from "@xterm/addon-web-links";
37
-import { WebglAddon } from "@xterm/addon-webgl";
3837
 import { Terminal } from "@xterm/xterm";
3938
 
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
+
4047
 const trace = (msg: string, extra?: Record<string, unknown>) => {
4148
   const payload = extra ? `${msg} ${JSON.stringify(extra)}` : msg;
4249
   // eslint-disable-next-line no-console
@@ -185,21 +192,11 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
185192
 
186193
   useEffect(() => {
187194
     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.
198197
     container.replaceChildren();
199198
 
200
-    let term: Terminal;
201
-    try {
202
-      term = new Terminal({
199
+    const term = new Terminal({
203200
       theme: TERMINAL_THEME,
204201
       fontFamily:
205202
         '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
@@ -210,51 +207,24 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
210207
       // Required before we can set `term.unicode.activeVersion`.
211208
       allowProposedApi: true,
212209
     });
213
-    } catch (err) {
214
-      trace("new Terminal threw", { error: String(err) });
215
-      throw err;
216
-    }
217210
 
218211
     const fit = new FitAddon();
219212
     term.loadAddon(fit);
220213
     term.loadAddon(new WebLinksAddon());
221214
     term.loadAddon(new Unicode11Addon());
222215
 
223
-    try {
224216
     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.
235217
     try {
236218
       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.
249221
     }
250222
 
251223
     try {
252224
       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.
258228
     }
259229
 
260230
     let ptyId: string | null = null;
@@ -265,8 +235,7 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
265235
     const attach = async () => {
266236
       // Resolve a ptyId — either reattach to an already-running
267237
       // 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.
270239
       const existing = useSessionStore.getState().ptyIds.get(sessionId);
271240
       const reattach = !!existing;
272241
       let resolved: string;
@@ -279,10 +248,9 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
279248
           term.rows,
280249
         );
281250
       } catch (err) {
282
-        trace("spawn_pty failed", { error: String(err) });
283251
         if (!cancelled) {
284252
           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`,
286254
           );
287255
         }
288256
         return;
@@ -290,25 +258,19 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
290258
       if (cancelled) return;
291259
       ptyId = resolved;
292260
 
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.
296263
       if (reattach) {
297
-        trace("reattach path", { ptyId: resolved });
298264
         try {
299265
           const snapshot = await getPtyBuffer(resolved);
300266
           if (cancelled) return;
301267
           if (snapshot.length > 0) writeBase64Chunked(term, snapshot);
302
-          trace("ring buffer replayed", { bytes: snapshot.length });
303268
         } catch (err) {
304
-          trace("get_pty_buffer failed during reattach", {
305
-            error: String(err),
306
-          });
269
+          trace("reattach replay failed", { error: String(err) });
307270
         }
308271
       }
309272
 
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.
312274
       try {
313275
         const pid = resolved;
314276
         const un = await onPtyData(pid, (ev: PtyDataEvent) => {
@@ -323,22 +285,18 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
323285
         trace("onPtyData listen failed", { error: String(err) });
324286
       }
325287
 
326
-      // Wire keystrokes → PTY writer.
327288
       const dataDispose = term.onData((data) => {
328289
         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 */
331292
         });
332293
       });
333294
       disposeListeners.push(() => dataDispose.dispose());
334295
 
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.
338296
       const resizeDispose = term.onResize(({ cols, rows }) => {
339297
         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 */
342300
         });
343301
       });
344302
       disposeListeners.push(() => resizeDispose.dispose());
@@ -359,28 +317,21 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
359317
     ro.observe(container);
360318
 
361319
     return () => {
362
-      trace("unmount", { sessionId, ptyId });
363320
       cancelled = true;
364321
       ro.disconnect();
365322
       if (unlistenData) unlistenData();
366323
       for (const d of disposeListeners) d();
367324
       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.
372328
       try {
373329
         term.dispose();
374
-      } catch (err) {
375
-        trace("term.dispose threw", { error: String(err) });
330
+      } catch {
331
+        /* disposed twice — harmless */
376332
       }
377
-      // Clear container so a re-mount on the same ref (React 19
378
-      // StrictMode) starts from a clean slate.
379333
       container.replaceChildren();
380334
     };
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.
384335
     // eslint-disable-next-line react-hooks/exhaustive-deps
385336
   }, [sessionId]);
386337