diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 27a957d..b5a0f6f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@v4 - name: Install dependencies run: sudo apt-get update && sudo apt-get install -y jq - - name: Run tests - run: npm exec --yes bats -- t/ - - name: Shellcheck (error severity; warnings are tracked, not gated) - run: shellcheck -S error statusline.sh install.sh + # `make check` = shellcheck (error severity; warnings are tracked, not + # gated) then the bats suite — the same two commands a contributor runs. + - name: make check + run: make check diff --git a/CHANGELOG.md b/CHANGELOG.md index b2252c1..f9e44c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,77 @@ ## Unreleased +## v0.34.0 — 2026-08-24 — five slots, and a window you are in is not one you have left + +**The 5h strip is five cells again — but the future in them is empty, not +judged.** v0.33.0 was right that `5h ▮▯×××` was unreadable and wrong about +which half to cut. The `×` was the problem: a linear projection dressed as +ink, saying a third time what the badge (`5h[38%@23:00]`) and the notice +(`5h caps ~14:20`) already say with better gates and an exact time. The +hollow cells were never the problem — they were the axis. Ending the strip +at `▮` took the ruler away with the forecast, and left a bar that grew an +hour at a time and answered "how long have I got" with nothing. + +So: five slots, always, one per hour, no dry cell in any of them. +`5h ▃▃▮▯▯` is two whole hours after this one, read off the row without +arithmetic and without a second glance at the clock. Fixed width is the +other half of it — the row holds its shape for the life of the window +instead of reflowing every hour, which is the difference between an axis +and a bar that grows at you. An empty cell is a fact; a `×` is a guess, and +only one of those belongs in a ledger. + +`▮` now rides the real clock rather than the grid. `five_period_start` +rounds to five minutes so `week_scan`'s cache key holds still across renders +— a `resets_at` that jitters by a second would re-run a whole-log `jq` pass +every render — and that rounding offsets every hour boundary by up to 2½ +minutes. Invisible in a bar height; wrong exactly where this strip is read. +With the marker at `4 - floor(left / 1h)` the hollow count is the whole hours +remaining to the second: at the 119-minute mark, three hours and one minute +left drew as two, and now does not. + +**`N✕5h left` no longer counts the window you are standing in.** The row +draws it as `▮` and line 1 prices it as `5h[38%]`, so counting it again +made `▮ + 11` read as twelve, and the budget sentence beside it agreed with +the miscount. "Left" now means still to come: what remains after this +window closes, `(7d left - 5h left)`, divided into windows — a stub at the +end of the week is still a window you can spend, so that rounds up. + +The arithmetic has a property the old one did not: both clocks tick down +together, so the difference does not move. The count holds steady for the +life of a window and steps down by exactly one at each rollover. It was a +reading that drifted; it is a countdown now. `windows_ahead` is that +definition in one place, and the folded `...▯(✕N)` prints what the budget +line computed rather than re-deriving it off a 34-cell grid that spans 170h +against a 168h period. + +`last window` now means the week ends inside the one you are in — nothing +ahead of it, nothing to divide the surplus across. It used to fire at one +window ahead too, to skip a `/win` clause that would just restate the +headroom; calling two windows the last one to save a redundant clause is the +wrong trade. At one ahead the line keeps the grammar: `1✕5h left · 25.0%/win`. + +**`make install`.** The one-liner installed from GitHub and there was no +way to install the tree in front of you, so a working copy got there by +hand — and a stale hand-copy is how v0.28.0 once faked a red "7d dry" at +2%. `make install` runs the same `install.sh` with `STATUSLINE_SRC` set: +one installer, two entrypoints, no drift. It also refuses a +`statusline.sh` that does not parse — a broken statusline is not a worse +render, it is no statusline. + +Three things the installer should have been doing all along, now on both +paths: it **keeps the flags** already on `statusLine.command` (rewriting +the whole command silently reverted `--order` and `--debug` on every +update), it writes through a temp file and renames (the script runs on +every render; a half-written one is a broken prompt), and it installs the +`usage-insight` skill beside it (`STATUSLINE_SKILL=0` opts out). + +`make status` reports installed-vs-tree drift, settings command and skill +state; `make check` is shellcheck + bats; `make install-check` gates the +install on both. `make help` lists the rest. + +435 tests (was 427). + + ## v0.33.0 — 2026-08-23 — the strip is a record, not a forecast **The 5h strip stops at `▮`.** It used to draw the rest of the window as diff --git a/DESIGN.md b/DESIGN.md index 7368362..a454529 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -82,22 +82,28 @@ own signal. ## The ledgers (row 2) ``` -5h ▃▄▮ 0.6x @04:00 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▮▯▯...▯(x14) 0.7x @Wed 09:00 -``` - -One grammar, two scales. `5h` = the hours of this window that have -happened; `7d` = the period as 34 five-hour slots, oldest left, a gap at -each local midnight *in history only* — the run from `▮` on is -contiguous. Only the 7d strip draws a future at all: the 5h one stops at -`▮`, because when this window ends is the badge's job (`5h[38%@23:00]`) -and whether it caps is the notice's (`5h caps ~14:20`). Strips carry -history, badges carry state, notices do the warning. History draws in -full; the live row folds the 7d future after two kept cells into -`...▯(x14)` — the 5h windows left before the reset, all alike, `×` red -when the tail projects dry (the `week` report still draws every slot). The -count is the budget line's own, priced from the same instant, and it folds -as soon as folding hides two cells: the future is one fact, and the ink -belongs to history. Each strip ends +5h ▃▄▮▯▯ 0.6x @04:00 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▮▯▯...▯(x14) 0.7x @Wed 09:00 +``` + +One grammar, two scales. `5h` = this window as five hour cells; `7d` = +the period as 34 five-hour slots, oldest left, a gap at each local +midnight *in history only* — the run from `▮` on is contiguous. Both +strips draw their whole grid, so a strip is an axis and not a bar that +grows at you: it holds its width for the life of the window and `▮` walks +it. On the 5h strip that makes the hollow run the answer to *how long +have I got* — `▃▄▮▯▯` is two whole hours after this one. + +What neither strip draws on 5h is a *forecast*. An empty cell is a fact; +a `×` is a guess, and on a 5h window the guess is owned twice already — +the badge states the end (`5h[38%@23:00]`) and the notice names the wall +(`5h caps ~14:20`). Cells carry the shape, badges carry state, notices do +the warning. History draws in full; the live row folds the 7d future +after two kept cells into `...▯(x14)` — the 5h windows *after* the one +you are in, all alike, `×` red when the tail projects dry (the `week` +report still draws every slot). The count is the budget line's own, +priced from the same instant, and it folds as soon as folding hides two +cells: the future is one fact, and the ink belongs to history. Each strip +ends with its pace (used ÷ elapsed; dim <1x, pressure ≥1x, hidden until 5% of the window has run) and the reset its right edge is — axis labels, not restated badges. @@ -106,9 +112,9 @@ badges. ▁▂▃▄▅▆▇█ burned; height = points that cell cost (▁ ≤2 … ▅ ≤11 … █ >20) ˍ ran, negligible — a bar of height zero, on the baseline ░ unknown — no sample; never drawn as idle -▮ now — and the last cell of the 5h strip -▯ ahead — the hollow of ▮ (7d only) -...▯(x14) the folded future: 14 more 5h windows before the reset (live 7d row) +▮ now +▯ ahead — the hollow of ▮; on 5h, the hours left in the window +...▯(x14) the folded future: 14 more 5h windows after this one (live 7d row) × pace won't cover it (7d only: learned forecast, linear when cold) ``` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d58ecb5 --- /dev/null +++ b/Makefile @@ -0,0 +1,89 @@ +# claude-code-statusline — dev tasks. +# +# make install put THIS tree's statusline.sh (+ the usage-insight skill) +# into ~/.claude and point settings.json at it, keeping the +# flags already on the command +# make status what is installed vs what is in the tree +# make check shellcheck + the bats suite +# +# install shells out to install.sh with STATUSLINE_SRC set, so the local path +# and the public curl one-liner share one installer and cannot drift. + +SHELL := /bin/bash +.DEFAULT_GOAL := help + +CLAUDE_CONFIG_DIR ?= $(HOME)/.claude +DEST := $(CLAUDE_CONFIG_DIR)/statusline.sh +SKILL := $(CLAUDE_CONFIG_DIR)/skills/usage-insight/SKILL.md +SETTINGS := $(CLAUDE_CONFIG_DIR)/settings.json +BATS := npm exec --yes bats -- + +.PHONY: help install install-check uninstall status diff test lint check + +help: + @echo "claude-code-statusline" + @echo + @echo " make install install this tree into $(CLAUDE_CONFIG_DIR)" + @echo " make install-check run check first, then install" + @echo " make status installed vs tree, settings, skill" + @echo " make diff diff the installed copy against this tree" + @echo " make uninstall remove the script, the skill and the setting" + @echo " make test bats t/" + @echo " make lint shellcheck -S error" + @echo " make check lint + test" + @echo + @echo " CLAUDE_CONFIG_DIR=... to target another config dir" + @echo " STATUSLINE_SKILL=0 to skip the usage-insight skill" + +install: + @STATUSLINE_SRC="$(CURDIR)" CLAUDE_CONFIG_DIR="$(CLAUDE_CONFIG_DIR)" ./install.sh + +# The one you want when the tree has uncommitted work: a statusline that +# renders wrong is worse than one that is a version behind. +install-check: check install + +uninstall: + @rm -f "$(DEST)"; echo "removed $(DEST)" + @rm -rf "$(dir $(SKILL))"; echo "removed $(dir $(SKILL))" + @if [ -f "$(SETTINGS)" ] && jq -e '.statusLine' "$(SETTINGS)" >/dev/null 2>&1; then \ + tmp="$(SETTINGS).tmp.$$$$"; \ + jq 'del(.statusLine)' "$(SETTINGS)" > "$$tmp" && mv -f "$$tmp" "$(SETTINGS)"; \ + echo "removed statusLine from $(SETTINGS)"; \ + fi + +status: + @src=$$(sha256sum statusline.sh | cut -c1-12); \ + printf 'tree statusline.sh %s bytes %s (%s%s)\n' \ + "$$(wc -c < statusline.sh)" "$$src" \ + "$$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')" \ + "$$(git diff --quiet -- statusline.sh 2>/dev/null || echo ', dirty')"; \ + if [ -f "$(DEST)" ]; then \ + dst=$$(sha256sum "$(DEST)" | cut -c1-12); \ + printf 'installed %s %s bytes %s %s\n' "$(DEST)" \ + "$$(wc -c < "$(DEST)")" "$$dst" \ + "$$([ "$$src" = "$$dst" ] && echo 'in sync' || echo 'STALE — make install')"; \ + else \ + printf 'installed %s MISSING — make install\n' "$(DEST)"; \ + fi; \ + if [ -f "$(SETTINGS)" ]; then \ + printf 'settings %s\n' "$$(jq -r '.statusLine.command // "(no statusLine — make install)"' "$(SETTINGS)")"; \ + else \ + printf 'settings %s MISSING\n' "$(SETTINGS)"; \ + fi; \ + if [ -f "$(SKILL)" ]; then \ + printf 'skill %s %s\n' "$(SKILL)" \ + "$$(cmp -s skills/usage-insight/SKILL.md "$(SKILL)" && echo 'in sync' || echo 'STALE — make install')"; \ + else \ + printf 'skill %s not installed\n' "$(SKILL)"; \ + fi + +diff: + @diff -u "$(DEST)" statusline.sh && echo "installed copy matches the tree" + +test: + @$(BATS) t/ + +lint: + @shellcheck -S error statusline.sh install.sh + +check: lint test diff --git a/README.md b/README.md index 8a1d4c4..0556a54 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,28 @@ No daemon, no telemetry, no npm. curl -fsSL https://raw.githubusercontent.com/thevibeworks/claude-code-statusline/main/install.sh | bash ``` -Downloads `statusline.sh` to `~/.claude/` and wires up `settings.json`. +Downloads `statusline.sh` and the `usage-insight` skill to `~/.claude/` +and wires up `settings.json`. Re-run it any time to update: it replaces +the script but keeps the flags already on your `statusLine.command`. Restart Claude Code (or send a message) and the statusline appears. -**Requires:** Bash, `jq`, `curl`. +**Requires:** Bash, `jq`, `curl`. `STATUSLINE_SKILL=0` skips the skill; +`CLAUDE_CONFIG_DIR` targets another config dir. + +### Install from a checkout + +```bash +git clone https://github.com/thevibeworks/claude-code-statusline +cd claude-code-statusline +make install # this tree -> ~/.claude, same installer, no download +make status # installed vs tree, the settings command, the skill +``` + +`make install` runs `install.sh` with `STATUSLINE_SRC` set, so the local +path and the curl one-liner share one installer and cannot drift. It +refuses to install a `statusline.sh` that does not parse. `make +install-check` runs shellcheck and the test suite first; `make help` +lists the rest. ### Install via Claude Code @@ -91,7 +109,7 @@ Every component earns its place: | User tier | Neutral white-weight (MAX bold, PRO normal, dim otherwise) — identity, never a status color. Truncated display name. | | 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 is hybrid: day-relative `@5d` while the reset is >= 24h out — decays one day per day, mild and narrow — switching to the same wall-clock `@04:00` inside the last day, where an `@6h`/`@<1h` countdown decayed by the hour exactly when pressure keeps the suffix visible.) 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 when relief arrives: `7d[44%@5d]` red means "at your pace, dry days before the reset 5 days from now"; inside the last day it reads `7d[92%@04:00]` — resets at 04:00. 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. | | Extra usage | Monthly spend, limit, prepaid balance. `--extra auto` shows when quota runs out. | -| Week row | **A row of its own, under the badges**: `5h ▅█▃▮ 0.9✕ @23:00 7d ▅▁▂ ▃▅ˍ▃▅ …▮▯▯...▯(✕19) 0.7✕ @Wed 09:00` — this sitting by the hour, the week as its 5h windows (day-gapped, the far future folded to a counted `...▯(✕19)`), height = what each cell burned, `▮` now and, on the 5h strip, the end of it — that strip is history, the badge above already says when the window closes. `×` marks where the pool runs dry on the week; each strip ends with its pace and reset. Reconstructed from your own usage log; `auto` shows it once there is history to show. See [Week row](#week-row). | +| Week row | **A row of its own, under the badges**: `5h ▅█▃▮▯ 0.9✕ @23:00 7d ▅▁▂ ▃▅ˍ▃▅ …▮▯▯...▯(✕19) 0.7✕ @Wed 09:00` — this sitting hour by hour on a fixed five-slot axis (the hollow run is the hours left), the week as its 5h windows (day-gapped, the far future folded to a counted `...▯(✕19)`), height = what each cell burned, `▮` = now. `×` marks where the pool runs dry on the week; each strip ends with its pace and reset. Reconstructed from your own usage log; `auto` shows it once there is history to show. See [Week row](#week-row). | | Deadman | **Invisible until a switch is armed.** Surfaces [deadman](https://github.com/thevibeworks/deadman) — a dead man's switch that hands the session off when you stop responding. `[☠ armed 42m]` (dim) counts down to the auto-handoff; `[☠ warned 3m]` (yellow) means the phone warning went out; `[☠ due]` means the handoff fires imminently. Sits on the left lane next to the path — it describes this session's lifecycle, not a quota. One `command -v` when the tool is absent, one fast file read when present; nothing armed renders nothing. `--deadman off` disables it. | | Cache health | **Quiet until it bites.** Claude Code never re-renders an idle session, and while you work the prompt cache is always freshly ~1 TTL from expiry — so a proactive "expiring soon" isn't honestly observable, and `auto` spends no width on it. It speaks only when a rewrite actually happens: `≡!419k` the instant you resume onto a dead cache (idle longer than the TTL) or a mid-session prefix collapse — a 419k-token re-cache at ~20x the read rate (and the same burn on your 5h/7d quota on subscriptions). **Bold red past 200k** — the premium-band miss. `≡~` while a large prefix rebuilds. `--cache always` additionally keeps the freeze-safe deadline `≡@15:20` (last request + TTL; a past time in a frozen frame reads "expired at 15:20"). TTL defaults to 1h (claude.ai subscriber sessions) or 5m (API-key auth); an observed usage breakdown overrides it. The `≡` glyph (U+2261) reads as stacked cache layers — one terminal column, quiet and distinct. | @@ -255,7 +273,7 @@ right edge shared with line 1: ```text proj (main*) +84/-14 8m $6.72 fabl5[1m][██░░42%] fb[66%] [MAX|@work] 5h[38%@23:00] 7d[39%] -3✕5h left · 20%/win 5h ▅█▃▮ 0.9✕ 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▅ˍ▂▁▁ ˍˍˍ▃▅ ˍˍˍ▅ ▆▆ˍ▂▮▯▯ 0.7✕ @Wed 09:00 +3✕5h left · 20%/win 5h ▅█▃▮▯ 0.9✕ 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▅ˍ▂▁▁ ˍˍˍ▃▅ ˍˍˍ▅ ▆▆ˍ▂▮▯▯ 0.7✕ @Wed 09:00 ``` The 5h strip prints no reset: line 1's `5h[38%@23:00]` already carries @@ -272,7 +290,7 @@ edge and the full sentence sits flush-left beneath them: ```text proj (main*) fabl5[1m][██░░42%] fb[66%] [MAX|@work] 5h[38%@23:00] 7d[39%] - 5h ▅█▃▮ 0.9✕ @23:00 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▅ˍ▂▁▁ ˍˍˍ▃▅ ˍˍˍ▅ ▆▆ˍ▂▮▯▯ 0.7✕ @Wed 09:00 + 5h ▅█▃▮▯ 0.9✕ @23:00 7d ▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ▅ˍ▂▁▁ ˍˍˍ▃▅ ˍˍˍ▅ ▆▆ˍ▂▮▯▯ 0.7✕ @Wed 09:00 budget ~3✕5h left · even 20%/win · heading ~52% ``` @@ -285,22 +303,26 @@ budget line shows with it (windows left, what even looks like, where you land); pressure and surplus clauses still take its place when they fire. -- **`5h`** — this sitting: the hours of the current 5h window that have - happened, height = the 5h points that hour added (each positive step - between consecutive samples credited to the hour the later sample fell - in). It ends at `▮` and draws no future: when the window closes is what - the badge says (`5h[38%@23:00]`), and whether you will hit the wall is - what the notice says (`5h caps ~14:20`) — three hollow cells and a row - of `×` only said it a third time, louder. +- **`5h`** — this sitting: the current 5h window as five hour cells, + always all five, height = the 5h points that hour added (each positive + step between consecutive samples credited to the hour the later sample + fell in). Fixed width makes it an axis rather than a bar that grows at + you, and it makes the hollow run after `▮` the answer to *how long have + I got*: `▃▃▮▯▯` is two whole hours left after this one, no arithmetic + and no second glance at the clock. What it does not draw is a forecast — + no `×` wall. An empty cell is a fact (that hour has not happened); a dry + one is a guess, and the guess is already owned twice, by the badge + (`5h[38%@23:00]`) and by the notice (`5h caps ~14:20`, with its own + gates and an exact time). - **`7d`** — the week: the 7d period as its 5h windows (34 slots, oldest left, the last a 3h stub), height = the 7d points that window burned, with a thin gap at each local midnight so days read as clusters — and a day that held five windows shows it — without a ruler. History draws in full; the future folds as soon as folding hides two cells: two hollow - cells after `▮`, then `...▯(✕28)` — 28 more 5h windows before the reset, - all alike (`×` red when the tail projects dry). That count is the budget - line's own `~28✕5h left`, priced from the same instant: one row, one - arithmetic. The future is one fact and the strip's ink belongs to + cells after `▮`, then `...▯(✕28)` — 28 more 5h windows **after the one + you are in**, all alike (`×` red when the tail projects dry). That count + is the budget line's own `~28✕5h left`, priced from the same instant: + one row, one arithmetic. The future is one fact and the strip's ink belongs to history; a closing week needs no help to draw to its edge, since four cells or fewer leave nothing worth folding. The `week` subcommand's wide ledger still draws every slot. @@ -311,9 +333,9 @@ fire. | `ˍ` | ran, cost under a point — or ran idle inside the log's coverage; a bar of height zero, on the baseline | | `░` | 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. **7d only**: the 5h strip stops at `▮` | +| `▯` | a cell still ahead of you — the hollow of `▮`, an empty slot waiting. On the 5h strip the run of them is the hours left in the window | | `×` | a cell the pool will not cover at the current pace — **7d only**: the learned forecast's dry point, linear when untrained. The 5h window's wall belongs to the `5h caps ~14:20` notice, which states the time instead of shading cells | -| `...▯(✕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 | +| `...▯(✕28)` | the folded 7d future: 28 more 5h windows after the one you are in, one token instead of a run of hollow cells (`×` red when the tail ends dry). The count is 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) | Burn cells take their badge's pressure color; everything else is neutral, @@ -341,7 +363,7 @@ long one: ```text proj (main*) +84/-14 8m $6.72 fabl5[1m][██░░42%] fb[91%] [MAX|@work] 5h[38%@23:00] 7d[55%] -+ fb 91% vs 7d 55% · go op 5h ▅█▃▮ 0.9✕ 7d ▅▁▂ ▃▅ˍ▃▅ … ▆▆ˍ▂▮▯▯ 0.6✕ @Wed 09:00 ++ fb 91% vs 7d 55% · go op 5h ▅█▃▮▯ 0.9✕ 7d ▅▁▂ ▃▅ˍ▃▅ … ▆▆ˍ▂▮▯▯ 0.6✕ @Wed 09:00 + fb weekly 91% against 7d 55% · the model caps first, not the account; op sits at 33%, so run it for the bulk ``` @@ -376,7 +398,7 @@ clauses about one window can never disagree. | `+ 5h ~40m left · 70% unused` | Said only when the *week* is stranding capacity — an unspent 5h window is otherwise headroom, not waste, since the 5h window is a rate limit and not a budget. | | `+ ~62% will expire · go heavier` | On pace to strand a large chunk of the subscription. Speaks only to an engaged, unsqueezed session. | | `+ work 5h[8%] free` | A sibling account in the same shared home is idle while this one is pinned. | -| `19✕5h left · 1.1%/win` | The calm budget: runway, what even looks like, where you land (long form). It wears no sigil — `!` and `+` interrupt, the week's resting reading does not. | +| `19✕5h left · 1.1%/win` | The calm budget: runway, what even looks like, where you land (long form). 19 counts the windows **after** the one you are in — the window you are inside is where you are, not what you have left, and it is already drawn as `▮` and priced by `5h[38%]`. The count holds steady inside a window and steps down by one at each rollover. It wears no sigil — `!` and `+` interrupt, the week's resting reading does not. | `--notice off` keeps row 3 quiet; `--advisor off` silences both. `--check` and `--week` print the long form, since a terminal command has a whole @@ -520,8 +542,9 @@ run. Setting only `CLAUDE_CACHE_DIR` keeps the legacy single-dir behavior. npm exec --yes bats -- t/ ``` -427 tests across `t/statusline.bats` (415 statusline + integration) and -`t/install.bats` (12 installer). CI runs on push and PR to `main`. +435 tests across `t/statusline.bats` (417 statusline + integration) and +`t/install.bats` (18 installer) — `make check` runs shellcheck first. CI +runs on push and PR to `main`. ## Project Structure @@ -529,7 +552,8 @@ npm exec --yes bats -- t/ statusline.sh Main script (one file, ~4400 lines) DESIGN.md The language: rows, color lanes, glyphs, time, requests llms.txt Agent-facing map of the repo -install.sh One-line installer +Makefile make install / status / check +install.sh Installer: curl one-liner or STATUSLINE_SRC= t/statusline.bats Unit and integration tests t/install.bats Installer tests (mock curl, isolated $HOME) t/helpers.bash Sources real functions from statusline.sh diff --git a/docs/index.html b/docs/index.html index 379f874..0791608 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

