Skip to content

feat(opencode): quota sidebar on OpenCode 2 - #395

Merged
ualtinok merged 4 commits into
cortexkit:mainfrom
TodoniK:feat/opencode2-quota-sidebar
Oct 20, 2026
Merged

ualtinok merged 4 commits into
cortexkit:mainfrom
TodoniK:feat/opencode2-quota-sidebar

Conversation

@TodoniK

@TodoniK TodoniK commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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

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
Loading

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

Copilot AI balanced review requested due to automatic review settings October 18, 2026 12:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 19 files

Confidence score: 3/5

  • In claude-sidebar.tsx, stripping lastUpdated before change detection can freeze dynamic quota values after an unchanged response. Compare the full response or trigger a render tick.
  • In v2/sidebar.ts, disabled vault accounts can disappear from the sidebar even when they need sign-in. Retain vault rows and populate their custody state, as OpenCode 1 does.
  • In claude-sidebar.tsx, a failed slot registration can leave the preference watcher running because cleanup is only returned after registration succeeds. Dispose the controller in the registration failure path.
  • The sidebar tests miss important coverage: opencode2-sidebar-render.test.ts does not check that rendered quota state survives a later polling error, and opencode2-sidebar.test.ts does not exercise enabled hybrid cache-keep. Add those cases so regressions in either path are caught.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/tui/claude-sidebar.tsx">

<violation number="1" location="packages/opencode/src/tui/claude-sidebar.tsx:954">
P2: The OpenCode 2 sidebar freezes dynamic quota values after the first unchanged response because it strips `lastUpdated` before change detection. Compare the full response, or add a render tick, so reset countdowns and pacing refresh at the configured poll interval.</violation>

<violation number="2" location="packages/opencode/src/tui/claude-sidebar.tsx:989">
P3: A failed OpenCode 2 slot registration leaks the preference watcher created by `createSidebarController`, because cleanup is returned only after registration succeeds. Dispose the controller in a catch around `host.ui.slot` before rethrowing the mount error.</violation>
</file>

<file name="packages/opencode/src/v2/sidebar.ts">

<violation number="1" location="packages/opencode/src/v2/sidebar.ts:69">
P2: This filter drops disabled vault accounts, so a vault credential needing sign-in can disappear from the OpenCode 2 sidebar. Retain vault rows using the OpenCode 1 rule and populate the corresponding custody state so the shared renderer can display them.</violation>
</file>

<file name="packages/opencode/src/tests/opencode2-sidebar-render.test.ts">

<violation number="1" location="packages/opencode/src/tests/opencode2-sidebar-render.test.ts:167">
P2: This fallback test only verifies the initial empty state when `sidebar` is unavailable, not preservation of an already-rendered quota state after a later polling error. Add a probe response sequence that returns valid state first, then rejects subsequent calls, and assert the quota remains visible.</violation>
</file>

<file name="packages/opencode/src/tests/opencode2-sidebar.test.ts">

<violation number="1" location="packages/opencode/src/tests/opencode2-sidebar.test.ts:147">
P2: This test does not exercise the active cache-keep path or verify its activation flag. Add `claudeCache: { enabled: true, mode: 'hybrid' }` to the fixture and assert `state.cacheKeep?.enabled` is `true`, otherwise a regression that disables cache keepalive can pass unnoticed.</violation>
</file>

<file name="STRUCTURE.md">

<violation number="1" location="STRUCTURE.md:80">
P3: This description omits the raw TSX fallback used by older hosts and bare Bun. Document the compiled path as conditional so the structure guide accurately reflects supported loading behavior.</violation>
</file>

<file name="docs/sidebar.md">

<violation number="1" location="docs/sidebar.md:7">
P2: This overstates the RPC privacy guarantee: the sidebar response includes local account IDs and labels (`fallbacks[].id`, `fallbacks[].label`, and `activeId`) so the renderer can identify accounts. Say that it excludes provider account identities and credential material instead of claiming it contains no account identities.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant UI as OpenCode 2 TUI
    participant Entry as tui/entry.mjs
    participant Setup as setupAnthropicTui
    participant Mount as mountClaudeSidebar
    participant RPC as Native RPC
    participant Server as v2/menu.ts
    participant Source as Native Source
    participant Snapshot as Account Snapshot
    participant Builder as v2/sidebar.ts
    participant Renderer as ClaudeSidebar

    Note over UI,Entry: PR: OpenCode 2 quota sidebar

    UI->>Entry: setup(context)
    Entry->>Setup: setupAnthropicTui(ctx, {mountSidebar})
    Setup->>Setup: Register /claude menu
    Setup->>Mount: mountSidebar(ctx, read)

    Note over Mount,Builder: NEW: Sidebar polling loop
    loop pollMs per session
        Mount->>RPC: rpc.sidebar({sessionId}, {signal})
        RPC->>Server: registered sidebar handler
        Server->>Server: readSidebar(sessionID)
        Server->>Source: servedAccount(sessionID)
        Source-->>Server: account ID or undefined
        Server->>Snapshot: runtime.read()
        Server->>Builder: buildNativeSidebarState(snapshot, extras)
        Builder->>Builder: Filter API keys, project OAuth accounts
        Builder-->>Server: SidebarState (no secrets)
        Server-->>Mount: RPC response
        Mount->>Renderer: Draw with theme colors
        alt Valid state
            Renderer-->>Mount: Render quota bars, routing, cache
        else RPC method not found
            Mount->>Renderer: Show "Waiting for quota…"
        end
    end

    Note over Source: NEW: servedBy Map
    Source->>Source: Track accountId per session on authorize
    Source->>Source: Delete on forgetSession

    Note over UI,Setup: Session lifecycle
    UI->>Setup: Cleanup call
    Setup->>Mount: releaseSidebar()
    Mount-->>Setup: Release sidebar.content slot
    Setup-->>UI: Return dispose
