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:
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user