Skip to content

Every wrapper measures runes, not display cells: a 75-rune budget emits a 150-column line for CJK and emoji #397

Description

@ZacxDev

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:

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions