Skip to content

fix(custom-command): keep styling after the command's resets, drop output with no visible text, pipe the JSON as it arrived - #775

Merged
sirmalloc merged 5 commits into
sirmalloc:mainfrom
eric-engberg:fix/custom-command-output
Oct 30, 2026
Merged

sirmalloc merged 5 commits into
sirmalloc:mainfrom
eric-engberg:fix/custom-command-output

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

What

Three fixes to Custom Command output and input:

  • Styling after the command's own resets. With preserve colors on, a Custom Command now keeps its background, bold and dim across the whole widget in both render modes, even when the command's output resets its own colors partway through. Before, in plain mode, the text after the command's first \e[0m lost the widget's background, bold and dim. With a widget background or a global background override, that left a stretch of terminal background between the widget's colored padding cells. Powerline mode kept the background but lost bold and dim.
  • Output with no visible text. A command with preserve colors on that prints only color codes, such as printf '\033[32m%s\033[0m' "$value" when the value is empty, now drops out of the line the same way it does with preserve colors off. Before, it still took a slot: a doubled separator in plain mode (A | | B instead of A | B), or an empty segment with an arrow on each side in Powerline mode.
  • The JSON piped to the command. The command now gets Claude Code's JSON as it arrived, plus terminal_width, as docs/USAGE.md says. Before, it got the copy ccstatusline had checked against its schema. That copy drops nested fields the schema doesn't list (such as workspace.added_dirs, workspace.git_worktree, workspace.repo, and any new key under model, cost, context_window or rate_limits) and turns number strings into numbers. So a status line tool chained in as a Custom Command saw less than it would on its own.

Why

