diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index 0f38375..fdf42b9 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -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]; diff --git a/packages/runtime/src/paint/sanitize-ansi.test.ts b/packages/runtime/src/paint/sanitize-ansi.test.ts new file mode 100644 index 0000000..6e6aa43 --- /dev/null +++ b/packages/runtime/src/paint/sanitize-ansi.test.ts @@ -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"); + }); +}); diff --git a/packages/runtime/src/paint/sanitize-ansi.ts b/packages/runtime/src/paint/sanitize-ansi.ts new file mode 100644 index 0000000..671019a --- /dev/null +++ b/packages/runtime/src/paint/sanitize-ansi.ts @@ -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; +}