diff --git a/apps/code/scripts/postinstall.sh b/apps/code/scripts/postinstall.sh index 7716d0610..b620ad8cd 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 +# 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" + 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/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 31f6546f3..d381263d3 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; @@ -162,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); @@ -197,6 +202,9 @@ class TerminalManagerImpl { term, fitAddon: fit, serializeAddon: serialize, + webglAddon: null, + writeBuffer: "", + flushHandle: null, attachedElement: null, terminalElement: null, isReady: false, @@ -297,10 +305,35 @@ 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 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(() => { + 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 { @@ -401,6 +434,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 slower under heavy output. + private loadWebglRenderer(instance: TerminalInstance): void { + if (!this.useWebgl || 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 +478,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 +520,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 +551,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(); } @@ -553,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) { 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: