Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@

## Unreleased

## v0.32.1 — 2026-08-23 — one projection, one floor

**Row 3's gate was measuring the wrong thing.** It required the flash to
beat the pin by `NOTICE_FLASH_MIN_GAIN` columns — the right question in
v0.31.0, when row 3 restated row 2 and the only issue was whether
truncation had left anything extra to say. Since v0.32.0 the flash is a
*different* notice, so that subtraction compared two unrelated sentences
and dropped a short one whenever the pin happened to be long: at 30
columns `! 5h caps ~23:54` (16) beside `! 7d caps ~Tue 07:22` (20) is a
gain of 4, and the second window lost its row for no reason a reader
could name. The gate is now an absolute floor on the compacted flash
itself (`NOTICE_FLASH_MIN_CHARS=16`, in `notice_flash_worth_row`): did
truncation leave a sentence, or a stub? `7d dry ~Wed` says nothing you
can act on; `7d dry ~Wed 19:50` still carries the number.

**A ten-minute-old 5h window drew `▮▯×××`.** One big prompt front-loads
burn — 7% in ten minutes — and `five_dry_cell` projected that as a rate
and walled off the rest of the window. On the very same render the pace
suffix hid itself (too young to judge) and the "5h caps" notice stayed
silent (it waits 15 minutes): three surfaces, one linear projection,
and only the loudest one spoke. The 5h dry cells now wait on the same
evidence the other two do. `window_evidence_floor` is that rule in one
place — 5% of the window's own length, so the 5h trio agrees at 15
minutes and the 7d pace suffix keeps its ~8.4h — and
`ADVISOR_PACE_MIN_ELAPSED` is retired into it, because a constant beside
a formula is a drift waiting to happen.

## v0.32.0 — 2026-08-23 — say it once, and only when you know it

**Row 3 was restating row 2 at greater length.** The flash reader
Expand Down
6 changes: 4 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ badges.
▮ now
▯ ahead — the hollow of ▮
...▯(x14) the folded future: 14 more 5h windows before the reset (live 7d row)
× pace won't cover it (7d: learned forecast, linear when cold; 5h: linear)
× pace won't cover it (7d: learned forecast, linear when cold; 5h: linear;
no projection before 5% of the window has run — same floor as the pace)
```

Burn cells take their badge's pressure color; nothing else in the row is
Expand All @@ -128,7 +129,8 @@ rank voice scope key hl short long
frame: two clauses about one window can never disagree.
- **key** — identity of the *condition*, not of the text. Row 3 shows a
notice only while its key is new to this session (~90 s), so a long
explanation arrives once and then gets out of the way.
explanation arrives once and then gets out of the way. Never the pin's
key: row 3 starts at the second record, so one condition costs one row.
- **hl** — the number the reader acts on: bold, then back to the voice.
- **short / long** — the pin, and the sentence for a surface with a
whole line (row 3, `--check`, `--week`).
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ fire.
| `░` | unknown: the log has no sample for that cell (never drawn as idle — a gap in the record is not a quiet session) |
| `▮` | the cell you are in now |
| `▯` | a cell still ahead of you — the hollow of `▮`, an empty slot waiting |
| `×` | a cell the pool will not cover at the current pace (7d: the learned forecast's dry point, linear when untrained; 5h: linear, the same projection as the badge) |
| `×` | a cell the pool will not cover at the current pace (7d: the learned forecast's dry point, linear when untrained; 5h: linear, the same projection as the badge). Waits on the same evidence the pace suffix does — 5% of the window — so one front-loaded prompt cannot wall off a window that just opened |
| `...▯(✕28)` | the folded 7d future: 28 more 5h windows before the reset, one token instead of a run of hollow cells (`×` red when the tail ends dry). The count is windows-to-reset — what the budget line prices — not the number of cells the fold happened to hide |
| `✕` | not a cell — the multiplication sign, the row's one operator (`...▯(✕28)`, `0.7✕`, `19✕5h left`). Deliberately not `×` (U+00D7), which is already a reading: cells are the ink, the operator is punctuation, and `...×(×28)` has to say both at once. One terminal column and no emoji fallback, so the row still meets line 1's edge; override with `MULT_GLYPH` (`╳` and `✖` look stronger but are ambiguous-width and emoji-presentation respectively) |

Expand Down Expand Up @@ -339,11 +339,13 @@ proj (main*) +84/-14 8m $6.72 fabl5[1m][██░░42%] fb[91%] [MAX|@work
```

