▚▚ fix(utils): measure WrapLine width in display columns, not bytes
BR
1mo ago+61+61+01234567890123456789-24-24-01234567890123456789
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
visibleLenreturnedlen(StripANSI(s)), so a 3-byte rune such as═,—, orécounted as three columns and a 2-column CJK character as three.splitLongWorddidstripped[:maxWidth]on the byte string, which produces chunks that end in the middle of a multibyte rune (invalid UTF-8).TruncateWithEllipsisin the same file already usesrunewidth, so the two wrapping helpers were simply inconsistent with it.
Fix
internal/utils/text.govisibleLennow returnsrunewidth.StringWidth(StripANSI(s)).splitLongWordcompares withrunewidth.StringWidthand 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.goTestWrapLine_MultibyteFitsWithinWidthIsNotWrapped:"héllo wörld"(11 columns, 13 bytes) at width 12 must stay on one line. Without the fix it wraps afterhé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
splitLongWordstill drops ANSI codes from a word it has to split (existingTODO); this change only fixes the width measurement and the rune-boundary cut.TestServerUnixModefails 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