Skip to content

Commit 9fc8783

Browse files
lroolleclaude
andcommitted
feat: v0.15.0 — model-scoped weekly quota badge (fb[65%])
The usage API moved per-model weekly limits out of dedicated fields (seven_day_opus/seven_day_sonnet — both now null) into a generic limits[] array: kind=weekly_scoped entries carrying scope.model.display_name. The op/sn badges silently died with that contract change. Parse the new shape generically and render only the scope matching the model this session runs: fb[65%] on Fable 5, op[33%] on Opus. Other models' scoped quotas stay hidden — the limit constraining the session is signal, the rest is noise. Legacy field rendering kept for old cached responses, skipped when scoped limits exist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fac9b00 commit 9fc8783

5 files changed

Lines changed: 131 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,31 @@
11
# Changelog
22

3+
## v0.15.0 — 2026-07-05 — model-scoped weekly quota (`fb[65%]`)
4+
5+
The usage API moved per-model weekly limits out of dedicated fields
6+
(`seven_day_opus` / `seven_day_sonnet` — both now arrive null) into a generic
7+
`limits[]` array: `kind=weekly_scoped` entries carrying
8+
`scope.model.display_name`. The old `op`/`sn` badges silently died with that
9+
contract change; this release reads the new shape.
10+
11+
- Parse `limits[]` weekly_scoped entries generically — any model family the
12+
API scopes a weekly limit to, current or future (Fable 5 being the one that
13+
surfaced the change).
14+
- Render only the scope matching the model **this session** is running:
15+
`fb[65%]` on a Fable 5 session, `op[33%]` on Opus. Other models' scoped
16+
quotas stay hidden — the limit that constrains the session is signal, the
17+
rest is noise.
18+
- Badge labels follow the existing `op`/`sn` convention: `fb` (fable), `hk`
19+
(haiku); unknown families degrade to their first two letters rather than
20+
hiding the quota behind an unmapped name.
21+
- Legacy `seven_day_opus`/`seven_day_sonnet` rendering is kept for old cached
22+
responses but skipped whenever scoped limits exist (the new contract
23+
supersedes it).
24+
- `build_usage_display` gains the session model as an optional 4th argument;
25+
both call sites pass stdin's `model.id`.
26+
27+
6 new tests (248 total).
28+
329
## v0.14.0 — 2026-07-02 — 5h reset back to wall-clock (@14:30)
430

531
v0.11.0 re-made a mistake v0.7.0 had already fixed and documented: it turned

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ Every component earns its place:
8282
| Effort | Compact lowercase badge: `lo` / `md` / `xh` / `max` / `ultra` / `auto` (`high` is the default and stays hidden). Dim for routine levels; `max` / `ultra` use the pressure color; `fast` shows in fast mode. |
8383
| Context bar | Merged with model. Green / yellow / red by window pressure. On 1M models the bar also carries the premium input-pricing band: yellow past 200k tokens, red past 800k — the % alone looks calm (320k = 32%) while every request bills at the premium rate. A real 0% (e.g. right after `/compact` resets the window) renders a visibly empty bar `[░░░░░░0%]` — the snap to empty *is* the refresh signal; the bar hides only when no context data exists at all. |
8484
| User tier | Neutral white-weight (MAX bold, PRO normal, dim otherwise) — identity, never a status color. Truncated display name. |
85-
| Quota | Integer percentages. The 5h badge always carries its reset time while a window is live — `5h[42%@14:30]` reads "42% used, resets at 14:30" — because on a 5h horizon the reset is the number you plan the current sitting around. Wall-clock, not a countdown, on purpose: Claude Code only re-renders the statusline on activity, so a relative "@1h38m" silently decays into a lie during idle gaps, while "@14:30" stays true in a frozen frame. (The 7d badge stays relative — `@5d` decays at day granularity, slower than any idle gap.) When a window's utilization climbs between renders, a reverse-video `+N` token appears right after the badge for ~60s: `5h[44%@14:32]+2` means "you just burned 2%". A drop (window reset) stays quiet — the fresh low number is its own signal. **7d is forecast, not leveled**: a learned per-weekday burn profile (EWMA over your own usage history) plus your recent 24h burn project whether the quota outlasts the window — your heavy Tuesday counts more than a generic average. The verdict is color alone; under pressure the badge shows explicit time remaining in the window: `7d[44%@5d]` red means "at your pace, dry days before the reset 5 days from now". Cold start (<14 days history) falls back to window-average pacing. Recovery color when reset is imminent. |
85+
| Quota | Integer percentages. The 5h badge always carries its reset time while a window is live — `5h[42%@14:30]` reads "42% used, resets at 14:30" — because on a 5h horizon the reset is the number you plan the current sitting around. Wall-clock, not a countdown, on purpose: Claude Code only re-renders the statusline on activity, so a relative "@1h38m" silently decays into a lie during idle gaps, while "@14:30" stays true in a frozen frame. (The 7d badge stays relative — `@5d` decays at day granularity, slower than any idle gap.) When a window's utilization climbs between renders, a reverse-video `+N` token appears right after the badge for ~60s: `5h[44%@14:32]+2` means "you just burned 2%". A drop (window reset) stays quiet — the fresh low number is its own signal. **7d is forecast, not leveled**: a learned per-weekday burn profile (EWMA over your own usage history) plus your recent 24h burn project whether the quota outlasts the window — your heavy Tuesday counts more than a generic average. The verdict is color alone; under pressure the badge shows explicit time remaining in the window: `7d[44%@5d]` red means "at your pace, dry days before the reset 5 days from now". Cold start (<14 days history) falls back to window-average pacing. Recovery color when reset is imminent. **Model-scoped weekly quota**: when the usage API carries a per-model weekly limit (`limits[]`, `kind=weekly_scoped`) for the model your session is running, it gets its own badge — `fb[65%]` on a Fable 5 session, `op[33%]` on Opus. Other models' scoped quotas stay hidden: only the limit constraining *this* session is signal. Supersedes the legacy `seven_day_opus`/`seven_day_sonnet` fields, which the API now sends as null. |
8686
| Extra usage | Monthly spend, limit, prepaid balance. `--extra auto` shows when quota runs out. |
8787
| Cache health | Detects observed prompt-cache rebuilds and cache-read drops. `cache!` on break, `cache~` when building. If a future Claude Code stdin includes TTL breakdown, `--cache always` can show `cache:1h@14:20`; current stdin usually exposes aggregate cache tokens only. Hidden when healthy by default. |
8888