-
427 tests · jq + curl · no daemon · no telemetry
+
435 tests · jq + curl · no daemon · no telemetry
@@ -247,12 +247,12 @@

Read the meter

The 7-day window, forecast-colored. A learned per-weekday burn profile projects whether the pool outlasts the window — your heavy Tuesday counts more than a generic average. Red answers: at your pace, dry before the reset.
-
5h ▂▅█ 0.8✕№ 0001
-
The 5h ledger — the hours of this sitting that have happened. Height is what each hour cost; now and the end of the strip, ˍ ran-but-negligible, no sample. It draws no future: when the window closes is the badge's job, whether it caps is the notice's. Ends with its pace: above 1✕ you cap before the reset.
+
5h ▂▅█ 0.8✕№ 0001
+
The 5h ledger — this sitting, five hour cells, always all five. Height is what each hour cost; now, an hour still to come, ˍ ran-but-negligible, no sample. The hollow run is how long you have got; a fixed axis also means the row never reflows as the window drains. What it will not draw is a forecast — no × wall: when the window closes is the badge's job, whether it caps is the notice's. Ends with its pace: above 1✕ you cap before the reset.
7d ▃▅ˍ▃▅ ▃▃▁▂▁▯▯...▯(✕14) 0.7✕№ 0001
-
The week ledger — history in full, the future folded. Each cell one 5h window, day-gapped; after the now-marker the empty run folds to a counted tail: ...▯(✕14) answers "14 windows before the reset" — the same 14 the budget line prices — and × red answers "the tail runs dry"; is the operator, × is a reading.
+
The week ledger — history in full, the future folded. Each cell one 5h window, day-gapped; after the now-marker the empty run folds to a counted tail: ...▯(✕14) answers "14 windows after this one" — the window you are in is , not something you have left, and 14 is the same number the budget line prices — and × red answers "the tail runs dry"; is the operator, × is a reading.
! 7d dry ~Tue 04:00 · hard stop№ 0008
@@ -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. 427 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. 435 bats tests on the one file.

