Skip to content

Commit fac9b00

Browse files
lroolleclaude
andcommitted
fix: v0.14.0 — 5h reset back to wall-clock (@14:30)
v0.11.0 re-made a mistake v0.7.0 had fixed: a relative countdown (@1h38m) decays into a lie during idle gaps because the statusline only re-renders on activity. Cost/context/usage are "as of" facts that change only with activity; a countdown is a "from now" claim that decays at zero activity. Wall-clock stays true in a frozen frame. The always-visible half of v0.11.0 survives; only the format reverts. 7d stays relative (@5d decays at day granularity). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 366e429 commit fac9b00

5 files changed

Lines changed: 115 additions & 39 deletions

File tree

CHANGELOG.md

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

3+
## v0.14.0 — 2026-07-02 — 5h reset back to wall-clock (@14:30)
4+
5+
v0.11.0 re-made a mistake v0.7.0 had already fixed and documented: it turned
6+
the 5h reset into a relative countdown (`@1h38m`). The statusline only
7+
re-renders on activity, so a countdown rendered 30 minutes before you look at
8+
it overstates the wait by 30 minutes — and idle is exactly when no re-render
9+
comes to correct it.
10+
11+
The v0.11.0 rationale ("everything in a frozen frame is equally stale")
12+
missed the asymmetry: cost, context %, and usage % are **"as of" facts**
13+
they only change with activity, and activity triggers a re-render, so a stale
14+
frame still reads truthfully as "the state when I stepped away." A countdown
15+
is a **"from now" claim** whose truth decays with wall-clock time at zero
16+
activity. Wall-clock `@14:30` is the only format that stays true in a frozen
17+
frame.
18+
19+
The decay rule for what a non-realtime statusline may display: a quantity is
20+
safe if it changes only with activity, or decays slower than a plausible idle
21+
gap. Minute-scale countdowns on a 5h window fail; the 7d badge's `@5d` passes
22+
(day granularity) and stays relative.
23+
24+
### Changed
25+
26+
- **5h reset time is wall-clock again**: `5h[42%@14:30]`, local TZ, `@now`
27+
when past. Restored `format_reset_absolute` (lean version — the old `day`
28+
mode stays gone; the 7d badge no longer uses day names).
29+
- v0.11.0's *always-visible* half survives: the reset time still shows on
30+
every live window, not just under pressure. Only the format reverted.
31+
32+
242 tests (4 restored), shellcheck baseline unchanged.
33+
334
## v0.13.0 — 2026-07-02 — escalating fetch cooldown + categorized !badges
435

536
Live logs showed 23% of usage fetches failing 429 in clusters exactly one

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ Then add to `~/.claude/settings.json`:
6666
## What It Shows
6767

6868
```text
69-
project (main*) +84/-14 1h30m $6.72 opus4.8[1m][███░░░26%] cache:1h@14:20~ [MAX|you] 5h[87%@1h20m] 7d[75%@2d] ex[$16.29/$200 8% bal$4.66]
69+
project (main*) +84/-14 1h30m $6.72 opus4.8[1m][███░░░26%] cache:1h@14:20~ [MAX|you] 5h[87%@14:30] 7d[75%@2d] ex[$16.29/$200 8% bal$4.66]
7070
| | | | | | | | | | |
7171
path branch edits time cost model+context cache user 5h quota 7d quota extra usage
7272
```
@@ -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 window countdown while a window is live — `5h[42%@1h20m]` reads "42% used, resets in 1h20m" — because on a 5h horizon the time remaining is the number you plan the current sitting around. Same `@remaining` language as the 7d badge; relative, not wall-clock, so no mental subtraction. When a window's utilization climbs between renders, a reverse-video `+N` token appears right after the badge for ~60s: `5h[44%@1h18m]+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. |
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,7 @@ 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-
| 238 bats tests + CI | -- | Yes |
111+
| 242 bats tests + CI | -- | Yes |
112112

113113
## Configuration
114114

@@ -143,11 +143,11 @@ Flags go in the command string in `~/.claude/settings.json`:
143143
### Extra Usage Gating
144144

