Skip to content

Commit e97e50e

Browse files
lroolleclaude
andcommitted
feat: v0.15.1 — scoped quota badge moves next to the model
fb[67%] renders right after the model+context block instead of at the tail of the 5h/7d cluster. The scoped quota is a property of the model the session is running — the eye looks for it where the model is. It is still a weekly number (same reset as the 7d badge), scoped to one model. Extracted into build_scoped_quota_display; build_usage_display keeps only the legacy-field suppression check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9fc8783 commit e97e50e

5 files changed

Lines changed: 94 additions & 57 deletions

File tree

CHANGELOG.md

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

3+
## v0.15.1 — 2026-07-05 — scoped quota badge moves next to the model
4+
5+
`fb[67%]` now renders right after the model+context block instead of at the
6+
tail of the 5h/7d cluster:
7+
8+
```
9+
260128_ccreverse-up +131/-16 $10.53 fabl5[1m][█░░░░░12%] fb[67%] [MAX|you] 5h[95%@23:00] 7d[55%]
10+
```
11+
12+
The scoped quota is a property of the model the session is running — the eye
13+
looks for it where the model is, not in the account-wide quota cluster. It is
14+
still a weekly number (same reset as the 7d badge), just scoped to one model.
15+
16+
- Extracted into `build_scoped_quota_display`; `build_usage_display` keeps
17+
only the legacy-field suppression check.
18+
- Test count: 249.
19+
320
## v0.15.0 — 2026-07-05 — model-scoped weekly quota (`fb[65%]`)
421

522
The usage API moved per-model weekly limits out of dedicated fields

README.md

Lines changed: 3 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. **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. |
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 renders right after the model+context block — `fabl5[1m][12%] fb[67%]` on a Fable 5 session, `op[33%]` on Opus — because the quota is a property of the model you're running, not of the account-wide 5h/7d cluster. It's a weekly number (same reset as the 7d badge), scoped to one model. 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

@@ -109,7 +109,7 @@ red, matching its Claude Code TUI color) = model family; everything else is
109109
| OAuth + macOS Keychain | -- | Yes |
110110
| Quota bump flash (+N) | -- | Yes |
111111
| Model-scoped weekly quota (`fb`/`op`/`sn`) | -- | Yes |
112-
| 248 bats tests + CI | -- | Yes |
112+
| 249 bats tests + CI | -- | Yes |
113113

114114
## Configuration
115115

