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) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-31 05:48:49 +08:00
committed by GitHub
parent 9b5eac3ba4
commit 32b99da7a0
2 changed files with 53 additions and 6 deletions
@@ -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(() => () => (
<Box width={20}>
<Text wrap="wrap">{text}</Text>
</Box>
)),
{ 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(() => () => (
<Box width={5}>
<Text wrap="wrap">{text}</Text>
</Box>
)),
{ 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(
+16 -4
View File
@@ -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");