Releases: SophanaSok/ai-usage-tui
Release list
v1.1.0
The Claude Code hook starts seeing the test runs it was installed to see. Nothing a script reads
changes meaning: --routing-json and --summary-json gain a routing.withheld block, and the
journal gains a table older builds ignore. After upgrading, expect the routing panel to fill --
and expect failures in it that the shell reported as successes, because they were.
Fixed
-
The Claude Code hook recorded almost none of the test runs it saw, and said nothing about the
rest. It records a result only when the command line's exit status is the test runner's own
-- rightly:cargo test | tailexits withtail's status. But on the author's machine every
test command is trimmed throughgrep,tailorheadso its output fits a tool result.
Replayed over eighteen days of transcripts, 1,074 command lines ran a test runner, ten had a
status the hook could trust, and the journal held two events. Each of the others was skipped
with a good reason, printed to a stdout nobody reads, counted nowhere: a broken measurement
rendered as "no test runs", which is the failure this project exists to refuse.The capture that settled the fix: a failing
cargo test 2>&1 | grep -E "^test result|FAILED"fires Claude Code's success hook, becausegrepsucceeded -- so trusting the
status more would have recorded failures as passes. The runner's own summary line is in the
payload's output, and where the status does not speak, the hook now reads it:test result: ok./FAILED.and cargo'serror: test failedforcargo test, and the equivalents for
pytest,go testanddeno test-- the four runners whose real output is kept under
tests/fixtures/hook/; a recipe (make test,npm test,just check) is read for all four.
A failure marker is always believed; a pass needs the end of the output to be there, so it is
withheld when aheadfilled its limit or a filter selects passing lines by name. The summary
decides against the hook event when the two disagree, and the output is never stored. Replayed
over the same 1,074 lines: 396 runs recorded (272 passes, 124 failures), where there were 10.
Added
- What the hook cannot record, it counts. A test run with neither a trustworthy status nor a
readable summary adds one to a tally in the journal (withheld_test_run: UTC day, agent, reason
-- no command line, which can carry a credential, and no output).--doctorprints it under
CLAUDE CODE beside the number recorded; the routing panel's title carries the total;
--routing-jsonand--summary-jsongain arouting.withheldblock (runs, andby_reason
withpipe,sequence,or_after,after_or,background,substitution,and_chain).
Two events beside 800 withheld runs is a coverage gap; two events beside none is a quiet
machine, and until now they were the same screen.--prune-journalages the tally out with
the rest. just checkis a recognised test runner, asmake checkalready was.scripts/redact-hook-payload.pyandCONTRIBUTING.md, "Capturing a Claude Code hook payload":
how the fixtures were taken, and how to teach the hook another runner's summary line -- from a
capture, not from its documentation.
Changed
- The write-up answers the question it left open.
docs/what-a-max-subscription-bought.md
said it did not know why the journal held two hook events beside 129 commits. It now says why,
with the replay's figures.
v1.0.1
The binary is v1.0.0's. This release exists to carry a document: the site renders the write-up
from the latest release tag, so a rewritten one reaches it only with a tag.
Changed
- The write-up is a second edition, measured again at v1.0.0.
docs/what-a-max-subscription-bought.mddescribed a v0.13.0 tool on 2026-09-02, and two things
in it had stopped being true: the--claude-code-hookit said was never installed is installed,
and its tables could no longer be re-derived by anyone, because Claude Code on that machine
deletes transcripts after twenty days (cleanupPeriodDays) and everything before 08-31 was
gone -- 835 sessions then, 362 now. A measurement whose commands no longer reproduce it is a
claim, so it is measured afresh over 2026-08-31 to 09-18 rather than patched. New in it: a
section on what running the tool showed (real spend kept apart from the API-equivalent, how
close the limit windows are, where the tokens go, that transcripts expire, what--doctor
reports as not measured), and an open question it does not answer -- the routing journal
holds two hook events beside 129 commits. The first edition is in the file's history.
v1.0.0
The first release under the promise in docs/stability.md: from here, breaking a flag, an exit
code, a config key, a JSON key, a CSV column or the journal needs a 2.0. The Rust library API is
not part of that promise and never was. Nothing in this release breaks a surface v0.20.0 had.
Two things will look different after upgrading, and both are corrections. Claude Code output
totals rise by the share of your work that ran in subagents, which was being counted from a
placeholder. On Linux, install.sh, the .deb, the .rpm and Homebrew now install a static
binary, because the one linked against glibc did not start on Debian 12, Ubuntu 22.04 or RHEL 9.
Added
-
PowerShell completions ship.
--completions powershellalways worked and nothing ever ran
it: the Windows zip was the one archive with no completions in it. It now carries
completions/_ai-usage-tui.ps1, and the unix archives carry it beside bash, zsh and fish. -
The Limits panel says when a window resets, not only how long. An
ATcolumn gives the
reset on the local clock --Fri 14:00, orSep 25 14:00once a weekday alone would read as
today. A countdown is already old when it is read off a twenty-minute-old snapshot, and "can I
start this at three?" is asked of a clock. -
--summary-jsonhas abuildblock: the version, how the binary was installed, the
command that upgrades an install of that kind, and the last cached answer of the opt-in update
check. These were the two things--doctorknew that no JSON document said. The check is read
and never made;build.update: nullmeans nobody has asked, not that the build is current.
There is no--doctor --json, by decision: everything else it would hold was already here, and
a second document is a second stable surface. -
Codex's rate-limit windows, on every platform. On a ChatGPT plan Codex writes the
account's 5-hour and weekly windows into everytoken_countevent of its rollouts, and nothing
read them, so Codex's row in the Limits panel existed only on Omarchy. They are read now -- the
newest block per limit, from the three most recently written rollouts -- and filed under the id
Omarchy uses, so the two readings of one plan are one row.--json,--summary-jsonand
--doctor(acodexrow underLIMITS) carry them too. An API key gets no such headers and
shows nothing. The format was taken from bytes this time: no Codex account was needed, because
scripts/codex-standin.pyanswers the Responses API on localhost and the real CLI writes a
real rollout (tests/fixtures/codex_capture, recipe indocs/provider-support.md). That
confirmed the synthetic fixture the collector was built on and found one thing reading the
source had not: a response can carry several limit families, the thread keeps one snapshot, and
the last family parsed replaces the rest -- so "the lastrate_limitsin the file" can be a
2%-used one-hour window for some other limit, on every line. Readings are keyed onlimit_id. -
Codex rollouts the CLI has compressed are read. With
local_thread_store_compressionon
(off by default as of codex-cli 0.155.0) every rollout untouched for a week becomes
<name>.jsonl.zstand the plain file is removed. Tried against the real CLI, the collector then
reported two of six calls and said nothing about the rest; a week of history would have left the
dashboard at each start. They are decoded as a stream, read once, and one that does not decode
is counted as unreadable by name. One new dependency,ruzstd: decoder only, pure Rust, MIT. -
A terminal is sent the colours it says it can draw. The palette is 24-bit and went to
every terminal as such; one that does not understand the sequence draws whatever it makes of
it. The depth is now read once at startup --COLORTERM, thenWT_SESSION, thenTERM-- and
anything short of 24-bit has the finished frame mapped down in a single pass, asNO_COLOR
is, so a new panel cannot forget it. 256 colours take each colour's nearest neighbour; this
tier exists becausesshandsudodropCOLORTERMand keepTERM. Sixteen map the named
palette by meaning, so muted text and borders do not land on one grey, leave the backgrounds
to the terminal, and draw white text in the default foreground so a light theme can read it.
--doctorprints the depth chosen and the variable it came from. If a terminal that does
support 24-bit colour looks flatter than it did, setCOLORTERM=truecolor. -
--install-hookand--install-statuslineput this tool into Claude Code's settings;
--uninstall-hook,--uninstall-statuslineand--uninstalltake it out. Routing
analytics, the feature nothing else here has, sat behind a hand-runjq -s '.[0] * .[1]'
merge into~/.claude/settings.json-- andjq's*replaces arrays, so a user with any
otherPostToolUsehook lost it, which three documents had to warn about; and nothing could say
whether the hook was installed at all. The commands append to the two event lists and never
replace them, write nothing on a second run, keep every other key in the order it was found,
keep the file's permission bits -- itsenvblock may hold keys, and nothing of the file but
this tool's own entries is ever printed -- and refuse a file that is not a JSON object, naming
the file and the position and changing nothing. The command written is the bareai-usage-tui
when a binary of that name is onPATHand the running binary's absolute path when not, and
the report says which, because the two age differently.--install-statuslinerefuses to
replace another program's status line and names it;--uninstall-statuslineleaves one that is
not this tool's.--uninstallremoves both entries and the cachesdocs/stability.mdcalls the
tool's own -- a test holds the two lists together -- then prints the journal's and the config
file's paths with thermthat would delete them, and does not run it: those are the user's.
--doctorgained aCLAUDE CODEsection, asked of the same detector the installers use, so
what it calls installed is exactly what--uninstall-hookremoves; a hook on one event of the
two is reported as such. The command is the consent, as for--check-update: no config key, no
prompt, and the dashboard never writes there. The setup guide, the README and
contrib/claude-code/README.mdnow name the commands first and keep the hand merge as the
alternative. -
--prune-journal DAYSdeletes old journal rows and hands the space back;--doctorsays
how big the journal is.usage.dbhad no retention and noVACUUM, and--doctorcounted
its usage rows and nothing else -- not its bytes, not its routing events, which no source row
counts, not how far back it goes. The journal is also the only copy of what--record-*and
the hook wrote, so the answer is a command and not a policy: nothing prunes on a timer, at
startup or from the dashboard, and no config key exists to make it. The command refuses fewer
than 31 days and never reaches into the current month, because a monthly budget still reads
those rows. It keeps the routing events of a Claude Code session that has newer ones -- the hook
sums a session's earlier rows to know which requests it has already attributed, and would
attribute them again -- and the usage row with the highest id, because SQLite hands a deleted
top id out again, below the cursor of a dashboard that is open. It reports rows deleted of rows
present per table, what it kept and why, and bytes before and after; it creates nothing when
there is nothing to prune; and aVACUUMthat fails exits2with the rows still deleted, to
be retried by running it again.
Fixed
-
The Linux downloads did not start on current stable distributions. A binary linked against
glibc needs a glibc at least as new as the machine that built it, and that was the release
runner's: 2.39, measured on the v0.20.0 binary. Soinstall.shinstalled something that
answeredversion `GLIBC_2.39' not foundon Debian 12, Ubuntu 22.04 and RHEL 9; the.deb
declaredlibc6 (>= 2.39)and apt refused it there; the.rpmdeclared nothing, installed,
and failed when run; and none of it could be loaded on Alpine. Releases now carry static
builds (-x86_64-linux-musl.tar.gz,-aarch64-linux-musl.tar.gz), checked withfileand
run on a bare Alpine in the release build.install.shtakes them (--libc gnufor the other),
as docargo binstallon a musl host and the Homebrew formula on Linux, and the.deband
.rpmare built from them, checked to require no C library, and installed and run on Debian
11, Ubuntu 20.04, Rocky 8 and Fedora before they are published. The glibc archives are still
published, and the AUR package uses them. -
A webhook URL was printed in full when a POST failed. A Slack, Discord or ntfy webhook is
its URL -- the token is the path -- andreqwestputs the URL in every error it returns, so a
timeout or a 404 wrote the credential to stderr and to the diagnostic log; the bad-scheme error
quoted it too. Every message now names the host and nothing else. Found while deciding what the
notice below should print. -
The journal, the caches and the log are created owner-only. They were created at the
umask, which on most systems means readable by every account on the machine: project paths,
session ids and spend in the journal, a subscription's utilisation in the caches. New files are
0600-- the journal is made before SQLite opens it, because SQLite creates at the umask and
its side files copy the main file's bits. A file that already exists keeps what it has: the
caches tighten as they are rewritten, and for a journal from an earlier release--doctorprints
its mode and thechmod 600that fixes it, and does not run it.
...
v0.20.0
Added
- Releases are attested, and ship a bill of materials. A checksum proves a download is the
file the release lists, and nothing more:checksums.txtcomes from the same place as the
archive, so whoever could replace one could replace both. From the next release every archive
and every.deband.rpmcarries a build attestation -- signed by the release workflow's own
identity and kept by GitHub apart from the release's files -- binding the file's digest to this
repository,release.ymland the tagged commit.gh attestation verify <file> --repo SophanaSok/ai-usage-tui --signer-workflow …/release.yml --source-ref refs/tags/<tag>checks
it; the last flag matters, because a hand-run dry run attests what it builds too, as built from
its branch. Each release also shipsai-usage-tui-<tag>.cdx.json, a CycloneDX list of every
crate any released target links, with version, licence and registry checksum, attested against
the same files. Both attestations are made before the release is created, so a failure there
publishes nothing. Tried end to end on a dry run before merging: the tarball, the.deband the
bill-of-materials predicate verify; a tampered copy, another workflow and the wrong ref do not. install.shchecks the attestation when it can, and refuses a download that fails it. With
a usable GitHub CLI -- installed, recent enough to tie a file to a tag, signed in -- the
installer verifies the archive it just downloaded. Nothing is refused for the lack of a tool:
that is reported as "not checked" and the install goes on. A check that fails on a release
that should be attested is different, and refuses; the first draft of this step printed "do not
use this download" and then installed it, which the review of this change caught.
--require-attestationmakes "not checked" fatal as well, and--no-attestationskips the
step for whoever has a reason to.
Changed
- A failure exits
2;1now means only that a budget is over. Every failure and a
breached budget shared one exit code, so the scheduled--check-budgetsthis tool tells people
to run could not tell "you are over" from "your config does not parse" -- the recipe shipped in
--agent-guide recipesparsed stdout withjqto find out which it had, and a cron line
testing the status alone would have raised a budget alarm for a typo. The codes now mean what
they mean togrepanddiff:0fine,1the check said no,2trouble -- a flag the tool
does not know, a config or source it could not read, a write that did not happen. The breach
keeps1, the number the README documented, anddocs/stability.mdhad promised only
"non-zero" for a failure, so a script written against either keeps working unless it tested a
failure for== 1. This is the last change of its kind before 1.0.0, which freezes it.
--helpand the man page gained anEXIT STATUSsection, and the budget recipe is three lines
shorter. One exception, from a capture and not from documentation: on Claude Code 2.1.275 a
PostToolUsehook that exits2has its stderr given to the model as something to act on, and
one that exits1does not -- so a failed--claude-code-hookstill exits1, including when
what failed was the config, before the hook's own code ran. A journal that could not be written
is not the model's to fix. - Every action is pinned by commit, and every workflow token is least-privilege. Actions
were named by tag -- a pointer its owner can move -- including in the job that holds the
crates.io token, and the MSRV job tracked a branch. All are nowowner/action@<commit> # version,
which Dependabot maintains; the one tool the release job downloads is pinned by version and
checked against a digest written in the workflow, not the one served beside it.release.yml
grantedcontents: writeto all ten jobs; it is read-only at the top, the release job alone can
write, and the tap job gets no repository token at all.ci.ymland the two Claude workflows
declare their permissions instead of inheriting a setting. Two tests hold this: one fails for
anyuses:not pinned to a 40-hex commit with a version comment, one for a workflow with no
permissions:block or a top-level write. - A release goes through a pull request.
mainis now protected -- changes by pull request
with the seven CI checks passing, no force-push, no deletion, no bypass -- so the release commit
no longer goes straight to it.scripts/release.shruns onrelease/vX.Y.Zbefore the pull
request and again onmainbefore the tag, where it also refuses amainthat is not
origin/main. The ruleset that existed was switched off, and could not have been switched on:
it required three checks that do not exist and an approving review from a second maintainer
the project does not have.
Fixed
- A release whose asset list failed its check said nothing about why.
publish-release.sh --publishcaptures the listplanprints, andplanprinted its errors to the same stream --
so a missing manifest failed the job with no message at all. The dry run (--plan) showed the
error, which is how it went unnoticed. Errors go to stderr now; found by the test for a missing
bill of materials, which asserted on a message that never arrived.
v0.19.0
Added
-
--agent-guide recipesand--agent-guide extend: an agent can build on the data, and cover
what the tool lacks.recipessays what is stable enough to script against and what is not,
carries the reading rules into code (.cost // 0injqturns an unknown into a zero nobody can
see; SQLite sums an empty CSV field as0), and gives worked scripts: a Waybar module, a guard
that exits non-zero before a plan window runs out, a budget alert, a weekly Markdown digest, a
per-project table, rows into SQLite. Every recipe that needs onlyjqis run by the test
suite, as written, through a shim that pins every source -- a recipe that fails on real output,
or printsnullwhere it promised a value, fails the build.extendroutes a request to the
cheapest thing that answers it: an adapter into--record-event(keys, a worked example, why
re-sending the whole log is the right design), a script, or a change to the source, with the
rule that outranks all of them -- a tool that does not measure its token counts gets no row.
The shipped skill and the pastedAGENTS.mdblock are broadened to set-up, build and extend
requests, and still name no topic: a test refuses one, because an installed skill outlives the
binary and an older binary rejects a topic. -
--agent-guide setup: an agent can set the tool up, not only read it.--agent-guidetakes
an optional topic. Bare it prints what it always has, byte for byte -- every installed skill and
pastedAGENTS.mdblock says "run--agent-guide", and they outlive the binary they were
written for; the default guide now lists the topics, and nothing installed names one, because
an older binary would reject it.setupcovers the config file and budgets, the Claude Code
hook and status line, the systemd timers, a tool with no collector, and undoing all of it.
It is written for an agent on a binary install, where there is nocontrib/to copy from: the
hook's JSON and the four units are inside the guide, and a test holds each to the shipped file
byte for byte. The tool still edits no other program's files and gains no install command --
the agent makes the change, after showing it, and--doctorsays whether it took. What an
agent gets wrong without being told is in there too: which commands write or use the network,
that--print-config's sample budgets are live, that merging the hook withjq's*deletes
the user's other hooks, and that a budget counts dollars and so watches nothing on a
subscription plan. -
--record-event: a way in for a tool that has no collector. The recorders each understood
one server's response, and a bare response cannot say where it was made -- so usage fed in from
outside never reached the Projects or Sessions views, had no cache writes, and could not say it
was billed against a plan.--record-eventreads usage in this tool's own terms, one JSON
object per line:provider,model,input_tokens,output_tokens, one ofevent_idor
created, and optionally the rest of the token split,project,session_id, acostthe
tool itself recorded (kept asreported, never re-estimated) or"billing": "subscription"
(aquotarow withapi_equivalent_costbeside it, on the same path a native collector's
takes). A few lines ofjqover a tool's own log is a whole integration.It is strict where a collector is tolerant, because an adapter's author -- often an LLM agent
-- learns from the exit status and nothing else: an unknown key, a count that is not a whole
number, a line that is not JSON each refuse the whole batch, by name, before the journal is
opened. And it records measured counts only. An event without its token counts is refused,
never stored as zero, so a tool that keeps no counts cannot be journaled by guessing them; and
nocost_statusbutreportedcan be supplied, so this tool never vouches for arithmetic it
did not see. A suppliedevent_idis stored asevent:<provider>:<id>, since identities share
one namespace across sources.The journal's
usage_eventgains three nullable columns (session_id,project,billing).
An older build's writer and reader name their columns, so they are unaffected, and the journal
schema version stays1: an old hook and a new dashboard can keep sharing one file. -
A monthly job keeps the bundled rate table from going stale by neglect. The tool now tells
a user when their install's rates are over 90 days old; this is the other end of that promise.
pricing-drift.ymlregeneratespricing/litellm.tsvwhen LiteLLM's table has moved, runs the
pricing engine's tests against it, and opens one issue with what changed and a link that opens
the pull request -- an issue, because a pull request opened by the workflow token gets no CI, and
never a red build, because upstream moving is not a failure. If the tests fail against the new
table it says so and pushes nothing. The community table stays release-bound by decision:
refreshing it at runtime would give--refresh-pricinga new host to contact. -
Bundled pricing says when it is old. Rates ship in the binary, and only the refreshed cache
was ever compared to the clock -- so an install six months old priced at six-month-old rates
without a word, which is a confident number resting on a fact nobody checked. Both tables carry an
# Updated:date; past 90 days the engine now says so, naming both dates and what to do
(upgrade:--refresh-pricingupdates the curated Zen rates only). A fresh refreshed cache
supersedes the curated table's date, so then only the community snapshot's age counts. -
Pricing warnings reach the dashboard. A refused cache -- stale, unreadable, invalid -- and the
age notice were printed by--doctorand nowhere a running dashboard could show them, so a
dashboard pricing from a table it had silently fallen back to looked exactly like one that was
not. The status line now carries one clause (pricing: 1 problem(s), see --doctor, or
pricing: bundled rates over 90 days old); a fault turns the header red, age alone does not. -
The currency and the table dates are stated.
--doctorprints when each bundled table was
cut, and--summary-json'spricingblock gainscurrency(USD, list price, nothing
converted),community_table_dateandcurated_table_date. No figure anywhere had a unit.
Changed
-
The contributor's guide says what adding a data source really takes, and tests hold the parts
of it that were wrong.CONTRIBUTING.mdcalled it "two files" and listed the other six
thirty lines later; its fixture command andjust runboth called themselves hermetic while
leaving Copilot and Gemini unpinned, so the documented fixture-only run printed the reader's own
rows. The section now opens with the two questions that decide whether there should be a
collector at all (does the tool measure its own counts; would--record-eventdo), requires a
redacted real capture before a parser, lists every file, and names the tests that will say what
is missing.documented_fixture_commands_pin_every_sourceruns both documented commands as
written and asks--doctorwhere each source resolved.AGENTS.mdis no longer headed as one
vendor's instructions and gains an "Extending it" section -- cheapest route first, and the rules
that outrank a request (never invent a number, work from real bytes, a test must fail against
its bug). A project skill,.claude/skills/add-data-source/, gives a coding agent the order to
work in, including the two places it should stop. The check that agent-facing files name only
real flags now matches the parser exactly -- it was a substring search that accepted--record
-- and coversAGENTS.mdand the new skill. -
The guards a new source or panel trips now ask the code, not a list kept in a test. Five
checks each carried a hand-written list -- of source ids, of billing-capable sources, of panels,
of overlay words, of actions--oncerefuses -- and a list in a test passes for the entry nobody
added to it: Gemini was billing-capable and outside the billing check;--record-usageand
--statuslinewere outside the--oncecheck. They now iterate the registry,Panel::ALL
(generated beside the enum by one macro), the bindings table and the parser's own action list.
Two new ones:--doctorunder the test harness must resolve every registered source inside
tests/fixtures, which catches a source the harness forgot to pin and one reached through an
environment variable; and--schema's sentence listing the source ids must match the registry.
One leak closed on the way: the registry's reachability test defaulted the roots it did not
name, so it read the developer's real~/.copilotand~/.gemini. -
The bundled community rate table is refreshed from LiteLLM (snapshot of 2026-09-17, the
first opened from the monthly drift job's issue): 3,975 keys become 4,627 -- 713 added, 61
removed, 182 repriced -- and the engine prices 4,370 models, up from 3,785. Of the repriced, 59
only gained a published rate (usually cache reads) or lost a rounding; 63 got cheaper, such as
azure/gpt-5.6-solfrom $5/$30 to $4/$20 per million, 52 dearer and 8 moved both ways. A cost
computed for one of those models changes with this release, in either direction; the curated Zen
table, which wins where both list a model, is untouched.
Fixed
- A free model the rate table lists at
0.0isFREEagain. The rule added earlier in this
cycle -- a name does not make a model free if the pricing table lists a rate for it -- asked only
whether a rate was listed, and the community table publishes free tiers as an explicit
input=0.0 output=0.0. Sollama-3.3-70b-instruct-turbo-free, and every other free model the
table knew about, became `PAID...
v0.18.0
Added
--summary-json: the whole picture in one compact document.--jsonprints one object per
request -- 13.3 MB for the 25,000 requests on the machine this was written on, about a hundred
times what fits in a model's context window -- and no aggregated JSON existed at all: the
by-model, by-project, by-session and by-day rollups were computed for the dashboard and rendered
only there. The summary is those, as one line of about 33 KB for the same history:totals,
by_category,by_model,by_project,by_session,by_day, the trailing-hourburnrate,
every budget including the ones stillOK,limits,escalations,provenanceandrouting,
plus what only--doctor's text carried --sources(rows found, status, the billing decision,
skipped data) andpricingwarnings.--top N(default 10) lists the largest models, projects
and sessions and folds the rest intoother, so a truncated list still adds up to the totals.
Every rollup carries derived figures nothing computed before:cache_hit_pct,
tokens_per_request,cost_per_request,output_pct,reasoning_pct,share_of_tokens_pct.
Eachby_modelrow carrieslist_input_rate, and each escalationfrom_input_rateand
to_input_rate(also in--json): the pricing table's dollars per million input tokens, so
which model is the expensive one is a number rather than something inferred from a name -- a
model reading an early build called an escalation to a newer, pricier model a "downgrade".
They are facts, not advice -- no thresholds, no verdicts -- and unknown stays unknown: a
percentage nothing recorded isnull, not0(several sources never report cache or reasoning
tokens), andcostisnullwhen nothing in a bucket could be priced.--schemaand--agent-guide: the output explains itself, from the binary. The meanings
of the JSON lived in README prose,docs/data-model.mdand Rust doc comments, none of which the
CLI could hand to whatever was reading its output --"cost_basis": "floor"could be resolved
only by reading the source, anddocs/data-model.mdspelled the categories in lower case while
the exports print them in upper.--schemaprints a JSON glossary of every key of every
document and every value of every closed vocabulary, with its type, whether it can benull,
and what it means. It cannot drift: a test walks real--summary-json,--json,
--routing-jsonand--check-budgetsoutput against it and fails on any key, enum value or
nullit does not describe, and another holds each vocabulary to the labels the code prints.
--agent-guideprints the guide for an LLM agent: start with the summary, drill down with the
filters, the reading rules (nullis never 0,costcan be a floor,quotais real cost with
no figure,api_equivalent_costwas never charged, a token share is not a cost share), what to
look for in usage and routing, and what not to claim. Both are compiled in, because no binary
install shipsdocs/, and both work before the config is read.- A Claude Code skill, and a paste-in block for every other agent.
contrib/fed data into
the tool -- the hook, the status line, the recorders -- and shipped nothing for reading it back
out.contrib/claude-code/plugin/skills/ai-usage/is a skill that answers "how can I cut my
token usage?" or "is the expensive model worth it here?" from the tool's own data. It is
deliberately thin: it sends Claude to--agent-guideand--summary-json, so the instructions
always match the installed version and the skill never needs updating; it pre-approves
ai-usage-tuicommands and nothing else. Install it as a plugin -- the repository is its own
marketplace,/plugin marketplace add SophanaSok/ai-usage-tui-- or copy the directory into
~/.claude/skills/.contrib/agents/README.mdis the same three lines forAGENTS.md,
.cursorrulesor a system prompt. Checked end to end against a real account: Claude loaded the
skill, ran the guide and the summary and never--json, reported 1.47B tokens at a 98.5% cache
hit as plan-billed with no dollar figure, named context size rather than caching as the lever,
and declined to judge the routing because only one outcome had been recorded. - The README has a section for this -- "Ask an LLM about your usage" -- and the documents a
reader meets say what shipped: the routing guide defines everycost_basisvalue and what it
does tocost_per_success, the privacy and security notes say what an agent's provider sees
(project paths and session ids; never prompts or transcripts, and nothing sent by this tool),
and the roadmap records why the tool gives facts rather than advice and why there is no MCP
server in V1. --project PATHand--session IDfilter every export, so a reader goes from the summary
to one project or session without pulling every row.--csv -writes the CSV to stdout. It is the compact row format and could only be written
to a file.billingin each--jsonrow (per_token/subscription), which the data model
documented and no export carried, andsuccess_ratein--routing-json, which the panel
showed and the export left to the reader to divide.
Changed
--routing-jsonhonours a range flag when one is given. It was all history or nothing.
Without a flag it still means all history: the default range elsewhere is a week, and applying
that unasked would have shrunk every existing script's output.- The dashboard's model table and the summary's
by_modelare grouped by one function
(summary::model_rows), with a test holding them to each other. - Every channel now points at the website. crates.io and GitHub's About box named the site,
while the Homebrew formula, the Scoop manifest, the Chocolatey nuspec and the AURPKGBUILDall
named the source repository as their homepage, and--help, the man page,--doctorand the
installer named nothing -- so how the tool was installed decided whether a user ever learned the
documentation existed. The packaging templates carry__HOMEPAGE__, rendered by the release job
fromCargo.toml'shomepagelike the description is;--helpand the man page end with a
MORE:block naming the site and the repository;--doctorlists it under THIS BUILD; the
installer prints it when it finishes; the README leads with a badge and a line saying what is
there.package.documentationnames the site too, so crates.io's Documentation link goes to the
user documentation rather than to docs.rs for a library APIdocs/stability.mdsays not to use.
A test holds every one of these to the single field. - Release assets are uploaded one at a time, each confirmed before the release goes public.
softprops/action-gh-releaseuploaded all fifteen at once, and on v0.17.0 GitHub left the
multi-megabyte ones stuck half-finished (state: starter), which a same-name upload cannot
replace -- each re-run deleted and re-uploaded everything and left more stuck, one then three then
five, until the draft was deleted by hand.scripts/publish-release.shnow creates a draft,
uploads serially through the REST endpoint, checks every asset's state and size against the API,
deletes a stuck one before retrying with backoff, and publishes only when all are confirmed. The
dry run checks the same asset list. It is tested against a fakeghandcurlin CI, where
removing the stuck-asset deletion or letting a failed asset check fall through both fail the test.
v0.17.0
Added
-
A journal schema version. Writers stamp
PRAGMA user_versionand refuse, by name, a journal a
newer build has stamped higher -- a hook installed from one channel beside a dashboard from another
is how two builds come to share one file. Seedocs/data-model.md. -
NO_COLOR. Any non-empty value draws the dashboard without colour, per no-color.org. Every
colour was a hard-coded RGB value, backgrounds included, with no way to turn it off. The colour is
removed from the finished frame in one pass rather than branched in every panel, so a panel added
later cannot ignore the setting; bold and the rest stay, and the selected row -- which colour
alone had marked -- is drawn in reverse video. -
--print-configprints the annotated example configuration, which is now in the binary.
--doctorused to tell a user without a config to "copy examples/config.toml there", a file no
binary install channel ships. It works before the config is read, so a broken config does not
stop it. The example's budgets are live samples, so the hint says to edit them rather than
suggesting a> config.tomlredirect. -
docs/stability.md: what a version number promises. Semantic versioning covers the
command-line tool -- flags, exit codes, config keys, JSON and CSV output, the journal schema,
environment variables -- and explicitly not the Rust library API, which exists so the binary, its
tests and the screenshot renderer can share code. Nothing had said either, while the crate
published nineteen public modules on crates.io. The crate documentation now says the same. -
"schema_version": 1in every JSON document ----json,--routing-jsonand
--check-budgets-- so a consumer can check what it is reading. Additive: no key moved.
Changed
#![forbid(unsafe_code)]in the library and the binary.SECURITY.mdpromised nounsafe
code; the build now enforces it.
Fixed
-
A
kill, a closed terminal window or a logout no longer leaves the terminal broken. The
panic hook restored raw mode and the alternate screen; a signal never reached that code, so
SIGTERM, SIGHUP or an outside SIGINT ended the dashboard with the shell still on the alternate
screen and echo off. All three now set a flag the event loop checks every 250ms and leave through
the same exit asq; a second signal exits at once. Checked in a real pseudo-terminal against
the v0.16.0 binary, which died on each signal without leaving the alternate screen. -
Quitting no longer freezes a raw-mode terminal behind a poll in flight. The dashboard owned
the collector handle, and dropping it joined every collector thread before the terminal was
restored -- with no bound, and a poll cannot be interrupted, so pressingqduring a
rate-limitedzen_pricingfetch held a frozen screen for most of a minute. The terminal is now
restored first, and the join waits at most two seconds before leaving a stuck poll to the
process exit (CollectorHandle::join_within). -
The
zen_pricingcollector no longer prints into the dashboard. Its rate-limit retry notice
went to stderr from a background thread, which lands in the middle of the frame. The collector
logs it; the one-shot--refresh-pricingstill prints it. -
--record-ollama,--record-usage,--record-routingand--claude-code-hooksurvive a
closed stdout. Each confirmed with a bareprintln!after journaling, so a caller that closed
the pipe got a written row, a panic, and a failing exit status that said the recording had not
happened. -
Data a collector reads around now shows on the dashboard. Every tailing reader skipped an
unreadable file withErr(_) => continueand a line that was not JSON as "no usage here", and
counted neither: a transcript with a bad byte, a Codex rollout written in a new encoding or a
corrupt OpenCode row made the totals smaller while the header stayed green. Claude Code, Codex,
OpenCode, Gemini CLI and Copilot's legacy logs now record both through onecollector::skipped
type and implementCollector::warning-- which until now only the local-model journal did --
so the live status line reads, say,claude_code: 1 file(s) unreadable, 2 malformed record(s) skippedand the header is marked degraded. The one-shot status carries the same note plus the
first unreadable path and error into--once,--jsonand--doctor, and the log records each
change. An unreadable file is a current state (it is retried every poll and drops out once it
reads); a skipped line is permanent for the process, and is counted exactly once -- including
OpenCode's deliberately re-read boundary row, which would otherwise have grown by one per poll.
Gemini's existing count had lived only inside a single read, so the incremental dashboard
reported it for one poll at most. -
Subscription windows this build does not recognise are reported, not dropped. The
~/.claude.jsonreader counted entries of an unknownkind, and nothing read the count, so a
window Claude Code added upstream vanished from the Limits panel,--jsonand--doctoralike.
It is now a limits problem -- on the status line, and in aproblemrow under--doctor's LIMITS
section, which had never printed the problems the panel flags at all. -
Concurrent hooks no longer fail on an unmigrated journal. Opening the journal to write ran
probe-then-ALTERwith no lock held between the two, so writers that opened a journal from
beforeevent_idtogether -- parallel subagents fire parallel hooks -- all saw the column
missing, and every one but the first died on "duplicate column name". A test with eight writers
reproduced it on the first round. Migrations now run underBEGIN IMMEDIATE, the routing
table's rebuild runs inside that transaction instead of opening its own, and writers wait up to
five seconds for the lock instead of 250ms. -
The update and pricing caches no longer share a temporary file between writers. Both wrote
through a fixedjson.tmp/toml.tmpon the belief that only the dashboard wrote them; in fact
two dashboards each runzen_pricing, and a scheduled--check-updatecan land beside an
opted-in--doctor. Writers sharing a temporary race, and the loser's rename moves a half-written
file into place. All three caches now go through onehelpers::write_atomic, which names the
temporary per process and removes it when the rename fails -- the rule--statuslinealready
followed. -
A new install no longer opens on a blank table. With no rows the default panel drew a header
over nothing beside tiles reading0-- a working dashboard with nothing to report, the least
likely reading of an empty screen. It now says no usage was collected and names
ai-usage-tui --doctor, or, when data exists outside the range or filter, says so and how to widen
it. -
A pane shorter than 20 rows says so (21 while a budget alert's banner is showing). Below the
height the layout needs, ratatui squeezed the panels to zero height one by one without complaint.
The dashboard now shows the rows it needs and has, says so when a budget alert is active so a short
pane cannot hide one, and keeps the key hints -- and how to quit -- on the last line. -
SECURITY.mdlisted the network calls as--refresh-zen,--refresh-pricingand the budget
webhook, omitting--check-updateand an opted-in--doctor, which have called GitHub's
releases API since v0.11.0. The security policy and the README's privacy section now agree. -
The Limits panel was described as "from Omarchy's agents panel" in
--help, the?overlay
and the README panel table, although Claude Code's cache and status line have fed it since
v0.13.0 with no Omarchy at all. The README's privacy section and paths table still said "Ollama
journaling", and its prerequisites omitted Gemini CLI and llama.cpp. -
The review workflow's rubric read a
CLAUDE.mdthat did not exist.CLAUDE.mdnow imports
AGENTS.md, Claude Code's documented way to share one instructions file, so the reviewer and a
local session read the same conventions.
v0.16.0
Added
--record-usage PROVIDER, so llama.cpp usage stops being invisible. The journal's only
write path spoke Ollama's API and hardcodedprovider = 'ollama'in its INSERT, so a machine
serving its models through llama.cpp'sllama-server-- or LM Studio, or vLLM, none of which
speak that format -- had no way in at all.llamacppappeared in this codebase in exactly one
place, theLOCAL_HOSTSlist that labels such a row once some collector has produced one,
and no collector ever did: the usage reached the dashboard only when OpenCode happened to be
proxying it, and--doctorreportedjournal found 0 rowswithout hinting why. The new
command reads a completed OpenAI-compatible response from stdin and journals it under a
provider you name. Three things it will not do: it will not guess the provider, because that
is what decides local-at-a-genuine-zero against a price it would then have to look up; it will
not count cached prompt tokens twice, since OpenAI reports them insideprompt_tokenswhile
this tool keepsinput_tokensandcache_read_tokensapart; and it will not journal a row of
zeros for a streamed response that carried nousage, which is what a request that forgot
stream_options.include_usagegets -- it fails and names the flag instead. Raw server-sent
events pipe in directly, and the response's own id keys the row, so a replay is a no-op.contrib/codecompanion/, which wires that into CodeCompanion -- the way llama.cpp gets
driven from Neovim. It asks for usage on streamed requests and pipes the chunk that carries it
into--record-usage, fire-and-forget, so a missing binary can never interrupt a chat.
Changed
- The
journalsource is now "Local models", not "Ollama". It was never only Ollama's -- it
is the local-model journal, and it now has a second recorder feeding it. The Omarchy record
still writes under the idollama, which is the filename Omarchy's panel reads, but no longer
filters the journal down to rows whose provider is literallyollama: that filter would have
silently dropped every llama.cpp row from the panel.
Security
- rustls 0.23.45, closing RUSTSEC-2026-0285. Earlier rustls accepted a TLS 1.3 handshake
message sent at the wrong encryption level when it shared a record with a key-changing message,
where RFC 8446 §5.1 requires the connection be terminated. The transcript stays authenticated, so
a peer could not alter or complete a handshake -- only send in plaintext what should have been
encrypted without being hung up on. This tool reaches rustls throughreqwest, and only on its
opt-in outbound calls:--refresh-pricing/--refresh-zenand thezen_pricingcollector,
--check-updateand an opted-in--doctor, and a configured budget webhook. Lockfile only; every
install channel built from v0.15.0 carries the affected version, which is why this release exists.
v0.15.0
Added
--check-update, and a timer to run it. The release check used to be reachable only
from an opted-in--doctor, so a user who never ran that never learned a release existed.
--check-updateis the check on its own: a one-shot command in the--refresh-pricing
family that asks GitHub for the latest tag, caches it where the dashboard header reads it,
says whether it is newer than the running build, and exits non-zero when it could neither ask
nor cache. No config key gates it -- the command is the consent, as it is for the refreshes.
contrib/systemd/user/ai-usage-update.{service,timer}run it daily for anyone who wants the
header kept current without running anything by hand. The dashboard process itself still
never makes the request: the periodic writer lives in the schedule the user installed, not in
a background collector, which is why this was deferred and how it is resolved.
--doctorand the command now share one implementation,update::check_and_cache, and the
doctor's "not checked" line names both ways of opting in.
Fixed
- The library's export tests read the developer's own Copilot store and journal. Their
Clipinned Claude Code, Codex and Omarchy to paths under a temp directory and left the
rest at the defaults, so on a machine with~/.copilot/session-store.dbthe JSON export test
printed that machine's real rows and both export tests opened the real routing journal. This
is the gap PR #78 closed intests/cli.rswithhermetic_with; the in-process tests never
went through the binary and were missed. They now build on apinned_clithat names every
source root, and assert that only the fixtures' providers reach the export, so a root added
later without a pin fails the test instead of leaking silently.
v0.14.0
Added
-
A demo you can watch, rendered from data that was never real. The README opens on
docs/assets/demo.gif: the dashboard walked key by key — the routing panel, a sort and its
reverse, a project drill-down and back, the limits panel, the key reference. It is not a
recording.examples/render-screenshots.rstakes a--scriptof key tokens and replays them
throughApp::apply, the dispatch the event loop itself now calls (it was an inlinematch
in the loop, so the demo would otherwise have carried a second copy of what each key does),
writing one SVG per key;scripts/render-readme-screenshots.shrasterises the frames and
assembles the GIF with ImageMagick, skipping it with a warning where that is missing. Every
frame comes from the invented fixture through the same off-screen path as the stills, so the
disclaimer under the image stays true of the moving one. The limits panel is the eighth
still: the fixture generator now writes therate_limitspayload Claude Code would push, and
the script feeds it through the real--statuslineinto the scratch data root, so the panel
and the header's limits line show windows that were made up for the purpose. -
--statusline: Claude Code's rate limits, pushed. Claude Code hands a statusline command
its officialrate_limitsblock on every redraw and again when a window reaches its reset, and
nothing else in this tool is pushed at it —~/.claude.jsonand Omarchy's records are polled
on the dashboard's interval.ai-usage-tui --statuslinereads that payload from stdin, prints
a one-line readout for the status bar (5h 42% (resets 2h 10m) · 7d 63% (resets 3d 4h), in
red past 90%), and caches the windows under the data directory, wherelimits::loadreads them
as a third producer beside the config cache and Omarchy. So thelpanel and--jsoncarry
them on any platform, and Claude Code gets an always-visible readout in the same change.
contrib/claude-code/statusline-settings.jsonis the one-line settings entry; it is a separate
file from the hooks entry so installing one does not install the other.Absence is meaning, four times over. The block is absent on an API-billed account and in
every session before its first response: that is "no such thing here", not 0%, so the line is
empty, the exit is 0 and the cache is left as it was. Each window may be independently absent,
and the cache is rewritten with exactly what is present, so a window that has gone is cleared
from the panel rather than frozen at its last figure. Claude Code drops a window once its
resets_athas passed, so a window behind the clock is dropped at read time, whichever side
of the cache it is on — rendering the last-known percentage after the reset would show a full
bar on an empty window. And a percentage that is not a finite, non-negative number drops its
window rather than becoming one.What is read, and the guarantee around it. From the payload only
rate_limits.{five_hour,seven_day,spend_limit}.{used_percentage,resets_at}; the session id,
transcript path, working directory, model and session cost beside them are never deserialised,
the three windows are struct fields rather than an iterated map, and a test plants a marker in
every one of those places and fails if it reaches the cache, the line or aDebugrendering.
resets_athere is epoch seconds as a number while~/.claude.jsonspells the same instant as
RFC 3339 text, and the two readers are kept separate so neither format is accepted where the
other is meant. The freshness rule is the two-sided one from v0.13.0. One subscription stays one
row: the statusline files under the same agent as the config cache and the fresher reading
wins, at the recorded cost that the statusline carries no per-model weekly window.The line is the product and the cache a by-product, and the exit code says so. Claude Code
shows stdout only from a command that exited 0 and blanks the status line otherwise, with
stderr going to its debug log alone — read from the 2.1.258 bundle, not assumed. So a cache
that cannot be written is said on stderr and in the log and is never an exit code; the non-zero
exit is reserved for stdin that is not the document. The cache's temporary file is named per
process, because unlike every other cache this tool writes, this one has a writer per open
Claude Code session, and two sharing a name would race each other's rename.--doctorgains aLIMITSsection naming where each of the three sources was looked for and,
for the statusline cache, how many windows are live and when the payload arrived; with
[collectors.claude_code] enabled = falseit says "disabled" for the two Claude Code rows
rather than "found" for a file the panel will never read. And the README screenshot renderer
now requiresXDG_DATA_HOMEto name a scratch directory, because the statusline cache has no
flag to pin it and would otherwise have put the author's own rate-limit window into every
image's header — the fourth such leak, caught before it happened rather than after. -
The first launch write-up.
docs/what-a-max-subscription-bought.md: nineteen days of the
author's own Claude Code use at API-equivalent rates — by model, project and day — beside what
this repository shipped in the same period, with the derived escalation rate and the two holes
the method has (sessions with no local transcript; a day of the newest model unpriced). It
says in its first paragraph why the routing panel's own measure, tests passed per dollar per
model, is not in it: the hook was never installed on the measuring machine, and on a Max plan
every attempt isquotaby this tool's own rule. Linked from the README under Write-ups.
Changed
-
The pricing snapshot is current again.
pricing/litellm.tsvwas nine days old and did not
knowclaude-fable-5-1, so every request to it wasquotawith no API-equivalent figure
beside it — a gap that showed up as a hole in the first day's numbers the launch write-up is
built from. Regenerated from upstream withjust pricing: 3,757 keys, of which 347 are new, 219
re-priced and 42 retired upstream. -
Two green checks that meant nothing now mean something. Neither is in the binary; both are
in the release path a user's install depends on.update-tapsverifies what it pushed. The job that keeps the Homebrew tap and the Scoop
bucket current skipped with a warning when a clone failed, so an expiredTAP_TOKENlet a
release succeed whilebrew upgradeandscoop updatewent on serving the previous version
indefinitely — the hazarddocs/release-process.mdhad recorded and left. It still skips with a
notice when the secret is unset, since that is the documented pre-setup state; an expired
token now fails the clone and the job, and after the push each manifest is read back through
the API (not the raw CDN, which caches for minutes) and must name the tag.claude-reviewreviews every push. Its skip rule was "a comment from me already exists",
so the review ran once per pull request, ever: every push after the first was a green check over
an unreviewed diff, and a fix made in response to a finding was the one change guaranteed never
to be looked at. The prompt is now handed the pull request's head sha, each review comment opens
withReviewed <sha>, and the skip rule is "a comment of mine already names this sha" — so a
push gets its own review and the same commit never gets two. The same sha feeds the permalinks,
which used to come fromgit rev-parse HEADon a checkout sitting on the merge commit. -
A poll prices the rows it merged, not the whole history. Every poll of every collector
re-ran the pricing pass over every row ever collected, inside the write lock thatsnapshot()
needs on the render thread — a walk that grew with the history and never changed a result,
since the engine is immutable between reloads and a row is skipped once its status is anything
but unavailable.mergenow reports where its new rows begin and the poll prices from there.
The refresh path is deliberately untouched: a pricing refresh still re-prices everything,
because the rows collected before it are exactly the ones whose price was missing, and that
pass is the one that reaches them. A test holds the two halves together, and the roadmap entry
that recorded the constraint is closed under it.
Fixed
- Two documents that described work as open after it had landed.
docs/routing-analytics.md
spelled the hook'sevent_idwithout the scope segment the code writes
(claude-code:<session_id>:<scope>:<tool_use_id>);docs/roadmap.mdstill filed the quota P1
as open, wrote the statusline route in the future tense, said a push to a pull request would
not be re-reviewed, and recorded the expired-TAP_TOKENhazard #88 removed. Reconciled against
the tree.