Loading

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread packages/opencode/src/tui/claude-sidebar.tsx
Comment thread packages/opencode/src/v2/sidebar.ts Outdated
Comment thread packages/opencode/src/v2/sidebar.ts Outdated
Comment thread packages/opencode/src/tests/opencode2-sidebar-render.test.ts Outdated
Comment thread packages/opencode/src/tests/opencode2-sidebar.test.ts
Comment thread docs/sidebar.md Outdated
Comment thread packages/opencode/src/tui/claude-sidebar.tsx Outdated
Comment thread STRUCTURE.md Outdated
Comment thread packages/opencode/src/tui/claude-sidebar.tsx
Comment thread packages/opencode/src/v2/sidebar.ts

@antauth-alfonso antauth-alfonso Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, the direction is right: one renderer for both OpenCode generations, an additive sidebar method on the existing menu RPC that fails closed to the empty state, and a TUI bundle that stays JSX-free. The build, typecheck, lint and your new tests pass on our side.

A few things need changing before we can merge:

  1. Countdowns and reading ages freeze. HostSidebar drops lastUpdated before comparing answers, so an answer with the same values never updates, and reset countdowns, the reading age and stale dimming stop moving. Comparing the full answer instead would remount every row on each poll, which is the flicker in #379. We're fixing #379 on main first (collapsed rows keyed by position instead of by object); after that, please add a clock signal (a now that ticks) that the time-based values read, rather than redrawing on every poll. A test that a countdown advances without a state change would cover it.
  2. Turned-off vault accounts disappear. v2/sidebar.ts filters fallbacks on account.enabled. OpenCode 1 keeps vault rows when they're turned off and sets custodyState, which the shared renderer uses to show "turned off". Please keep source === 'vault' rows and derive custodyState the same way, with a test for a turned-off vault account.
  3. Watcher leak. If host.ui.slot throws, the controller's watcher keeps running. Wrapping the call and disposing the controller on failure fixes it.
  4. Docs wording. docs/sidebar.md says the answer carries no account identities, but it does include local route ids and labels. "No tokens, keys, provider account identities or credential material" would be accurate.

Smaller points, optional:

  • refreshBackoffActive gets an undefined identity and fingerprint, so a stale backoff from a replaced login could still show; passing account.accountIdentity avoids that.
  • API-key routes set servedBy, which marks main as active while an API key is serving (OpenCode 1 does the same today).
  • STRUCTURE.md could mention the TSX fallback.

Because the moved renderer also carries the #379 fix and the credit amounts we're restoring for #390, please rebase once those land on main; I'll comment here when they do. One test, opencode2-native-source.test.ts > inactive native vault admission bounds a stalled refresh at two seconds, timed out once when we ran it alongside other files; it's probably load, but please run that file alone on your branch.

@antauth-alfonso

Copy link
Copy Markdown

