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
4 changes: 4 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 All @@ -54,6 +56,7 @@
"clsx": "^2.1.1",
"culori": "^4.0.2",
"date-fns": "^4.1.0",
"default-shell": "^2.2.0",
"dnd-core": "^16.0.1",
"dotenv": "^17.2.3",
"electron-router-dom": "^2.1.0",
Expand All @@ -69,6 +72,7 @@
"monaco-editor": "^0.55.1",
"nanoid": "^5.1.6",
"node-pty": "1.1.0-beta30",
"os-locale": "^6.0.2",
"react": "^19.1.1",
"react-arborist": "^3.4.3",
"react-dnd": "^16.0.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { observable } from "@trpc/server/observable";
import { db } from "main/lib/db";
import { terminalManager } from "main/lib/terminal-manager";
import { terminalManager } from "main/lib/terminal";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { getWorktreePath } from "../workspaces/utils/worktree";
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { db } from "main/lib/db";
import { terminalManager } from "main/lib/terminal-manager";
import { terminalManager } from "main/lib/terminal";
import { nanoid } from "nanoid";
import { SUPERSET_DIR_NAME, WORKTREES_DIR_NAME } from "shared/constants";
import { z } from "zod";
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { setupAgentHooks } from "./lib/agent-setup";
import { initAppState } from "./lib/app-state";
import { setupAutoUpdater } from "./lib/auto-updater";
import { initDb } from "./lib/db";
import { terminalManager } from "./lib/terminal-manager";
import { terminalManager } from "./lib/terminal";
import { MainWindow } from "./windows/main";

// Protocol scheme for deep linking
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.
* Note: Does NOT call decoder.end() - the decoder retains state for
* incomplete multi-byte UTF-8 sequences that may span multiple flushes.
*/
flush(): void {
if (this.timeout !== null) {
clearTimeout(this.timeout);
this.timeout = null;
}

if (this.buffer.length > 0) {
this.onFlush(this.buffer);
this.buffer = "";
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/**
* Dispose of the batcher, flushing any remaining data.
* Only here do we call decoder.end() to handle any trailing incomplete sequences.
*/
dispose(): void {
this.flush();

// On stream termination: flush any incomplete multi-byte sequences
const remaining = this.decoder.end();
if (remaining) {
this.onFlush(remaining);
}
}
}
140 changes: 140 additions & 0 deletions apps/desktop/src/main/lib/terminal-escape-filter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from "bun:test";
import {
containsClearScrollbackSequence,
extractContentAfterClear,
filterTerminalQueryResponses,
TerminalEscapeFilter,
} from "./terminal-escape-filter";
Expand Down Expand Up @@ -519,3 +521,141 @@ describe("TerminalEscapeFilter (stateful)", () => {
});
});
});

describe("containsClearScrollbackSequence", () => {
it("should detect ED3 sequence", () => {
expect(containsClearScrollbackSequence(`${ESC}[3J`)).toBe(true);
});

it("should detect RIS sequence", () => {
expect(containsClearScrollbackSequence(`${ESC}c`)).toBe(true);
});

it("should detect ED3 in mixed content", () => {
expect(containsClearScrollbackSequence(`before${ESC}[3Jafter`)).toBe(true);
});

it("should detect RIS in mixed content", () => {
expect(containsClearScrollbackSequence(`before${ESC}cafter`)).toBe(true);
});

it("should return false for no clear sequence", () => {
expect(containsClearScrollbackSequence("normal text")).toBe(false);
});

it("should return false for other escape sequences", () => {
expect(containsClearScrollbackSequence(`${ESC}[2J`)).toBe(false); // Clear screen (not scrollback)
expect(containsClearScrollbackSequence(`${ESC}[H`)).toBe(false); // Cursor home
});
});

