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");