Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 2 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@
"@trpc/react-query": "^11.7.1",
"@trpc/server": "^11.7.1",
"@types/express": "^5.0.5",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-image": "^0.8.0",
"@xterm/addon-ligatures": "^0.9.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/addon-unicode11": "^0.8.0",
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/lib/agent-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,13 @@ _superset_home="\${SUPERSET_ORIG_ZDOTDIR:-$HOME}"
`;
fs.writeFileSync(zprofilePath, zprofileScript, { mode: 0o644 });

// Create .zshrc to source user's .zshrc then prepend our bin
// Create .zshrc - reset ZDOTDIR before sourcing so Oh My Zsh works correctly
const zshrcPath = path.join(ZSH_DIR, ".zshrc");
const zshrcScript = `# Superset zsh rc wrapper
_superset_home="\${SUPERSET_ORIG_ZDOTDIR:-$HOME}"
export ZDOTDIR="$_superset_home"
[[ -f "$_superset_home/.zshrc" ]] && source "$_superset_home/.zshrc"
export PATH="$HOME/${SUPERSET_DIR_NAME}/bin:$PATH"
export ZDOTDIR="$_superset_home"
`;
fs.writeFileSync(zshrcPath, zshrcScript, { mode: 0o644 });
console.log("[agent-setup] Created zsh wrapper");
Expand Down
84 changes: 84 additions & 0 deletions apps/desktop/src/main/lib/data-batcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { StringDecoder } from "node:string_decoder";

/**
* Batches terminal data to reduce IPC overhead between main and renderer processes.
* Based on Hyper terminal's DataBatcher implementation.
*
* This minimizes the number of IPC messages by:
* 1. Time-based batching: Flushes every BATCH_DURATION_MS (16ms = ~60fps)
* 2. Size-based batching: Flushes when batch exceeds BATCH_MAX_SIZE (200KB)
* 3. Proper UTF-8 handling: Uses StringDecoder to handle multi-byte characters
* that may be split across data chunks
*/

// Batch timing - 16ms provides smooth 60fps updates
const BATCH_DURATION_MS = 16;

// Maximum batch size before forcing a flush (200KB)
const BATCH_MAX_SIZE = 200 * 1024;

export class DataBatcher {
private decoder: StringDecoder;
private buffer: string = "";
private timeout: ReturnType<typeof setTimeout> | null = null;
private onFlush: (data: string) => void;

constructor(onFlush: (data: string) => void) {
this.decoder = new StringDecoder("utf8");
this.onFlush = onFlush;
}

/**
* Add data to the batch. Data will be flushed either when:
* - BATCH_DURATION_MS has elapsed since the first write
* - Buffer size exceeds BATCH_MAX_SIZE
*/
write(data: Buffer | string): void {
// Decode buffer data to handle multi-byte UTF-8 characters correctly
const decoded = typeof data === "string" ? data : this.decoder.write(data);
this.buffer += decoded;

// If buffer is getting large, flush immediately
if (this.buffer.length >= BATCH_MAX_SIZE) {
this.flush();
return;
}

// Schedule flush if not already scheduled
if (this.timeout === null) {
this.timeout = setTimeout(() => this.flush(), BATCH_DURATION_MS);
}
}

/**
* Flush any buffered data immediately.
* Called automatically by timer or when buffer is full.
*/
flush(): void {
if (this.timeout !== null) {
clearTimeout(this.timeout);
this.timeout = null;
}

// Flush any remaining bytes from the decoder
const remaining = this.decoder.end();
if (remaining) {
this.buffer += remaining;
}

if (this.buffer.length > 0) {
this.onFlush(this.buffer);
this.buffer = "";
}

// Reset decoder for next batch
this.decoder = new StringDecoder("utf8");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Dispose of the batcher, flushing any remaining data.
*/
dispose(): void {
this.flush();
}
}
22 changes: 19 additions & 3 deletions apps/desktop/src/main/lib/terminal-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import os from "node:os";
import * as pty from "node-pty";
import { PORTS } from "shared/constants";
import { getShellArgs, getShellEnv } from "./agent-setup";
import { DataBatcher } from "./data-batcher";
import {
containsClearScrollbackSequence,
TerminalEscapeFilter,
Expand All @@ -23,6 +24,7 @@ interface TerminalSession {
wasRecovered: boolean;
historyWriter?: HistoryWriter;
escapeFilter: TerminalEscapeFilter;
dataBatcher: DataBatcher;
}

export interface TerminalDataEvent {
Expand Down Expand Up @@ -159,6 +161,10 @@ export class TerminalManager extends EventEmitter {
const env = {
...baseEnv,
...shellEnv,
TERM_PROGRAM: "Superset",
TERM_PROGRAM_VERSION: process.env.npm_package_version || "1.0.0",
COLORTERM: "truecolor",
LANG: baseEnv.LANG || "en_US.UTF-8",
SUPERSET_PANE_ID: paneId,
SUPERSET_TAB_ID: tabId,
SUPERSET_WORKSPACE_ID: workspaceId,
Expand Down Expand Up @@ -210,6 +216,12 @@ export class TerminalManager extends EventEmitter {
);
await historyWriter.init(recoveredScrollback || undefined);

// Create data batcher to reduce IPC overhead
// Batches data and emits at ~60fps for smooth rendering
const dataBatcher = new DataBatcher((batchedData) => {
this.emit(`data:${paneId}`, batchedData);
});

const session: TerminalSession = {
pty: ptyProcess,
paneId,
Expand All @@ -223,6 +235,7 @@ export class TerminalManager extends EventEmitter {
wasRecovered,
historyWriter,
escapeFilter: new TerminalEscapeFilter(),
dataBatcher,
};

// Send initial commands after first shell output (shell is ready)
Expand All @@ -241,8 +254,9 @@ export class TerminalManager extends EventEmitter {
const filteredData = session.escapeFilter.filter(data);
session.scrollback += filteredData;
session.historyWriter?.write(filteredData);
// Emit ORIGINAL data to xterm - it needs to process query responses
this.emit(`data:${paneId}`, data);

// Batch data for IPC efficiency - emits at ~60fps
session.dataBatcher.write(data);

if (shouldRunCommands && !commandsSent) {
commandsSent = true;
Expand All @@ -258,7 +272,9 @@ export class TerminalManager extends EventEmitter {
ptyProcess.onExit(async ({ exitCode, signal }) => {
session.isAlive = false;

// Flush any buffered data from the escape filter
// Flush any buffered data from batcher and escape filter
session.dataBatcher.dispose();

const remaining = session.escapeFilter.flush();
if (remaining) {
session.scrollback += remaining;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,36 @@ import type { ITerminalOptions } from "@xterm/xterm";

// Use user's theme
export const TERMINAL_THEME: ITerminalOptions["theme"] = undefined;

// Nerd Fonts first for shell theme compatibility (Oh My Posh, Powerlevel10k, etc.)
const TERMINAL_FONT_FAMILY = [
"MesloLGM Nerd Font",
"MesloLGM NF",
"MesloLGS NF",
"MesloLGS Nerd Font",
"Hack Nerd Font",
"FiraCode Nerd Font",
"JetBrainsMono Nerd Font",
"CaskaydiaCove Nerd Font",
"Menlo",
"Monaco",
'"Courier New"',
"monospace",
].join(", ");

export const TERMINAL_OPTIONS: ITerminalOptions = {
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontFamily: TERMINAL_FONT_FAMILY,
theme: TERMINAL_THEME,
allowProposedApi: true,
scrollback: 10000,
macOptionIsMeta: true,
cursorStyle: "block",
cursorInactiveStyle: "outline",
fastScrollModifier: "alt",
fastScrollSensitivity: 5,
screenReaderMode: false,
};

export const RESIZE_DEBOUNCE_MS = 150;
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { CanvasAddon } from "@xterm/addon-canvas";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import type { ITheme } from "@xterm/xterm";
import { Terminal as XTerm } from "@xterm/xterm";
import { debounce } from "lodash";
Expand Down Expand Up @@ -49,6 +52,42 @@ export function getDefaultTerminalBg(): string {
return getDefaultTerminalTheme().background ?? "#1a1a1a";
}

/**
* Load GPU-accelerated renderer with automatic fallback.
* Tries WebGL first, falls back to Canvas if WebGL fails.
*/
function loadRenderer(xterm: XTerm): { dispose: () => void } {
let renderer: WebglAddon | CanvasAddon | null = null;

try {
const webglAddon = new WebglAddon();

webglAddon.onContextLoss(() => {
webglAddon.dispose();
try {
renderer = new CanvasAddon();
xterm.loadAddon(renderer);
} catch {
// Canvas fallback failed, use default renderer
}
});

xterm.loadAddon(webglAddon);
renderer = webglAddon;
} catch {
try {
renderer = new CanvasAddon();
xterm.loadAddon(renderer);
} catch {
// Both renderers failed, use default
}
}

return {
dispose: () => renderer?.dispose(),
};
}

export function createTerminalInstance(
container: HTMLDivElement,
cwd?: string,
Expand Down Expand Up @@ -76,15 +115,28 @@ export function createTerminalInstance(
});

const clipboardAddon = new ClipboardAddon();

const unicode11Addon = new Unicode11Addon();
const imageAddon = new ImageAddon();

xterm.open(container);

xterm.loadAddon(fitAddon);
const renderer = loadRenderer(xterm);

xterm.loadAddon(webLinksAddon);
xterm.loadAddon(clipboardAddon);
xterm.loadAddon(unicode11Addon);
xterm.loadAddon(imageAddon);

import("@xterm/addon-ligatures")
.then(({ LigaturesAddon }) => {
try {
xterm.loadAddon(new LigaturesAddon());
} catch {
// Ligatures not supported by current font
}
})
.catch(() => {});

const cleanupQuerySuppression = suppressQueryResponses(xterm);

Expand Down Expand Up @@ -115,7 +167,10 @@ export function createTerminalInstance(
return {
xterm,
fitAddon,
cleanup: cleanupQuerySuppression,
cleanup: () => {
cleanupQuerySuppression();
renderer.dispose();
},
};
}

Expand Down
Loading