@@ -108,7 +108,8 @@ red, matching its Claude Code TUI color) = model family; everything else is
108108
| Prompt cache break detection | -- | Yes |
109109
| OAuth + macOS Keychain | -- | Yes |
110110
| Quota bump flash (+N) | -- | Yes |
111-
| 242 bats tests + CI | -- | Yes |
111+
| Model-scoped weekly quota (`fb`/`op`/`sn`) | -- | Yes |
112+
| 248 bats tests + CI | -- | Yes |
112113

113114
## Configuration
114115

@@ -281,7 +282,7 @@ run. Setting only `CLAUDE_CACHE_DIR` keeps the legacy single-dir behavior.
281282
npm exec --yes bats -- t/
282283
```
283284

284-
242 tests across `t/statusline.bats` (230 statusline + integration) and
285+
248 tests across `t/statusline.bats` (236 statusline + integration) and
285286
`t/install.bats` (12 installer). CI runs on push and PR to `main`.
286287

287288
## Project Structure

statusline.sh

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,10 +1693,24 @@ quota_bump_notice() {
16931693
echo "${five_d:-0} ${seven_d:-0}"
16941694
}
16951695

1696+
# Two-letter badge label for a model-scoped limit, following the op/sn
1697+
# convention. Unknown families degrade to their first two letters rather
1698+
# than hiding the quota behind a name we haven't mapped yet.
1699+
model_scope_abbrev() {
1700+
case "$1" in
1701+
opus*) echo "op" ;;
1702+
sonnet*) echo "sn" ;;
1703+
haiku*) echo "hk" ;;
1704+
fable*) echo "fb" ;;
1705+
*) printf '%.2s\n' "$1" ;;
1706+
esac
1707+
}
1708+
16961709
build_usage_display() {
16971710
local usage_data="$1"
16981711
local user_tier="${2:-}" # MAX, PRO, ENT, TEAM, or empty
16991712
local bump_state_file="${3:-}" # optional: enables the +N bump flash
1713+
local current_model="${4:-}" # model id/name of this session; gates scoped badges
17001714

17011715
if [ -z "$usage_data" ]; then
17021716
echo ""
@@ -1824,18 +1838,49 @@ build_usage_display() {
18241838
parts+=("${DIM}7d${color}[${seven_int}%${reset_suffix}]${bump_part}${RESET}")
18251839
fi
18261840

1827-
# Model-specific 7d quotas
1841+
# Model-scoped weekly quotas (limits[] kind=weekly_scoped, the contract
1842+
# that replaced seven_day_opus/sonnet — those now arrive null). Only the
1843+
# scope matching the model THIS session is burning renders: that is the
1844+
# number constraining you right now; other models' quotas are noise.
1845+
# Match is a substring test so scope "Fable" hits claude-fable-5.
1846+
local has_scoped=false
1847+
local scoped_tsv
1848+
scoped_tsv=$(echo "$usage_data" | jq -r '
1849+
.limits[]? | select(.kind == "weekly_scoped" and (.scope.model.display_name // "") != "")
1850+
| [(.scope.model.display_name | ascii_downcase), (.percent // 0)] | @tsv' 2>/dev/null)
1851+
if [ -n "$scoped_tsv" ]; then
1852+
has_scoped=true
1853+
local model_lc
1854+
model_lc=$(printf '%s' "$current_model" | tr '[:upper:]' '[:lower:]')
1855+
local scope_name scope_pct
1856+
while IFS=$'\t' read -r scope_name scope_pct; do
1857+
[ -n "$scope_name" ] || continue
1858+
case "$model_lc" in
1859+
*"$scope_name"*)
1860+
local scope_int=$(printf '%.0f' "$scope_pct" 2>/dev/null || echo 0)
1861+
if [ "$scope_int" -gt 0 ] 2>/dev/null; then
1862+
local color=$(get_seven_day_color "$scope_int")
1863+
parts+=("${DIM}$(model_scope_abbrev "$scope_name")${color}[${scope_int}%]${RESET}")
1864+
fi
1865+
;;
1866+
esac
1867+
done <<<"$scoped_tsv"
1868+
fi
1869+
1870+
# Legacy model-specific 7d quotas (pre-limits[] responses only)
18281871
# MAX users: show opus only (per your spec: "no need to show sonnet for max users")
18291872
# Others: show sonnet
1830-
if [ "$user_tier" = "MAX" ]; then
1831-
if [ "$opus_int" -gt 0 ] 2>/dev/null; then
1832-
local color=$(get_seven_day_color "$opus_int")
1833-
parts+=("${DIM}op${color}[${opus_int}%]${RESET}")
1834-
fi
1835-
else
1836-
if [ "$sonnet_int" -gt 0 ] 2>/dev/null; then
1837-
local color=$(get_seven_day_color "$sonnet_int")
1838-
parts+=("${DIM}sn${color}[${sonnet_int}%]${RESET}")
1873+
if [ "$has_scoped" = false ]; then
1874+
if [ "$user_tier" = "MAX" ]; then
1875+
if [ "$opus_int" -gt 0 ] 2>/dev/null; then
1876+
local color=$(get_seven_day_color "$opus_int")
1877+
parts+=("${DIM}op${color}[${opus_int}%]${RESET}")
1878+
fi
1879+
else
1880+
if [ "$sonnet_int" -gt 0 ] 2>/dev/null; then
1881+
local color=$(get_seven_day_color "$sonnet_int")
1882+
parts+=("${DIM}sn${color}[${sonnet_int}%]${RESET}")
1883+
fi
18391884
fi
18401885
fi
18411886

@@ -2452,7 +2497,7 @@ if [ -n "$session_id" ] && [ "$_may_have_oauth" = true ]; then
24522497
[ -n "$rl_seven_pct" ] && seven_int_cache=$(printf '%.0f' "$rl_seven_pct" 2>/dev/null || echo "$seven_int_cache")
24532498
fi
24542499

2455-
quota_display=$(build_usage_display "$usage_data" "$user_tier" "$quota_state_file")
2500+
quota_display=$(build_usage_display "$usage_data" "$user_tier" "$quota_state_file" "${model_id:-$model_display}")
24562501
[ -n "$quota_display" ] && quota_component="$quota_display"
24572502

24582503
if [ -n "$quota_component" ]; then
@@ -2511,7 +2556,7 @@ if [ -n "$session_id" ] && [ "$_may_have_oauth" = true ]; then
25112556
--arg sr "${rl_seven_reset}" \
25122557
'{five_hour:{utilization:$fp,resets_at:(if $fr == "" then null else $fr end)},
25132558
seven_day:{utilization:$sp,resets_at:(if $sr == "" then null else $sr end)}}')
2514-
quota_display=$(build_usage_display "$stdin_usage" "$user_tier" "$quota_state_file")
2559+
quota_display=$(build_usage_display "$stdin_usage" "$user_tier" "$quota_state_file" "${model_id:-$model_display}")
25152560
[ -n "$quota_display" ] && quota_component="$quota_display"
25162561
five_int=$(printf '%.0f' "${rl_five_pct:-0}" 2>/dev/null || echo 0)
25172562
seven_int_cache=$(printf '%.0f' "${rl_seven_pct:-0}" 2>/dev/null || echo 0)

t/helpers.bash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ debug_log() {
3939
# Source individual functions by extracting them from statusline.sh.
4040
# This is deliberate: we test the actual production code, not copies.
4141
eval "$(awk '
42-
/^(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|format_cache_active_time|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|reap_stale_lock|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|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier)\(\)/ { capture=1 }
42+
/^(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|format_cache_active_time|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|reap_stale_lock|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|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier)\(\)/ { capture=1 }
4343
capture { print }
4444
capture && /^}$/ { capture=0 }
4545
' "$SCRIPT_DIR/statusline.sh")"

t/statusline.bats

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,49 @@ setup() {
230230
[[ "$plain" != *"op["* ]]
231231
}
232232

233+
@test "build_usage_display: scoped weekly limit shows for matching model" {
234+
usage='{"five_hour":{"utilization":10},"seven_day":{"utilization":5},"limits":[{"kind":"weekly_scoped","group":"weekly","percent":64,"resets_at":"2099-01-05T00:00:00Z","scope":{"model":{"id":null,"display_name":"Fable"},"surface":null},"is_active":false}]}'
235+
result=$(build_usage_display "$usage" "MAX" "" "claude-fable-5")
236+
plain=$(strip_ansi "$result")
237+
[[ "$plain" == *"fb[64%]"* ]]
238+
}
239+
240+
@test "build_usage_display: scoped weekly limit hidden for non-matching model" {
241+
usage='{"five_hour":{"utilization":10},"seven_day":{"utilization":5},"limits":[{"kind":"weekly_scoped","group":"weekly","percent":64,"resets_at":"2099-01-05T00:00:00Z","scope":{"model":{"id":null,"display_name":"Fable"},"surface":null},"is_active":false}]}'
242+
result=$(build_usage_display "$usage" "MAX" "" "claude-opus-4-8")
243+
plain=$(strip_ansi "$result")
244+
[[ "$plain" != *"fb["* ]]
245+
}
246+
247+
@test "build_usage_display: scoped limits supersede legacy per-model fields" {
248+
usage='{"five_hour":{"utilization":10},"seven_day":{"utilization":5},"seven_day_opus":{"utilization":20},"limits":[{"kind":"weekly_scoped","group":"weekly","percent":64,"resets_at":"2099-01-05T00:00:00Z","scope":{"model":{"id":null,"display_name":"Fable"},"surface":null},"is_active":false}]}'
249+
result=$(build_usage_display "$usage" "MAX" "" "claude-fable-5")
250+
plain=$(strip_ansi "$result")
251+
[[ "$plain" == *"fb[64%]"* ]]
252+
[[ "$plain" != *"op["* ]]
253+
}
254+
255+
@test "build_usage_display: scoped limit for opus renders op badge on opus session" {
256+
usage='{"five_hour":{"utilization":10},"seven_day":{"utilization":5},"limits":[{"kind":"weekly_scoped","group":"weekly","percent":33,"resets_at":"2099-01-05T00:00:00Z","scope":{"model":{"id":null,"display_name":"Opus"},"surface":null},"is_active":false}]}'
257+
result=$(build_usage_display "$usage" "MAX" "" "claude-opus-4-8")
258+
plain=$(strip_ansi "$result")
259+
[[ "$plain" == *"op[33%]"* ]]
260+
}
261+
262+
@test "build_usage_display: unknown scoped model falls back to two-letter abbrev" {
263+
usage='{"five_hour":{"utilization":10},"seven_day":{"utilization":5},"limits":[{"kind":"weekly_scoped","group":"weekly","percent":42,"resets_at":"2099-01-05T00:00:00Z","scope":{"model":{"id":null,"display_name":"Zephyr"},"surface":null},"is_active":false}]}'
264+
result=$(build_usage_display "$usage" "MAX" "" "claude-zephyr-1")
265+
plain=$(strip_ansi "$result")
266+
[[ "$plain" == *"ze[42%]"* ]]
267+
}
268+
269+
@test "build_usage_display: zero-percent scoped limit is hidden" {
270+
usage='{"five_hour":{"utilization":10},"seven_day":{"utilization":5},"limits":[{"kind":"weekly_scoped","group":"weekly","percent":0,"resets_at":"2099-01-05T00:00:00Z","scope":{"model":{"id":null,"display_name":"Fable"},"surface":null},"is_active":false}]}'
271+
result=$(build_usage_display "$usage" "MAX" "" "claude-fable-5")
272+
plain=$(strip_ansi "$result")
273+
[[ "$plain" != *"fb["* ]]
274+
}
275+
233276
@test "build_usage_display: on-pace 7d hides runway and reset suffix" {
234277
# 7d 40% late in the window (reset in ~1d => ~6d elapsed) is well under
235278
# pace, so no runway hint, no @reset. The 5h reset time is always visible

0 commit comments

Comments
 (0)