Skip to content

feat(themes): apply color themes in regular (non-powerline) mode (fg-only) - #607

Open
axisrow wants to merge 13 commits into
sirmalloc:mainfrom
axisrow:feat/regular-mode-themes
Open

axisrow wants to merge 13 commits into
sirmalloc:mainfrom
axisrow:feat/regular-mode-themes

Conversation

@axisrow

@axisrow axisrow commented Sep 26, 2026 •

Copy link
Copy Markdown

Addresses the feature half of #604 — applying the built-in theme catalog in regular (non-powerline) mode. The U+E0B0 separator-font fallback (the bug half) is deliberately left for a separate change, so the issue stays open.

The same diff is merged and live-verified in our fork: axisrow#12.

What

Makes the built-in theme catalog usable in regular (non-powerline) mode: a new top-level theme setting cycles the chosen theme's segment palette across widgets as foreground colors.

Semantics

  • theme: "dracula" (any POWERLINE_THEMES name) cycles theme[level].bg[] as widget foregrounds — bg[] is the vivid segment palette, while fg[] holds the dark text colors meant to sit on those segments. ansi16-level bg* names are converted through the existing bgToFg() helper.
  • Explicit per-widget colors win over the theme; the theme beats widget default colors (Feature: use the built-in color themes in non-powerline mode (+ font-safe separator fallback) #604's proposed override semantics). Explicit colors keep their palette slot, so adding one accent doesn't shift the rhythm of the surrounding widgets.
  • Separators and flex separators are not themed and consume no slot (powerline filters them out before indexing — same rule).
  • Merged widgets share one slot; preserves-colors widgets keep their intrinsic ANSI output and still advance the slot — both mirror the powerline renderer.
  • Unset / 'custom' / unknown name → no theming. The level follows colorLevel, using the 16/256/truecolor calibrations already shipped with each theme.
  • Additive optional schema key: old configs parse unchanged, no version bump. ccstatusline set theme <name> / get work through the existing generic allowlist — zero CLI code changes.

Rework: uniform coverage across all themes

  • Tests: a parametrized matrix now covers every POWERLINE_THEMES entry (custom excluded) at every color level 1/2/3 — each theme cycles its bg[] palette through the identical slot rule — plus a guard that every theme ships a non-empty palette at all levels.
  • TUI parity: the theme selector now serves regular mode too (new mode prop): the main menu gains a "Theme" entry (hidden while powerline mode is enabled — powerline has its own picker), with a None entry that clears the setting. The powerline-only "copy theme to widget colors" flow remains powerline-only.
  • CLI validation: validate reports unknown theme names as warnings (valid stays true; warnings array in the --json payload); set theme <name> warns on unknown names while still writing them, so a typo never invalidates a config. No schema change — unknown names keep rendering as a silent no-op.

Verification

  • bun test: all theme/menu/CLI tests green (see note below). Covers cycle order, explicit-wins + slot rhythm, separators unthemed, preserves-colors, merged widgets sharing a slot, unset/'custom'/unknown names, the all-themes × all-levels matrix, the regular-mode selector writes (settings.theme set/clear), and the CLI warning paths.
  • bun run lint clean; bun run build OK.
  • Render check through the built binary with a theme: "dracula", colorLevel: 3 config: uncolored widgets emit exact truecolor codes (38;2;189;147;249, 38;2;248;248;242, …), separators stay unthemed; a theme: "nord" config renders the Nord palette the same way.
  • CLI: validate with an unknown theme → exit 0, OK … (with warnings), machine-readable warnings array listing the known themes; set theme nord → get --json reports "theme":"nord".
  • Live run as the active Claude Code status line (theme set, per-widget colors temporarily removed): all widgets picked up the cycled palette in the real render path; config restored afterwards.

Note: the repo's ink-based TUI tests have pre-existing timing flakiness on a loaded machine — the same tests (ItemsEditor, TerminalWidthMenu, ImportPreviewDialog, UsageLocaleEditor) fail intermittently on a clean checkout too. All tests touched by this PR are deterministic and green.

🤖 Generated with Claude Code

axisrow and others added 13 commits September 25, 2026 11:18
With several concurrent Claude Code sessions every statusline invocation
is a fresh process, so per-render fixed costs multiply by session count.

- terminal: probe each ancestor with a single `ps -o ppid=,tty=` call
  instead of two spawns per ancestor; the walk runs per render on macOS
  where numeric widths are deliberately not persisted
- usage-fetch, claude-service-status: load https-proxy-agent (and its
  transitive graph) on demand, only when a proxy is actually configured

Benchmark (13MB transcript, 4 parallel workers x 20 renders via stdin):
27.2 -> 20.6 CPU-seconds total, 0.34 -> 0.26 CPU-s per render (-24%),
wall 7.5s -> 5.6s. node --cpu-prof shows the undici/proxy graph gone
from startup (compileForInternalLoader 40.7ms -> 7.5ms per render).

Co-Authored-By: Claude Code <noreply@anthropic.com>
#1)

* feat(widgets): add Tokens Last Turn widget for per-message token usage

Implements sirmalloc#44: a widget showing token usage for
the most recent assistant message only.

The usage is collected in the shared single-pass transcript scan
(getTranscriptAnalysis) behind an includeLastTurnTokens opt-in and is
deduplicated by message.id: Claude Code writes one JSONL entry per
content block of a single API call, all sharing one message.id, so
entries are grouped per id and only the highest output_tokens is kept
instead of summing every entry (sirmalloc#549). Streaming
partials resolve to the finalized value, and the in-flight call still
counts while it streams.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(jsonl): keep sidechain and API-error usage out of the last-turn widget

trackLastTurnTokens now runs only for main-chain usage entries, matching
the recency rule already used for context length. While a Task subagent
runs, its assistant entries are the newest usage rows in the transcript,
so without the gate Turn: reported the subagent call as the user's turn.

Also documents the widget in docs/USAGE.md.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* ci: trigger workflow registration for PR branch

Co-Authored-By: Claude Code <noreply@anthropic.com>

* ci: trigger CI after enabling Actions on the fork

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
…wiring

- terminal: check the ancestor TTY before reassigning pid so each loop
  iteration reads as "one ps answer, one pid"; no call-order change
- claude-service-status: cover the lazy proxy-agent branch with tests
  (agent attached when HTTPS_PROXY is set, absent otherwise), matching
  the usage-fetch proxy coverage

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ation

- ps: use two -o flags instead of the comma list; FreeBSD's parser treats
  everything after the first '=' as one header, which collapsed the
  combined form into a single bogus column and dropped the probe to the
  tput fallback. Still one spawn per ancestor.
- walk: start at process.ppid and read each generation's TTY before
  terminating on its PPID, restoring the old two-spawn walk's generation
  coverage (1..8) and keeping a dead-end PPID from hiding a valid width
  on the same ps row. Known narrowing vs the old sequence: if the combined
  lookup fails entirely, the walk stops (the old two-call flow could still
  continue on the separately obtained PPID).

Co-Authored-By: Claude Code <noreply@anthropic.com>
* perf: cut per-repaint subprocess and module-graph cost (sirmalloc#397)

With several concurrent Claude Code sessions every statusline invocation
is a fresh process, so per-render fixed costs multiply by session count.

- terminal: probe each ancestor with a single `ps -o ppid=,tty=` call
  instead of two spawns per ancestor; the walk runs per render on macOS
  where numeric widths are deliberately not persisted
- usage-fetch, claude-service-status: load https-proxy-agent (and its
  transitive graph) on demand, only when a proxy is actually configured

Benchmark (13MB transcript, 4 parallel workers x 20 renders via stdin):
27.2 -> 20.6 CPU-seconds total, 0.34 -> 0.26 CPU-s per render (-24%),
wall 7.5s -> 5.6s. node --cpu-prof shows the undici/proxy graph gone
from startup (compileForInternalLoader 40.7ms -> 7.5ms per render).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(review): clarify width-walk invariant and test status-page proxy wiring

- terminal: check the ancestor TTY before reassigning pid so each loop
  iteration reads as "one ps answer, one pid"; no call-order change
- claude-service-status: cover the lazy proxy-agent branch with tests
  (agent attached when HTTPS_PROXY is set, absent otherwise), matching
  the usage-fetch proxy coverage

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(terminal): restore walk parity and BSD-safe ps format per verification

- ps: use two -o flags instead of the comma list; FreeBSD's parser treats
  everything after the first '=' as one header, which collapsed the
  combined form into a single bogus column and dropped the probe to the
  tput fallback. Still one spawn per ancestor.
- walk: start at process.ppid and read each generation's TTY before
  terminating on its PPID, restoring the old two-spawn walk's generation
  coverage (1..8) and keeping a dead-end PPID from hiding a valid width
  on the same ps row. Known narrowing vs the old sequence: if the combined
  lookup fails entirely, the walk stops (the old two-call flow could still
  continue on the separately obtained PPID).

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
…der path (#3)

* fix(review): clarify width-walk invariant and test status-page proxy wiring

- terminal: check the ancestor TTY before reassigning pid so each loop
  iteration reads as "one ps answer, one pid"; no call-order change
- claude-service-status: cover the lazy proxy-agent branch with tests
  (agent attached when HTTPS_PROXY is set, absent otherwise), matching
  the usage-fetch proxy coverage

Co-Authored-By: Claude Code <noreply@anthropic.com>

* perf(widgets): load Ink editors only when opened

Extend the lazy TUI boundary from 35440e4 to widget editors: the registry previously initialized Ink's useInput/reconciler/Yoga graph on every piped render. Move editor implementations into lazy chunks and retain synchronous renderEditor APIs through React Suspense. Add an isolated startup regression check and wait for lazy editors in interaction tests.\n\nVerified: bun test (2361 pass), bun run lint, Node/Bun built renders and 13MB four-worker benchmarks. Node14 distribution build failure predates this change and is tracked separately in the perf verification report.

* docs(perf): record PR 2 adversarial findings and follow-up benchmarks

---------

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
The fork's main had PR #2's pre-review revision (177686f); this brings in
the final revision from ao/ccstatusline-2/root: per-repaint subprocess and
module-graph cost cut (sirmalloc#397), the width-walk invariant clarification, and
the BSD-safe two-flag ps format with restored walk parity (0fc829c).

Conflicted on src/utils/terminal.ts: kept the TTY-probe-before-break
ordering from the final revision and dropped the obsolete duplicate check
after the parentPid break.

Co-Authored-By: Claude Code <noreply@anthropic.com>
* fix(jsonl): dedup cumulative token totals by message.id (#8, sirmalloc#549)

Claude Code writes one JSONL entry per content block of a single API call,
all sharing one message.id. The cumulative token path summed every entry,
inflating Tokens Input/Output/Total by up to ~1.84x. Mirror the grouping
already used for Tokens Last Turn: consecutive counted entries sharing an
id contribute one usage (prompt side once, output as the running max).

- transcripts without duplicate-id entries are unchanged (existing tests)
- streaming partials resolve to the finalized value, counted once mid-stream
- sidechain/API-error rows keep their current treatment in sums and recency

Added vitest cases on a synthetic sirmalloc#549-shaped transcript asserting exact
cumulative totals (12 in / 292 out / 16660 total vs inflated 16/393/...),
streaming partials with ids, and a sidechain per-content-block group.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* docs(jsonl): note the pinned recency snapshot within a message.id group

Review follow-up on #9: the duplicate early return deliberately keeps the
context-length snapshot from the group's first counted entry. Write the
invariant down at the return so it survives as a conscious assumption.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
…es (#10)

* feat(widgets): compact label presets with global and per-widget toggles

Add abbreviated label presets for labeled widgets, complementary to the
free-form label editing proposed upstream (sirmalloc#601):

- New `compactLabels` settings flag (default off). Additive v4 key: old
  configs parse unchanged and render identically, no version bump needed.
- Presets live in one map (`Model:` -> `M:`, `Context:` -> `Ctx:`,
  `Cost:` -> `` — the value's own `$` is the glyph) inside
  `formatRawOrLabeledValue`, so every widget routed through the helper
  picks them up with no per-widget changes. Model, Session Cost, and
  Context Bar moved onto the helper.
- One-click global toggle in Global Overrides `(j)`; per-widget override
  via `(j) compact label` in the items editor (metadata `compactLabel`),
  which wins over the global setting in both directions. Capability for
  the editor keybind is derived from a cached preview probe of the
  widget registry.
- Extend the Git Branch width-cap pattern to Current Working Dir and
  Session Name (`w` max-width keybind).
- Document per-widget padding/separator/label width overhead in
  docs/USAGE.md; defaults unchanged.

Tests: preset resolution, toggle metadata, global injection precedence,
migration parity (v1->v4 and schema-less v4 parse), registry-wide `j`
keybind reservation, capable-type probe sanity, Global Overrides `(j)`
keystroke test. bun test 2400 pass; bun run lint clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(tui): compact-label toggle cycles over the effective state

The per-widget (j) toggle only wrote/removed 'true', so with the global
Compact Labels setting on it became a visual no-op: the render-time
injection re-compactified the widget after the key was removed, and no
row marker showed the effective state.

- toggleCompactLabel now takes the settings and cycles the EFFECTIVE
  state: compact (saved 'true' or inherited global on) -> write 'false';
  forced off -> drop the key and inherit; otherwise -> write 'true'.
- getCompactLabelModifierText reports the effective state:
  '(compact label)' / '(compact label: on)' / '(compact label: off)'.
- ItemsEditor/input-handlers pass settings through to both.
- docs/USAGE.md: fix preset savings arithmetic (Model -4, Context -4).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* test(renderer): cover (j) toggle forcing labels off while global compact is on

Co-Authored-By: Claude Code <noreply@anthropic.com>

* style: fix import order in compact-labels renderer test

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
…sirmalloc#602) (#11)

* feat(cli): non-interactive subcommands for agent-driven configuration (sirmalloc#602)

Add `src/utils/cli.ts` and wire it into mode detection in
src/ccstatusline.ts: args present + TTY stdin dispatches the CLI before
the interactive TUI; piped stdin keeps the statusline render path.

Subcommands operating on the same settings file (load/save/migration via
src/utils/config.ts):

- get [--json] - print the effective post-migration config
- widget add <line> <widget> [--index N] [--option value ...]
- widget remove <line> <index-or-type>
- widget move <line> <index> --to <index>
- set <option-path> <value> - global options, JSON or plain string values
- validate [--file <path>] - exit 0/1 with a machine-readable report
- help

Guarantees preserved: mutations refuse to run on an unreadable/invalid
config (never overwrite it with in-memory defaults), every write is
validate-before-write through SettingsSchema and persists via
saveSettings() (atomic temp+rename, symlink write-through). All errors
are single-line and machine-parseable with a --json variant. No new
dependencies; runtime stays Bun+Node compatible.

Vitest coverage (34 cases) for every subcommand including error paths,
idempotency, invalid-config refusal, and symlink write-through.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(cli): review follow-ups - hasOwn option guard, --json argv walk, README section

- set <path>: guard option paths with Object.prototype.hasOwnProperty instead
  of `in`, so inherited keys like toString/constructor cannot pass the check,
  get stripped by the schema, and report a false success (Node 14-safe; no
  Object.hasOwn). Regression test: set toString / powerline.constructor fail
  and leave the file untouched.
- extractJsonFlag(): walk argv and treat only a standalone --json as the
  output flag; tokens filling a value or boolean option are kept intact, so
  'widget add 0 custom-text --customText --json' no longer loses its value.
  Unit tests for the flag/value boundary plus an add-persistence case.
- README: new 'Non-Interactive CLI' section documenting the subcommands and
  noting that get/validate write defaults on a missing settings.json (first
  run) while never overwriting an existing unreadable/invalid file.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* docs(cli): note first-run default config creation in help output

Adds a line to the CLI usage block so agent loops see that get and
validate write the default config when settings.json is missing (the
behavior itself is unchanged, matching the TUI first-run contract).

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: axisrow <axisrow@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Fork builds carry the base upstream version plus a .forkN suffix
(currently 2.2.30.fork1), so the TUI title, --version output and any
build are visibly distinguishable from the npm release. Rebase the
suffix onto the new base version at each upstream sync.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…only

Add a top-level `theme` setting that cycles a POWERLINE_THEMES segment
palette across widgets as foreground colors when powerline is disabled.
The theme's bg[] array is the vivid palette (fg[] holds the dark
text-on-segment colors); ansi16-level bg* names are converted with the
existing bgToFg() helper.

Semantics: explicit per-widget colors win over the theme, theme beats
widget defaults; explicit colors keep their palette slot so the rhythm
of the surrounding widgets is stable. Separators and flex separators
are neither themed nor consume slots; merged widgets share one slot;
preserves-colors widgets keep their intrinsic ANSI output and still
advance the slot - both mirroring the powerline renderer. Unset,
'custom', or unknown names disable theming. Additive optional schema
key - configs written before it parse unchanged (no version bump);
`ccstatusline set theme <name>` works through the existing generic
`set` allowlist with zero CLI changes.

Closes the feature half of sirmalloc#604.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…CLI warnings

Follow-up to the regular-mode theme feature:

- Parametrized tests: every POWERLINE_THEMES entry (custom excluded)
  cycles its bg[] palette identically at color levels 1/2/3, plus a
  guard that every theme ships a non-empty palette at every level.
- TUI: the theme selector now also serves regular (non-powerline) mode
  via a new mode prop - the main menu gains a Theme entry (hidden while
  powerline is enabled, which has its own picker); None clears the
  setting; the powerline-only "copy theme to widget colors" flow stays
  powerline-only.
- CLI: validate reports unknown theme names as warnings (valid stays
  true, warnings array in --json output), set theme warns on unknown
  names while still writing them - a typo must not invalidate a config.

Co-Authored-By: Claude Code <noreply@anthropic.com>
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