diff --git a/apps/desktop/src/main/lib/fast-escape-filter.test.ts b/apps/desktop/src/main/lib/fast-escape-filter.test.ts new file mode 100644 index 000000000..5597d79e4 --- /dev/null +++ b/apps/desktop/src/main/lib/fast-escape-filter.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "bun:test"; +import { FastEscapeFilter } from "./fast-escape-filter"; + +const ESC = "\x1b"; +const BEL = "\x07"; + +describe("FastEscapeFilter", () => { + describe("Cursor Position Report (CPR)", () => { + it("should filter ESC[row;colR", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}[24;1Rworld`)).toBe("helloworld"); + }); + + it("should filter ESC[rowR (single number)", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}[2Rworld`)).toBe("helloworld"); + }); + + it("should filter multiple CPRs", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`a${ESC}[1;1Rb${ESC}[99;99Rc`)).toBe("abc"); + }); + }); + + describe("Primary Device Attributes (DA1)", () => { + it("should filter ESC[?...c", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}[?1;0cworld`)).toBe("helloworld"); + }); + + it("should filter ESC[?c (minimal)", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}[?cworld`)).toBe("helloworld"); + }); + }); + + describe("Secondary Device Attributes (DA2)", () => { + it("should filter ESC[>...c", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}[>0;276;0cworld`)).toBe("helloworld"); + }); + }); + + describe("Device Attributes (no prefix)", () => { + it("should filter ESC[digits;...c", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}[0;276;0cworld`)).toBe("helloworld"); + }); + }); + + describe("Mode Reports", () => { + it("should filter ESC[?...;...$y (DECRPM)", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}[?1;2$yworld`)).toBe("helloworld"); + }); + + it("should filter ESC[...;...$y (standard mode)", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}[12;2$yworld`)).toBe("helloworld"); + }); + }); + + describe("OSC Color Responses", () => { + it("should filter OSC 10 with BEL terminator", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}]10;rgb:ff/ff/ff${BEL}world`)).toBe( + "helloworld", + ); + }); + + it("should filter OSC 11 with ST terminator", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}]11;rgb:00/00/00${ESC}\\world`)).toBe( + "helloworld", + ); + }); + + it("should filter OSC 19", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}]19;rgb:ab/cd/ef${BEL}world`)).toBe( + "helloworld", + ); + }); + }); + + describe("DCS Sequences", () => { + it("should filter DA3 (ESC P ! | ... ESC \\)", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}P!|test${ESC}\\world`)).toBe( + "helloworld", + ); + }); + + it("should filter XTVERSION (ESC P > | ... ESC \\)", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}P>|xterm(123)${ESC}\\world`)).toBe( + "helloworld", + ); + }); + }); + + describe("Unknown CSI", () => { + it("should filter ESC[O", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter(`hello${ESC}[Oworld`)).toBe("helloworld"); + }); + }); + + describe("Preserves normal sequences", () => { + it("should preserve color codes", () => { + const filter = new FastEscapeFilter(); + const colored = `${ESC}[32mgreen${ESC}[0m`; + expect(filter.filter(colored)).toBe(colored); + }); + + it("should preserve cursor movement", () => { + const filter = new FastEscapeFilter(); + const cursor = `${ESC}[H${ESC}[2J`; + expect(filter.filter(cursor)).toBe(cursor); + }); + + it("should preserve clear scrollback (ESC[3J)", () => { + const filter = new FastEscapeFilter(); + const clear = `${ESC}[3J`; + expect(filter.filter(clear)).toBe(clear); + }); + + it("should preserve reset (ESC c)", () => { + const filter = new FastEscapeFilter(); + const reset = `${ESC}c`; + expect(filter.filter(reset)).toBe(reset); + }); + }); + + describe("Chunked sequences", () => { + it("should handle CPR split across chunks", () => { + const filter = new FastEscapeFilter(); + const result1 = filter.filter(`hello${ESC}[24`); + const result2 = filter.filter(";1Rworld"); + expect(result1 + result2).toBe("helloworld"); + }); + + it("should handle DA1 split across chunks", () => { + const filter = new FastEscapeFilter(); + const result1 = filter.filter(`hello${ESC}[?1;`); + const result2 = filter.filter("0cworld"); + expect(result1 + result2).toBe("helloworld"); + }); + }); + + describe("flush", () => { + it("should return buffered incomplete sequence", () => { + const filter = new FastEscapeFilter(); + filter.filter(`hello${ESC}[24`); + expect(filter.flush()).toBe(`${ESC}[24`); + }); + + it("should return empty string when no buffered data", () => { + const filter = new FastEscapeFilter(); + filter.filter("hello"); + expect(filter.flush()).toBe(""); + }); + }); + + describe("plain text passthrough", () => { + it("should pass through plain text unchanged", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter("hello world")).toBe("hello world"); + }); + + it("should handle empty string", () => { + const filter = new FastEscapeFilter(); + expect(filter.filter("")).toBe(""); + }); + + it("should handle large plain text", () => { + const filter = new FastEscapeFilter(); + const large = "x".repeat(100000); + expect(filter.filter(large)).toBe(large); + }); + }); +}); diff --git a/apps/desktop/src/main/lib/fast-escape-filter.ts b/apps/desktop/src/main/lib/fast-escape-filter.ts new file mode 100644 index 000000000..a1c9d1675 --- /dev/null +++ b/apps/desktop/src/main/lib/fast-escape-filter.ts @@ -0,0 +1,333 @@ +/** + * High-performance single-pass escape sequence filter. + * + * This replaces the regex-based TerminalEscapeFilter with a state machine + * that processes data in a single O(n) pass with minimal allocations. + * + * Filters terminal query responses that appear as garbage when stored in scrollback: + * - CPR (Cursor Position Report): ESC[row;colR + * - DA1 (Primary Device Attributes): ESC[?...c + * - DA2 (Secondary Device Attributes): ESC[>...c + * - DA (no prefix): ESC[digits;...c + * - DA3 (Tertiary Device Attributes): ESC P ! | ... ESC \ + * - DECRPM (DEC Private Mode Report): ESC[?...;...$y + * - Standard Mode Report: ESC[...;...$y + * - OSC Color Responses: ESC]1digit;rgb:... BEL or ESC\ + * - XTVERSION: ESC P > | ... ESC \ + * - Unknown CSI: ESC[O + */ + +const ESC = 0x1b; // \x1b +const BEL = 0x07; // \x07 +const BACKSLASH = 0x5c; // \ + +// Character codes for fast comparison +const BRACKET = 0x5b; // [ +const RBRACKET = 0x5d; // ] +const QUESTION = 0x3f; // ? +const GREATER = 0x3e; // > +const DOLLAR = 0x24; // $ +const SEMICOLON = 0x3b; // ; +const EXCLAIM = 0x21; // ! +const PIPE = 0x7c; // | +const P = 0x50; // P + +// Final bytes for CSI sequences +const CHAR_R = 0x52; // R (CPR) +const CHAR_c = 0x63; // c (Device Attributes) +const CHAR_y = 0x79; // y (Mode Report) +const CHAR_O = 0x4f; // O (Unknown) + +// State machine states +enum State { + Normal, + Escape, // Saw ESC + CSI, // Saw ESC [ + CSIQuery, // Saw ESC [ ? + CSIGreater, // Saw ESC [ > + CSIDigits, // Saw ESC [ digit + CSIDollar, // Saw ESC [ ... $ + OSC, // Saw ESC ] + OSC1, // Saw ESC ] 1 + OSC1x, // Saw ESC ] 1 digit + OSCBody, // In OSC body, waiting for BEL or ESC \ + DCS, // Saw ESC P + DCSExclaim, // Saw ESC P ! + DCSGreater, // Saw ESC P > + DCSBody, // In DCS body, waiting for ESC \ + DCSEscape, // Saw ESC in DCS body + OSCEscape, // Saw ESC in OSC body +} + +function isDigit(c: number): boolean { + return c >= 0x30 && c <= 0x39; // 0-9 +} + +/** + * Fast single-pass escape filter using a state machine. + * Maintains buffer state for sequences split across chunks. + */ +export class FastEscapeFilter { + private state: State = State.Normal; + private pending: number[] = []; // Buffered bytes during escape sequence + + /** + * Filter terminal query responses from data in a single O(n) pass. + */ + filter(data: string): string { + const len = data.length; + if (len === 0) return ""; + + // Pre-allocate output array (will be at most input length) + const output: number[] = []; + + for (let i = 0; i < len; i++) { + const c = data.charCodeAt(i); + this.processChar(c, output); + } + + return String.fromCharCode(...output); + } + + private processChar(ch: number, output: number[]): void { + switch (this.state) { + case State.Normal: + if (ch === ESC) { + this.state = State.Escape; + this.pending = [ESC]; + } else { + output.push(ch); + } + break; + + case State.Escape: + this.pending.push(ch); + if (ch === BRACKET) { + this.state = State.CSI; + } else if (ch === RBRACKET) { + this.state = State.OSC; + } else if (ch === P) { + this.state = State.DCS; + } else { + // Not a sequence we care about, emit pending + this.emitPending(output); + } + break; + + case State.CSI: + this.pending.push(ch); + if (ch === QUESTION) { + this.state = State.CSIQuery; + } else if (ch === GREATER) { + this.state = State.CSIGreater; + } else if (isDigit(ch)) { + this.state = State.CSIDigits; + } else if (ch === CHAR_O) { + // ESC [ O - unknown CSI, filter it + this.discardPending(); + } else { + // Not a query response pattern, emit pending + this.emitPending(output); + } + break; + + case State.CSIQuery: // ESC [ ? ... + this.pending.push(ch); + if (isDigit(ch) || ch === SEMICOLON) { + // Continue accumulating + } else if (ch === CHAR_c) { + // ESC [ ? ... c - DA1, filter it + this.discardPending(); + } else if (ch === DOLLAR) { + this.state = State.CSIDollar; + } else { + // Not a query response, emit pending + this.emitPending(output); + } + break; + + case State.CSIGreater: // ESC [ > ... + this.pending.push(ch); + if (isDigit(ch) || ch === SEMICOLON) { + // Continue accumulating + } else if (ch === CHAR_c) { + // ESC [ > ... c - DA2, filter it + this.discardPending(); + } else { + // Not a query response, emit pending + this.emitPending(output); + } + break; + + case State.CSIDigits: // ESC [ digit ... + this.pending.push(ch); + if (isDigit(ch) || ch === SEMICOLON) { + // Continue accumulating + } else if (ch === CHAR_R) { + // ESC [ digits R or ESC [ digits ; digits R - CPR, filter it + this.discardPending(); + } else if (ch === CHAR_c) { + // ESC [ digits ; ... c - DA without prefix, filter it + this.discardPending(); + } else if (ch === DOLLAR) { + this.state = State.CSIDollar; + } else { + // Not a query response (probably a color code), emit pending + this.emitPending(output); + } + break; + + case State.CSIDollar: // ESC [ ... $ + this.pending.push(ch); + if (ch === CHAR_y) { + // ESC [ ... $ y - Mode report, filter it + this.discardPending(); + } else { + // Not a mode report, emit pending + this.emitPending(output); + } + break; + + case State.OSC: // ESC ] + this.pending.push(ch); + if (ch === 0x31) { + // '1' + this.state = State.OSC1; + } else { + // Not OSC 10-19, emit pending + this.emitPending(output); + } + break; + + case State.OSC1: // ESC ] 1 + this.pending.push(ch); + if (isDigit(ch)) { + // OSC 10-19 + this.state = State.OSC1x; + } else { + // Not a color query response, emit pending + this.emitPending(output); + } + break; + + case State.OSC1x: // ESC ] 1 digit + this.pending.push(ch); + if (ch === SEMICOLON) { + // Now in the body, looking for rgb:... and terminator + this.state = State.OSCBody; + } else { + // Unexpected, emit pending + this.emitPending(output); + } + break; + + case State.OSCBody: // In OSC body + this.pending.push(ch); + if (ch === BEL) { + // OSC terminated with BEL, filter it + this.discardPending(); + } else if (ch === ESC) { + this.state = State.OSCEscape; + } + // Otherwise keep accumulating + break; + + case State.OSCEscape: // Saw ESC in OSC body + this.pending.push(ch); + if (ch === BACKSLASH) { + // OSC terminated with ESC \, filter it + this.discardPending(); + } else { + // False alarm, back to OSC body + this.state = State.OSCBody; + } + break; + + case State.DCS: // ESC P + this.pending.push(ch); + if (ch === EXCLAIM) { + this.state = State.DCSExclaim; + } else if (ch === GREATER) { + this.state = State.DCSGreater; + } else { + // Not a query response DCS, emit pending + this.emitPending(output); + } + break; + + case State.DCSExclaim: // ESC P ! + this.pending.push(ch); + if (ch === PIPE) { + // ESC P ! | - DA3, enter body + this.state = State.DCSBody; + } else { + // Not DA3, emit pending + this.emitPending(output); + } + break; + + case State.DCSGreater: // ESC P > + this.pending.push(ch); + if (ch === PIPE) { + // ESC P > | - XTVERSION, enter body + this.state = State.DCSBody; + } else { + // Not XTVERSION, emit pending + this.emitPending(output); + } + break; + + case State.DCSBody: // In DCS body + this.pending.push(ch); + if (ch === ESC) { + this.state = State.DCSEscape; + } + // Otherwise keep accumulating + break; + + case State.DCSEscape: // Saw ESC in DCS body + this.pending.push(ch); + if (ch === BACKSLASH) { + // DCS terminated with ESC \, filter it + this.discardPending(); + } else { + // False alarm, back to DCS body + this.state = State.DCSBody; + } + break; + } + } + + private emitPending(output: number[]): void { + for (const c of this.pending) { + output.push(c); + } + this.pending = []; + this.state = State.Normal; + } + + private discardPending(): void { + this.pending = []; + this.state = State.Normal; + } + + /** + * Flush any remaining buffered data. + * Call this when the terminal session ends. + */ + flush(): string { + if (this.pending.length === 0) return ""; + const result = String.fromCharCode(...this.pending); + this.pending = []; + this.state = State.Normal; + return result; + } + + /** + * Reset the filter state. + */ + reset(): void { + this.pending = []; + this.state = State.Normal; + } +} diff --git a/apps/desktop/src/main/lib/scrollback-buffer.ts b/apps/desktop/src/main/lib/scrollback-buffer.ts new file mode 100644 index 000000000..92d3cb696 --- /dev/null +++ b/apps/desktop/src/main/lib/scrollback-buffer.ts @@ -0,0 +1,69 @@ +/** + * High-performance scrollback buffer using array-based storage. + * + * JavaScript strings are immutable, so `str += data` creates a new string + * by copying the entire contents. For a 1MB scrollback, each append copies + * 1MB, making it O(n) per operation and O(n²) over time. + * + * This class stores chunks in an array (O(1) amortized append) and only + * joins them when the string representation is needed (lazy evaluation). + */ +export class ScrollbackBuffer { + private chunks: string[] = []; + private cachedString: string | null = null; + private totalLength = 0; + + /** + * Append data to the buffer. O(1) amortized. + */ + append(data: string): void { + if (data.length === 0) return; + + this.chunks.push(data); + this.totalLength += data.length; + this.cachedString = null; // Invalidate cache + } + + /** + * Get the full scrollback as a string. O(n) but cached. + */ + toString(): string { + if (this.cachedString === null) { + this.cachedString = this.chunks.join(""); + // Compact: replace chunks array with single string to reduce memory + if (this.chunks.length > 1) { + this.chunks = [this.cachedString]; + } + } + return this.cachedString; + } + + /** + * Clear the buffer. O(1). + */ + clear(): void { + this.chunks = []; + this.cachedString = null; + this.totalLength = 0; + } + + /** + * Initialize from an existing string. O(1). + */ + static fromString(str: string): ScrollbackBuffer { + const buffer = new ScrollbackBuffer(); + if (str.length > 0) { + buffer.chunks = [str]; + buffer.cachedString = str; + buffer.totalLength = str.length; + } + return buffer; + } + + /** + * Get the total length without joining. O(1). + */ + get length(): number { + return this.totalLength; + } +} diff --git a/apps/desktop/src/main/lib/terminal-escape-filter.test.ts b/apps/desktop/src/main/lib/terminal-escape-filter.test.ts index 019334c52..77c97b0f8 100644 --- a/apps/desktop/src/main/lib/terminal-escape-filter.test.ts +++ b/apps/desktop/src/main/lib/terminal-escape-filter.test.ts @@ -364,6 +364,151 @@ describe("filterTerminalQueryResponses", () => { }); }); +describe("TUI application scenarios (Claude Code, Codex)", () => { + describe("filters CPR responses generated by TUI cursor queries", () => { + it("should filter rapid CPR bursts from TUI redraws", () => { + // TUIs like Claude Code query cursor position frequently during redraws + const cprBurst = + `${ESC}[1;1R${ESC}[1;1R${ESC}[9;3R${ESC}[3;3R${ESC}[9;50R` + + `${ESC}[9;1R${ESC}[7;3R${ESC}[7;3R${ESC}[3;3R${ESC}[7;3R`; + expect(filterTerminalQueryResponses(cprBurst)).toBe(""); + }); + + it("should filter CPR responses interleaved with TUI output", () => { + // Simulates TUI drawing UI while receiving CPR responses + const input = + `${ESC}[32m┌──────┐${ESC}[0m${ESC}[1;1R` + + `${ESC}[32m│ Menu │${ESC}[0m${ESC}[2;80R` + + `${ESC}[32m└──────┘${ESC}[0m${ESC}[3;1R`; + const expected = + `${ESC}[32m┌──────┐${ESC}[0m` + + `${ESC}[32m│ Menu │${ESC}[0m` + + `${ESC}[32m└──────┘${ESC}[0m`; + expect(filterTerminalQueryResponses(input)).toBe(expected); + }); + + it("should filter multiple query types from TUI initialization", () => { + // TUIs query multiple terminal capabilities on startup + const initQueries = + `${ESC}[1;1R` + // CPR + `${ESC}[?1;0c` + // DA1 + `${ESC}[>0;276;0c` + // DA2 + `${ESC}]10;rgb:ffff/ffff/ffff${BEL}` + // OSC 10 foreground + `${ESC}]11;rgb:1a1a/1a1a/1a1a${BEL}` + // OSC 11 background + `${ESC}[?1;2$y`; // DECRPM + expect(filterTerminalQueryResponses(initQueries)).toBe(""); + }); + + it("should handle tab switch scenario: accumulated CPR responses", () => { + // When switching tabs, TUI keeps running and CPR responses accumulate + // This is the exact bug scenario - responses queue up during tab switch + const accumulated = + `${ESC}[1;1R${ESC}[1;1R${ESC}[9;3R${ESC}[3;3R${ESC}[9;50R${ESC}[9;1R` + + `${ESC}[7;3R${ESC}[7;3R${ESC}[3;3R${ESC}[7;3R${ESC}[7;3R${ESC}[3;3R` + + `${ESC}[7;3R${ESC}[9;1R${ESC}[1;1R${ESC}[1;1R${ESC}[9;3R${ESC}[3;3R`; + expect(filterTerminalQueryResponses(accumulated)).toBe(""); + }); + }); + + describe("stateful filter handles chunked TUI output", () => { + it("should filter CPR split across chunks during TUI redraw", () => { + const filter = new TerminalEscapeFilter(); + // Simulates network/IPC chunking during rapid TUI updates + const chunk1 = `${ESC}[32m┌──────┐${ESC}[0m${ESC}[24;`; + const chunk2 = `80R${ESC}[32m│ Menu │${ESC}[0m`; + const result1 = filter.filter(chunk1); + const result2 = filter.filter(chunk2); + expect(result1 + result2).toBe( + `${ESC}[32m┌──────┐${ESC}[0m${ESC}[32m│ Menu │${ESC}[0m`, + ); + }); + + it("should handle rapid state changes from TUI event loop", () => { + const filter = new TerminalEscapeFilter(); + // Simulate TUI sending multiple queries in tight loop + const chunks = [`text1${ESC}[1;`, `1R`, `text2${ESC}[2;`, `80R`, `text3`]; + let result = ""; + for (const chunk of chunks) { + result += filter.filter(chunk); + } + expect(result).toBe("text1text2text3"); + }); + + it("should filter accumulated responses when tab regains focus", () => { + const filter = new TerminalEscapeFilter(); + // Simulates the burst of accumulated responses sent to xterm on refocus + const accumulated = [ + `${ESC}[1;1R${ESC}[1;1R${ESC}[9;3R`, + `${ESC}[3;3R${ESC}[9;50R${ESC}[9;1R`, + `${ESC}[7;3R${ESC}[7;3R`, + `Welcome back!`, // TUI greeting after responses + ]; + let result = ""; + for (const chunk of accumulated) { + result += filter.filter(chunk); + } + expect(result).toBe("Welcome back!"); + }); + + it("should NOT buffer ESC alone - too common, causes lag", () => { + const filter = new TerminalEscapeFilter(); + // ESC alone should pass through immediately for performance + const chunk1 = `output${ESC}`; + const result1 = filter.filter(chunk1); + expect(result1).toBe(`output${ESC}`); + }); + + it("should NOT buffer ESC[ alone - too common, causes lag", () => { + const filter = new TerminalEscapeFilter(); + // ESC[ alone should pass through immediately for performance + const chunk1 = `output${ESC}[`; + const result1 = filter.filter(chunk1); + expect(result1).toBe(`output${ESC}[`); + }); + + it("should buffer ESC[ followed by digit (potential CPR)", () => { + const filter = new TerminalEscapeFilter(); + // ESC[1 is buffered because it could be start of CPR + const chunk1 = `output${ESC}[1`; + const chunk2 = `;80R`; + const result1 = filter.filter(chunk1); + const result2 = filter.filter(chunk2); + expect(result1 + result2).toBe("output"); + }); + + it("should handle CPR split after digit", () => { + const filter = new TerminalEscapeFilter(); + // This is the realistic case - CPR split after the digit + const chunks = [`text${ESC}[1`, `;1R${ESC}[3`, `;3R${ESC}[3`, `R`]; + let result = ""; + for (const chunk of chunks) { + result += filter.filter(chunk); + } + expect(result).toBe("text"); + }); + + it("should handle ESC]1 at chunk boundary for OSC", () => { + const filter = new TerminalEscapeFilter(); + // ESC]1 is buffered (OSC 10-19 pattern) + const chunk1 = `text${ESC}]1`; + const chunk2 = `0;rgb:ffff/ffff/ffff${BEL}more`; + const result1 = filter.filter(chunk1); + const result2 = filter.filter(chunk2); + expect(result1 + result2).toBe("textmore"); + }); + + it("should pass through non-query CSI after buffering ESC[digit", () => { + const filter = new TerminalEscapeFilter(); + // ESC[3 buffered (could be CPR), but completed as color code + const chunk1 = `text${ESC}[3`; + const chunk2 = `2mgreen`; + const result1 = filter.filter(chunk1); + const result2 = filter.filter(chunk2); + expect(result1 + result2).toBe(`text${ESC}[32mgreen`); + }); + }); +}); + describe("TerminalEscapeFilter (stateful)", () => { describe("handles chunked data", () => { it("should reassemble split DA1 response", () => { @@ -419,19 +564,19 @@ describe("TerminalEscapeFilter (stateful)", () => { expect(result2).toBe(`${ESC}[32mgreen`); }); - it("should NOT buffer ESC alone at end", () => { + it("should NOT buffer ESC alone - passes through for performance", () => { const filter = new TerminalEscapeFilter(); const chunk1 = `text${ESC}`; const result1 = filter.filter(chunk1); - // ESC alone should pass through (conservative - don't buffer) + // ESC alone passes through immediately - buffering causes lag expect(result1).toBe(`text${ESC}`); }); - it("should NOT buffer ESC [ alone at end", () => { + it("should NOT buffer ESC [ alone - passes through for performance", () => { const filter = new TerminalEscapeFilter(); const chunk1 = `text${ESC}[`; const result1 = filter.filter(chunk1); - // ESC [ alone should pass through (could be any CSI) + // ESC [ alone passes through immediately - buffering causes lag expect(result1).toBe(`text${ESC}[`); }); diff --git a/apps/desktop/src/main/lib/terminal-escape-filter.ts b/apps/desktop/src/main/lib/terminal-escape-filter.ts index 832b78c1d..a59f439f4 100644 --- a/apps/desktop/src/main/lib/terminal-escape-filter.ts +++ b/apps/desktop/src/main/lib/terminal-escape-filter.ts @@ -124,6 +124,12 @@ export class TerminalEscapeFilter { const combined = this.buffer + data; this.buffer = ""; + // Fast path: if no ESC character in combined data, skip regex filtering entirely + // This significantly reduces CPU work for plain text bursts + if (!combined.includes(ESC)) { + return combined; + } + // Check if the data ends with a potential incomplete query response const lastEscIndex = combined.lastIndexOf(ESC); @@ -149,20 +155,24 @@ export class TerminalEscapeFilter { /** * Check if a string looks like the START of a query response we want to filter. - * Conservative but must handle chunked sequences: buffers potential query responses - * at chunk boundaries. If the complete sequence doesn't match our filter, it passes through. + * + * IMPORTANT: We must be conservative here to avoid adding latency to normal terminal output. + * Only buffer sequences that strongly indicate a query response pattern. + * ESC alone and ESC[ alone are too common (color codes, cursor moves) to buffer. */ private looksLikeQueryResponse(str: string): boolean { - if (str.length < 2) return false; // Just ESC alone - don't buffer, could be anything + // Don't buffer ESC alone - too common in normal output, causes typing lag + if (str.length < 2) return false; const secondChar = str[1]; - // CSI query responses we want to buffer: + // CSI query responses - only buffer when we see query-specific patterns: // - ESC [ ? (DA1, DECRPM private mode) // - ESC [ > (DA2 secondary) // - ESC [ digit (CPR, standard mode reports, device attributes) + // Do NOT buffer ESC [ alone - too common (every color code starts with it) if (secondChar === "[") { - if (str.length < 3) return false; // ESC [ alone - don't buffer + if (str.length < 3) return false; const thirdChar = str[2]; // Buffer ? (private mode) or > (secondary DA) if (thirdChar === "?" || thirdChar === ">") return true; @@ -180,14 +190,14 @@ export class TerminalEscapeFilter { // OSC color responses: ESC ] 1 (OSC 10-19) if (secondChar === "]") { - if (str.length < 3) return false; // ESC ] alone - don't buffer + if (str.length < 3) return false; // Only buffer if it starts with 1 (OSC 10-19 color responses) return str[2] === "1"; } // DCS responses: ESC P > (XTVERSION) or ESC P ! (DA3) if (secondChar === "P") { - if (str.length < 3) return false; // ESC P alone - don't buffer + if (str.length < 3) return false; const thirdChar = str[2]; return thirdChar === ">" || thirdChar === "!"; } diff --git a/apps/desktop/src/main/lib/terminal/manager.test.ts b/apps/desktop/src/main/lib/terminal/manager.test.ts index 6058c67a7..cc116cc1b 100644 --- a/apps/desktop/src/main/lib/terminal/manager.test.ts +++ b/apps/desktop/src/main/lib/terminal/manager.test.ts @@ -515,7 +515,7 @@ describe("TerminalManager", () => { expect(dataHandler).toHaveBeenCalledWith("test output\n"); }); - it("should pass through raw data including escape sequences", async () => { + it("should filter CPR/DA/OSC query responses from data stream", async () => { const dataHandler = mock(() => {}); await manager.createOrAttach({ @@ -527,6 +527,7 @@ describe("TerminalManager", () => { manager.on("data:pane-raw", dataHandler); const onDataCallback = mockPty.onData.mock.results[0]?.value; + // Data with CPR (ESC[2;1R), DA1 (ESC[?1;0c), and OSC color response const dataWithEscapes = "hello\x1b[2;1R\x1b[?1;0cworld\x1b]10;rgb:ffff/ffff/ffff\x07\n"; if (onDataCallback) { @@ -536,8 +537,8 @@ describe("TerminalManager", () => { // Wait for DataBatcher to flush (16ms batching interval) await new Promise((resolve) => setTimeout(resolve, 30)); - // Raw data passed through unchanged - expect(dataHandler).toHaveBeenCalledWith(dataWithEscapes); + // Query responses should be filtered out, only text content remains + expect(dataHandler).toHaveBeenCalledWith("helloworld\n"); }); it("should emit exit events", async () => { diff --git a/apps/desktop/src/main/lib/terminal/manager.ts b/apps/desktop/src/main/lib/terminal/manager.ts index 544439958..a91425fe9 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -36,7 +36,7 @@ export class TerminalManager extends EventEmitter { } return { isNew: false, - scrollback: existing.scrollback, + scrollback: existing.scrollback.toString(), wasRecovered: existing.wasRecovered, }; } @@ -44,7 +44,7 @@ export class TerminalManager extends EventEmitter { // Create new session const creationPromise = this.doCreateSession({ ...params, - existingScrollback: existing?.scrollback || null, + existingScrollback: existing?.scrollback ?? null, }); this.pendingSessions.set(paneId, creationPromise); @@ -77,7 +77,7 @@ export class TerminalManager extends EventEmitter { return { isNew: true, - scrollback: session.scrollback, + scrollback: session.scrollback.toString(), wasRecovered: session.wasRecovered, }; } @@ -108,7 +108,7 @@ export class TerminalManager extends EventEmitter { try { await this.doCreateSession({ ...params, - existingScrollback: session.scrollback || null, + existingScrollback: session.scrollback, useFallbackShell: true, }); return; // Recovered - don't emit exit @@ -243,7 +243,7 @@ export class TerminalManager extends EventEmitter { return; } - session.scrollback = ""; + session.scrollback.clear(); await reinitializeHistory(session); session.lastActive = Date.now(); } diff --git a/apps/desktop/src/main/lib/terminal/session.test.ts b/apps/desktop/src/main/lib/terminal/session.test.ts index 3c9796943..3a3d4339f 100644 --- a/apps/desktop/src/main/lib/terminal/session.test.ts +++ b/apps/desktop/src/main/lib/terminal/session.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { promises as fs } from "node:fs"; import { join } from "node:path"; +import { ScrollbackBuffer } from "../scrollback-buffer"; import { getHistoryDir } from "../terminal-history"; import { flushSession, recoverScrollback } from "./session"; import type { TerminalSession } from "./types"; @@ -8,13 +9,14 @@ import type { TerminalSession } from "./types"; describe("session", () => { describe("recoverScrollback", () => { it("should return existing scrollback if provided", async () => { + const existingBuffer = ScrollbackBuffer.fromString("existing content"); const result = await recoverScrollback( - "existing content", + existingBuffer, "workspace-1", "pane-1", ); - expect(result.scrollback).toBe("existing content"); + expect(result.scrollback.toString()).toBe("existing content"); expect(result.wasRecovered).toBe(true); }); @@ -25,7 +27,7 @@ describe("session", () => { "non-existent-pane", ); - expect(result.scrollback).toBe(""); + expect(result.scrollback.toString()).toBe(""); expect(result.wasRecovered).toBe(false); }); @@ -46,7 +48,7 @@ describe("session", () => { expect(result.wasRecovered).toBe(true); // Escape sequences should be filtered out - expect(result.scrollback).toBe("helloworld"); + expect(result.scrollback.toString()).toBe("helloworld"); } finally { // Cleanup await fs.rm(historyDir, { recursive: true, force: true }); @@ -63,14 +65,15 @@ describe("session", () => { await fs.writeFile(join(historyDir, "scrollback.bin"), "disk content"); try { + const existingBuffer = ScrollbackBuffer.fromString("memory content"); const result = await recoverScrollback( - "memory content", + existingBuffer, workspaceId, paneId, ); // Should use the provided existing scrollback, not disk - expect(result.scrollback).toBe("memory content"); + expect(result.scrollback.toString()).toBe("memory content"); expect(result.wasRecovered).toBe(true); } finally { await fs.rm(historyDir, { recursive: true, force: true }); @@ -93,11 +96,12 @@ describe("session", () => { }; let historyWritten = ""; + const scrollbackBuffer = ScrollbackBuffer.fromString("initial"); const mockSession = { dataBatcher: mockDataBatcher, escapeFilter: mockEscapeFilter, - scrollback: "initial", + scrollback: scrollbackBuffer, historyWriter: { write: (data: string) => { historyWritten = data; @@ -108,7 +112,7 @@ describe("session", () => { flushSession(mockSession); expect(flushedData).toBe("batcher disposed"); - expect(mockSession.scrollback).toBe("initialremaining data"); + expect(mockSession.scrollback.toString()).toBe("initialremaining data"); expect(historyWritten).toBe("remaining data"); }); @@ -121,17 +125,19 @@ describe("session", () => { flush: () => "", }; + const scrollbackBuffer = ScrollbackBuffer.fromString("original"); + const mockSession = { dataBatcher: mockDataBatcher, escapeFilter: mockEscapeFilter, - scrollback: "original", + scrollback: scrollbackBuffer, historyWriter: null, } as unknown as TerminalSession; flushSession(mockSession); // Scrollback should not be modified when flush returns empty - expect(mockSession.scrollback).toBe("original"); + expect(mockSession.scrollback.toString()).toBe("original"); }); }); }); diff --git a/apps/desktop/src/main/lib/terminal/session.ts b/apps/desktop/src/main/lib/terminal/session.ts index 4c9ea1005..215c4619c 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -2,24 +2,28 @@ import os from "node:os"; import * as pty from "node-pty"; import { getShellArgs } from "../agent-setup"; import { DataBatcher } from "../data-batcher"; +import { FastEscapeFilter } from "../fast-escape-filter"; +import { ScrollbackBuffer } from "../scrollback-buffer"; import { containsClearScrollbackSequence, extractContentAfterClear, - TerminalEscapeFilter, } from "../terminal-escape-filter"; import { HistoryReader, HistoryWriter } from "../terminal-history"; import { buildTerminalEnv, FALLBACK_SHELL, getDefaultShell } from "./env"; import type { InternalCreateSessionParams, TerminalSession } from "./types"; +// ESC character for fast-path checks +const ESC = "\x1b"; + const DEFAULT_COLS = 80; const DEFAULT_ROWS = 24; export async function recoverScrollback( - existingScrollback: string | null, + existingScrollback: ScrollbackBuffer | null, workspaceId: string, paneId: string, -): Promise<{ scrollback: string; wasRecovered: boolean }> { - if (existingScrollback) { +): Promise<{ scrollback: ScrollbackBuffer; wasRecovered: boolean }> { + if (existingScrollback && existingScrollback.length > 0) { return { scrollback: existingScrollback, wasRecovered: true }; } @@ -28,13 +32,16 @@ export async function recoverScrollback( if (history.scrollback) { // Strip protocol responses from recovered history - const recoveryFilter = new TerminalEscapeFilter(); + const recoveryFilter = new FastEscapeFilter(); const filtered = recoveryFilter.filter(history.scrollback) + recoveryFilter.flush(); - return { scrollback: filtered, wasRecovered: true }; + return { + scrollback: ScrollbackBuffer.fromString(filtered), + wasRecovered: true, + }; } - return { scrollback: "", wasRecovered: false }; + return { scrollback: new ScrollbackBuffer(), wasRecovered: false }; } function spawnPty(params: { @@ -107,7 +114,10 @@ export async function createSession( terminalCols, terminalRows, ); - await historyWriter.init(recoveredScrollback || undefined); + // Pass string representation to history writer for persistence + const scrollbackStr = + recoveredScrollback.length > 0 ? recoveredScrollback.toString() : undefined; + await historyWriter.init(scrollbackStr); const dataBatcher = new DataBatcher((batchedData) => { onData(paneId, batchedData); @@ -125,7 +135,7 @@ export async function createSession( isAlive: true, wasRecovered, historyWriter, - escapeFilter: new TerminalEscapeFilter(), + escapeFilter: new FastEscapeFilter(), dataBatcher, shell, startTime: Date.now(), @@ -144,21 +154,39 @@ export function setupDataHandler( let commandsSent = false; session.pty.onData((data) => { - let dataToStore = data; - - if (containsClearScrollbackSequence(data)) { - session.scrollback = ""; - session.escapeFilter = new TerminalEscapeFilter(); - onHistoryReinit().catch(() => {}); - dataToStore = extractContentAfterClear(data); + // Fast path: check if data contains any escape sequences + // Most plain text output has no ESC, so we can skip all filtering + const hasEsc = data.includes(ESC); + + if (!hasEsc) { + // No escape sequences - skip all filtering, direct passthrough + session.dataBatcher.write(data); + session.scrollback.append(data); + session.historyWriter?.write(data); + } else { + // Slow path: data contains escape sequences, need to filter + // Check for clear scrollback sequences (ESC[3J, ESC c) + const hasClear = containsClearScrollbackSequence(data); + if (hasClear) { + session.scrollback.clear(); + session.escapeFilter = new FastEscapeFilter(); + onHistoryReinit().catch(() => {}); + } + + // Filter once: remove CPR/DA/OSC query responses but PRESERVE clear sequences + const filtered = session.escapeFilter.filter(data); + + // Send filtered data to renderer (clear sequences preserved for visual clearing) + session.dataBatcher.write(filtered); + + // For history: apply extractContentAfterClear to the already-filtered result + const dataForHistory = hasClear + ? extractContentAfterClear(filtered) + : filtered; + session.scrollback.append(dataForHistory); + session.historyWriter?.write(dataForHistory); } - const filteredData = session.escapeFilter.filter(dataToStore); - session.scrollback += filteredData; - session.historyWriter?.write(filteredData); - - session.dataBatcher.write(data); - if (shouldRunCommands && !commandsSent) { commandsSent = true; setTimeout(() => { @@ -215,7 +243,7 @@ export function flushSession(session: TerminalSession): void { const remaining = session.escapeFilter.flush(); if (remaining) { - session.scrollback += remaining; + session.scrollback.append(remaining); session.historyWriter?.write(remaining); } } diff --git a/apps/desktop/src/main/lib/terminal/types.ts b/apps/desktop/src/main/lib/terminal/types.ts index f099ed3b2..ef28fabca 100644 --- a/apps/desktop/src/main/lib/terminal/types.ts +++ b/apps/desktop/src/main/lib/terminal/types.ts @@ -1,6 +1,7 @@ import type * as pty from "node-pty"; import type { DataBatcher } from "../data-batcher"; -import type { TerminalEscapeFilter } from "../terminal-escape-filter"; +import type { FastEscapeFilter } from "../fast-escape-filter"; +import type { ScrollbackBuffer } from "../scrollback-buffer"; import type { HistoryWriter } from "../terminal-history"; export interface TerminalSession { @@ -11,12 +12,12 @@ export interface TerminalSession { cols: number; rows: number; lastActive: number; - scrollback: string; + scrollback: ScrollbackBuffer; isAlive: boolean; deleteHistoryOnExit?: boolean; wasRecovered: boolean; historyWriter?: HistoryWriter; - escapeFilter: TerminalEscapeFilter; + escapeFilter: FastEscapeFilter; dataBatcher: DataBatcher; shell: string; startTime: number; @@ -56,6 +57,6 @@ export interface CreateSessionParams { } export interface InternalCreateSessionParams extends CreateSessionParams { - existingScrollback: string | null; + existingScrollback: ScrollbackBuffer | null; useFallbackShell?: boolean; }