Skip to content

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

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

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

Conversation

@sylvaindd

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

Configuration

// opencode.json — unchanged, the recording half needs no configuration
{ "plugin": ["opencode-claude-auth@latest"] }
// 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.

Two placement caveats worth knowing, both documented at the call site: 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

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 returning null rather than a blank snapshot, both state paths, atomic write leaving no .tmp behind, and write deduplication ignoring updatedAt.
  • Full test run: 284 pass / 8 fail on this branch, against 269 pass / 8 fail on a clean main. The 8 failures are identical on both and are pre-existing on Windows — readAllClaudeAccounts, writeBackCredentials (file source) and CLAUDE_CONFIG_DIR support, which shell out to the macOS security binary. Net effect of this branch is +15 tests, no change in failures. Worth a second look on Linux/macOS CI.
  • 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.
  • tsc --noEmit clean; oxlint reports 0 warnings and 0 errors on the changed files.
  • tui/smoke.ts renders 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 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.

Caveat on make all: I could not run it. There is no make on this machine, and pnpm run build / clean use rm -rf, which is not available to npm scripts on Windows. I ran the three underlying steps individually instead — tsc for build, oxlint for lint, node --test for test. Note also that oxfmt --check . fails repo-wide here (38 files), including on an untouched main, purely because core.autocrlf=true gives the working copy CRLF endings; it is not related to this branch and will not reproduce on CI.

Checklist

  • PR title follows Conventional Commits
  • make all passes locally — could not run on Windows; equivalent steps run individually, see the caveat above
  • Tests added or updated where applicable
  • README or docs updated where applicable — happy to add a README section if you want this merged; left out pending your view on whether the TUI half belongs in this package at all.

Sylvain DUARTE and others added 3 commits July 31, 2026 16:43
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-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown

Greptile Summary

This PR surfaces Claude subscription quota in the OpenCode TUI by reading the anthropic-ratelimit-unified-* headers that Anthropic already returns on every /v1/messages response, writing a small JSON snapshot to disk, and rendering it in a new opt-in TUI plugin — adding no extra API calls.

  • src/usage.ts parses quota headers, atomically writes a snapshot via .tmp→rename, and deduplicates writes by comparing content without updatedAt.
  • tui/claude-usage.tsx polls the snapshot file every 5 s and renders per-slot readouts using Solid.js signals; a separate 30-second clock drives the countdown display for the 5-hour window.
  • src/index.ts calls recordUsageFromHeaders after all retry/recovery logic, so the recorded numbers reflect the response that actually counted against quota.

Confidence Score: 4/5

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

Important Files Changed

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)
Loading

Reviews (2): Last reviewed commit: "test: make the TUI smoke test self-conta..." | 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 2 commits July 31, 2026 16:56
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>
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.

1 participant