feat: display Claude subscription quota in the TUI - #262
Conversation
Greptile SummaryThis PR surfaces the Claude subscription quota inside the OpenCode TUI by reading
Confidence Score: 5/5Safe to merge — the quota recording path is wrapped in a top-level try/catch that can never surface to callers, and the TUI half is entirely opt-in. The two previously identified bugs (countdown signal with no subscriber, smoke test that could not fail) are both fixed. The recordUsageFromHeaders call is placed after all retry loops on a response already committed to the caller, so it cannot affect request handling. The atomic write prevents the TUI from seeing a torn snapshot. Only a minor style inconsistency in repeated signal accessor calls remains. Files Needing Attention: No files require special attention. The style note on tui/claude-usage.tsx is non-blocking.
|
| Filename | Overview |
|---|---|
| src/usage.ts | New module: parses anthropic-ratelimit-unified-* headers, atomically writes a JSON snapshot, and deduplicates writes by ignoring updatedAt changes. Logic is correct and well-guarded. |
| src/usage.test.ts | Fifteen new tests covering header parsing, write deduplication, atomic write, and both the quota-present and quota-absent paths. Fixtures redirect HOME/USERPROFILE to a temp dir to avoid touching developer state. |
| tui/claude-usage.tsx | TUI plugin that polls the JSON snapshot every 5 s and renders quota via Solid.js signals. Previously flagged bugs (countdown signal, smoke test exit code) are fixed. Minor style inconsistency: fiveHour()/sevenDay() called multiple times per render without memoization, unlike the fiveHourCountdown memo. |
| src/index.ts | Adds recordUsageFromHeaders() call on the final response (after all auth-recovery and beta-retry loops), and exports the new usage API. Placement is correct — reads from the response that actually counted against quota. |
| tui/smoke.ts | Smoke test now seeds a synthetic snapshot in a temp HOME, tests both no-snapshot (renders nothing) and seeded (renders readout) phases, and sets process.exitCode = 1 on failure so CI sees a non-zero exit code. |
| package.json | Pins @opencode-ai/plugin to 1.15.13 and adds @opentui/core, @opentui/keymap, @opentui/solid, and solid-js as devDependencies to support the new TUI plugin. |
Sequence Diagram
sequenceDiagram
participant OC as OpenCode (server process)
participant Interceptor as fetch interceptor (src/index.ts)
participant Usage as src/usage.ts
participant FS as claude-usage.json
participant TUI as TUI process (tui/claude-usage.tsx)
OC->>Interceptor: fetch("/v1/messages", ...)
Interceptor->>Anthropic: HTTP request
Anthropic-->>Interceptor: "200 OK + anthropic-ratelimit-unified-* headers"
Interceptor->>Usage: recordUsageFromHeaders(response.headers)
Usage->>Usage: parseUsageHeaders() snapshot
Usage->>FS: writeFileSync(.tmp) renameSync(.tmp claude-usage.json)
Interceptor-->>OC: transformResponseStream(response)
loop every 5 seconds
TUI->>FS: readFileSync(claude-usage.json)
FS-->>TUI: UsageSnapshot JSON
TUI->>TUI: setSnapshot(parsed) if changed
TUI->>TUI: re-render 5h 26% 2h13m 7d 19%
end
loop every 30 seconds
TUI->>TUI: setTick(n+1) fiveHourCountdown memo recalculates
end
Reviews (3): Last reviewed commit: "docs: document the quota display" | Re-trigger Greptile
Types for the TUI plugin added in a follow-up commit. `@opencode-ai/plugin` moves off the `latest` tag because that tag currently resolves to 1.2.27, which predates the `./tui` export the plugin types live behind. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Anthropic returns the `anthropic-ratelimit-unified-*` family on every /v1/messages response under subscription auth, carrying 5h and 7d utilization and their reset times. The fetch interceptor already holds those responses, so recording the quota costs no extra API call. Read from the response that is actually returned, after the 401, rotation and long-context retries, so the numbers match the call that counted rather than an attempt that was discarded. A response without the family yields null instead of a blank snapshot, since count_tokens calls and validation errors carry no quota headers and would otherwise erase a good reading. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
A TUI plugin reads the snapshot the server half writes and renders it as `5h 46% 2h29m - 7d 15%`. The two halves are separate modules in separate processes and cannot share state directly, so they meet at a JSON file. Placement is configurable because the trade-offs are visual rather than technical; it defaults to `sidebar_title`, the only slot above the Context section. That slot is single_winner, so the plugin re-renders the session title it displaces. A window whose reset time has passed is re-derived as empty, which is what lets the recording side get away with reading only from responses: an idle session sends none, but its window still empties. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
The clock signal's getter was discarded, so nothing subscribed to it and setting it scheduled no re-render. The countdown therefore only moved when a response happened to rewrite the snapshot — never on the idle session the clock exists for. The countdown is now a memo that reads the tick, which also gives it a single evaluation per render. It was previously computed twice in one JSX expression, once to test for a value and once to display it, so a minute boundary falling between the two calls would render the literal text `null`. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
It rendered against the real snapshot file, so it asserted on whatever quota the developer's machine happened to hold and failed outright on a machine that had never run the plugin. It now seeds a synthetic snapshot in a temporary HOME and asserts against that. It also could not fail: a rejection from the top-level await left the exit code at zero, so every assertion in it was decorative. Failures now set a non-zero exit code, which is what makes the two added checks worth anything — that no slot renders a quota when no snapshot exists, and that the countdown never renders as the literal text `null`. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Covers what is recorded automatically versus what has to be opted into from tui.json, the slot and label options, and the two caveats a user cannot infer: that `sidebar_title` and `sidebar_footer` are single_winner slots and so replace what OpenCode draws there, and that a window past its reset shows as empty rather than at its last recorded value. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
abf633a to
1e4bee1
Compare
|
Thanks — that's exactly the kind of account shape I can't see from here (I'm on a Max subscription, single seat). The normal-team-seat behaviour you describe is by design: the readout renders each window only if its headers are present, so an account with no weekly limit shows just the 5h one rather than a For the Premium seat's Fable-scoped weekly, I need one thing before implementing, because it decides the design: Does that limit appear in the This PR deliberately reads only the response headers, so recording costs no extra request. I know the usage endpoint exposes scoped weeklies — it returns a If you're willing, this prints just the rate-limit headers from a 1-token request (no secrets in the output, costs a negligible slice of your 5h window): curl -sS -D /dev/stderr -o /dev/null https://api.anthropic.com/v1/messages \
-H "authorization: Bearer $(jq -r '.claudeAiOauth.accessToken' ~/.claude/.credentials.json)" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: oauth-2025-04-20" \
-H "content-type: application/json" \
-d '{"model":"claude-haiku-4-5","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' \
2>&1 | grep -i 'anthropic-ratelimit-unified'If the Fable weekly is in there under some bucket name, the fix is better than a special case: I'd parse every |


Summary
Surfaces the Claude subscription quota inside OpenCode, the way Claude Code's own status line does —
5h 46% 2h29m · 7d 15%.Anthropic returns the
anthropic-ratelimit-unified-*header family on every/v1/messagesresponse under subscription auth, carrying 5h and 7d utilization plus their reset times. It is the same source Claude Code's status line reads. This plugin's fetch interceptor already holds those responses, so recording the quota costs no extra API call and no quota.Two halves:
src/usage.tsparses the headers and writes a small JSON snapshot next toauth.json. Called from one place in the existing interceptor, after the 401/rotation/long-context retries, so the numbers match the response that actually counted rather than an attempt that was discarded.tui/claude-usage.tsxis a TUI plugin that reads that snapshot and renders it. A server plugin and a TUI plugin are separate modules in separate processes and cannot share state directly, so they meet at the file.Deliberately not used:
GET /api/oauth/usage. It returns the same numbers, but polling it spends a request per refresh on an undocumented endpoint to learn what responses already carry. The reader instead re-derives a window whose reset time has passed as empty — which is what that poll would have been for, since an idle session sends no requests but its window still empties.The TUI half is opt-in: it does nothing unless the user adds it to
tui.json, and the server half works standalone with no configuration.Configuration
slotsaccepts any ofsidebar_title(default — the only slot above the Context section),sidebar_content,sidebar_footer,session_prompt_right,home_prompt_right. Documented in the README, including the two caveats a user cannot infer:sidebar_titleandsidebar_footeraresingle_winnerslots, so choosing them displaces the host's title block (the plugin re-renders it) or OpenCode's own footer line respectively.Related issue
None.
Testing
Rebased onto
mainat v2.1.6.make allpasses —lint,buildandtestgreen, 347 tests, 0 failures. Run on Linux (Ubuntu 24.04); see the note below on why not on my primary machine.src/usage.test.ts— 15 new tests covering header parsing (both windows, fraction units, missing reset, rejection, non-numeric utilization, overage present/absent), the quota-less response returningnullrather than a blank snapshot, both state paths, atomic write leaving no.tmpbehind, and write deduplication ignoringupdatedAt.usage.tstoSOURCE_FILESinindex.test.tsis required — the existing suite copies sources to a temp dir, and without it the new import fails to resolve.tui/smoke.tsrenders every placement through OpenTUI's real Solid transform against a synthetic snapshot in a temporaryHOME, asserting that no slot shows a quota when no snapshot exists, that every slot shows one when it does, that the countdown never renders as the literal textnull, and that the heading appears only on the sidebar placements. Run withbun tui/smoke.ts.opencode runrepeatedly and watched the recorded utilization track real consumption (24% → 25% → 26% → 41% → 51%), withstatus,representativeClaimand the overage pool populated from real responses. Then confirmed the readout rendering in a real OpenCode TUI session.Windows note, in case it is useful to you: I develop on Windows and
make allcannot pass there, for three reasons that are all environmental rather than related to this branch.makeis not present;buildandcleancallrm -rf, which npm scripts cannot run undercmd.exe; andoxfmt --check .fails on every file whencore.autocrlf=truegives the working copy CRLF endings — that last one reproduces on an untouchedmain. There are also 8 test failures on Windows (readAllClaudeAccounts,writeBackCredentials (file source),CLAUDE_CONFIG_DIR support) which shell out to the macOSsecuritybinary and likewise fail on an untouchedmain. A.gitattributeswith* text=auto eol=lfwould fix the formatting half of that if you would find it useful — happy to open a separate PR, but it is out of scope here.Checklist
feat:,fix:,docs:,chore:, etc.)make allpasses locally (runs lint, build, and test)