Skip to content

Fix ANSI measurement / truncation / wrapping edge cases - #624

Open
GoodForOneFare wants to merge 4 commits into
gordo-ansi-replayfrom
gordo-expand-csi-stripping
Open

Fix ANSI measurement / truncation / wrapping edge cases#624
GoodForOneFare wants to merge 4 commits into
gordo-ansi-replayfrom
gordo-expand-csi-stripping

Conversation

@GoodForOneFare

@GoodForOneFare GoodForOneFare commented Aug 11, 2026

Copy link
Copy Markdown
Member

Fixes some minor layout calculation issues. This is a big change, for issues that you may never have noticed 😅

This changes existing width and layout behavior and should ideally ship as part of the pending major 3.0.0 bump (#622).

Depends on helpers added in #625.

Layout stops being wrong around invisible sequences

Anything measured for layout – frame titles, box borders, table columns, centered text – previously counted the bytes of \e[?25l (hide cursor), \e[K (clear line), and \e[m as if they were printed characters. A line containing a hidden-cursor sequence was "10 characters" of phantom width, so borders broke early, padding came up short, and columns misaligned. Those sequences now measure as zero, so frames and tables line up.

Truncation stops emitting garbage

The old truncater didn't know ? parameters or OSC sequences, so truncating a line could slice \e[?25l in half – printing literal ?25l fragments – or cut an OSC 8 hyperlink mid-sequence, leaving the terminal with an unterminated escape that can swallow everything after it. Now sequences always pass through whole or not at all, and if the cut lands inside a hyperlink, the link is properly closed before the ….

Truncated output also always stays one line: a newline measures zero columns, but truncating "ab\ncd" to 3 columns gives ab…, not ab\n… — the cut lands before the line break instead of silently absorbing it.

Wide glyphs stop overflowing

Emoji render two columns wide but were measured as one, so every emoji in a spin-group title or table cell pushed content one column past the frame border. Width measurement and truncation now agree on two columns, so emoji-bearing lines fit. Same family of fix: 👩‍💻-style ZWJ sequences and accented characters like e+combining-acute are
measured as the single glyph you see, and a string like 🌈🌈🌈 asked to fit in 3 columns actually truncates instead of
sprawling to 6.

The width table covers the real wide-glyph territory, not just the core emoji block: ✅, ⭐, 🚀, 🛒, CJK text (漢字 is 4 columns), flags, and VS16 emoji-presentation forms like ⚠️ (which is cli-ui's own Glyph::WARNING) all measure two columns. wcwidth(3) counts VS16 forms as one column, but the terminals cli-ui targets render them as two, so we side with the terminals.

(A vendored wcwidth table remains the exact fix — ambiguous-width East Asian characters are still counted 1 — but this is 🤞 Good Enough for Shopify's use cases 🤞 without new dependencies)

Wrapped text keeps (and releases) its colors correctly

Two visible fixes in CLI::UI.puts word-wrapping:

  • Colors that were reset mid-paragraph no longer come back from the dead on the next wrapped line (the reset-detection branch was dead code, so stale colors reaccumulated forever). Resets hiding inside a parameter list count too: \e[0;33m and \e[;1m kill the codes before them, and only the parameters after the reset are resent on continuation lines
  • Truecolor codes in colon form (\e[38:2::255:0:0m) now survive onto continuation lines instead of silently dropping to default (and a 0 subparameter there is never mistaken for a reset). Plus a hyperlink spanning a wrap point no longer makes the next line's frame gutter part of the clickable link

Performance

Performance depends on the input:

Input Before After Change
Typical ASCII line 10.4 µs 0.28 µs ~37× faster
44-character CJK line 19.6 µs 35 µs ~1.8× slower
Emoji-dense text ~7× slower

The ASCII fast path makes ordinary text substantially faster. Unicode text requiring grapheme segmentation and width-table lookup is slower in exchange for correct terminal-width measurement.

The absolute cost remains tens of microseconds per line in these benchmarks, but Unicode-heavy output that is measured on every repaint (e.g., Japanese-language spinner) may see a noticeable relative regression.

Bot-generated explanations follow


The grammar

CSI_SEQUENCE required at least one parameter character, so strip_codes passed \e[K, \e[m, and private-mode sequences like \e[?25l through as text, and printing_width counted their bytes as printed columns: printing_width("\e[?25lx\e[K") returned 10 for one visible character. It now matches the full CSI grammar (any parameter bytes including private-mode markers, then intermediate bytes, then one final byte). The OSC 8 hyperlink grammar also moves into ANSI: CLI::UI.link builds links and Truncater/Wrap close them from the same definitions.

The walk

ANSI.each_token scans a string as alternating runs of whole control sequences and the text between them, so consumers that measure or cut at token boundaries can't slice a well-formed sequence open or count its bytes as printable. printing_width, Truncater, and Wrap are all rebuilt on it, replacing three independent hand-rolled parsers (one of which, Truncater's, didn't know about ? parameters or OSC at all — it sliced \e[?25l in half and could emit a dangling unterminated hyperlink).

Input that arrives already mangled is contained rather than compounded: a CSI or OSC sequence missing its terminator at the end of the string (something upstream — a log pipeline, a byte-limited buffer — cut it open) tokenizes as one zero-width sequence, so Truncater drops it instead of counting its bytes as columns and slicing it a second time. Mid-string, a missing terminator is ambiguous, so only the trailing case gets this treatment.

Behavior changes

  • printing_width: wide glyphs are 2 columns — the East Asian Wide and Fullwidth blocks, the emoji planes, scattered BMP emoji (✅, ⭐, ❌), and VS16 emoji-presentation clusters like ⚠️ (was 1 for all of these; Truncater said 2 for the core emoji block only — they now agree via the shared terminal-width helper and its generated Unicode table). Newlines and combining marks are 0 columns (was 1), and ZWJ sequences are measured as single grapheme clusters. Table columns, frame padding, and spin_group truncation all shift for emoji- and CJK-containing content; downstream repos with exact-output assertions will churn. Glyph::WARNING (⚠️) itself goes from 1 column to 2.
  • Truncater.call: no longer slices private-mode sequences mid-way or counts their bodies as text; closes an open OSC 8 hyperlink at the cut; measures by columns even on the fast path (call("🌈🌈🌈", 3) previously returned all six columns untouched because the early-out compared character count); counts a line break as one column so truncated output stays one line (call("ab\ncd", 3)ab…); drops a trailing unterminated sequence past the cut instead of re-slicing it.
  • Wrap: an \e[0m reset now actually clears the SGR codes resent after each break — the old reset branch was the single-quoted literal '\x1B[0?m', which no token ever equals, so codes accumulated forever and a reset color could come back on the next wrapped line. A reset hiding mid-list (\e[0;33m, \e[;1m) is detected by parsing the parameter list, and only the parameters after the last reset survive to continuation lines. Colon-form extended colors (\e[38:2::255:0:0m) are now tracked too — a 0 subparameter never counts as a reset — and a hyperlink spanning a break is closed and reopened so the frame gutter stays outside the link.
  • Removed public constants: Truncater::EMOJI_RANGE and Truncater::PARSE_ROOT/PARSE_ANSI/PARSE_ESC/PARSE_ZWJ (internals of the deleted hand-rolled parser). A GitHub-wide code search finds no references outside cli-ui and wholesale vendored copies of the gem, so no deprecation aliases are provided. Truncater::TRUNCATED remains. New public surface: ANSI.each_token, ANSI.grapheme_width, ANSI::CSI_SEQUENCE/OSC_SEQUENCE/SEQUENCE/UNTERMINATED_SEQUENCE/TEXT_RUN/HYPERLINK/HYPERLINK_END.

🤖 Generated with Claude Code

@GoodForOneFare
GoodForOneFare marked this pull request as ready for review August 11, 2026 18:53
@GoodForOneFare
GoodForOneFare requested a review from a team as a code owner August 11, 2026 18:53
@GoodForOneFare GoodForOneFare changed the title Strip parameter-less and private CSI sequences Rebuild ANSI measurement, truncation, and wrapping on one sequence grammar Aug 11, 2026
@GoodForOneFare
GoodForOneFare force-pushed the gordo-expand-csi-stripping branch 2 times, most recently from 1cb093c to a833fef Compare August 12, 2026 19:00
@GoodForOneFare GoodForOneFare changed the title Rebuild ANSI measurement, truncation, and wrapping on one sequence grammar Fix ANSI measurement / truncation / wrapping edge cases Aug 12, 2026
@GoodForOneFare
GoodForOneFare force-pushed the gordo-expand-csi-stripping branch 3 times, most recently from d92d828 to 5ce45be Compare August 12, 2026 20:58
Assisted-By: devx/b840c1e6-d979-473d-9216-a53477380bf1
Expand CSI recognition, expose whole ANSI/text tokens, and route printing widths through the Unicode terminal-width helper introduced for replay.

Assisted-By: devx/b840c1e6-d979-473d-9216-a53477380bf1
Assisted-By: devx/b840c1e6-d979-473d-9216-a53477380bf1
Assisted-By: devx/b840c1e6-d979-473d-9216-a53477380bf1
@GoodForOneFare
GoodForOneFare force-pushed the gordo-expand-csi-stripping branch from 5ce45be to 5cc704f Compare August 13, 2026 11:19
@GoodForOneFare
GoodForOneFare changed the base branch from main to gordo-ansi-replay August 13, 2026 11:20
@GoodForOneFare
GoodForOneFare force-pushed the gordo-ansi-replay branch 5 times, most recently from fe7fb3f to f230d66 Compare August 13, 2026 14:00
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