describe("extractContentAfterClear", () => {
describe("ED3 sequence handling", () => {
it("should return empty string for ED3 only", () => {
expect(extractContentAfterClear(`${ESC}[3J`)).toBe("");
});

it("should return content after ED3", () => {
expect(extractContentAfterClear(`${ESC}[3Jnew content`)).toBe(
"new content",
);
});

it("should drop content before ED3", () => {
expect(extractContentAfterClear(`old stuff${ESC}[3Jnew content`)).toBe(
"new content",
);
});

it("should handle ED3 at end of data", () => {
expect(extractContentAfterClear(`some content${ESC}[3J`)).toBe("");
});

it("should handle multiple ED3 sequences - use last one", () => {
expect(extractContentAfterClear(`a${ESC}[3Jb${ESC}[3Jc`)).toBe("c");
});
});

describe("RIS sequence handling", () => {
it("should return empty string for RIS only", () => {
expect(extractContentAfterClear(`${ESC}c`)).toBe("");
});

it("should return content after RIS", () => {
expect(extractContentAfterClear(`${ESC}cnew content`)).toBe(
"new content",
);
});

it("should drop content before RIS", () => {
expect(extractContentAfterClear(`old stuff${ESC}cnew content`)).toBe(
"new content",
);
});

it("should handle RIS at end of data", () => {
expect(extractContentAfterClear(`some content${ESC}c`)).toBe("");
});

it("should handle multiple RIS sequences - use last one", () => {
expect(extractContentAfterClear(`a${ESC}cb${ESC}cc`)).toBe("c");
});
});

describe("mixed ED3 and RIS sequences", () => {
it("should use last sequence when RIS comes after ED3", () => {
expect(extractContentAfterClear(`a${ESC}[3Jb${ESC}cc`)).toBe("c");
});

it("should use last sequence when ED3 comes after RIS", () => {
expect(extractContentAfterClear(`a${ESC}cb${ESC}[3Jc`)).toBe("c");
});

it("should handle complex mixed sequences", () => {
expect(
extractContentAfterClear(
`first${ESC}[3Jsecond${ESC}cthird${ESC}[3Jfinal`,
),
).toBe("final");
});
});

describe("no clear sequence", () => {
it("should return original data when no clear sequence", () => {
expect(extractContentAfterClear("normal text")).toBe("normal text");
});

it("should return original data with other escape sequences", () => {
const data = `${ESC}[32mgreen${ESC}[0m`;
expect(extractContentAfterClear(data)).toBe(data);
});

it("should return empty string for empty input", () => {
expect(extractContentAfterClear("")).toBe("");
});
});

describe("edge cases", () => {
it("should handle unicode content after clear", () => {
expect(extractContentAfterClear(`old${ESC}[3J日本語🎉`)).toBe("日本語🎉");
});

it("should handle newlines after clear", () => {
expect(extractContentAfterClear(`old${ESC}[3J\nnew\nlines`)).toBe(
"\nnew\nlines",
);
});

it("should handle ANSI colors after clear", () => {
const result = extractContentAfterClear(
`old${ESC}[3J${ESC}[32mgreen${ESC}[0m`,
);
expect(result).toBe(`${ESC}[32mgreen${ESC}[0m`);
});

it("should not confuse similar sequences", () => {
// ESC[3 (without J) is not a clear sequence
expect(extractContentAfterClear(`${ESC}[3mtext`)).toBe(`${ESC}[3mtext`);
});
});
});
24 changes: 24 additions & 0 deletions apps/desktop/src/main/lib/terminal-escape-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,27 @@ export const patterns = FILTER_PATTERNS;
export function containsClearScrollbackSequence(data: string): boolean {
return CLEAR_SCROLLBACK_PATTERN.test(data);
}

const ED3_SEQUENCE = `${ESC}[3J`;
const RIS_SEQUENCE = `${ESC}c`;

/**
* Extracts content after the last clear scrollback sequence.
* When a clear sequence is detected, only the content AFTER the last
* clear sequence should be persisted to scrollback/history.
*/
export function extractContentAfterClear(data: string): string {
const ed3Index = data.lastIndexOf(ED3_SEQUENCE);
const risIndex = data.lastIndexOf(RIS_SEQUENCE);

const ed3End = ed3Index !== -1 ? ed3Index + ED3_SEQUENCE.length : -1;
const risEnd = risIndex !== -1 ? risIndex + RIS_SEQUENCE.length : -1;

const cutPoint = Math.max(ed3End, risEnd);

if (cutPoint <= 0) {
return data;
}

return data.slice(cutPoint);
}
Loading
Loading