From fe520927ea89fcfc2d0bf60572cf7573d2b02f2b Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Sun, 23 Aug 2026 23:26:41 -0700 Subject: [PATCH] fix(notice): one projection, one floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row 3's gate measured the wrong thing. It required the flash to beat the pin by NOTICE_FLASH_MIN_GAIN columns — right in v0.31.0, when row 3 restated row 2 and the only question was whether truncation left anything extra. Since v0.32.0 the flash is a DIFFERENT notice, so that subtraction compared two unrelated sentences: 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 rule 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? A ten-minute-old 5h window drew ▮▯×××. One big prompt front-loads burn — 7% in ten minutes — and five_dry_cell read that as a rate and walled off the window, while on the same render the pace suffix hid itself and the "5h caps" notice stayed silent. Three surfaces, one linear projection, only the loudest one speaking. window_evidence_floor is that rule in one place — 5% of a window's own length — and now backs the 5h dry cells, the pace suffix and the caps notice alike. ADVISOR_PACE_MIN_ELAPSED is retired into it: a constant beside a formula is drift waiting to happen. week_dry_slot keeps its own day-long floor on purpose — a pace reading summarises what happened, a multi-day projection needs a day. 426 tests (was 423). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 27 +++++++++++++++++++ DESIGN.md | 6 +++-- README.md | 16 ++++++----- docs/index.html | 6 ++--- llms.txt | 2 +- statusline.sh | 60 +++++++++++++++++++++++++++++------------ t/helpers.bash | 5 ++-- t/statusline.bats | 68 +++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 157 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68bccdd..ea73cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DESIGN.md b/DESIGN.md index bd3af9f..8bf7ed8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 @@ -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`). diff --git a/README.md b/README.md index 8b47237..60669cf 100644 --- a/README.md +++ b/README.md @@ -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) | @@ -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: @@ -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 diff --git a/docs/index.html b/docs/index.html index 96dd473..05c6dd3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,7 +4,7 @@ Claude Code Statusline — a meter for your Claude Code plan - + @@ -204,7 +204,7 @@

claude-code-statusline — a meter for your Claude Code plan

-
423 tests · jq + curl · no daemon · no telemetry
+
426 tests · jq + curl · no daemon · no telemetry
@@ -277,7 +277,7 @@

Built like a meter should be

Numbers that don't lie

Wall-clock resets instead of decaying countdowns. A visible [░░░░░░0%] after /compact instead of a hidden bar. Stale data is badged, never passed off as fresh. Burn is a monotone envelope, never a re-earned dip.

Nothing running in the background

-

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.

+

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.

diff --git a/llms.txt b/llms.txt index 772341d..dd2bf7c 100644 --- a/llms.txt +++ b/llms.txt @@ -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: diff --git a/statusline.sh b/statusline.sh index efeea21..4f35bf4 100755 --- a/statusline.sh +++ b/statusline.sh @@ -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 @@ -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 )) @@ -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) }') @@ -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 @@ -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' @@ -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 diff --git a/t/helpers.bash b/t/helpers.bash index 66e8198..d0d442c 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -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 @@ -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 @@ -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")" diff --git a/t/statusline.bats b/t/statusline.bats index 65bb0ef..296b43d 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -1259,6 +1259,27 @@ _seed_week_store() { [[ "$plain" =~ @[A-Z][a-z][a-z]\ [0-9]{2}:[0-9]{2}$ ]] } +@test "five_dry_cell: a fresh 5h window projects nothing — one floor for the trio" { + now=$(date +%s) + # Live report: one big prompt front-loaded 7% ten minutes into a window + # and the strip drew `▮▯×××` — while the pace suffix beside it judged + # itself too young to speak and the "5h caps" notice stayed silent. Same + # projection, three surfaces; they wait on the same evidence now. + [ "$(five_dry_cell 7 $((18000 - 600)) "$now" $((now - 600)))" = "-1" ] + [ "$(window_evidence_floor 18000)" = "900" ] + # past the floor the same burn is a rate, and the wall comes back + [ "$(five_dry_cell 7 $((18000 - 900)) "$now" $((now - 900)))" != "-1" ] + # the strip is what the user actually sees + tmpdir=$(mktemp -d) + CLAUDE_ACCOUNT_DIR="$tmpdir" + reset_5h=$(date -u -d "@$((now + 18000 - 600))" '+%Y-%m-%dT%H:%M:%SZ') + usage=$(printf '{"fetched_at":%s,"five_hour":{"utilization":7,"resets_at":"%s"},"seven_day":{"utilization":20}}' "$now" "$reset_5h") + five=$(strip_ansi "$(build_week_row "$usage" always)") + [[ "$five" == "5h "* ]] + [[ "$five" != *×* ]] + rm -rf "$tmpdir" +} + @test "strip_tail: young is a fraction of the window, not a clock reading" { now=$(date +%s) # 5h is unchanged: 15m is 5% of it, and the pace is legible from there @@ -3655,6 +3676,23 @@ JSON rm -rf "$(dirname "$state")" } +@test "notice_flash_worth_row: a sentence earns the row, a truncation stub does not" { + # the floor is absolute, not relative to the pin: since row 3 became a + # DIFFERENT notice than row 2, subtracting their lengths compared two + # unrelated sentences + run notice_flash_worth_row "! 7d dry ~Wed 19:50" # 19: carries the number + [ "$status" -eq 0 ] + run notice_flash_worth_row "! 7d dry ~Wed" # 13: says nothing to act on + [ "$status" -eq 1 ] + # the boundary itself, both sides + run notice_flash_worth_row "$(printf '%*s' "$NOTICE_FLASH_MIN_CHARS" '')" + [ "$status" -eq 0 ] + run notice_flash_worth_row "$(printf '%*s' $((NOTICE_FLASH_MIN_CHARS - 1)) '')" + [ "$status" -eq 1 ] + run notice_flash_worth_row "" + [ "$status" -eq 1 ] +} + @test "notice_flash_line: a NEW condition speaks even when the old one has faded" { state=$(mktemp -d)/notice_seen now=$(date +%s) @@ -4349,6 +4387,36 @@ make_deadman_shim() { # $1=tmpdir $2=chip output rm -rf "$tmpdir" } +@test "integration: a narrow width keeps a short flash beside a long pin" { + # The regression this guards: row 3's gate used to require the flash to + # beat the PIN by 12 columns. At 30 columns the pin is `! 5h caps ~23:54` + # (16) and the flash is cut at its first joint to `! 7d caps ~Tue 07:22` + # (20) — a gain of 4, so the second notice lost its row for being about a + # different window than the first. It is a sentence with a number in it; + # it gets the row. 30 columns is deliberate: the full sentence runs ~38 + # even when the reset gap renders short (`40h` vs `39h59m`), so the joint + # is always dropped and the lengths here do not drift with the clock. + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/.claude/statusline" + printf '{"claudeAiOauth":{"accessToken":"tok"}}' > "$tmpdir/.claude/.credentials.json" + reset_5h=$(date -u -d '+2 hours' '+%Y-%m-%dT%H:%M:%SZ') + reset_7d=$(date -u -d '+3 days' '+%Y-%m-%dT%H:%M:%SZ') + printf '{"five_hour":{"utilization":85,"resets_at":"%s"},"seven_day":{"utilization":75,"resets_at":"%s"},"fetched_at":%s}' \ + "$reset_5h" "$reset_7d" "$(date +%s)" > "$tmpdir/.claude/statusline/usage.cache" + out=$(echo '{"session_id":"flashfloor","model":{"id":"claude-opus-4-6","display_name":"Opus"},"cwd":"/t","workspace":{"current_dir":"/t"},"cost":{"total_cost_usd":0},"context_window":{"used_percentage":10,"context_window_size":200000}}' \ + | COLUMNS=30 HOME="$tmpdir" bash "$SCRIPT_DIR/statusline.sh") + [ "$(printf '%s\n' "$out" | wc -l)" -eq 3 ] + pin=$(strip_ansi "$(printf '%s\n' "$out" | sed -n 2p)" | sed 's/^ *//') + flash=$(strip_ansi "$(printf '%s\n' "$out" | sed -n 3p)" | sed 's/^ *//') + [[ "$pin" =~ ^!\ 5h\ caps\ ~[0-9]{2}:[0-9]{2}$ ]] + [[ "$flash" =~ ^!\ 7d\ caps\ ~[A-Z][a-z]{2}\ [0-9]{2}:[0-9]{2}$ ]] + # the two rows speak about different windows, and the flash is shorter + # than the old rule would ever have allowed + [ $(( ${#flash} - ${#pin} )) -lt 12 ] + [ "${#flash}" -ge "$NOTICE_FLASH_MIN_CHARS" ] + rm -rf "$tmpdir" +} + # --- last_logged_model: model context survives markers and foreign samples --- @test "last_logged_model: newest record with a model wins" {