Skip to content

[low] fix(ansi): never truncate to a line wider than maxWidth - #625

Merged
sirmalloc merged 1 commit into
sirmalloc:mainfrom
elhoim:fix/truncate-cluster-across-escapes
Oct 9, 2026
Merged

sirmalloc merged 1 commit into
sirmalloc:mainfrom
elhoim:fix/truncate-cluster-across-escapes

Conversation

@elhoim

@elhoim elhoim commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

BLUF

  • Priority: low. truncateStyledText can return a string wider than maxWidth.
  • Bug: the pre-check uses getVisibleWidth, which clusters the escape-stripped text. The loop clusters each run between escapes on its own. A grapheme split by an escape (❤ SGR U+FE0F, keycap, ZWJ, flag pairs) counts as 1 + 0 columns instead of 2. The loop then never overshoots and the function returns the original, over-wide text. With a flag pair split by an SGR the result is even wider than the input.
  • Fix: cluster the escape-stripped text exactly as getVisibleWidth does, and copy escapes that fall inside a cluster. This also removes the per-cluster rescan of the rest of the visible run (O(n²)).
  • Test: a new renderer-ansi test fails on main (12 columns returned for maxWidth 9) and passes with the fix.

Details

  • ❤\x1b[31m️\x1b[39m repeated 6 times is 12 columns. truncateStyledText(text, 9) returns it unchanged. With { ellipsis: false }, the renderer's maxWidth path, the same happens for every width from 1 to 11.
  • How it is triggered: an escape inside a grapheme cluster, e.g. custom-command output with preserveColors, or colours that split a cluster at a widget boundary. The line gradient is not affected, because it clusters the whole text.
  • Evidence:
    • A Lean 4 model of consumeDisplayCluster / getVisibleWidth / truncateStyledText matched the real code on 19,614 generated cases and proves the counterexamples.

    • An exhaustive comparison of old vs new over every string of up to 5 tokens (13-token alphabet, maxWidth 0–8, with and without ellipsis) found:

      Cases Over-wide results
      Old 7,240,194 9,807
      New 7,240,194 0
    • Neither version ever leaves an OSC 8 link open.

    • Outputs only differ where a cluster straddles an escape: the old code emitted half a cluster, the new code keeps or drops whole clusters.

Checks

  • bun run lint: clean.
  • bun run build: clean.
  • bun test src/utils/__tests__/renderer* src/utils/__tests__/ansi*: 110 pass.

🤖 Generated with Claude Code

truncateStyledText measured each run of text between escape sequences
separately, while getVisibleWidth measures the escape-stripped text. A
grapheme cluster split by an escape (U+2764, SGR, U+FE0F) therefore
counted as 1 + 0 columns instead of 2, the loop never overshot, and the
function returned the original, over-wide text.

Cluster the escape-stripped text, as getVisibleWidth does, and copy
escapes that fall inside a cluster. This also drops the rescan of the
rest of the visible run for every cluster.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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.

2 participants