Every line-wrapper in this CLI measures its budget in runes, and a rune is not a display cell. East-Asian and emoji runes occupy two cells, so a line the wrapper considers "inside the budget" is up to twice as wide on screen — the terminal then soft-wraps it, and the spill lands at column zero, which is the exact forgery slot wrapServerText and indentContinuation exist to close (#367, #382).
Split out of #393, whose first half (stripping the invisible/bidi rune class in safeTerm) is being fixed separately. This half was deferred deliberately: closing it needs a character-width table, and that is a dependency decision AGENTS.md says to ask about before making.
The three wrappers
| function |
file |
budget |
wrapTokens |
internal/cmd/validate_print.go |
findingWrapWidth = 79 |
wrapRunes |
internal/cmd/exitcodes_doc.go |
79 |
wrapServerText (+ hardSplitOverlong) |
internal/cmd/safeterm.go |
listReasonWrapWidth = 75 |
All three count len([]rune(line)). hardSplitOverlong chunks an over-long token by rune count too, so it also under-splits.
Measured
#393 measured 38 CJK runes producing a 109-column line from a 71-rune budget. Re-measured on main (27cd5de), widths via mattn/go-runewidth:
wrapServerText, budget=75 (listReasonWrapWidth): 75 runes -> 150 cells (CJK)
wrapTokens, budget=79 (findingWrapWidth): 79 runes -> 158 cells (CJK)
wrapTokens, budget=79: 79 runes -> 158 cells (emoji)
So on a standard 80-column terminal, a CJK or emoji reason is re-wrapped by the terminal at roughly half its emitted length, and every second physical row starts at column zero.
The residual is already stated in three places and is known, not new — internal/cmd/safeterm.go's wrapServerText doc comment, hardSplitOverlong's "Residual" paragraph, and the README Troubleshooting row for An indented line under a row is what the server recorded ("in a narrower terminal — or with wide (CJK) characters — your terminal re-wraps and the overflow can still reach column zero"). This issue is to close it rather than keep documenting it.
Why it needs a decision, not just a patch
Go's standard library has no display-width table. Two routes:
github.com/mattn/go-runewidth. Already in the module graph at v0.0.19 — as an indirect dependency (via charmbracelet/bubbles / lipgloss), so it is already built and already in go.sum. Closing this means promoting an existing indirect requirement to direct, not pulling a new module into the tree. AGENTS.md → Permission boundaries → "Ask first: adding a new third-party dependency" still applies, and that is the ask.
- Hand-roll on
golang.org/x/text (already a direct requirement). golang.org/x/text/width ships the East Asian Width property (width.LookupRune(r).Kind() → EastAsianWide / EastAsianFullwidth / …), which covers CJK. It does not by itself cover emoji (Extended_Pictographic) or zero-width combining marks, so a hand-rolled table is x/text plus a small set of extra rules — more code, and a second thing to keep in step with Unicode.
Either way the width function must live in ONE place. internal/saferune (added by the #393 fix) is the obvious home: it already owns "which runes may reach a terminal", and all three wrappers plus hardSplitOverlong would call it.
Guard shape
Pin the property, not the fixture: no line this CLI emits exceeds its budget in DISPLAY CELLS, for any input. The existing rune-count assertions (TestWrapServerText/"no emitted line exceeds the budget, for any input", TestWorkflowsList_NoEmittedLineCanOverflowIntoColumnZero) are the same test with the wrong unit — they should be converted, not duplicated, and the CJK/emoji atoms already in their corpora become the cases that go red first.
Note the guard needs a negative control: measure the current rune-based wrapper against the same corpus and confirm it FAILS, or the new assertion is not testing the change.
Out of scope
The CLI never asks how wide the terminal actually is (x/term.GetSize appears nowhere in the repo); it wraps to a fixed budget. Fixing the unit does not fix that, and a terminal narrower than 79 columns still re-wraps. That is a separate question.
Every line-wrapper in this CLI measures its budget in runes, and a rune is not a display cell. East-Asian and emoji runes occupy two cells, so a line the wrapper considers "inside the budget" is up to twice as wide on screen — the terminal then soft-wraps it, and the spill lands at column zero, which is the exact forgery slot
wrapServerTextandindentContinuationexist to close (#367,#382).Split out of
#393, whose first half (stripping the invisible/bidi rune class insafeTerm) is being fixed separately. This half was deferred deliberately: closing it needs a character-width table, and that is a dependency decisionAGENTS.mdsays to ask about before making.The three wrappers
wrapTokensinternal/cmd/validate_print.gofindingWrapWidth = 79wrapRunesinternal/cmd/exitcodes_doc.gowrapServerText(+hardSplitOverlong)internal/cmd/safeterm.golistReasonWrapWidth = 75All three count
len([]rune(line)).hardSplitOverlongchunks an over-long token by rune count too, so it also under-splits.Measured
#393measured 38 CJK runes producing a 109-column line from a 71-rune budget. Re-measured onmain(27cd5de), widths viamattn/go-runewidth:So on a standard 80-column terminal, a CJK or emoji reason is re-wrapped by the terminal at roughly half its emitted length, and every second physical row starts at column zero.
The residual is already stated in three places and is known, not new —
internal/cmd/safeterm.go'swrapServerTextdoc comment,hardSplitOverlong's "Residual" paragraph, and the README Troubleshooting row forAn indented line under a row is what the server recorded("in a narrower terminal — or with wide (CJK) characters — your terminal re-wraps and the overflow can still reach column zero"). This issue is to close it rather than keep documenting it.Why it needs a decision, not just a patch
Go's standard library has no display-width table. Two routes:
github.com/mattn/go-runewidth. Already in the module graph atv0.0.19— as an indirect dependency (viacharmbracelet/bubbles/lipgloss), so it is already built and already ingo.sum. Closing this means promoting an existing indirect requirement to direct, not pulling a new module into the tree.AGENTS.md→ Permission boundaries → "Ask first: adding a new third-party dependency" still applies, and that is the ask.golang.org/x/text(already a direct requirement).golang.org/x/text/widthships the East Asian Width property (width.LookupRune(r).Kind()→EastAsianWide/EastAsianFullwidth/ …), which covers CJK. It does not by itself cover emoji (Extended_Pictographic) or zero-width combining marks, so a hand-rolled table is x/text plus a small set of extra rules — more code, and a second thing to keep in step with Unicode.Either way the width function must live in ONE place.
internal/saferune(added by the#393fix) is the obvious home: it already owns "which runes may reach a terminal", and all three wrappers plushardSplitOverlongwould call it.Guard shape
Pin the property, not the fixture: no line this CLI emits exceeds its budget in DISPLAY CELLS, for any input. The existing rune-count assertions (
TestWrapServerText/"no emitted line exceeds the budget, for any input",TestWorkflowsList_NoEmittedLineCanOverflowIntoColumnZero) are the same test with the wrong unit — they should be converted, not duplicated, and the CJK/emoji atoms already in their corpora become the cases that go red first.Note the guard needs a negative control: measure the current rune-based wrapper against the same corpus and confirm it FAILS, or the new assertion is not testing the change.
Out of scope
The CLI never asks how wide the terminal actually is (
x/term.GetSizeappears nowhere in the repo); it wraps to a fixed budget. Fixing the unit does not fix that, and a terminal narrower than 79 columns still re-wraps. That is a separate question.