feat(opencode): quota sidebar on OpenCode 2

Merged
TO
7h ago
+1992-899

Why

On OpenCode 2 there is no quota sidebar: docs/sidebar.md says the live sidebar is OpenCode 1 only, and the generation-2 TUI only registers /claude and toasts. Users moving to OpenCode 2 lose the per-account 5h/7d bars, pacing, routing and health at a glance.

What changes

Server (OpenCode 2)

  • New sidebar method on the existing cortexkit-anthropic-native RPC (id and other methods unchanged). Output is validated with normalizeSidebarState, so the boundary fails closed to the empty state.
  • v2/sidebar.ts projects the NativeAccountSnapshot to the same SidebarState OpenCode 1 reads, with OpenCode 1's field rules: state lines, tiers, quota/refresh backoffs (matched to the account identity), permanent-refresh re-sign-in, routing mode, fast mode, effective relay, cache keepalive and Prime. Vault rows stay listed when turned off and carry OpenCode 1's custodyState, so the shared renderer shows "turned off". A local account whose saved credential is not a Claude OAuth token shows why it cannot serve. API-key accounts are not listed. The answer carries local route ids and labels, but no tokens, keys, provider account identities or credential material.
  • The active account is per session: source.ts records the account behind each session's latest authorization (servedAccount) and drops it in forgetSession, which already bounds sessions to 512.

Shared renderer

  • The sidebar rendering moves from tui.tsx to src/tui/claude-sidebar.tsx, with main's #379, #390 and #392 changes carried over (collapsed rows keyed by position, credit amounts after the shared figure). tui.tsx keeps file polling and the /claude-quota dialog and renders ClaudeSidebar. The preference controller now returns the watcher's dispose (OpenCode 1 still keeps it for the process).
  • Countdowns, reading ages, pacing and backoff times read a sidebar clock that ticks at prefs.pollMs and on each state change, so they advance while the server answers the same state. Rows are not rebuilt: the clock only updates their text, and bar segments are keyed by position.
  • Before an account has served the session, the collapsed rows mark none active, matching the expanded view (both generations).
  • So both generations draw the same panel and read the same anthropic-auth entry of tui-preferences.jsonc.

TUI (OpenCode 2)

  • mountClaudeSidebar claims sidebar.content, maps OpenCode 2's ResolvedTheme (text.base/muted, text.feedback.*.base, text.action.primary.base, background.base, border.base) to the flat tokens, polls rpc.sidebar per session at prefs.pollMs with an abort signal, redraws only when content changes, and keeps the last state on errors (an older server shows "Waiting for quota…"). If the slot claim throws, the preference watcher is disposed.
  • build-tui.ts precompiles tui/claude-sidebar.tsx; entry.mjs loads it with the same runtime-registry probe as the OpenCode 1 loader (raw TSX fallback on older hosts and bare Bun) and passes it to setupAnthropicTui, which stays JSX-free and does not load the OpenCode 1 TUI. A renderer that fails to load leaves /claude working.

Rendered by the test through entry.mjs setup() with an OpenCode 2-shaped context:

  ▼ CLAUDE                         v2.1.1
 Quota
 main                              active
 5h         ████▓▓░░░░ 40% left     2h59m
            deficit 20% · out in 1h20m
 7d         ██▒▒▒▒▒▒▒▒ 80% left     2h59m
            reserve 78% · lasts
 Routing
 Route                         main-first
 Mode                                 std
 Relay                                  —

OpenCode 2.1.0

Checked against @opencode/plugin and @opencode/theme 2.1.0 (released 2026-10-20). The plugin API changes since 2.0.22 (SessionCompactionResult.providerState renamed native, session.list, dialog select search/actions) are not used here, and the TUI context still exposes the nested ResolvedTheme and ui.slot this PR reads. With the dev pin moved to 2.1.0 locally, typecheck passes, and the packed real-host suite (opencode2-e2e.test.ts with @opencode/cli@2.1.0) passes its six OpenCode 2 cases. The pin itself stays at 2.0.22 in this PR; bumping it belongs with the dependency updates.

Out of scope

  • Push updates (polling like OpenCode 1), Fable recovery notices and the WebSocket relay (not OpenCode 2 features).
  • API-key routes still mark main active while a key serves, as OpenCode 1 does today.
  • Starting the background quota tick before the first owned request: persisted readings show with their age, as /claude does.

Tests

  • opencode2-sidebar.test.ts: projection (accounts, API keys excluded, active per session, backoffs, permanent vs expired refresh failure, vault re-sign-in line), turned-off / cold / served vault rows with their custody state, unsupported saved credential, backoff from a replaced login ignored, policy fields including the cache keepalive enabled flag, RPC round trip and no secret, fail-closed output schema, registerNativeMenu sidebar handler.
  • opencode2-native-source.test.ts: servedAccount per session and after forgetSession. The file passes alone (36 pass), including the two-second stalled-refresh bound.
  • opencode2-sidebar-render.test.ts: loads src/tui/entry.mjs setup() in a Bun subprocess with OpenTUI's test renderer, a fake OpenCode 2 context and RPC: slot claimed, server quota drawn with theme colors asserted per span, slot released on cleanup; a server without sidebar shows "Waiting for quota…"; a countdown advances from 2h59m to 1h59m while the server keeps answering the same state (fails without the clock); the last quota stays drawn, and keeps counting down, after later polls fail.
  • sidebar-state.test.ts: no collapsed row active before the first request.
  • Existing OpenCode 1 render, preference and sidebar-state tests pass unchanged.
  • Local gates (Bun 1.3.14): lint (one existing warning in local-routing-migration.test.ts), format:check, typecheck, build and smoke:tui (npm 11; npm 12 blocks @opencode/cli's postinstall and changed npm pack --json). bun test src/tests in packages/opencode: 2282 pass, 8 fail. Five fail identically on main here (OC2 setup acceptance, S2 rerun, vault generation 2 x2, Pi auth path), the OpenCode 1 e2e case times out in this environment, and two timed out under load and pass alone (custody tombstone, first render case; the render cases now allow 20 s for the subprocess).

Specified with OpenSpec (proposal, capability specs sidebar-state-rpc and opencode2-sidebar, design, tasks); the artifacts are kept out of the repository.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no new actionable defects were established.

Summary

Adds the Claude quota sidebar to OpenCode 2 through the existing native RPC.

  • OpenCode 2's server now answers quota requests for each session.
  • OpenCode 1 and OpenCode 2 share a live quota panel.

Diagram

sequenceDiagram
  participant TUI as OpenCode 2 TUI
  participant RPC as Native RPC
  participant Accounts as Native account snapshot
  participant Panel as Shared Claude sidebar
  TUI->>RPC: sidebar(sessionId)
  RPC->>Accounts: Read accounts and policy
  Accounts-->>RPC: Snapshot
  RPC-->>TUI: Normalized sidebar state
  TUI->>Panel: Update changed content
  Note over Panel: Local clock advances countdowns

Reviews (3) · Last reviewed commit: "test(opencode): a refused OpenCode 2 sid..." · Reviewed by Greptile