feat: port ANSI sanitizer and integrate into paint pipeline

Port Ink's sanitize-ansi.ts to strip non-SGR/non-OSC ANSI escape
sequences (cursor movement, screen clearing) from text nodes.
Integrated as the final step of renderTextWithInlineStyles() so all
squashed text is sanitized before layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-26 13:29:33 +08:00
parent f6bd888444
commit 57887eefe1
3 changed files with 60 additions and 1 deletions
+2 -1
View File
@@ -8,6 +8,7 @@ import {
tokenize,
} from "@alcalzone/ansi-tokenize";
import { applyChalk } from "./text-style.ts";
import { sanitizeAnsi } from "./sanitize-ansi.ts";
import Yoga from "yoga-layout";
import type {
TuiNode,
@@ -288,7 +289,7 @@ function renderTextWithInlineStyles(node: TuiText | TuiVirtualText, acc: TextPro
}
// Skip comments inserted by Vue for null/undefined renders
}
return out;
return sanitizeAnsi(out);
}
type BoxStyle = (typeof cliBoxes)[keyof cliBoxes.Boxes];
@@ -0,0 +1,25 @@
import { describe, test, expect } from "vite-plus/test";
import { sanitizeAnsi } from "./sanitize-ansi.ts";
describe("sanitize-ansi", () => {
test("preserves SGR (color) sequences", () => {
expect(sanitizeAnsi("\x1b[31mred\x1b[0m")).toBe("\x1b[31mred\x1b[0m");
});
test("strips cursor movement", () => {
expect(sanitizeAnsi("\x1b[2Ahello")).toBe("hello");
});
test("strips screen clearing", () => {
expect(sanitizeAnsi("\x1b[2Jhello")).toBe("hello");
});
test("preserves OSC (hyperlinks)", () => {
const link = "\x1b]8;;https://example.com\x07click\x1b]8;;\x07";
expect(sanitizeAnsi(link)).toBe(link);
});
test("passes through plain text unchanged", () => {
expect(sanitizeAnsi("hello world")).toBe("hello world");
});
});
@@ -0,0 +1,33 @@
import { tokenizeAnsi, hasAnsiControlCharacters } from "./ansi-tokenizer.ts";
const sgrParametersRegex = /^[\d:;]*$/;
// Strip ANSI escape sequences that would conflict with vue-tui's layout.
// Preserved: SGR sequences (colors, bold, etc. - end with 'm') and
// OSC sequences (hyperlinks, etc. - ESC ] or C1 OSC).
// Stripped: cursor movement, screen clearing, and other control sequences.
export function sanitizeAnsi(text: string): string {
if (!hasAnsiControlCharacters(text)) {
return text;
}
let output = "";
for (const token of tokenizeAnsi(text)) {
if (token.type === "text" || token.type === "osc") {
output += token.value;
continue;
}
if (
token.type === "csi" &&
token.finalCharacter === "m" &&
token.intermediateString === "" &&
sgrParametersRegex.test(token.parameterString)
) {
output += token.value;
}
}
return output;
}