The sidebar fixes this PR builds on are now on main as de983cd (#405): collapsed rows keyed by position (#379), credit amounts after the shared credit figure (#390), and a signed-out vault default shown once as main (#392). Please rebase onto main when you pick up the review changes; tui.tsx changed in the collapsed rows and AccountBlock, so those parts of the moved renderer need the same edits.

TodoniK and others added 2 commits October 20, 2026 14:46
OpenCode 2 had no quota sidebar: the generation-2 TUI only registered
/claude and toasts, so 5h/7d bars, pacing, routing and health were only
visible on OpenCode 1.

- the OpenCode 2 server answers a new `sidebar` method on the existing
  native RPC with the OpenCode 1 SidebarState, projected from the native
  account snapshot (v2/sidebar.ts); the output is normalized fail-closed
  and carries no token, key, receipt or account identity
- the active account is per session: the native source records the
  account behind each session's latest authorization and forgets it with
  the session
- the sidebar rendering moves unchanged from tui.tsx to
  tui/claude-sidebar.tsx, shared by both generations with the same
  tui-preferences.jsonc settings; tui.tsx keeps file polling and dialogs
- on OpenCode 2, entry.mjs loads the precompiled renderer and
  setupAnthropicTui mounts it on sidebar.content with the host theme
  mapped to the flat tokens; it polls per session and shows "Waiting for
  quota" against servers without the method

Docs and changelog updated. cortexkit#379 is unchanged and a fix in the shared
renderer will cover both generations.
- Countdowns, reading ages, pacing and backoff times read a sidebar
  clock that ticks at the poll interval and on each state change, so
  they advance while the server answers the same state. Quota bar
  segments are keyed by position so the tick does not remount them.
- Turned-off vault accounts stay listed with OpenCode 1's custody
  state ("turned off"); vaultServed and vaultReauth follow it.
- A local account with an unsupported saved credential shows why it
  cannot serve instead of looking ready.
- Refresh backoffs are matched to the account identity, so one left by
  a replaced login no longer shows.
- The collapsed rows mark no account active before one has served the
  session, matching the expanded view, in both generations.
- A failing slot claim disposes the preference watcher.
- Docs: the RPC answer carries route ids and labels but no tokens,
  keys, provider account identities or credential material; STRUCTURE
  names the raw TSX fallback. Changelog entry moved to Unreleased.

Tests: countdown advances without a state change, last quota kept
after failed polls, turned-off/cold/served vault rows, unsupported
credential, replaced-login backoff, cache keepalive enabled flag,
no active collapsed row before the first request.
@TodoniK
TodoniK force-pushed the feat/opencode2-quota-sidebar branch from 816ef2e to 1f1b92b Compare October 20, 2026 13:19
@TodoniK

TodoniK commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

@ualtinok rebased onto main (2.1.1) with the review changes in a separate commit, 1f1b92b, so the delta is easy to read. Ready for another look and, if it suits you, a merge.

From the review:

  1. Frozen countdowns. The renderer now has a now clock that ticks at prefs.pollMs and on each state change. Countdowns, reading ages, stale dimming, pacing and backoff times read it, rows are not rebuilt, and bar segments are keyed by position. The new render test sees a countdown go from 2h59m to 1h59m while the server keeps answering the same state; without the clock it fails.
  2. Turned-off vault accounts. Vault rows are kept and get OpenCode 1's custodyState (on-vault-reauth, on-vault-served, on-cold); vaultServed/vaultReauth follow it. A test covers turned-off, cold and served vault rows.
  3. Watcher leak. A throwing ui.slot now disposes the controller.
  4. Docs. "No tokens, keys, provider account identities or credential material", and it names the route ids and labels it does carry.

Optional points: refreshBackoffActive now gets account.accountIdentity (test with a replaced login); STRUCTURE.md names the TSX fallback. I left API-key routes marking main active, as on OpenCode 1.

The #379/#390/#392 changes from de983cd are applied to the moved renderer (Index for collapsed rows, creditLine). opencode2-native-source.test.ts passes alone (36 pass).

Also handled: the bots' unsupported-credential state line, the collapsed view marking main active before the first request, the cache keepalive assertion and a "keeps the last quota after failed polls" test.

OpenCode 2.1.0 (out today): nothing this PR uses changed. With the dev pin moved to @opencode/plugin 2.1.0 locally, typecheck passes and opencode2-e2e.test.ts against @opencode/cli@2.1.0 passes its six OpenCode 2 cases. I kept the pin at 2.0.22 here.

Comment thread packages/opencode/src/sidebar-state.ts
…es watcher

Setup keeps the /claude menu when the sidebar cannot mount. The test checks that the controller created for the refused slot is disposed: the child process must exit on its own, which a leaked preferences directory watcher prevents. Removing the dispose in mountClaudeSidebar makes it fail.
@ualtinok
ualtinok merged commit b04aa63 into cortexkit:main Oct 20, 2026
19 of 21 checks passed
@antauth-alfonso

Copy link
Copy Markdown

Merged, thank you. All four review changes were in, plus two of the optional ones, and the moved renderer kept the #379, #390 and #392 fixes.

Two small things I did on top, so you know what changed in your branch:

  • Merged main (2.1.2) into it. The only conflict was the changelog, where both sides were kept.
  • Added the test for the refused slot (OpenCode 2 setup survives a refused sidebar slot and stops watching preferences). Setup keeps the /claude menu when the sidebar can't mount, so the test checks that the controller is disposed: the child process has to exit on its own, which it doesn't if the preferences watcher is left open.

It will go out in the next release.

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.

3 participants