diff --git a/apps/desktop/src/main/lib/terminal-escape-filter.test.ts b/apps/desktop/src/main/lib/terminal-escape-filter.test.ts deleted file mode 100644 index f4b64c93264..00000000000 --- a/apps/desktop/src/main/lib/terminal-escape-filter.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { - filterTerminalQueryResponses, - TerminalEscapeFilter, -} from "./terminal-escape-filter"; - -// Control characters for building test sequences -const ESC = "\x1b"; -const BEL = "\x07"; - -describe("filterTerminalQueryResponses", () => { - describe("preserves normal terminal output", () => { - it("should return empty string unchanged", () => { - expect(filterTerminalQueryResponses("")).toBe(""); - }); - - it("should preserve plain text", () => { - expect(filterTerminalQueryResponses("hello world")).toBe("hello world"); - }); - - it("should preserve text with newlines", () => { - const input = "line1\nline2\r\nline3"; - expect(filterTerminalQueryResponses(input)).toBe(input); - }); - - it("should preserve ANSI color codes", () => { - const colored = `${ESC}[32mgreen text${ESC}[0m`; - expect(filterTerminalQueryResponses(colored)).toBe(colored); - }); - - it("should preserve cursor movement sequences", () => { - const cursorMove = `${ESC}[H${ESC}[2J`; // Home + clear screen - expect(filterTerminalQueryResponses(cursorMove)).toBe(cursorMove); - }); - - it("should preserve text styling sequences", () => { - const styled = `${ESC}[1mbold${ESC}[0m ${ESC}[4munderline${ESC}[0m`; - expect(filterTerminalQueryResponses(styled)).toBe(styled); - }); - }); - - describe("filters Cursor Position Reports (CPR)", () => { - it("should filter basic CPR response", () => { - const cpr = `${ESC}[24;1R`; - expect(filterTerminalQueryResponses(cpr)).toBe(""); - }); - - it("should filter CPR with single digit positions", () => { - const cpr = `${ESC}[1;1R`; - expect(filterTerminalQueryResponses(cpr)).toBe(""); - }); - - it("should filter CPR with row only (no column)", () => { - const cpr = `${ESC}[2R`; - expect(filterTerminalQueryResponses(cpr)).toBe(""); - }); - - it("should filter CPR with single digit row only", () => { - const cpr = `${ESC}[1R`; - expect(filterTerminalQueryResponses(cpr)).toBe(""); - }); - - it("should filter CPR with large positions", () => { - const cpr = `${ESC}[999;999R`; - expect(filterTerminalQueryResponses(cpr)).toBe(""); - }); - - it("should filter CPR mixed with text", () => { - const input = `before${ESC}[24;80Rafter`; - expect(filterTerminalQueryResponses(input)).toBe("beforeafter"); - }); - - it("should filter multiple CPR responses", () => { - const input = `${ESC}[1;1R${ESC}[24;80R`; - expect(filterTerminalQueryResponses(input)).toBe(""); - }); - - it("should filter mixed full and row-only CPRs", () => { - const input = `${ESC}[2R${ESC}[1R${ESC}[24;80R`; - expect(filterTerminalQueryResponses(input)).toBe(""); - }); - }); - - describe("filters Primary Device Attributes (DA1)", () => { - it("should filter VT100 response", () => { - const da1 = `${ESC}[?1;0c`; - expect(filterTerminalQueryResponses(da1)).toBe(""); - }); - - it("should filter VT100 with options", () => { - const da1 = `${ESC}[?1;2c`; - expect(filterTerminalQueryResponses(da1)).toBe(""); - }); - - it("should filter xterm-style DA1", () => { - const da1 = `${ESC}[?62;1;2;6;7;8;9;15c`; - expect(filterTerminalQueryResponses(da1)).toBe(""); - }); - - it("should filter simple DA1 response", () => { - const da1 = `${ESC}[?c`; - expect(filterTerminalQueryResponses(da1)).toBe(""); - }); - - it("should filter DA1 mixed with text", () => { - const input = `prompt$ ${ESC}[?1;0c command`; - expect(filterTerminalQueryResponses(input)).toBe("prompt$ command"); - }); - }); - - describe("filters Secondary Device Attributes (DA2)", () => { - it("should filter basic DA2 response", () => { - const da2 = `${ESC}[>0;276;0c`; - expect(filterTerminalQueryResponses(da2)).toBe(""); - }); - - it("should filter DA2 with different version", () => { - const da2 = `${ESC}[>41;354;0c`; - expect(filterTerminalQueryResponses(da2)).toBe(""); - }); - - it("should filter simple DA2 response", () => { - const da2 = `${ESC}[>c`; - expect(filterTerminalQueryResponses(da2)).toBe(""); - }); - - it("should filter DA2 mixed with other sequences", () => { - const input = `${ESC}[32m${ESC}[>0;276;0cgreen`; - expect(filterTerminalQueryResponses(input)).toBe(`${ESC}[32mgreen`); - }); - }); - - describe("filters Device Attributes without prefix", () => { - it("should filter DA response without ? or > prefix", () => { - const da = `${ESC}[0;276;0c`; - expect(filterTerminalQueryResponses(da)).toBe(""); - }); - - it("should filter simple DA response without prefix", () => { - const da = `${ESC}[1;0c`; - expect(filterTerminalQueryResponses(da)).toBe(""); - }); - - it("should filter DA with multiple params", () => { - const da = `${ESC}[62;1;2;6;7;8;9c`; - expect(filterTerminalQueryResponses(da)).toBe(""); - }); - }); - - describe("filters DEC Private Mode Reports (DECRPM)", () => { - it("should filter mode set response", () => { - const decrpm = `${ESC}[?1;1$y`; // Mode 1 is set - expect(filterTerminalQueryResponses(decrpm)).toBe(""); - }); - - it("should filter mode reset response", () => { - const decrpm = `${ESC}[?1;2$y`; // Mode 1 is reset - expect(filterTerminalQueryResponses(decrpm)).toBe(""); - }); - - it("should filter mode permanently set response", () => { - const decrpm = `${ESC}[?25;3$y`; // Mode 25 permanently set - expect(filterTerminalQueryResponses(decrpm)).toBe(""); - }); - - it("should filter mode permanently reset response", () => { - const decrpm = `${ESC}[?12;4$y`; // Mode 12 permanently reset - expect(filterTerminalQueryResponses(decrpm)).toBe(""); - }); - - it("should filter multiple DECRPM responses", () => { - const input = `${ESC}[?1;2$y${ESC}[?25;1$y${ESC}[?12;2$y`; - expect(filterTerminalQueryResponses(input)).toBe(""); - }); - }); - - describe("filters OSC color responses", () => { - it("should filter OSC 10 (foreground) with BEL terminator", () => { - const osc10 = `${ESC}]10;rgb:ffff/ffff/ffff${BEL}`; - expect(filterTerminalQueryResponses(osc10)).toBe(""); - }); - - it("should filter OSC 10 with ST terminator", () => { - const osc10 = `${ESC}]10;rgb:0000/0000/0000${ESC}\\`; - expect(filterTerminalQueryResponses(osc10)).toBe(""); - }); - - it("should filter OSC 11 (background)", () => { - const osc11 = `${ESC}]11;rgb:1c1c/1c1c/1c1c${BEL}`; - expect(filterTerminalQueryResponses(osc11)).toBe(""); - }); - - it("should filter OSC 12 (cursor color)", () => { - const osc12 = `${ESC}]12;rgb:00ff/00ff/00ff${BEL}`; - expect(filterTerminalQueryResponses(osc12)).toBe(""); - }); - - it("should filter OSC 13-19 (highlight colors)", () => { - for (let i = 13; i <= 19; i++) { - const osc = `${ESC}]${i};rgb:aaaa/bbbb/cccc${BEL}`; - expect(filterTerminalQueryResponses(osc)).toBe(""); - } - }); - - it("should filter mixed case hex values", () => { - const osc = `${ESC}]10;rgb:AbCd/EfAb/1234${BEL}`; - expect(filterTerminalQueryResponses(osc)).toBe(""); - }); - - it("should filter multiple OSC responses", () => { - const input = - `${ESC}]10;rgb:ffff/ffff/ffff${BEL}` + - `${ESC}]11;rgb:0000/0000/0000${BEL}` + - `${ESC}]12;rgb:00ff/00ff/00ff${BEL}`; - expect(filterTerminalQueryResponses(input)).toBe(""); - }); - - it("should filter short-form (2-digit) hex color responses", () => { - const osc10 = `${ESC}]10;rgb:f5/f5/f5${BEL}`; - expect(filterTerminalQueryResponses(osc10)).toBe(""); - }); - - it("should filter short-form hex with ST terminator", () => { - const osc11 = `${ESC}]11;rgb:1a/1a/1a${ESC}\\`; - expect(filterTerminalQueryResponses(osc11)).toBe(""); - }); - - it("should filter 3-digit hex color responses", () => { - const osc10 = `${ESC}]10;rgb:fff/fff/fff${BEL}`; - expect(filterTerminalQueryResponses(osc10)).toBe(""); - }); - }); - - describe("filters Standard Mode Reports", () => { - it("should filter standard mode report", () => { - const report = `${ESC}[12;2$y`; - expect(filterTerminalQueryResponses(report)).toBe(""); - }); - - it("should filter mode report with different values", () => { - const report = `${ESC}[4;1$y`; - expect(filterTerminalQueryResponses(report)).toBe(""); - }); - }); - - describe("filters Tertiary Device Attributes (DA3)", () => { - it("should filter DA3 response with unit ID", () => { - const da3 = `${ESC}P!|00000000${ESC}\\`; - expect(filterTerminalQueryResponses(da3)).toBe(""); - }); - - it("should filter DA3 response with alphanumeric ID", () => { - const da3 = `${ESC}P!|7E565445${ESC}\\`; - expect(filterTerminalQueryResponses(da3)).toBe(""); - }); - }); - - describe("filters XTVERSION responses", () => { - it("should filter xterm version response", () => { - const xtversion = `${ESC}P>|XTerm(354)${ESC}\\`; - expect(filterTerminalQueryResponses(xtversion)).toBe(""); - }); - - it("should filter custom terminal version", () => { - const xtversion = `${ESC}P>|MyTerminal 1.0${ESC}\\`; - expect(filterTerminalQueryResponses(xtversion)).toBe(""); - }); - }); - - describe("handles complex mixed content", () => { - it("should filter all query responses from realistic output", () => { - const input = - `$ echo hello${ESC}[24;1R\n` + - `hello\n` + - `${ESC}[?1;0c${ESC}[>0;276;0c` + - `${ESC}]10;rgb:ffff/ffff/ffff${BEL}` + - `${ESC}]11;rgb:0000/0000/0000${BEL}` + - `${ESC}[?1;2$y` + - `$ `; - - const expected = `$ echo hello\nhello\n$ `; - expect(filterTerminalQueryResponses(input)).toBe(expected); - }); - - it("should handle interleaved responses and output", () => { - const input = `a${ESC}[1;1Rb${ESC}[?1;0cc${ESC}]10;rgb:ffff/ffff/ffff${BEL}d`; - expect(filterTerminalQueryResponses(input)).toBe("abcd"); - }); - - it("should preserve colored output while filtering responses", () => { - const input = `${ESC}[32mSuccess${ESC}[0m${ESC}[24;1R${ESC}[?1;0c\n`; - const expected = `${ESC}[32mSuccess${ESC}[0m\n`; - expect(filterTerminalQueryResponses(input)).toBe(expected); - }); - - it("should handle the exact user-reported issue", () => { - // User reported: 2R1R0;276;0c10;rgb:ffff/ffff/ffff11;rgb:0000/0000/000012;2$y - // This is the interpreted version with escape sequences - const input = - `${ESC}[2R${ESC}[1R${ESC}[0;276;0c` + - `${ESC}]10;rgb:ffff/ffff/ffff${BEL}` + - `${ESC}]11;rgb:0000/0000/0000${BEL}` + - `${ESC}[?12;2$y`; - - expect(filterTerminalQueryResponses(input)).toBe(""); - }); - - it("should handle rapid successive responses", () => { - const responses = [ - `${ESC}[1;1R`, - `${ESC}[?1;0c`, - `${ESC}[>0;276;0c`, - `${ESC}]10;rgb:ffff/ffff/ffff${BEL}`, - `${ESC}]11;rgb:0000/0000/0000${BEL}`, - `${ESC}]12;rgb:00ff/00ff/00ff${BEL}`, - `${ESC}[?1;2$y`, - `${ESC}[?25;1$y`, - ]; - const input = responses.join(""); - expect(filterTerminalQueryResponses(input)).toBe(""); - }); - }); - - describe("edge cases", () => { - it("should handle data with only ESC characters", () => { - const input = `${ESC}${ESC}${ESC}`; - expect(filterTerminalQueryResponses(input)).toBe(input); - }); - - it("should not filter incomplete CPR sequence", () => { - const incomplete = `${ESC}[24;`; // Missing R - expect(filterTerminalQueryResponses(incomplete)).toBe(incomplete); - }); - - it("should not filter incomplete DA1 sequence", () => { - const incomplete = `${ESC}[?1;0`; // Missing c - expect(filterTerminalQueryResponses(incomplete)).toBe(incomplete); - }); - - it("should not filter incomplete OSC sequence", () => { - const incomplete = `${ESC}]10;rgb:ffff/ffff/ffff`; // Missing terminator - expect(filterTerminalQueryResponses(incomplete)).toBe(incomplete); - }); - - it("should handle very long strings efficiently", () => { - const longText = "x".repeat(100000); - const withResponse = `${longText}${ESC}[24;1R${longText}`; - const result = filterTerminalQueryResponses(withResponse); - expect(result).toBe(longText + longText); - }); - - it("should handle unicode content", () => { - const unicode = `日本語${ESC}[24;1Rテスト🎉`; - expect(filterTerminalQueryResponses(unicode)).toBe("日本語テスト🎉"); - }); - - it("should handle binary-like content", () => { - const binary = `\x00\x01\x02${ESC}[24;1R\x03\x04\x05`; - expect(filterTerminalQueryResponses(binary)).toBe( - "\x00\x01\x02\x03\x04\x05", - ); - }); - }); -}); - -describe("TerminalEscapeFilter (stateful)", () => { - describe("handles chunked data", () => { - it("should reassemble split DA1 response", () => { - const filter = new TerminalEscapeFilter(); - // DA1 response split across chunks - const chunk1 = `hello${ESC}[?`; - const chunk2 = `1;0c`; - const result1 = filter.filter(chunk1); - const result2 = filter.filter(chunk2); - expect(result1 + result2).toBe("hello"); - }); - - it("should reassemble split standard mode report", () => { - const filter = new TerminalEscapeFilter(); - // Standard mode report ESC[12;2$y split across chunks - const chunk1 = `text${ESC}[1`; - const chunk2 = `2;2$y`; - const result1 = filter.filter(chunk1); - const result2 = filter.filter(chunk2); - expect(result1 + result2).toBe("text"); - }); - - it("should reassemble split CPR with row only", () => { - const filter = new TerminalEscapeFilter(); - // CPR ESC[2R split across chunks - const chunk1 = `prompt${ESC}[2`; - const chunk2 = `R`; - const result1 = filter.filter(chunk1); - const result2 = filter.filter(chunk2); - expect(result1 + result2).toBe("prompt"); - }); - - it("should reassemble split OSC color response", () => { - const filter = new TerminalEscapeFilter(); - // OSC 10 response split across chunks - 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 buffer digit CSI but pass through color codes when complete", () => { - const filter = new TerminalEscapeFilter(); - // Color sequence is buffered initially (could be CPR/mode report) - const chunk1 = `text${ESC}[32`; - const chunk2 = `mgreen`; - const result1 = filter.filter(chunk1); - const result2 = filter.filter(chunk2); - // First chunk buffered, second chunk completes sequence - // Color code doesn't match filter patterns, so passes through - expect(result1).toBe("text"); - expect(result2).toBe(`${ESC}[32mgreen`); - }); - - it("should NOT buffer ESC alone at end", () => { - const filter = new TerminalEscapeFilter(); - const chunk1 = `text${ESC}`; - const result1 = filter.filter(chunk1); - // ESC alone should pass through (conservative - don't buffer) - expect(result1).toBe(`text${ESC}`); - }); - - it("should NOT buffer ESC [ alone at end", () => { - const filter = new TerminalEscapeFilter(); - const chunk1 = `text${ESC}[`; - const result1 = filter.filter(chunk1); - // ESC [ alone should pass through (could be any CSI) - expect(result1).toBe(`text${ESC}[`); - }); - - it("should buffer ESC [ digit (could be CPR/mode report/DA)", () => { - const filter = new TerminalEscapeFilter(); - const chunk1 = `text${ESC}[2`; - const result1 = filter.filter(chunk1); - // ESC [ digit is buffered - could be start of CPR, mode report, or DA - // When complete, if it's a color code it will pass through (no match) - expect(result1).toBe("text"); - // Complete with R (CPR) - should be filtered - const result2 = filter.filter("R"); - expect(result2).toBe(""); - }); - - it("should pass through color codes when complete", () => { - const filter = new TerminalEscapeFilter(); - // Color code split at chunk boundary - const chunk1 = `text${ESC}[32`; - const result1 = filter.filter(chunk1); - expect(result1).toBe("text"); // Buffered - // Complete with m - not a query response, passes through - const result2 = filter.filter("mgreen"); - expect(result2).toBe(`${ESC}[32mgreen`); - }); - - it("should NOT buffer complete CSI followed by text", () => { - const filter = new TerminalEscapeFilter(); - // Complete color code followed by text at chunk end - const chunk = `hello${ESC}[31mworld\n`; - const result = filter.filter(chunk); - // Should pass through immediately - CSI is complete - expect(result).toBe(`hello${ESC}[31mworld\n`); - }); - - it("should NOT buffer SGR reset followed by prompt", () => { - const filter = new TerminalEscapeFilter(); - // Reset code followed by prompt - const chunk = `${ESC}[0m$ `; - const result = filter.filter(chunk); - expect(result).toBe(`${ESC}[0m$ `); - }); - }); - - describe("flush behavior", () => { - it("should flush buffered incomplete sequence", () => { - const filter = new TerminalEscapeFilter(); - const chunk = `hello${ESC}[?1;0`; // Incomplete DA1 - const result = filter.filter(chunk); - expect(result).toBe("hello"); - // Flush returns the buffered data (filtered) - const flushed = filter.flush(); - expect(flushed).toBe(`${ESC}[?1;0`); // Not filtered because incomplete - }); - - it("should return empty on flush when no buffer", () => { - const filter = new TerminalEscapeFilter(); - filter.filter("complete data"); - expect(filter.flush()).toBe(""); - }); - }); - - describe("reset behavior", () => { - it("should clear buffer on reset", () => { - const filter = new TerminalEscapeFilter(); - filter.filter(`text${ESC}[?1;0`); // Leaves incomplete in buffer - filter.reset(); - expect(filter.flush()).toBe(""); // Buffer was cleared - }); - }); - - describe("preserves normal output", () => { - it("should not buffer or delay normal text", () => { - const filter = new TerminalEscapeFilter(); - const result = filter.filter("normal text output"); - expect(result).toBe("normal text output"); - }); - - it("should preserve ANSI colors even at chunk boundaries", () => { - const filter = new TerminalEscapeFilter(); - const chunk1 = `${ESC}[31mred${ESC}`; - const chunk2 = `[0mnormal`; - const result1 = filter.filter(chunk1); - const result2 = filter.filter(chunk2); - // Colors should pass through - expect(result1 + result2).toBe(`${ESC}[31mred${ESC}[0mnormal`); - }); - }); -}); diff --git a/apps/desktop/src/main/lib/terminal-escape-filter.ts b/apps/desktop/src/main/lib/terminal-escape-filter.ts deleted file mode 100644 index 9ba7e5bef54..00000000000 --- a/apps/desktop/src/main/lib/terminal-escape-filter.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * Filters terminal escape sequence responses from PTY output. - * - * When xterm.js initializes or queries terminal capabilities, the terminal - * responds with escape sequences. These responses should not be stored in - * scrollback as they display as garbage when replayed on reattach. - */ - -// Control characters -const ESC = "\x1b"; -const BEL = "\x07"; - -/** - * Pattern definitions for terminal query responses. - * Each pattern matches a specific type of response that should be filtered. - */ -const FILTER_PATTERNS = { - /** - * Cursor Position Report (CPR): ESC [ Pl ; Pc R or ESC [ Pl R - * Response to DSR (Device Status Report) query ESC [ 6 n - * Examples: - * - ESC[24;1R (cursor at row 24, column 1) - * - ESC[2R (cursor at row 2, column defaults to 1) - */ - cursorPositionReport: `${ESC}\\[\\d+(?:;\\d+)?R`, - - /** - * Primary Device Attributes (DA1): ESC [ ? Ps c - * Response to DA1 query ESC [ c or ESC [ 0 c - * Example: ESC[?1;0c (VT100 with no options) - */ - primaryDeviceAttributes: `${ESC}\\[\\?[\\d;]*c`, - - /** - * Secondary Device Attributes (DA2): ESC [ > Ps c - * Response to DA2 query ESC [ > c or ESC [ > 0 c - * Example: ESC[>0;276;0c (xterm version 276) - */ - secondaryDeviceAttributes: `${ESC}\\[>[\\d;]*c`, - - /** - * Device Attributes without prefix: ESC [ Ps c - * Some terminals respond without ? or > prefix - * Example: ESC[0;276;0c - */ - deviceAttributesNoPrefix: `${ESC}\\[[\\d;]+c`, - - /** - * Tertiary Device Attributes (DA3): ESC P ! | ... ESC \ - * Response to DA3 query, returns unit ID - */ - tertiaryDeviceAttributes: `${ESC}P![|][^${ESC}]*${ESC}\\\\`, - - /** - * DEC Private Mode Report (DECRPM): ESC [ ? Ps ; Pm $ y - * Response to DECRQM query for private mode status - * Example: ESC[?1;2$y (mode 1 is set) - */ - decPrivateModeReport: `${ESC}\\[\\?\\d+;\\d+\\$y`, - - /** - * Standard Mode Report: ESC [ Ps ; Pm $ y - * Response to DECRQM query for standard (non-private) mode status - * Example: ESC[12;2$y (mode 12 status) - */ - standardModeReport: `${ESC}\\[\\d+;\\d+\\$y`, - - /** - * OSC (Operating System Command) color responses - * Response format: ESC ] Ps ; rgb:rr/gg/bb ST or ESC ] Ps ; rgb:rrrr/gggg/bbbb ST - * Where ST is BEL (\x07) or ESC \ - * Hex values can be 2-4 digits per channel depending on terminal - * - * Common queries: - * - OSC 10: Foreground color - * - OSC 11: Background color - * - OSC 12: Cursor color - * - OSC 13-19: Various highlight colors - */ - oscColorResponse: `${ESC}\\]1[0-9];rgb:[0-9a-fA-F]{2,4}/[0-9a-fA-F]{2,4}/[0-9a-fA-F]{2,4}(?:${BEL}|${ESC}\\\\)`, - - /** - * XTVERSION response: ESC P > | text ESC \ - * Response to XTVERSION query for terminal version - */ - xtversion: `${ESC}P>\\|[^${ESC}]*${ESC}\\\\`, - - /** - * ESC [ O - Unknown/malformed sequence that appears in some terminals - */ - unknownCSI_O: `${ESC}\\[O`, -} as const; - -/** - * Combined regex pattern for all terminal query responses. - * Patterns are joined with | (OR) to match any of them. - */ -const COMBINED_PATTERN = new RegExp( - Object.values(FILTER_PATTERNS).join("|"), - "g", -); - -/** - * Stateful filter that handles escape sequences split across data chunks. - * Maintains a buffer to reassemble split sequences before filtering. - * Only buffers sequences that look like query responses we want to filter. - */ -export class TerminalEscapeFilter { - private buffer = ""; - - /** - * Filter terminal query responses from data. - * Handles sequences that may be split across multiple data events. - */ - filter(data: string): string { - // Combine buffered data with new data - const combined = this.buffer + data; - this.buffer = ""; - - // Check if the data ends with a potential incomplete query response - const lastEscIndex = combined.lastIndexOf(ESC); - - // Only consider buffering if ESC is very close to end (max 30 chars for reasonable sequence) - // and the sequence looks like one of our target patterns - if (lastEscIndex !== -1 && lastEscIndex > combined.length - 30) { - const afterEsc = combined.slice(lastEscIndex); - - // Only buffer if it looks like an incomplete query response pattern - if ( - this.looksLikeQueryResponse(afterEsc) && - this.isIncomplete(afterEsc) - ) { - this.buffer = afterEsc; - const toFilter = combined.slice(0, lastEscIndex); - return toFilter.replace(COMBINED_PATTERN, ""); - } - } - - // No incomplete query response, filter the whole thing - return combined.replace(COMBINED_PATTERN, ""); - } - - /** - * 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. - */ - private looksLikeQueryResponse(str: string): boolean { - if (str.length < 2) return false; // Just ESC alone - don't buffer, could be anything - - const secondChar = str[1]; - - // CSI query responses we want to buffer: - // - ESC [ ? (DA1, DECRPM private mode) - // - ESC [ > (DA2 secondary) - // - ESC [ digit (CPR, standard mode reports, device attributes) - if (secondChar === "[") { - if (str.length < 3) return false; // ESC [ alone - don't buffer - const thirdChar = str[2]; - // Buffer ? (private mode) or > (secondary DA) - if (thirdChar === "?" || thirdChar === ">") return true; - // Buffer digit-starting CSI sequences that could be query responses: - // - CPR: ESC[24;1R or ESC[1R - // - Standard mode report: ESC[12;2$y - // - Device attributes: ESC[0;276;0c - // Color codes like ESC[32m will complete quickly and pass through - // since they don't match our filter patterns. - if (/\d/.test(thirdChar)) { - return true; - } - return false; - } - - // OSC color responses: ESC ] 1 (OSC 10-19) - if (secondChar === "]") { - if (str.length < 3) return false; // ESC ] alone - don't buffer - // 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 - const thirdChar = str[2]; - return thirdChar === ">" || thirdChar === "!"; - } - - return false; - } - - /** - * Check if a potential query response sequence is incomplete. - */ - private isIncomplete(str: string): boolean { - if (str.length < 2) return true; - - const secondChar = str[1]; - - // CSI sequence: ESC [ - if (secondChar === "[") { - const csiBody = str.slice(2); - if (csiBody.length === 0) return true; - // CSI is complete once we encounter the first final byte (A–Z, a–z, or ~) - // Scan from the start to avoid treating trailing text as part of the CSI - const finalIndex = csiBody.search(/[A-Za-z~]/); - return finalIndex === -1; - } - - // OSC sequence: ESC ] - if (secondChar === "]") { - // OSC ends with BEL or ST (ESC \) - return !str.includes(BEL) && !str.includes(`${ESC}\\`); - } - - // DCS sequence: ESC P - if (secondChar === "P") { - // DCS ends with ST (ESC \) - return !str.includes(`${ESC}\\`); - } - - return false; - } - - /** - * Flush any remaining buffered data. - * Call this when the terminal session ends. - */ - flush(): string { - const remaining = this.buffer; - this.buffer = ""; - return remaining.replace(COMBINED_PATTERN, ""); - } - - /** - * Reset the filter state. - */ - reset(): void { - this.buffer = ""; - } -} - -/** - * Filters out terminal query responses from PTY output. - * Stateless version - does not handle chunked sequences. - * - * @param data - Raw PTY output data - * @returns Filtered data with query responses removed - * @deprecated Use TerminalEscapeFilter class for proper chunked handling - */ -export function filterTerminalQueryResponses(data: string): string { - return data.replace(COMBINED_PATTERN, ""); -} - -// Export patterns for testing -export const patterns = FILTER_PATTERNS; diff --git a/apps/desktop/src/main/lib/terminal-manager.test.ts b/apps/desktop/src/main/lib/terminal-manager.test.ts index f15956e62e8..f4467f6e8b1 100644 --- a/apps/desktop/src/main/lib/terminal-manager.test.ts +++ b/apps/desktop/src/main/lib/terminal-manager.test.ts @@ -509,55 +509,29 @@ describe("TerminalManager", () => { expect(dataHandler).toHaveBeenCalledWith("test output\n"); }); - it("should filter escape sequences from scrollback but emit raw data to xterm", async () => { - // Integration test: verifies that filterTerminalQueryResponses is properly - // integrated - raw data goes to xterm, filtered data goes to scrollback. - // See terminal-escape-filter.test.ts for detailed filter behavior tests. + it("should pass through raw data including escape sequences", async () => { + // Terminal manager passes raw data through - filtering happens at the + // display layer (xterm.js parser hooks in suppressQueryResponses.ts) const dataHandler = mock(() => {}); await manager.createOrAttach({ - tabId: "tab-filter", + tabId: "tab-raw", workspaceId: "workspace-1", tabTitle: "Test Tab", workspaceName: "Test Workspace", }); - manager.on("data:tab-filter", dataHandler); + manager.on("data:tab-raw", dataHandler); const onDataCallback = mockPty.onData.mock.results[0]?.value; - const dataWithResponses = + const dataWithEscapes = "hello\x1b[2;1R\x1b[?1;0cworld\x1b]10;rgb:ffff/ffff/ffff\x07\n"; if (onDataCallback) { - onDataCallback(dataWithResponses); + onDataCallback(dataWithEscapes); } - // Raw data emitted to xterm (needs to process the responses) - expect(dataHandler).toHaveBeenCalledWith(dataWithResponses); - - // Terminate and recover to check scrollback - const exitPromise = new Promise((resolve) => { - manager.once("exit:tab-filter", () => resolve()); - }); - - const onExitCallback = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback) { - await onExitCallback({ exitCode: 0, signal: undefined }); - } - - await exitPromise; - await manager.cleanup(); - - const result = await manager.createOrAttach({ - tabId: "tab-filter", - workspaceId: "workspace-1", - tabTitle: "Test Tab", - workspaceName: "Test Workspace", - }); - - // Scrollback has filtered data (no escape sequence responses) - expect(result.wasRecovered).toBe(true); - expect(result.scrollback).toBe("helloworld\n"); + // Raw data passed through unchanged + expect(dataHandler).toHaveBeenCalledWith(dataWithEscapes); }); 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 d9572fb46f0..198f9155b61 100644 --- a/apps/desktop/src/main/lib/terminal-manager.ts +++ b/apps/desktop/src/main/lib/terminal-manager.ts @@ -3,7 +3,6 @@ import os from "node:os"; import * as pty from "node-pty"; import { PORTS } from "shared/constants"; import { getShellArgs, getShellEnv } from "./agent-setup"; -import { TerminalEscapeFilter } from "./terminal-escape-filter"; import { HistoryReader, HistoryWriter } from "./terminal-history"; interface TerminalSession { @@ -19,7 +18,6 @@ interface TerminalSession { deleteHistoryOnExit?: boolean; wasRecovered: boolean; historyWriter?: HistoryWriter; - escapeFilter: TerminalEscapeFilter; } export interface TerminalDataEvent { @@ -147,31 +145,17 @@ export class TerminalManager extends EventEmitter { isAlive: true, wasRecovered, historyWriter, - escapeFilter: new TerminalEscapeFilter(), }; ptyProcess.onData((data) => { - // Filter terminal query responses for storage only - // xterm needs raw data for proper terminal behavior (DA/DSR/OSC responses) - const filteredData = session.escapeFilter.filter(data); - session.scrollback += filteredData; - session.historyWriter?.write(filteredData); - // Emit ORIGINAL data to xterm - it needs to process query responses + session.scrollback += data; + session.historyWriter?.write(data); this.emit(`data:${tabId}`, data); }); ptyProcess.onExit(async ({ exitCode, signal }) => { session.isAlive = false; - - // Flush any buffered data from the escape filter - const remaining = session.escapeFilter.flush(); - if (remaining) { - session.scrollback += remaining; - session.historyWriter?.write(remaining); - } - await this.closeHistory(session, exitCode); - this.emit(`exit:${tabId}`, exitCode, signal); const timeout = setTimeout(() => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index ebbed8a51e2..12f29eb99d5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -80,11 +80,11 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const container = terminalRef.current; if (!container) return; - const { xterm, fitAddon } = createTerminalInstance( - container, - workspaceCwd, - terminalTheme, - ); + const { + xterm, + fitAddon, + cleanup: cleanupQuerySuppression, + } = createTerminalInstance(container, workspaceCwd, terminalTheme); xtermRef.current = xterm; fitAddonRef.current = fitAddon; isExitedRef.current = false; @@ -190,6 +190,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { inputDisposable.dispose(); cleanupFocus?.(); cleanupResize(); + cleanupQuerySuppression(); // Keep PTY running for reattachment detachRef.current({ tabId }); setSubscriptionEnabled(false); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index a67d08e514a..a917a311802 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -11,6 +11,7 @@ import { isAppHotkey } from "shared/hotkeys"; import { builtInThemes, DEFAULT_THEME_ID } from "shared/themes"; import { RESIZE_DEBOUNCE_MS, TERMINAL_OPTIONS } from "./config"; import { FilePathLinkProvider } from "./FilePathLinkProvider"; +import { suppressQueryResponses } from "./suppressQueryResponses"; /** * Get the default terminal theme from localStorage cache. @@ -55,6 +56,7 @@ export function createTerminalInstance( ): { xterm: XTerm; fitAddon: FitAddon; + cleanup: () => void; } { // Use provided theme, or fall back to localStorage-based default to prevent flash const theme = initialTheme ?? getDefaultTerminalTheme(); @@ -82,6 +84,10 @@ export function createTerminalInstance( xterm.loadAddon(clipboardAddon); xterm.loadAddon(unicode11Addon); + // Suppress terminal query responses (DA1, DA2, CPR, OSC color responses, etc.) + // These are protocol-level responses that should be handled internally, not displayed + const cleanupQuerySuppression = suppressQueryResponses(xterm); + // Register file path link provider (Cmd+Click to open in Cursor/VSCode) const filePathLinkProvider = new FilePathLinkProvider( xterm, @@ -113,7 +119,11 @@ export function createTerminalInstance( // Fit after addons are loaded fitAddon.fit(); - return { xterm, fitAddon }; + return { + xterm, + fitAddon, + cleanup: cleanupQuerySuppression, + }; } /** diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/suppressQueryResponses.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/suppressQueryResponses.ts new file mode 100644 index 00000000000..bb0fec9296e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/suppressQueryResponses.ts @@ -0,0 +1,55 @@ +import type { Terminal } from "@xterm/xterm"; + +/** + * Registers parser hooks to suppress terminal query responses from being displayed. + * + * When programs query terminal capabilities (DA1, DA2, CPR, etc.), the terminal + * responds with escape sequences. These responses should be handled internally, + * not displayed as visible text. xterm.js's parser hooks let us intercept and + * suppress these sequences at the display layer. + * + * @param terminal - The xterm.js Terminal instance + * @returns Cleanup function to dispose all registered handlers + */ +export function suppressQueryResponses(terminal: Terminal): () => void { + const disposables: { dispose: () => void }[] = []; + const parser = terminal.parser; + + // CSI sequences ending in 'c' - Device Attributes responses + // DA1: ESC[?1;2c (primary device attributes) + // DA2: ESC[>0;276;0c (secondary device attributes) + // Also handles ESC[0;276;0c (without ? or > prefix) + disposables.push(parser.registerCsiHandler({ final: "c" }, () => true)); + + // CSI sequences ending in 'R' - Cursor Position Report + // CPR: ESC[24;1R (row;column) + disposables.push(parser.registerCsiHandler({ final: "R" }, () => true)); + + // CSI sequences ending in 'y' with '$' intermediate - Mode Reports + // DECRPM: ESC[?1;2$y (private mode report) + // Standard mode report: ESC[12;2$y + disposables.push( + parser.registerCsiHandler({ intermediates: "$", final: "y" }, () => { + return true; // Suppress - don't display + }), + ); + + // OSC 10-19 - Color query responses + // OSC 10: foreground color (ESC]10;rgb:ffff/ffff/ffff BEL) + // OSC 11: background color + // OSC 12: cursor color + // etc. + for (let i = 10; i <= 19; i++) { + disposables.push( + parser.registerOscHandler(i, () => { + return true; // Suppress - don't display + }), + ); + } + + return () => { + for (const disposable of disposables) { + disposable.dispose(); + } + }; +}