Skip to content

Statusline 2.1.0 — one cost ledger, ten modules, width-aware rendering - #12

Merged
cdeust merged 7 commits into
mainfrom
refactor/statusline-module-split
Jul 26, 2026
Merged

Statusline 2.1.0 — one cost ledger, ten modules, width-aware rendering#12
cdeust merged 7 commits into
mainfrom
refactor/statusline-module-split

Conversation

@cdeust

@cdeust cdeust commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Statusline only. No change to context-guard or refine-gate.

Six commits:

  • b7b269acosts.sh becomes the one cost ledger, statusline-costs.py is removed.
  • b7c3509 — the renderer is split into ten modules; the identity line puts dir before the session dials.
  • 8af5a74 — install path, docs, and the CI gate follow the new directory.
  • bf85315 — tests for the width probe and preset resolution; the lint gate extended to the suites.
  • 58ab8eb — the A && B || true idiom in costs.sh rewritten as explicit if.
  • 600e2db — shellcheck pinned to 0.11.0 in CI.

The first three were each verified green before the gate was widened. bf85315
and 58ab8eb each failed the widened gate on findings the local (newer)
shellcheck did not report — that skew is what 600e2db closes. CI is green on
the merge head (600e2db).

Why

One cost ledger. statusline-costs.py summed every assistant line of every
transcript and over-counted ~2.2x, because Claude Code re-logs one API response
2–3 times (streaming / tool continuation). A correct total deduplicates on
message.id:requestId before pricing. Measured over 172 local transcripts:
$3438.84 deduped vs $7645.04 raw. costs.sh also prices the full recursive
<transcript>/subagents/ subtree, so Task, worktree-isolated and workflow agents
are billed — none of them appear in Claude Code's own .cost.total_cost_usd.