**Row 2 pins** the top notice, compacted to the room the ledgers leave —
it stays while the condition holds. **Row 3 flashes** the same notice in
full, but only for ~90 s after the condition first appears in this
session: the explanation arrives once, then gets out of the way and
leaves the pin. A second story that shows up later takes row 3 next.
The number you act on is **bold**.
it stays while the condition holds. **Row 3 flashes** the *next* notice
in full — never the pin's own sentence, which row 2 is already carrying
— and only for ~90 s after that condition first appears in this session:
the explanation arrives once, then gets out of the way and leaves the
pin. One thing to say means one row, not the same sentence at two
lengths. A flash that truncation has cut below a readable sentence is
dropped rather than shown as a stub. The number you act on is **bold**.

Three voices, one hue each:

Expand Down Expand Up @@ -511,7 +513,7 @@ run. Setting only `CLAUDE_CACHE_DIR` keeps the legacy single-dir behavior.
npm exec --yes bats -- t/
```

423 tests across `t/statusline.bats` (411 statusline + integration) and
426 tests across `t/statusline.bats` (414 statusline + integration) and
`t/install.bats` (12 installer). CI runs on push and PR to `main`.

## Project Structure
Expand Down
6 changes: 3 additions & 3 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Claude Code Statusline — a meter for your Claude Code plan</title>
<meta name="description" content="Your plan is metered; this is the meter. Live 5h/7d quota with reset times, per-model weekly limits, context pressure, session cost, burn ledgers with a learned dry-out forecast, and a notice engine that says which limit binds first — in your Claude Code prompt. One bash file, 423 tests, no daemon, no telemetry.">
<meta name="description" content="Your plan is metered; this is the meter. Live 5h/7d quota with reset times, per-model weekly limits, context pressure, session cost, burn ledgers with a learned dry-out forecast, and a notice engine that says which limit binds first — in your Claude Code prompt. One bash file, 426 tests, no daemon, no telemetry.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;600&display=swap" rel="stylesheet">
Expand Down Expand Up @@ -204,7 +204,7 @@

<header class="wrap plate">
<h1>claude-code-statusline <span>— a meter for your Claude Code plan</span></h1>
<div class="tariff">423 tests · jq + curl · no daemon · no telemetry</div>
<div class="tariff">426 tests · jq + curl · no daemon · no telemetry</div>
</header>

<main class="wrap">
Expand Down Expand Up @@ -277,7 +277,7 @@ <h2>Built like a meter should be</h2>
<div class="val"><h3>Numbers that don't lie</h3>
<p>Wall-clock resets instead of decaying countdowns. A visible <code>[░░░░░░0%]</code> after <code>/compact</code> instead of a hidden bar. Stale data is badged, never passed off as fresh. Burn is a monotone envelope, never a re-earned dip.</p></div>
<div class="val"><h3>Nothing running in the background</h3>
<p>No daemon, no telemetry, no npm. It runs when Claude Code renders the prompt, reads stdin plus one shared cached API call, prints its rows, exits. 423 bats tests on the one file.</p></div>
<p>No daemon, no telemetry, no npm. It runs when Claude Code renders the prompt, reads stdin plus one shared cached API call, prints its rows, exits. 426 bats tests on the one file.</p></div>
</div>
</section>

Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
> the points went), an advisor row that interprets the
> numbers (cap projections, expiring-surplus and underuse advice, fleet
> relief), and a `report` subcommand that ledgers what each closed window
> expired unused. 423 bats tests, deps are jq + curl, no daemon, no
> expired unused. 426 bats tests, deps are jq + curl, no daemon, no
> telemetry.

Key facts for agents:
Expand Down
60 changes: 43 additions & 17 deletions statusline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,13 @@ CACHE_BREAK_MIN_TOKENS=2000 # ignore cache drops below this (noise)
CACHE_BREAK_DROP_PCT=5 # cache read must drop >5% to count as break
CACHE_HEAVY_TOKENS=200000 # break badge turns bold-red past this: the
# >200k premium-band prefix, an expensive rewrite
ADVISOR_PACE_MIN_ELAPSED=900 # 5h cap projection needs >=15m of window aged
ADVISOR_FLEET_FRESH_SECS=3600 # sibling account cache older than this is unknown
ADVISOR_FLEET_FREE_PCT=40 # sibling counts as "free" at or under this 5h%
ADVISOR_EXPIRY_HORIZON_SECS=86400 # 7d surplus clause: inside the last day of the week
ADVISOR_SURPLUS_MIN_PCT=30 # unused weekly % that counts as expiring waste
ADVISOR_UNDERUSE_END_PCT=60 # projected end-of-week <= this => underuse advice
NOTICE_FLASH_SECS=90 # a new notice explains itself on row 3 this long
NOTICE_FLASH_MIN_GAIN=12 # ...and only while it still says more than the pin
NOTICE_FLASH_MIN_CHARS=16 # ...and only if truncation left a sentence, not a stub
NOTICE_REBASE_SECS=1800 # an out-of-band 7d reset stays newsworthy this long
NOTICE_REBASE_DROP_PCT=10 # 7d falling this much inside one window = re-based
NOTICE_TAIL_SECS=3600 # "this 5h window is closing" horizon
Expand Down Expand Up @@ -2938,12 +2937,27 @@ ledger_has_past() {
return 1
}

# How much of a window must have run before its own numbers may project:
# 5% of its length (5h -> 15m, 7d -> ~8.4h, the fraction seven_day_elapsed
# has always called the noise floor). One rule with one caller-supplied
# length, because the alternative is a constant per surface and surfaces
# that disagree about the same window — which is exactly how a 10-minute-old
# 5h window came to draw `▮▯×××` while the pace suffix on the same row
# judged itself too young to speak.
window_evidence_floor() {
echo $(( $1 / 20 ))
}

# The 5h window's own dry cell: linear pace, the same projection the 5h
# badge and the advisor's "5h caps ~14:20" use. -1 when it holds to reset.
# badge and the advisor's "5h caps ~14:20" use — so it waits for the same
# evidence they do. One prompt front-loading 7% ten minutes in is not a rate
# yet, and a wall of × is the loudest way to say it. -1 when it holds to
# reset, and while the window is too young to judge.
five_dry_cell() {
local five_int="$1" five_secs="$2" now="$3" five_start="$4"
local elapsed=$(( now - five_start ))
[ "$five_int" -gt 0 ] 2>/dev/null && [ "$elapsed" -gt 0 ] || { echo -1; return 0; }
local elapsed=$(( now - five_start )) floor
floor=$(window_evidence_floor 18000)
[ "$five_int" -gt 0 ] 2>/dev/null && [ "$elapsed" -ge "$floor" ] || { echo -1; return 0; }
local dry_epoch=$(( now + elapsed * (100 - five_int) / five_int ))
if [ "$dry_epoch" -lt $(( now + five_secs )) ]; then
echo $(( (dry_epoch - five_start) / FIVE_CELL_SECS ))
Expand All @@ -2961,18 +2975,20 @@ five_period_start() {
# Tail of a strip: pace and the axis label of its right end (the reset).
# pace = used / elapsed-fraction; >1✕ means the pool caps before the reset.
# Dim below 1✕, pressure-tinted from 1✕ (status lane), hidden while the
# window is too young to judge — and young is a FRACTION of the window, not a
# clock reading: 5% of its own length, so 5h still waits 15m and 7d waits
# ~8.4h (the same fraction seven_day_elapsed calls noisy). A flat 15m was
# right for 5h and nonsense for a week: an hour into a fresh window, 2% of
# the pool divided by 0.6% of the time printed 3✕ in red. Reset is wall
# window is too young to judge — window_evidence_floor, the one rule the 5h
# dry cells and the caps notice also wait on: 5% of the window's own length,
# so 5h waits 15m and 7d waits ~8.4h (the fraction seven_day_elapsed calls
# noisy). A flat 15m was right for 5h and nonsense for a week: an hour into a
# fresh window, 2% of the pool divided by 0.6% of the time printed 3✕ in red.
# Reset is wall
# clock: `@04:00` inside 24h, `@Wed 09:00` beyond — an axis label for a
# timeline that ends there. $5 = "hide" drops it: when the badge above
# already carries that reset, printing it again spends columns to say
# nothing (one badge per fact, in both directions).
strip_tail() {
local pct="$1" secs_left="$2" length="$3" now="$4" reset_mode="${5:-show}"
local out="" elapsed=$(( length - secs_left )) min_elapsed=$(( length / 20 ))
local out="" elapsed=$(( length - secs_left )) min_elapsed
min_elapsed=$(window_evidence_floor "$length")
if [ "$elapsed" -ge "$min_elapsed" ] && [ "$pct" -gt 0 ] 2>/dev/null; then
local pace tint band
pace=$(awk -v u="$pct" -v e="$elapsed" -v l="$length" 'BEGIN{ printf "%.1f", (u/100)/(e/l) }')
Expand Down Expand Up @@ -3732,6 +3748,17 @@ notice_flash_line() {
return 0
}

# Is the flash still worth a row after the width took its bite? The old rule
# asked whether it beat the PIN by NOTICE_FLASH_MIN_GAIN columns, which made
# sense only while row 3 restated row 2 — once the flash became a different
# notice, that subtraction compared two unrelated sentences and dropped a
# short one whenever the pin happened to be long. The question is absolute:
# `7d dry ~Wed` has spent a row to say nothing you can act on, `7d dry ~Wed
# 19:50` still carries the number.
notice_flash_worth_row() {
[ "${#1}" -ge "$NOTICE_FLASH_MIN_CHARS" ]
}

