Skip to content

▚▚ fix(utils): measure WrapLine width in display columns, not bytes - #233

Open
breken-ai wants to merge 2 commits into
Use-Tusk:mainfrom
breken-ai:fix/wrapline-multibyte
Open

breken-ai wants to merge 2 commits into
Use-Tusk:mainfrom
breken-ai:fix/wrapline-multibyte

Conversation

@breken-ai

@breken-ai breken-ai commented Sep 14, 2026 •

Copy link
Copy Markdown

utils.WrapLine (used by the TUI content and log panels, headless agent output, and the markdown-render fallback) measures text in bytes rather than terminal columns. Any line with non-ASCII characters is wrapped too early, and a long non-ASCII token is sliced at byte offsets, cutting UTF-8 runes in half so the panel shows � replacement characters instead of the original text.

Root cause

  • visibleLen returned len(StripANSI(s)), so a 3-byte rune such as ═, —, or é counted as three columns and a 2-column CJK character as three.
  • splitLongWord did stripped[:maxWidth] on the byte string, which produces chunks that end in the middle of a multibyte rune (invalid UTF-8).
  • TruncateWithEllipsis in the same file already uses runewidth, so the two wrapping helpers were simply inconsistent with it.

Fix

  • internal/utils/text.go
    • visibleLen now returns runewidth.StringWidth(StripANSI(s)).
    • splitLongWord compares with runewidth.StringWidth and splits by walking runes and accumulating display width, so chunks always end on rune boundaries and wide characters count as two columns. The two near-identical byte-splitting loops collapse into one; the existing behaviour of stripping ANSI codes from a split word is unchanged.

Tests

  • internal/utils/text_test.go
    • TestWrapLine_MultibyteFitsWithinWidthIsNotWrapped: "héllo wörld" (11 columns, 13 bytes) at width 12 must stay on one line. Without the fix it wraps after héllo.
    • TestWrapLine_LongMultibyteWordSplitsOnRuneBoundaries: 30 ═ at width 20 must yield 2 valid-UTF-8 chunks that rejoin to the original. Without the fix it yields 5 chunks containing �.
    • TestWrapLine_WideCharactersCountAsTwoColumns: 10 CJK characters at width 10 must yield 2 chunks of at most 10 columns. Without the fix it yields 3 corrupted chunks.

Not changed

  • splitLongWord still drops ANSI codes from a word it has to split (existing TODO); this change only fixes the width measurement and the rune-boundary cut.
  • TestServerUnixMode fails locally on a clean checkout when the clone lives under a long temp path (unix socket path limit); unrelated to this change.

▚▚ Shipped by breken — self-healing software. This one's on us. breken.ai

Review in cubic

WrapLine and splitLongWord measured text with len() on the ANSI-stripped
string, i.e. in bytes. Any non-ASCII text (accented letters, em dashes,
box-drawing characters, CJK, emoji) was overcounted, so lines that fit
were wrapped early, and a long word was sliced at byte offsets, cutting
multibyte runes in half and emitting invalid UTF-8 that renders as
replacement characters in the TUI and in headless agent output.

visibleLen now uses runewidth.StringWidth (already used by
TruncateWithEllipsis in the same file), and splitLongWord walks runes,
accumulating by display width, so chunks always end on rune boundaries
and wide characters count as two columns.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread internal/utils/text_test.go Resolved
…-width runes

U+2550 is East Asian ambiguous width, so under RUNEWIDTH_EASTASIAN=1 or a
CJK locale it measures 2 columns and 30 of them split into 3 chunks at
width 20, not 2. Compute the expected count from runewidth.RuneWidth.

This branch has not been deployed

No deployments
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.

1 participant