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 0ce231c57..d7e271c4e 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.2 - Requests no longer wait on relay or session-pin bookkeeping locks; relay settings and tokens are checked together before sending. diff --git a/STRUCTURE.md b/STRUCTURE.md index 9a5776abb..6807d4fed 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 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/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..57050b783 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, 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/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/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-native-source.test.ts b/packages/opencode/src/tests/opencode2-native-source.test.ts index 5ff90f6d9..17e256dd0 100644 --- a/packages/opencode/src/tests/opencode2-native-source.test.ts +++ b/packages/opencode/src/tests/opencode2-native-source.test.ts @@ -1216,6 +1216,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..34b40b2b8 --- /dev/null +++ b/packages/opencode/src/tests/opencode2-sidebar-render.test.ts @@ -0,0 +1,327 @@ +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 +}) + +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( + answers: unknown[], + steps: Step[], +): Promise<{ + frames: 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, 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 answers = ${JSON.stringify(answers)}; +const steps = ${JSON.stringify(steps)}; +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 }) => { + 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; + }, + }) }, + 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(); } }); +const frames = []; +try { + await render(() => claims[0].render({ sessionID: 'ses_oc2' }), setupRenderer.renderer); + 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({ frames, asked, released, colors })); +} finally { + 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) ?? '{}') +} + +// 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( + (at) => Number.parseInt(hex.slice(at, at + 2), 16) / 255, + ) + return `rgba(${r?.toFixed(2)}, ${g?.toFixed(2)}, ${b?.toFixed(2)}, 1.00)` +} + +// 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, + remainingPercent: 100 - used, + checkedAt: now, + resetsAt: new Date(now + 3 * 3_600_000).toISOString(), + }) + return { + main: { + quota: { checkedAt: now, five_hour: window(60), seven_day: window(20) }, + }, + fallbacks: [], + activeId: 'main', + route: 'main-first', + relay: null, + fastMode: false, + lastUpdated: now, + } +} + +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 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 () => { + 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 new file mode 100644 index 000000000..9392b0396 --- /dev/null +++ b/packages/opencode/src/tests/opencode2-sidebar.test.ts @@ -0,0 +1,320 @@ +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) + 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', () => { + const state = buildNativeSidebarState( + snapshot([account({})], { + routing: { mode: 'fallback-first' }, + claudeFast: { enabled: true }, + relay: { + enabled: true, + url: 'https://relay.invalid', + tokenConfigured: true, + transport: 'http', + }, + claudeCache: { enabled: true, mode: 'hybrid' }, + 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?.enabled).toBe(true) + 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() + expect(plain.cacheKeep?.enabled).toBe(false) + }) + + 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/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 86417a081..e16ccede4 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 @@ -576,6 +82,7 @@ function QuotaDialogContent(props: { tierLabel={state().main?.tierLabel} stateLine={state().main?.stateLine} quotaDated={state().main?.quotaDated} + now={Date.now()} /> @@ -593,6 +100,7 @@ function QuotaDialogContent(props: { stateLine={fb.stateLine} quotaDated={fb.quotaDated} marginTop={1} + now={Date.now()} /> )} @@ -608,77 +116,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 +159,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..f18eab6c9 --- /dev/null +++ b/packages/opencode/src/tui/claude-sidebar.tsx @@ -0,0 +1,1075 @@ +/** @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, + createMemo, + 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, now: number): string { + if (!resetsAt) return '' + 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` + 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, now: number): string { + if (!until) return '' + const ms = until - 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 + now: number +}) { + const [width, setWidth] = createSignal(40) + let row: BoxRenderable | undefined + const summary = () => getCollapsedQuotaSummary(props.account.quota, props.now) + 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 + now: number +}) { + 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, props.now)}` + : `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)} `} + {/* Index: the clock rebuilds the segments, which must not remount. */} + + {(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 + now: 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, props.now) + : null + const parts = () => + quotaWindowParts(quotaDisplayProjection(props.quota, props.now), { + now: props.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 + // 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'), + ) + 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, now()) + const activePacingDeficit = () => { + if (!prefs().sections.pacing) return false + if (activeAccount().quotaDated) return null + const quota = activeAccount().quota + if (!quota) return false + 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, now()) + 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. + // 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 + 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()), + ) + 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/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..e31db59a9 --- /dev/null +++ b/packages/opencode/src/v2/sidebar.ts @@ -0,0 +1,129 @@ +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. +// Authorization refuses an unsupported saved credential even while enabled. +const stateLineOf = (account: NativeAccountView) => + 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 + +/** + * 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, + main?.accountIdentity, + now, + undefined, + ), + refreshBackoffUntil: refreshError?.nextRetryAt, + 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 || account.source === 'vault'), + ) + .map((account): SidebarAccountState => { + const stateLine = stateLineOf(account) + const custodyState = custodyStateOf(account) + 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, + account.accountIdentity, + now, + undefined, + ), + ...(custodyState === 'on-vault-reauth' ? { vaultReauth: true } : {}), + ...(custodyState === 'on-vault-served' ? { vaultServed: true } : {}), + ...(custodyState !== 'off' ? { custodyState } : {}), + 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 87b216866..83f62c8fe 100644 --- a/packages/opencode/src/v2/source.ts +++ b/packages/opencode/src/v2/source.ts @@ -111,6 +111,8 @@ export interface NativeSource readonly close: () => Promise readonly drainRoutingPublications: () => 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 }, @@ -148,6 +150,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 = () => { @@ -469,6 +474,7 @@ export function createNativeAnthropicSource( active, relayFallback, }) + servedBy.set(scope.sessionID, accountId) return { ...choice.auth, dispatch } } const common = { @@ -498,6 +504,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}` } @@ -523,6 +530,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, @@ -547,6 +555,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', @@ -800,12 +809,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?.() } }