fix(coding-agent): strip private-parameter CSI sequences in stripAnsi

stripAnsi treated <, = and > after ESC [ as final bytes, so ESC[<35;59;1M
(SGR mouse report) left 35;59;1M in tool output. Accept one private prefix
byte only directly after a CSI introducer (ESC [ or 0x9B). After a bare ESC
these bytes stay final bytes, so ESC= and ESC> (keypad modes) keep the
digits of the text that follows.

closes #11377
This commit is contained in:
Petr Houška 2026-10-28 20:09:32 +01:00
parent 4e5cd1a973
commit b5fc6dc84f
3 changed files with 32 additions and 3 deletions

View file

@ -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

View file

@ -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.

View file

@ -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: "" });
});
});