Skip to content

fix: hide tput charset escapes, recognize colon-form colors, print tabs as one space - #781

Merged
sirmalloc merged 7 commits into
sirmalloc:mainfrom
eric-engberg:fix/custom-command-escapes
Nov 5, 2026
Merged

sirmalloc merged 7 commits into
sirmalloc:mainfrom
eric-engberg:fix/custom-command-escapes

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

What

Three fixes to how widget output's escape sequences and tabs are parsed and measured, mostly visible with Custom Command:

  1. With preserve colors off (the default), a command colored with tput no longer shows a stray B after each reset, and DCS/SOS/PM/APC strings no longer print their payload.
  2. Color codes written with colons (ESC[38:5:196m, ESC[38:2::255:0:0m, ESC[4:3m) are recognized. Max Width closes them after a cut, a global foreground override replaces them, and Powerline re-applies the segment background after a colon-form reset.
  3. A tab in widget output prints as one space and is measured as one column, so a flex line or a truncated line stays within the terminal.

Why (evidence)

  1. On xterm-256color, tput sgr0 prints ESC ( B ESC [ m. With preserve colors off, Custom Command reduces its output to visible text with getVisibleText before the renderer's sanitizer runs. Its parser read ESC plus any character other than [ or ] as a two-character sequence. So getVisibleText('\x1b[32mOK\x1b(B\x1b[m done') returned OKB done, and 'a\x1bPq#0;2;0;0;0\x1b\\b' returned aq#0;2;0;0;0b. terminal-sanitize.ts already parses both forms, but by the time it ran they were ordinary text.

  2. terminal-sanitize.ts keeps SGR matching [0-9;:]*m, and its tests keep ESC[38:2::255:128:0m. The color pattern in ansi.ts only allowed [0-9;]*m, so:

    • printf '\033[38:5:196mhello world long text\033[0m' with Max Width 8 rendered ESC[38:5:196mhello... | … with no closing reset, and the red ran into the separator. The semicolon form gets the reset.
    • Under a global foreground override (cyan), the colon form stayed red while the semicolon form was recolored.
    • In Powerline mode, the segment background wasn't re-applied after ESC[0;38:5:196m.

    Colon form isn't exotic: tput Smulx 3 (curly underline) prints ESC[4:3m.

  3. The sanitizer kept tabs, and getVisibleWidth('3\t5') was 2: string-width gives control characters no width, but a terminal moves to the next tab stop. At CCSTATUSLINE_WIDTH=40, [Custom Command printf '3\t5', flex separator, "END"] printed 35 characters spanning 41 columns, against a 34-column target. A seven-field tabbed line measured 27 columns, spanned 51, and was never truncated. Everyday commands print tabs: git rev-list --left-right --count HEAD...@{u} prints 0\t3, and du -sh . prints 1.2G\t..

How

  1. parseEscapeSequence uses the same rules as terminal-sanitize.ts:
    • ESC, then intermediate bytes (0x20–0x2F), then a final byte, is one sequence.
    • A DCS, SOS, PM or APC string runs to ST or BEL, and stops before any other ESC so an unterminated string doesn't hide the text after it.
    • ESC 7 and ESC c stay two characters.
  2. The color pattern accepts :. When reading a sequence for a background reset, a colon-form extended color holds its own arguments, so the parameters after it are no longer skipped as if they were its arguments. ESC[48:5:1;0m now ends with the background cleared, and ESC[0;38:2::0:0:0;41m keeps its red background.
  3. The sanitizer prints each tab as one space, and width measurement counts a tab as one column. Text measured before sanitizing, such as Max Width in Custom Command and Git Branch, then agrees with what is printed. A tab's real advance depends on the column it lands in. That column isn't known while a widget is measured, and it changes with where the host draws the line, so measuring the real width isn't reliable. One space reads as the field separator a tab usually is in one-line output. USAGE.md and DEVELOPMENT.md are updated.

Demo

Piped renders of Custom Command widgets, before (main) and after.

tput reset with preserve colors off. Line 1 has preserve colors off; line 2 has it on, for reference. Before: "build okB, 3 warningsB".

Powerline

Before:

tput reset before the fix, Powerline

After:

tput reset after the fix, Powerline

Plain

Before:

tput reset before the fix, Plain

After:

tput reset after the fix, Plain

Tabs beside a flex separator. The line is rendered at the terminal's width, and the dashes mark where flex fills it to. Line 1 prints ahead<TAB>3<TAB>behind<TAB>0; line 2 prints the same with spaces. Before, line 1 runs past the terminal and wraps. After, it ends with line 2.

Powerline

Before:

Tabs beside a flex separator before the fix, Powerline

After:

Tabs beside a flex separator after the fix, Powerline

Plain

Before:

Tabs beside a flex separator before the fix, Plain

After:

Tabs beside a flex separator after the fix, Plain

Colon-form colors under a foreground override, cut by Max Width. The global foreground override is white, and both commands are cut to 12 columns. Line 1 writes its underline and color with colons; line 2 writes them with semicolons. Before, line 1 keeps its pink, and its curly underline runs into the separator and Context Length.

Powerline

Before:

Colon-form colors before the fix, Powerline

After:

Colon-form colors after the fix, Powerline

Plain

Before:

Colon-form colors before the fix, Plain

After:

Colon-form colors after the fix, Plain

Testing

  • On main as of fix: use env override and settings for usable context ratio calculation #407 (71e8b42): merges cleanly, bun run lint is clean, and bun test gives 3959 pass, 0 fail. The built CLI under Bun 1.4.4 and Node 26.13.0 matches main in plain and Powerline mode with the baseline configs, and the TUI opens and exits cleanly. It adds no JavaScript or Node API newer than Node 14, the build's target.
  • bun run lint is clean.
  • bun test passes 3189 tests, 0 failing, across 178 files. The five touched test files go from 122 to 143 tests. 17 of the 21 new cases fail on main. Of the other 4, 1 pins down an escape whose final byte is missing and 3 keep the background after a colon-form color. One of those 3 (0;38:2::0:0:0;41) fails if only the pattern is changed; the other two guard against regressions.
  • The touched test files under Node (v26.10.0, Vitest) pass 141 of 143, against 120 of 122 on main. The same 2 tests fail on both because they spy on os.homedir, which Node's ESM doesn't allow. All new tests pass.
  • Built dist/ under Bun 1.4.2 and Node v26.10.0:
    • The baseline plain and Powerline renders agree across runtimes and match main. The TUI opens and exits cleanly under both.
    • The three demo configs agree across runtimes and differ from main as shown above.

With Preserve Colors off, Custom Command reduces its output to visible
text with getVisibleText before the renderer's sanitizer sees it. The
escape parser behind it read ESC and any character other than [ or ]
as a complete two-character sequence. That is right for ESC 7 or ESC c,
but not for ESC ( B, a character-set selection with an intermediate
byte, which `tput sgr0` prints before ESC [ m on xterm-256color. Its
final "B" was left as text, so a script printing
"$(tput setaf 2)OK$(tput sgr0) done" showed "OKB done". DCS, SOS, PM
and APC strings (ESC P ... ESC \) lost only their first two characters,
so their payload printed as text. By the time the sanitizer ran, these
were ordinary characters it had no reason to remove.

Read these forms the way terminal-sanitize.ts already does: ESC,
intermediate bytes (0x20-0x2F) and a final byte as one sequence, and a
DCS, SOS, PM or APC string up to ST or BEL, stopping before any other
ESC so an unterminated string doesn't hide the text after it. Visible
width goes through the same parser, so with Preserve Colors on these
sequences no longer count toward Max Width either.
SGR parameters can be separated with colons, the ITU T.416 form:
ESC[38:5:196m, ESC[38:2::255:0:0m, or ESC[4:3m for a curly underline,
which `tput Smulx 3` prints on xterm-256color. The terminal sanitizer
keeps these, but the color pattern in ansi.ts only allowed digits and
semicolons, so everything downstream treated them as unknown:

- a Preserve Colors Custom Command cut by Max Width never got its
  closing reset, so its color or underline ran into the separators and
  widgets after it
- a global foreground override left the command's own colon-form color
  in place, where the semicolon form is recolored
- in Powerline mode, a colon-form reset such as ESC[0;38:5:196m didn't
  get the segment background re-applied after it

Allow colons in the pattern. When reading a sequence for a background
reset, an extended color written with colons holds its arguments in its
own parameter, so the following parameters are no longer skipped as if
they were its arguments: ESC[48:5:1;0m ends with the background cleared,
and ESC[0;38:2::0:0:0;41m keeps its red background.
Widget output kept its tabs: the terminal sanitizer let them through,
and width measurement gave them no columns, as string-width does for
control characters. A terminal moves a tab on to the next tab stop, up
to 8 columns, so a line holding one printed wider than it was measured.
A Custom Command such as `git rev-list --left-right --count HEAD...@{u}`,
which prints "0<tab>3", beside a flex separator filled the line to its
width by count and then ran past it on screen, where it wraps or gets
cut off. Max Width and the line's own truncation let the extra columns
through the same way.

How far a tab moves depends on the column it lands in. That isn't known
while a widget is measured, before the line is laid out, and it changes
with where the host draws the line, so measuring tabs at their real
width can't be done reliably. Instead, the sanitizer prints each tab as
one space, and width measurement counts a tab as one column, so text
measured before it is sanitized, as Custom Command's Max Width is,
agrees with what is printed. A single space reads as the field
separator a tab usually is in one-line output, without spending up to
8 columns of the status line on it.
An escape with intermediate bytes (ESC ( B) was only tested with its final
byte present. Pin down what happens when it is missing: at the end of the
text the escape is hidden up to where it stops, and a character that cannot
end it, here an emoji, stays visible after it.
…yte range

The scan for ESC, intermediate bytes and a final byte compared raw UTF-16
units and spelled the final-byte range inline in the slice call. It now reads
code points, as the grapheme-cluster scan in this file does, and tests the final
byte with isEscapeFinalByte next to isIntermediateByte, so the return reads as
"one past the final byte if there is one".

The parse is the same for every input: both ranges are ASCII, and code points
only differ from code units at a surrogate pair (a value of 0x10000 or more
instead of 0xD800 to 0xDBFF) and past the end of the text (undefined instead
of NaN), which neither range accepts either way.
# Conflicts:
#	docs/DEVELOPMENT.md
#	docs/USAGE.md
# Conflicts:
#	docs/USAGE.md
#	src/utils/ansi.ts
@axisrow

axisrow commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Reproduced on current main (66e9a08): this PR's test files over unpatched main sources.

Red: 17 failing / 166 total — matching the "17 of the 21 new cases fail on main" claim, across all three symptom families: tput sgr0 leaves a stray B (Expected: "OK done" / Received: "OKB done"), a colon-form color survives a max-width cut without being closed (output.endsWith(SGR_RESET) → false), and a tab measures as 0 columns so truncated lines overflow.

Green: with the full PR head checked out, the same files pass 166 / 0.

bun test on macOS (the tput path is Unix-specific; verified at the PR's mock level).

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.

3 participants