Ten modules. The single file had reached 1022 lines, over the 500-line cap
(rules/coding-standards.md §4.1). statusline-command.sh is now a 173-line
composition root sourcing statusline-lib/*.sh; the largest module is
render.sh at 260. A missing module is a hard failure that names the file
rather than a silently partial statusline. $STATUSLINE_LIB overrides the
location.

dir before the dials. Segment order is priority order under width
fitting. The previous order put dir last, which made the working directory the
first thing dropped on a narrow terminal.

BREAKING

  • statusline-costs.py is removed. Installs carrying a stale
    ~/.claude/statusline-costs.py should delete it; the SessionStart hook now
    does so.
  • The renderer ships as a directory. statusline-lib/ must be installed next
    to statusline-command.sh.

Full detail in the CHANGELOG 2.1.0 entry.

Completion Ledger

Paths introduced by the diff

Path Evidence
module loader — happy path (all ten modules present) test_modules_all_load_and_define_their_functions
module loader — missing module → hard failure naming the file test_missing_module_fails_loudly (asserts the emitted message names the file, not a downstream effect)
$STATUSLINE_LIB override test_missing_module_fails_loudly drives the loader through a relocated $STATUSLINE_LIB
fit_line — fits, drops trailing segments, invalid budget passes through test_fit_line_* (incl. test_fit_line_invalid_budget_passes_through)
width probe — $STATUSLINE_COLS, invalid override, $COLUMNS, tput only when stdout is a tty, wide fallback test_probe_cols_* (5 cases). The tput rung carries a negative assertion: the answer must be the wide fallback, never terminfo's blind 80
preset resolution — env pin at any width, invalid pin, config preference, width cap boundary (89/90), cap never promotes test_resolve_preset_* (6 cases)
pace — both windows, severity carried, suppressed below 10% elapsed test_pace_* cases
used_percentage non-numeric ("n/a") rejected before awk test_quota_reading_rejects_absent_window, test_pace_ratio_rejects_bad_input (previously rendered as a healthy 0%)
file_mtime — existing file, missing file → 0 test_file_mtime_reads_an_existing_file, test_file_mtime_missing_file_is_zero; test_static_no_bare_stat_call sweeps every source for a raw stat
identity segment order test_identity_puts_dir_before_the_session_dials
heat/bar rendering, palette traversal test_heat_rgb.sh (22 cases)
costs.sh dedup + subagent subtree pricing A/B against the pre-change ledger on the same store: identical row $10.178724, identical totals, same 3 sidecars

§13.1 checklist

Item State
A1 happy paths end-to-end Both suites run in full, output quoted below. Total: 22 — Echecs: 0 / Total: 46 — Echecs: 0
A2 edge cases Enumerated in the path table: missing file, non-numeric percentage, invalid budget, narrow/absent terminal, missing module, no controlling tty
A3 failure paths asserted by observable effect Loader failure asserts the message itself; file_mtime missing → 0; invalid budget → pass-through
A4 boundary validation used_percentage and width values validated before awk/arithmetic
A5 invariants Behaviour preservation is the split's stated invariant — see the golden-render section
A6 idempotency N/A — the renderer is a pure per-invocation read
B1–B3 concurrency N/A — no concurrency introduced; the renderer is a single synchronous pass
C1 scalability costs.sh walks the transcript tree once per cache miss; results are mtime-cached
C2 resource lifecycle No processes or descriptors held across the invocation; the tty probe redirects the whole group (fixes a leaked Device not configured)
C3 hot path measured Monolith-vs-modular A/B on the same machine state: no measurable difference. An earlier 1437ms reading was machine load, not the split — it did not reproduce against a same-state baseline
D1–D3 security N/A for injection/secrets — no query or command is built from data; no new access
E1 interface change Two breaks, both documented above and versioned as 2.1.0
E2 downstream consumers hooks/hooks.json, skills/statusline/SKILL.md, both READMEs, marketplace.json, plugin.json, ci.yml, and both suites — all updated in this PR. The local ~/.claude install was re-synced and verified byte-identical to the assets
E3 persisted data statusline-costs.jsonl format unchanged; the stale .py is deleted by the SessionStart hook (one-shot, no shim)
E4 cross-platform The stat -f BSD-only bug is the fix in this PR. file_mtime tries the BSD then the GNU spelling; only the runner's own spelling is exercised per platform, so test_static_no_bare_stat_call is what holds the contract on both — it sweeps every source for a raw stat call bypassing the helper. The suites run on macOS's stock bash 3.2 and on CI's GNU bash
F1 actionable signals The missing-module failure exits non-zero AND names the missing file — both asserted by test_missing_module_fails_loudly
F2 degraded modes explicit Width-driven preset fallback is a named preset, not a silent default
G1 path ledger Above
G2 regression tests for each fix stat -f, "n/a" percentage, tput cols under a pipe, the non-numeric $STATUSLINE_COLS override, and the vacuous-static-test defect each have a case. The tty-probe stderr leak is a redirection shape, asserted by review rather than a test — it has no observable output to assert on beyond the absence CI cannot see
G3 deterministic tests No sleeps, no shared fixed paths; suites re-run clean
G4 negative assertions test_static_no_lerp_or_gradrgb_residue and the other absence checks now sweep a SOURCES_UNDER_TEST array rather than one file
G5 full suite + lint Both suites green (quoted); shellcheck clean across every asset and every suite — CI now checks the test tree too, which is how the lint findings below had survived
H1 standards §4.1 was the motivation: 1022 → composition root 173, largest module 260
H2 readable, no dead code statusline-costs.py deleted rather than kept; one dead log= in costs.sh removed
H3 conventions One language per file; shell throughout
H4 CHANGELOG + docs 2.1.0 entry; both READMEs corrected (they still described the retired emoji layout)
H5 commit hygiene Six commits, conventional messages, logic separate from lint
H6 CI green Green on the pushed head 600e2db. Two intermediate commits went red on the widened gate and are fixed by the two that follow — see the commit list
H7 boy-scout (see also the lint-gate section) costs.sh carried 6 pre-existing shellcheck findings (3× SC2155, 1 dead assignment, 2 SC2016 on jq programs) — all fixed here and A/B-verified byte-identical output. shellcheck cannot follow a sourced-module boundary, so SC2034 (libs, main) and SC2154/SC2153 (render.sh) are disabled per file with a written rationale; no file is excluded from the check. The test tree carried its own SC1090/SC2004/SC2034/SC2153/SC2329 findings, invisible because CI checked only the assets — fixed or disabled per site with a rationale, and the gate widened so they cannot come back

The lint gate was not a gate

Widening shellcheck to the test tree exposed that the gate itself was
unreliable: CI installed whatever apt shipped, several releases behind a
current local install, and the two versions disagree — the older raises SC2317
where 0.11.0 raises SC2329, and SC2015 on A && B || true which 0.11.0 no
longer flags. A clean local run was therefore not evidence of a clean gate, and
two commits here went red proving it.

Fixed at the source: CI installs a pinned 0.11.0 from the upstream release, and
the four stub directives name both codes so the suites are clean under either
version — verified against 0.9.0 in a container and 0.11.0 locally, both exit 0.

The three costs.sh findings this surfaced are real regardless of reporter and
were rewritten, not silenced. The weekday counter is the one with teeth:
(( count++ )) evaluates to 0 — FALSE — on the first weekday of the month,
which is exactly why the || true was needed to keep the loop alive. A/B on the
same store, before and after: today, month and info byte-identical
($291.29 / $3528.10 / $196.01 avg-weekday).

Mutation check (§12)

The five new probe_cols / resolve_preset cases were verified against four
injected mutants rather than trusted for being green — each is killed:

Mutant Killed by
tput consulted unconditionally (drop the [ -t 1 ] guard) test_probe_cols_ignores_tput_when_stdout_is_not_a_terminal — got 80, expected 200
width cap off by one (-lt-le) test_resolve_preset_width_cap_boundary — got s at the threshold
override left unvalidated (the pre-fix code) test_probe_cols_rejects_a_non_numeric_override — got abc
config size treated as a pin (cap removed) test_resolve_preset_config_size_is_a_preference_capped_by_width + the boundary case

Behaviour preservation

The split is behaviour-preserving, verified by a golden render across a
34-configuration preset × width matrix, byte-identical before and after
(the countdown field is masked because resets in moves between runs).

Test output

$ bash tests/statusline/test_heat_rgb.sh
Total: 22 — Echecs: 0

$ bash tests/statusline/test_fit_and_pace.sh
Total: 46 — Echecs: 0

🤖 Generated with Claude Code

https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4

cdeust and others added 7 commits July 26, 2026 13:24
…ne-costs.py

statusline-costs.py summed every assistant line of every transcript, which
over-counts spend ~2.2x: Claude Code re-logs one API response 2-3 times
(streaming / tool continuation), so a correct total must deduplicate on
message.id:requestId before pricing. Measured over 172 local transcripts:
$3438.84 deduped vs $7645.04 raw.

costs.sh replaces it and prices each session from its OWN transcript plus the
full recursive <transcript>/subagents/ subtree, so Task, worktree-isolated and
workflow agents are all billed — none of them appear in Claude Code's
.cost.total_cost_usd, which covers the main thread only and additionally carries
prior spend forward on a resumed session.

pricing.json ships alongside: costs.sh resolves it next to itself, then falls
back to ~/.claude/pricing.json, and refuses to run without it.

Also fixes the six shellcheck findings the file carried, so it can join the
CI shellcheck step rather than be excluded from it: three `local x="$(...)"`
splits (SC2155 masks the substitution's exit status), one dead `log=`
assignment (SC2034), and two scoped SC2016 disables on the jq programs, whose
$models/$ml sigils are jq variables that must reach jq unexpanded.

Verified behaviour-preserving against the unmodified script over the real
ledger and a 531-line transcript: byte-identical ledger row ($10.178724),
identical today/month totals, same three cache sidecars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4
… the dials

The renderer had reached 1022 lines against the 500-line cap
(rules/coding-standards.md §4.1). It is now a composition root plus ten modules
under statusline-lib/, one concern per file, largest 249 lines:

  platform.sh       BSD/GNU spelling differences (stat, date)
  palette.sh        colour tokens, the heat-track bar
  fit.sh            visible-width measurement and trimming
  severity.sh       the one ok/warn/danger scale and its thresholds
  format.sh         numbers and times as the reader sees them
  config.sh         the two JSON config files
  gitctx.sh         the repository facts
  session_state.sh  cost ledger, transcript telemetry, subagent tracker
  layout.sh         terminal width probe, verbosity preset
  render.sh         one function per status line

statusline-command.sh now only wires: it reads the statusLine JSON, asks each
collector for its facts, asks each renderer for its line, and emits what fits.
Modules resolve relative to the script's own path ($STATUSLINE_LIB overrides),
and a missing one exits non-zero naming the file — a silently blank status bar
would read as "nothing is happening".

The split is behaviour-preserving: verified byte-identical against a
34-configuration preset x width golden render.

One deliberate behaviour change rides along, isolated in its own golden diff
(only identity lines move): the identity line renders

  model | dir | effort | thinking

rather than putting dir last. Segment order IS priority order — fit_line drops
from the tail — so the old order volunteered the working directory as the first
thing to lose on a narrow terminal, while the effort and thinking dials, which
the reader chose and already knows, survived. Confirmed against the old golden:
at 50 columns the previous build rendered "model Opus 5 | effort high |
thinking on" with no directory at all.

Tests. The static checks had been reading a single file, so every one of them
would have passed vacuously on a rule a module was the one breaking. They now
sweep the whole source set (SOURCES_UNDER_TEST = the script plus its
statusline-lib/*.sh), and five tests are added: the §4.1 size cap, every module
loading and defining its exports, the loader's missing-module failure path, and
the two identity-order regressions. Each was mutation-checked — cap raised past
a padded module, token_color duplicated into a second module, a bare BSD-only
stat call added to a module, the loader made to continue silently, and the
identity order reverted: all six mutants killed.

22/22 and 35/35 pass, shellcheck clean on the renderer and every module.
SC2034/SC2154 are disabled per file with a written rationale: shellcheck
analyses one file at a time and cannot follow a sourced-module boundary, so it
reads every cross-module constant as unused. Scoped to those two codes, never
by excluding a file from the check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4
…ut, gate in CI

Install path. The renderer is no longer one file: the skill and the SessionStart
auto-update hook now place costs.sh, pricing.json and the whole statusline-lib/
directory, and delete a leftover ~/.claude/statusline-costs.py. The lib is
installed as a WHOLE directory — the renderer refuses to start on a missing
module, so a half-updated module set is worse than an absent one. Post-install
verification checks each of the ten modules and both execute bits.

CI. It ran one of the two statusline suites and shellchecked one file, so
nothing added in this series would have been exercised. It now runs both suites
and shellchecks the renderer, every module, and costs.sh — which is why that
file's six pre-existing findings were fixed in the ledger commit rather than
excluded from the step. pricing.json joins the JSON validation.

Docs. Both READMEs still described an emoji layout (🌿 branch, 💰 cost, 🎯
gauges) that the word-based design-system rendering had already replaced, a
`grad_rgb` continuous interpolation that the discrete 4-palier heat track
replaced, and statusline-costs.py. They now document what ships: the segment
map, the module table, pace, and width fitting. plugin.json and the marketplace
entry still said "Catppuccin Mocha".

Versions: statusline plugin and skill 2.0.0 -> 2.1.0, marketplace metadata to
match. CHANGELOG entry records the removal of statusline-costs.py and the
module directory as the two breaking changes for anyone installing manually.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4
…he suites

The completion ledger for this branch surfaced two load-bearing functions
introduced by it with no asserting test: probe_cols and resolve_preset. A
coverage gap in new code is blocking (rules/coding-standards.md §13.3), so it
is closed here rather than deferred.

probe_cols walks four sources and only the $STATUSLINE_COLS rung is reachable
as-is from a test process — the rest depend on whether the runner owns a
terminal, which differs between a developer's shell and CI. Each lower rung is
reached by shadowing the command it calls (stty, tput) with a failing stub in
the test's subshell. No production seam is added for the tests and no case
depends on the runner. The tput rung gets the negative assertion the bug
deserves: with stdout on a pipe the answer must be the wide fallback, never
terminfo's blind 80.

Writing those cases exposed a defect: probe_cols printed $STATUSLINE_COLS
straight through, so a non-numeric override escaped its own postcondition ("a
positive integer, always") and broke every arithmetic width comparison
downstream. An invalid override now falls through to the probes.

The four new cases were verified against injected mutants — unconditional
tput, an off-by-one width cap, the unvalidated override, and a config size
treated as a pin — each of which they kill.

Boy-scout (§14), in the touched test tree: SC1090 x3, SC2004 x4, SC2034 x4,
SC2153 and SC2329 x9 fixed or disabled per site with a written rationale, and
CI extended to shellcheck tests/statusline/*.sh — it checked only the assets,
which is how those findings survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4
…plicit if

CI's shellcheck flags three SC2015 sites that the local 0.11.0 suppresses when
C is `true` — a version skew that makes a local run greener than the gate. The
findings are real regardless of which version reports them (§14), so the three
sites are rewritten rather than silenced.

The weekday counter is the one with teeth: `(( count++ ))` evaluates to 0 —
FALSE — on the first weekday of the month, which is precisely why the `|| true`
was needed to keep the loop alive. An assignment has no such trap.

A/B on the same ledger store, before and after: `today`, `month` and `info` all
byte-identical ($291.29 today, $3528.10 month, $196.01 avg/weekday).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4
…codes

The gate ran whatever apt shipped, which is several releases behind a current
local install. The two versions do not report the same findings — the older one
raises SC2317 where 0.11.0 raises SC2329, and SC2015 on `A && B || true` which
0.11.0 no longer flags. That skew is how three costs.sh findings and five test
findings reached CI unseen by a clean local run: an unpinned linter is not a
gate, it is a lottery.

CI now installs 0.11.0 from the upstream release, matching a local run. The
four stub directives list both codes so the suites stay clean under either
version — verified against 0.9.0 in a container and 0.11.0 locally, both exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4
The §8 citation on FIT_RATIO pointed at pictet-tech claude-statusline
assets/statusline.sh:98-108. That plugin has been uninstalled, so the
cited source can no longer be consulted, and its claim (a line near the
full width is truncated with "…" and costs the block its second row) was
never reproduced in this repo. A citation nobody can follow is not a
source.

Replaces it with what is actually established — code.claude.com/docs/en/
statusline, read 2026-07-26 against Claude Code 2.1.220: the reserve the
host takes is additive (statusLine.padding, "extra horizontal spacing (in
characters)", default 0, added to the interface's built-in spacing, whose
column count is unpublished), not proportional. Records that 85 is an
inherited working default rather than a measurement, and states the known
cost of keeping it: measure_widths.sh puts preset l's widest line at 89
columns, so at 85% l is untrimmed only from 104 columns up, while
SIZE_L_MIN_COLS selects l from 90 — l is trimmed on every refresh between
those two widths.

Comment-only; FIT_RATIO stays 85 and no rendered output changes.
Verified: shellcheck 0.11.0 clean, 46 + 22 tests pass, smoke render exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SQwMscnDJfTN7sz3Jwzg4
@cdeust
cdeust merged commit e4f089e into main Jul 26, 2026
1 check passed
@cdeust
cdeust deleted the refactor/statusline-module-split branch July 26, 2026 15:02
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