Fix ANSI measurement / truncation / wrapping edge cases - #624
Open
GoodForOneFare wants to merge 4 commits into
Open
Fix ANSI measurement / truncation / wrapping edge cases#624GoodForOneFare wants to merge 4 commits into
GoodForOneFare wants to merge 4 commits into
Conversation
GoodForOneFare
marked this pull request as ready for review
August 11, 2026 18:53
GoodForOneFare
force-pushed
the
gordo-expand-csi-stripping
branch
2 times, most recently
from
August 12, 2026 19:00
1cb093c to
a833fef
Compare
GoodForOneFare
force-pushed
the
gordo-expand-csi-stripping
branch
3 times, most recently
from
August 12, 2026 20:58
d92d828 to
5ce45be
Compare
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
force-pushed
the
gordo-expand-csi-stripping
branch
from
August 13, 2026 11:19
5ce45be to
5cc704f
Compare
This was referenced Aug 13, 2026
GoodForOneFare
force-pushed
the
gordo-ansi-replay
branch
5 times, most recently
from
August 13, 2026 14:00
fe7fb3f to
f230d66
Compare
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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[mas 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[?25lin half – printing literal?25lfragments – 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 givesab…, notab\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 ofsprawling to 6.
The width table covers the real wide-glyph territory, not just the core emoji block: ✅, ⭐, 🚀, 🛒, CJK text (⚠️ (which is cli-ui's own
漢字is 4 columns), flags, and VS16 emoji-presentation forms likeGlyph::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.putsword-wrapping:\e[0;33mand\e[;1mkill the codes before them, and only the parameters after the reset are resent on continuation lines\e[38:2::255:0:0m) now survive onto continuation lines instead of silently dropping to default (and a0subparameter 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 linkPerformance
Performance depends on the input:
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_SEQUENCErequired at least one parameter character, sostrip_codespassed\e[K,\e[m, and private-mode sequences like\e[?25lthrough as text, andprinting_widthcounted their bytes as printed columns:printing_width("\e[?25lx\e[K")returned10for 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 intoANSI:CLI::UI.linkbuilds links andTruncater/Wrapclose them from the same definitions.The walk
ANSI.each_tokenscans 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, andWrapare 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[?25lin 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
Truncaterdrops 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 likeGlyph::WARNING(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[0mreset 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 — a0subparameter never counts as a reset — and a hyperlink spanning a break is closed and reopened so the frame gutter stays outside the link.Truncater::EMOJI_RANGEandTruncater::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::TRUNCATEDremains. 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