From d8b1bb332f61dd7fd1a6866e78610184db864a21 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:42:55 +0200 Subject: [PATCH] feat(opencode): add killswitch indicators to TUI sidebar Layer killswitch awareness onto the restyled sidebar (killed state in SidebarState + writeSidebarState via killswitchPassesPolicy, blocked status word, Killswitch health row, degraded/LIMITED inclusion). Also restores the process-scoped 'let sessionRequestCount' (a prior cascade had flipped it to const, which left the active-route fallback every-N refresh reading a never-incremented counter). --- README.md | 2 +- packages/opencode/src/index.ts | 30 +++++++--- packages/opencode/src/sidebar-state.ts | 13 ++++- .../opencode/src/tests/sidebar-state.test.ts | 56 ++++++++++++++----- packages/opencode/src/tui.tsx | 36 ++++++++++-- 5 files changed, 107 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index ea05d6c..74b469f 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,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 ed1915c..94f881d 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -385,11 +385,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 @@ -403,18 +410,27 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }, fallbacks: (storage?.accounts ?? []) .filter((account) => account.enabled !== false) - .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 8e0f92b..4091244 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,8 +103,14 @@ 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, + } } diff --git a/packages/opencode/src/tests/sidebar-state.test.ts b/packages/opencode/src/tests/sidebar-state.test.ts index 2a20bbc..868512d 100644 --- a/packages/opencode/src/tests/sidebar-state.test.ts +++ b/packages/opencode/src/tests/sidebar-state.test.ts @@ -3,6 +3,7 @@ import { type AccountQuota, DEFAULT_SIDEBAR_STATE, resolveActiveAccount, + type SidebarAccountState, type SidebarState, } from '../sidebar-state' @@ -11,25 +12,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') @@ -40,9 +55,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') }) @@ -50,9 +63,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) @@ -61,20 +74,35 @@ 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) + }) }) diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index 3fa9e04..988af37 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -197,11 +197,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 ( @@ -283,10 +286,18 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { const activeAccount = () => resolveActiveAccount(state()) const activeFiveHourPct = () => activeAccount().quota?.five_hour?.usedPercent ?? null + 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 @@ -343,7 +354,8 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { - {/* Collapsed: active account 5h quota + dot, plus fast-mode when on */} + {/* Collapsed: active account 5h quota + dot (red ⊘ when blocked), + plus fast-mode when on */} - {' \u25cf'} + {activeAccount().killed ? ' \u2298' : ' \u25cf'} @@ -398,6 +412,7 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { theme={theme()} name='main' quota={state().main.quota} + killed={state().main.killed} active={state().activeId === 'main'} /> @@ -406,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} /> @@ -473,6 +489,14 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { /> + 0}> + + )