fix(runtime): a 0-width box wraps its text onto its own line, not drops it (Ink parity) (#103)

A 0-width text container (flexBasis=0, width=0, width="0%", a negative parsed
percent) DROPPED its text in vue where Ink wraps it onto its own row. Ink's wrapText
has no width<=0 guard: wrapAnsi("A", 0, {hard:true, trim:false}) = "\nA" (height 2),
so the glyph occupies a second row and a row-sibling renders "B\nA". vue collapsed it
to height 1 (wrapText's `width <= 0 -> [""]` guard), then the paint clamp re-collapsed
the wrap, so the sibling overwrote the text -> "B".

Fixes, all confined to the width<=0 branch:
- text-measure.ts: drop the `width <= 0 -> [""]` guard. A styled string can't go
  through wrapAnsi at width 0 (wrap-ansi@10 byte-splits SGR codes -> garbage like
  "B\n["), so the wrap/hard branch routes through a new wrapZeroWidthAnsi that
  derives its line STRUCTURE from wrapAnsi on the PLAIN (stripped) text — which is
  correct for zero-width graphemes (ZWSP/ZWNJ/ZWJ/combining/VS16/BOM, interior and
  trailing) — then re-applies SGR per grapheme via slice-ansi's slot model, keeping
  wide glyphs whole. Input is NFC-normalized first so combining sequences compose to
  match wrap-ansi (and vue's own normal-width path), not the decomposed source bytes.
- paint.ts: pad the bg to the TRUE wrap width (0), not a >=1-clamped width — a 0-width
  box pads nothing (Ink getMaxWidth=0); clamping bg-padded the empty leading wrap line
  into a stray cell that collided with a row-sibling.

A comparison-battery test locks wrapZeroWidthAnsi's plain output to wrapAnsi's
width-0 layout for ~22 inputs (zero-width, wide, emoji, ZWJ, combining decomposed +
composed, multiline). The full layout suite is byte-unchanged for all width>=1 cases.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-31 23:54:12 +08:00
committed by GitHub
parent 759af7fa5f
commit b6dc9a0919
4 changed files with 332 additions and 19 deletions
@@ -207,20 +207,13 @@ test("non-number/non-string flexBasis falls back to auto (Ink parity), does not
expect(lastFrame({ trimLines: true })).toBe("AB");
});
// PRE-EXISTING DOWNSTREAM DIVERGENCE — out of scope for the string→percent setter.
// A zero/negative parsed percent ("0"→0%, "-5"→-5%, "0x10"→parseInt=0→0%) produces
// a 0-width inner box. Ink renders "B\nA" (B on the row, A wraps onto the next line);
// vue renders "B" (A dropped). EVIDENCE: with width=6 and this exact tree, the frame is
// input vue-OLD (setFlexBasis(string)) vue-NEW (setFlexBasisPercent) Ink v7.0.4
// "0" "B" "B" "B\nA"
// "-5" "B" "B" "B\nA"
// "0x10" "B" "B" "B\nA"
// vue-OLD already differed from Ink here, so this PR's setter change neither caused nor
// fixed it — the two setters yield byte-identical yoga COMPUTED layout for these inputs;
// the "B" vs "B\nA" gap is a separate downstream paint/wrap divergence. Skipped (not
// xfail-asserted as "B") so we don't lock vue's current behavior as correct: the target
// is Ink's "B\nA". Tracked separately from the flexBasis-percent setter work.
test.skip("zero/negative flexBasis% wraps the sibling in Ink (downstream divergence)", async () => {
// A zero/negative parsed percent ("0"→0%, "-5"→-5%, "0x10"→parseInt=0→0%) produces a
// 0-width inner box. Ink renders "B\nA" (B on the row, A wraps onto the next line). The
// 0-width text measures via wrapAnsi("A", 0, {hard:true, trim:false}) = "\nA" → height 2,
// so A occupies a second row. vue previously dropped the text ("B") because wrapText's
// `width <= 0 → [""]` guard collapsed the measure to height 1. Verified against Ink v7.0.4
// (@40b3a75): all four of flexBasis=0/"0%" and width=0/"0%" render "B\nA".
test("zero/negative flexBasis% wraps the sibling in Ink (downstream divergence)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
@@ -232,6 +225,86 @@ test.skip("zero/negative flexBasis% wraps the sibling in Ink (downstream diverge
)),
{ columns: 100 },
);
// Ink v7.0.4 renders "B\nA"; vue currently renders "B" (see comment above).
// Ink v7.0.4 renders "B\nA".
expect(lastFrame({ trimLines: true })).toBe("B\nA");
});
test("zero-width Box wraps its text onto its own line (width={0})", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box width={6}>
<Box width={0}>
<Text>A</Text>
</Box>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
// Ink v7.0.4 renders "B\nA": the 0-width text measures height 2 via
// wrapAnsi("A", 0, {hard:true}) = "\nA", so A wraps below sibling B.
expect(lastFrame({ trimLines: true })).toBe("B\nA");
});
test('zero-percent-width Box wraps its text onto its own line (width="0%")', async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box width={6}>
<Box width="0%">
<Text>A</Text>
</Box>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
// Ink v7.0.4 renders "B\nA" — same as width={0}; a 0% resolved width is also 0px.
expect(lastFrame({ trimLines: true })).toBe("B\nA");
});
test("zero-width Box with EMPTY text adds no spurious row", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box width={6}>
<Box width={0}>
<Text>{""}</Text>
</Box>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
// Ink v7.0.4 renders "B": empty text measures width 0 (≤ 0), so it never wraps and
// never gains a second row. The 0-width fix must NOT add a blank row here.
expect(lastFrame({ trimLines: true })).toBe("B");
});
test("zero-width Box with backgroundColor wraps cleanly, keeping the bg glyph (Ink parity)", async () => {
// Regression guard for the wrap-ansi width<=0 byte-split: at width 0 the 0-width Box's
// text wraps onto its own row, but vue bakes the bg color INTO the string before wrapping,
// and wrap-ansi@10 byte-splits the SGR escapes of a STYLED string at width<=0
// (wrapAnsi("\x1b[41mA\x1b[49m", 0) = "\x1b\n[\n4\n1\nm\nA\n…"). That scattered the escape
// bytes across rows and rendered a garbage "B\n[" (the 2nd byte of "\x1b[41m"). wrapText
// now routes width<=0 styled text through an ANSI-aware per-grapheme split, matching Ink,
// which wraps PLAIN text and colorizes per line afterwards.
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
<Box width={0} backgroundColor="red">
<Text>A</Text>
</Box>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
// RAW-byte parity target captured from Ink v7.0.4 (@40b3a75) with chalk level 3:
// "B\n\x1b[41mA\x1b[49m\n" — row 2 keeps the FULL bg-colored glyph (overflow:visible).
// vue trims trailing whitespace/newlines per frame line, so the equivalent raw frame is
// "B\n\x1b[41mA\x1b[49m" (no trailing newline). The bg glyph must survive intact.
expect(lastFrame({ raw: true })).toBe("B\n\x1b[41mA\x1b[49m");
// And the stripped visible layout is "B\nA" (sanity check on the wrap position).
// eslint-disable-next-line no-control-regex -- strip ANSI to assert the visible layout
const visible = lastFrame({ trimLines: true })!.replace(/\x1b\[[0-9;]*m/g, "");
expect(visible).toBe("B\nA");
});
@@ -1,6 +1,7 @@
import { defineComponent, h } from "vue";
import { expect, test } from "vite-plus/test";
import stringWidth from "string-width";
import wrapAnsi from "wrap-ansi";
import { createText, createTextLeaf, createTransform, createVirtualText } from "./nodes.ts";
import { flattenLeaves, measureTextNatural, wrapText } from "./text-measure.ts";
import { renderToString } from "../render-to-string.ts";
@@ -64,6 +65,124 @@ test("wrapText truncate-end cuts with ellipsis", () => {
expect(wrapText("abcdefgh", 5, "truncate-end")).toEqual(["abcd…"]);
});
test("wrapText at width 0 wraps non-empty text onto its own line (Ink parity)", () => {
// Ink has NO width<=0 guard: wrapAnsi("A", 0, {hard:true, trim:false}) = "\nA",
// so a 0-width text occupies a SECOND row (height 2). This is what makes Ink
// render "B\nA" for a 0-width Box beside a sibling, instead of dropping the text.
expect(wrapText("A", 0, "wrap")).toEqual(["", "A"]);
expect(wrapText("A", 0, "hard")).toEqual(["", "A"]);
});
test("wrapText at width 0 keeps EMPTY text empty (no spurious blank row)", () => {
// Empty text measures width 0 (≤ 0), so wrapAnsi("", 0) = "" → [""] (height 1).
// The 0-width fix must not turn empty text into an extra row.
expect(wrapText("", 0, "wrap")).toEqual([""]);
});
test("wrapText at width 0 truncates to empty", () => {
// cliTruncate("A", 0) = "" — matches Ink's truncate path at a 0-width cell.
expect(wrapText("A", 0, "truncate")).toEqual([""]);
});
test("wrapText at width 0 keeps SGR codes intact on a styled string (no byte-split)", () => {
// wrap-ansi@10 byte-splits the escapes of a STYLED string at width<=0
// (wrapAnsi("\x1b[41mA\x1b[49m", 0) = "\x1b\n[\n4\n1\nm\nA\n…"), which corrupted the
// frame to "B\n[". wrapText now splits ANSI-awarely: leading "" + one entry per grapheme
// with its SGR span preserved, matching Ink's per-grapheme colored output.
expect(wrapText("\x1b[41mA\x1b[49m", 0, "wrap")).toEqual(["", "\x1b[41mA\x1b[49m"]);
expect(wrapText("\x1b[41mAB\x1b[49m", 0, "wrap")).toEqual([
"",
"\x1b[41mA\x1b[49m",
"\x1b[41mB\x1b[49m",
]);
// hard mode behaves identically at width 0 (every grapheme must break anyway).
expect(wrapText("\x1b[41mAB\x1b[49m", 0, "hard")).toEqual([
"",
"\x1b[41mA\x1b[49m",
"\x1b[41mB\x1b[49m",
]);
});
test("wrapText at width 0 keeps a wide (CJK) glyph whole and styled", () => {
// A 2-column glyph must NOT be column-sliced in half; slice-ansi keeps it whole and
// re-emits its bg span — matching Ink's "\x1b[41m你\x1b[49m" on its own row.
expect(wrapText("\x1b[41m你好\x1b[49m", 0, "wrap")).toEqual([
"",
"\x1b[41m你\x1b[49m",
"\x1b[41m好\x1b[49m",
]);
// Mixed narrow + wide.
expect(wrapText("A你", 0, "wrap")).toEqual(["", "A", "你"]);
});
test("wrapText at width 0 splits each hard-newline line independently", () => {
// wrapAnsi("A\nB", 0) = "\nA\n\nB"; each input line gets a leading "" plus its graphemes.
expect(wrapText("A\nB", 0, "wrap")).toEqual(["", "A", "", "B"]);
});
test("wrapText at width 0 places a ZERO-WIDTH char on its OWN row (line-count parity)", () => {
// Reviewer reproducer 1: the old column-stepping slice glued the ZWSP (U+200B) onto the
// next grapheme and advanced only 1 column, yielding ["", "A", "​B"] (count 3). wrap-ansi
// places the ZWSP on its own row: ["", "A", "​", "", "B"] (count 5) — required for height
// parity with Ink (wrong line count → wrong yoga height).
expect(wrapText("A​B", 0, "wrap")).toEqual(["", "A", "​", "", "B"]);
});
test("wrapText at width 0 does NOT drop text after a leading zero-width + wide glyph", () => {
// Reviewer reproducer 2: the old `if (cellWidth === 0) break` abandoned the rest of the
// line when a zero-width char preceded a wide glyph, so "​中" returned [""] (中 GONE).
// wrap-ansi keeps everything: ["​", "", "中"].
expect(wrapText("​中", 0, "wrap")).toEqual(["​", "", "中"]);
});
// Load-bearing lock: wrapZeroWidthAnsi's LINE STRUCTURE must EXACTLY equal wrap-ansi's
// authoritative width-0 layout for plain text across a battery of zero-width / wide / combining
// / emoji / multiline inputs. Imported the same way the source imports wrap-ansi.
test("wrapText at width 0 matches wrap-ansi's plain width-0 layout for the full battery", () => {
const battery = [
"A",
"AB",
"",
" ",
"A\nB",
"A​B", // ZWSP
"​中", // ZWSP + wide
"中​A", // wide + ZWSP
"\u00e1b", // composed acute (NFC form)
"a\u0301b", // EXPLICITLY decomposed (a + U+0301): wrap-ansi NFC-composes, so wrapText must too
"\u0301a", // leading combining mark
"\u4e2d\u0301", // combining mark on a wide glyph
"⚠️", // VS16
"🍔", // emoji
"👨‍👩‍👧", // ZWJ family
"a­b", // soft hyphen
"A", // BOM
"X​Y中​Z\nP­Q", // mixed multiline
"中​", // TRAILING zero-width: wrap-ansi glues it to the prev row (["","中​"]), not its own row
"AB​", // trailing zero-width after a narrow glyph
"A​​B", // consecutive interior zero-widths (each its own row, no extra leading "")
"中​中", // wide / interior zero-width / wide
];
for (const input of battery) {
const expected = wrapAnsi(input, 0, { hard: true, trim: false }).split("\n");
expect(wrapText(input, 0, "wrap"), `input=${JSON.stringify(input)}`).toEqual(expected);
}
});
test("wrapText at width 0 preserves SGR styling per non-empty row with a zero-width char", () => {
// A styled input whose painted span straddles a zero-width char: each non-empty output row
// keeps its SGR span, and the line count matches the PLAIN version (structure parity).
const styled = "\x1b[41mA​B\x1b[49m";
const plainStructure = wrapAnsi("A​B", 0, { hard: true, trim: false }).split("\n");
const got = wrapText(styled, 0, "wrap");
// Line count matches the plain structure exactly.
expect(got.length).toBe(plainStructure.length);
// Each non-empty row carries its red-bg SGR span; empty rows stay empty.
expect(got).toEqual(["", "\x1b[41mA\x1b[49m", "\x1b[41m​\x1b[49m", "", "\x1b[41mB\x1b[49m"]);
// Stripping the SGR from each row reproduces the plain structure.
expect(got.map(stripAnsi)).toEqual(plainStructure);
});
test("truncate keeps ZWJ emoji whole", () => {
const [line] = wrapText("👨‍👩‍👧‍👦abcdefgh", 5, "truncate");
expect(line).toContain("👨‍👩‍👧‍👦");
+111 -1
View File
@@ -2,6 +2,7 @@ import cliTruncate from "cli-truncate";
import sliceAnsi from "slice-ansi";
import stringWidth from "string-width";
import wrapAnsi from "wrap-ansi";
import { tokenizeAnsi } from "../paint/ansi-tokenizer.ts";
import { sanitizeAnsi } from "../paint/sanitize-ansi.ts";
import type { TextProps, TuiNode, TuiText, TuiTransform, TuiVirtualText } from "./nodes.ts";
@@ -132,8 +133,103 @@ export function safeSliceEnd(text: string, maxCols: number): string {
return sliced;
}
// Grapheme segmenter shared across calls (constructing one is non-trivial). Locale-independent:
// we only segment, never collate, so the default locale's segmentation rules suffice.
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
/**
* Strip ALL ANSI from `text`, returning only its visible code points. Reuses the paint
* tokenizer (the same one sanitizeAnsi uses) rather than a strip-ansi regex dep: every
* non-`text` token — SGR, OSC hyperlinks, control strings — is dropped, so the result is the
* exact visible string wrap-ansi must lay out. (wrap-ansi recognises SGR/OSC8 and would
* byte-split SGR at width<=0; feeding it the plain string sidesteps that bug entirely.)
*/
function stripAnsi(text: string): string {
let out = "";
for (const token of tokenizeAnsi(text)) {
if (token.type === "text") out += token.value;
}
return out;
}
/**
* Replicate wrap-ansi's width<=0 layout for a (possibly STYLED) string, ANSI-awarely.
*
* The output's LINE STRUCTURE exactly equals `wrapAnsi(stripAnsi(text), 0, {hard, trim:false})
* .split("\n")` — wrap-ansi's authoritative width-0 layout. wrap-ansi breaks BEFORE each
* grapheme it cannot fit, so an interior zero-width grapheme (ZWSP/ZWNJ/ZWJ, combining mark,
* VS16, soft-hyphen, BOM) lands on its OWN row with the surrounding `""` blanks — but a
* TRAILING zero-width run (nothing visible after it) stays glued to the preceding grapheme's
* row (wrapAnsi("中​",0)=["","中​"], not ["","中","​"]). Deriving structure from wrap-ansi on
* the plain string reproduces both cases for free; the old column-stepping `slice(col,col+1)`
* could not (it glued an interior zero-width onto the next grapheme → line-count too low, and
* `break`-ed on a leading zero-width + wide glyph → dropped the rest of the line).
*
* Styling is re-applied in lockstep: the plain and styled strings share an identical grapheme
* sequence (SGR/OSC are zero-width), so each NON-EMPTY plain line maps to a contiguous run of
* graphemes (USUALLY one, but a trailing zero-width run makes it several — e.g. "中​"). We slice
* that run out of the STYLED text with slice-ansi via the same slot model wrap-ansi's plain
* layout implies, so slice-ansi re-emits the active SGR span around it (e.g. "\x1b[41mA\x1b[49m")
* and keeps a wide glyph whole — matching Ink's per-grapheme colored output. We never let
* wrap-ansi touch the styled string (it byte-splits the escapes at width<=0); we only ask it
* for structure.
*/
function wrapZeroWidthAnsi(text: string): string[] {
// NFC-normalize first: wrap-ansi (and therefore vue's NORMAL-width wrap path, which feeds
// the styled string straight to wrapAnsi) composes combining sequences (e.g. "á" →
// "á"). Deriving structure from wrapAnsi(stripAnsi(text)) yields composed rows, so the
// styled slices must be composed too or they'd diverge (same glyph/width/line-count, but
// different code points than the normal-width path + Ink). SGR/OSC bytes are ASCII → NFC-invariant.
text = text.normalize("NFC");
const result: string[] = [];
// Process each hard-newline line independently so `\n` never enters the grapheme walk
// (wrap-ansi joins line-blocks with `\n`, so each input line contributes its own block).
const styledLines = text.split("\n");
for (const styledLine of styledLines) {
const plainLine = stripAnsi(styledLine);
const plainLines = wrapAnsi(plainLine, 0, { hard: true, trim: false }).split("\n");
// Assign each grapheme of the plain line a slice-ansi slot range: a grapheme occupies
// max(1, visibleWidth) slots (a zero-width grapheme gets 1 slot of its own; a wide glyph 2).
// We re-style by mapping each non-empty plain line to the slot range covering its graphemes
// and slicing the STYLED text there (slice-ansi re-emits the active SGR span around it).
const slotEnds: number[] = []; // slotEnds[i] = end slot of the i-th grapheme
let slot = 0;
for (const { segment } of graphemeSegmenter.segment(plainLine)) {
slot += Math.max(1, stringWidth(segment));
slotEnds.push(slot);
}
// Walk wrap-ansi's plain layout. An empty row passes through verbatim; a non-empty row
// consumes as many graphemes as it contains (one, or several for a trailing zero-width run),
// and we emit the styled slice over that grapheme run's slot range.
let graphemeIndex = 0;
for (const line of plainLines) {
if (line === "") {
result.push("");
continue;
}
const startSlot = graphemeIndex === 0 ? 0 : slotEnds[graphemeIndex - 1]!;
const graphemeCount = [...graphemeSegmenter.segment(line)].length;
graphemeIndex += graphemeCount;
const endSlot = slotEnds[graphemeIndex - 1] ?? startSlot;
result.push(sliceAnsi(styledLine, startSlot, endSlot));
}
}
return result;
}
export function wrapText(text: string, width: number, mode: WrapMode = "wrap"): string[] {
if (width <= 0) return [""];
// NO `width <= 0` short-circuit — Ink's wrapText (wrap-text.ts) has none either, and
// a 0-width cell is an ordinary in-range value (flexBasis=0, width=0, width="0%", a
// negative parsed percent). A 0-width cell forces non-empty text onto its OWN second
// row (height 2) — that is exactly what makes Ink render "B\nA" beside a sibling instead
// of DROPPING the text (vue's old `[""]` collapsed it to height 1, then paint overwrote
// it with the sibling → "B"). For PLAIN text wrap-ansi already does this; for STYLED text
// it would byte-corrupt the SGR codes at width<=0, so the wrap/hard branches route through
// wrapZeroWidthAnsi (ANSI-safe) instead. Empty/zero-width text is unaffected: the fast-path
// below returns [""] for it (and the yoga measure func short-circuits raw==="" before ever
// calling here), so no spurious blank row appears. Negative widths flow identically.
if (mode === "wrap" || mode === "hard") {
// Mirror Ink's render-node-to-output.ts:144-150: only invoke wrap-ansi when
@@ -148,6 +244,20 @@ export function wrapText(text: string, width: number, mode: WrapMode = "wrap"):
// exactly as Ink's `output.write` does for the unwrapped string.
if (measureTextNatural(text).width <= width) return text.split("\n");
// ANSI-safe width<=0 wrap. We reach here only for NON-empty text wider than the
// cell (the fast-path above already returned for empty/fitting text), so width<=0
// means an undersized cell that forces every grapheme onto its own row. wrap-ansi@10
// produces exactly that for PLAIN text — wrapAnsi("AB", 0) = "\nA\nB" (leading blank
// line, one grapheme per line) — but it has a width<=0 bug: it cannot recognise the
// SGR codes in a STYLED string and byte-splits them, so wrapAnsi("\x1b[41mA\x1b[49m", 0)
// = "\x1b\n[\n4\n1\nm\nA\n…", scattering the escape bytes across rows and corrupting
// the frame. Ink never hits this because it wraps the PLAIN squashed text and applies
// color via a per-line transform AFTER wrapping; vue bakes color into the string before
// wrapping, so we must reproduce wrap-ansi's plain-text layout ANSI-awarely. slice-ansi
// is grapheme-aware and re-emits the active SGR span around each slice, matching Ink's
// per-grapheme colored output (e.g. "B\n\x1b[41mA\x1b[49m" for a 0-width bg Box).
if (width <= 0) return wrapZeroWidthAnsi(text);
if (mode === "wrap") {
return wrapAnsi(text, width, { hard: true, trim: false }).split("\n");
}
+14 -3
View File
@@ -650,8 +650,15 @@ function paintNode(
// Skip writing empty text — avoids applying line transformers to empty
// content, which matches Ink's behavior of not writing empty text nodes.
if (text === "") return;
const cellWidth = Math.max(1, Math.floor(layout.width));
const wrapped = wrapText(text, cellWidth, node.props.wrap ?? "wrap");
// Wrap at the TRUE cell width (unclamped), matching Ink's paint, which wraps at
// getMaxWidth(yogaNode) — a value that can legitimately be 0 (flexBasis=0, width=0,
// width="0%"). At width 0, wrapText returns the leading-newline wrap "\nA" → ["", "A"],
// pushing the glyph onto its own SECOND row exactly as the measure func reported
// (height 2). Clamping this to 1 would re-collapse to ["A"] on the first row, where a
// row-sibling overwrites it (the text-drop bug). Fitting text is untouched: wrapText's
// fast-path returns it verbatim.
const wrapWidth = Math.floor(layout.width);
const wrapped = wrapText(text, wrapWidth, node.props.wrap ?? "wrap");
// Pad each line to the cell width with the INHERITED Box background only —
// this fills the space behind the text with the Box's bg (the Box also fills
// it via fillBackground), and is the reason a Box bg pads to full width while
@@ -660,10 +667,14 @@ function paintNode(
// its OWN glyphs, never the surrounding Box fill. The already-rendered glyphs
// in `wrapped[i]` keep their effective bg, so a `backgroundColor=""` Text
// stays bare even though we pad the trailing cells with the inherited bg.
// Pad to wrapWidth (NOT a ≥1-clamped width): at width 0 there is nothing to
// pad, matching Ink (getMaxWidth=0 → no padding). Clamping to 1 here would
// bg-pad the empty leading wrap line "" into a stray 1-cell fill that
// collides with a row-sibling at the 0-width box origin.
if (inheritedBg) {
const padProps: TextProps = { backgroundColor: inheritedBg };
for (let i = 0; i < wrapped.length; i++) {
const pad = cellWidth - stringWidth(wrapped[i]!);
const pad = wrapWidth - stringWidth(wrapped[i]!);
if (pad > 0) {
wrapped[i] = wrapped[i]! + applyChalk(" ".repeat(pad), padProps);
}