From fc1d0d7bf39ed780ca9de690b3a78360dd09bbd0 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 30 Nov 2025 17:16:48 -0800 Subject: [PATCH] fix(desktop): suppress terminal query responses using xterm.js parser hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hacky PTY-level escape sequence filtering with proper xterm.js parser hooks. Terminal query responses (DA1, DA2, CPR, OSC color responses) are now suppressed at the display layer using the official xterm.js API. Changes: - Remove terminal-escape-filter.ts module and its tests - Add suppressQueryResponses.ts with xterm.js parser hooks - Update terminal-manager.ts to pass raw data through - Integrate parser hooks in terminal creation The parser hooks intercept CSI sequences ending in 'c' (device attributes), 'R' (cursor position reports), '$y' (mode reports), and OSC 10-19 (color query responses) - returning true to suppress display. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../main/lib/terminal-escape-filter.test.ts | 522 ------------------ .../src/main/lib/terminal-escape-filter.ts | 255 --------- .../src/main/lib/terminal-manager.test.ts | 44 +- apps/desktop/src/main/lib/terminal-manager.ts | 20 +- .../TabsContent/Terminal/Terminal.tsx | 11 +- .../TabsContent/Terminal/helpers.ts | 12 +- .../Terminal/suppressQueryResponses.ts | 55 ++ 7 files changed, 83 insertions(+), 836 deletions(-) delete mode 100644 apps/desktop/src/main/lib/terminal-escape-filter.test.ts delete mode 100644 apps/desktop/src/main/lib/terminal-escape-filter.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/suppressQueryResponses.ts 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(); + } + }; +}