From c28c4211cbd3f8cf8e22e5c0f49e6e00e8b7a488 Mon Sep 17 00:00:00 2001 From: Jules Royet <95073665+TodoniK@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:39:07 +0200 Subject: [PATCH 1/3] feat(opencode): quota sidebar on OpenCode 2 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. #379 is unchanged and a fix in the shared renderer will cover both generations. --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 1 + STRUCTURE.md | 5 +- docs/features.md | 6 +- docs/opencode-2.md | 4 + docs/sidebar.md | 4 +- packages/opencode/scripts/build-tui.ts | 1 + .../src/tests/opencode2-native-source.test.ts | 10 + .../tests/opencode2-sidebar-render.test.ts | 171 +++ .../src/tests/opencode2-sidebar.test.ts | 232 ++++ packages/opencode/src/tui.tsx | 899 +------------- packages/opencode/src/tui/claude-sidebar.tsx | 1044 +++++++++++++++++ packages/opencode/src/tui/entry.mjs | 16 +- packages/opencode/src/v2/menu-contract.ts | 3 + packages/opencode/src/v2/menu.ts | 12 + packages/opencode/src/v2/rpc.ts | 13 + packages/opencode/src/v2/sidebar.ts | 115 ++ packages/opencode/src/v2/source.ts | 11 + packages/opencode/src/v2/tui.ts | 25 +- 19 files changed, 1680 insertions(+), 896 deletions(-) create mode 100644 packages/opencode/src/tests/opencode2-sidebar-render.test.ts create mode 100644 packages/opencode/src/tests/opencode2-sidebar.test.ts create mode 100644 packages/opencode/src/tui/claude-sidebar.tsx create mode 100644 packages/opencode/src/v2/sidebar.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bfed5c094..7e24de50c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -23,7 +23,7 @@ **@cortexkit/opencode-anthropic-auth (OpenCode Plugin):** - Purpose: OpenCode plugin that intercepts Anthropic fetch requests, provides CLI, TUI sidebar, and command modal dialogs - Location: `packages/opencode/src/` -- Contains: Plugin entry point (`index.ts`), CLI (`cli.ts`), request transform/SSE stripping (`transform.ts`), internal request policy and killswitch messaging (`request-policy.ts`), cache diagnostics tracking and accounting (`cache-diagnostics.ts`), Anthropic server-side safety fallback (`server-fallback.ts`), deterministic client content-filter recovery (`fable-fallback.ts`), system prompt sanitization/memoization (`sanitize-memo.ts`), message-history context reconstruction (`prompt-context.ts`), request-scoped Fable 5.1 effort history (`effort-history.ts`), user-message prompt UUID and request ID lineage tracking across tool loops (`billing-lineage.ts`), lane-start request correlation (`lane-start.ts`), quota priming manager registry (`prime-manager-registry.ts`), scoped Claustrum startup admission (`custody-mode.ts`, `custody-dimensions.ts`), offline setup wizard (`setup/`), TUI sidebar widget (`tui.tsx`), TUI preferences (`tui-preferences.ts`), command modal dialogs (`tui/command-dialogs.tsx`), loopback RPC server/client for TUI IPC (`rpc/`), and TUI sidebar IPC state management (`sidebar-state.ts`) +- Contains: Plugin entry point (`index.ts`), CLI (`cli.ts`), request transform/SSE stripping (`transform.ts`), internal request policy and killswitch messaging (`request-policy.ts`), cache diagnostics tracking and accounting (`cache-diagnostics.ts`), Anthropic server-side safety fallback (`server-fallback.ts`), deterministic client content-filter recovery (`fable-fallback.ts`), system prompt sanitization/memoization (`sanitize-memo.ts`), message-history context reconstruction (`prompt-context.ts`), request-scoped Fable 5.1 effort history (`effort-history.ts`), user-message prompt UUID and request ID lineage tracking across tool loops (`billing-lineage.ts`), lane-start request correlation (`lane-start.ts`), quota priming manager registry (`prime-manager-registry.ts`), scoped Claustrum startup admission (`custody-mode.ts`, `custody-dimensions.ts`), offline setup wizard (`setup/`), TUI sidebar widget (`tui.tsx`, rendering shared with OpenCode 2 in `tui/claude-sidebar.tsx`), TUI preferences (`tui-preferences.ts`), command modal dialogs (`tui/command-dialogs.tsx`), loopback RPC server/client for TUI IPC (`rpc/`), and TUI sidebar IPC state management (`sidebar-state.ts`) - Depends on: `@cortexkit/anthropic-auth-core`, `@opencode-ai/plugin` (peer), `@opentui/core` + `@opentui/solid` + `solid-js` (TUI), `jsonc-parser` (TUI preferences) - Used by: OpenCode agent (loaded as plugin + TUI plugin) @@ -201,7 +201,7 @@ **SidebarState:** - Purpose: Shared state file between OpenCode server process and TUI widget process for live quota/routing/cache display and per-session safety fallback status - Location: `packages/opencode/src/sidebar-state.ts` -- Pattern: JSON file under `$TMPDIR`; server writes after each routing decision or quota refresh; TUI polls on interval. Supports displaying standard usage windows alongside scoped model quotas (e.g. Fable weekly limit), omitting standard five-hour and seven-day placeholders when only model-scoped limits are visible, preserving empty scoped arrays `[]` to distinguish explicit ownership from missing data, and carries a bounded set of session-keyed server-side fallback or legacy Opus/Fable recovery notices so concurrent sessions do not overwrite each other's sidebar state. Non-authoritative updates re-read the file before merging to preserve active routing sessions (`activeId`). Cross-process writes are guarded by an `mkdir` directory lock with atomic rename-claim eviction of stale locks, jittered retries, and lock-budget exhaustion skips. Writes are fenced with pre- and post-rename ownership checks; if a post-rename lock loss is detected, a single bounded locked repair restores the routing-authoritative fields into the successor's fresh state. +- Pattern: OpenCode 1: JSON file under `$TMPDIR`; server writes after each routing decision or quota refresh; TUI polls on interval. OpenCode 2: no file; the TUI polls the native RPC `sidebar` method, which projects the same shape from the native account snapshot per session (`v2/sidebar.ts`). Supports displaying standard usage windows alongside scoped model quotas (e.g. Fable weekly limit), omitting standard five-hour and seven-day placeholders when only model-scoped limits are visible, preserving empty scoped arrays `[]` to distinguish explicit ownership from missing data, and carries a bounded set of session-keyed server-side fallback or legacy Opus/Fable recovery notices so concurrent sessions do not overwrite each other's sidebar state. Non-authoritative updates re-read the file before merging to preserve active routing sessions (`activeId`). Cross-process writes are guarded by an `mkdir` directory lock with atomic rename-claim eviction of stale locks, jittered retries, and lock-budget exhaustion skips. Writes are fenced with pre- and post-rename ownership checks; if a post-rename lock loss is detected, a single bounded locked repair restores the routing-authoritative fields into the successor's fresh state. **TuiPreferences:** - Purpose: Comment-preserving JSONC preferences and live sidebar configuration reloads diff --git a/CHANGELOG.md b/CHANGELOG.md index da4881dd6..2f38895af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ### Sidebar +- OpenCode 2 now shows the CLAUDE quota sidebar too, without opening `/claude`. It is the same panel as OpenCode 1 (per-account 5h, 7d and model bars with pacing, collapsed rows, routing, cache, Prime and health), drawn from one shared component in the active OpenCode 2 theme's colors and configured by the same `anthropic-auth` entry in `tui-preferences.jsonc`. The OpenCode 2 server answers it through the native RPC (`sidebar` method) from the account pool, with no file and no credential; the active account is the one that last served the sidebar's session. A TUI talking to an older server shows "Waiting for quota…". - The collapsed OpenCode 1 sidebar now draws quotas with common-auth's shared narrow form, the same one the other CortexKit auth plugins use. An account-wide window without a reading is now left out instead of shown as a placeholder: `5h 87% · 7d — left` becomes `5h 87% left`. An account with no reading at all still shows a muted `—`, and stale readings are still dimmed. - The collapsed OpenCode 1 sidebar no longer rebuilds every account row each time the sidebar file is rewritten, which made the rows flicker (#379). Rows now stay in place and only their text changes. - A vault default account whose sign-in expired now shows once, as `main` with "needs sign-in in the vault", instead of also appearing as a second vault account (#392). It is still not used for requests until you sign in again. diff --git a/STRUCTURE.md b/STRUCTURE.md index 9a5776abb..6b3b649b9 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -42,7 +42,7 @@ anthropic-auth/ **`packages/opencode/src/`:** - Purpose: OpenCode plugin implementation — fetch interception, request rewriting, CLI, TUI sidebar, command dialogs - Contains: Plugin entry point, transform pipeline, cache diagnostics, CLI, TUI widget (SolidJS), precompiled TUI loader/output, RPC server for TUI IPC, preferences management -- Key files: `index.ts` (single exported plugin factory — auth loader, command registration, background services), `request-policy.ts` (internal killswitch request classification and messaging), `transform.ts` (request body rewriting + SSE stream stripping), `custody-mode.ts` (fail-closed scoped startup admission), `custody-dimensions.ts` (route custody classification), `cache-diagnostics.ts` (cache diagnosis beta opt-in, message ID tracking, and schema v:2 record formatting), `server-fallback.ts` (Anthropic server-side safety fallback opt-in, fallback-boundary preservation, outcome detection, and completed-tool refusal continuation), `fable-fallback.ts` (session-and-source-family 10-response Opus 4.8 backstop/legacy recovery and standby cache-anchor state), `prime-manager-registry.ts` (process-wide PrimeManager registry keyed by account-storage identity), `lane-start.ts` (synthetic prompt injection and request correlation), `cli.ts` (fallback account login + relay setup), `tui.tsx` (sidebar source), `tui/entry.mjs` (host-runtime-aware compiled/raw loader), `tui/command-dialogs.tsx` (command modal dialog components), `tui-compiled/` (generated build output, shipped but git-ignored), `tui-preferences.ts` (comment-preserving JSONC preferences with directory-watch and independent polling reload paths), `sidebar-state.ts` (quota/routing, prime status, and session-keyed recovery state for TUI sidebar IPC), `sanitize-memo.ts` (system prompt sanitization memoization), `prompt-context.ts` (prompt context resolver), `effort-history.ts` (request-scoped Fable 5.1 effort timeline), `billing-lineage.ts` (user-turn prompt UUID and previous Anthropic request ID tracking across tool loops, internal correlation headers, and session invalidation) +- Key files: `index.ts` (single exported plugin factory — auth loader, command registration, background services), `request-policy.ts` (internal killswitch request classification and messaging), `transform.ts` (request body rewriting + SSE stream stripping), `custody-mode.ts` (fail-closed scoped startup admission), `custody-dimensions.ts` (route custody classification), `cache-diagnostics.ts` (cache diagnosis beta opt-in, message ID tracking, and schema v:2 record formatting), `server-fallback.ts` (Anthropic server-side safety fallback opt-in, fallback-boundary preservation, outcome detection, and completed-tool refusal continuation), `fable-fallback.ts` (session-and-source-family 10-response Opus 4.8 backstop/legacy recovery and standby cache-anchor state), `prime-manager-registry.ts` (process-wide PrimeManager registry keyed by account-storage identity), `lane-start.ts` (synthetic prompt injection and request correlation), `cli.ts` (fallback account login + relay setup), `tui.tsx` (OpenCode 1 sidebar polling and dialogs), `tui/claude-sidebar.tsx` (sidebar renderer shared by OpenCode 1 and 2, plus the OpenCode 2 mount), `tui/entry.mjs` (host-runtime-aware compiled/raw loader), `v2/sidebar.ts` (OpenCode 2 sidebar state projected from the native snapshot), `tui/command-dialogs.tsx` (command modal dialog components), `tui-compiled/` (generated build output, shipped but git-ignored), `tui-preferences.ts` (comment-preserving JSONC preferences with directory-watch and independent polling reload paths), `sidebar-state.ts` (quota/routing, prime status, and session-keyed recovery state for TUI sidebar IPC), `sanitize-memo.ts` (system prompt sanitization memoization), `prompt-context.ts` (prompt context resolver), `effort-history.ts` (request-scoped Fable 5.1 effort timeline), `billing-lineage.ts` (user-turn prompt UUID and previous Anthropic request ID tracking across tool loops, internal correlation headers, and session invalidation) **`packages/opencode/src/setup/`:** - Purpose: Anthropic-specific callbacks for the shared OpenCode/Pi setup runner @@ -76,7 +76,8 @@ anthropic-auth/ - `packages/opencode/src/index.ts`: OpenCode plugin factory (exported as `AnthropicAuthPlugin`) - `packages/opencode/src/cli.ts`: CLI binary entry (`opencode-anthropic-auth`) - `packages/pi/src/index.ts`: Pi extension entry (default export function) -- `packages/opencode/src/tui.tsx`: TUI sidebar source (SolidJS component), loaded through `src/tui/entry.mjs` from precompiled output on current OpenTUI hosts +- `packages/opencode/src/tui.tsx`: OpenCode 1 TUI plugin (sidebar file polling, loopback dialogs), loaded through `src/tui/entry.mjs` from precompiled output on current OpenTUI hosts +- `packages/opencode/src/tui/claude-sidebar.tsx`: the quota sidebar renderer both generations use; OpenCode 2's `entry.mjs` setup loads it precompiled and mounts it on `sidebar.content` - `packages/core/src/index.ts`: Shared core library entry (re-exports all modules) **Configuration:** diff --git a/docs/features.md b/docs/features.md index d51037360..af009e6e9 100644 --- a/docs/features.md +++ b/docs/features.md @@ -15,10 +15,10 @@ | Quota refresh and killswitch edits | Menu actions | Summary only; no killswitch edits | | Prime quota-window scheduler | Status and on/off actions | Status only | | Lane start | Menu action | Not available | -| Quota sidebar | OpenCode 1 TUI plugin | Not available | +| Quota sidebar | OpenCode 1 and OpenCode 2 TUI | Not available | | Cloudflare relay | HTTP; OpenCode 1 also supports WebSocket | HTTP or WebSocket | -[Setup](installation.md#recommended-setup) prepares the selected hosts and records which credential store they may use before sign-in. OpenCode 2 has its own [login and transport boundaries](opencode-2.md); OpenCode 1's sidebar, host-local quota-feed publisher and deterministic client safety recovery are not OpenCode 2 features. +[Setup](installation.md#recommended-setup) prepares the selected hosts and records which credential store they may use before sign-in. OpenCode 2 has its own [login and transport boundaries](opencode-2.md); OpenCode 1's host-local quota-feed publisher and deterministic client safety recovery are not OpenCode 2 features. ## What CortexKit adds over the original plugin @@ -26,7 +26,7 @@ CortexKit adds account and request controls beyond OpenCode's or Pi's single bui - **Fallback accounts and quota-aware routing:** ordered OAuth accounts and API-key routes, minimum remaining-quota thresholds, and `sticky-balanced` session affinity. See [accounts](accounts-and-routing.md) and the [routing reference](configuration.md#fallback-accounts-and-session-affinity). - **Cache controls:** explicit, automatic or hybrid prompt-cache placement, optional keepalive, and opt-in quota-window priming. [Cache controls](cache.md) explain the schedules and billed synthetic requests. -- **Quota visibility and limits:** per-account status, OpenCode quota refresh and killswitch thresholds, the OpenCode 1 sidebar, and its optional sanitized [quota header feed](configuration.md#quota-header-feed). +- **Quota visibility and limits:** per-account status, OpenCode quota refresh and killswitch thresholds, the quota sidebar, and OpenCode 1's optional sanitized [quota header feed](configuration.md#quota-header-feed). - **Model controls:** adaptive reasoning summaries, effort levels, fast mode and OpenCode 1 safety fallback. See [models](models.md) and [safety fallback](request-rewriting.md#safety-fallback-opencode-1). - **Optional custody and transport:** Claustrum is a local credential-vault service that can supply accounts instead of the local pool; a user-owned Cloudflare Worker can relay request bodies. See [vault custody](vault.md) and [transports](transports.md). - **Claude-compatible request shaping:** identity and billing headers, final-body signing, tool replay and prompt-cache placement. See [request rewriting](request-rewriting.md#request-rewriting). diff --git a/docs/opencode-2.md b/docs/opencode-2.md index 014892707..1c4d4208b 100644 --- a/docs/opencode-2.md +++ b/docs/opencode-2.md @@ -45,6 +45,10 @@ OpenCode 1 and 2 share the [local routing file](accounts-and-routing.md#local-st Version-5 journals have no rollback path. Do not delete or rewrite a journal to make an older release accept it; see [upgrade and recovery](releases.md#upgrade-and-recovery). +## Quota sidebar + +The TUI adds the CLAUDE quota panel to the session sidebar and polls the server's `sidebar` RPC method at the preference poll interval. See [sidebar](sidebar.md#quota-sidebar). Quota readings refresh in the background once the location has served a request; before that, persisted readings are shown with their age. + ## Vault and transport boundaries Generation-2 vault setup also leaves OpenCode 1 auth and all OpenCode databases untouched. In an enrolled host, choose **Claude vault accounts**, not the local-pool login. The vault and local-pool login methods both return only the non-secret marker; startup creates neither connection automatically. Enrollment, not account settings, selects custody. See [vault setup](vault.md#setup) and [custody gates](custody-state-machine.md#opencode-2). diff --git a/docs/sidebar.md b/docs/sidebar.md index 9e4ef9880..3d3f61d0e 100644 --- a/docs/sidebar.md +++ b/docs/sidebar.md @@ -2,7 +2,9 @@ ## Quota sidebar -OpenCode 1 can render a live quota sidebar without opening `/claude`. This is an OpenCode 1 TUI plugin, not a Pi widget or an OpenCode 2-only setup requirement. +OpenCode 1 and OpenCode 2 render a live quota sidebar without opening `/claude`. It is not a Pi widget. Both generations draw it from the same component and read the same preferences below. + +OpenCode 2 needs no extra registration: the main plugin entry loads its TUI, which adds the panel to the session sidebar, takes its colors from the active OpenCode 2 theme, and asks the location's server for the session's state over the plugin's native RPC. The server builds that state from the account pool and never includes tokens, keys or account identities. The active account is the one that last served that session; before its first request none is marked active. An OpenCode 2 TUI talking to an older plugin server shows "Waiting for quota…". OpenCode 1 loads TUI plugins from `tui.json` or `tui.jsonc` in its config directory, separately from the main `plugin` array: diff --git a/packages/opencode/scripts/build-tui.ts b/packages/opencode/scripts/build-tui.ts index 107abb284..9a1ae8925 100644 --- a/packages/opencode/scripts/build-tui.ts +++ b/packages/opencode/scripts/build-tui.ts @@ -9,6 +9,7 @@ const outputRoot = join(sourceRoot, 'tui-compiled') const shippedSourceFiles = [ 'tui.tsx', 'tui/command-dialogs.tsx', + 'tui/claude-sidebar.tsx', 'sidebar-state.ts', 'tui-preferences.ts', 'rpc/rpc-client.ts', diff --git a/packages/opencode/src/tests/opencode2-native-source.test.ts b/packages/opencode/src/tests/opencode2-native-source.test.ts index 6e85fe876..16fc97cff 100644 --- a/packages/opencode/src/tests/opencode2-native-source.test.ts +++ b/packages/opencode/src/tests/opencode2-native-source.test.ts @@ -1051,6 +1051,16 @@ describe('OC2 source over actual native services', () => { expect(JSON.stringify(snapshot)).not.toContain('native-only-refresh') expect(JSON.stringify(snapshot)).not.toContain(auth.accessToken) }) + test('remembers the account that served each session until the session is forgotten', async () => { + const fixture = await local() + const native = source(fixture.runtime, fixture.paths) + expect(native.servedAccount(scope.sessionID)).toBeUndefined() + expect(await native.authorizeAccount(scope, 'main')).toBeDefined() + expect(native.servedAccount(scope.sessionID)).toBe('main') + expect(native.servedAccount('ses_never_served')).toBeUndefined() + native.forgetSession?.(scope.sessionID) + expect(native.servedAccount(scope.sessionID)).toBeUndefined() + }) test('genuine header quota is account/version bound and persists through native publication', async () => { const fixture = await local() const native = source(fixture.runtime, fixture.paths) diff --git a/packages/opencode/src/tests/opencode2-sidebar-render.test.ts b/packages/opencode/src/tests/opencode2-sidebar-render.test.ts new file mode 100644 index 000000000..746338bfe --- /dev/null +++ b/packages/opencode/src/tests/opencode2-sidebar-render.test.ts @@ -0,0 +1,171 @@ +import { afterEach, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +let child: Bun.Subprocess<'ignore', 'pipe', 'pipe'> | undefined +let fixture: string | undefined +afterEach(async () => { + child?.kill() + await child?.exited + child = undefined + if (fixture) await rm(fixture, { recursive: true, force: true }) + fixture = undefined +}) + +// Loads the package's `./tui` entry the way OpenCode 2 does (`setup(context)`), +// so the bundled v2 TUI, the RPC call and the precompiled sidebar all run. +async function renderOpenCode2Sidebar( + answer: unknown, + wanted: string[], +): Promise<{ + frame: string + asked: string[] + released: number + colors: Record +}> { + fixture = await mkdtemp(join(tmpdir(), 'anthropic-oc2-sidebar-')) + await writeFile( + join(fixture, 'preferences.jsonc'), + JSON.stringify({ 'anthropic-auth': { rememberCollapsed: false } }), + ) + const probe = ` +import { createTestRenderer } from '@opentui/core/testing'; +import { render } from '@opentui/solid'; +import { ensureRuntimePluginSupport } from '@opentui/solid/runtime-plugin-support/configure'; +ensureRuntimePluginSupport(); +const { setup } = await import(${JSON.stringify(new URL('../tui/entry.mjs', import.meta.url).href)}); +const answer = ${JSON.stringify(answer)}; +const asked = []; +const claims = []; +let released = 0; +const cleanup = await setup({ + location: { directory: process.cwd() }, + theme: { + text: { + base: '#ffffff', muted: '#888888', + action: { primary: { base: '#3366ff' } }, + feedback: { success: { base: '#00ff00' }, warning: { base: '#ffff00' }, error: { base: '#ff0000' } }, + }, + background: { base: '#000000' }, + border: { base: '#ffffff' }, + }, + client: { rpc: () => ({ + events: { on: () => () => {} }, + open: async () => { throw new Error('unused'); }, + apply: async () => { throw new Error('unused'); }, + sidebar: async ({ sessionId }) => { + asked.push(sessionId); + if (answer === null) throw Object.assign(new Error('missing'), { code: 'rpc.method_not_found' }); + return answer; + }, + }) }, + keymap: { layer() {} }, + ui: { + router: { current: () => ({ type: 'session', sessionID: 'ses_oc2' }) }, + slot(claim) { claims.push(claim); return () => { released++; }; }, + toast: { show() {} }, + dialog: {}, + }, +}); +if (claims.length !== 1 || claims[0].append !== 'sidebar.content') throw new Error('sidebar slot was not claimed'); +let destroyed; +const teardown = new Promise((resolve) => { destroyed = resolve; }); +const setupRenderer = await createTestRenderer({ width: 42, height: 32, onDestroy() { destroyed(); } }); +let settle; +const frame = new Promise((resolve) => { settle = resolve; }); +const observe = () => { + const text = setupRenderer.captureCharFrame(); + if (${JSON.stringify(wanted)}.every((part) => text.includes(part))) settle(text); +}; +setupRenderer.renderer.on('frame', observe); +try { + await render(() => claims[0].render({ sessionID: 'ses_oc2' }), setupRenderer.renderer); + setupRenderer.renderer.requestRender(); + const text = await frame; + const colors = {}; + for (const line of setupRenderer.captureSpans().lines) + for (const span of line.spans) + if (span.text.trim()) colors[span.text.trim()] ??= String(span.fg); + cleanup(); + console.log(JSON.stringify({ frame: text, asked, released, colors })); +} finally { + setupRenderer.renderer.off('frame', observe); + setupRenderer.renderer.destroy(); + await teardown; +} +process.exit(0); +` + child = Bun.spawn( + [process.execPath, '--preload', '@opentui/solid/preload', '-e', probe], + { + cwd: fileURLToPath(new URL('../../', import.meta.url)), + env: { + ...process.env, + OPENCODE_TUI_PREFERENCES_FILE: join(fixture, 'preferences.jsonc'), + }, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }, + ) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: '' }) + return JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') +} + +// The test renderer prints colors as `RGBA.toString()`. +const rgba = (hex: string) => { + const [r, g, b] = [1, 3, 5].map( + (at) => Number.parseInt(hex.slice(at, at + 2), 16) / 255, + ) + return `rgba(${r?.toFixed(2)}, ${g?.toFixed(2)}, ${b?.toFixed(2)}, 1.00)` +} + +test('OpenCode 2 sidebar draws server quota with the host theme and releases its slot', async () => { + const now = Date.now() + const window = (used: number) => ({ + usedPercent: used, + remainingPercent: 100 - used, + checkedAt: now, + resetsAt: new Date(now + 3 * 3_600_000).toISOString(), + }) + const result = await renderOpenCode2Sidebar( + { + main: { + quota: { checkedAt: now, five_hour: window(60), seven_day: window(20) }, + }, + fallbacks: [], + activeId: 'main', + route: 'main-first', + relay: null, + fastMode: false, + lastUpdated: now, + }, + ['40% left', '80% left'], + ) + expect(result.frame).toContain('CLAUDE') + expect(result.frame).toContain('main') + expect(result.frame).toContain('active') + expect(result.frame).toContain('main-first') + expect(result.asked).toContain('ses_oc2') + const colorOf = (text: string) => + Object.entries(result.colors).find(([span]) => span.includes(text))?.[1] + // 60 % used is past the default warning threshold; 20 % is fine. + expect(colorOf('40% left')).toBe(rgba('#ffff00')) + expect(colorOf('80% left')).toBe(rgba('#00ff00')) + expect(colorOf('active')).toBe(rgba('#00ff00')) + expect(colorOf('main-first')).toBe(rgba('#3366ff')) + expect(result.released).toBe(1) +}) + +test('OpenCode 2 sidebar waits instead of failing when the server has no sidebar method', async () => { + const result = await renderOpenCode2Sidebar(null, ['Waiting for quota']) + expect(result.frame).toContain('CLAUDE') + expect(result.asked.length).toBeGreaterThan(0) +}) diff --git a/packages/opencode/src/tests/opencode2-sidebar.test.ts b/packages/opencode/src/tests/opencode2-sidebar.test.ts new file mode 100644 index 000000000..462d6d31c --- /dev/null +++ b/packages/opencode/src/tests/opencode2-sidebar.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, test } from 'bun:test' +import type { + NativeAccountSnapshot, + NativeAccountView, +} from '@cortexkit/anthropic-auth-core' +import { normalizeSidebarState } from '../sidebar-state' +import type { NativeMenuCapabilities } from '../v2/menu-contract' +import { NATIVE_MENU_RPC, registerNativeMenu } from '../v2/rpc' +import { buildNativeSidebarState } from '../v2/sidebar' + +const now = Date.UTC(2026, 9, 18, 12) +const checkedAt = now - 60_000 +const quota = (fiveHourUsed: number, sevenDayUsed: number) => ({ + checkedAt, + five_hour: { + usedPercent: fiveHourUsed, + remainingPercent: 100 - fiveHourUsed, + checkedAt, + }, + seven_day: { + usedPercent: sevenDayUsed, + remainingPercent: 100 - sevenDayUsed, + checkedAt, + }, +}) + +function account(overrides: Partial): NativeAccountView { + return { + id: 'main', + type: 'oauth', + source: 'local', + enabled: true, + ...overrides, + } +} + +function snapshot( + accounts: NativeAccountView[], + policyStorage: Record = {}, +): NativeAccountSnapshot { + return { + mode: 'local', + settings: {}, + accounts, + policyStorage: { version: 1, accounts: [], ...policyStorage }, + } as unknown as NativeAccountSnapshot +} + +const extras = { activeId: undefined, trackedSessions: 0, now } + +describe('OpenCode 2 sidebar state', () => { + test('lists main and enabled OAuth fallbacks with their quota, never API keys', () => { + const state = buildNativeSidebarState( + snapshot([ + account({ quota: quota(20, 40) }), + account({ id: 'work', label: 'Work', quota: quota(85, 10) }), + account({ id: 'off', label: 'Off', enabled: false }), + account({ id: 'paid', type: 'api', label: 'Paid key' }), + ]), + extras, + ) + expect(state.main.quota?.five_hour?.remainingPercent).toBe(80) + expect(state.fallbacks.map((entry) => entry.id)).toEqual(['work']) + expect(state.fallbacks[0]?.label).toBe('Work') + expect(state.fallbacks[0]?.quota?.five_hour?.usedPercent).toBe(85) + expect(state.lastUpdated).toBe(now) + }) + + test('names the account that served the session as active', () => { + const accounts = [ + account({ quota: quota(1, 1) }), + account({ id: 'work', quota: quota(1, 1) }), + ] + expect( + buildNativeSidebarState(snapshot(accounts), { + ...extras, + activeId: 'work', + }).activeId, + ).toBe('work') + expect(buildNativeSidebarState(snapshot(accounts), extras).activeId).toBe( + undefined, + ) + }) + + test('reports active backoffs and accounts that need a new sign-in', () => { + const state = buildNativeSidebarState( + snapshot([ + account({ + lastQuotaRefreshError: { + message: 'rate limited', + checkedAt: now - 1_000, + nextRetryAt: now + 120_000, + }, + lastRefreshError: { + message: 'temporarily unavailable', + checkedAt: now - 1_000, + nextRetryAt: now + 60_000, + status: 503, + }, + }), + account({ + id: 'dead', + lastRefreshError: { + message: 'invalid_grant', + checkedAt: now - 1_000, + nextRetryAt: now + 86_400_000, + permanent: true, + }, + }), + account({ + id: 'old', + lastRefreshError: { + message: 'invalid_grant', + checkedAt: now - 90_000_000, + nextRetryAt: now - 1_000, + permanent: true, + }, + }), + account({ id: 'vault', source: 'vault', state: 'needs_reauth' }), + ] as NativeAccountView[]), + extras, + ) + expect(state.main.quotaBackedOff).toBe(true) + expect(state.main.quotaBackoffUntil).toBe(now + 120_000) + expect(state.main.refreshBackedOff).toBe(true) + const byId = Object.fromEntries( + state.fallbacks.map((entry) => [entry.id, entry]), + ) + expect(byId.dead?.needsReauth).toBe(true) + expect(byId.old?.needsReauth).toBe(false) + expect(byId.vault?.vaultReauth).toBe(true) + expect(byId.vault?.stateLine).toBe('needs sign-in in the vault') + expect(byId.vault?.quotaDated).toBe(true) + }) + + test('projects routing, fast mode, relay, cache keepalive and Prime from policy', () => { + const state = buildNativeSidebarState( + snapshot([account({})], { + routing: { mode: 'fallback-first' }, + claudeFast: { enabled: true }, + relay: { + enabled: true, + url: 'https://relay.invalid', + tokenConfigured: true, + transport: 'http', + }, + cacheKeep: { enabled: true, always: true }, + prime: { enabled: true }, + }), + { ...extras, trackedSessions: 3 }, + ) + expect(state.route).toBe('fallback-first') + expect(state.fastMode).toBe(true) + expect(state.relay).toEqual({ enabled: true, transport: 'http' }) + expect(state.cacheKeep?.window).toBe('always') + expect(state.cacheKeep?.trackedSessions).toBe(3) + expect(state.prime?.enabled).toBe(true) + expect(state.prime?.accounts[0]?.id).toBe('main') + + const plain = buildNativeSidebarState(snapshot([account({})]), extras) + expect(plain.fastMode).toBe(false) + expect(plain.relay).toBeNull() + expect(plain.prime).toBeUndefined() + }) + + test('survives the RPC boundary unchanged and carries no secret', () => { + const state = buildNativeSidebarState( + snapshot([ + account({ + quota: quota(30, 60), + accountIdentity: '11111111-2222-4333-8444-555555555555', + credentialId: 'credential-secret-id', + }), + ]), + { ...extras, activeId: 'main' }, + ) + const wire = JSON.parse(JSON.stringify(state)) + expect(normalizeSidebarState(wire)).toEqual(normalizeSidebarState(state)) + const text = JSON.stringify(state) + expect(text).not.toContain('11111111-2222') + expect(text).not.toContain('credential-secret-id') + }) +}) + +describe('OpenCode 2 sidebar RPC', () => { + test('the output schema fails closed to the empty state', async () => { + const validate = + NATIVE_MENU_RPC.methods.sidebar.output['~standard'].validate + const result = await validate('not a state') + expect('value' in result && result.value.fallbacks).toEqual([]) + expect('value' in result && result.value.main.quota).toBeNull() + }) + + test('registers a sidebar handler that reads the requested session', async () => { + let handlers: Record unknown> = {} + const ctx = { + rpc: { + register: async (_definition: unknown, value: typeof handlers) => { + handlers = value + return { dispose: async () => {}, events: { emit: async () => {} } } + }, + }, + command: { + transform: async () => ({ dispose: async () => {} }), + }, + event: { + subscribe: async function* () {}, + }, + } + const asked: string[] = [] + const capabilities = { + readSidebar: async (sessionID: string) => { + asked.push(sessionID) + return normalizeSidebarState({ activeId: 'main', route: 'main' }) + }, + forgetSession: () => {}, + } as unknown as NativeMenuCapabilities + const registration = await registerNativeMenu( + ctx as never, + {} as never, + capabilities, + ) + const answer = (await handlers.sidebar?.({ + sessionId: 'ses_a', + } as never)) as { + activeId?: string + } + expect(asked).toEqual(['ses_a']) + expect(answer.activeId).toBe('main') + await registration.dispose() + }) +}) diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index 86417a081..3a189099a 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -1,61 +1,34 @@ /** @jsxImportSource @opentui/solid */ -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { - formatCreditAmounts, - type QuotaWindowPart, - quotaDisplayProjection, - quotaWindowParts, -} from '@cortexkit/anthropic-auth-core' import type { TuiPlugin, TuiPluginApi, TuiPluginModule, } from '@opencode-ai/plugin/tui' -import { type BoxRenderable, TextAttributes } from '@opentui/core' -import { - createEffect, - createSignal, - For, - Index, - type JSX, - onCleanup, - Show, -} from 'solid-js' +import { createEffect, createSignal, For, onCleanup, Show } from 'solid-js' import { createRpcClient } from './rpc/rpc-client.js' import { getRpcDir } from './rpc/rpc-dir.js' import { - type AccountQuota, - computeQuotaPacing, DEFAULT_SIDEBAR_STATE, - FIVE_HOUR_MS, - formatPrimeAccountValue, - formatQuotaRowLabel, - getCollapsedAccounts, - getCollapsedQuotaSummary, - getFableRecoverySummary, getSidebarState, - type QuotaPacing, - resolveActiveAccount, - SEVEN_DAY_MS, type SidebarState, } from './sidebar-state.js' +import { + AccountBlock, + ClaudeSidebar, + createSidebarController, + type SidebarController, +} from './tui/claude-sidebar.js' import { openCommandDialog, openNativeMenuDialog, } from './tui/command-dialogs.js' import { - type AnthropicAuthTuiPrefs, - type AppearancePrefs, computeEffectiveOrder, DEFAULT_SLOT_ORDER, PLUGIN_KEY, - queueTuiPreferenceUpdate, readTuiPreferencesFile, resolveAnthropicAuthPrefs, - watchTuiPreferences, } from './tui-preferences.js' const RPC_POLL_MS = 500 @@ -63,473 +36,6 @@ let rpcPollStarted = false const ID = 'cortexkit.anthropic-auth' -// Read package metadata from either the raw src/ entry or its generated -// src/tui-compiled/ counterpart. Avoid a JSON import because package.json sits -// outside the declaration build's rootDir. -const PLUGIN_VERSION: string = (() => { - const here = dirname(fileURLToPath(import.meta.url)) - for (const packageFile of [ - join(here, '..', 'package.json'), - join(here, '..', '..', 'package.json'), - ]) { - try { - const raw = readFileSync(packageFile, 'utf8') - const version = (JSON.parse(raw) as { version?: string }).version - if (version) return version - } catch { - // Try the path for the other TUI entry layout. - } - } - return '' -})() - -// biome-ignore lint/suspicious/noExplicitAny: opentui border prop not typed in plugin tui surface -const SINGLE_BORDER = { type: 'single' } as any - -type Tone = 'ok' | 'warn' | 'err' | 'muted' | 'accent' | 'text' -type ThemeCurrent = TuiPluginApi['theme']['current'] -type ThemeColor = ThemeCurrent['text'] - -// Tone -> theme token. Theme tokens only — never hardcoded colors. Fallbacks -// resolve to other theme tokens so the sidebar always tracks the active theme. -function toneColor(theme: ThemeCurrent, tone: Tone): ThemeColor { - switch (tone) { - case 'ok': - return theme.success ?? theme.accent - case 'warn': - return theme.warning ?? theme.accent - case 'err': - return theme.error ?? theme.accent - case 'muted': - return theme.textMuted ?? theme.text - case 'accent': - return theme.accent ?? theme.text - default: - return theme.text - } -} - -function usageTone(usedPct: number, appearance: AppearancePrefs): Tone { - if (usedPct < appearance.warnThreshold) return 'ok' - if (usedPct < appearance.errorThreshold) return 'warn' - return 'err' -} - -interface BarSegment { - text: string - tone: Tone -} - -const PACE_RESERVE_CHAR = '\u2592' -const PACE_DEFICIT_CHAR = '\u2593' - -// Variant C pacing bar: normal fill up to min(used, pace), then a pace -// segment covering the gap — headroom being banked (reserve, ok tone) or the -// overshoot itself (deficit, err tone) — then empty cells. Without pacing the -// bar renders as a single fill+empty pair, identical to the pre-pacing look. -// Empty text nodes still occupy a phantom cell in the opentui flex row, so -// zero-length segments are dropped from every return path (plain and pacing). -function quotaBarSegments( - usedPct: number, - appearance: AppearancePrefs, - pacing: QuotaPacing | null, -): BarSegment[] { - const width = appearance.barWidth - const cells = (pct: number) => - Math.max(0, Math.min(Math.round((pct / 100) * width), width)) - const usedCells = cells(usedPct) - const fillTone = usageTone(usedPct, appearance) - const plain: BarSegment[] = [ - { text: appearance.barFilledChar.repeat(usedCells), tone: fillTone }, - { - text: appearance.barEmptyChar.repeat(width - usedCells), - tone: fillTone, - }, - ] - if (!pacing) return plain.filter((segment) => segment.text.length > 0) - const paceCells = cells(pacing.pacePercent) - const lo = Math.min(usedCells, paceCells) - const hi = Math.max(usedCells, paceCells) - if (hi === lo) return plain.filter((segment) => segment.text.length > 0) - const overspent = usedCells > paceCells - return ( - [ - { text: appearance.barFilledChar.repeat(lo), tone: fillTone }, - { - text: (overspent ? PACE_DEFICIT_CHAR : PACE_RESERVE_CHAR).repeat( - hi - lo, - ), - tone: overspent ? 'err' : 'ok', - }, - { text: appearance.barEmptyChar.repeat(width - hi), tone: fillTone }, - ] as BarSegment[] - ).filter((segment) => segment.text.length > 0) -} - -function formatResetIn(resetsAt: string | undefined): string { - if (!resetsAt) return '' - const ms = new Date(resetsAt).getTime() - Date.now() - if (ms <= 0) return 'now' - const mins = Math.floor(ms / 60_000) - if (mins < 60) return `${mins}m` - const hrs = Math.floor(mins / 60) - if (hrs < 24) { - const rm = mins % 60 - return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h` - } - const days = Math.floor(hrs / 24) - const rh = hrs % 24 - return rh > 0 ? `${days}d${rh}h` : `${days}d` -} - -function formatUntil(until: number | undefined): string { - if (!until) return '' - const ms = until - Date.now() - if (ms <= 0) return 'now' - const mins = Math.ceil(ms / 60_000) - if (mins < 60) return `${mins}m` - const hrs = Math.floor(mins / 60) - const rm = mins % 60 - return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h` -} - -// --- Reusable components (aft-style) --------------------------------------- - -function SectionHeader(props: { - theme: ThemeCurrent - title: string - marginTop?: number -}) { - return ( - - - {props.title} - - - ) -} - -function StatRow(props: { - theme: ThemeCurrent - label: string - value: string - tone?: Tone -}) { - return ( - - {props.label} - - {props.value} - - - ) -} - -// Compact row for the collapsed view: muted label left, caller-provided value -// right. Mirrors StatRow's layout so columns line up. -function CollapsedRow(props: { - theme: ThemeCurrent - label: string - children: JSX.Element -}) { - return ( - - {props.label} - {props.children} - - ) -} - -function CollapsedAccountRow(props: { - theme: ThemeCurrent - account: ReturnType[number] - tone: Tone -}) { - const [width, setWidth] = createSignal(40) - let row: BoxRenderable | undefined - const summary = () => getCollapsedQuotaSummary(props.account.quota) - const value = () => summary().text ?? '—' - const labelWidth = () => Math.max(0, width() - Bun.stringWidth(value()) - 1) - const dim = () => - !props.account.active || props.account.quotaDated || summary().stale - const attributes = () => (dim() ? TextAttributes.DIM : TextAttributes.BOLD) - const color = () => - dim() ? props.theme.textMuted : toneColor(props.theme, props.tone) - return ( - { - row = element - setWidth(element.width || 40) - }} - onSizeChange={() => setWidth(row?.width || 40)} - > - 0}> - - {formatQuotaRowLabel(props.account.name, labelWidth())} - - - {' '} - - - - {value()} - - - ) -} - -// Quota window row: muted label left, tone-colored bar + shared figure right, -// with an optional muted reset suffix. When pacing data is present, the bar -// gains a pace segment and an off-pace window adds a muted subline with the -// reserve/deficit delta and the projected runout. -function QuotaRow(props: { - theme: ThemeCurrent - appearance: AppearancePrefs - label: string - part: QuotaWindowPart | undefined - usedPercent: number | undefined - pacing: QuotaPacing | null - binding?: boolean -}) { - const used = () => props.usedPercent ?? 0 - const reset = () => props.part?.resetIn - const paceLine = () => { - const pacing = props.pacing - if (!pacing || pacing.state === 'on-pace') return null - const pct = Math.round(Math.abs(pacing.deltaPercent)) - if (pacing.state === 'reserve') return `reserve ${pct}% \u00b7 lasts` - return pacing.runsOutAt - ? `deficit ${pct}% \u00b7 out in ${formatResetIn(pacing.runsOutAt)}` - : `deficit ${pct}% \u00b7 lasts` - } - return ( - - {`${formatQuotaRowLabel(props.label)} `} - {'\u2014'} - - } - > - {/* Left group (label · bar · figure) stays left-aligned in fixed columns so - bars and figures line up across rows; the reset time is pushed to - the right edge so reset times align in their own right column. */} - - - {`${formatQuotaRowLabel(props.label)} `} - - {(segment) => ( - - {segment.text} - - )} - - - {` ${props.part?.figure}${props.binding ? ' •' : ''}`} - - - - {reset()} - - - - - {formatQuotaRowLabel('', 11)} - - {paceLine()} - - - - - ) -} - -// The shared credit figure followed by the spent and limit amounts: -// `credits 75% left · $25.00/$100.00`. When that line is wider than the -// measured width, the amounts drop their cents (`$1,250/$5,000`) instead of -// wrapping onto a second line. -function creditLine( - figure: string, - extraUsage: AccountQuota['extraUsage'], - width: number | undefined, -) { - const amounts = formatCreditAmounts(extraUsage) - if (!amounts) return figure - const line = `${figure} · ${amounts}` - if (width === undefined || Bun.stringWidth(line) <= width) return line - return `${figure} · ${formatCreditAmounts(extraUsage, { wholeUnits: true })}` -} - -// Account block: header row (name + status word) then per-window quota rows. -// Pacing is computed here — per window, against the wall clock at render time -// (re-evaluated on every state poll) — and disabled by passing false. -function AccountBlock(props: { - theme: ThemeCurrent - appearance: AppearancePrefs - name: string - quota: AccountQuota | null - active: boolean - pacingEnabled: boolean - needsReauth?: boolean - vaultReauth?: boolean - stateLine?: string - quotaDated?: boolean - tierLabel?: string - marginTop?: number -}) { - const statusWord = () => - props.quotaDated && props.quota - ? 'dated' - : props.needsReauth - ? 're-login' - : props.vaultReauth - ? 'needs sign-in in the vault' - : props.active - ? 'active' - : 'idle' - const statusTone = (): Tone => - props.needsReauth || props.vaultReauth || props.stateLine - ? 'warn' - : props.active - ? 'ok' - : 'muted' - const pacingFor = ( - window: - | { usedPercent: number; remainingPercent: number; resetsAt?: string } - | undefined, - windowMs: number, - ) => - props.pacingEnabled && !props.quotaDated && window - ? computeQuotaPacing(window, windowMs, Date.now()) - : null - const parts = () => { - const now = Date.now() - return quotaWindowParts(quotaDisplayProjection(props.quota, now), { now }) - } - const partFor = (name: string) => - parts().windows.find((part) => part.name === name) - return ( - - - - {`${props.name}${props.tierLabel ? ` · ${props.tierLabel}` : ''}`} - - - {statusWord()} - - - - {props.stateLine} - - {'checking\u2026'}} - > - - - - {(window) => ( - - )} - - - {(budget: () => QuotaWindowPart) => { - // The line box's laid-out width, read when it mounts and on each - // resize. It is undefined (amounts keep their cents) until layout. - const [width, setWidth] = createSignal() - let line: BoxRenderable | undefined - return ( - { - line = element - setWidth(element.width || undefined) - }} - onSizeChange={() => setWidth(line?.width || undefined)} - > - - {creditLine( - budget().figure, - props.quota?.extraUsage, - width(), - )} - - - ) - }} - - - {parts().age} - - - {'→ fallback advised'} - - - - ) -} - // --- Quota dialog content --------------------------------------------------- // Renders the same rich visualization as the sidebar (account blocks with @@ -608,77 +114,12 @@ async function readStateFromFile(): Promise { return getSidebarState() } -interface SidebarController { - prefs: () => AnthropicAuthTuiPrefs - collapsed: () => boolean - toggleCollapsed: () => void -} - -// The TUI may unmount and remount sidebar_content when the user switches -// views (e.g. main -> subagent -> main). A remount re-runs the component -// body, so any signal created inside the component would reset to its -// seed. The controller lives in the plugin closure (process lifetime) -// and owns the durable prefs/collapse signals plus the single shared -// watcher subscription, so collapse and live pref reloads survive the -// remount. No effects or memos here — those need a Solid owner, so the -// poll-interval createEffect stays inside the component. -function createSidebarController( - initialPrefs: AnthropicAuthTuiPrefs, -): SidebarController { - const [prefs, setPrefs] = createSignal(initialPrefs) - const seedCollapsed = - initialPrefs.rememberCollapsed && initialPrefs.collapsed != null - ? initialPrefs.collapsed - : initialPrefs.startCollapsed - const [collapsed, setCollapsed] = createSignal(seedCollapsed) - let lastPersistedCollapsed: boolean | null = initialPrefs.collapsed - let lastApplied = JSON.stringify(initialPrefs) - - // The watcher lives for the plugin/process lifetime — it is intentionally - // never disposed. Collapse guard mirrors the race-fix in toggleCollapsed: - // lastPersistedCollapsed is advanced only once our own write lands, so - // watcher echoes of the previous persisted value are rejected by the - // `!==` check and cannot revert a user click. - watchTuiPreferences(() => { - void (async () => { - const next = resolveAnthropicAuthPrefs(await readTuiPreferencesFile()) - const serialized = JSON.stringify(next) - if (serialized === lastApplied) return - lastApplied = serialized - setPrefs(next) - if ( - next.rememberCollapsed && - next.collapsed != null && - next.collapsed !== lastPersistedCollapsed - ) { - lastPersistedCollapsed = next.collapsed - setCollapsed(next.collapsed) - } - })() - }) - - function toggleCollapsed() { - const next = !collapsed() - setCollapsed(next) - if (prefs().rememberCollapsed) { - void queueTuiPreferenceUpdate(PLUGIN_KEY, ['collapsed'], next).then( - () => { - lastPersistedCollapsed = next - }, - ) - } - } - - return { prefs, collapsed, toggleCollapsed } -} - function QuotaSidebar(props: { api: TuiPluginApi controller: SidebarController sessionId: string }) { const prefs = props.controller.prefs - const collapsed = props.controller.collapsed const [state, setState] = createSignal(DEFAULT_SIDEBAR_STATE) let lastUpdated = 0 let debounce: ReturnType | null = null @@ -716,327 +157,13 @@ function QuotaSidebar(props: { if (debounce) clearTimeout(debounce) for (const u of unsubs) u() }) - - const theme = () => props.api.theme.current - const enabledFallbacks = () => - (state().fallbacks ?? []).filter( - (f) => f.enabled || (f.custodyState && f.custodyState !== 'off'), - ) - const hasData = () => - state().main?.quota != null || - state().main?.stateLine != null || - enabledFallbacks().length > 0 - - const headerLabel = () => { - const name = prefs().header.label - return !hasData() ? name : collapsed() ? `\u25b6 ${name}` : `\u25bc ${name}` - } - const activeAccount = () => resolveActiveAccount(state()) - const activeQuotaSummary = () => - getCollapsedQuotaSummary(activeAccount().quota) - const activePacingDeficit = () => { - if (!prefs().sections.pacing) return false - if (activeAccount().quotaDated) return null - const quota = activeAccount().quota - if (!quota) return false - const now = Date.now() - const windows: Array< - [ - ( - | { usedPercent: number; remainingPercent: number; resetsAt?: string } - | undefined - ), - number, - ] - > = [ - [quota.five_hour, FIVE_HOUR_MS], - [quota.seven_day, SEVEN_DAY_MS], - ...(quota.scoped ?? []).map((window): [typeof window, number] => [ - window, - SEVEN_DAY_MS, - ]), - ] - return windows.some( - ([w, ms]) => - w != null && computeQuotaPacing(w, ms, now)?.state === 'deficit', - ) - } - const activeQuotaTone = (): Tone => { - const summary = activeQuotaSummary() - const values = [ - summary.fiveHourUsedPercent, - summary.sevenDayUsedPercent, - ...summary.scopedUsedPercents, - ].filter((value): value is number => value != null) - // Pacing deficit is an advisory projection, not actual quota exhaustion, - // so it can only BUMP the usage tone up to warn at most — never soften a - // real warn/err usage reading and never paint a true red. - const base: Tone = - values.length > 0 - ? usageTone(Math.max(...values), prefs().appearance) - : 'muted' - if (!activePacingDeficit()) return base - return base === 'ok' || base === 'muted' ? 'warn' : base - } - - const quotaBackedOff = () => state().main?.quotaBackedOff === true - const refreshBackedOff = () => state().main?.refreshBackedOff === true - const needsReauth = () => enabledFallbacks().some((f) => f.needsReauth) - const vaultReauth = () => enabledFallbacks().some((f) => f.vaultReauth) - const degraded = () => - quotaBackedOff() || refreshBackedOff() || needsReauth() || vaultReauth() - - const fableRecoverySummary = () => - getFableRecoverySummary(state(), props.sessionId) - const fableRecoveryTone = (): Tone => - state().fableRecoveries?.find( - (recovery) => recovery.sessionId === props.sessionId, - )?.mode === 'fable' - ? 'ok' - : 'warn' - const cacheKeep = () => state().cacheKeep - const showCache = () => - prefs().sections.cache && cacheKeep() != null && cacheKeep()?.window != null - const prime = () => state().prime - const showPrime = () => prime() != null - const primeHasError = () => - prime()?.accounts.some((account) => account.lastResult === 'error') ?? false - const relayValue = () => { - const r = state().relay - if (!r) return '\u2014' - return `${r.transport} \u00b7 ${r.enabled ? 'on' : 'off'}` - } - return ( - - {/* Header: ▼/▶ CLAUDE badge (click to collapse) + version, or LIMITED badge */} - {/* biome-ignore lint/a11y/noStaticElementInteractions: opentui renders to a terminal, not the DOM — ARIA roles do not apply */} - { - if (hasData()) props.controller.toggleCollapsed() - }} - > - - - {headerLabel()} - - - - {`v${PLUGIN_VERSION}`} - - } - > - - - {'LIMITED'} - - - - - - {/* Collapsed accounts keep placement order; only a fresh active row is emphasized. */} - - {/* Index keys rows by position: each state update builds new account - objects, so For would rebuild every row and its measured width. - Passing the accessor's value as a prop keeps the row reactive. */} - - {(account) => ( - - )} - - - - - {'fast'} - - - - - {(summary: () => string) => ( - - - {summary()} - - - )} - - - - {/* Expanded: full sections. Also render when there's no data so the - sidebar can never go blank if data clears while collapsed. */} - - - {'Waiting for quota\u2026'} - - } - > - {/* Quota */} - - - - - - {(fb) => ( - - )} - - - - - - {/* Routing */} - - - - - - - - - {(summary: () => string) => ( - - )} - - - {/* Cache */} - - - - 0}> - - - - - {/* Prime — only when enabled. Expanded view only (no collapsed surface). */} - - - - {(account) => { - const value = () => formatPrimeAccountValue(account) - return ( - - ) - }} - - - - {/* Health — only when something is wrong */} - - - - - - - - - - - + props.api.theme.current} + controller={props.controller} + sessionId={props.sessionId} + /> ) } diff --git a/packages/opencode/src/tui/claude-sidebar.tsx b/packages/opencode/src/tui/claude-sidebar.tsx new file mode 100644 index 000000000..b5737f65f --- /dev/null +++ b/packages/opencode/src/tui/claude-sidebar.tsx @@ -0,0 +1,1044 @@ +/** @jsxImportSource @opentui/solid */ + +// The CLAUDE quota sidebar, shared by the OpenCode 1 and OpenCode 2 TUIs. +// Each host supplies the sidebar state, a flat theme token set and the +// preference controller; this module only renders. + +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + formatCreditAmounts, + type QuotaWindowPart, + quotaDisplayProjection, + quotaWindowParts, +} from '@cortexkit/anthropic-auth-core' +import { type BoxRenderable, type RGBA, TextAttributes } from '@opentui/core' +import { + createEffect, + createSignal, + For, + Index, + type JSX, + onCleanup, + Show, +} from 'solid-js' +import { + type AccountQuota, + computeQuotaPacing, + DEFAULT_SIDEBAR_STATE, + FIVE_HOUR_MS, + formatPrimeAccountValue, + formatQuotaRowLabel, + getCollapsedAccounts, + getCollapsedQuotaSummary, + getFableRecoverySummary, + type QuotaPacing, + resolveActiveAccount, + SEVEN_DAY_MS, + type SidebarState, +} from '../sidebar-state.js' +import { + type AnthropicAuthTuiPrefs, + type AppearancePrefs, + PLUGIN_KEY, + queueTuiPreferenceUpdate, + readTuiPreferencesFile, + resolveAnthropicAuthPrefs, + watchTuiPreferences, +} from '../tui-preferences.js' + +// Read package metadata from either the raw src/ entry or its generated +// src/tui-compiled/tui/ counterpart. Avoid a JSON import because package.json sits +// outside the declaration build's rootDir. +const PLUGIN_VERSION: string = (() => { + const here = dirname(fileURLToPath(import.meta.url)) + for (const packageFile of [ + join(here, '..', '..', 'package.json'), + join(here, '..', '..', '..', 'package.json'), + ]) { + try { + const raw = readFileSync(packageFile, 'utf8') + const version = (JSON.parse(raw) as { version?: string }).version + if (version) return version + } catch { + // Try the path for the other TUI entry layout. + } + } + return '' +})() + +// biome-ignore lint/suspicious/noExplicitAny: opentui border prop not typed in plugin tui surface +const SINGLE_BORDER = { type: 'single' } as any + +export type SidebarColor = RGBA | string + +/** The flat theme tokens the sidebar draws with; OpenCode 1's `theme.current` has them all. */ +export interface SidebarTheme { + readonly text: SidebarColor + readonly textMuted: SidebarColor + readonly success: SidebarColor + readonly warning: SidebarColor + readonly error: SidebarColor + readonly accent: SidebarColor + readonly background: SidebarColor + readonly borderActive: SidebarColor +} + +type Tone = 'ok' | 'warn' | 'err' | 'muted' | 'accent' | 'text' + +// Tone -> theme token. Theme tokens only — never hardcoded colors. Fallbacks +// resolve to other theme tokens so the sidebar always tracks the active theme. +function toneColor(theme: SidebarTheme, tone: Tone): SidebarColor { + switch (tone) { + case 'ok': + return theme.success ?? theme.accent + case 'warn': + return theme.warning ?? theme.accent + case 'err': + return theme.error ?? theme.accent + case 'muted': + return theme.textMuted ?? theme.text + case 'accent': + return theme.accent ?? theme.text + default: + return theme.text + } +} + +function usageTone(usedPct: number, appearance: AppearancePrefs): Tone { + if (usedPct < appearance.warnThreshold) return 'ok' + if (usedPct < appearance.errorThreshold) return 'warn' + return 'err' +} + +interface BarSegment { + text: string + tone: Tone +} + +const PACE_RESERVE_CHAR = '\u2592' +const PACE_DEFICIT_CHAR = '\u2593' + +// Variant C pacing bar: normal fill up to min(used, pace), then a pace +// segment covering the gap — headroom being banked (reserve, ok tone) or the +// overshoot itself (deficit, err tone) — then empty cells. Without pacing the +// bar renders as a single fill+empty pair, identical to the pre-pacing look. +// Empty text nodes still occupy a phantom cell in the opentui flex row, so +// zero-length segments are dropped from every return path (plain and pacing). +function quotaBarSegments( + usedPct: number, + appearance: AppearancePrefs, + pacing: QuotaPacing | null, +): BarSegment[] { + const width = appearance.barWidth + const cells = (pct: number) => + Math.max(0, Math.min(Math.round((pct / 100) * width), width)) + const usedCells = cells(usedPct) + const fillTone = usageTone(usedPct, appearance) + const plain: BarSegment[] = [ + { text: appearance.barFilledChar.repeat(usedCells), tone: fillTone }, + { + text: appearance.barEmptyChar.repeat(width - usedCells), + tone: fillTone, + }, + ] + if (!pacing) return plain.filter((segment) => segment.text.length > 0) + const paceCells = cells(pacing.pacePercent) + const lo = Math.min(usedCells, paceCells) + const hi = Math.max(usedCells, paceCells) + if (hi === lo) return plain.filter((segment) => segment.text.length > 0) + const overspent = usedCells > paceCells + return ( + [ + { text: appearance.barFilledChar.repeat(lo), tone: fillTone }, + { + text: (overspent ? PACE_DEFICIT_CHAR : PACE_RESERVE_CHAR).repeat( + hi - lo, + ), + tone: overspent ? 'err' : 'ok', + }, + { text: appearance.barEmptyChar.repeat(width - hi), tone: fillTone }, + ] as BarSegment[] + ).filter((segment) => segment.text.length > 0) +} + +function formatResetIn(resetsAt: string | undefined): string { + if (!resetsAt) return '' + const ms = new Date(resetsAt).getTime() - Date.now() + if (ms <= 0) return 'now' + const mins = Math.floor(ms / 60_000) + if (mins < 60) return `${mins}m` + const hrs = Math.floor(mins / 60) + if (hrs < 24) { + const rm = mins % 60 + return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h` + } + const days = Math.floor(hrs / 24) + const rh = hrs % 24 + return rh > 0 ? `${days}d${rh}h` : `${days}d` +} + +function formatUntil(until: number | undefined): string { + if (!until) return '' + const ms = until - Date.now() + if (ms <= 0) return 'now' + const mins = Math.ceil(ms / 60_000) + if (mins < 60) return `${mins}m` + const hrs = Math.floor(mins / 60) + const rm = mins % 60 + return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h` +} + +// --- Reusable components (aft-style) --------------------------------------- + +function SectionHeader(props: { + theme: SidebarTheme + title: string + marginTop?: number +}) { + return ( + + + {props.title} + + + ) +} + +function StatRow(props: { + theme: SidebarTheme + label: string + value: string + tone?: Tone +}) { + return ( + + {props.label} + + {props.value} + + + ) +} + +// Compact row for the collapsed view: muted label left, caller-provided value +// right. Mirrors StatRow's layout so columns line up. +function CollapsedRow(props: { + theme: SidebarTheme + label: string + children: JSX.Element +}) { + return ( + + {props.label} + {props.children} + + ) +} + +function CollapsedAccountRow(props: { + theme: SidebarTheme + account: ReturnType[number] + tone: Tone +}) { + const [width, setWidth] = createSignal(40) + let row: BoxRenderable | undefined + const summary = () => getCollapsedQuotaSummary(props.account.quota) + const value = () => summary().text ?? '—' + const labelWidth = () => Math.max(0, width() - Bun.stringWidth(value()) - 1) + const dim = () => + !props.account.active || props.account.quotaDated || summary().stale + const attributes = () => (dim() ? TextAttributes.DIM : TextAttributes.BOLD) + const color = () => + dim() ? props.theme.textMuted : toneColor(props.theme, props.tone) + return ( + { + row = element + setWidth(element.width || 40) + }} + onSizeChange={() => setWidth(row?.width || 40)} + > + 0}> + + {formatQuotaRowLabel(props.account.name, labelWidth())} + + + {' '} + + + + {value()} + + + ) +} + +// Quota window row: muted label left, tone-colored bar + shared figure right, +// with an optional muted reset suffix. When pacing data is present, the bar +// gains a pace segment and an off-pace window adds a muted subline with the +// reserve/deficit delta and the projected runout. +function QuotaRow(props: { + theme: SidebarTheme + appearance: AppearancePrefs + label: string + part: QuotaWindowPart | undefined + usedPercent: number | undefined + pacing: QuotaPacing | null + binding?: boolean +}) { + const used = () => props.usedPercent ?? 0 + const reset = () => props.part?.resetIn + const paceLine = () => { + const pacing = props.pacing + if (!pacing || pacing.state === 'on-pace') return null + const pct = Math.round(Math.abs(pacing.deltaPercent)) + if (pacing.state === 'reserve') return `reserve ${pct}% \u00b7 lasts` + return pacing.runsOutAt + ? `deficit ${pct}% \u00b7 out in ${formatResetIn(pacing.runsOutAt)}` + : `deficit ${pct}% \u00b7 lasts` + } + return ( + + {`${formatQuotaRowLabel(props.label)} `} + {'\u2014'} + + } + > + {/* Left group (label · bar · figure) stays left-aligned in fixed columns so + bars and figures line up across rows; the reset time is pushed to + the right edge so reset times align in their own right column. */} + + + {`${formatQuotaRowLabel(props.label)} `} + + {(segment) => ( + + {segment.text} + + )} + + + {` ${props.part?.figure}${props.binding ? ' •' : ''}`} + + + + {reset()} + + + + + {formatQuotaRowLabel('', 11)} + + {paceLine()} + + + + + ) +} + +// The shared credit figure followed by the spent and limit amounts: +// `credits 75% left · $25.00/$100.00`. When that line is wider than the +// measured width, the amounts drop their cents (`$1,250/$5,000`) instead of +// wrapping onto a second line. +function creditLine( + figure: string, + extraUsage: AccountQuota['extraUsage'], + width: number | undefined, +) { + const amounts = formatCreditAmounts(extraUsage) + if (!amounts) return figure + const line = `${figure} · ${amounts}` + if (width === undefined || Bun.stringWidth(line) <= width) return line + return `${figure} · ${formatCreditAmounts(extraUsage, { wholeUnits: true })}` +} + +// Account block: header row (name + status word) then per-window quota rows. +// Pacing is computed here — per window, against the wall clock at render time +// (re-evaluated on every state poll) — and disabled by passing false. +export function AccountBlock(props: { + theme: SidebarTheme + appearance: AppearancePrefs + name: string + quota: AccountQuota | null + active: boolean + pacingEnabled: boolean + needsReauth?: boolean + vaultReauth?: boolean + stateLine?: string + quotaDated?: boolean + tierLabel?: string + marginTop?: number +}) { + const statusWord = () => + props.quotaDated && props.quota + ? 'dated' + : props.needsReauth + ? 're-login' + : props.vaultReauth + ? 'needs sign-in in the vault' + : props.active + ? 'active' + : 'idle' + const statusTone = (): Tone => + props.needsReauth || props.vaultReauth || props.stateLine + ? 'warn' + : props.active + ? 'ok' + : 'muted' + const pacingFor = ( + window: + | { usedPercent: number; remainingPercent: number; resetsAt?: string } + | undefined, + windowMs: number, + ) => + props.pacingEnabled && !props.quotaDated && window + ? computeQuotaPacing(window, windowMs, Date.now()) + : null + const parts = () => { + const now = Date.now() + return quotaWindowParts(quotaDisplayProjection(props.quota, now), { now }) + } + const partFor = (name: string) => + parts().windows.find((part) => part.name === name) + return ( + + + + {`${props.name}${props.tierLabel ? ` · ${props.tierLabel}` : ''}`} + + + {statusWord()} + + + + {props.stateLine} + + {'checking\u2026'}} + > + + + + {(window) => ( + + )} + + + {(budget: () => QuotaWindowPart) => { + // The line box's laid-out width, read when it mounts and on each + // resize. It is undefined (amounts keep their cents) until layout. + const [width, setWidth] = createSignal() + let line: BoxRenderable | undefined + return ( + { + line = element + setWidth(element.width || undefined) + }} + onSizeChange={() => setWidth(line?.width || undefined)} + > + + {creditLine( + budget().figure, + props.quota?.extraUsage, + width(), + )} + + + ) + }} + + + {parts().age} + + + {'→ fallback advised'} + + + + ) +} + +export interface SidebarController { + prefs: () => AnthropicAuthTuiPrefs + collapsed: () => boolean + toggleCollapsed: () => void + /** Stops the preference watcher; OpenCode 1 keeps it for the process. */ + dispose: () => void +} + +// The TUI may unmount and remount sidebar_content when the user switches +// views (e.g. main -> subagent -> main). A remount re-runs the component +// body, so any signal created inside the component would reset to its +// seed. The controller lives in the plugin closure (process lifetime) +// and owns the durable prefs/collapse signals plus the single shared +// watcher subscription, so collapse and live pref reloads survive the +// remount. No effects or memos here — those need a Solid owner, so the +// poll-interval createEffect stays inside the component. +export function createSidebarController( + initialPrefs: AnthropicAuthTuiPrefs, +): SidebarController { + const [prefs, setPrefs] = createSignal(initialPrefs) + const seedCollapsed = + initialPrefs.rememberCollapsed && initialPrefs.collapsed != null + ? initialPrefs.collapsed + : initialPrefs.startCollapsed + const [collapsed, setCollapsed] = createSignal(seedCollapsed) + let lastPersistedCollapsed: boolean | null = initialPrefs.collapsed + let lastApplied = JSON.stringify(initialPrefs) + + // OpenCode 1 keeps the watcher for the process lifetime; OpenCode 2 calls + // dispose when the plugin unloads. Collapse guard mirrors the race-fix in toggleCollapsed: + // lastPersistedCollapsed is advanced only once our own write lands, so + // watcher echoes of the previous persisted value are rejected by the + // `!==` check and cannot revert a user click. + const dispose = watchTuiPreferences(() => { + void (async () => { + const next = resolveAnthropicAuthPrefs(await readTuiPreferencesFile()) + const serialized = JSON.stringify(next) + if (serialized === lastApplied) return + lastApplied = serialized + setPrefs(next) + if ( + next.rememberCollapsed && + next.collapsed != null && + next.collapsed !== lastPersistedCollapsed + ) { + lastPersistedCollapsed = next.collapsed + setCollapsed(next.collapsed) + } + })() + }) + + function toggleCollapsed() { + const next = !collapsed() + setCollapsed(next) + if (prefs().rememberCollapsed) { + void queueTuiPreferenceUpdate(PLUGIN_KEY, ['collapsed'], next).then( + () => { + lastPersistedCollapsed = next + }, + ) + } + } + + return { prefs, collapsed, toggleCollapsed, dispose } +} + +/** The sidebar panel for one session; the host owns polling and the theme. */ +export function ClaudeSidebar(props: { + state: () => SidebarState + theme: () => SidebarTheme + controller: SidebarController + sessionId: string +}) { + const prefs = props.controller.prefs + const collapsed = props.controller.collapsed + const state = props.state + const theme = props.theme + const enabledFallbacks = () => + (state().fallbacks ?? []).filter( + (f) => f.enabled || (f.custodyState && f.custodyState !== 'off'), + ) + const hasData = () => + state().main?.quota != null || + state().main?.stateLine != null || + enabledFallbacks().length > 0 + + const headerLabel = () => { + const name = prefs().header.label + return !hasData() ? name : collapsed() ? `\u25b6 ${name}` : `\u25bc ${name}` + } + const activeAccount = () => resolveActiveAccount(state()) + const activeQuotaSummary = () => + getCollapsedQuotaSummary(activeAccount().quota) + const activePacingDeficit = () => { + if (!prefs().sections.pacing) return false + if (activeAccount().quotaDated) return null + const quota = activeAccount().quota + if (!quota) return false + const now = Date.now() + const windows: Array< + [ + ( + | { usedPercent: number; remainingPercent: number; resetsAt?: string } + | undefined + ), + number, + ] + > = [ + [quota.five_hour, FIVE_HOUR_MS], + [quota.seven_day, SEVEN_DAY_MS], + ...(quota.scoped ?? []).map((window): [typeof window, number] => [ + window, + SEVEN_DAY_MS, + ]), + ] + return windows.some( + ([w, ms]) => + w != null && computeQuotaPacing(w, ms, now)?.state === 'deficit', + ) + } + const activeQuotaTone = (): Tone => { + const summary = activeQuotaSummary() + const values = [ + summary.fiveHourUsedPercent, + summary.sevenDayUsedPercent, + ...summary.scopedUsedPercents, + ].filter((value): value is number => value != null) + // Pacing deficit is an advisory projection, not actual quota exhaustion, + // so it can only BUMP the usage tone up to warn at most — never soften a + // real warn/err usage reading and never paint a true red. + const base: Tone = + values.length > 0 + ? usageTone(Math.max(...values), prefs().appearance) + : 'muted' + if (!activePacingDeficit()) return base + return base === 'ok' || base === 'muted' ? 'warn' : base + } + + const quotaBackedOff = () => state().main?.quotaBackedOff === true + const refreshBackedOff = () => state().main?.refreshBackedOff === true + const needsReauth = () => enabledFallbacks().some((f) => f.needsReauth) + const vaultReauth = () => enabledFallbacks().some((f) => f.vaultReauth) + const degraded = () => + quotaBackedOff() || refreshBackedOff() || needsReauth() || vaultReauth() + + const fableRecoverySummary = () => + getFableRecoverySummary(state(), props.sessionId) + const fableRecoveryTone = (): Tone => + state().fableRecoveries?.find( + (recovery) => recovery.sessionId === props.sessionId, + )?.mode === 'fable' + ? 'ok' + : 'warn' + const cacheKeep = () => state().cacheKeep + const showCache = () => + prefs().sections.cache && cacheKeep() != null && cacheKeep()?.window != null + const prime = () => state().prime + const showPrime = () => prime() != null + const primeHasError = () => + prime()?.accounts.some((account) => account.lastResult === 'error') ?? false + const relayValue = () => { + const r = state().relay + if (!r) return '\u2014' + return `${r.transport} \u00b7 ${r.enabled ? 'on' : 'off'}` + } + + return ( + + {/* Header: ▼/▶ CLAUDE badge (click to collapse) + version, or LIMITED badge */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: opentui renders to a terminal, not the DOM — ARIA roles do not apply */} + { + if (hasData()) props.controller.toggleCollapsed() + }} + > + + + {headerLabel()} + + + + {`v${PLUGIN_VERSION}`} + + } + > + + + {'LIMITED'} + + + + + + {/* Collapsed accounts keep placement order; only a fresh active row is emphasized. */} + + {/* Index keys rows by position: each state update builds new account + objects, so For would rebuild every row and its measured width. + Passing the accessor's value as a prop keeps the row reactive. */} + + {(account) => ( + + )} + + + + + {'fast'} + + + + + {(summary: () => string) => ( + + + {summary()} + + + )} + + + + {/* Expanded: full sections. Also render when there's no data so the + sidebar can never go blank if data clears while collapsed. */} + + + {'Waiting for quota\u2026'} + + } + > + {/* Quota */} + + + + + + {(fb) => ( + + )} + + + + + + {/* Routing */} + + + + + + + + + {(summary: () => string) => ( + + )} + + + {/* Cache */} + + + + 0}> + + + + + {/* Prime — only when enabled. Expanded view only (no collapsed surface). */} + + + + {(account) => { + const value = () => formatPrimeAccountValue(account) + return ( + + ) + }} + + + + {/* Health — only when something is wrong */} + + + + + + + + + + + + ) +} + +// --- OpenCode 2 ------------------------------------------------------------- + +/** The OpenCode 2 theme tokens the sidebar reads (`ResolvedTheme` subset). */ +export interface HostTheme { + readonly text: { + readonly base: SidebarColor + readonly muted: SidebarColor + readonly action: { readonly primary: { readonly base: SidebarColor } } + readonly feedback: { + readonly success: { readonly base: SidebarColor } + readonly warning: { readonly base: SidebarColor } + readonly error: { readonly base: SidebarColor } + } + } + readonly background: { readonly base: SidebarColor } + readonly border: { readonly base: SidebarColor } +} + +/** OpenCode 2's semantic theme as the flat tokens OpenCode 1 provides. */ +export function sidebarThemeFromHost(theme: HostTheme): SidebarTheme { + return { + text: theme.text.base, + textMuted: theme.text.muted, + success: theme.text.feedback.success.base, + warning: theme.text.feedback.warning.base, + error: theme.text.feedback.error.base, + accent: theme.text.action.primary.base, + background: theme.background.base, + borderActive: theme.border.base, + } +} + +/** The OpenCode 2 TUI context members the sidebar uses. */ +export interface ClaudeSidebarHost { + readonly theme: HostTheme + readonly ui: { + slot(claim: { + readonly append: 'sidebar.content' + readonly render: (input: { readonly sessionID: string }) => JSX.Element + }): () => void + } +} + +/** Reads one session's sidebar state from the location's server. */ +export type ReadSidebarState = ( + sessionID: string, + signal: AbortSignal, +) => Promise + +function HostSidebar(props: { + host: ClaudeSidebarHost + read: ReadSidebarState + controller: SidebarController + sessionId: string +}) { + const [state, setState] = createSignal(DEFAULT_SIDEBAR_STATE) + const abort = new AbortController() + let inFlight = false + let shown = '' + const refresh = async () => { + if (inFlight || abort.signal.aborted) return + inFlight = true + try { + const next = await props.read(props.sessionId, abort.signal) + // Every answer is stamped now; redraw only when its content changed. + const content = JSON.stringify({ ...next, lastUpdated: 0 }) + if (!abort.signal.aborted && content !== shown) { + shown = content + setState(next) + } + } catch { + // Keep the last state; an older server without `sidebar` keeps waiting. + } finally { + inFlight = false + } + } + createEffect(() => { + const timer = setInterval(refresh, props.controller.prefs().pollMs) + onCleanup(() => clearInterval(timer)) + }) + void refresh() + onCleanup(() => abort.abort()) + return ( + sidebarThemeFromHost(props.host.theme)} + controller={props.controller} + sessionId={props.sessionId} + /> + ) +} + +/** Claims the OpenCode 2 session sidebar; the returned function releases it. */ +export async function mountClaudeSidebar( + host: ClaudeSidebarHost, + read: ReadSidebarState, +): Promise<() => void> { + const controller = createSidebarController( + resolveAnthropicAuthPrefs(await readTuiPreferencesFile()), + ) + const release = host.ui.slot({ + append: 'sidebar.content', + render: (input) => ( + + ), + }) + return () => { + release() + controller.dispose() + } +} diff --git a/packages/opencode/src/tui/entry.mjs b/packages/opencode/src/tui/entry.mjs index 3c78ef5da..64a797a74 100644 --- a/packages/opencode/src/tui/entry.mjs +++ b/packages/opencode/src/tui/entry.mjs @@ -37,6 +37,17 @@ async function loadLegacyTui() { return mod.default } +// The quota sidebar renderer shared with OpenCode 1, loaded the same way. +async function loadSidebar() { + try { + await import(runtimeProbe) + } catch (error) { + if (!isMissingRuntimeRegistry(error)) throw error + return import('./claude-sidebar.tsx') + } + return import('../tui-compiled/tui/claude-sidebar.tsx') +} + let legacyModule // OpenCode 1 reads id and tui from the default definition; OpenCode 2 reads @@ -57,7 +68,10 @@ export const setup = async (context) => { // TUI must not load the OpenCode 1 renderer or start that renderer's separate // loopback notification poller. const { setupAnthropicTui } = await import('../../dist/v2/tui.js') - return setupAnthropicTui(context) + return setupAnthropicTui(context, { + mountSidebar: async (ctx, read) => + (await loadSidebar()).mountClaudeSidebar(ctx, read), + }) } export default { id, tui, setup } diff --git a/packages/opencode/src/v2/menu-contract.ts b/packages/opencode/src/v2/menu-contract.ts index 6adeacd50..a2930479a 100644 --- a/packages/opencode/src/v2/menu-contract.ts +++ b/packages/opencode/src/v2/menu-contract.ts @@ -2,6 +2,7 @@ import type { NativeMenuCommandId, NativeMenuDispatch, } from '@cortexkit/anthropic-auth-core' +import type { SidebarState } from '../sidebar-state' /** Account, setting and session actions exposed through the server's menu RPC. */ export interface NativeMenuCapabilities { @@ -9,6 +10,8 @@ export interface NativeMenuCapabilities { readonly readStatus: ( command: Exclude, ) => Promise + /** The quota sidebar's state for one session; carries no secrets. */ + readonly readSidebar: (sessionID: string) => Promise readonly forgetSession: (sessionID: string) => void readonly close: () => void } diff --git a/packages/opencode/src/v2/menu.ts b/packages/opencode/src/v2/menu.ts index 3e8b04927..a8407dbcd 100644 --- a/packages/opencode/src/v2/menu.ts +++ b/packages/opencode/src/v2/menu.ts @@ -22,6 +22,7 @@ import { } from '@cortexkit/anthropic-auth-core' import { LANE_START_TEXT } from '../lane-start' import type { NativeMenuCapabilities } from './menu-contract' +import { buildNativeSidebarState } from './sidebar' import type { NativeSource } from './source' export type { NativeMenuCapabilities } from './menu-contract' @@ -320,6 +321,17 @@ export function createNativeMenuCapabilities(options: { } as const return JSON.stringify(snapshot.settings[fields[command]] ?? {}) }, + async readSidebar(sessionID) { + const [snapshot, sessions] = await Promise.all([ + runtime.read(), + options.cacheKeepRegistry?.list() ?? [], + ]) + return buildNativeSidebarState(snapshot, { + activeId: source.servedAccount(sessionID), + trackedSessions: sessions.length, + now: Date.now(), + }) + }, forgetSession(sessionID) { pending.get(sessionID)?.active.abort() pending.delete(sessionID) diff --git a/packages/opencode/src/v2/rpc.ts b/packages/opencode/src/v2/rpc.ts index a8ab7b5a0..3cd7ac025 100644 --- a/packages/opencode/src/v2/rpc.ts +++ b/packages/opencode/src/v2/rpc.ts @@ -8,6 +8,7 @@ import type { import type { Plugin } from '@opencode/plugin' import type { Registration } from '@opencode/plugin/promise/registration' import type { StandardSchemaV1 } from '@standard-schema/spec' +import { normalizeSidebarState, type SidebarState } from '../sidebar-state' import type { NativeMenuCapabilities } from './menu-contract' const record = (value: unknown): value is Record => @@ -79,11 +80,22 @@ const result = (value: unknown): value is CommandApplyResult => record(value.menu) && Array.isArray(value.menu.sections) +// Sidebar state is display data: anything that does not parse becomes the +// empty state, the same fail-closed rule the OpenCode 1 sidebar file uses. +const sidebarState: StandardSchemaV1 = { + '~standard': { + version: 1 as const, + vendor: 'cortexkit-native', + validate: (value: unknown) => ({ value: normalizeSidebarState(value) }), + }, +} + export const NATIVE_MENU_RPC = { id: 'cortexkit-anthropic-native', methods: { open: { input: schema(session), output: schema(dialog) }, apply: { input: schema(apply), output: schema(result) }, + sidebar: { input: schema(session), output: sidebarState }, }, events: { opened: { @@ -129,6 +141,7 @@ export async function registerNativeMenu( const rpc = await ctx.rpc.register(NATIVE_MENU_RPC, { open: (input) => command.open(invocation(input.sessionId)), apply: (input) => command.apply(input, invocation(input.sessionId)), + sidebar: (input) => capabilities.readSidebar(input.sessionId), }) let registered: Registration try { diff --git a/packages/opencode/src/v2/sidebar.ts b/packages/opencode/src/v2/sidebar.ts new file mode 100644 index 000000000..5b33885b5 --- /dev/null +++ b/packages/opencode/src/v2/sidebar.ts @@ -0,0 +1,115 @@ +import { + buildPrimeAccountStatuses, + effectiveRelayStatus, + formatNativeAccountState, + formatOAuthAccountTier, + getCacheKeepWindow, + getRoutingMode, + isCacheKeepAlways, + isCacheKeepHybridActive, + isPermanentRefreshError, + isPrimePersistentlyEnabled, + type NativeAccountSnapshot, + type NativeAccountView, + nativeAccountCanServe, + refreshBackoffActive, +} from '@cortexkit/anthropic-auth-core' +import type { SidebarAccountState, SidebarState } from '../sidebar-state' + +export interface NativeSidebarExtras { + /** The account that served the requested session, if any. */ + readonly activeId: string | undefined + readonly trackedSessions: number + readonly now: number +} + +// Why an account cannot serve, shown in place of its quota; undefined when it can. +const stateLineOf = (account: NativeAccountView) => + nativeAccountCanServe(account) + ? undefined + : formatNativeAccountState(account) || undefined + +const retryPending = (retryAt: number | undefined, now: number) => + typeof retryAt === 'number' && retryAt > now + +/** + * The OpenCode 1 sidebar state, projected from the native account snapshot. + * The snapshot carries no tokens, keys or receipts, and neither does this. + */ +export function buildNativeSidebarState( + snapshot: NativeAccountSnapshot, + extras: NativeSidebarExtras, +): SidebarState { + const { now } = extras + const storage = snapshot.policyStorage + const oauth = snapshot.accounts.filter((account) => account.type === 'oauth') + const main = oauth.find((account) => account.id === 'main') + const mainStateLine = main ? stateLineOf(main) : undefined + const quotaRetryAt = main?.lastQuotaRefreshError?.nextRetryAt + const refreshError = main?.lastRefreshError + const window = getCacheKeepWindow(storage) + return { + main: { + quota: main?.quota ?? null, + enabled: main?.enabled, + tierLabel: formatOAuthAccountTier(main?.profile), + quotaBackedOff: retryPending(quotaRetryAt, now), + quotaBackoffUntil: quotaRetryAt, + refreshBackedOff: refreshBackoffActive( + refreshError, + undefined, + now, + undefined, + ), + refreshBackoffUntil: refreshError?.nextRetryAt, + stateLine: mainStateLine, + quotaDated: Boolean(mainStateLine), + }, + fallbacks: oauth + .filter((account) => account.id !== 'main' && account.enabled) + .map((account): SidebarAccountState => { + const stateLine = stateLineOf(account) + const vault = account.source === 'vault' + return { + id: account.id, + label: account.label, + quota: account.quota ?? null, + enabled: account.enabled, + tierLabel: formatOAuthAccountTier(account.profile), + // Only a permanently dead refresh token whose backoff still runs + // needs a new sign-in; transient failures recover on their own. + needsReauth: + isPermanentRefreshError(account.lastRefreshError) && + refreshBackoffActive( + account.lastRefreshError, + undefined, + now, + undefined, + ), + ...(vault && account.state === 'needs_reauth' + ? { vaultReauth: true } + : {}), + ...(vault && account.state === 'active' ? { vaultServed: true } : {}), + stateLine, + quotaDated: Boolean(stateLine), + } + }), + activeId: extras.activeId, + route: getRoutingMode(storage), + relay: effectiveRelayStatus(storage.relay), + fastMode: storage.claudeFast?.enabled === true, + cacheKeep: { + enabled: isCacheKeepHybridActive(storage), + window: isCacheKeepAlways(storage) + ? 'always' + : window + ? `${window.startHour}-${window.endHour}` + : undefined, + trackedSessions: extras.trackedSessions, + }, + prime: isPrimePersistentlyEnabled(storage) + ? { enabled: true, accounts: buildPrimeAccountStatuses(storage, { now }) } + : undefined, + lastUpdated: now, + } +} diff --git a/packages/opencode/src/v2/source.ts b/packages/opencode/src/v2/source.ts index 75009cc09..bce8376fd 100644 --- a/packages/opencode/src/v2/source.ts +++ b/packages/opencode/src/v2/source.ts @@ -110,6 +110,8 @@ export interface NativeSource extends NativeAnthropicSource { readonly close: () => Promise readonly resetRouting: (sessionID: string) => Promise + /** The account that served the session's latest authorization, if any. */ + readonly servedAccount: (sessionID: string) => string | undefined readonly recordPrime: ( provenance: NativeSourceProvenance, usage: { inputTokens?: number; outputTokens?: number }, @@ -146,6 +148,9 @@ export function createNativeAnthropicSource( const retries = new Map() const choices = new Map() const sessions = new Map() + // The account behind each session's latest authorization, for the sidebar. + // Entries leave with forgetSession, which also bounds `sessions`. + const servedBy = new Map() const reported = new WeakSet() let closed = false const boundRetries = () => { @@ -467,6 +472,7 @@ export function createNativeAnthropicSource( active, relayFallback, }) + servedBy.set(scope.sessionID, accountId) return { ...choice.auth, dispatch } } const common = { @@ -496,6 +502,7 @@ export function createNativeAnthropicSource( subject: material.subject, } states.set(provenance, common) + servedBy.set(scope.sessionID, accountId) const headers: HeaderEdits = material.authHeader === 'authorization-bearer' ? { authorization: `Bearer ${material.apiKey}` } @@ -521,6 +528,7 @@ export function createNativeAnthropicSource( states.set(provenance, { ...common, receipt }) const identity = receipt.assertedAccountIdentity if (!identity) return undefined + servedBy.set(scope.sessionID, accountId) return { route: 'oauth', accessToken: receipt.accessToken, @@ -545,6 +553,7 @@ export function createNativeAnthropicSource( subject: material.subject, } states.set(provenance, { ...common, access: material.access }) + servedBy.set(scope.sessionID, accountId) const identity = material.subject.binding.identity return { route: 'oauth', @@ -798,12 +807,14 @@ export function createNativeAnthropicSource( forgetSession(sessionID) { sessions.get(sessionID)?.abort() sessions.delete(sessionID) + servedBy.delete(sessionID) for (const [reason, retry] of retries) if (retry.sessionID === sessionID) retries.delete(reason) for (const [queueKey, queue] of choices) if (queue[0]?.sessionID === sessionID) choices.delete(queueKey) options.onForgetSession?.(sessionID) }, + servedAccount: (sessionID) => servedBy.get(sessionID), resetRouting: async (sessionID) => routerFor(await runtime.read()).clear(sessionID), async recordPrime(provenance, usage) { diff --git a/packages/opencode/src/v2/tui.ts b/packages/opencode/src/v2/tui.ts index cfe1dfce7..e358313a8 100644 --- a/packages/opencode/src/v2/tui.ts +++ b/packages/opencode/src/v2/tui.ts @@ -6,15 +6,28 @@ import type { } from '@cortexkit/anthropic-auth-core' import type { Plugin as Tui } from '@opencode/plugin/tui' import type { KeyEvent, PasteEvent } from '@opentui/core' +import type { SidebarState } from '../sidebar-state' import { NATIVE_MENU_RPC } from './rpc' +/** + * Claims the quota sidebar. `entry.mjs` passes the precompiled renderer, so + * this bundle stays free of JSX and of the OpenCode 1 TUI. + */ +export type MountSidebar = ( + ctx: Tui.Context, + read: (sessionID: string, signal: AbortSignal) => Promise, +) => Promise<() => void> + /** * Shows the `claude` account menu in OpenCode 2 dialogs. The menu's sections, * items and actions come from the plugin server over NATIVE_MENU_RPC, built by * the same Core menu code that OpenCode 1 and Pi render, so every host shows * the same menu. */ -export async function setupAnthropicTui(ctx: Tui.Context) { +export async function setupAnthropicTui( + ctx: Tui.Context, + options: { readonly mountSidebar?: MountSidebar } = {}, +) { const rpc = ctx.client.rpc(NATIVE_MENU_RPC) let active = true let rendering = false @@ -254,11 +267,21 @@ export async function setupAnthropicTui(ctx: Tui.Context) { }, ], })) + let releaseSidebar: (() => void) | undefined + try { + releaseSidebar = await options.mountSidebar?.(ctx, (sessionId, signal) => + rpc.sidebar({ sessionId }, { signal, location: ctx.location }), + ) + } catch { + // The /claude menu works without the sidebar; a renderer that cannot + // load must not take the menu down with it. + } return () => { active = false abort.abort() remove() removeNotifications() + releaseSidebar?.() } } From 1f1b92b1f80d4fe55c85c47c4f7817842fff3a70 Mon Sep 17 00:00:00 2001 From: Jules Royet Date: Tue, 20 Oct 2026 15:19:15 +0200 Subject: [PATCH 2/3] fix(opencode): address review on the OpenCode 2 quota sidebar - 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. --- CHANGELOG.md | 8 +- STRUCTURE.md | 2 +- docs/sidebar.md | 2 +- packages/opencode/src/sidebar-state.ts | 11 +- .../tests/opencode2-sidebar-render.test.ts | 170 +++++++++++++----- .../src/tests/opencode2-sidebar.test.ts | 88 +++++++++ .../opencode/src/tests/sidebar-state.test.ts | 7 + packages/opencode/src/tui.tsx | 2 + packages/opencode/src/tui/claude-sidebar.tsx | 95 ++++++---- packages/opencode/src/v2/sidebar.ts | 32 +++- 10 files changed, 321 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f38895af..4807a29ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. The OpenCode package is a fork of the original `@ex-machina/opencode-anthropic-auth` plugin, so older entries below the initial CortexKit release are inherited from upstream package history. +## Unreleased + +### Sidebar + +- OpenCode 2 now shows the CLAUDE quota sidebar too, without opening `/claude`. It is the same panel as OpenCode 1 (per-account 5h, 7d and model bars with pacing, collapsed rows, routing, cache, Prime and health), drawn from one shared component in the active OpenCode 2 theme's colors and configured by the same `anthropic-auth` entry in `tui-preferences.jsonc`. The OpenCode 2 server answers it through the native RPC (`sidebar` method) from the account pool, with no file and no credential; the active account is the one that last served the sidebar's session. Turned-off vault accounts stay listed, as on OpenCode 1, and reset countdowns, reading ages and pacing keep advancing between server changes. A TUI talking to an older server shows "Waiting for quota…". +- Before an account has served the session, the collapsed sidebar no longer marks `main` as active, matching the expanded view. + ## 2.1.1 ### Packaging @@ -17,7 +24,6 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ### Sidebar -- OpenCode 2 now shows the CLAUDE quota sidebar too, without opening `/claude`. It is the same panel as OpenCode 1 (per-account 5h, 7d and model bars with pacing, collapsed rows, routing, cache, Prime and health), drawn from one shared component in the active OpenCode 2 theme's colors and configured by the same `anthropic-auth` entry in `tui-preferences.jsonc`. The OpenCode 2 server answers it through the native RPC (`sidebar` method) from the account pool, with no file and no credential; the active account is the one that last served the sidebar's session. A TUI talking to an older server shows "Waiting for quota…". - The collapsed OpenCode 1 sidebar now draws quotas with common-auth's shared narrow form, the same one the other CortexKit auth plugins use. An account-wide window without a reading is now left out instead of shown as a placeholder: `5h 87% · 7d — left` becomes `5h 87% left`. An account with no reading at all still shows a muted `—`, and stale readings are still dimmed. - The collapsed OpenCode 1 sidebar no longer rebuilds every account row each time the sidebar file is rewritten, which made the rows flicker (#379). Rows now stay in place and only their text changes. - A vault default account whose sign-in expired now shows once, as `main` with "needs sign-in in the vault", instead of also appearing as a second vault account (#392). It is still not used for requests until you sign in again. diff --git a/STRUCTURE.md b/STRUCTURE.md index 6b3b649b9..6807d4fed 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -77,7 +77,7 @@ anthropic-auth/ - `packages/opencode/src/cli.ts`: CLI binary entry (`opencode-anthropic-auth`) - `packages/pi/src/index.ts`: Pi extension entry (default export function) - `packages/opencode/src/tui.tsx`: OpenCode 1 TUI plugin (sidebar file polling, loopback dialogs), loaded through `src/tui/entry.mjs` from precompiled output on current OpenTUI hosts -- `packages/opencode/src/tui/claude-sidebar.tsx`: the quota sidebar renderer both generations use; OpenCode 2's `entry.mjs` setup loads it precompiled and mounts it on `sidebar.content` +- `packages/opencode/src/tui/claude-sidebar.tsx`: the quota sidebar renderer both generations use; OpenCode 2's `entry.mjs` setup loads the precompiled bundle when the host runtime registry is available, falls back to the TSX source otherwise, and mounts it on `sidebar.content` - `packages/core/src/index.ts`: Shared core library entry (re-exports all modules) **Configuration:** diff --git a/docs/sidebar.md b/docs/sidebar.md index 3d3f61d0e..57050b783 100644 --- a/docs/sidebar.md +++ b/docs/sidebar.md @@ -4,7 +4,7 @@ OpenCode 1 and OpenCode 2 render a live quota sidebar without opening `/claude`. It is not a Pi widget. Both generations draw it from the same component and read the same preferences below. -OpenCode 2 needs no extra registration: the main plugin entry loads its TUI, which adds the panel to the session sidebar, takes its colors from the active OpenCode 2 theme, and asks the location's server for the session's state over the plugin's native RPC. The server builds that state from the account pool and never includes tokens, keys or account identities. The active account is the one that last served that session; before its first request none is marked active. An OpenCode 2 TUI talking to an older plugin server shows "Waiting for quota…". +OpenCode 2 needs no extra registration: the main plugin entry loads its TUI, which adds the panel to the session sidebar, takes its colors from the active OpenCode 2 theme, and asks the location's server for the session's state over the plugin's native RPC. The server builds that state from the account pool and never includes tokens, keys, provider account identities or credential material; it does carry the local route ids and labels the panel shows. The active account is the one that last served that session; before its first request none is marked active. An OpenCode 2 TUI talking to an older plugin server shows "Waiting for quota…". OpenCode 1 loads TUI plugins from `tui.json` or `tui.jsonc` in its config directory, separately from the main `plugin` array: diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index bfe56e25d..a143d36d2 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -888,7 +888,10 @@ export function formatPrimeCost(value: number): string { return value.toFixed(Math.min(6, Math.max(0, 4))) } -export function formatPrimeAccountValue(account: PrimeSidebarAccountState): { +export function formatPrimeAccountValue( + account: PrimeSidebarAccountState, + now = Date.now(), +): { text: string hasError: boolean } { @@ -898,7 +901,7 @@ export function formatPrimeAccountValue(account: PrimeSidebarAccountState): { if (account.lastResult === 'skipped') { return { text: 'skip', hasError: false } } - if (account.nextDueAt && account.nextDueAt > Date.now()) { + if (account.nextDueAt && account.nextDueAt > now) { return { text: formatPrimeTime(account.nextDueAt), hasError: false } } if (account.lastPrimedAt) { @@ -994,7 +997,9 @@ export function getCollapsedAccounts( state: SidebarState, showFallbacks = true, ) { - const activeId = resolveActiveAccount(state).id + // Before any request no account is active; do not mark main as serving. + const activeId = + state.activeId === undefined ? undefined : resolveActiveAccount(state).id return [ { id: 'main', diff --git a/packages/opencode/src/tests/opencode2-sidebar-render.test.ts b/packages/opencode/src/tests/opencode2-sidebar-render.test.ts index 746338bfe..8e6b932eb 100644 --- a/packages/opencode/src/tests/opencode2-sidebar-render.test.ts +++ b/packages/opencode/src/tests/opencode2-sidebar-render.test.ts @@ -14,13 +14,24 @@ afterEach(async () => { fixture = undefined }) +interface Step { + /** Text the frame must contain before the step completes. */ + wanted: string[] + /** Sidebar calls the server must have answered first. */ + calls?: number + /** Moves the probe's clock forward once the step completes. */ + advanceMs?: number +} + // Loads the package's `./tui` entry the way OpenCode 2 does (`setup(context)`), // so the bundled v2 TUI, the RPC call and the precompiled sidebar all run. +// The server gives `answers` in order and repeats the last; `null` is a server +// without the `sidebar` method. async function renderOpenCode2Sidebar( - answer: unknown, - wanted: string[], + answers: unknown[], + steps: Step[], ): Promise<{ - frame: string + frames: string[] asked: string[] released: number colors: Record @@ -28,15 +39,21 @@ async function renderOpenCode2Sidebar( fixture = await mkdtemp(join(tmpdir(), 'anthropic-oc2-sidebar-')) await writeFile( join(fixture, 'preferences.jsonc'), - JSON.stringify({ 'anthropic-auth': { rememberCollapsed: false } }), + JSON.stringify({ + 'anthropic-auth': { rememberCollapsed: false, pollMs: 500 }, + }), ) const probe = ` import { createTestRenderer } from '@opentui/core/testing'; import { render } from '@opentui/solid'; import { ensureRuntimePluginSupport } from '@opentui/solid/runtime-plugin-support/configure'; ensureRuntimePluginSupport(); +const realNow = Date.now; +let clock = 0; +Date.now = () => realNow() + clock; const { setup } = await import(${JSON.stringify(new URL('../tui/entry.mjs', import.meta.url).href)}); -const answer = ${JSON.stringify(answer)}; +const answers = ${JSON.stringify(answers)}; +const steps = ${JSON.stringify(steps)}; const asked = []; const claims = []; let released = 0; @@ -56,6 +73,7 @@ const cleanup = await setup({ open: async () => { throw new Error('unused'); }, apply: async () => { throw new Error('unused'); }, sidebar: async ({ sessionId }) => { + const answer = answers[Math.min(asked.length, answers.length - 1)]; asked.push(sessionId); if (answer === null) throw Object.assign(new Error('missing'), { code: 'rpc.method_not_found' }); return answer; @@ -73,25 +91,28 @@ if (claims.length !== 1 || claims[0].append !== 'sidebar.content') throw new Err let destroyed; const teardown = new Promise((resolve) => { destroyed = resolve; }); const setupRenderer = await createTestRenderer({ width: 42, height: 32, onDestroy() { destroyed(); } }); -let settle; -const frame = new Promise((resolve) => { settle = resolve; }); -const observe = () => { - const text = setupRenderer.captureCharFrame(); - if (${JSON.stringify(wanted)}.every((part) => text.includes(part))) settle(text); -}; -setupRenderer.renderer.on('frame', observe); +const frames = []; try { await render(() => claims[0].render({ sessionID: 'ses_oc2' }), setupRenderer.renderer); - setupRenderer.renderer.requestRender(); - const text = await frame; + for (const step of steps) { + for (;;) { + await setupRenderer.renderOnce(); + const text = setupRenderer.captureCharFrame(); + if (asked.length >= (step.calls ?? 0) && step.wanted.every((part) => text.includes(part))) { + frames.push(text); + break; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + clock += step.advanceMs ?? 0; + } const colors = {}; for (const line of setupRenderer.captureSpans().lines) for (const span of line.spans) if (span.text.trim()) colors[span.text.trim()] ??= String(span.fg); cleanup(); - console.log(JSON.stringify({ frame: text, asked, released, colors })); + console.log(JSON.stringify({ frames, asked, released, colors })); } finally { - setupRenderer.renderer.off('frame', observe); setupRenderer.renderer.destroy(); await teardown; } @@ -119,6 +140,9 @@ process.exit(0); return JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') } +// Each test starts Bun with OpenTUI; a loaded machine takes past the 5 s default. +const SUBPROCESS_TIMEOUT_MS = 20_000 + // The test renderer prints colors as `RGBA.toString()`. const rgba = (hex: string) => { const [r, g, b] = [1, 3, 5].map( @@ -127,7 +151,8 @@ const rgba = (hex: string) => { return `rgba(${r?.toFixed(2)}, ${g?.toFixed(2)}, ${b?.toFixed(2)}, 1.00)` } -test('OpenCode 2 sidebar draws server quota with the host theme and releases its slot', async () => { +// Main at 60 % (5h) and 20 % (7d) used, both resetting three hours from now. +function quotaState() { const now = Date.now() const window = (used: number) => ({ usedPercent: used, @@ -135,37 +160,84 @@ test('OpenCode 2 sidebar draws server quota with the host theme and releases its checkedAt: now, resetsAt: new Date(now + 3 * 3_600_000).toISOString(), }) - const result = await renderOpenCode2Sidebar( - { - main: { - quota: { checkedAt: now, five_hour: window(60), seven_day: window(20) }, - }, - fallbacks: [], - activeId: 'main', - route: 'main-first', - relay: null, - fastMode: false, - lastUpdated: now, + return { + main: { + quota: { checkedAt: now, five_hour: window(60), seven_day: window(20) }, }, - ['40% left', '80% left'], - ) - expect(result.frame).toContain('CLAUDE') - expect(result.frame).toContain('main') - expect(result.frame).toContain('active') - expect(result.frame).toContain('main-first') - expect(result.asked).toContain('ses_oc2') - const colorOf = (text: string) => - Object.entries(result.colors).find(([span]) => span.includes(text))?.[1] - // 60 % used is past the default warning threshold; 20 % is fine. - expect(colorOf('40% left')).toBe(rgba('#ffff00')) - expect(colorOf('80% left')).toBe(rgba('#00ff00')) - expect(colorOf('active')).toBe(rgba('#00ff00')) - expect(colorOf('main-first')).toBe(rgba('#3366ff')) - expect(result.released).toBe(1) -}) + fallbacks: [], + activeId: 'main', + route: 'main-first', + relay: null, + fastMode: false, + lastUpdated: now, + } +} -test('OpenCode 2 sidebar waits instead of failing when the server has no sidebar method', async () => { - const result = await renderOpenCode2Sidebar(null, ['Waiting for quota']) - expect(result.frame).toContain('CLAUDE') - expect(result.asked.length).toBeGreaterThan(0) -}) +test( + 'OpenCode 2 sidebar draws server quota with the host theme and releases its slot', + async () => { + const result = await renderOpenCode2Sidebar( + [quotaState()], + [{ wanted: ['40% left', '80% left'] }], + ) + const [frame] = result.frames + expect(frame).toContain('CLAUDE') + expect(frame).toContain('main') + expect(frame).toContain('active') + expect(frame).toContain('main-first') + expect(result.asked).toContain('ses_oc2') + const colorOf = (text: string) => + Object.entries(result.colors).find(([span]) => span.includes(text))?.[1] + // 60 % used is past the default warning threshold; 20 % is fine. + expect(colorOf('40% left')).toBe(rgba('#ffff00')) + expect(colorOf('80% left')).toBe(rgba('#00ff00')) + expect(colorOf('active')).toBe(rgba('#00ff00')) + expect(colorOf('main-first')).toBe(rgba('#3366ff')) + expect(result.released).toBe(1) + }, + SUBPROCESS_TIMEOUT_MS, +) + +test( + 'OpenCode 2 sidebar waits instead of failing when the server has no sidebar method', + async () => { + const result = await renderOpenCode2Sidebar( + [null], + [{ wanted: ['Waiting for quota'] }], + ) + expect(result.frames[0]).toContain('CLAUDE') + expect(result.asked.length).toBeGreaterThan(0) + }, + SUBPROCESS_TIMEOUT_MS, +) + +test( + 'OpenCode 2 sidebar countdowns advance while the server answers the same state', + async () => { + const result = await renderOpenCode2Sidebar( + [quotaState()], + [ + { wanted: ['40% left', '2h59m'], calls: 2, advanceMs: 3_600_000 }, + { wanted: ['1h59m'] }, + ], + ) + expect(result.frames[1]).toContain('40% left') + expect(result.frames[1]).not.toContain('2h59m') + }, + SUBPROCESS_TIMEOUT_MS, +) + +test( + 'OpenCode 2 sidebar keeps the last quota when later polls fail', + async () => { + const result = await renderOpenCode2Sidebar( + [quotaState(), null], + [ + { wanted: ['40% left'], calls: 3, advanceMs: 1_800_000 }, + { wanted: ['40% left', '2h29m'], calls: 4 }, + ], + ) + expect(result.frames[1]).not.toContain('Waiting for quota') + }, + SUBPROCESS_TIMEOUT_MS, +) diff --git a/packages/opencode/src/tests/opencode2-sidebar.test.ts b/packages/opencode/src/tests/opencode2-sidebar.test.ts index 462d6d31c..9392b0396 100644 --- a/packages/opencode/src/tests/opencode2-sidebar.test.ts +++ b/packages/opencode/src/tests/opencode2-sidebar.test.ts @@ -131,6 +131,91 @@ describe('OpenCode 2 sidebar state', () => { expect(byId.vault?.vaultReauth).toBe(true) expect(byId.vault?.stateLine).toBe('needs sign-in in the vault') expect(byId.vault?.quotaDated).toBe(true) + expect(byId.vault?.custodyState).toBe('on-vault-reauth') + }) + + test('keeps turned-off vault accounts with their custody state, as OpenCode 1 does', () => { + const state = buildNativeSidebarState( + snapshot([ + account({ source: 'vault', state: 'active' }), + account({ id: 'served', source: 'vault', state: 'active' }), + account({ + id: 'off', + label: 'Off', + source: 'vault', + state: 'active', + enabled: false, + quota: quota(10, 10), + }), + account({ id: 'cold', source: 'vault', state: 'cold' }), + account({ id: 'local-off', enabled: false }), + ]), + extras, + ) + const byId = Object.fromEntries( + state.fallbacks.map((entry) => [entry.id, entry]), + ) + expect(Object.keys(byId)).toEqual(['served', 'off', 'cold']) + expect(byId.served).toMatchObject({ + custodyState: 'on-vault-served', + vaultServed: true, + }) + expect(byId.served?.stateLine).toBeUndefined() + expect(byId.off).toMatchObject({ + enabled: false, + custodyState: 'on-cold', + stateLine: 'turned off', + quotaDated: true, + }) + expect(byId.off?.vaultServed).toBeUndefined() + expect(byId.cold).toMatchObject({ + custodyState: 'on-cold', + stateLine: 'unavailable in the vault', + }) + // The shared renderer keeps rows whose custody is not 'off'. + expect( + normalizeSidebarState(JSON.parse(JSON.stringify(state))).fallbacks.map( + (entry) => entry.custodyState, + ), + ).toEqual(['on-vault-served', 'on-cold', 'on-cold']) + }) + + test('explains an unsupported saved credential instead of showing it as serving', () => { + const state = buildNativeSidebarState( + snapshot([ + account({ state: 'unsupported-access' }), + account({ id: 'work', state: 'unsupported-access' }), + ]), + extras, + ) + expect(state.main.stateLine).toContain('sign in to this account again') + expect(state.main.quotaDated).toBe(true) + expect(state.fallbacks[0]?.stateLine).toContain( + 'sign in to this account again', + ) + }) + + test('ignores a refresh backoff recorded for a replaced login', () => { + const lastRefreshError = { + message: 'invalid_grant', + checkedAt: now - 1_000, + nextRetryAt: now + 86_400_000, + permanent: true, + accountIdentity: 'previous-account', + } + const state = buildNativeSidebarState( + snapshot([ + account({ accountIdentity: 'current-account', lastRefreshError }), + account({ + id: 'work', + accountIdentity: 'current-account', + lastRefreshError, + }), + ]), + extras, + ) + expect(state.main.refreshBackedOff).toBe(false) + expect(state.fallbacks[0]?.needsReauth).toBe(false) }) test('projects routing, fast mode, relay, cache keepalive and Prime from policy', () => { @@ -144,6 +229,7 @@ describe('OpenCode 2 sidebar state', () => { tokenConfigured: true, transport: 'http', }, + claudeCache: { enabled: true, mode: 'hybrid' }, cacheKeep: { enabled: true, always: true }, prime: { enabled: true }, }), @@ -152,6 +238,7 @@ describe('OpenCode 2 sidebar state', () => { expect(state.route).toBe('fallback-first') expect(state.fastMode).toBe(true) expect(state.relay).toEqual({ enabled: true, transport: 'http' }) + expect(state.cacheKeep?.enabled).toBe(true) expect(state.cacheKeep?.window).toBe('always') expect(state.cacheKeep?.trackedSessions).toBe(3) expect(state.prime?.enabled).toBe(true) @@ -161,6 +248,7 @@ describe('OpenCode 2 sidebar state', () => { expect(plain.fastMode).toBe(false) expect(plain.relay).toBeNull() expect(plain.prime).toBeUndefined() + expect(plain.cacheKeep?.enabled).toBe(false) }) test('survives the RPC boundary unchanged and carries no secret', () => { diff --git a/packages/opencode/src/tests/sidebar-state.test.ts b/packages/opencode/src/tests/sidebar-state.test.ts index 7f32a4c33..17efd17f8 100644 --- a/packages/opencode/src/tests/sidebar-state.test.ts +++ b/packages/opencode/src/tests/sidebar-state.test.ts @@ -708,6 +708,13 @@ describe('getCollapsedAccounts', () => { ]) }) + test('marks no row active before any account has served', () => { + const state = make({ activeId: undefined, main: { quota: quota(10) } }) + expect(getCollapsedAccounts(state).map(({ active }) => active)).toEqual([ + false, + ]) + }) + test('uses the custody-selected inventory and keeps visible disabled vault accounts', () => { const state = make({ main: { quota: quota(10) }, diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index 3a189099a..e16ccede4 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -82,6 +82,7 @@ function QuotaDialogContent(props: { tierLabel={state().main?.tierLabel} stateLine={state().main?.stateLine} quotaDated={state().main?.quotaDated} + now={Date.now()} /> @@ -99,6 +100,7 @@ function QuotaDialogContent(props: { stateLine={fb.stateLine} quotaDated={fb.quotaDated} marginTop={1} + now={Date.now()} /> )} diff --git a/packages/opencode/src/tui/claude-sidebar.tsx b/packages/opencode/src/tui/claude-sidebar.tsx index b5737f65f..f18eab6c9 100644 --- a/packages/opencode/src/tui/claude-sidebar.tsx +++ b/packages/opencode/src/tui/claude-sidebar.tsx @@ -16,6 +16,7 @@ import { import { type BoxRenderable, type RGBA, TextAttributes } from '@opentui/core' import { createEffect, + createMemo, createSignal, For, Index, @@ -163,9 +164,9 @@ function quotaBarSegments( ).filter((segment) => segment.text.length > 0) } -function formatResetIn(resetsAt: string | undefined): string { +function formatResetIn(resetsAt: string | undefined, now: number): string { if (!resetsAt) return '' - const ms = new Date(resetsAt).getTime() - Date.now() + const ms = new Date(resetsAt).getTime() - now if (ms <= 0) return 'now' const mins = Math.floor(ms / 60_000) if (mins < 60) return `${mins}m` @@ -179,9 +180,9 @@ function formatResetIn(resetsAt: string | undefined): string { return rh > 0 ? `${days}d${rh}h` : `${days}d` } -function formatUntil(until: number | undefined): string { +function formatUntil(until: number | undefined, now: number): string { if (!until) return '' - const ms = until - Date.now() + const ms = until - now if (ms <= 0) return 'now' const mins = Math.ceil(ms / 60_000) if (mins < 60) return `${mins}m` @@ -241,10 +242,11 @@ function CollapsedAccountRow(props: { theme: SidebarTheme account: ReturnType[number] tone: Tone + now: number }) { const [width, setWidth] = createSignal(40) let row: BoxRenderable | undefined - const summary = () => getCollapsedQuotaSummary(props.account.quota) + const summary = () => getCollapsedQuotaSummary(props.account.quota, props.now) const value = () => summary().text ?? '—' const labelWidth = () => Math.max(0, width() - Bun.stringWidth(value()) - 1) const dim = () => @@ -302,6 +304,7 @@ function QuotaRow(props: { usedPercent: number | undefined pacing: QuotaPacing | null binding?: boolean + now: number }) { const used = () => props.usedPercent ?? 0 const reset = () => props.part?.resetIn @@ -311,7 +314,7 @@ function QuotaRow(props: { const pct = Math.round(Math.abs(pacing.deltaPercent)) if (pacing.state === 'reserve') return `reserve ${pct}% \u00b7 lasts` return pacing.runsOutAt - ? `deficit ${pct}% \u00b7 out in ${formatResetIn(pacing.runsOutAt)}` + ? `deficit ${pct}% \u00b7 out in ${formatResetIn(pacing.runsOutAt, props.now)}` : `deficit ${pct}% \u00b7 lasts` } return ( @@ -334,13 +337,16 @@ function QuotaRow(props: { {`${formatQuotaRowLabel(props.label)} `} - + {/* Index: the clock rebuilds the segments, which must not remount. */} + {(segment) => ( - - {segment.text} + + {segment().text} )} - + @@ -400,6 +406,7 @@ export function AccountBlock(props: { quotaDated?: boolean tierLabel?: string marginTop?: number + now: number }) { const statusWord = () => props.quotaDated && props.quota @@ -424,12 +431,12 @@ export function AccountBlock(props: { windowMs: number, ) => props.pacingEnabled && !props.quotaDated && window - ? computeQuotaPacing(window, windowMs, Date.now()) + ? computeQuotaPacing(window, windowMs, props.now) : null - const parts = () => { - const now = Date.now() - return quotaWindowParts(quotaDisplayProjection(props.quota, now), { now }) - } + const parts = () => + quotaWindowParts(quotaDisplayProjection(props.quota, props.now), { + now: props.now, + }) const partFor = (name: string) => parts().windows.find((part) => part.name === name) return ( @@ -457,6 +464,7 @@ export function AccountBlock(props: { usedPercent={props.quota?.five_hour?.usedPercent} pacing={pacingFor(props.quota?.five_hour, FIVE_HOUR_MS)} binding={props.quota?.bindingWindow === 'five_hour'} + now={props.now} /> {(window) => ( @@ -477,6 +486,7 @@ export function AccountBlock(props: { usedPercent={window.usedPercent} pacing={pacingFor(window, SEVEN_DAY_MS)} binding={props.quota?.bindingWindow === window.id} + now={props.now} /> )} @@ -604,6 +614,18 @@ export function ClaudeSidebar(props: { const collapsed = props.controller.collapsed const state = props.state const theme = props.theme + // Countdowns, reading ages and pacing read this clock, so they advance on + // each tick and state change without a new answer or rebuilt rows. + const [tick, setTick] = createSignal(0) + createEffect(() => { + const timer = setInterval(() => setTick((n) => n + 1), prefs().pollMs) + onCleanup(() => clearInterval(timer)) + }) + const now = createMemo(() => { + tick() + state() + return Date.now() + }) const enabledFallbacks = () => (state().fallbacks ?? []).filter( (f) => f.enabled || (f.custodyState && f.custodyState !== 'off'), @@ -619,13 +641,12 @@ export function ClaudeSidebar(props: { } const activeAccount = () => resolveActiveAccount(state()) const activeQuotaSummary = () => - getCollapsedQuotaSummary(activeAccount().quota) + getCollapsedQuotaSummary(activeAccount().quota, now()) const activePacingDeficit = () => { if (!prefs().sections.pacing) return false if (activeAccount().quotaDated) return null const quota = activeAccount().quota if (!quota) return false - const now = Date.now() const windows: Array< [ ( @@ -644,7 +665,7 @@ export function ClaudeSidebar(props: { ] return windows.some( ([w, ms]) => - w != null && computeQuotaPacing(w, ms, now)?.state === 'deficit', + w != null && computeQuotaPacing(w, ms, now())?.state === 'deficit', ) } const activeQuotaTone = (): Tone => { @@ -756,6 +777,7 @@ export function ClaudeSidebar(props: { theme={theme()} account={account()} tone={activeQuotaTone()} + now={now()} /> )} @@ -801,6 +823,7 @@ export function ClaudeSidebar(props: { tierLabel={state().main?.tierLabel} stateLine={state().main?.stateLine} quotaDated={state().main?.quotaDated} + now={now()} /> @@ -818,6 +841,7 @@ export function ClaudeSidebar(props: { stateLine={fb.stateLine} quotaDated={fb.quotaDated} marginTop={1} + now={now()} /> )} @@ -888,7 +912,7 @@ export function ClaudeSidebar(props: { /> {(account) => { - const value = () => formatPrimeAccountValue(account) + const value = () => formatPrimeAccountValue(account, now()) return ( @@ -916,7 +940,7 @@ export function ClaudeSidebar(props: { @@ -991,6 +1015,7 @@ function HostSidebar(props: { try { const next = await props.read(props.sessionId, abort.signal) // Every answer is stamped now; redraw only when its content changed. + // Countdowns and ages advance on the sidebar's own clock. const content = JSON.stringify({ ...next, lastUpdated: 0 }) if (!abort.signal.aborted && content !== shown) { shown = content @@ -1026,17 +1051,23 @@ export async function mountClaudeSidebar( const controller = createSidebarController( resolveAnthropicAuthPrefs(await readTuiPreferencesFile()), ) - const release = host.ui.slot({ - append: 'sidebar.content', - render: (input) => ( - - ), - }) + let release: () => void + try { + release = host.ui.slot({ + append: 'sidebar.content', + render: (input) => ( + + ), + }) + } catch (error) { + controller.dispose() + throw error + } return () => { release() controller.dispose() diff --git a/packages/opencode/src/v2/sidebar.ts b/packages/opencode/src/v2/sidebar.ts index 5b33885b5..e31db59a9 100644 --- a/packages/opencode/src/v2/sidebar.ts +++ b/packages/opencode/src/v2/sidebar.ts @@ -24,11 +24,21 @@ export interface NativeSidebarExtras { } // Why an account cannot serve, shown in place of its quota; undefined when it can. +// Authorization refuses an unsupported saved credential even while enabled. const stateLineOf = (account: NativeAccountView) => - nativeAccountCanServe(account) + nativeAccountCanServe(account) && account.state !== 'unsupported-access' ? undefined : formatNativeAccountState(account) || undefined +// OpenCode 1's `custodyStateFor` rule for a fallback; vault views are roster rows. +const custodyStateOf = ( + account: NativeAccountView, +): SidebarAccountState['custodyState'] => { + if (account.source !== 'vault') return 'off' + if (account.state === 'needs_reauth') return 'on-vault-reauth' + return nativeAccountCanServe(account) ? 'on-vault-served' : 'on-cold' +} + const retryPending = (retryAt: number | undefined, now: number) => typeof retryAt === 'number' && retryAt > now @@ -57,7 +67,7 @@ export function buildNativeSidebarState( quotaBackoffUntil: quotaRetryAt, refreshBackedOff: refreshBackoffActive( refreshError, - undefined, + main?.accountIdentity, now, undefined, ), @@ -65,11 +75,16 @@ export function buildNativeSidebarState( stateLine: mainStateLine, quotaDated: Boolean(mainStateLine), }, + // Vault rows stay listed when turned off, as on OpenCode 1. fallbacks: oauth - .filter((account) => account.id !== 'main' && account.enabled) + .filter( + (account) => + account.id !== 'main' && + (account.enabled || account.source === 'vault'), + ) .map((account): SidebarAccountState => { const stateLine = stateLineOf(account) - const vault = account.source === 'vault' + const custodyState = custodyStateOf(account) return { id: account.id, label: account.label, @@ -82,14 +97,13 @@ export function buildNativeSidebarState( isPermanentRefreshError(account.lastRefreshError) && refreshBackoffActive( account.lastRefreshError, - undefined, + account.accountIdentity, now, undefined, ), - ...(vault && account.state === 'needs_reauth' - ? { vaultReauth: true } - : {}), - ...(vault && account.state === 'active' ? { vaultServed: true } : {}), + ...(custodyState === 'on-vault-reauth' ? { vaultReauth: true } : {}), + ...(custodyState === 'on-vault-served' ? { vaultServed: true } : {}), + ...(custodyState !== 'off' ? { custodyState } : {}), stateLine, quotaDated: Boolean(stateLine), } From b04aa638703a596f5d6b8899d1ad21afb3c6c5e5 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:15:05 +0200 Subject: [PATCH 3/3] test(opencode): a refused OpenCode 2 sidebar slot stops the preferences 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. --- .../tests/opencode2-sidebar-render.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/packages/opencode/src/tests/opencode2-sidebar-render.test.ts b/packages/opencode/src/tests/opencode2-sidebar-render.test.ts index 8e6b932eb..34b40b2b8 100644 --- a/packages/opencode/src/tests/opencode2-sidebar-render.test.ts +++ b/packages/opencode/src/tests/opencode2-sidebar-render.test.ts @@ -211,6 +211,90 @@ test( SUBPROCESS_TIMEOUT_MS, ) +test( + 'OpenCode 2 setup survives a refused sidebar slot and stops watching preferences', + async () => { + fixture = await mkdtemp(join(tmpdir(), 'anthropic-oc2-sidebar-slot-')) + await writeFile( + join(fixture, 'preferences.jsonc'), + JSON.stringify({ 'anthropic-auth': { rememberCollapsed: false } }), + ) + // Setup keeps the /claude menu when the sidebar can't mount, so it resolves + // rather than rethrowing. The child never calls process.exit: it ends only + // once nothing keeps its event loop alive. A preferences directory watcher + // left open after the refused slot keeps it running, and the deadline + // below catches that. + const probe = ` +const { setup } = await import(${JSON.stringify(new URL('../tui/entry.mjs', import.meta.url).href)}); +let slotCalls = 0; +let result; +try { + result = await setup({ + location: { directory: process.cwd() }, + theme: { + text: { + base: '#ffffff', muted: '#888888', + action: { primary: { base: '#3366ff' } }, + feedback: { success: { base: '#00ff00' }, warning: { base: '#ffff00' }, error: { base: '#ff0000' } }, + }, + background: { base: '#000000' }, + border: { base: '#ffffff' }, + }, + client: { rpc: () => ({ + events: { on: () => () => {} }, + open: async () => { throw new Error('unused'); }, + apply: async () => { throw new Error('unused'); }, + sidebar: async () => { throw new Error('unused'); }, + }) }, + keymap: { layer() {} }, + ui: { + router: { current: () => ({ type: 'session', sessionID: 'ses_oc2' }) }, + slot() { slotCalls++; throw new Error('slot refused by host'); }, + toast: { show() {} }, + dialog: {}, + }, + }); +} catch (error) { + result = error; +} +console.log(JSON.stringify({ slotCalls, setupReturned: typeof result })); +` + child = Bun.spawn( + [process.execPath, '--preload', '@opentui/solid/preload', '-e', probe], + { + cwd: fileURLToPath(new URL('../../', import.meta.url)), + env: { + ...process.env, + OPENCODE_TUI_PREFERENCES_FILE: join(fixture, 'preferences.jsonc'), + }, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }, + ) + const running = child + const exited = await Promise.race([ + running.exited.then(() => true), + Bun.sleep(8_000).then(() => false), + ]) + if (!exited) running.kill() + const [stdout, stderr] = await Promise.all([ + new Response(running.stdout).text(), + new Response(running.stderr).text(), + ]) + expect({ exited, exitCode: running.exitCode, stderr }).toEqual({ + exited: true, + exitCode: 0, + stderr: '', + }) + expect(JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}')).toEqual({ + slotCalls: 1, + setupReturned: 'function', + }) + }, + SUBPROCESS_TIMEOUT_MS, +) + test( 'OpenCode 2 sidebar countdowns advance while the server answers the same state', async () => {