feat: display Claude subscription quota in the TUI - #262
Conversation
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>
Greptile SummaryThis PR surfaces Claude subscription quota in the OpenCode TUI by reading the
Confidence Score: 4/5Safe to merge for the recording half; the TUI display has one correctness gap that should be addressed before the plugin is widely used. The server-side recording path is solid — atomic writes, null-safe header parsing, and well-isolated tests. The TUI display has a reactivity gap: fiveHour() and sevenDay() in JSX only subscribe to the snapshot signal, not to the 30-second tick. When a window's reset time passes on an idle session, the countdown correctly disappears (the memo tracks tick), but the percentage stays frozen at the pre-expiry value until the next API call updates the snapshot file. Files Needing Attention: tui/claude-usage.tsx — the fiveHour/sevenDay accessor definitions need to also call tick() so the percentage display updates when windows expire on idle sessions.
|
| Filename | Overview |
|---|---|
| src/usage.ts | New module that parses anthropic-ratelimit-unified-* headers and persists a JSON snapshot atomically; dedup cache is updated even on write failure, which can suppress retries |
| tui/claude-usage.tsx | New TUI plugin that renders quota readouts; fiveHour/sevenDay accessors in JSX only track snapshot(), so the percentage display is stale (not tick-driven) after a window expires on an idle session |
| src/usage.test.ts | 15 new tests covering header parsing, both state paths, atomic write, and dedup; all pass cleanly alongside existing suite |
| src/index.ts | Adds recordUsageFromHeaders call after all retry/recovery logic; placement is correct — reads the final response that actually counts against quota |
| tui/smoke.ts | Smoke test now seeds a synthetic snapshot before the seeded phase and checks both the no-snapshot and seeded paths; previous thread concerns are resolved |
| package.json | Pins @opencode-ai/plugin to 1.15.13 and adds opentui/solid stack; solid-js installed at 1.9.14 conflicts with @opentui/solid's exact 1.9.12 peer requirement |
Sequence Diagram
sequenceDiagram
participant OC as OpenCode (server process)
participant Plugin as claude-auth plugin
participant Anthropic as Anthropic API
participant File as claude-usage.json
participant TUI as TUI process
participant Timer as 30s tick / 5s poll
OC->>Plugin: fetch() (auth loader)
Plugin->>Anthropic: POST /v1/messages
Anthropic-->>Plugin: "response + anthropic-ratelimit-unified-* headers"
Plugin->>Plugin: recordUsageFromHeaders(headers)
Plugin->>Plugin: "parseUsageHeaders() -> UsageSnapshot"
Plugin->>File: writeFileSync(.tmp) + renameSync (atomic)
Plugin-->>OC: transformResponseStream(response)
Timer->>TUI: poll every 5s
TUI->>File: readSnapshot()
File-->>TUI: UsageSnapshot (or null if stale/missing)
TUI->>TUI: setSnapshot(next) if changed
Timer->>TUI: tick every 30s
TUI->>TUI: fiveHourCountdown memo re-runs
TUI->>TUI: currentWindow() checks Date.now() vs resetsAt
TUI-->>TUI: countdown display updates (or hides if expired)
Reviews (2): Last reviewed commit: "test: make the TUI smoke test self-conta..." | Re-trigger Greptile
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>
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.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.Configuration
slotsaccepts any ofsidebar_title(default — the only slot above the Context section),sidebar_content,sidebar_footer,session_prompt_right,home_prompt_right.Two placement caveats worth knowing, both documented at the call site:
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
Developed and verified on Windows, which is worth stating up front because it limits what I could run — see the caveat below.
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.main. The 8 failures are identical on both and are pre-existing on Windows —readAllClaudeAccounts,writeBackCredentials (file source)andCLAUDE_CONFIG_DIR support, which shell out to the macOSsecuritybinary. Net effect of this branch is +15 tests, no change in failures. Worth a second look on Linux/macOS CI.usage.tstoSOURCE_FILESinindex.test.tsis required — the existing suite copies sources to a temp dir, and without it the new import fails to resolve.tsc --noEmitclean;oxlintreports 0 warnings and 0 errors on the changed files.tui/smoke.tsrenders every placement through OpenTUI's real Solid transform and asserts each frame actually contains the readout, 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.Caveat on
make all: I could not run it. There is nomakeon this machine, andpnpm run build/cleanuserm -rf, which is not available to npm scripts on Windows. I ran the three underlying steps individually instead —tscfor build,oxlintfor lint,node --testfor test. Note also thatoxfmt --check .fails repo-wide here (38 files), including on an untouchedmain, purely becausecore.autocrlf=truegives the working copy CRLF endings; it is not related to this branch and will not reproduce on CI.Checklist
make allpasses locally — could not run on Windows; equivalent steps run individually, see the caveat above