From a83c1095c15b31ef35edb3967e5ddd6fc1d934ff Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 21 May 2026 20:23:57 +0200 Subject: [PATCH 1/2] feat(opencode): show quota usage toast after quota refresh Displays quota usage bar notifications via client.tui.showToast after quota data is refreshed. Shows main and fallback account usage with visual bars, percentage, and reset time. Toast variant reflects severity (info < 70%, warning >= 70%, error >= 90%). --- packages/opencode/src/index.ts | 129 ++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index cd349bc..26b3cbc 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -42,6 +42,7 @@ import { loadAccounts, log, mergeAnthropicBetas, + type OAuthQuotaSnapshot, PARALLEL_TOOL_CALLS_SYSTEM_PROMPT, parseCache1hCommandAction, parseCacheKeepCommandAction, @@ -671,6 +672,110 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }) } + function quotaBar(pct: number, width = 16): string { + const filled = Math.min(Math.round((pct / 100) * width), width) + return 'โ–ˆ'.repeat(filled) + 'โ–‘'.repeat(width - filled) + } + + function formatResetIn(resetsAt: string | undefined): string { + if (!resetsAt) return '' + const ms = new Date(resetsAt).getTime() - Date.now() + if (ms <= 0) return 'resets now' + const mins = Math.floor(ms / 60_000) + if (mins < 60) return `resets ${mins}m` + const hrs = Math.floor(mins / 60) + const rm = mins % 60 + return rm > 0 ? `resets ${hrs}h${rm}m` : `resets ${hrs}h` + } + + function showQuotaToast( + quota: OAuthQuotaSnapshot | null, + fallbacks?: Array<{ + id: string + label?: string + quota?: OAuthQuotaSnapshot + }>, + activeAccountId?: string, + ) { + const sections: string[] = [] + let globalMaxUsed = 0 + + // Main account + if (quota) { + const fh = quota.five_hour + const sd = quota.seven_day + if (fh || sd) { + const mainActive = activeAccountId === 'main' + const indicator = mainActive ? '๐ŸŸข' : ' ' + const reset = formatResetIn(fh?.resetsAt) + const lines: string[] = [ + `${indicator} main${reset ? ` (${reset})` : ''}`, + ] + if (fh) { + lines.push( + `5h ${quotaBar(fh.usedPercent)} ${Math.round(fh.usedPercent)}%`, + ) + globalMaxUsed = Math.max(globalMaxUsed, fh.usedPercent) + } + if (sd) { + lines.push( + `1w ${quotaBar(sd.usedPercent)} ${Math.round(sd.usedPercent)}%`, + ) + globalMaxUsed = Math.max(globalMaxUsed, sd.usedPercent) + } + sections.push(lines.join('\n')) + } + } + + // Fallback accounts + if (fallbacks?.length) { + for (const fb of fallbacks) { + const q = fb.quota + if (!q) continue + const fh = q.five_hour + const sd = q.seven_day + if (!fh && !sd) continue + const name = fb.label || 'alt' + const fbActive = activeAccountId === fb.id + const indicator = fbActive ? '๐ŸŸข' : ' ' + const fbReset = formatResetIn(fh?.resetsAt) + const lines: string[] = [ + `${indicator} ${name}${fbReset ? ` (${fbReset})` : ''}`, + ] + if (fh) { + lines.push( + `5h ${quotaBar(fh.usedPercent)} ${Math.round(fh.usedPercent)}%`, + ) + globalMaxUsed = Math.max(globalMaxUsed, fh.usedPercent) + } + if (sd) { + lines.push( + `1w ${quotaBar(sd.usedPercent)} ${Math.round(sd.usedPercent)}%`, + ) + globalMaxUsed = Math.max(globalMaxUsed, sd.usedPercent) + } + sections.push(lines.join('\n')) + } + } + + if (!sections.length) return + const message = sections.join('\n\n') + const variant = + globalMaxUsed >= 90 ? 'error' : globalMaxUsed >= 70 ? 'warning' : 'info' + + // biome-ignore lint/suspicious/noExplicitAny: SDK client.tui type not exposed to server plugins + void (client.tui as any) + ?.showToast?.({ + body: { + title: 'Claude Quota', + message, + variant, + duration: variant === 'error' ? 8000 : 5000, + }, + }) + ?.catch?.(() => {}) + } + return { config: async (config: { command?: Record }) => { config.command = { @@ -1710,6 +1815,26 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { trace.done('missing_access_error') throw new Error('OAuth access token is missing after refresh') } + /** Show quota toast from current QuotaManager state */ + function showQuotaToastFromCache() { + const mainEntry = quotaManager.getMain() + if (!mainEntry) return + const fallbacks = (storage?.accounts ?? []).filter( + (a) => a.enabled !== false, + ) + const mainPassesPolicy = quotaSnapshotPassesPolicy( + mainEntry.quota, + storage, + ) + let activeId: string | undefined + if (mainPassesPolicy) { + activeId = 'main' + } else { + activeId = fallbacks[0]?.id + } + showQuotaToast(mainEntry.quota, fallbacks, activeId) + } + if (replayableRequest && mainQuotaRoutingEnabled(storage)) { try { const quotaStart = nowMs() @@ -1719,14 +1844,16 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { let routingQuota = quotaManager.getMain(auth.access)?.quota if (!routingQuota) { routingQuota = await quotaManager.refreshMain(auth.access) + showQuotaToastFromCache() } else if (quotaManager.needsRefresh(sessionRequestCount)) { // Stale OR every-N request boundary โ€” background refresh, // return current snapshot to avoid blocking. Refresh the - // sidebar again once the new main quota lands. + // sidebar and show the toast once the new main quota lands. void quotaManager .refreshMain(auth.access) .then(() => { void refreshSidebarQuota() + showQuotaToastFromCache() }) .catch(() => {}) } From 4b7a88c3a3352c3244e5646b797fffcb32852990 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:23:22 +0200 Subject: [PATCH 2/2] feat(opencode): restyle quota toast to match the TUI sidebar Align the quota refresh toast with the sidebar's visual language: - Replace the emoji status dots with status words (active/idle) - Use the shared bar width and a padded percentage via a quotaLine helper - Rename the seven-day label from 1w to 7d to match the sidebar - Keep severity-driven variant color (info/warning/error) --- packages/opencode/src/index.ts | 61 +++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 26b3cbc..7322505 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -672,16 +672,23 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }) } - function quotaBar(pct: number, width = 16): string { - const filled = Math.min(Math.round((pct / 100) * width), width) + function quotaBar(pct: number, width = 10): string { + const filled = Math.max(0, Math.min(Math.round((pct / 100) * width), width)) return 'โ–ˆ'.repeat(filled) + 'โ–‘'.repeat(width - filled) } + function quotaLine(label: string, pct: number): string { + return `${label} ${quotaBar(pct)} ${String(Math.round(pct)).padStart(3)}%` + } + function formatResetIn(resetsAt: string | undefined): string { if (!resetsAt) return '' - const ms = new Date(resetsAt).getTime() - Date.now() + const ts = new Date(resetsAt).getTime() + if (Number.isNaN(ts)) return '' + const ms = ts - Date.now() if (ms <= 0) return 'resets now' const mins = Math.floor(ms / 60_000) + if (mins < 1) return 'resets <1m' if (mins < 60) return `resets ${mins}m` const hrs = Math.floor(mins / 60) const rm = mins % 60 @@ -706,21 +713,17 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { const sd = quota.seven_day if (fh || sd) { const mainActive = activeAccountId === 'main' - const indicator = mainActive ? '๐ŸŸข' : ' ' + const status = mainActive ? 'active' : 'idle' const reset = formatResetIn(fh?.resetsAt) const lines: string[] = [ - `${indicator} main${reset ? ` (${reset})` : ''}`, + `main ยท ${status}${reset ? ` (${reset})` : ''}`, ] if (fh) { - lines.push( - `5h ${quotaBar(fh.usedPercent)} ${Math.round(fh.usedPercent)}%`, - ) + lines.push(quotaLine('5h', fh.usedPercent)) globalMaxUsed = Math.max(globalMaxUsed, fh.usedPercent) } if (sd) { - lines.push( - `1w ${quotaBar(sd.usedPercent)} ${Math.round(sd.usedPercent)}%`, - ) + lines.push(quotaLine('7d', sd.usedPercent)) globalMaxUsed = Math.max(globalMaxUsed, sd.usedPercent) } sections.push(lines.join('\n')) @@ -737,21 +740,17 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { if (!fh && !sd) continue const name = fb.label || 'alt' const fbActive = activeAccountId === fb.id - const indicator = fbActive ? '๐ŸŸข' : ' ' + const status = fbActive ? 'active' : 'idle' const fbReset = formatResetIn(fh?.resetsAt) const lines: string[] = [ - `${indicator} ${name}${fbReset ? ` (${fbReset})` : ''}`, + `${name} ยท ${status}${fbReset ? ` (${fbReset})` : ''}`, ] if (fh) { - lines.push( - `5h ${quotaBar(fh.usedPercent)} ${Math.round(fh.usedPercent)}%`, - ) + lines.push(quotaLine('5h', fh.usedPercent)) globalMaxUsed = Math.max(globalMaxUsed, fh.usedPercent) } if (sd) { - lines.push( - `1w ${quotaBar(sd.usedPercent)} ${Math.round(sd.usedPercent)}%`, - ) + lines.push(quotaLine('7d', sd.usedPercent)) globalMaxUsed = Math.max(globalMaxUsed, sd.usedPercent) } sections.push(lines.join('\n')) @@ -1819,9 +1818,19 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { function showQuotaToastFromCache() { const mainEntry = quotaManager.getMain() if (!mainEntry) return - const fallbacks = (storage?.accounts ?? []).filter( - (a) => a.enabled !== false, - ) + // Prefer the shared QuotaManager cache for fallback quota so the + // toast matches the sidebar and reflects background refreshes + // rather than the request-start storage snapshot. + const fallbacks = (storage?.accounts ?? []) + .filter((a) => a.enabled !== false) + .map((a) => ({ + ...a, + // Token-aware read so a cached snapshot bound to a previous + // access token (account re-login) is never shown. + quota: + quotaManager.getFallback(a.id, a.access)?.quota ?? + a.quota, + })) const mainPassesPolicy = quotaSnapshotPassesPolicy( mainEntry.quota, storage, @@ -1830,7 +1839,13 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { if (mainPassesPolicy) { activeId = 'main' } else { - activeId = fallbacks[0]?.id + // Mirror routing: the active account is the first fallback that + // actually passes quota policy; if none do, routing falls + // through to main, so label main โ€” never a failing fallback. + activeId = + fallbacks.find((f) => + quotaSnapshotPassesPolicy(f.quota, storage), + )?.id ?? 'main' } showQuotaToast(mainEntry.quota, fallbacks, activeId) }