From 4b7cc3b956d2b00ca5d8da77f49f981e6f6d858b Mon Sep 17 00:00:00 2001 From: George Munyoro Date: Mon, 15 Jun 2026 16:34:09 +0200 Subject: [PATCH 1/3] perf(terminal): use WebGL renderer and batch writes per frame The terminal ran on xterm's default DOM renderer and wrote every pty chunk synchronously, so heavy output (build logs, large files) pinned the renderer. - Load the WebGL addon after term.open(), with an onContextLoss handler that disposes it so xterm falls back to the DOM renderer instead of rendering nothing, and a try/catch fallback when WebGL is unavailable. - Coalesce pty output into one term.write() per animation frame; flush the buffer before serializing on detach and cancel the pending frame on destroy. Also restore the execute bit on node-pty's spawn-helper in the Electron postinstall: pnpm extracts the prebuilt binary as 0644, which makes posix_spawnp fail ("posix_spawnp failed") the first time a terminal session opens after a fresh install. --- apps/code/scripts/postinstall.sh | 11 +++ packages/ui/package.json | 1 + .../src/features/terminal/TerminalManager.ts | 75 ++++++++++++++++++- pnpm-lock.yaml | 19 ++++- 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/apps/code/scripts/postinstall.sh b/apps/code/scripts/postinstall.sh index 7716d0610..d98f91f75 100755 --- a/apps/code/scripts/postinstall.sh +++ b/apps/code/scripts/postinstall.sh @@ -26,6 +26,17 @@ echo "Rebuilding native modules for Electron..." cd "$REPO_ROOT" node scripts/rebuild-better-sqlite3-electron.mjs +# Restore the execute bit on node-pty's spawn-helper. pnpm extracts node-pty's +# prebuilt binaries without preserving the executable mode, so the helper lands +# as 0644 and posix_spawnp fails at runtime with "posix_spawnp failed" the first +# time a terminal session is opened. Re-mark every prebuilt helper executable. +for helper in "$REPO_ROOT"/node_modules/node-pty/prebuilds/*/spawn-helper; do + if [ -f "$helper" ] && [ ! -x "$helper" ]; then + echo "Restoring execute bit on $helper" + chmod +x "$helper" + fi +done + echo "Patching Electron app name..." bash "$SCRIPTS_DIR/patch-electron-name.sh" diff --git a/packages/ui/package.json b/packages/ui/package.json index 975f28edd..d977fdf4f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -80,6 +80,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-serialize": "^0.13.0", "@xterm/addon-web-links": "^0.11.0", + "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "canvas-confetti": "^1.9.4", "clsx": "^2.1.1", diff --git a/packages/ui/src/features/terminal/TerminalManager.ts b/packages/ui/src/features/terminal/TerminalManager.ts index 31f6546f3..dc55044d0 100644 --- a/packages/ui/src/features/terminal/TerminalManager.ts +++ b/packages/ui/src/features/terminal/TerminalManager.ts @@ -10,6 +10,7 @@ import { isMac } from "@posthog/ui/utils/platform"; import { FitAddon } from "@xterm/addon-fit"; import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; +import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal as XTerm } from "@xterm/xterm"; const log = logger.scope("terminal-manager"); @@ -35,6 +36,9 @@ export interface TerminalInstance { term: XTerm; fitAddon: FitAddon; serializeAddon: SerializeAddon; + webglAddon: WebglAddon | null; + writeBuffer: string; + flushHandle: number | null; attachedElement: HTMLElement | null; terminalElement: HTMLElement | null; isReady: boolean; @@ -197,6 +201,9 @@ class TerminalManagerImpl { term, fitAddon: fit, serializeAddon: serialize, + webglAddon: null, + writeBuffer: "", + flushHandle: null, attachedElement: null, terminalElement: null, isReady: false, @@ -297,12 +304,37 @@ class TerminalManagerImpl { writeData(sessionId: string, data: string): void { const instance = this.instances.get(sessionId); - if (instance) { - instance.term.write(data); - this.scheduleSave(sessionId, instance); + if (!instance) { + return; + } + + // Coalesce bursts of pty output into a single term.write() per animation + // frame. A heavy stream (build logs, cat-ing a file) otherwise produces one + // synchronous write — and DOM reflow — per IPC chunk, which pins the + // renderer. + instance.writeBuffer += data; + if (instance.flushHandle === null) { + instance.flushHandle = requestAnimationFrame(() => { + instance.flushHandle = null; + this.flushWrite(sessionId, instance); + }); } } + private flushWrite(sessionId: string, instance: TerminalInstance): void { + if (instance.flushHandle !== null) { + cancelAnimationFrame(instance.flushHandle); + instance.flushHandle = null; + } + if (instance.writeBuffer.length === 0) { + return; + } + const data = instance.writeBuffer; + instance.writeBuffer = ""; + instance.term.write(data); + this.scheduleSave(sessionId, instance); + } + handleExit(sessionId: string, exitCode?: number): void { const instance = this.instances.get(sessionId); if (instance) { @@ -401,6 +433,31 @@ class TerminalManagerImpl { }, 500); } + // The WebGL renderer must be loaded after term.open() — it needs the canvas + // the terminal creates on attach. Without it xterm falls back to its DOM + // renderer, which is dramatically slower under heavy output. + private loadWebglRenderer(instance: TerminalInstance): void { + if (instance.webglAddon) { + return; + } + try { + const webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => { + // GPU context lost (e.g. driver reset). Drop the addon so xterm falls + // back to the DOM renderer rather than rendering nothing. + webglAddon.dispose(); + instance.webglAddon = null; + }); + instance.term.loadAddon(webglAddon); + instance.webglAddon = webglAddon; + } catch (error) { + log.warn( + "WebGL renderer unavailable, using DOM renderer instead:", + error, + ); + } + } + attach(sessionId: string, element: HTMLElement): void { const instance = this.instances.get(sessionId); if (!instance) { @@ -420,6 +477,7 @@ class TerminalManagerImpl { instance.term.open(element); instance.hasOpened = true; instance.terminalElement = element.querySelector(".xterm") as HTMLElement; + this.loadWebglRenderer(instance); } else if (instance.terminalElement) { element.appendChild(instance.terminalElement); instance.term.refresh(0, instance.term.rows - 1); @@ -461,6 +519,9 @@ class TerminalManagerImpl { this.disconnectResizeObserver(instance); + // Drain buffered output so the serialized snapshot reflects the latest data. + this.flushWrite(sessionId, instance); + const serialized = instance.serializeAddon.serialize(); this.emit("stateChange", { sessionId, @@ -489,6 +550,14 @@ class TerminalManagerImpl { clearTimeout(instance.saveTimeout); } + if (instance.flushHandle !== null) { + cancelAnimationFrame(instance.flushHandle); + instance.flushHandle = null; + } + + instance.webglAddon?.dispose(); + instance.webglAddon = null; + for (const cleanup of instance.cleanups) { cleanup(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0d37e505..d428b64a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1258,6 +1258,9 @@ importers: '@xterm/addon-web-links': specifier: ^0.11.0 version: 0.11.0(@xterm/xterm@5.5.0) + '@xterm/addon-webgl': + specifier: ^0.18.0 + version: 0.18.0(@xterm/xterm@5.5.0) '@xterm/xterm': specifier: ^5.5.0 version: 5.5.0 @@ -6727,6 +6730,11 @@ packages: peerDependencies: '@xterm/xterm': ^5.0.0 + '@xterm/addon-webgl@0.18.0': + resolution: {integrity: sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + '@xterm/xterm@5.5.0': resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} @@ -12583,6 +12591,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -19584,6 +19593,10 @@ snapshots: dependencies: '@xterm/xterm': 5.5.0 + '@xterm/addon-webgl@0.18.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + '@xterm/xterm@5.5.0': {} '@xtuc/ieee754@1.2.0': {} @@ -21765,7 +21778,7 @@ snapshots: fs-minipass@3.0.3: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 fs-temp@1.2.1: dependencies: @@ -21921,7 +21934,7 @@ snapshots: foreground-child: 3.3.1 jackspeak: 3.4.3 minimatch: 9.0.5 - minipass: 7.1.2 + minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -23787,7 +23800,7 @@ snapshots: minipass-collect@2.0.1: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 minipass-fetch@2.1.2: dependencies: From 9778cd35ab72963ae1cdcd25bc57ecf897b015a0 Mon Sep 17 00:00:00 2001 From: George Munyoro Date: Mon, 15 Jun 2026 17:20:25 +0200 Subject: [PATCH 2/3] feat(terminal): add setting to toggle GPU (WebGL) rendering Adds a "GPU rendering" toggle to terminal settings (default on). When disabled, TerminalManager disposes the WebGL addon so xterm falls back to its DOM renderer; toggling applies live to open terminals. Persisted via the settings store and synced into TerminalManager like theme and font. Generated-By: PostHog Code Task-Id: 177e4a61-ddc5-440a-8f9b-81d043f41335 --- .../settings/sections/TerminalSettings.tsx | 29 +++++++++++++++++-- .../ui/src/features/settings/settingsStore.ts | 6 ++++ .../ui/src/features/terminal/Terminal.tsx | 6 ++++ .../src/features/terminal/TerminalManager.ts | 25 +++++++++++++++- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/features/settings/sections/TerminalSettings.tsx b/packages/ui/src/features/settings/sections/TerminalSettings.tsx index aed39c074..fb39d955d 100644 --- a/packages/ui/src/features/settings/sections/TerminalSettings.tsx +++ b/packages/ui/src/features/settings/sections/TerminalSettings.tsx @@ -6,7 +6,7 @@ import { } from "@posthog/ui/features/settings/settingsStore"; import { useDebounce } from "@posthog/ui/primitives/hooks/useDebounce"; import { track } from "@posthog/ui/shell/analytics"; -import { Flex, Select, Text, TextField } from "@radix-ui/themes"; +import { Flex, Select, Switch, Text, TextField } from "@radix-ui/themes"; import { useEffect, useState } from "react"; export function TerminalSettings() { @@ -18,6 +18,10 @@ export function TerminalSettings() { const setTerminalCustomFontFamily = useSettingsStore( (s) => s.setTerminalCustomFontFamily, ); + const terminalGpuRendering = useSettingsStore((s) => s.terminalGpuRendering); + const setTerminalGpuRendering = useSettingsStore( + (s) => s.setTerminalGpuRendering, + ); const [draftCustomFont, setDraftCustomFont] = useState( terminalCustomFontFamily, @@ -54,6 +58,15 @@ export function TerminalSettings() { setTerminalFont(value); }; + const handleGpuRenderingChange = (enabled: boolean) => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "terminal_gpu_rendering", + new_value: enabled, + old_value: terminalGpuRendering, + }); + setTerminalGpuRendering(enabled); + }; + const showCustomInput = terminalFont === "custom"; return ( @@ -61,7 +74,6 @@ export function TerminalSettings() { )} + + + + ); } diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index 4e2e496e5..d07f56a3c 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -116,8 +116,10 @@ interface SettingsStore { // Terminal terminalFont: TerminalFont; terminalCustomFontFamily: string; + terminalGpuRendering: boolean; setTerminalFont: (font: TerminalFont) => void; setTerminalCustomFontFamily: (value: string) => void; + setTerminalGpuRendering: (enabled: boolean) => void; // Experimental / misc hedgehogMode: boolean; @@ -224,9 +226,12 @@ export const useSettingsStore = create()( // Terminal terminalFont: "berkeley-mono", terminalCustomFontFamily: "", + terminalGpuRendering: true, setTerminalFont: (font) => set({ terminalFont: font }), setTerminalCustomFontFamily: (value) => set({ terminalCustomFontFamily: value }), + setTerminalGpuRendering: (enabled) => + set({ terminalGpuRendering: enabled }), // Experimental / misc hedgehogMode: false, @@ -307,6 +312,7 @@ export const useSettingsStore = create()( // Terminal terminalFont: state.terminalFont, terminalCustomFontFamily: state.terminalCustomFontFamily, + terminalGpuRendering: state.terminalGpuRendering, // Experimental / misc hedgehogMode: state.hedgehogMode, diff --git a/packages/ui/src/features/terminal/Terminal.tsx b/packages/ui/src/features/terminal/Terminal.tsx index a898a2277..01f2d665f 100644 --- a/packages/ui/src/features/terminal/Terminal.tsx +++ b/packages/ui/src/features/terminal/Terminal.tsx @@ -40,6 +40,7 @@ export function Terminal({ const terminalCustomFontFamily = useSettingsStore( (s) => s.terminalCustomFontFamily, ); + const terminalGpuRendering = useSettingsStore((s) => s.terminalGpuRendering); // Create instance (idempotent) useEffect(() => { @@ -79,6 +80,11 @@ export function Terminal({ ); }, [terminalFont, terminalCustomFontFamily]); + // GPU rendering sync + useEffect(() => { + terminalManager.setUseWebgl(terminalGpuRendering); + }, [terminalGpuRendering]); + // Subscribe to shell data + exit events via the host shell client. useEffect(() => { if (!sessionId) return; diff --git a/packages/ui/src/features/terminal/TerminalManager.ts b/packages/ui/src/features/terminal/TerminalManager.ts index dc55044d0..e08f1f386 100644 --- a/packages/ui/src/features/terminal/TerminalManager.ts +++ b/packages/ui/src/features/terminal/TerminalManager.ts @@ -166,6 +166,7 @@ class TerminalManagerImpl { private listeners = new Map>>(); private isDarkMode = true; private fontFamily: string = DEFAULT_TERMINAL_FONT_FAMILY; + private useWebgl = true; has(sessionId: string): boolean { return this.instances.has(sessionId); @@ -437,7 +438,7 @@ class TerminalManagerImpl { // the terminal creates on attach. Without it xterm falls back to its DOM // renderer, which is dramatically slower under heavy output. private loadWebglRenderer(instance: TerminalInstance): void { - if (instance.webglAddon) { + if (!this.useWebgl || instance.webglAddon) { return; } try { @@ -622,6 +623,28 @@ class TerminalManagerImpl { } } + setUseWebgl(enabled: boolean): void { + if (this.useWebgl === enabled) { + return; + } + + this.useWebgl = enabled; + + for (const instance of this.instances.values()) { + if (enabled) { + // Only opened terminals have the canvas WebGL needs; the rest pick it + // up the first time they attach. + if (instance.hasOpened) { + this.loadWebglRenderer(instance); + } + } else if (instance.webglAddon) { + // Disposing the addon makes xterm fall back to its DOM renderer. + instance.webglAddon.dispose(); + instance.webglAddon = null; + } + } + } + on(event: T, listener: Listener): () => void { let listeners = this.listeners.get(event); if (!listeners) { From f18f915a3c53bf63a020156a74d373e03b1c1199 Mon Sep 17 00:00:00 2001 From: George Munyoro Date: Mon, 15 Jun 2026 18:18:25 +0200 Subject: [PATCH 3/3] docs(terminal): correct write-batching and renderer comments Fix the writeData comment, which claimed term.write() was synchronous and reflowed per IPC chunk; xterm buffers writes and RAF-debounces rendering, so the batching actually trims per-call parse/write-buffer overhead. Also soften "dramatically slower" and an unverified 0644 mode claim in postinstall. Generated-By: PostHog Code Task-Id: 177e4a61-ddc5-440a-8f9b-81d043f41335 --- apps/code/scripts/postinstall.sh | 4 ++-- packages/ui/src/features/terminal/TerminalManager.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/code/scripts/postinstall.sh b/apps/code/scripts/postinstall.sh index d98f91f75..b620ad8cd 100755 --- a/apps/code/scripts/postinstall.sh +++ b/apps/code/scripts/postinstall.sh @@ -28,8 +28,8 @@ node scripts/rebuild-better-sqlite3-electron.mjs # Restore the execute bit on node-pty's spawn-helper. pnpm extracts node-pty's # prebuilt binaries without preserving the executable mode, so the helper lands -# as 0644 and posix_spawnp fails at runtime with "posix_spawnp failed" the first -# time a terminal session is opened. Re-mark every prebuilt helper executable. +# without +x and posix_spawnp fails at runtime with "posix_spawnp failed" the +# first time a terminal session is opened. Re-mark every prebuilt helper executable. for helper in "$REPO_ROOT"/node_modules/node-pty/prebuilds/*/spawn-helper; do if [ -f "$helper" ] && [ ! -x "$helper" ]; then echo "Restoring execute bit on $helper" diff --git a/packages/ui/src/features/terminal/TerminalManager.ts b/packages/ui/src/features/terminal/TerminalManager.ts index e08f1f386..d381263d3 100644 --- a/packages/ui/src/features/terminal/TerminalManager.ts +++ b/packages/ui/src/features/terminal/TerminalManager.ts @@ -310,9 +310,9 @@ class TerminalManagerImpl { } // Coalesce bursts of pty output into a single term.write() per animation - // frame. A heavy stream (build logs, cat-ing a file) otherwise produces one - // synchronous write — and DOM reflow — per IPC chunk, which pins the - // renderer. + // frame instead of one call per IPC chunk, cutting the per-call parse and + // write-buffer overhead that piles up on the main thread under a heavy + // stream (build logs, cat-ing a file). instance.writeBuffer += data; if (instance.flushHandle === null) { instance.flushHandle = requestAnimationFrame(() => { @@ -436,7 +436,7 @@ class TerminalManagerImpl { // The WebGL renderer must be loaded after term.open() — it needs the canvas // the terminal creates on attach. Without it xterm falls back to its DOM - // renderer, which is dramatically slower under heavy output. + // renderer, which is slower under heavy output. private loadWebglRenderer(instance: TerminalInstance): void { if (!this.useWebgl || instance.webglAddon) { return;