diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1ed5d448d..31571b8e9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -26,6 +26,7 @@ - Fixed streamed tool calls re-running their call renderer on every argument delta; the interactive mode now renders the latest arguments once per frame, so a large streamed `write` no longer re-highlights its whole content per delta ([#9265](https://github.com/earendil-works/pi/issues/9265)) - Fixed the login dialog opening sign-in URLs that are not web URLs, and printing control characters from sign-in, device-code, and info URLs, which could end a terminal hyperlink and send escape sequences to the terminal - Fixed `tui.select.pageUp` and `tui.select.pageDown` being ignored in slash command and file autocomplete, `/settings`, and the model, scoped models, login, fork, `/mcp`, and llama.cpp selectors. In fullscreen mode, PageUp/PageDown now move an open list or autocomplete menu instead of scrolling the transcript, and a page key bound to Ctrl+V pages autocomplete instead of pasting an image ([#7629](https://github.com/earendil-works/pi/issues/7629), based on [#7680](https://github.com/earendil-works/pi/pull/7680) by [@muyiyr](https://github.com/muyiyr)) +- Fixed tool output and `!` command output showing the parameters of control sequences with a `<`, `=` or `>` prefix as text, for example `35;59;1M` from a mouse report `ESC[<35;59;1M` or `0;276;0c` from a terminal reply `ESC[>0;276;0c`. Text after the keypad-mode sequences `ESC=` and `ESC>` is still kept ([#11377](https://github.com/earendil-works/pi/issues/11377)) ## [1.4.0] - 2026-10-21 diff --git a/packages/coding-agent/src/utils/ansi.ts b/packages/coding-agent/src/utils/ansi.ts index cad5eb65b..6d49fc1bc 100644 --- a/packages/coding-agent/src/utils/ansi.ts +++ b/packages/coding-agent/src/utils/ansi.ts @@ -32,8 +32,11 @@ const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)"; // OSC sequences: ESC ] ... ST const OSC_START = "\\u001B\\]"; -// CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :), then final byte -const CSI_START = "[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?"; +// CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :), then final byte. +// A private prefix (<, =, >) is accepted only right after a real CSI introducer (ESC [ or 0x9B), so +// ESC[<35;59;1M (SGR mouse report) is stripped whole. After a bare ESC these bytes stay final bytes: +// ESC = and ESC > (keypad modes) must not swallow the digits of the text that follows. +const CSI_START = "(?:(?:\\u001B\\[|\\u009B)[<=>]|[\\u001B\\u009B][[\\]()#;?]*)(?:\\d{1,4}(?:[;:]\\d{0,4})*)?"; const CSI_FINAL = "[\\dA-PR-TZcf-nq-uy=><~]"; // Complete sequences. OSC is non-greedy until the first ST. diff --git a/packages/coding-agent/test/ansi-utils.test.ts b/packages/coding-agent/test/ansi-utils.test.ts index 15bb43140..f108e482c 100644 --- a/packages/coding-agent/test/ansi-utils.test.ts +++ b/packages/coding-agent/test/ansi-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { stripAnsi } from "../src/utils/ansi.ts"; +import { splitIncompleteAnsiSuffix, stripAnsi } from "../src/utils/ansi.ts"; function referenceAnsiRegex(): RegExp { const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)"; @@ -107,4 +107,29 @@ describe("stripAnsi", () => { const input = "a\x1b[31mred\x1b[0m\x1b]8;;https://example.com\x07link\x1b]8;;\x07z"; expect(stripAnsi(input)).toBe("aredlinkz"); }); + + // https://github.com/earendil-works/pi/issues/11377 + it("strips private-parameter CSI sequences with their parameters", () => { + expect(stripAnsi("\x1b[<35;59;1Mstep 1")).toBe("step 1"); + expect(stripAnsi("a\x1b[<0;10;20mb")).toBe("ab"); + expect(stripAnsi("a\x1b[>0;276;0cb")).toBe("ab"); + expect(stripAnsi("a\x1b[>cb")).toBe("ab"); + expect(stripAnsi("a\x1b[=5hb")).toBe("ab"); + expect(stripAnsi("a\x9b<35;59;1Mb")).toBe("ab"); + }); + + // https://github.com/earendil-works/pi/issues/11377 + it("keeps digits after bare ESC keypad-mode sequences", () => { + expect(stripAnsi("\x1b[?1h\x1b=123 tests passed")).toBe("123 tests passed"); + expect(stripAnsi("\x1b[?1l\x1b>42 files")).toBe("42 files"); + expect(stripAnsi("\x1b<10 items")).toBe("10 items"); + }); +}); + +describe("splitIncompleteAnsiSuffix", () => { + // https://github.com/earendil-works/pi/issues/11377 + it("holds back unfinished private-parameter CSI sequences", () => { + expect(splitIncompleteAnsiSuffix("x\x1b[<35;59")).toEqual({ complete: "x", pending: "\x1b[<35;59" }); + expect(splitIncompleteAnsiSuffix("x\x1b=")).toEqual({ complete: "x\x1b=", pending: "" }); + }); });