diff --git a/packages/chord/src/output.ts b/packages/chord/src/output.ts index a31cdfd2a..e0e36a183 100644 --- a/packages/chord/src/output.ts +++ b/packages/chord/src/output.ts @@ -32,13 +32,16 @@ export interface OutputSkip { const NEWLINE = 0x0a; const INVALID_OUTPUT = /[\x00-\x08\x0b-\x1f\ufff9-\ufffb]/g; +/** Whole 7-bit escape sequences: strings (OSC, DCS, APC, PM, SOS) up to BEL or ST, CSI, and short `ESC x` forms. */ +const ESCAPE_SEQUENCE = + /\x1b[\]PX^_][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\x1b[\x20-\x2f]*[\x30-\x7e]/g; const encoder = new TextEncoder(); /** Slices decode exactly: a U+FEFF at a slice's start is text, not a byte-order mark. */ const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); -/** Remove control characters that break display and transcripts; tabs and newlines stay. */ +/** Remove escape sequences and control characters that break display and transcripts; tabs and newlines stay. */ export function sanitizeOutput(text: string): string { - return text.replace(INVALID_OUTPUT, ""); + return text.replace(ESCAPE_SEQUENCE, "").replace(INVALID_OUTPUT, ""); } /** diff --git a/packages/chord/test/output.test.ts b/packages/chord/test/output.test.ts index 00e7d647e..b35a62bf6 100644 --- a/packages/chord/test/output.test.ts +++ b/packages/chord/test/output.test.ts @@ -57,6 +57,18 @@ describe("tool output bounds", () => { expect(sanitizeOutput("a\0b\tc\nd\re\u0007f\ufff9g\ufffbh😀")).toBe("ab\tc\ndefgh😀"); }); + // #11604 + it("removes whole ANSI escape sequences, not only their ESC bytes", () => { + expect(sanitizeOutput("\x1b[31mERROR\x1b[0m\n")).toBe("ERROR\n"); + expect(sanitizeOutput("\x1b[1;38;5;208mx\x1b[?25l\x1b[2K\x1b(By\x1b7z")).toBe("xyz"); + expect(sanitizeOutput("\x1b]8;;https://pi.dev\x07link\x1b]8;;\x1b\\ \x1b]0;title\x07ok")).toBe("link ok"); + const output = new OutputBuffer({ retain: "tail", maxBytes: 1024, maxLines: 100 }); + output.push("\x1b[3"); + output.push("1mERROR\x1b[0"); + output.push("m\n"); + expect(output.snapshot().text).toBe("ERROR\n"); + }); + it("keeps output within the limits unchanged", () => { expect(bound("a\nb\n", head(2))).toEqual({ kept: "a\nb\n", droppedBytes: 0, droppedLines: 0 }); expect(bound("a\nb", tail(2))).toEqual({ kept: "a\nb", droppedBytes: 0, droppedLines: 0 });