@@ -282,7 +282,7 @@ run. Setting only `CLAUDE_CACHE_DIR` keeps the legacy single-dir behavior.
282282
npm exec --yes bats -- t/
283283
```
284284

285-
248 tests across `t/statusline.bats` (236 statusline + integration) and
285+
249 tests across `t/statusline.bats` (237 statusline + integration) and
286286
`t/install.bats` (12 installer). CI runs on push and PR to `main`.
287287

288288
## Project Structure

statusline.sh

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1706,11 +1706,47 @@ model_scope_abbrev() {
17061706
esac
17071707
}
17081708

1709+
# Model-scoped weekly quota badge (limits[] kind=weekly_scoped, the contract
1710+
# that replaced seven_day_opus/sonnet). Renders only the scope matching the
1711+
# model THIS session is burning — other models' quotas are noise. Substring
1712+
# match, so scope "Fable" hits claude-fable-5. It's a WEEKLY number (shares
1713+
# its reset with the 7d badge) but displays next to the model+context block,
1714+
# not in the 5h/7d cluster: the quota is a property of the model you're
1715+
# running, and that's where the eye looks for it.
1716+
build_scoped_quota_display() {
1717+
local usage_data="$1"
1718+
local current_model="$2"
1719+
[ -n "$usage_data" ] || return 0
1720+
[ -n "$current_model" ] || return 0
1721+
1722+
local scoped_tsv
1723+
scoped_tsv=$(echo "$usage_data" | jq -r '
1724+
.limits[]? | select(.kind == "weekly_scoped" and (.scope.model.display_name // "") != "")
1725+
| [(.scope.model.display_name | ascii_downcase), (.percent // 0)] | @tsv' 2>/dev/null)
1726+
[ -n "$scoped_tsv" ] || return 0
1727+
1728+
local model_lc
1729+
model_lc=$(printf '%s' "$current_model" | tr '[:upper:]' '[:lower:]')
1730+
local scope_name scope_pct
1731+
while IFS=$'\t' read -r scope_name scope_pct; do
1732+
[ -n "$scope_name" ] || continue
1733+
case "$model_lc" in
1734+
*"$scope_name"*)
1735+
local scope_int=$(printf '%.0f' "$scope_pct" 2>/dev/null || echo 0)
1736+
if [ "$scope_int" -gt 0 ] 2>/dev/null; then
1737+
local color=$(get_seven_day_color "$scope_int")
1738+
printf '%s' "${DIM}$(model_scope_abbrev "$scope_name")${color}[${scope_int}%]${RESET}"
1739+
return 0
1740+
fi
1741+
;;
1742+
esac
1743+
done <<<"$scoped_tsv"
1744+
}
1745+
17091746
build_usage_display() {
17101747
local usage_data="$1"
17111748
local user_tier="${2:-}" # MAX, PRO, ENT, TEAM, or empty
17121749
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
17141750

17151751
if [ -z "$usage_data" ]; then
17161752
echo ""
@@ -1838,38 +1874,14 @@ build_usage_display() {
18381874
parts+=("${DIM}7d${color}[${seven_int}%${reset_suffix}]${bump_part}${RESET}")
18391875
fi
18401876

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)
1877+
# Legacy model-specific 7d quotas (pre-limits[] responses only). When the
1878+
# response carries weekly_scoped limits the new contract supersedes these
1879+
# fields (they arrive null anyway); the scoped badge itself renders next
1880+
# to the model+context block via build_scoped_quota_display, not here.
18711881
# MAX users: show opus only (per your spec: "no need to show sonnet for max users")
18721882
# Others: show sonnet
1883+
local has_scoped=false
1884+
echo "$usage_data" | jq -e '[.limits[]? | select(.kind == "weekly_scoped")] | length > 0' >/dev/null 2>&1 && has_scoped=true
18731885
if [ "$has_scoped" = false ]; then
18741886
if [ "$user_tier" = "MAX" ]; then
18751887
if [ "$opus_int" -gt 0 ] 2>/dev/null; then
@@ -2497,9 +2509,14 @@ if [ -n "$session_id" ] && [ "$_may_have_oauth" = true ]; then
24972509
[ -n "$rl_seven_pct" ] && seven_int_cache=$(printf '%.0f' "$rl_seven_pct" 2>/dev/null || echo "$seven_int_cache")
24982510
fi
24992511

2500-
quota_display=$(build_usage_display "$usage_data" "$user_tier" "$quota_state_file" "${model_id:-$model_display}")
2512+
quota_display=$(build_usage_display "$usage_data" "$user_tier" "$quota_state_file")
25012513
[ -n "$quota_display" ] && quota_component="$quota_display"
25022514

2515+
# Model-scoped weekly quota hugs the model+context block (it's a
2516+
# property of the model this session runs, not of the 5h/7d cluster).
2517+
scoped_display=$(build_scoped_quota_display "$usage_data" "${model_id:-$model_display}")
2518+
[ -n "$scoped_display" ] && model_component="${model_component} ${scoped_display}"
2519+
25032520
if [ -n "$quota_component" ]; then
25042521
if [ -f "$lock_file" ]; then
25052522
quota_component="${quota_component}${DIM}~${RESET}"
@@ -2556,7 +2573,7 @@ if [ -n "$session_id" ] && [ "$_may_have_oauth" = true ]; then
25562573
--arg sr "${rl_seven_reset}" \
25572574
'{five_hour:{utilization:$fp,resets_at:(if $fr == "" then null else $fr end)},
25582575
seven_day:{utilization:$sp,resets_at:(if $sr == "" then null else $sr end)}}')
2559-
quota_display=$(build_usage_display "$stdin_usage" "$user_tier" "$quota_state_file" "${model_id:-$model_display}")
2576+
quota_display=$(build_usage_display "$stdin_usage" "$user_tier" "$quota_state_file")
25602577
[ -n "$quota_display" ] && quota_component="$quota_display"
25612578
five_int=$(printf '%.0f' "${rl_five_pct:-0}" 2>/dev/null || echo 0)
25622579
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|model_scope_abbrev|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_scoped_quota_display|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: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -230,47 +230,50 @@ setup() {
230230
[[ "$plain" != *"op["* ]]
231231
}
232232

233-
@test "build_usage_display: scoped weekly limit shows for matching model" {
233+
@test "build_scoped_quota_display: scoped weekly limit shows for matching model" {
234234
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")
235+
result=$(build_scoped_quota_display "$usage" "claude-fable-5")
236236
plain=$(strip_ansi "$result")
237-
[[ "$plain" == *"fb[64%]"* ]]
237+
[[ "$plain" == "fb[64%]" ]]
238238
}
239239

240-
@test "build_usage_display: scoped weekly limit hidden for non-matching model" {
240+
@test "build_scoped_quota_display: scoped weekly limit hidden for non-matching model" {
241241
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["* ]]
242+
result=$(build_scoped_quota_display "$usage" "claude-opus-4-8")
243+
[ -z "$result" ]
245244
}
246245

247-
@test "build_usage_display: scoped limits supersede legacy per-model fields" {
246+
@test "build_usage_display: scoped limits suppress legacy per-model fields" {
248247
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")
248+
result=$(build_usage_display "$usage" "MAX")
250249
plain=$(strip_ansi "$result")
251-
[[ "$plain" == *"fb[64%]"* ]]
252250
[[ "$plain" != *"op["* ]]
253251
}
254252

255-
@test "build_usage_display: scoped limit for opus renders op badge on opus session" {
253+
@test "build_scoped_quota_display: opus scope renders op badge on opus session" {
256254
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")
255+
result=$(build_scoped_quota_display "$usage" "claude-opus-4-8")
258256
plain=$(strip_ansi "$result")
259-
[[ "$plain" == *"op[33%]"* ]]
257+
[[ "$plain" == "op[33%]" ]]
260258
}
261259

262-
@test "build_usage_display: unknown scoped model falls back to two-letter abbrev" {
260+
@test "build_scoped_quota_display: unknown scoped model falls back to two-letter abbrev" {
263261
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")
262+
result=$(build_scoped_quota_display "$usage" "claude-zephyr-1")
265263
plain=$(strip_ansi "$result")
266-
[[ "$plain" == *"ze[42%]"* ]]
264+
[[ "$plain" == "ze[42%]" ]]
267265
}
268266

269-
@test "build_usage_display: zero-percent scoped limit is hidden" {
267+
@test "build_scoped_quota_display: zero-percent scoped limit is hidden" {
270268
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["* ]]
269+
result=$(build_scoped_quota_display "$usage" "claude-fable-5")
270+
[ -z "$result" ]
271+
}
272+
273+
@test "build_scoped_quota_display: empty model or usage yields nothing" {
274+
usage='{"limits":[{"kind":"weekly_scoped","percent":64,"scope":{"model":{"display_name":"Fable"}}}]}'
275+
[ -z "$(build_scoped_quota_display "$usage" "")" ]
276+
[ -z "$(build_scoped_quota_display "" "claude-fable-5")" ]
274277
}
275278

276279
@test "build_usage_display: on-pace 7d hides runway and reset suffix" {

0 commit comments

Comments
 (0)