Skip to content

Releases: SophanaSok/ai-usage-tui

v1.1.0

Choose a tag to compare

@github-actions github-actions released this 18 Sep 23:01
eaa6681

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 | tail exits with tail's status. But on the author's machine every
    test command is trimmed through grep, tail or head so 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, because grep succeeded -- 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's error: test failed for cargo test, and the equivalents for
    pytest, go test and deno 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 a head filled 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). --doctor prints it under
    CLAUDE CODE beside the number recorded; the routing panel's title carries the total;
    --routing-json and --summary-json gain a routing.withheld block (runs, and by_reason
    with pipe, 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-journal ages the tally out with
    the rest.
  • just check is a recognised test runner, as make check already was.
  • scripts/redact-hook-payload.py and CONTRIBUTING.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

Choose a tag to compare

@github-actions github-actions released this 18 Sep 21:55
3d4fb52

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.md described a v0.13.0 tool on 2026-09-02, and two things
    in it had stopped being true: the --claude-code-hook it 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

Choose a tag to compare

@github-actions github-actions released this 18 Sep 20:13
eb264e8

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 powershell always 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 AT column gives the
    reset on the local clock -- Fri 14:00, or Sep 25 14:00 once 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-json has a build block: 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 --doctor knew that no JSON document said. The check is read
    and never made; build.update: null means 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 every token_count event 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-json and
    --doctor (a codex row under LIMITS) 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.py answers the Responses API on localhost and the real CLI writes a
    real rollout (tests/fixtures/codex_capture, recipe in docs/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 last rate_limits in the file" can be a
    2%-used one-hour window for some other limit, on every line. Readings are keyed on limit_id.

  • Codex rollouts the CLI has compressed are read. With local_thread_store_compression on
    (off by default as of codex-cli 0.155.0) every rollout untouched for a week becomes
    <name>.jsonl.zst and 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, then WT_SESSION, then TERM -- and
    anything short of 24-bit has the finished frame mapped down in a single pass, as NO_COLOR
    is, so a new panel cannot forget it. 256 colours take each colour's nearest neighbour; this
    tier exists because ssh and sudo drop COLORTERM and keep TERM. 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.
    --doctor prints the depth chosen and the variable it came from. If a terminal that does
    support 24-bit colour looks flatter than it did, set COLORTERM=truecolor.

  • --install-hook and --install-statusline put this tool into Claude Code's settings;
    --uninstall-hook, --uninstall-statusline and --uninstall take it out.
    Routing
    analytics, the feature nothing else here has, sat behind a hand-run jq -s '.[0] * .[1]'
    merge into ~/.claude/settings.json -- and jq's * replaces arrays, so a user with any
    other PostToolUse hook 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 -- its env block 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 bare ai-usage-tui
    when a binary of that name is on PATH and the running binary's absolute path when not, and
    the report says which, because the two age differently. --install-statusline refuses to
    replace another program's status line and names it; --uninstall-statusline leaves one that is
    not this tool's. --uninstall removes both entries and the caches docs/stability.md calls the
    tool's own -- a test holds the two lists together -- then prints the journal's and the config
    file's paths with the rm that would delete them, and does not run it: those are the user's.
    --doctor gained a CLAUDE CODE section, asked of the same detector the installers use, so
    what it calls installed is exactly what --uninstall-hook removes; 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.md now name the commands first and keep the hand merge as the
    alternative.

  • --prune-journal DAYS deletes old journal rows and hands the space back; --doctor says
    how big the journal is.
    usage.db had no retention and no VACUUM, and --doctor counted
    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 a VACUUM that fails exits 2 with 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. So install.sh installed something that
    answered version `GLIBC_2.39' not found on Debian 12, Ubuntu 22.04 and RHEL 9; the .deb
    declared libc6 (>= 2.39) and apt refused it there; the .rpm declared 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 with file and
    run on a bare Alpine in the release build. install.sh takes them (--libc gnu for the other),
    as do cargo binstall on a musl host and the Homebrew formula on Linux, and the .deb and
    .rpm are 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 -- and reqwest puts 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 --doctor prints
    its mode and the chmod 600 that fixes it, and does not run it.

...

Read more

v0.20.0

Choose a tag to compare

@github-actions github-actions released this 17 Sep 23:33
d06b9eb

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.txt comes from the same place as the
    archive, so whoever could replace one could replace both. From the next release every archive
    and every .deb and .rpm carries 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.yml and 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 ships ai-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 .deb and the
    bill-of-materials predicate verify; a tampered copy, another workflow and the wrong ref do not.
  • install.sh checks 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-attestation makes "not checked" fatal as well, and --no-attestation skips the
    step for whoever has a reason to.

Changed

  • A failure exits 2; 1 now means only that a budget is over. Every failure and a
    breached budget shared one exit code, so the scheduled --check-budgets this tool tells people
    to run could not tell "you are over" from "your config does not parse" -- the recipe shipped in
    --agent-guide recipes parsed stdout with jq to 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 to grep and diff: 0 fine, 1 the check said no, 2 trouble -- a flag the tool
    does not know, a config or source it could not read, a write that did not happen. The breach
    keeps 1, the number the README documented, and docs/stability.md had 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.
    --help and the man page gained an EXIT STATUS section, and the budget recipe is three lines
    shorter. One exception, from a capture and not from documentation: on Claude Code 2.1.275 a
    PostToolUse hook that exits 2 has its stderr given to the model as something to act on, and
    one that exits 1 does not -- so a failed --claude-code-hook still exits 1, 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 now owner/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
    granted contents: write to 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.yml and the two Claude workflows
    declare their permissions instead of inheriting a setting. Two tests hold this: one fails for
    any uses: 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. main is 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.sh runs on release/vX.Y.Z before the pull
    request and again on main before the tag, where it also refuses a main that 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 --publish captures the list plan prints, and plan printed 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

Choose a tag to compare

@github-actions github-actions released this 17 Sep 22:01

Added

  • --agent-guide recipes and --agent-guide extend: an agent can build on the data, and cover
    what the tool lacks.
    recipes says what is stable enough to script against and what is not,
    carries the reading rules into code (.cost // 0 in jq turns an unknown into a zero nobody can
    see; SQLite sums an empty CSV field as 0), 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 only jq is run by the test
    suite, as written, through a shim that pins every source -- a recipe that fails on real output,
    or prints null where it promised a value, fails the build. extend routes 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 pasted AGENTS.md block 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-guide takes
    an optional topic. Bare it prints what it always has, byte for byte -- every installed skill and
    pasted AGENTS.md block 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. setup covers 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 no contrib/ 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 --doctor says 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 with jq'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-event reads usage in this tool's own terms, one JSON
    object per line: provider, model, input_tokens, output_tokens, one of event_id or
    created, and optionally the rest of the token split, project, session_id, a cost the
    tool itself recorded (kept as reported, never re-estimated) or "billing": "subscription"
    (a quota row with api_equivalent_cost beside it, on the same path a native collector's
    takes). A few lines of jq over 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
    no cost_status but reported can be supplied, so this tool never vouches for arithmetic it
    did not see. A supplied event_id is stored as event:<provider>:<id>, since identities share
    one namespace across sources.

    The journal's usage_event gains 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 stays 1: 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.yml regenerates pricing/litellm.tsv when 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-pricing a 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-pricing updates 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 --doctor and 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. --doctor prints when each bundled table was
    cut, and --summary-json's pricing block gains currency (USD, list price, nothing
    converted), community_table_date and curated_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.md called it "two files" and listed the other six
    thirty lines later; its fixture command and just run both 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-event do), 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_source runs both documented commands as
    written and asks --doctor where each source resolved. AGENTS.md is 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 covers AGENTS.md and 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 --once refuses -- and a list in a test passes for the entry nobody
    added to it: Gemini was billing-capable and outside the billing check; --record-usage and
    --statusline were outside the --once check. 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: --doctor under 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 ~/.copilot and ~/.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-sol from $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.0 is FREE again. 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. So llama-3.3-70b-instruct-turbo-free, and every other free model the
    table knew about, became `PAID...
Read more

v0.18.0

Choose a tag to compare

@github-actions github-actions released this 17 Sep 19:10

Added

  • --summary-json: the whole picture in one compact document. --json prints 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-hour burn rate,
    every budget including the ones still OK, limits, escalations, provenance and routing,
    plus what only --doctor's text carried -- sources (rows found, status, the billing decision,
    skipped data) and pricing warnings. --top N (default 10) lists the largest models, projects
    and sessions and folds the rest into other, 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.
    Each by_model row carries list_input_rate, and each escalation from_input_rate and
    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 is null, not 0 (several sources never report cache or reasoning
    tokens), and cost is null when nothing in a bucket could be priced.
  • --schema and --agent-guide: the output explains itself, from the binary. The meanings
    of the JSON lived in README prose, docs/data-model.md and 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, and docs/data-model.md spelled the categories in lower case while
    the exports print them in upper. --schema prints a JSON glossary of every key of every
    document and every value of every closed vocabulary, with its type, whether it can be null,
    and what it means. It cannot drift: a test walks real --summary-json, --json,
    --routing-json and --check-budgets output against it and fails on any key, enum value or
    null it does not describe, and another holds each vocabulary to the labels the code prints.
    --agent-guide prints the guide for an LLM agent: start with the summary, drill down with the
    filters, the reading rules (null is never 0, cost can be a floor, quota is real cost with
    no figure, api_equivalent_cost was 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 ships docs/, 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-guide and --summary-json, so the instructions
    always match the installed version and the skill never needs updating; it pre-approves
    ai-usage-tui commands 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.md is the same three lines for AGENTS.md,
    .cursorrules or 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 every cost_basis value and what it
    does to cost_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 PATH and --session ID filter 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.
  • billing in each --json row (per_token / subscription), which the data model
    documented and no export carried, and success_rate in --routing-json, which the panel
    showed and the export left to the reader to divide.

Changed

  • --routing-json honours 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_model are 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 AUR PKGBUILD all
    named the source repository as their homepage, and --help, the man page, --doctor and 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
    from Cargo.toml's homepage like the description is; --help and the man page end with a
    MORE: block naming the site and the repository; --doctor lists 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.documentation names the site too, so crates.io's Documentation link goes to the
    user documentation rather than to docs.rs for a library API docs/stability.md says 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-release uploaded 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.sh now 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 fake gh and curl in CI, where
    removing the stuck-asset deletion or letting a failed asset check fall through both fail the test.

v0.17.0

Choose a tag to compare

@github-actions github-actions released this 17 Sep 17:02

Added

  • A journal schema version. Writers stamp PRAGMA user_version and 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. See docs/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-config prints the annotated example configuration, which is now in the binary.
    --doctor used 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.toml redirect.

  • 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": 1 in every JSON document -- --json, --routing-json and
    --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.md promised no unsafe
    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 as q; 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 pressing q during a
    rate-limited zen_pricing fetch 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_pricing collector 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-pricing still prints it.

  • --record-ollama, --record-usage, --record-routing and --claude-code-hook survive a
    closed stdout.
    Each confirmed with a bare println! 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 with Err(_) => continue and 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 one collector::skipped
    type and implement Collector::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) skipped and the header is marked degraded. The one-shot status carries the same note plus the
    first unreadable path and error into --once, --json and --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.json reader counted entries of an unknown kind, and nothing read the count, so a
    window Claude Code added upstream vanished from the Limits panel, --json and --doctor alike.
    It is now a limits problem -- on the status line, and in a problem row 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-ALTER with no lock held between the two, so writers that opened a journal from
    before event_id together -- 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 under BEGIN 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 fixed json.tmp / toml.tmp on the belief that only the dashboard wrote them; in fact
    two dashboards each run zen_pricing, and a scheduled --check-update can 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 one helpers::write_atomic, which names the
    temporary per process and removes it when the rename fails -- the rule --statusline already
    followed.

  • A new install no longer opens on a blank table. With no rows the default panel drew a header
    over nothing beside tiles reading 0 -- 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.md listed the network calls as --refresh-zen, --refresh-pricing and the budget
    webhook
    , omitting --check-update and 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.md that did not exist. CLAUDE.md now 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

Choose a tag to compare

@github-actions github-actions released this 17 Sep 15:41

Added

  • --record-usage PROVIDER, so llama.cpp usage stops being invisible. The journal's only
    write path spoke Ollama's API and hardcoded provider = 'ollama' in its INSERT, so a machine
    serving its models through llama.cpp's llama-server -- or LM Studio, or vLLM, none of which
    speak that format -- had no way in at all. llamacpp appeared in this codebase in exactly one
    place, the LOCAL_HOSTS list 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 --doctor reported journal found 0 rows without 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 inside prompt_tokens while
    this tool keeps input_tokens and cache_read_tokens apart; and it will not journal a row of
    zeros for a streamed response that carried no usage, which is what a request that forgot
    stream_options.include_usage gets -- 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 journal source 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 id ollama, which is the filename Omarchy's panel reads, but no longer
    filters the journal down to rows whose provider is literally ollama: 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 through reqwest, and only on its
    opt-in outbound calls: --refresh-pricing / --refresh-zen and the zen_pricing collector,
    --check-update and 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

Choose a tag to compare

@github-actions github-actions released this 03 Sep 17:37

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-update is 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.
    --doctor and 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
    Cli pinned 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.db the 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 in tests/cli.rs with hermetic_with; the in-process tests never
    went through the binary and were missed. They now build on a pinned_cli that 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

Choose a tag to compare

@github-actions github-actions released this 02 Sep 22:18

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.rs takes a --script of key tokens and replays them
    through App::apply, the dispatch the event loop itself now calls (it was an inline match
    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.sh rasterises 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 the rate_limits payload Claude Code would push, and
    the script feeds it through the real --statusline into 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 official rate_limits block on every redraw and again when a window reaches its reset, and
    nothing else in this tool is pushed at it — ~/.claude.json and Omarchy's records are polled
    on the dashboard's interval. ai-usage-tui --statusline reads 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, where limits::load reads them
    as a third producer beside the config cache and Omarchy. So the l panel and --json carry
    them on any platform, and Claude Code gets an always-visible readout in the same change.
    contrib/claude-code/statusline-settings.json is 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_at has 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 a Debug rendering.
    resets_at here is epoch seconds as a number while ~/.claude.json spells 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.

    --doctor gains a LIMITS section 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 = false it 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 requires XDG_DATA_HOME to 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 is quota by this tool's own rule. Linked from the README under Write-ups.

Changed

  • The pricing snapshot is current again. pricing/litellm.tsv was nine days old and did not
    know claude-fable-5-1, so every request to it was quota with 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 with just 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-taps verifies 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 expired TAP_TOKEN let a
    release succeed while brew upgrade and scoop update went on serving the previous version
    indefinitely — the hazard docs/release-process.md had 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-review reviews 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
    with Reviewed <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 from git rev-parse HEAD on 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 that snapshot()
    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. merge now 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's event_id without the scope segment the code writes
    (claude-code:<session_id>:<scope>:<tool_use_id>); docs/roadmap.md still 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_TOKEN hazard #88 removed. Reconciled against
    the tree.