Skip to content

Truncate each line of multi-line text separately - #1006

Merged
sindresorhus merged 4 commits into
vadimdemedes:masterfrom
costajohnt:truncate-multiline
Sep 20, 2026
Merged

sindresorhus merged 4 commits into
vadimdemedes:masterfrom
costajohnt:truncate-multiline

Conversation

@costajohnt

@costajohnt costajohnt commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

wrapText passes the whole text to cli-truncate, which treats its input as a single line: newlines are zero-width, so the widths of every line add up and the text is cut as one run. As soon as any line is wider than the box, lines are merged or dropped, and lines that fit are truncated. Because the Yoga measure function uses the same wrapText, the layout height collapses too, so the lost lines never show up as overlap.

<Box width={6}><Text wrap="truncate">{'foo\nhello world'}</Text></Box>
// master:   "fo…\n"        foo fits but is truncated, the second line is gone, stray empty row
// expected: "foo\nhello…"

wrap and hard already wrap each line on its own via wrap-ansi. This does the same for truncate, truncate-start and truncate-middle: split on \n, truncate each line, join.

Tests: unit cases for all three positions and a bordered renderToString case; they fail on master.

Fixes #1008

`cli-truncate` treats its input as a single line: newlines are
zero-width, so the widths of every line added up and the text was cut as
one run. As soon as any line overflowed, lines were merged or dropped and
lines that fit were truncated (`'foo\nhello world'` at width 6 rendered
`fo…`). Because the Yoga measure function uses the same code, the layout
height collapsed too, so the lost lines never showed up as overlap.

Truncate each line on its own, the same way `wrap` and `hard` wrap each
line via `wrap-ansi`.
@sindresorhus

Copy link
Copy Markdown
Collaborator

Truncating each explicit line independently makes sense, but splitting the raw string before truncating each line seperately loses ANSI state that spans a newline.

For example, \x1b[31mabcdef\nuvwxyz\x1b[39m at width 4 becomes \x1b[31mabc…\x1b[39m\nuvw…, so the second line is no longer red. The same thing happens with OSC 8 hyperlinks. This is especially easy to miss because the visible text and layout are correct once ANSI is stripped.

Could we tokenize the full string first, build self-contained styled lines, and then pass each serialized line to cliTruncate? That preserves the simple per-line behavior while retaining styles and links across explicit newlines. It would also be worth adding a regression test that compares the ANSI output rather than stripped text.

@sindresorhus

Copy link
Copy Markdown
Collaborator

Two things before this is ready:

  1. The new tokenization path now runs for every truncated string, including the common single-line case. That makes cache misses much more expensive. In a quick benchmark, a changing 10,000-character line went from about 0.04 ms to 7.4 ms, and a 100,000-character line went from about 0.09 ms to 82 ms. Could we keep the existing direct cliTruncate path when the text has no newline? Only multiline text needs the extra processing.

  2. This still loses ANSI state across newlines for C1 OSC hyperlinks and C1 SGR sequences because the tokenizer does not recognize those forms. Ink already explicitly preserves them, so a supported hyperlink spanning two lines is only applied to the first truncated line. The same issue affects C1 colors, and colon-form SGR is missed too. Could we use the same ANSI grammar or normalization that Ink supports and add an integrated regression test for these cases? The current tests only cover the standard ESC forms, so this is easy to miss seperately.

…rms across newlines

Only split on styled characters when the text contains a newline; single-line text goes straight to `cliTruncate` as before, so cache misses on long strings stay cheap.

Add integrated tests for a C1 SGR color, a C1 OSC 8 hyperlink, a colon 256-color and a colon truecolor parameter spanning a truncated newline. These forms are normalized by `squashTextNodes` before reaching `wrapText`.
@costajohnt

Copy link
Copy Markdown
Contributor Author
  1. Done. wrapText only tokenizes when the text contains a newline; single-line text goes straight to cliTruncate as before. A 100k-character single line is back to ~0.1 ms per cache miss locally.

  2. Merged master. Since 9dfe137 and b0dfd61, squashTextNodes rewrites C1 CSI/OSC/ST to their ESC forms and sanitizeAnsi normalizes colon color parameters to the semicolon form before any text reaches wrapText, so the tokenizer here only ever sees the ESC forms. Added integrated renderToString tests for a C1 SGR color, a C1 OSC 8 hyperlink with C1 terminators, a colon 256-color and a colon truecolor spanning a truncated newline, asserting the exact ANSI output with the style reopened on the second line. They fail on this branch without the master merge.

One gap: colon parameters on non-color SGR (ESC[4:3m) are not parsed by @alcalzone/ansi-tokenize, in Output either, so they are outside this change.

@sindresorhus
sindresorhus merged commit 4b44934 into vadimdemedes:master Sep 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Text wrap="truncate" cuts multi-line text as one run and drops lines

2 participants