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

BR
1mo ago
+61-24

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