# Read the account's live numbers and emit every notice they justify.
# Order below is the order the old advisor spoke in — pressure first, so the
# `pressure` gate still mutes the "go heavier" family; rank decides what the
Expand Down Expand Up @@ -3842,8 +3869,9 @@ notice_collect() {
# says capped; only the fleet hint still helps there.
if [ "$five_int" -ge 80 ] && [ "$five_int" -lt 100 ] \
&& [ -n "$five_secs" ] && [ "$five_secs" -gt "$FIVE_HOUR_RECOVERY_SECS" ] 2>/dev/null; then
local elapsed=$((18000 - five_secs))
if [ "$elapsed" -ge "$ADVISOR_PACE_MIN_ELAPSED" ]; then
local elapsed=$((18000 - five_secs)) floor
floor=$(window_evidence_floor 18000)
if [ "$elapsed" -ge "$floor" ]; then
local cap_secs=$(( (100 - five_int) * elapsed / five_int ))
if [ "$cap_secs" -lt "$five_secs" ]; then
local cap_hhmm gap voice='!yellow'
Expand Down Expand Up @@ -5221,10 +5249,8 @@ if [ -n "$notice_flash" ]; then
[ "$flash_anchor" -gt "$term_width" ] 2>/dev/null && flash_anchor=$term_width
notice_flash=$(compact_text "$notice_flash" $((flash_anchor - 1)) || printf '%s' "$notice_flash")
flash_plain=$(plain_text "$notice_flash")
pin_plain=$(plain_text "${advisor_short:-$advisor_line}")
# once truncation has eaten the explanation, row 3 is only the pin again:
# cheaper to say nothing than to spend a row restating it
if [ $(( ${#flash_plain} - ${#pin_plain} )) -ge "$NOTICE_FLASH_MIN_GAIN" ]; then
# a stub is not an explanation; see notice_flash_worth_row
if notice_flash_worth_row "$flash_plain"; then
queue_row "${notice_flash}${RESET}" "FLASH"
fi
fi
Expand Down
5 changes: 2 additions & 3 deletions t/helpers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ CACHE_TTL_DEFAULT="1h"
CACHE_HEAVY_TOKENS=200000
CACHE_GLYPH="≡"
MULT_GLYPH="✕"
ADVISOR_PACE_MIN_ELAPSED=900
ADVISOR_FLEET_FRESH_SECS=3600
ADVISOR_FLEET_FREE_PCT=40
ADVISOR_EXPIRY_HORIZON_SECS=86400
Expand All @@ -80,7 +79,7 @@ WEEK_FUTURE_KEEP=2
WEEK_FUTURE_MIN_HIDE=10
WEEK_CACHE_TTL_SECS=300
NOTICE_FLASH_SECS=90
NOTICE_FLASH_MIN_GAIN=12
NOTICE_FLASH_MIN_CHARS=16
NOTICE_REBASE_SECS=1800
NOTICE_REBASE_DROP_PCT=10
NOTICE_TAIL_SECS=3600
Expand All @@ -97,7 +96,7 @@ debug_log() {
# Source individual functions by extracting them from statusline.sh.
# This is deliberate: we test the actual production code, not copies.
eval "$(awk '
/^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|infer_cache_ttl_class|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|curl_ca_bundle|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|premium_band_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|build_trace_url|build_trace_component|delta_flash|delta_flash_part|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier|build_advisor_line|build_advisor_fleet_hint|_seven_day_walk|forecast_pct_per_window|build_deadman_component|log_usage_snapshot|log_stdin_snapshot|detect_session_boundary|last_logged_model|run_usage_report|run_check|run_session_summary|run_week|week_period_start|week_scan|week_history_cells|five_history_cells|week_dry_slot|ledger_has_past|build_ledger_strip|build_week_strip|build_five_strip|five_dry_cell|five_period_start|strip_tail|build_week_row|compact_text|plain_text|notice_add|notice_voice_color|notice_highlight|notice_render|notice_first_seen|notice_ranked|notice_pin_line|notice_long_line|notice_flash_line|notice_collect|_profile_walk|_scoped_walk|scoped_forecast|scoped_profile_name|forecast_usd_per_pct|session_telemetry_json)\(\)/ { capture=1 }
/^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|infer_cache_ttl_class|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|curl_ca_bundle|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|premium_band_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|build_trace_url|build_trace_component|delta_flash|delta_flash_part|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier|build_advisor_line|build_advisor_fleet_hint|_seven_day_walk|forecast_pct_per_window|build_deadman_component|log_usage_snapshot|log_stdin_snapshot|detect_session_boundary|last_logged_model|run_usage_report|run_check|run_session_summary|run_week|week_period_start|week_scan|week_history_cells|five_history_cells|week_dry_slot|ledger_has_past|window_evidence_floor|build_ledger_strip|build_week_strip|build_five_strip|five_dry_cell|five_period_start|strip_tail|build_week_row|compact_text|plain_text|notice_add|notice_voice_color|notice_highlight|notice_render|notice_first_seen|notice_ranked|notice_pin_line|notice_long_line|notice_flash_line|notice_flash_worth_row|notice_collect|_profile_walk|_scoped_walk|scoped_forecast|scoped_profile_name|forecast_usd_per_pct|session_telemetry_json)\(\)/ { capture=1 }
capture { print }
capture && /^}$/ { capture=0 }
' "$SCRIPT_DIR/statusline.sh")"
Expand Down
Loading
Loading