Styling after resets. The plain renderer wraps preserve-colors output once: widget bold, dim and background in front, and their off codes after. Any \e[0m, \e[22m or \e[49m in the command's output turns that styling off for the rest of the widget. Powerline mode already put the segment background back after each such reset, but only the background. A widget with background hex:112233, bold on and the command printf '\033[32m+5\033[0m \033[31m-3\033[0m', piped through main under Bun in plain mode, renders the widget as:

\e[1m\e[48;2;17;34;51m\e[32m+5\e[0m \e[31m-3\e[0m\e[49m\e[22m

Nothing restores the background or bold after +5\e[0m, so -3 is drawn on the terminal's background in normal weight. The same happens with a global background override and no widget background. In Powerline mode the same widget gives \e[1m\e[48;2;17;34;51m \e[32m+5\e[0m\e[48;2;17;34;51m \e[31m-3\e[0m\e[48;2;17;34;51m \e[0m: the background comes back, bold doesn't.

Output with no visible text. The widget strips escape codes only when preserve colors is off, then returns output || null. With it on, \e[32m\e[0m is a non-empty string, so it was returned as the widget's text. The renderer decides whether a widget is there by whether its text is empty, not by its width, so the zero-width widget kept its separators and padding. The line Custom Text "A", separator, that command with preserve colors on, separator, Custom Text "B", piped through main, prints [ A | | B ] (brackets added). With preserve colors off it prints [ A | B ]. In Powerline mode, with backgrounds on the three widgets, main draws an extra segment in the command's background color, with its two arrows, between A and B.

The JSON piped to the command. main parses stdin, validates it with StatusJSONSchema, and keeps only the validated copy. The widget then serializes context.data. The schema keeps unknown keys only at the top level (z.looseObject). Its nested objects are plain z.object, which drops keys it doesn't list, and its number fields convert number strings. With a Custom Command of cat > captured.json; echo ok and this payload:

{"session_id":"s","model":{"id":"claude-opus-4-6","display_name":"Opus","family":"opus"},"workspace":{"current_dir":"/p","project_dir":"/p","added_dirs":["/e"],"git_worktree":"wt","repo":{"name":"app"}},"cost":{"total_cost_usd":"1.5"},"context_window":{"context_window_size":200000,"new_key":1},"rate_limits":{"five_hour":{"used_percentage":42,"resets_at":1774020000,"extra":true}},"top_level_unknown":{"a":1}}

main writes:

{"session_id":"s","model":{"id":"claude-opus-4-6","display_name":"Opus"},"workspace":{"current_dir":"/p","project_dir":"/p"},"cost":{"total_cost_usd":1.5},"context_window":{"context_window_size":200000},"rate_limits":{"five_hour":{"used_percentage":42,"resets_at":1774020000}},"top_level_unknown":{"a":1},"terminal_width":245}

model.family, workspace.added_dirs, workspace.git_worktree, workspace.repo, context_window.new_key and rate_limits.five_hour.extra are gone, and "1.5" became 1.5. This branch writes the payload back unchanged, with terminal_width added.

How

  • restoreBackgroundAfterResets in ansi.ts becomes restoreStyleAfterResets, which takes the background code plus the widget's bold and dim. For each color sequence in the text, it reads the parameters in order and tracks the background, bold and dim separately. 0 (or an empty parameter) clears all three, 49 the background, and 22 bold and dim. A later 1, 2 or background color in the same sequence counts as the command's own setting, so it stays. After the sequence, it re-applies whatever was cleared and not set again. Extended color arguments are still skipped, so the 22 in 38;5;22 is not read as a reset.
  • Powerline mode passes the segment's bold and dim along with its background. Plain mode now calls the same function before wrapping the widget, with the widget's background (or the global background override, through a new resolveBackgroundColor helper that applyColorsWithOverride now uses too), globalBold or the widget's bold, and dim when it is on for the whole widget (not the parentheses-only setting). Claude Status's history strip also preserves its colors, but it only emits foreground codes, so this changes nothing for it. The sentence in docs/USAGE.md that said only Powerline backgrounds are restored now says both modes restore background, bold and dim.
  • CustomCommandWidget.render returns null when the output has no visible text, whether or not colors are preserved. It checks with getVisibleText, which also drops OSC 8 links with no text.
  • The piped entry point keeps the parsed JSON next to the validated copy and passes it as rawData in the render context. Custom Command serializes rawData (falling back to data) plus terminal_width. All other widgets still read the validated data.

Left as is:

  • A command that turns bold off for part of its output, inside a widget the user set to bold, gets bold back after its \e[22m. The widget's bold, dim and background cover the whole widget. Powerline mode already did this for the background.
  • Output whose only visible text is spaces, such as a colored block, still renders. Those spaces take cells, and preserve colors off behaves the same.
  • The JSON is re-serialized from the parsed object, as before, so whitespace and number formatting can differ from the bytes Claude Code sent. The values are the same.
  • The TUI preview shows [cmd: …] and never runs the command, so it isn't affected.

Demo

Each case quits the TUI, then pipes a payload through the CLI. The dim text above the status line says what the command does.

  1. A preserve-colors command with a gray background and bold whose output resets its colors twice: printf '\033[32m12 added\033[0m, \033[31m3 removed\033[0m, 2 files'. Before, plain mode drops the background and bold after 12 added, and Powerline drops the bold. After, the whole widget is gray and bold in both.
  2. printf '\033[32m%s\033[0m' "${VIRTUAL_ENV##*/}" with no virtualenv active, with preserve colors off on line 1 and on on line 2. Before, line 2 has a doubled separator in plain mode, and in Powerline an empty segment that also moves Ctx on to the next theme color. After, both lines match.
  3. A command that reads .workspace.git_worktree and .workspace.added_dirs from its stdin with jq, fed a payload that sets both. Before, it prints worktree: none, added dirs: 0. After, it prints worktree: feature-login, added dirs: 1.

Powerline

Styling after the command's resets, before:

Preserve-colors command with resets in its output, Powerline, before the fix

Styling after the command's resets, after:

Preserve-colors command with resets in its output, Powerline, after the fix

Output with no visible text, before:

Command printing only color codes, Powerline, before the fix

Output with no visible text, after:

Command printing only color codes, Powerline, after the fix

JSON piped to the command, before:

Command reading workspace fields from its stdin, Powerline, before the fix

JSON piped to the command, after:

Command reading workspace fields from its stdin, Powerline, after the fix

Plain

Styling after the command's resets, before:

Preserve-colors command with resets in its output, plain, before the fix

Styling after the command's resets, after:

Preserve-colors command with resets in its output, plain, after the fix

Output with no visible text, before:

Command printing only color codes, plain, before the fix

Output with no visible text, after:

Command printing only color codes, plain, after the fix

JSON piped to the command, before:

Command reading workspace fields from its stdin, plain, before the fix

JSON piped to the command, after:

Command reading workspace fields from its stdin, plain, after the fix

Testing

  • On main as of [low] perf(claude-status): hold a lock while the status page fetch is in flight #635 and [medium] perf(git-review): key the PR cache through the cached runGit #639 (62a371b): merges cleanly, bun run lint is clean, and bun test gives 3730 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.
  • bun test: 3195 pass, 0 fail across 178 files (main: 3168 pass, 0 fail). There are 27 new tests, and all 27 fail against main's source:
    • ansi.test.ts, 20: bold and dim restored after 0, an empty parameter and 22, each attribute tracked on its own, and sequences that leave bold on, so it must not be re-applied after them (22;1, 2, 49, 38;5;22 and others). Each of those comes after a \e[0m that must bring bold back, so it fails on main too. main has no restoreStyleAfterResets, so these ran against main through a wrapper under the new name that passes the style's background to main's restoreBackgroundAfterResets. The existing background tests move to the new name and pass on both.
    • renderer-ansi.test.ts, 2: plain mode keeps a widget background with bold, and a global background override, after the command's resets.
    • renderer-powerline-separators.test.ts, 2: Powerline re-applies bold and dim in a segment with a background, and bold in one without.
    • CustomCommand.test.ts, 3: color codes around an empty value, and an OSC 8 link with no text, render nothing with preserve colors on. The command gets the JSON as it arrived, not the validated copy.
  • bun run lint: clean.
  • Node 26.10.0, the 4 changed test files through vitest: 119 pass, 2 fail (main: 92 pass, 2 fail). The 2 failures are the same on both: existing CustomCommand.test.ts cache tests whose temp-home helper calls vi.spyOn(os, 'homedir'), which Node's ESM doesn't allow. All 27 new tests pass under Node. Against main's source, with the same wrapper, Node gives 92 pass and 29 fail: the 27 new tests plus the same 2.
  • Built dist under Bun 1.4.2 and Node 26.10.0:
    • With the baseline plain and Powerline configs, the runtimes agree and the output is the same as main.
    • With each demo's configs (and the demo payload for the stdin case), the runtimes agree with each other and differ from main as intended.
    • The TUI smoke test passes on both runtimes.
  • Manual piped renders under a scratch HOME, main against this branch: the outputs quoted under Why, plus the global background override case in plain mode, which now re-applies the override after each reset.

…ble text

A command with preserve colors on that colors a value which can be empty,
such as printf '\033[32m%s\033[0m' "$branch", prints only the color codes
when the value is empty. With preserve colors off those codes are stripped,
the output is empty and the widget drops out. With it on, the codes were
returned as the widget's text.

The renderer decides whether a widget is there by whether its text is
empty, not by its width, so the invisible widget kept its separators:
"A |  | B" in plain mode, and an empty segment with an arrow on each side
in Powerline mode.

Output with no visible text now renders the widget empty, whether or not
its colors are preserved.
…ived

The docs say a Custom Command receives the same JSON ccstatusline gets
from Claude Code, plus terminal_width, so another status line tool can be
chained in. The command got the schema-validated copy instead. Only the
top level of that schema keeps keys it doesn't list. Nested objects drop
them, so workspace.added_dirs, workspace.git_worktree and workspace.repo,
which Claude Code sends, never reached the command, and neither did any
new key under model, cost, context_window or rate_limits. Number strings
also arrived as numbers.

The entry point now keeps the parsed JSON next to the validated copy and
passes it in the render context. The widget pipes that, and the other
widgets keep reading the validated copy.
…and's resets

A Custom Command with preserve colors keeps the command's own escape
codes, and the renderer wraps them in the widget's background, bold and
dim. A reset in the output (\e[0m, \e[22m or \e[49m) clears that styling
too. Powerline mode re-applied the segment background after each reset,
but not bold or dim. Plain mode re-applied nothing: with a widget
background or a global background override, the text after the
command's first reset sat on the terminal's default background, between
the widget's colored padding cells, and lost its bold.

Both modes now re-apply the widget's background, bold and dim after each
color sequence in the output that clears them. 0 or an empty parameter
clears all three, 49 the background, and 22 bold and dim. Each is
tracked on its own: when the same sequence sets one again (1 for bold,
2 for dim, or a background color), the command's setting stays and only
the others are re-applied. In plain mode the background is the global
override when one is set, as for the rest of the widget.
# Conflicts:
#	src/ccstatusline.ts
@sirmalloc
sirmalloc merged commit 7fb9d08 into sirmalloc:main Oct 30, 2026
3 checks passed
pcvelz added a commit to pcvelz/ccstatusline-usage that referenced this pull request Nov 2, 2026
Upstream: weekly limit timers (sirmalloc#768), prompt cache miss widgets (sirmalloc#770), context window size env override (sirmalloc#352, sirmalloc#407), windowed speed fixes (sirmalloc#769, sirmalloc#771), separator/renderer fixes (sirmalloc#773), custom-command styling (sirmalloc#775), clickable cwd links (sirmalloc#744), account email from live credentials (sirmalloc#724), cache timer reported expiry + short toggle (sirmalloc#576, sirmalloc#592), claude-status lock (sirmalloc#635, sirmalloc#772), dedup token metrics (sirmalloc#622, sirmalloc#725, sirmalloc#735).
Not adopted: sirmalloc#765 terminal width from COLUMNS with fixed reserve - the fork keeps flexMode/compactThreshold and tmux pane priority.

Hand edits outside conflicts:
- README.md: kept fork Configure flex separator behavior bullet and older-updates details wrapper; dropped upstream Related Projects additions (claude-eta)
- AGENTS.md: kept fork terminal width wording (sirmalloc#765 not adopted)
- src/types/FlexMode.ts, src/tui/components/TerminalWidthMenu.tsx, src/tui/components/__tests__/TerminalWidthMenu.test.ts, src/tui/components/TerminalOptionsMenu.tsx, src/tui/components/__tests__/TerminalOptionsMenu.test.ts: restored, fork keeps terminal width settings
- src/tui/App.tsx, src/tui/components/index.ts, src/types/index.ts: re-wired terminal width menu and FlexMode export
- src/types/StatusJSON.ts: dropped fork duplicate prompt_cache key, upstream schema is a superset
- src/utils/terminal.ts: COLUMNS source gated on non-tmux so tmux pane width keeps priority
- src/widgets/WeeklyLimitTimer.ts, src/utils/usage-prefetch.ts, src/widgets/__tests__/WeeklyLimitTimer.test.ts: compat fix - fableUsage/fableResetAt -> weeklyFableUsage/weeklyFableResetAt
- src/widgets/ApiUsage.tsx: compat fix - Context Bar honors effective context window (override, clamp, 1.0M format); Weekly renders a valid value despite a session error
- src/widgets/__tests__/ApiUsage.localModelFallback.test.ts: output tokens no longer counted in context (upstream sirmalloc#730)
- src/utils/__tests__/renderer-flex-width.test.ts, src/utils/__tests__/renderer-ansi.test.ts, src/utils/__tests__/gradient.test.ts, src/utils/__tests__/renderer-separator-collapse.test.ts, src/utils/__tests__/config.test.ts, src/tui/__tests__/App.test.ts: fork deviation - fork width model (flexMode, 6-column full reserve, kept settings, Terminal Width menu entry)
- src/utils/__tests__/default-colors.test.ts: fork deviation - accept ansi256:/hex: defaults
- src/utils/__tests__/usage-prefetch.test.ts: fork deviation - weekly usage comes from the API, fork widget imports from ApiUsage
- src/widgets/__tests__/CurrentWorkingDir.test.ts, src/widgets/__tests__/CustomCommand.test.ts: Settings literal gains flexMode/compactThreshold
- src/utils/__tests__/context-window.test.ts, src/utils/__tests__/jsonl-metrics.test.ts: restored fork tests upstream dropped
- docs/test-retirements.md: ledger for the dev-reload snapshot-path and RemoteControlStatus glyph (sirmalloc#605) tests upstream replaced
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.

2 participants