@@ -327,7 +327,7 @@

What it is not

// Rows beneath line 1 hang as one block: its right edge meets line 1's // edge, every row shares one left edge — exactly how the script pads them. // 5h ledger: 5 hour cells. 7d ledger: history in full, future folded. - var L5 = s('d','5h ') + s('g','▂▅█') + s('w','▮') + s('d',' 0.8✕'); + var L5 = s('d','5h ') + s('g','▂▅█') + s('w','▮') + s('d2','▯') + s('d',' 0.8✕'); var L5b = s('d','5h ') + s('g','▂▅█▄') + s('w','▮') + s('d',' 0.9✕'); var L7 = s('d','7d ') + s('y','▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ') + s('w','▮') + s('d2','▯▯...▯(✕14)') + s('d',' 0.7✕ @Wed 09:00'); var L7x = s('d','7d ') + s('y','▅▁▂ ▃▅ˍ▃▅ ▃▃▁▂▁ ') + s('w','▮') + s('d2','▯▯') + s('r','...×(✕14)') + s('d',' 0.9✕ @Wed 09:00'); diff --git a/install.sh b/install.sh index 795cbec..25f7222 100755 --- a/install.sh +++ b/install.sh @@ -1,11 +1,26 @@ #!/bin/bash +# Install claude-code-statusline into ~/.claude. +# +# Two entry points, one installer: +# curl -fsSL .../install.sh | bash # end users: fetch from GitHub +# make install # this checkout: STATUSLINE_SRC=$PWD +# +# Env: +# STATUSLINE_SRC install from this directory instead of downloading +# CLAUDE_CONFIG_DIR target config dir (default ~/.claude) +# STATUSLINE_SKILL 0 to skip the usage-insight skill +# BRANCH branch to download from (default main) set -eu REPO="thevibeworks/claude-code-statusline" -BRANCH="main" +BRANCH="${BRANCH:-main}" RAW="https://raw.githubusercontent.com/${REPO}/${BRANCH}" -DEST="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/statusline.sh" -SETTINGS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" +SRC="${STATUSLINE_SRC:-}" +CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +DEST="$CONFIG_DIR/statusline.sh" +SETTINGS="$CONFIG_DIR/settings.json" +SKILL_DIR="$CONFIG_DIR/skills/usage-insight" +WITH_SKILL="${STATUSLINE_SKILL:-1}" RED='\033[31m'; GREEN='\033[32m'; DIM='\033[2m'; RESET='\033[0m' info() { printf "${GREEN}>>>${RESET} %s\n" "$*"; } @@ -13,27 +28,68 @@ warn() { printf "${RED}>>>${RESET} %s\n" "$*" >&2; } die() { warn "$@"; exit 1; } command -v jq >/dev/null 2>&1 || die "jq is required: brew install jq / apt install jq" -command -v curl >/dev/null 2>&1 || die "curl is required" +[ -n "$SRC" ] || command -v curl >/dev/null 2>&1 || die "curl is required" -info "Downloading statusline.sh" -curl -fsSL "${RAW}/statusline.sh" -o "$DEST" +# One file lands at a time and it lands whole: the statusline runs on every +# render, and a half-written script is a broken prompt for whoever is mid- +# session. Write beside the target, then rename — atomic inside one dir. +fetch() { # fetch + local tmp="$2.tmp.$$" + if [ -n "$SRC" ]; then + [ -f "$SRC/$1" ] || die "not in $SRC: $1" + cp -f "$SRC/$1" "$tmp" + else + curl -fsSL "${RAW}/$1" -o "$tmp" + fi + mv -f "$tmp" "$2" +} + +mkdir -p "$CONFIG_DIR" + +# A local install ships whatever is in the tree, including a half-finished +# edit. `bash -n` costs nothing and is the difference between a bad render +# and no statusline at all. +if [ -n "$SRC" ]; then + bash -n "$SRC/statusline.sh" || die "$SRC/statusline.sh does not parse — not installing" + info "Installing from $SRC" +else + info "Downloading statusline.sh" +fi +fetch "statusline.sh" "$DEST" chmod +x "$DEST" -info "Installed to $DEST" +info "Installed $DEST" + +if [ "$WITH_SKILL" = 1 ]; then + mkdir -p "$SKILL_DIR" + fetch "skills/usage-insight/SKILL.md" "$SKILL_DIR/SKILL.md" + info "Installed skill $SKILL_DIR/SKILL.md" +fi # claude-watch.sh retired in v0.19.0 (superseded by the advisor line and # claudex's claude.py --watch-usage). Clean up a copy left by old installs. -OLD_WATCH="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/claude-watch.sh" +OLD_WATCH="$CONFIG_DIR/claude-watch.sh" if [ -f "$OLD_WATCH" ]; then rm -f "$OLD_WATCH" info "Removed retired $OLD_WATCH" fi _tilde='~' -STATUSLINE_CMD="bash ${DEST/#$HOME/$_tilde}" +DEST_DISPLAY="${DEST/#$HOME/$_tilde}" + +# Keep the flags. A configured statusline usually carries them — `--order`, +# `--debug`, a width — and an installer that rewrites the whole command +# silently reverts the user's setup every time they update. +ARGS="" +if [ -f "$SETTINGS" ]; then + PREV=$(jq -r '.statusLine.command // ""' "$SETTINGS" 2>/dev/null || echo "") + case "$PREV" in + *statusline.sh*) ARGS="${PREV#*statusline.sh}" ;; + esac +fi +STATUSLINE_CMD="bash ${DEST_DISPLAY}${ARGS}" if [ ! -f "$SETTINGS" ]; then info "Creating $SETTINGS" - mkdir -p "$(dirname "$SETTINGS")" cat > "$SETTINGS" << SETTINGSEOF { "statusLine": { @@ -45,17 +101,18 @@ if [ ! -f "$SETTINGS" ]; then SETTINGSEOF else if jq -e '.statusLine' "$SETTINGS" >/dev/null 2>&1; then - info "statusLine already configured in $SETTINGS — updating command" - tmp="${SETTINGS}.tmp.$$" - jq --arg cmd "$STATUSLINE_CMD" '.statusLine.type = "command" | .statusLine.command = $cmd | .statusLine.padding = (.statusLine.padding // 0)' "$SETTINGS" > "$tmp" - mv -f "$tmp" "$SETTINGS" + info "statusLine already configured — updating command" else info "Adding statusLine to $SETTINGS" - tmp="${SETTINGS}.tmp.$$" - jq --arg cmd "$STATUSLINE_CMD" '. + {"statusLine": {"type": "command", "command": $cmd, "padding": 0}}' "$SETTINGS" > "$tmp" - mv -f "$tmp" "$SETTINGS" fi + tmp="${SETTINGS}.tmp.$$" + jq --arg cmd "$STATUSLINE_CMD" \ + '.statusLine = ((.statusLine // {}) + | .type = "command" | .command = $cmd | .padding = (.padding // 0))' \ + "$SETTINGS" > "$tmp" || die "could not update $SETTINGS (invalid JSON?)" + mv -f "$tmp" "$SETTINGS" fi +info " statusLine.command = $STATUSLINE_CMD" info "Done. Restart Claude Code or send a message to see the statusline." printf "${DIM} Docs: https://github.com/${REPO}${RESET}\n" diff --git a/llms.txt b/llms.txt index a940da5..617e87b 100644 --- a/llms.txt +++ b/llms.txt @@ -3,17 +3,21 @@ > One bash file that renders live quota (5h/7d windows with reset times, > per-model weekly limits), context pressure, session cost, prompt-cache > health, and git activity in Claude Code's statusLine hook — plus a -> week row (the hours of this 5h window that have happened, the 7d -> period as its 5h windows with the future folded to a counted -> `...▯(✕28)` tail: where the points went), an advisor row that interprets the +> week row (this 5h window as five hour cells, the 7d period as its 5h +> windows with the future folded to a counted `...▯(✕28)` tail: where 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. 427 bats tests, deps are jq + curl, no daemon, no +> expired unused. 435 bats tests, deps are jq + curl, no daemon, no > telemetry. Key facts for agents: -- Everything lives in `statusline.sh` (~5200 lines, plain bash). Install - copies it to `~/.claude/statusline.sh` and wires `settings.json`. +- Everything lives in `statusline.sh` (~5300 lines, plain bash). Install + copies it to `~/.claude/statusline.sh`, copies the usage-insight skill to + `~/.claude/skills/`, and wires `settings.json` (keeping any flags already + on the command). `install.sh` is the one installer for both entrypoints: + `curl | bash`, or `make install` from a checkout (`STATUSLINE_SRC=$PWD`). + `make status` reports installed-vs-tree drift. - State lives under `~/.claude/statusline/` (account-scoped caches) and `~/.claude/statusline/sessions/` (per-session state). Never commit these. - Quota data: Claude Code hands 5h/7d (`rate_limits`) on stdin every @@ -39,11 +43,12 @@ Key facts for agents: ## Source - [statusline.sh](https://github.com/thevibeworks/claude-code-statusline/blob/main/statusline.sh): the entire product -- [install.sh](https://github.com/thevibeworks/claude-code-statusline/blob/main/install.sh): installer (curl | bash entrypoint) -- [t/statusline.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/statusline.bats): 406 statusline tests -- [t/install.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/install.bats): 12 installer tests +- [install.sh](https://github.com/thevibeworks/claude-code-statusline/blob/main/install.sh): installer (curl | bash, or STATUSLINE_SRC=) +- [Makefile](https://github.com/thevibeworks/claude-code-statusline/blob/main/Makefile): install / status / diff / uninstall / check +- [t/statusline.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/statusline.bats): 417 statusline tests +- [t/install.bats](https://github.com/thevibeworks/claude-code-statusline/blob/main/t/install.bats): 18 installer tests ## Optional - [Site](https://thevibeworks.github.io/claude-code-statusline/): animated demo of the line -- [usage-insight skill](https://github.com/thevibeworks/claude-code-statusline/blob/main/skills/usage-insight/SKILL.md): teaches an agent to read the state dir and answer usage questions (install: `cp -r skills/usage-insight ~/.claude/skills/`) +- [usage-insight skill](https://github.com/thevibeworks/claude-code-statusline/blob/main/skills/usage-insight/SKILL.md): teaches an agent to read the state dir and answer usage questions (installed by `install.sh` / `make install`; `STATUSLINE_SKILL=0` opts out) diff --git a/statusline.sh b/statusline.sh index 977fa5b..79390d5 100755 --- a/statusline.sh +++ b/statusline.sh @@ -2653,11 +2653,12 @@ run_session_summary() { # ▮ the window you are in now # ▯ a window still ahead of you # × a window the pool will not cover at the current pace -# The report draws every slot, so ▮ and what follows IS the budget line's -# "~N✕5h left" laid out cell by cell; the live row folds that same tail and -# prints the count instead. Past cells come from usage.jsonl; unknown and -# idle stay different glyphs because drawing a gap in the record as an idle -# session is the one lie this row must not tell. The prospective glance beside report's +# The report draws every slot, so what follows ▮ IS the budget line's +# "~N✕5h left" laid out cell by cell — ▮ itself is where you are, not a +# window you have left. The live row folds that same tail and prints the +# count instead. Past cells come from usage.jsonl; unknown and idle stay +# different glyphs because drawing a gap in the record as an idle session +# is the one lie this row must not tell. The prospective glance beside report's # retrospective ledger; the same strip claude.py renders, so both # surfaces tell one story. Reads usage.cache; stale data renders but says so. # --- week row (the two windows as ledgers) ------------------------------------ @@ -2670,25 +2671,29 @@ run_session_summary() { # ˍ ran, burned under a point, or idle inside the log's coverage — a # bar of height zero, on the baseline # ░ unknown — outside the sample log's coverage -# ▮ the cell you are in now — and the END of the 5h strip: that -# strip is history, it has no cells after this one -# ▯ a cell still ahead of you (the hollow of ▮: an empty slot) — 7d -# × a cell the pool will not cover at the current pace — 7d -# ...▯(✕12) the folded future: 12 more 5h windows before the reset, all -# alike (× red when the tail projects dry) — live 7d strip only. -# The count is windows-to-reset, the same number the budget line -# prices as "~12✕5h left" — one line, one arithmetic. (It is NOT -# the hidden-cell count: the 34-cell grid spans 170h and the -# period is 168h, and a row that says 10 beside a budget that -# says 12 makes the reader arbitrate between its own halves.) +# ▮ the cell you are in now +# ▯ a cell still ahead of you (the hollow of ▮: an empty slot) +# × a cell the pool will not cover at the current pace — 7d only +# ...▯(✕11) the folded future: 11 more 5h windows AFTER the one you are in, +# all alike (× red when the tail projects dry) — live 7d strip +# only. The count is windows ahead, the same number the budget +# line prices as "~11✕5h left" — one line, one arithmetic. (It is +# NOT the hidden-cell count: the 34-cell grid spans 170h against a +# 168h period, and a row that says 10 beside a budget that says 11 +# makes the reader arbitrate between its own halves.) # ✕ is the operator, × is a cell: the two never mean the same. # Unknown and idle are deliberately different glyphs: drawing a gap in the # record as an idle session is the one lie this row must not tell. -# Only the 7d strip draws a future. The 5h one stops at ▮: a window whose end -# the badge already states (`5h[38%@23:00]`) and whose wall a notice already -# names (`5h caps ~14:20`) does not need three hollow cells and a row of × -# to say it a third time, louder. Strips carry history, badges carry state, -# notices do the warning. +# Both strips draw their whole grid, so a strip is an axis, not a growing bar: +# it holds its width for the life of the window and ▮ walks it. On the 5h +# strip that makes the hollow run the answer to "how long have I got" — five +# slots, one per hour, `▃▃▮▯▯` is two whole hours left after this one, no +# arithmetic and no second glance at the clock. +# What neither strip draws on 5h is a FORECAST. An empty cell is a fact (that +# hour has not happened); a × is a guess, and on a 5h window the guess is +# already owned twice — the badge states the end (`5h[38%@23:00]`) and a +# notice names the wall with its own gates and an exact time (`5h caps +# ~14:20`). Cells carry the shape, badges carry state, notices do the warning. # Shared by the live `--week` row and the `week` subcommand, so the two # surfaces cannot disagree. @@ -2697,8 +2702,9 @@ FIVE_CELLS=5 FIVE_CELL_SECS=3600 WEEK_CACHE_TTL_SECS=300 # The live row compresses the 7d strip's future run: after the now-marker it -# keeps WEEK_FUTURE_KEEP hollow cells, then folds the rest into `...▯(✕12)` -# (count = 5h windows before the reset; × red when the tail projects dry). +# keeps WEEK_FUTURE_KEEP hollow cells, then folds the rest into `...▯(✕11)` +# (count = windows_ahead, the 5h windows after this one; × red when the tail +# projects dry). # History is information; the future is one fact, and the fact is the count. # The threshold is 2, not a column break-even: eleven hollow cells were # measured to read as "too much future" long before they were expensive, and @@ -2718,6 +2724,24 @@ week_period_start() { echo $(( ( (now + seven_secs - SEVEN_DAY_WINDOW_SECS + 150) / 300 ) * 300 )) } +# How many 5h windows are still AHEAD of the one you are in. The current +# window is where you are, not what you have left: the row already draws it as +# ▮ and the badge already prices it, so counting it again makes `▮ + 11` read +# as twelve. What remains once it closes is (7d left - 5h left), and a partial +# window at the end of the week is still a window you can spend, so that +# divides up. Two properties fall out and both matter: the number is stable +# inside a window (both clocks tick down together, the difference does not +# move) and it steps down by exactly one at each 5h rollover — a countdown you +# can trust rather than a reading that drifts mid-window. With no live 5h +# window in the payload there is nothing to exclude and the whole 7d +# remainder is ahead. +windows_ahead() { + local seven_secs="${1:-0}" five_secs="${2:-0}" + local rest=$(( seven_secs - five_secs )) + [ "$rest" -gt 0 ] 2>/dev/null || { echo 0; return 0; } + echo $(( (rest + 17999) / 18000 )) +} + # One pass over usage.jsonl(.1) for both strips, cached in week.cache keyed # by the two period starts and the log's mtime:size — the scan is a whole-log # jq pass, far too heavy for a per-render path, and the cells only move when @@ -2837,16 +2861,18 @@ week_dry_slot() { # The colored strip. Args: fill percent (for the pressure tint), now, # period start, dry cell (-1 none), history line ("lo hi slot:cost,..."), -# cell count, cell seconds, day-gaps flag, how much future to draw (-1 none: -# the strip ends at ▮; 0 all; N keep N hollow cells then fold), and the TRUE -# period length in seconds — the fold token -# counts windows to the real reset, not cells on the grid, so it needs the -# period, not the drawing. Defaults to the grid when a caller has nothing -# truer to offer. Cells and gaps come out of one awk so the row is one string -# with a color run per role. +# cell count, cell seconds, day-gaps flag, how much future to draw (0 all; +# N keep N hollow cells then fold), the number the fold token prints — +# windows AHEAD of the current one, counted from real clocks by the caller; +# the strip must not re-derive it, since a 34-cell grid spans 170h against a +# 168h period and a count read off the drawing disagrees with the budget +# sentence beside it — and finally an optional now-cell (-1: derive it from +# the period start, which is what a grid-aligned strip wants). +# Cells and gaps come out of one awk so the row is one string with a color +# run per role. build_ledger_strip() { local pct="$1" now="$2" ps="$3" dry="$4" hist="$5" cells_n="$6" cell_secs="$7" gaps="${8:-0}" fut="${9:-0}" - local plen="${10:-$(( cells_n * cell_secs ))}" + local nleft="${10:-0}" nowslot_in="${11:--1}" local span_lo="" span_hi="" cells="" [ -n "$hist" ] && read -r span_lo span_hi cells <<<"$hist" local fill_color tzoff_s @@ -2854,7 +2880,8 @@ build_ledger_strip() { tzoff_s=$(date +%z | awk '{ s=substr($0,1,1)=="-"?-1:1; h=substr($0,2,2)+0; m=substr($0,4,2)+0; print s*(h*3600+m*60) }') awk -v w="$cells_n" -v cs="$cell_secs" -v ps="$ps" -v now="$now" -v dry="$dry" \ -v gaps="$gaps" -v tz="$tzoff_s" -v fut="$fut" \ - -v minhide="$WEEK_FUTURE_MIN_HIDE" -v plen="$plen" -v mult="$MULT_GLYPH" \ + -v minhide="$WEEK_FUTURE_MIN_HIDE" -v nleft="$nleft" -v mult="$MULT_GLYPH" \ + -v nsin="$nowslot_in" \ -v lo="${span_lo:--1}" -v hi="${span_hi:--1}" -v cells="$cells" \ -v C_FILL="$fill_color" -v C_DIM="$DIM" -v C_NOW="$BOLD" \ -v C_DRY="$RED" -v C_OFF="$RESET" ' @@ -2867,14 +2894,12 @@ build_ledger_strip() { BEGIN { n = split(cells, a, ",") for (i = 1; i <= n; i++) { split(a[i], kv, ":"); cost[kv[1]] = kv[2] } - nowslot = int((now - ps) / cs) + nowslot = (nsin >= 0 ? nsin : int((now - ps) / cs)) # fold the future tail: everything past the kept cells is the # same hollow slot, so name it once with a count instead of # drawing it N times lim = w - if (fut < 0) # history only: stop at the now-cell - lim = nowslot + 1 - else if (fut > 0 && w - (nowslot + 1 + fut) >= minhide) + if (fut > 0 && nleft > 0 && w - (nowslot + 1 + fut) >= minhide) lim = nowslot + 1 + fut s = ""; prev = ""; pday = -1 for (i = 0; i < lim; i++) { @@ -2900,16 +2925,15 @@ build_ledger_strip() { if (c != prev) { s = s C_OFF c; prev = c } s = s g } - if (fut >= 0 && lim < w) { + if (lim < w) { # the fold: glyph = how the tail ends (× red when the pool - # dries before the reset), count = 5h windows still to come - # before the reset — the same number the budget line prices, - # not the number of cells this fold happens to hide. + # dries before the reset), count = the 5h windows ahead of the + # one you are in — the same number the budget line prices, not + # the number of cells this fold happens to hide. # No apostrophes in here: this comment lives inside a # single-quoted awk program. if (dry >= 0 && dry < w) { tg = "×"; tc = C_DRY } else { tg = "▯"; tc = C_DIM } - nleft = int((ps + plen - now + cs - 1) / cs) s = s C_OFF tc "..." tg "(" mult nleft ")" } print s C_OFF @@ -2918,23 +2942,34 @@ build_ledger_strip() { # 7d strip: 34 ✕ 5h cells from the period start, day-gapped. $5 is # week_history_cells' line; $6 folds the future tail after that many kept -# cells (the live row passes WEEK_FUTURE_KEEP; the `week` report draws all). -# The grid overshoots the period by design (34 ✕ 5h = 170h against a 168h -# week), so the fold is told the period itself — its count ends at the reset, -# where the week ends, not at the last cell it drew. +# cells (the live row passes WEEK_FUTURE_KEEP; the `week` report draws all and +# never folds); $7 is windows_ahead, the number the fold token prints. build_week_strip() { - build_ledger_strip "$1" "$2" "$3" "$4" "$5" "$WEEK_CELLS" 18000 1 "${6:-0}" "$SEVEN_DAY_WINDOW_SECS" + build_ledger_strip "$1" "$2" "$3" "$4" "$5" "$WEEK_CELLS" 18000 1 "${6:-0}" "${7:-0}" } -# 5h strip: the hours of the current window that have HAPPENED, ending at ▮. -# $4 is five_history_cells' line. No hollow cells, no dry projection, no -# future of any kind: a strip carries history, the badge carries state -# (`5h[38%@23:00]` is the clock this window ends on) and a notice does the -# warning (`5h caps ~14:20`, with its own gates and its own exact time). -# Three empty cells and a wall of × said none of that — they spent four -# columns dramatising a countdown the badge above already prints. +# 5h strip: the five hours of the current window, always all five. $4 is +# five_history_cells' line, $5 the seconds still on the window. +# +# The hollow run after ▮ is the hours left — the reason the grid is fixed — +# but there is no dry cell in it: a × here would be a forecast, and the badge +# (`5h[38%@23:00]`) plus the "5h caps ~14:20" notice already own that warning +# with better gates and an exact time. Fixed width also means the row does not +# reflow every hour, which is the difference between an axis and a bar that +# grows at you. +# +# ▮ rides the real clock, not the grid. five_period_start rounds to 5 minutes +# so week_scan's cache key holds still across renders (a resets_at that jitters +# by a second would re-run a whole-log jq pass every render), and that rounding +# offsets every hour boundary by up to 2½ minutes. Invisible in a bar height; +# wrong exactly where this strip is read. With the marker at 4 - floor(left/1h) +# the hollow count IS the whole hours remaining, to the second: three hours and +# one minute left never draws as two. build_five_strip() { - build_ledger_strip "$1" "$2" "$3" -1 "$4" "$FIVE_CELLS" "$FIVE_CELL_SECS" 0 -1 + local nowslot=$(( 4 - ${5:-0} / 3600 )) + [ "$nowslot" -lt 0 ] && nowslot=0 + [ "$nowslot" -gt $((FIVE_CELLS - 1)) ] && nowslot=$((FIVE_CELLS - 1)) + build_ledger_strip "$1" "$2" "$3" -1 "$4" "$FIVE_CELLS" "$FIVE_CELL_SECS" 0 0 0 "$nowslot" } # Does a history line carry at least one cell BEFORE the now-cell? A single @@ -3010,7 +3045,7 @@ strip_tail() { printf '%s %s@%s%s' "$out" "$DIM" "$when" "$RESET" } -# The live row: `5h ▂▅█▃▮▯▯▯▯▯ 0.6✕ @04:00 7d ▅▁▂▃▅ ˍ▃▅▃▃ …▮▯▯ 0.7✕ @Wed 09:00` under the badges — this +# The live row: `5h ▂▅█▮▯ 0.6✕ @04:00 7d ▅▁▂▃▅ ˍ▃▅▃▃ …▮▯▯...▯(✕11) 0.7✕ @Wed 09:00` under the badges — this # sitting at the left, the week at the right, one grammar. Prints nothing # when there is no live window, or — in auto mode — when the log holds no # sample for either period yet (a row of ░░░▮▯▯ says nothing the badges do @@ -3054,13 +3089,13 @@ build_week_row() { fi local parts="" if [ "$have_five" = 1 ]; then - parts="${DIM}5h ${RESET}$(build_five_strip "$five_int" "$now" "$five_start" "$five_hist")$(strip_tail "$five_int" "$five_secs" 18000 "$now" "$five_reset_mode")" + parts="${DIM}5h ${RESET}$(build_five_strip "$five_int" "$now" "$five_start" "$five_hist" "$five_secs")$(strip_tail "$five_int" "$five_secs" 18000 "$now" "$five_reset_mode")" fi if [ "$have_seven" = 1 ]; then local dry dry=$(week_dry_slot "$seven_int" "$seven_secs" "$now" "$period_start") [ -n "$parts" ] && parts="$parts " - parts="${parts}${DIM}7d ${RESET}$(build_week_strip "$seven_int" "$now" "$period_start" "$dry" "$week_hist" "$WEEK_FUTURE_KEEP")$(strip_tail "$seven_int" "$seven_secs" "$SEVEN_DAY_WINDOW_SECS" "$now" "$seven_reset_mode")" + parts="${parts}${DIM}7d ${RESET}$(build_week_strip "$seven_int" "$now" "$period_start" "$dry" "$week_hist" "$WEEK_FUTURE_KEEP" "$(windows_ahead "$seven_secs" "${five_secs:-0}")")$(strip_tail "$seven_int" "$seven_secs" "$SEVEN_DAY_WINDOW_SECS" "$now" "$seven_reset_mode")" fi printf '%b' "$parts" } @@ -4136,29 +4171,35 @@ notice_collect() { # where you land. Shared frame with ccpace's watch advisor. if [ "$mode" = "always" ] && [ "$seven_int" -gt 0 ] && [ "$seven_int" -lt 100 ] \ && [ -n "$seven_secs" ] && [ "$seven_secs" -gt 0 ] 2>/dev/null; then - local windows=$(( (seven_secs + 17999) / 18000 )) - if [ "$windows" -gt 0 ]; then - local elapsed7=$((SEVEN_DAY_WINDOW_SECS - seven_secs)) - local heading="" heading_part="" walk_gap walk_end - read -r walk_gap walk_end <<<"$(_seven_day_walk "$seven_int" "$seven_secs")" - if [ -n "$walk_end" ]; then - heading="$walk_end" - elif [ "$elapsed7" -ge 86400 ]; then - heading=$((seven_int * SEVEN_DAY_WINDOW_SECS / elapsed7)) - [ "$heading" -gt 100 ] && heading=100 - fi - [ -n "$heading" ] && heading_part=" · heading ~${heading}%" - if [ "$windows" -le 1 ]; then - notice_add 10 '-' acct "acct.budget.last" "${surplus}%" \ - "last window · ${surplus}% left" \ - "budget last window · ${surplus}% left${heading_part}" - else - local even - even=$(awk -v h="$surplus" -v w="$windows" 'BEGIN{printf "%.1f", h/w}') - notice_add 10 '-' acct "acct.budget.${windows}" "${windows}${MULT_GLYPH}5h" \ - "${windows}${MULT_GLYPH}5h left · ${even}%/win" \ - "budget ~${windows}${MULT_GLYPH}5h left · even ${even}%/win${heading_part}" - fi + # windows AHEAD, not including the one you are in — the same number + # the 7d row folds into `...▯(✕N)`, so the two halves of one row never + # need arbitrating. "left" means still to come. + local windows elapsed7=$((SEVEN_DAY_WINDOW_SECS - seven_secs)) + windows=$(windows_ahead "$seven_secs" "${five_secs:-0}") + local heading="" heading_part="" walk_gap walk_end + read -r walk_gap walk_end <<<"$(_seven_day_walk "$seven_int" "$seven_secs")" + if [ -n "$walk_end" ]; then + heading="$walk_end" + elif [ "$elapsed7" -ge 86400 ]; then + heading=$((seven_int * SEVEN_DAY_WINDOW_SECS / elapsed7)) + [ "$heading" -gt 100 ] && heading=100 + fi + [ -n "$heading" ] && heading_part=" · heading ~${heading}%" + # 0 ahead is the only "last window": the week ends inside the one you + # are in, and there is nothing to divide the surplus across. At 1 the + # line keeps the count and the grammar — `1✕5h left · 25.0%/win` says + # the same thing the N-window form says, and calling two windows the + # last one to save a redundant clause is the wrong trade. + if [ "$windows" -le 0 ]; then + notice_add 10 '-' acct "acct.budget.last" "${surplus}%" \ + "last window · ${surplus}% left" \ + "budget last window · ${surplus}% left${heading_part}" + else + local even + even=$(awk -v h="$surplus" -v w="$windows" 'BEGIN{printf "%.1f", h/w}') + notice_add 10 '-' acct "acct.budget.${windows}" "${windows}${MULT_GLYPH}5h" \ + "${windows}${MULT_GLYPH}5h left · ${even}%/win" \ + "budget ~${windows}${MULT_GLYPH}5h left · even ${even}%/win${heading_part}" fi fi diff --git a/t/helpers.bash b/t/helpers.bash index 2427f76..27213b7 100644 --- a/t/helpers.bash +++ b/t/helpers.bash @@ -96,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|window_evidence_floor|build_ledger_strip|build_week_strip|build_five_strip|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 } + /^(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|windows_ahead|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_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/install.bats b/t/install.bats index c42e4a7..7d57b5e 100644 --- a/t/install.bats +++ b/t/install.bats @@ -10,18 +10,24 @@ setup() { export CLAUDE_CONFIG_DIR="$TEST_HOME/.claude" mkdir -p "$CLAUDE_CONFIG_DIR" - # Mock curl: copies the real statusline.sh instead of downloading + # Mock curl: serves the repo instead of GitHub. It resolves the URL to a + # repo path so a request for the skill does not silently hand back + # statusline.sh — a mock that answers everything with the same file cannot + # catch an installer that fetches the wrong thing. mkdir -p "$TEST_HOME/bin" cat > "$TEST_HOME/bin/curl" << MOCKCURL #!/bin/bash -for i in "\$@"; do - if [ "\$prev" = "-o" ]; then - cp "$SCRIPT_DIR/statusline.sh" "\$i" - exit 0 - fi - prev="\$i" +url=""; out="" +while [ \$# -gt 0 ]; do + case "\$1" in + -o) out="\$2"; shift 2 ;; + -*) shift ;; + *) url="\$1"; shift ;; + esac done -cat "$SCRIPT_DIR/statusline.sh" +path="\${url#*/main/}" +[ -f "$SCRIPT_DIR/\$path" ] || exit 22 +if [ -n "\$out" ]; then cp "$SCRIPT_DIR/\$path" "\$out"; else cat "$SCRIPT_DIR/\$path"; fi MOCKCURL chmod +x "$TEST_HOME/bin/curl" export PATH="$TEST_HOME/bin:$PATH" @@ -143,3 +149,72 @@ JSON result=$(run_install) [[ "$result" == *"statusline.sh"* ]] } + +# --- the flags are the user's, not the installer's --- + +@test "install: keeps the flags already on the statusLine command" { + # A configured statusline carries --order/--debug/a width. An installer + # that rewrites the whole command reverts the user's setup on every + # update, silently, and they find out by looking at a row that changed. + cat > "$CLAUDE_CONFIG_DIR/settings.json" << 'JSON' +{ + "statusLine": { + "type": "command", + "command": "bash ~/.claude/statusline.sh --debug --order activity,cost,model,quota,user", + "padding": 0 + } +} +JSON + run_install + result=$(jq -r '.statusLine.command' "$CLAUDE_CONFIG_DIR/settings.json") + [[ "$result" == *"statusline.sh --debug --order activity,cost,model,quota,user" ]] +} + +@test "install: a command that is not ours keeps no flags" { + cat > "$CLAUDE_CONFIG_DIR/settings.json" << 'JSON' +{"statusLine": {"type": "command", "command": "echo hi --not-our-flag"}} +JSON + run_install + result=$(jq -r '.statusLine.command' "$CLAUDE_CONFIG_DIR/settings.json") + [[ "$result" != *"--not-our-flag"* ]] +} + +# --- the skill ships with the script --- + +@test "install: installs the usage-insight skill" { + run_install + [ -f "$CLAUDE_CONFIG_DIR/skills/usage-insight/SKILL.md" ] + grep -q 'name: usage-insight' "$CLAUDE_CONFIG_DIR/skills/usage-insight/SKILL.md" +} + +@test "install: STATUSLINE_SKILL=0 skips the skill" { + STATUSLINE_SKILL=0 run_install + [ -f "$CLAUDE_CONFIG_DIR/statusline.sh" ] + [ ! -e "$CLAUDE_CONFIG_DIR/skills/usage-insight" ] +} + +# --- local source (what `make install` runs) --- + +@test "install: STATUSLINE_SRC installs from a checkout, no network" { + # the mock curl is removed: a local install that reaches the network at + # all is a local install in name only + rm -f "$TEST_HOME/bin/curl" + STATUSLINE_SRC="$SCRIPT_DIR" run_install + cmp "$SCRIPT_DIR/statusline.sh" "$CLAUDE_CONFIG_DIR/statusline.sh" + cmp "$SCRIPT_DIR/skills/usage-insight/SKILL.md" "$CLAUDE_CONFIG_DIR/skills/usage-insight/SKILL.md" + [ -x "$CLAUDE_CONFIG_DIR/statusline.sh" ] +} + +@test "install: a source tree that does not parse is not installed" { + # `make install` ships whatever is in the tree, half-finished edit and + # all. A broken statusline is not a worse render, it is no statusline. + src=$(mktemp -d) + mkdir -p "$src/skills/usage-insight" + printf 'if [\n' > "$src/statusline.sh" + : > "$src/skills/usage-insight/SKILL.md" + run env STATUSLINE_SRC="$src" bash "$SCRIPT_DIR/install.sh" + [ "$status" -ne 0 ] + [[ "$output" == *"does not parse"* ]] + [ ! -e "$CLAUDE_CONFIG_DIR/statusline.sh" ] + rm -rf "$src" +} diff --git a/t/statusline.bats b/t/statusline.bats index b75f28d..df26e1d 100644 --- a/t/statusline.bats +++ b/t/statusline.bats @@ -1267,39 +1267,71 @@ _seed_week_store() { [[ "$plain" =~ @[A-Z][a-z][a-z]\ [0-9]{2}:[0-9]{2}$ ]] } -@test "build_five_strip: the 5h strip is history — it ends at ▮, always" { - # The user could not read `5h ▮▯××`, and they were right: the future of a - # 5h window is a clock the badge already prints (`5h[7%@04:20]`) and the - # dry projection duplicates the rank-90 "5h caps" notice, which owns that - # warning with its own gates and its own exact time. +@test "build_five_strip: five slots, always — the hollow run is the hours left" { + # An axis, not a growing bar: five cells for five hours, ▮ walking them, + # so "how long have I got" is a count of ▯ and never a second glance at + # the clock — and the row holds its width for the life of the window. + # What the strip still refuses to draw is a FORECAST: no × wall. The badge + # (`5h[7%@04:20]`) states the end and the rank-90 "5h caps" notice owns + # the warning with its own gates and its own exact time. tmpdir=$(mktemp -d) CLAUDE_ACCOUNT_DIR="$tmpdir" now=$(date +%s) - # (1) a hot young window: one prompt front-loaded 7% ten minutes in — - # the fixture that used to draw ▮▯××× - _five_of() { - local reset_5h; reset_5h=$(date -u -d "@$1" '+%Y-%m-%dT%H:%M:%SZ') + _five_of() { # $1 = seconds still on the window, $2 = utilization + local reset_5h; reset_5h=$(date -u -d "@$((now + $1))" '+%Y-%m-%dT%H:%M:%SZ') local u=$(printf '{"fetched_at":%s,"five_hour":{"utilization":%s,"resets_at":"%s"},"seven_day":{"utilization":20}}' "$now" "$2" "$reset_5h") local p; p=$(strip_ansi "$(build_week_row "$u" always)") p="${p#5h }"; p="${p%% 7d*}" printf '%s' "$(printf '%s' "$p" | sed 's/ [0-9.]*✕ @.*$//; s/ @.*$//; s/ [0-9.]*✕$//')" } - young=$(_five_of $((now + 18000 - 600)) 7) - [[ "$young" == *▮ ]] - [[ "$young" != *▯* ]] - [[ "$young" != *×* ]] + # (1) a hot young window: one prompt front-loaded 7% ten minutes in — + # the fixture that used to draw ▮▯×××. Four hours still to come, and not + # one of them carries a verdict. + [ "$(_five_of $((18000 - 600)) 7)" = "▮▯▯▯▯" ] # (2) mid-window, burning hard enough that linear pace would have walled - # off every remaining cell - mid=$(_five_of $((now + 18000 - 9000)) 80) - [[ "$mid" == *▮ ]] - [[ "$mid" != *▯* ]] + # off every remaining cell: still ▯, never × + mid=$(_five_of 9000 80) + [ "$mid" = "░░▮▯▯" ] [[ "$mid" != *×* ]] - # ▯ and × are not gone from the vocabulary — the 7d strip still draws both - plain=$(strip_ansi "$(build_week_row "$(printf '{"fetched_at":%s,"five_hour":{"utilization":7},"seven_day":{"utilization":20,"resets_at":"%s"}}' "$now" "$(date -u -d '+5 days' '+%Y-%m-%dT%H:%M:%SZ')")" always)") - [[ "$plain" == *▯* ]] + # (3) the last hour: nothing ahead, same five columns — the row does not + # reflow as the window drains + [ "$(_five_of 600 40)" = "░░░░▮" ] + # the hollow run IS the whole hours left, to the second — not to the + # nearest cell boundary of a grid rounded to 5 minutes. 3h01m left draws + # three hollow cells; the 119-minute mark used to draw two. + for secs in 17400 12600 10860 9000 4000 600; do + hollow=$((secs / 3600)) + slot=$((4 - hollow)) + want="" + for ((i = 0; i < slot; i++)); do want="${want}░"; done + want="${want}▮" + for ((i = slot + 1; i < 5; i++)); do want="${want}▯"; done + [ "$(_five_of "$secs" 30)" = "$want" ] + done rm -rf "$tmpdir" } +@test "windows_ahead: the window you are in is not a window you have left" { + # 55h of week with 3h still on the current 5h window: 52h is ahead of it, + # and a stub at the end of the week is still a window you can spend — so + # 11 more, not the 12 that counting ▮ twice would give. + [ "$(windows_ahead $((55 * 3600)) $((3 * 3600)))" -eq 11 ] + # ...and it does not drift while both clocks tick down together: an hour + # later the week is 54h and the window 2h, still 52h ahead. A count that + # moved mid-window would be a reading; this is a countdown. + [ "$(windows_ahead $((54 * 3600)) $((2 * 3600)))" -eq 11 ] + [ "$(windows_ahead $((53 * 3600)) $((1 * 3600)))" -eq 11 ] + # it steps down by exactly one, at the rollover, when 5h is on the clock again + [ "$(windows_ahead $((52 * 3600 - 1)) 18000)" -eq 10 ] + # a partial window at the end of the week still counts + [ "$(windows_ahead $((8 * 3600)) $((3 * 3600)))" -eq 1 ] + # the week ends inside this window: nothing is ahead of it + [ "$(windows_ahead $((3 * 3600)) $((3 * 3600)))" -eq 0 ] + [ "$(windows_ahead $((2 * 3600)) $((3 * 3600)))" -eq 0 ] + # no live 5h window in the payload: nothing to exclude + [ "$(windows_ahead $((55 * 3600)) 0)" -eq 11 ] +} + @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 @@ -3597,11 +3629,12 @@ JSON } @test "build_advisor_line: budget degrades to plain headroom in the last window" { - # 3h to reset = 1 ceil'd window: per-window math would just restate the - # headroom, so the line says it straight. Surplus kept under + # The week ends INSIDE the current window (3h of 7d, 3h30 still on the + # 5h): nothing is ahead of it, so there is nothing to divide the surplus + # across and the line says the headroom straight. Surplus kept under # ADVISOR_SURPLUS_MIN_PCT — a bigger remainder belongs to the expiring # surplus clause, which owns the last-day zone. - reset_5h=$(date -u -d '+45 minutes' '+%Y-%m-%dT%H:%M:%SZ') + reset_5h=$(date -u -d '+3 hours 30 minutes' '+%Y-%m-%dT%H:%M:%SZ') reset_7d=$(date -u -d '+3 hours' '+%Y-%m-%dT%H:%M:%SZ') usage="{\"five_hour\":{\"utilization\":20,\"resets_at\":\"$reset_5h\"},\"seven_day\":{\"utilization\":75,\"resets_at\":\"$reset_7d\"}}" # 25% unused is under ADVISOR_SURPLUS_MIN_PCT, so the end-of-week voice @@ -3613,6 +3646,17 @@ JSON [[ "$long" =~ ^budget\ last\ window\ ·\ 25%\ left\ ·\ heading\ ~[0-9]+%$ ]] } +@test "build_advisor_line: one window ahead keeps the count and the grammar" { + # 3h of week with 45m on the current window: one more window follows this + # one. Calling that "last window" to save a redundant `/win` clause states + # something false about the week; the N-window form is true at N=1. + reset_5h=$(date -u -d '+45 minutes' '+%Y-%m-%dT%H:%M:%SZ') + reset_7d=$(date -u -d '+3 hours' '+%Y-%m-%dT%H:%M:%SZ') + usage="{\"five_hour\":{\"utilization\":20,\"resets_at\":\"$reset_5h\"},\"seven_day\":{\"utilization\":75,\"resets_at\":\"$reset_7d\"}}" + plain=$(strip_ansi "$(build_advisor_line "$usage" always)") + [ "$plain" = "1✕5h left · 25.0%/win" ] +} + @test "build_advisor_line: hot 5h pace projects the cap wall-clock" { # 85% only 2h into the window (3h left): pace ~2.1x, caps well before reset reset_5h=$(date -u -d '+3 hours' '+%Y-%m-%dT%H:%M:%SZ')