145145
```text
146-
--extra auto 5h[24%@3h10m] 7d[10%] (calm, hidden)
147-
--extra auto 5h[87%@1h20m] 7d[10%] ex[$19.52/$200 10%] (5h >= 80%, shown)
148-
--extra always 5h[24%@3h10m] 7d[10%] ex[$19.52/$200 10% bal$4.66] (always shown)
149-
--extra on-limit 5h[87%@1h20m] 7d[10%] ex[$19.52/$200 10%] (same as auto minus extra_util gate)
150-
--extra off 5h[24%@3h10m] 7d[10%] (always hidden)
146+
--extra auto 5h[24%@14:30] 7d[10%] (calm, hidden)
147+
--extra auto 5h[87%@14:30] 7d[10%] ex[$19.52/$200 10%] (5h >= 80%, shown)
148+
--extra always 5h[24%@14:30] 7d[10%] ex[$19.52/$200 10% bal$4.66] (always shown)
149+
--extra on-limit 5h[87%@14:30] 7d[10%] ex[$19.52/$200 10%] (same as auto minus extra_util gate)
150+
--extra off 5h[24%@14:30] 7d[10%] (always hidden)
151151
```
152152

153153
`auto` (default) shows extra when quota runs out (5h >= 80%, 7d >= 70%) or
@@ -281,7 +281,7 @@ run. Setting only `CLAUDE_CACHE_DIR` keeps the legacy single-dir behavior.
281281
npm exec --yes bats -- t/
282282
```
283283

284-
238 tests across `t/statusline.bats` (226 statusline + integration) and
284+
242 tests across `t/statusline.bats` (230 statusline + integration) and
285285
`t/install.bats` (12 installer). CI runs on push and PR to `main`.
286286

287287
## Project Structure

statusline.sh

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,6 +1364,28 @@ format_reset_relative() {
13641364
fi
13651365
}
13661366

1367+
# Wall-clock reset time (@14:30), local TZ. Claude Code only re-renders the
1368+
# statusline on activity, so any minute-scale countdown quietly decays into a
1369+
# lie during an idle gap — "@1h38m" rendered 30 minutes ago overstates the
1370+
# wait by 30 minutes, and idle is exactly when no re-render comes to fix it.
1371+
# Wall-clock stays true no matter how stale the frame is. The "as of" numbers
1372+
# in the same frame (cost, context %, usage %) don't have this problem: they
1373+
# only change with activity, and activity triggers a re-render. Restored from
1374+
# v0.7.0 (v0.11.0 reversed it and re-made the v0.6-era mistake). "now" when
1375+
# the reset is already past.
1376+
format_reset_absolute() {
1377+
local ts="$1"
1378+
[ -z "$ts" ] || [ "$ts" = "null" ] && return
1379+
local reset_epoch
1380+
reset_epoch=$(_epoch_from_ts "$ts")
1381+
[ -z "$reset_epoch" ] && return
1382+
if [ "$reset_epoch" -le "$(date +%s)" ] 2>/dev/null; then
1383+
echo "now"
1384+
return
1385+
fi
1386+
_fmt_epoch "$reset_epoch" '%H:%M'
1387+
}
1388+
13671389
get_reset_seconds() {
13681390
local ts="$1"
13691391
[ -z "$ts" ] || [ "$ts" = "null" ] && { echo ""; return; }
@@ -1704,12 +1726,14 @@ build_usage_display() {
17041726
local parts=()
17051727

17061728
# 5h quota (always show if >0)
1707-
# The window countdown is always visible while a window is live: a 5h
1708-
# horizon is short enough that "how long until reset" is the number you
1709-
# plan the current sitting around, so it isn't gated on pressure like the
1710-
# 7d deadline. Relative (@1h20m) — same @remaining language as the 7d
1711-
# badge — since "1h20m left" is the answer; a wall clock makes you do the
1712-
# subtraction. Recovery color (DIM_GREEN) when high usage + reset <= 30min.
1729+
# The reset time is always visible while a window is live: a 5h horizon
1730+
# is short enough that "when does it reset" is the number you plan the
1731+
# current sitting around, so it isn't gated on pressure like the 7d
1732+
# deadline. WALL-CLOCK (@14:30), not a countdown: the statusline only
1733+
# re-renders on activity, so "@1h38m" decays into a lie during idle gaps
1734+
# while "@14:30" stays true in a frozen frame (see format_reset_absolute).
1735+
# The 7d badge keeps relative @Nd — day granularity outlives any idle gap.
1736+
# Recovery color (DIM_GREEN) when high usage + reset <= 30min.
17131737
if [ "$five_int" -gt 0 ] 2>/dev/null; then
17141738
local color=$(get_usage_color "$five_int")
17151739
local reset_suffix=""
@@ -1720,8 +1744,8 @@ build_usage_display() {
17201744
if [ "$five_int" -ge 80 ] && [ -n "$reset_secs" ] && [ "$reset_secs" -le $FIVE_HOUR_RECOVERY_SECS ] 2>/dev/null; then
17211745
color="$DIM_GREEN"
17221746
fi
1723-
local rel=$(format_reset_relative "$five_reset")
1724-
[ -n "$rel" ] && reset_suffix="${DIM}@${rel}${color}"
1747+
local abs=$(format_reset_absolute "$five_reset")
1748+
[ -n "$abs" ] && reset_suffix="${DIM}@${abs}${color}"
17251749
fi
17261750
# Bump flash: reverse-video +N right after the badge — bound tight so
17271751
# it can't read as belonging to the next badge, unmissable without

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|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|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: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -196,12 +196,12 @@ setup() {
196196
[ -z "$result" ]
197197
}
198198

199-
@test "build_usage_display: 5h at 87% includes reset countdown" {
199+
@test "build_usage_display: 5h at 87% includes wall-clock reset time" {
200200
reset_time=$(date -u -d '+1 hour' '+%Y-%m-%dT%H:%M:%SZ')
201201
usage="{\"five_hour\":{\"utilization\":87,\"resets_at\":\"$reset_time\"},\"seven_day\":{\"utilization\":10}}"
202202
result=$(build_usage_display "$usage" "")
203203
plain=$(strip_ansi "$result")
204-
[[ "$plain" =~ 5h\[87%@ ]]
204+
[[ "$plain" =~ 5h\[87%@[0-9]{2}:[0-9]{2}\] ]]
205205
}
206206

207207
@test "build_usage_display: 7d at 75% under pace pressure shows reset" {
@@ -232,16 +232,14 @@ setup() {
232232

233233
@test "build_usage_display: on-pace 7d hides runway and reset suffix" {
234234
# 7d 40% late in the window (reset in ~1d => ~6d elapsed) is well under
235-
# pace, so no runway hint, no @reset. The 5h countdown is always visible
236-
# (a live window always shows its @remaining), so 5h carries @ here.
237-
# +3h30m (not a whole hour): renders 3h30m or 3h29m depending on the
238-
# second the clock ticks, so the @3h prefix assertion can't flake.
235+
# pace, so no runway hint, no @reset. The 5h reset time is always visible
236+
# (a live window always shows its wall-clock @HH:MM), so 5h carries @ here.
239237
reset_time=$(date -u -d '+3 hours 30 minutes' '+%Y-%m-%dT%H:%M:%SZ')
240238
reset_time_7d=$(date -u -d '+1 day' '+%Y-%m-%dT%H:%M:%SZ')
241239
usage="{\"five_hour\":{\"utilization\":50,\"resets_at\":\"$reset_time\"},\"seven_day\":{\"utilization\":40,\"resets_at\":\"$reset_time_7d\"}}"
242240
result=$(build_usage_display "$usage" "")
243241
plain=$(strip_ansi "$result")
244-
[[ "$plain" == *"5h[50%@3h"* ]]
242+
[[ "$plain" =~ 5h\[50%@[0-9]{2}:[0-9]{2}\] ]]
245243
[[ "$plain" == *"7d[40%]"* ]]
246244
[[ "$plain" != *"d!"* ]]
247245
}
@@ -1160,9 +1158,8 @@ JSON
11601158
payload 40 | HOME="$tmpdir" CLAUDE_DATA_DIR="$tmpdir/.claude/statusline" CLAUDE_CACHE_DIR="$tmpdir/sessions" bash "$SCRIPT_DIR/statusline.sh" >/dev/null
11611159
result=$(payload 43 | HOME="$tmpdir" CLAUDE_DATA_DIR="$tmpdir/.claude/statusline" CLAUDE_CACHE_DIR="$tmpdir/sessions" bash "$SCRIPT_DIR/statusline.sh")
11621160
plain=$(strip_ansi "$result")
1163-
# +N binds tight to its badge, outside the brackets: 5h[43%@3h...]+3
1164-
[[ "$plain" == *"5h[43%@3h"* ]]
1165-
[[ "$plain" == *"]+3"* ]]
1161+
# +N binds tight to its badge, outside the brackets: 5h[43%@HH:MM]+3
1162+
[[ "$plain" =~ 5h\[43%@[0-9]{2}:[0-9]{2}\]\+3 ]]
11661163
# State is per-session and lives beside the cache-health files.
11671164
[ -f "$tmpdir/sessions/sess-bump_quota_seen" ]
11681165
rm -rf "$tmpdir"
@@ -1198,25 +1195,50 @@ JSON
11981195
[ -z "$result" ]
11991196
}
12001197

1201-
# --- 5h reset countdown (always visible, relative) ---
1198+
# --- 5h reset time (always visible, wall-clock) ---
1199+
# Wall-clock, not a countdown: the statusline only re-renders on activity,
1200+
# so a relative "@1h38m" decays into a lie during idle gaps. @HH:MM stays
1201+
# true in a frozen frame (v0.7.0 rationale, restored in v0.14.0).
12021202

1203-
@test "build_usage_display: 5h at 40% shows relative remaining time" {
1204-
# +1h30m avoids the whole-hour boundary: renders 1h30m or 1h29m.
1203+
@test "format_reset_absolute: future timestamp renders local HH:MM" {
1204+
ts=$(date -u -d '+2 hours 30 minutes' '+%Y-%m-%dT%H:%M:%SZ')
1205+
result=$(format_reset_absolute "$ts")
1206+
[[ "$result" =~ ^[0-9]{2}:[0-9]{2}$ ]]
1207+
}
1208+
1209+
@test "format_reset_absolute: bare epoch works" {
1210+
ts=$(date -d '+2 hours' +%s)
1211+
result=$(format_reset_absolute "$ts")
1212+
[[ "$result" =~ ^[0-9]{2}:[0-9]{2}$ ]]
1213+
}
1214+
1215+
@test "format_reset_absolute: past timestamp returns now" {
1216+
ts=$(date -d '-1 hour' +%s)
1217+
result=$(format_reset_absolute "$ts")
1218+
[ "$result" = "now" ]
1219+
}
1220+
1221+
@test "format_reset_absolute: empty and null return empty" {
1222+
[ -z "$(format_reset_absolute "")" ]
1223+
[ -z "$(format_reset_absolute "null")" ]
1224+
}
1225+
1226+
@test "build_usage_display: 5h at 40% shows wall-clock reset time" {
12051227
reset_time=$(date -u -d '+1 hour 30 minutes' '+%Y-%m-%dT%H:%M:%SZ')
12061228
usage="{\"five_hour\":{\"utilization\":40,\"resets_at\":\"$reset_time\"},\"seven_day\":{\"utilization\":10}}"
12071229
result=$(build_usage_display "$usage" "")
12081230
plain=$(strip_ansi "$result")
1209-
[[ "$plain" == *"5h[40%@1h"* ]]
1231+
[[ "$plain" =~ 5h\[40%@[0-9]{2}:[0-9]{2}\] ]]
12101232
}
12111233

1212-
@test "build_usage_display: 5h at 30% shows countdown even far from reset" {
1213-
# Low usage, distant reset — the old gating hid this; the countdown is
1214-
# now unconditional while a window is live.
1234+
@test "build_usage_display: 5h at 30% shows reset time even far from reset" {
1235+
# Low usage, distant reset — the old gating hid this; the reset time is
1236+
# unconditional while a window is live.
12151237
reset_time=$(date -u -d '+3 hours 30 minutes' '+%Y-%m-%dT%H:%M:%SZ')
12161238
usage="{\"five_hour\":{\"utilization\":30,\"resets_at\":\"$reset_time\"},\"seven_day\":{\"utilization\":10}}"
12171239
result=$(build_usage_display "$usage" "")
12181240
plain=$(strip_ansi "$result")
1219-
[[ "$plain" == *"5h[30%@3h"* ]]
1241+
[[ "$plain" =~ 5h\[30%@[0-9]{2}:[0-9]{2}\] ]]
12201242
}
12211243

12221244
@test "build_usage_display: 5h without resets_at stays a bare badge" {
@@ -1295,9 +1317,8 @@ JSON
12951317
build_usage_display "$usage1" "" "$tmpdir/state" >/dev/null
12961318
result=$(build_usage_display "$usage2" "" "$tmpdir/state")
12971319
plain=$(strip_ansi "$result")
1298-
# +N outside, tight: 5h[45%@2h...]+3
1299-
[[ "$plain" == *"5h[45%@2h"* ]]
1300-
[[ "$plain" == *"]+3"* ]]
1320+
# +N outside, tight: 5h[45%@HH:MM]+3
1321+
[[ "$plain" =~ 5h\[45%@[0-9]{2}:[0-9]{2}\]\+3 ]]
13011322
rm -rf "$tmpdir"
13021323
}
13031324

0 commit comments

Comments
 (0)