From 32b99da7a0fb61574afe13df36c79ea6b5b8b13b Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 31 May 2026 05:48:49 +0800 Subject: [PATCH] fix(runtime): don't re-wrap fitting text, so non-hyperlink OSC text survives (Ink parity) (#82) vue-tui called wrapText unconditionally for every Text node; Ink only wraps when the text overflows its cell (render-node-to-output.ts:144-150). wrap-ansi can't account for the visible width of non-hyperlink OSC sequences (e.g. a set-title ESC]0;...BEL), so re-wrapping fitting text that contained one consumed the following visible text. Adds Ink's wrap-only-on-overflow guard to wrapText: when measureTextNatural(text).width <= width, return the text verbatim (also matches Ink's literal-tab handling as a bonus). Now "\x1b]0;My Title\x07Some text" renders "Some text" (was a single char). The overflow case (a non-hyperlink OSC before an overflowing word) is a separate remaining divergence tracked to gap #9 (vue wraps raw text; Ink wraps sanitized) -- its test stays skipped with an honest note. Un-skips 2 OSC tests (BEL + ST terminated). Co-authored-by: Claude Opus 4.8 (1M context) --- .../integration/components/text.test.tsx | 39 ++++++++++++++++++- packages/runtime/src/host/text-measure.ts | 20 ++++++++-- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/runtime-tests/integration/components/text.test.tsx b/packages/runtime-tests/integration/components/text.test.tsx index dde37fd..a3b3407 100644 --- a/packages/runtime-tests/integration/components/text.test.tsx +++ b/packages/runtime-tests/integration/components/text.test.tsx @@ -619,8 +619,7 @@ test("do not wrap text with ST-terminated OSC hyperlinks", async () => { expect(stripAnsi(output)).toBe("Click here"); }); -// Feature gap: non-hyperlink OSC title sequences are consumed into the OSC payload -test.skip("do not wrap text with non-hyperlink OSC sequences", async () => { +test("do not wrap text with non-hyperlink OSC (BEL-terminated) sequences", async () => { const text = "\x1b]0;My Title\x07Some text"; const output = renderToString( defineComponent(() => () => ( @@ -633,6 +632,42 @@ test.skip("do not wrap text with non-hyperlink OSC sequences", async () => { expect(stripAnsi(output)).toBe("Some text"); }); +test("do not wrap text with non-hyperlink OSC (ST-terminated) sequences", async () => { + const text = "\x1b]0;My Title\x1b\\Some text"; + const output = renderToString( + defineComponent(() => () => ( + + {text} + + )), + { columns: 20 }, + ); + expect(stripAnsi(output)).toBe("Some text"); +}); + +// KNOWN vue-tui divergence — pending the sanitize-before-wrap fix (parity gap #9), +// NOT a shared wrap-ansi limitation. When a generic (non-hyperlink) OSC sequence +// precedes a word long enough to force wrap-ansi's wrapWord codepath, vue-tui wraps +// the RAW string: wrap-ansi@10 only protects `]8;;` links, so it counts the OSC +// payload as visible columns and drops a char — vue renders "abcd\nfghij" here, NOT +// Ink's correct "abcde\nfghij". Ink does NOT corrupt this: it wraps the SANITIZED +// text (squash-text-nodes runs sanitizeAnsi before measure/wrap), so the OSC never +// reaches wrap-ansi. The FITTING case (above) is fixed via Ink's wrap-only-on-overflow +// guard; this overflow case is fixed once vue measures/wraps sanitized text (gap #9). +// The assertion below is Ink's correct output; un-skip when gap #9 lands. +test.skip("hard-wrap long word after non-hyperlink OSC sequence", async () => { + const text = "\x1b]0;My Title\x07abcdefghij"; + const output = renderToString( + defineComponent(() => () => ( + + {text} + + )), + { columns: 5 }, + ); + expect(stripAnsi(output)).toBe("abcde\nfghij"); +}); + test("hard-wrap single-word BEL-terminated OSC hyperlink", async () => { const hyperlink = "\x1b]8;;https://example.com\x07abcdefghij\x1b]8;;\x07"; const output = renderToString( diff --git a/packages/runtime/src/host/text-measure.ts b/packages/runtime/src/host/text-measure.ts index 411a8e7..39ef911 100644 --- a/packages/runtime/src/host/text-measure.ts +++ b/packages/runtime/src/host/text-measure.ts @@ -100,11 +100,23 @@ export function safeSliceEnd(text: string, maxCols: number): string { export function wrapText(text: string, width: number, mode: WrapMode = "wrap"): string[] { if (width <= 0) return [""]; - if (mode === "wrap") { - return wrapAnsi(text, width, { hard: true, trim: false }).split("\n"); - } + if (mode === "wrap" || mode === "hard") { + // Mirror Ink's render-node-to-output.ts:144-150: only invoke wrap-ansi when + // the text is actually wider than the cell (`currentWidth > maxWidth`). + // wrap-ansi@10 cannot account for the visible width of NON-hyperlink OSC + // sequences (its regex only recognises SGR and `]8;;` links), so it counts an + // OSC payload like a set-title `ESC]0;…BEL` as visible columns and re-wraps — + // mangling the following text. string-width DOES discount those bytes, so when + // the text already fits we must pass it through verbatim rather than asking + // wrap-ansi to "wrap" it. This also matches Ink, which skips wrapText entirely + // for fitting text. Splitting on `\n` preserves any embedded hard newlines, + // exactly as Ink's `output.write` does for the unwrapped string. + if (measureTextNatural(text).width <= width) return text.split("\n"); + + if (mode === "wrap") { + return wrapAnsi(text, width, { hard: true, trim: false }).split("\n"); + } - if (mode === "hard") { // `wordWrap: false` ensures breaks happen at the exact character boundary, // not at word boundaries. This is what Ink's "hard" wrap mode does. return wrapAnsi(text, width, { hard: true, trim: false, wordWrap: false }).split("\n");