From 5b99932a52b04848422b7a9a7bb7d32d073dca71 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 | 46 ++++++++++++--- 5 files changed, 116 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 623c18c..874a902 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 persists across restarts by default via `tui-preferences.jsonc` (`rememberCollapsed`); set `"rememberCollapsed": false` for the old per-session behavior. diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 9bf1994..7637b5f 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -509,11 +509,18 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { (storage?.accounts ?? []).filter(isOAuthAccount), ) 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 @@ -530,18 +537,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 5eabedf..e168da3 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 1f5e0d2..cdbd548 100644 --- a/packages/opencode/src/tests/sidebar-state.test.ts +++ b/packages/opencode/src/tests/sidebar-state.test.ts @@ -7,6 +7,7 @@ import { getCollapsedQuotaSummary, resolveActiveAccount, SEVEN_DAY_MS, + type SidebarAccountState, type SidebarState, } from '../sidebar-state' @@ -15,25 +16,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') @@ -44,9 +59,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') }) @@ -54,9 +67,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) @@ -65,22 +78,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 661534b..f82ed87 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -297,12 +297,15 @@ function AccountBlock(props: { appearance: AppearancePrefs name: string quota: AccountQuota | null + killed: boolean active: boolean pacingEnabled: 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' const pacingFor = ( window: | { usedPercent: number; remainingPercent: number; resetsAt?: string } @@ -501,10 +504,18 @@ function QuotaSidebar(props: { if (!activePacingDeficit()) return base return base === 'ok' || base === 'muted' ? 'warn' : base } + 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 = () => @@ -562,16 +573,27 @@ function QuotaSidebar(props: { - {/* 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'} + + @@ -602,6 +624,7 @@ function QuotaSidebar(props: { appearance={prefs().appearance} name='main' quota={state().main.quota} + killed={state().main.killed} active={state().activeId === 'main'} pacingEnabled={prefs().sections.pacing} /> @@ -613,6 +636,7 @@ function QuotaSidebar(props: { appearance={prefs().appearance} name={fb.label ?? fb.id} quota={fb.quota} + killed={fb.killed} active={state().activeId === fb.id} pacingEnabled={prefs().sections.pacing} marginTop={1} @@ -685,6 +709,14 @@ function QuotaSidebar(props: { /> + 0}> + + )