diff --git a/apps/desktop/src/main/lib/terminal-escape-filter.test.ts b/apps/desktop/src/main/lib/terminal-escape-filter.test.ts new file mode 100644 index 00000000000..ff185ee800c --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-escape-filter.test.ts @@ -0,0 +1,521 @@ +import { describe, expect, it } from "bun:test"; +import { + filterTerminalQueryResponses, + TerminalEscapeFilter, +} from "./terminal-escape-filter"; + +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 new file mode 100644 index 00000000000..9ba7e5bef54 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-escape-filter.ts @@ -0,0 +1,255 @@ +/** + * 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 26466f93de7..c46210d352c 100644 --- a/apps/desktop/src/main/lib/terminal-manager.test.ts +++ b/apps/desktop/src/main/lib/terminal-manager.test.ts @@ -3,6 +3,7 @@ import { promises as fs } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import * as pty from "node-pty"; +import { getHistoryDir } from "./terminal-history"; import { TerminalManager } from "./terminal-manager"; // Use real history implementation - it will write to tmpdir thanks to NODE_ENV=test @@ -148,6 +149,26 @@ describe("TerminalManager", () => { expect(mockPty.resize).toHaveBeenCalledWith(100, 30); }); + + it("should filter recovered scrollback from history", async () => { + const workspaceId = "workspace-1"; + const tabId = "tab-recover"; + const historyDir = getHistoryDir(workspaceId, tabId); + await fs.mkdir(historyDir, { recursive: true }); + const ESC = "\x1b"; + const rawScrollback = `before${ESC}[1;1Rafter${ESC}[?1;0c`; + await fs.writeFile(join(historyDir, "scrollback.bin"), rawScrollback); + + const result = await manager.createOrAttach({ + tabId, + workspaceId, + tabTitle: "Test Tab", + workspaceName: "Test Workspace", + }); + + expect(result.wasRecovered).toBe(true); + expect(result.scrollback).toBe("beforeafter"); + }); }); describe("write", () => { @@ -509,8 +530,6 @@ describe("TerminalManager", () => { }); 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({ diff --git a/apps/desktop/src/main/lib/terminal-manager.ts b/apps/desktop/src/main/lib/terminal-manager.ts index 198f9155b61..b7047199ff5 100644 --- a/apps/desktop/src/main/lib/terminal-manager.ts +++ b/apps/desktop/src/main/lib/terminal-manager.ts @@ -3,6 +3,7 @@ import os from "node:os"; import * as pty from "node-pty"; import { PORTS } from "shared/constants"; import { getShellArgs, getShellEnv } from "./agent-setup"; +import { TerminalEscapeFilter } from "./terminal-escape-filter"; import { HistoryReader, HistoryWriter } from "./terminal-history"; interface TerminalSession { @@ -18,6 +19,7 @@ interface TerminalSession { deleteHistoryOnExit?: boolean; wasRecovered: boolean; historyWriter?: HistoryWriter; + escapeFilter: TerminalEscapeFilter; } export interface TerminalDataEvent { @@ -113,6 +115,13 @@ export class TerminalManager extends EventEmitter { } } + if (recoveredScrollback) { + // Strip protocol responses from recovered history so replays stay clean + const recoveryFilter = new TerminalEscapeFilter(); + recoveredScrollback = + recoveryFilter.filter(recoveredScrollback) + recoveryFilter.flush(); + } + const shellArgs = getShellArgs(shell); const ptyProcess = pty.spawn(shell, shellArgs, { @@ -145,16 +154,29 @@ export class TerminalManager extends EventEmitter { isAlive: true, wasRecovered, historyWriter, + escapeFilter: new TerminalEscapeFilter(), }; ptyProcess.onData((data) => { - session.scrollback += data; - session.historyWriter?.write(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 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);