diff --git a/README.md b/README.md index 898602b..bf5e47b 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ The sidebar polls plugin state and refreshes on OpenCode session and message eve - **Quota** — per-account 5-hour and 7-day usage bars for the main account and each enabled fallback, with a status word (`active`, `blocked`, or `idle`) and the soonest reset time. - **Routing** — the current route, standard/fast mode, and relay transport state. - **Cache** — the 1-hour cache keepalive window and the number of tracked sessions, shown when cache keepalive is configured. -- **Health** — quota-API and token-refresh backoff countdowns. This section is hidden unless a backoff is active, and a `LIMITED` badge appears in the header. +- **Health** — quota-API and token-refresh backoff countdowns and the killswitch block list. This section is hidden unless one of these conditions is active, and a `LIMITED` badge appears in the header. Click the `CLAUDE` header to collapse or expand the sidebar. Collapsed, it shows the active account's 5-hour quota usage and a fast-mode row when fast mode is on; the header shows the plugin version (or a `LIMITED` badge when degraded). Collapse state is per-session and resets when OpenCode restarts. diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 5d0f45c..7c1d43e 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -469,11 +469,18 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { ) { lastSidebarRouting = { activeId: options.activeId, route: options.route } const mainEntry = quotaManager.getMain(options.mainAccessToken) + const ksEnabled = isKillswitchEnabled(storage) const lastApiError = quotaManager.getLastApiError() const mainRefreshError = storage?.refresh?.mainLastRefreshError const state: SidebarState = { main: { quota: mainEntry?.quota ?? null, + // No `quota != null` guard: under failClosedOnUnknownQuota the + // killswitch blocks unknown-quota accounts, so the sidebar must show + // them as killed too (killswitchPassesPolicy handles the null case). + killed: ksEnabled + ? !killswitchPassesPolicy(mainEntry?.quota, storage) + : false, quotaBackedOff: quotaManager.isBackedOff(), quotaBackoffUntil: lastApiError?.nextRetryAt, refreshBackedOff: mainRefreshError @@ -490,18 +497,27 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { (account): account is OAuthAccount => account.enabled !== false && isOAuthAccount(account), ) - .map((account) => ({ - id: account.id, - label: account.label, + .map((account) => { // Token-aware read: if a fallback account was re-logged with the same // id/label, an old in-memory quota snapshot must not be shown as the // new account's quota. - quota: account.access + const quota = account.access ? (quotaManager.getFallback(account.id, account.access)?.quota ?? null) - : null, - enabled: account.enabled !== false, - })), + : null + return { + id: account.id, + label: account.label, + quota, + // No `quota != null` guard: under failClosedOnUnknownQuota the + // killswitch blocks unknown-quota accounts, so the sidebar must + // show them as killed too. + killed: ksEnabled + ? !killswitchPassesPolicy(quota ?? undefined, storage, account.id) + : false, + enabled: account.enabled !== false, + } + }), activeId: options.activeId, route: options.route, relay: (() => { diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index b2ecbd6..5bfd811 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -13,12 +13,14 @@ export interface SidebarAccountState { id: string label: string | undefined quota: AccountQuota | null + killed: boolean enabled: boolean } export interface SidebarState { main: { quota: AccountQuota | null + killed: boolean quotaBackedOff?: boolean quotaBackoffUntil?: number refreshBackedOff?: boolean @@ -50,7 +52,7 @@ export function getSidebarStateFile(): string { } export const DEFAULT_SIDEBAR_STATE: SidebarState = { - main: { quota: null }, + main: { quota: null, killed: false }, fallbacks: [], activeId: undefined, route: 'main', @@ -85,6 +87,7 @@ export function resolveActiveAccount(state: SidebarState): { id: string name: string quota: AccountQuota | null + killed: boolean } { const activeId = state.activeId if (activeId && activeId !== 'main') { @@ -100,10 +103,16 @@ export function resolveActiveAccount(state: SidebarState): { id: fallback.id, name: fallback.label ?? fallback.id, quota: fallback.quota, + killed: fallback.killed, } } } - return { id: 'main', name: 'main', quota: state.main.quota } + return { + id: 'main', + name: 'main', + quota: state.main.quota, + killed: state.main.killed, + } } export function getCollapsedQuotaSummary(quota: AccountQuota | null): { diff --git a/packages/opencode/src/tests/sidebar-state.test.ts b/packages/opencode/src/tests/sidebar-state.test.ts index 96a3930..b606ffd 100644 --- a/packages/opencode/src/tests/sidebar-state.test.ts +++ b/packages/opencode/src/tests/sidebar-state.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_SIDEBAR_STATE, getCollapsedQuotaSummary, resolveActiveAccount, + type SidebarAccountState, type SidebarState, } from '../sidebar-state' @@ -12,25 +13,39 @@ const quota = (used: number): AccountQuota => ({ seven_day: { usedPercent: used, remainingPercent: 100 - used }, }) +const main = ( + q: AccountQuota | null, + killed = false, +): SidebarState['main'] => ({ quota: q, killed }) + +const fb = ( + overrides: Partial & { id: string }, +): SidebarAccountState => ({ + label: undefined, + quota: null, + killed: false, + enabled: true, + ...overrides, +}) + function make(overrides: Partial): SidebarState { return { ...DEFAULT_SIDEBAR_STATE, ...overrides } } describe('resolveActiveAccount', () => { test('activeId "main" resolves to the main account', () => { - const state = make({ activeId: 'main', main: { quota: quota(20) } }) + const state = make({ activeId: 'main', main: main(quota(20)) }) const active = resolveActiveAccount(state) expect(active.id).toBe('main') expect(active.name).toBe('main') expect(active.quota?.five_hour?.usedPercent).toBe(20) + expect(active.killed).toBe(false) }) test('activeId matching an enabled fallback resolves to that fallback (label name)', () => { const state = make({ activeId: 'fb1', - fallbacks: [ - { id: 'fb1', label: 'work', quota: quota(40), enabled: true }, - ], + fallbacks: [fb({ id: 'fb1', label: 'work', quota: quota(40) })], }) const active = resolveActiveAccount(state) expect(active.id).toBe('fb1') @@ -41,9 +56,7 @@ describe('resolveActiveAccount', () => { test('fallback without a label uses its id as the name', () => { const state = make({ activeId: 'fb1', - fallbacks: [ - { id: 'fb1', label: undefined, quota: quota(5), enabled: true }, - ], + fallbacks: [fb({ id: 'fb1', label: undefined, quota: quota(5) })], }) expect(resolveActiveAccount(state).name).toBe('fb1') }) @@ -51,9 +64,9 @@ describe('resolveActiveAccount', () => { test('activeId matching a DISABLED fallback falls back to main', () => { const state = make({ activeId: 'fb1', - main: { quota: quota(12) }, + main: main(quota(12)), fallbacks: [ - { id: 'fb1', label: 'work', quota: quota(40), enabled: false }, + fb({ id: 'fb1', label: 'work', quota: quota(40), enabled: false }), ], }) const active = resolveActiveAccount(state) @@ -62,22 +75,37 @@ describe('resolveActiveAccount', () => { }) test('undefined activeId resolves to main', () => { - const state = make({ activeId: undefined, main: { quota: quota(7) } }) + const state = make({ activeId: undefined, main: main(quota(7)) }) expect(resolveActiveAccount(state).id).toBe('main') }) test('unmatched activeId resolves to main', () => { const state = make({ activeId: 'ghost', - main: { quota: null }, - fallbacks: [ - { id: 'fb1', label: 'work', quota: quota(40), enabled: true }, - ], + main: main(null), + fallbacks: [fb({ id: 'fb1', label: 'work', quota: quota(40) })], }) const active = resolveActiveAccount(state) expect(active.id).toBe('main') expect(active.quota).toBeNull() }) + + test('carries through the killed flag for the active main account', () => { + const state = make({ activeId: 'main', main: main(quota(95), true) }) + expect(resolveActiveAccount(state).killed).toBe(true) + }) + + test('carries through the killed flag for the active fallback account', () => { + const state = make({ + activeId: 'fb1', + fallbacks: [ + fb({ id: 'fb1', label: 'work', quota: quota(99), killed: true }), + ], + }) + const active = resolveActiveAccount(state) + expect(active.id).toBe('fb1') + expect(active.killed).toBe(true) + }) }) describe('getCollapsedQuotaSummary', () => { diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index 5fd44e8..7aeebba 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -1,22 +1,22 @@ /** @jsxImportSource @opentui/solid */ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import type { TuiPlugin, TuiPluginApi, TuiPluginModule, } from '@opencode-ai/plugin/tui' -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { For, type JSX, Show, createSignal, onCleanup } from 'solid-js' +import { createSignal, For, type JSX, onCleanup, Show } from 'solid-js' import { type AccountQuota, DEFAULT_SIDEBAR_STATE, - type SidebarState, getCollapsedQuotaSummary, getSidebarState, resolveActiveAccount, + type SidebarState, } from './sidebar-state.js' const POLL_MS = 1500 @@ -198,11 +198,14 @@ function AccountBlock(props: { theme: ThemeCurrent name: string quota: AccountQuota | null + killed: boolean active: boolean marginTop?: number }) { - const statusWord = () => (props.active ? 'active' : 'idle') - const statusTone = (): Tone => (props.active ? 'ok' : 'muted') + const statusWord = () => + props.killed ? 'blocked' : props.active ? 'active' : 'idle' + const statusTone = (): Tone => + props.killed ? 'err' : props.active ? 'ok' : 'muted' return ( @@ -292,10 +295,18 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { ].filter((value): value is number => value != null) return values.length > 0 ? usageTone(Math.max(...values)) : 'muted' } + const killedNames = () => + [ + state().main.killed ? 'main' : '', + ...enabledFallbacks() + .filter((f) => f.killed) + .map((f) => f.label ?? f.id), + ].filter(Boolean) const quotaBackedOff = () => state().main.quotaBackedOff === true const refreshBackedOff = () => state().main.refreshBackedOff === true - const degraded = () => quotaBackedOff() || refreshBackedOff() + const degraded = () => + killedNames().length > 0 || quotaBackedOff() || refreshBackedOff() const cacheKeep = () => state().cacheKeep const showCache = () => cacheKeep() != null && cacheKeep()?.window != null @@ -352,16 +363,27 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { - {/* Collapsed: active account 5h + 7d quota, plus fast-mode when on */} + {/* Collapsed: active account 5h + 7d quota + dot (red ⊘ when killed), + plus fast-mode when on */} {'\u2014'}} > - - {activeQuotaSummary().text} - + + + {activeQuotaSummary().text} + + + {activeAccount().killed ? ' \u2298' : ' \u25cf'} + + @@ -390,6 +412,7 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { theme={theme()} name='main' quota={state().main.quota} + killed={state().main.killed} active={state().activeId === 'main'} /> @@ -398,6 +421,7 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { theme={theme()} name={fb.label ?? fb.id} quota={fb.quota} + killed={fb.killed} active={state().activeId === fb.id} marginTop={1} /> @@ -465,6 +489,14 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { /> + 0}> + + )