Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/code/scripts/postinstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 26 additions & 3 deletions packages/ui/src/features/settings/sections/TerminalSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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,
Expand Down Expand Up @@ -54,14 +58,22 @@ 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 (
<Flex direction="column" gap="1" py="4">
<SettingRow
label="Font"
description="Font used to render the terminal output"
noBorder={!showCustomInput}
>
<Select.Root
value={terminalFont}
Expand All @@ -82,7 +94,6 @@ export function TerminalSettings() {
<SettingRow
label="Custom font family"
description="Any CSS font-family value. Example: Fira Code, Cascadia Code"
noBorder
>
<Flex direction="column" align="end" gap="1">
<TextField.Root
Expand All @@ -98,6 +109,18 @@ export function TerminalSettings() {
</Flex>
</SettingRow>
)}

<SettingRow
label="GPU rendering"
description="Render the terminal with WebGL for smoother output under heavy load. Disable if you hit graphical glitches."
noBorder
>
<Switch
checked={terminalGpuRendering}
onCheckedChange={handleGpuRenderingChange}
size="1"
/>
</SettingRow>
</Flex>
);
}
6 changes: 6 additions & 0 deletions packages/ui/src/features/settings/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -224,9 +226,12 @@ export const useSettingsStore = create<SettingsStore>()(
// 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,
Expand Down Expand Up @@ -307,6 +312,7 @@ export const useSettingsStore = create<SettingsStore>()(
// Terminal
terminalFont: state.terminalFont,
terminalCustomFontFamily: state.terminalCustomFontFamily,
terminalGpuRendering: state.terminalGpuRendering,

Comment on lines 312 to 316
// Experimental / misc
hedgehogMode: state.hedgehogMode,
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/features/terminal/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function Terminal({
const terminalCustomFontFamily = useSettingsStore(
(s) => s.terminalCustomFontFamily,
);
const terminalGpuRendering = useSettingsStore((s) => s.terminalGpuRendering);

// Create instance (idempotent)
useEffect(() => {
Expand Down Expand Up @@ -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;
Expand Down
98 changes: 95 additions & 3 deletions packages/ui/src/features/terminal/TerminalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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;
Expand Down Expand Up @@ -162,6 +166,7 @@ class TerminalManagerImpl {
private listeners = new Map<EventType, Set<Listener<EventType>>>();
private isDarkMode = true;
private fontFamily: string = DEFAULT_TERMINAL_FONT_FAMILY;
private useWebgl = true;

has(sessionId: string): boolean {
return this.instances.has(sessionId);
Expand Down Expand Up @@ -197,6 +202,9 @@ class TerminalManagerImpl {
term,
fitAddon: fit,
serializeAddon: serialize,
webglAddon: null,
writeBuffer: "",
flushHandle: null,
attachedElement: null,
terminalElement: null,
isReady: false,
Expand Down Expand Up @@ -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;
Comment on lines +312 to +316
if (instance.flushHandle === null) {
instance.flushHandle = requestAnimationFrame(() => {
instance.flushHandle = null;
this.flushWrite(sessionId, instance);
});
Comment on lines +317 to +321
}
}

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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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<T extends EventType>(event: T, listener: Listener<T>): () => void {
let listeners = this.listeners.get(event);
if (!listeners) {
Expand Down
19 changes: 16 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading