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: