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
13 changes: 6 additions & 7 deletions apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ export const createTerminalRouter = () => {
return {
paneId,
isNew: result.isNew,
serializedState: result.serializedState,
scrollback: result.scrollback,
wasRecovered: result.wasRecovered,
};
}),

Expand Down Expand Up @@ -137,6 +138,7 @@ export const createTerminalRouter = () => {
.input(
z.object({
paneId: z.string(),
deleteHistory: z.boolean().optional(),
}),
)
.mutation(async ({ input }) => {
Expand All @@ -145,14 +147,11 @@ export const createTerminalRouter = () => {

/**
* Detach from terminal (keep session alive)
* Accepts serialized terminal state from renderer's SerializeAddon
* to enable clean reattachment without escape sequence issues
*/
detach: publicProcedure
.input(
z.object({
paneId: z.string(),
serializedState: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
Expand All @@ -161,16 +160,16 @@ export const createTerminalRouter = () => {

/**
* Clear scrollback buffer for terminal (used by Cmd+K / clear command)
* This clears in-memory scrollback and serialized state
* This clears both in-memory scrollback and persistent history file
*/
clearScrollback: publicProcedure
.input(
z.object({
paneId: z.string(),
}),
)
.mutation(({ input }) => {
terminalManager.clearScrollback(input);
.mutation(async ({ input }) => {
await terminalManager.clearScrollback(input);
}),

getSession: publicProcedure
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 = "";
}
}

/**
* 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);
}
}
}
139 changes: 139 additions & 0 deletions apps/desktop/src/main/lib/terminal-escape-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, it } from "bun:test";
import {
containsClearScrollbackSequence,
extractContentAfterClear,
} from "./terminal-escape-filter";

const ESC = "\x1b";

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

it("should NOT detect RIS sequence (used by TUI apps for repaints)", () => {
expect(containsClearScrollbackSequence(`${ESC}c`)).toBe(false);
});

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

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

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
expect(containsClearScrollbackSequence(`${ESC}c`)).toBe(false); // RIS (used by TUI apps)
});
});

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 (should NOT clear)", () => {
it("should NOT treat RIS as clear sequence - return original data", () => {
expect(extractContentAfterClear(`${ESC}c`)).toBe(`${ESC}c`);
});

it("should preserve RIS and surrounding content", () => {
expect(extractContentAfterClear(`${ESC}cnew content`)).toBe(
`${ESC}cnew content`,
);
});

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

describe("mixed ED3 and RIS sequences", () => {
it("should only use ED3 even when RIS comes after", () => {
// RIS is ignored, only ED3 matters
expect(extractContentAfterClear(`a${ESC}[3Jb${ESC}cc`)).toBe(`b${ESC}cc`);
});

it("should use ED3 and preserve RIS before it", () => {
expect(extractContentAfterClear(`a${ESC}cb${ESC}[3Jc`)).toBe("c");
});

it("should handle multiple ED3 sequences - use last one", () => {
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`);
});
});
});
46 changes: 46 additions & 0 deletions apps/desktop/src/main/lib/terminal-escape-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Utilities for detecting terminal clear scrollback sequences.
*/

const ESC = "\x1b";

/**
* Pattern to detect clear scrollback sequences:
* - ESC [ 3 J - Clear scrollback buffer (ED3)
*
* Note: We intentionally do NOT include ESC c (RIS - Reset to Initial State)
* because TUI applications (vim, htop, etc.) commonly use RIS for screen
* repaints/refreshes. Only ED3 is a deliberate "clear scrollback" action
* triggered by commands like `clear` or Cmd+K.
*/
const CLEAR_SCROLLBACK_PATTERN = new RegExp(`${ESC}\\[3J`);

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

/**
* Checks if data contains sequences that clear the scrollback buffer.
* Used to detect when the shell sends clear commands (e.g., from `clear` command or Cmd+K).
*
* Detected sequences:
* - ESC [ 3 J - Clear scrollback buffer (ED3)
*
* Note: ESC c (RIS) is intentionally not detected as TUI apps use it for repaints.
*/
export function containsClearScrollbackSequence(data: string): boolean {
return CLEAR_SCROLLBACK_PATTERN.test(data);
}

/**
* 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);

if (ed3Index === -1) {
return data;
}

return data.slice(ed3Index + ED3_SEQUENCE.length);
}
Loading
Loading