Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
30 changes: 23 additions & 7 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: (() => {
Expand Down
13 changes: 11 additions & 2 deletions packages/opencode/src/sidebar-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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') {
Expand All @@ -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,
}
}
56 changes: 42 additions & 14 deletions packages/opencode/src/tests/sidebar-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type AccountQuota,
DEFAULT_SIDEBAR_STATE,
resolveActiveAccount,
type SidebarAccountState,
type SidebarState,
} from '../sidebar-state'

Expand All @@ -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<SidebarAccountState> & { id: string },
): SidebarAccountState => ({
label: undefined,
quota: null,
killed: false,
enabled: true,
...overrides,
})

function make(overrides: Partial<SidebarState>): 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')
Expand All @@ -40,19 +55,17 @@ 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')
})

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)
Expand All @@ -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)
})
})
36 changes: 30 additions & 6 deletions packages/opencode/src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<box width='100%' flexDirection='column' marginTop={props.marginTop ?? 0}>
<box width='100%' flexDirection='row' justifyContent='space-between'>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -343,7 +354,8 @@ function QuotaSidebar(props: { api: TuiPluginApi }) {
</Show>
</box>

{/* 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 */}
<Show when={collapsed() && hasData()}>
<CollapsedRow theme={theme()} label={activeAccount().name}>
<Show
Expand All @@ -364,10 +376,12 @@ function QuotaSidebar(props: { api: TuiPluginApi }) {
<text
fg={toneColor(
theme(),
usageTone(activeFiveHourPct() as number),
activeAccount().killed
? 'err'
: usageTone(activeFiveHourPct() as number),
)}
>
{' \u25cf'}
{activeAccount().killed ? ' \u2298' : ' \u25cf'}
</text>
</box>
</Show>
Expand Down Expand Up @@ -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'}
/>
<For each={enabledFallbacks()}>
Expand All @@ -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}
/>
Expand Down Expand Up @@ -473,6 +489,14 @@ function QuotaSidebar(props: { api: TuiPluginApi }) {
/>
</Show>
</Show>
<Show when={killedNames().length > 0}>
<StatRow
theme={theme()}
label='Killswitch'
value={`${killedNames().join(', ')} blocked`}
tone='err'
/>
</Show>
</Show>
</box>
)
Expand Down