Skip to content

feat: display Claude subscription quota in the TUI - #262

Open
sylvaindd wants to merge 6 commits into
griffinmartin:mainfrom
sylvaindd:feat/claude-quota-readout
Open

feat: display Claude subscription quota in the TUI#262
sylvaindd wants to merge 6 commits into
griffinmartin:mainfrom
sylvaindd:feat/claude-quota-readout

Conversation

@sylvaindd

@sylvaindd sylvaindd commented Jul 31, 2026

Copy link
Copy Markdown

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/messages response 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.ts parses the headers and writes a small JSON snapshot next to auth.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.tsx is 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

// tui.json — opt in to the display
{
  "plugin": [
    ["opencode-claude-auth/tui", { "slots": ["sidebar_title"], "label": "Claude quota" }]
  ]
}

slots accepts any of sidebar_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_title and sidebar_footer are single_winner slots, so choosing them displaces the host's title block (the plugin re-renders it) or OpenCode's own footer line respectively.

Note: this branch ships the TUI entry as tui/claude-usage.tsx but does not add a ./tui export to package.json, since publishing a TUI entry point is a packaging decision for you rather than for this PR. Locally it is loaded by absolute path. Happy to add the export and a build step for it if you want it shipped on npm.

Related issue

None.

Testing

Rebased onto main at v2.1.6.

  • make all passeslint, build and test green, 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 returning null rather than a blank snapshot, both state paths, atomic write leaving no .tmp behind, and write deduplication ignoring updatedAt.
  • Adding usage.ts to SOURCE_FILES in index.test.ts is required — the existing suite copies sources to a temp dir, and without it the new import fails to resolve.
  • tui/smoke.ts renders every placement through OpenTUI's real Solid transform against a synthetic snapshot in a temporary HOME, 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 text null, and that the heading appears only on the sidebar placements. Run with bun tui/smoke.ts.
  • End-to-end against a live Max account: ran opencode run repeatedly and watched the recorded utilization track real consumption (24% → 25% → 26% → 41% → 51%), with status, representativeClaim and 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 all cannot pass there, for three reasons that are all environmental rather than related to this branch. make is not present; build and clean call rm -rf, which npm scripts cannot run under cmd.exe; and oxfmt --check . fails on every file when core.autocrlf=true gives the working copy CRLF endings — that last one reproduces on an untouched main. There are also 8 test failures on Windows (readAllClaudeAccounts, writeBackCredentials (file source), CLAUDE_CONFIG_DIR support) which shell out to the macOS security binary and likewise fail on an untouched main. A .gitattributes with * text=auto eol=lf would 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

  • PR title follows Conventional Commits (feat:, fix:, docs:, chore:, etc.)
  • make all passes locally (runs lint, build, and test)
  • Tests added or updated where applicable
  • README or docs updated where applicable

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown

Greptile Summary

This PR surfaces the Claude subscription quota inside the OpenCode TUI by reading anthropic-ratelimit-unified-* headers that are already present on every /v1/messages response — no extra API call, no quota cost.

  • src/usage.ts parses the header family into a typed snapshot, atomically writes it to claude-usage.json (via temp-file rename), and deduplicates writes by ignoring updatedAt-only changes.
  • tui/claude-usage.tsx is an opt-in Solid.js TUI plugin that polls the snapshot file every 5 s and renders 5h/7d utilization with configurable slot placement. The previously flagged countdown-signal bug and smoke-test exit-code bug are both fixed in this version.
  • src/usage.test.ts adds 15 tests covering header parsing, atomic write, deduplication, and both quota-present and quota-absent paths.

Confidence Score: 5/5

Safe 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.

Important Files Changed

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
Loading

Reviews (3): Last reviewed commit: "docs: document the quota display" | Re-trigger Greptile

Comment thread tui/claude-usage.tsx Outdated
Comment thread tui/smoke.ts Outdated
Comment thread tui/claude-usage.tsx Outdated
Sylvain DUARTE and others added 6 commits August 4, 2026 09:34
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>
@sylvaindd
sylvaindd force-pushed the feat/claude-quota-readout branch from abf633a to 1e4bee1 Compare August 4, 2026 07:38
@carson2222

Copy link
Copy Markdown

One issue i nit. Claude's "team" seats work in a bit different way. Normal ones have just 5h limit (not weekly at all) - this is correctly handled, we see only the 5h one. Tho the "Premium" team seat does have a weekly limit (for fable only, no general weekly one), if possible it might be displayed too.

image image

@sylvaindd

Copy link
Copy Markdown
Author

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 7d 0% that would be a lie. Good to have that confirmed against a real seat.

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 anthropic-ratelimit-unified-* response headers, or only in GET /api/oauth/usage?

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 limits[] array with kind: "weekly_scoped" and a scope.model — but if the Fable weekly is endpoint-only, supporting it means reintroducing a poll I removed on purpose, which is the maintainer's call rather than mine.

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 anthropic-ratelimit-unified-<bucket>-utilization / -reset pair generically instead of hardcoding 5h and 7d, so any scoped window Anthropic exposes — now or later — gets picked up without another patch. The readout would then show whichever buckets your account actually has.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants