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";
34
 import { FitAddon } from "@xterm/addon-fit";
34
 import { FitAddon } from "@xterm/addon-fit";
35
 import { Unicode11Addon } from "@xterm/addon-unicode11";
35
 import { Unicode11Addon } from "@xterm/addon-unicode11";
36
 import { WebLinksAddon } from "@xterm/addon-web-links";
36
 import { WebLinksAddon } from "@xterm/addon-web-links";
37
-import { WebglAddon } from "@xterm/addon-webgl";
38
 import { Terminal } from "@xterm/xterm";
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
 const trace = (msg: string, extra?: Record<string, unknown>) => {
47
 const trace = (msg: string, extra?: Record<string, unknown>) => {
41
   const payload = extra ? `${msg} ${JSON.stringify(extra)}` : msg;
48
   const payload = extra ? `${msg} ${JSON.stringify(extra)}` : msg;
42
   // eslint-disable-next-line no-console
49
   // eslint-disable-next-line no-console
@@ -185,76 +192,39 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
185
 
192
 
186
   useEffect(() => {
193
   useEffect(() => {
187
     const container = containerRef.current;
194
     const container = containerRef.current;
188
-    if (!container) {
195
+    if (!container) return;
189
-      trace("mount aborted: no container ref");
196
+    // Clear any stale xterm DOM left behind by a previous mount.
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.
198
     container.replaceChildren();
197
     container.replaceChildren();
199
 
198
 
200
-    let term: Terminal;
199
+    const term = new Terminal({
201
-    try {
200
+      theme: TERMINAL_THEME,
202
-      term = new Terminal({
201
+      fontFamily:
203
-        theme: TERMINAL_THEME,
202
+        '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
204
-        fontFamily:
203
+      fontSize: 13,
205
-          '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
204
+      lineHeight: 1.2,
206
-        fontSize: 13,
205
+      cursorBlink: true,
207
-        lineHeight: 1.2,
206
+      scrollback: 10000,
208
-        cursorBlink: true,
207
+      // Required before we can set `term.unicode.activeVersion`.
209
-        scrollback: 10000,
208
+      allowProposedApi: true,
210
-        // Required before we can set `term.unicode.activeVersion`.
209
+    });
211
-        allowProposedApi: true,
212
-      });
213
-    } catch (err) {
214
-      trace("new Terminal threw", { error: String(err) });
215
-      throw err;
216
-    }
217
 
210
 
218
     const fit = new FitAddon();
211
     const fit = new FitAddon();
219
     term.loadAddon(fit);
212
     term.loadAddon(fit);
220
     term.loadAddon(new WebLinksAddon());
213
     term.loadAddon(new WebLinksAddon());
221
     term.loadAddon(new Unicode11Addon());
214
     term.loadAddon(new Unicode11Addon());
222
 
215
 
223
-    try {
216
+    term.open(container);
224
-      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
     try {
217
     try {
236
       term.unicode.activeVersion = "11";
218
       term.unicode.activeVersion = "11";
237
-    } catch (err) {
219
+    } catch {
238
-      trace("unicode activeVersion failed", { error: String(err) });
220
+      // Older xterm builds may not have unicode registered — ignore.
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) });
249
     }
221
     }
250
 
222
 
251
     try {
223
     try {
252
       fit.fit();
224
       fit.fit();
253
-      trace("fit.fit ok", { cols: term.cols, rows: term.rows });
225
+    } catch {
254
-    } catch (err) {
226
+      // No layout yet — ResizeObserver below will retry on first
255
-      trace("fit.fit failed (will retry via ResizeObserver)", {
227
+      // real box update.
256
-        error: String(err),
257
-      });
258
     }
228
     }
259
 
229
 
260
     let ptyId: string | null = null;
230
     let ptyId: string | null = null;
@@ -265,8 +235,7 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
265
     const attach = async () => {
235
     const attach = async () => {
266
       // Resolve a ptyId — either reattach to an already-running
236
       // Resolve a ptyId — either reattach to an already-running
267
       // subprocess or dedupe-safely spawn a new one. The spawn
237
       // subprocess or dedupe-safely spawn a new one. The spawn
268
-      // lock guarantees at most one spawn per sessionId even under
238
+      // lock guarantees at most one spawn per sessionId.
269
-      // React 19 StrictMode's double-mount.
270
       const existing = useSessionStore.getState().ptyIds.get(sessionId);
239
       const existing = useSessionStore.getState().ptyIds.get(sessionId);
271
       const reattach = !!existing;
240
       const reattach = !!existing;
272
       let resolved: string;
241
       let resolved: string;
@@ -279,10 +248,9 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
279
           term.rows,
248
           term.rows,
280
         );
249
         );
281
       } catch (err) {
250
       } catch (err) {
282
-        trace("spawn_pty failed", { error: String(err) });
283
         if (!cancelled) {
251
         if (!cancelled) {
284
           term.write(
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
         return;
256
         return;
@@ -290,25 +258,19 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
290
       if (cancelled) return;
258
       if (cancelled) return;
291
       ptyId = resolved;
259
       ptyId = resolved;
292
 
260
 
293
-      // On reattach, replay the ring buffer into xterm in paced
261
+      // Replay on reattach. Chunked inside `writeBase64Chunked`
294
-      // chunks so a 200 KB scrollback doesn't stall the main
262
+      // so a ~200 KB scrollback doesn't stall the main thread.
295
-      // thread for multiple seconds.
296
       if (reattach) {
263
       if (reattach) {
297
-        trace("reattach path", { ptyId: resolved });
298
         try {
264
         try {
299
           const snapshot = await getPtyBuffer(resolved);
265
           const snapshot = await getPtyBuffer(resolved);
300
           if (cancelled) return;
266
           if (cancelled) return;
301
           if (snapshot.length > 0) writeBase64Chunked(term, snapshot);
267
           if (snapshot.length > 0) writeBase64Chunked(term, snapshot);
302
-          trace("ring buffer replayed", { bytes: snapshot.length });
303
         } catch (err) {
268
         } catch (err) {
304
-          trace("get_pty_buffer failed during reattach", {
269
+          trace("reattach replay failed", { error: String(err) });
305
-            error: String(err),
306
-          });
307
         }
270
         }
308
       }
271
       }
309
 
272
 
310
-      // Listen for stdout — per-pty event name, so this listener
273
+      // Per-pty event listener. Only wakes up for our own bytes.
311
-      // only wakes up for our own terminal's bytes.
312
       try {
274
       try {
313
         const pid = resolved;
275
         const pid = resolved;
314
         const un = await onPtyData(pid, (ev: PtyDataEvent) => {
276
         const un = await onPtyData(pid, (ev: PtyDataEvent) => {
@@ -323,22 +285,18 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
323
         trace("onPtyData listen failed", { error: String(err) });
285
         trace("onPtyData listen failed", { error: String(err) });
324
       }
286
       }
325
 
287
 
326
-      // Wire keystrokes → PTY writer.
327
       const dataDispose = term.onData((data) => {
288
       const dataDispose = term.onData((data) => {
328
         if (!ptyId) return;
289
         if (!ptyId) return;
329
-        void writePty(ptyId, data).catch((err) => {
290
+        void writePty(ptyId, data).catch(() => {
330
-          trace("write_pty failed", { error: String(err) });
291
+          /* silent — the next keystroke will retry */
331
         });
292
         });
332
       });
293
       });
333
       disposeListeners.push(() => dataDispose.dispose());
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
       const resizeDispose = term.onResize(({ cols, rows }) => {
296
       const resizeDispose = term.onResize(({ cols, rows }) => {
339
         if (!ptyId) return;
297
         if (!ptyId) return;
340
-        void resizePty(ptyId, cols, rows).catch((err) => {
298
+        void resizePty(ptyId, cols, rows).catch(() => {
341
-          trace("resize_pty failed", { error: String(err) });
299
+          /* silent — harmless if the backend dropped the pty */
342
         });
300
         });
343
       });
301
       });
344
       disposeListeners.push(() => resizeDispose.dispose());
302
       disposeListeners.push(() => resizeDispose.dispose());
@@ -359,28 +317,21 @@ export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps)
359
     ro.observe(container);
317
     ro.observe(container);
360
 
318
 
361
     return () => {
319
     return () => {
362
-      trace("unmount", { sessionId, ptyId });
363
       cancelled = true;
320
       cancelled = true;
364
       ro.disconnect();
321
       ro.disconnect();
365
       if (unlistenData) unlistenData();
322
       if (unlistenData) unlistenData();
366
       for (const d of disposeListeners) d();
323
       for (const d of disposeListeners) d();
367
       disposeListeners = [];
324
       disposeListeners = [];
368
-      // NOTE: intentionally NOT calling closePty — the PTY must
325
+      // Do NOT close the PTY — it must survive unmount so switching
369
-      // survive this unmount so reattaching later still works. The
326
+      // back to this session reattaches. Only closeSessionPty and
370
-      // only teardown paths are `store.closeSessionPty` and the
327
+      // the window-destroy reaper kill PTYs.
371
-      // window-destroy reaper in Rust.
372
       try {
328
       try {
373
         term.dispose();
329
         term.dispose();
374
-      } catch (err) {
330
+      } catch {
375
-        trace("term.dispose threw", { error: String(err) });
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
       container.replaceChildren();
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
     // eslint-disable-next-line react-hooks/exhaustive-deps
335
     // eslint-disable-next-line react-hooks/exhaustive-deps
385
   }, [sessionId]);
336
   }, [sessionId]);
386
 
337