From e0dcbfece6642aa49fb0fd420abb571bf26def08 Mon Sep 17 00:00:00 2001 From: improdead Date: Sun, 18 Jan 2026 16:58:38 -0500 Subject: [PATCH 1/2] feat(tui): add /usage command to view AI provider quota usage - Add DialogUsage component with progress bars for usage tracking - Support OpenAI, Anthropic, GitHub Copilot, and Antigravity providers - Add Tab toggle to switch between 'used' and 'remaining' view - Implement Copilot device flow auth for usage API access - Store Copilot usage token separately (different OAuth client ID) - Add graceful 401/403 error handling for all providers - Show 'Unlimited' for pay-per-use providers like OpenCode Zen Closes #9281 --- packages/opencode/src/cli/cmd/tui/app.tsx | 12 + .../cli/cmd/tui/component/dialog-usage.tsx | 347 ++++++++++++++++++ .../cli/cmd/tui/routes/session/sidebar.tsx | 216 ++++++++++- packages/opencode/src/usage/index.ts | 87 +++++ .../opencode/src/usage/providers/anthropic.ts | 85 +++++ .../src/usage/providers/antigravity.ts | 223 +++++++++++ .../src/usage/providers/copilot-auth.ts | 149 ++++++++ .../opencode/src/usage/providers/copilot.ts | 118 ++++++ .../opencode/src/usage/providers/openai.ts | 68 ++++ packages/opencode/src/usage/types.ts | 23 ++ 10 files changed, 1327 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx create mode 100644 packages/opencode/src/usage/index.ts create mode 100644 packages/opencode/src/usage/providers/anthropic.ts create mode 100644 packages/opencode/src/usage/providers/antigravity.ts create mode 100644 packages/opencode/src/usage/providers/copilot-auth.ts create mode 100644 packages/opencode/src/usage/providers/copilot.ts create mode 100644 packages/opencode/src/usage/providers/openai.ts create mode 100644 packages/opencode/src/usage/types.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4b177e292cf3..62d3eeacc16f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -13,6 +13,7 @@ import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel, useConnected } from "@tui/component/dialog-model" import { DialogMcp } from "@tui/component/dialog-mcp" import { DialogStatus } from "@tui/component/dialog-status" +import { DialogUsage } from "@tui/component/dialog-usage" import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" @@ -447,6 +448,17 @@ function App() { }, category: "System", }, + { + title: "View usage", + value: "usage.view", + category: "System", + slash: { + name: "usage", + }, + onSelect: () => { + dialog.replace(() => ) + }, + }, { title: "Switch theme", value: "theme.switch", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx new file mode 100644 index 000000000000..089ab2d203de --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx @@ -0,0 +1,347 @@ +import { TextAttributes } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" +import { useTheme } from "../context/theme" +import { useLocal } from "../context/local" +import { useDialog } from "../ui/dialog" +import { Show, createSignal, onMount, createMemo } from "solid-js" +import { Usage, type ProviderUsage, type RateWindow } from "@/usage" +import { Link } from "../ui/link" +import { + requestCopilotDeviceCode, + pollCopilotAccessToken, + saveCopilotUsageToken, +} from "@/usage/providers/copilot-auth" + +// Map OpenCode provider IDs to usage provider IDs +const PROVIDER_MAP: Record = { + "github-copilot": "github-copilot", + "github-copilot-enterprise": "github-copilot", + openai: "openai", + anthropic: "anthropic", + google: "antigravity", + "google-vertex": "antigravity", + opencode: "opencode", +} + +// Providers that are pay-per-use with no rate limits +const UNLIMITED_PROVIDERS: Record = { + opencode: "OpenCode Zen", +} + +export function DialogUsage() { + const { theme } = useTheme() + const local = useLocal() + const dialog = useDialog() + const [loading, setLoading] = createSignal(true) + const [providers, setProviders] = createSignal([]) + const [error, setError] = createSignal(null) + const [showRemaining, setShowRemaining] = createSignal(false) + + const currentProviderID = createMemo(() => { + const model = local.model.current() + if (!model) return null + return PROVIDER_MAP[model.providerID] ?? model.providerID + }) + + const currentProvider = createMemo((): ProviderUsage | null => { + const id = currentProviderID() + if (!id) return null + + // First check if we have usage data for this provider + const found = providers().find((p) => p.providerId === id) + if (found) return found + + // If not found but it's an unlimited provider, create a synthetic entry + if (UNLIMITED_PROVIDERS[id]) { + return { + providerId: id, + providerLabel: UNLIMITED_PROVIDERS[id], + status: "unlimited", + } + } + + return null + }) + + const refetchUsage = async () => { + setLoading(true) + setError(null) + try { + const snapshot = await Usage.fetch() + setProviders(snapshot.providers) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + } + + useKeyboard((evt) => { + if (evt.name === "tab") { + evt.preventDefault() + setShowRemaining((prev) => !prev) + } + }) + + onMount(refetchUsage) + + return ( + + + + Usage + + + tab toggle view | esc + + + + + Loading... + + + + Error: {error()} + + + + + + + + + Usage not available for current provider + + + + ) +} + +function CurrentProviderSection(props: { + provider: ProviderUsage + showRemaining: boolean + onAuthComplete: () => Promise +}) { + const { theme } = useTheme() + const p = () => props.provider + + const isCopilotReauth = createMemo(() => p().providerId === "github-copilot" && p().error === "copilot_reauth_required") + + return ( + + + + {p().providerLabel} + + + ({p().plan}) + + + + + + + + + {p().error} + + + + {p().error ?? "Usage tracking not supported"} + + + + + + Unlimited + + No rate limits - pay per use + + + + + + + + + + + + + + + {p().accountEmail} + + + + ) +} + +function CopilotSetupPrompt(props: { onAuthComplete: () => Promise }) { + const { theme } = useTheme() + const dialog = useDialog() + const [setting, setSetting] = createSignal(false) + const [authData, setAuthData] = createSignal<{ url: string; code: string } | null>(null) + const [authError, setAuthError] = createSignal(null) + + useKeyboard((evt) => { + if (setting()) return + if (evt.name === "y") { + evt.preventDefault() + evt.stopPropagation() + startCopilotAuth() + } + if (evt.name === "n") { + evt.preventDefault() + evt.stopPropagation() + dialog.clear() + } + }) + + async function startCopilotAuth() { + setSetting(true) + setAuthError(null) + try { + // Request device code using Copilot-specific client ID + const deviceCode = await requestCopilotDeviceCode() + + setAuthData({ + url: deviceCode.verificationUri, + code: deviceCode.userCode, + }) + + // Poll for access token + const tokenResponse = await pollCopilotAccessToken({ + deviceCode: deviceCode.deviceCode, + interval: deviceCode.interval, + expiresIn: deviceCode.expiresIn, + }) + + // Save the token for future usage fetches + await saveCopilotUsageToken({ + accessToken: tokenResponse.access_token, + scope: tokenResponse.scope, + createdAt: new Date().toISOString(), + }) + + // Refetch usage to show the new data + await props.onAuthComplete() + } catch (e) { + setAuthError(e instanceof Error ? e.message : String(e)) + setSetting(false) + setAuthData(null) + } + } + + return ( + + + + Usage tracking requires GitHub authentication with Copilot permissions. + + + Would you like to set up Copilot usage tracking now? + + + + y + yes + + + n + no + + + + + + Starting GitHub authentication... + + + + Error: {authError()} + + + + + Login with GitHub + + + + Enter code: {authData()!.code} + + Waiting for authorization... + + + ) +} + +function LargeRateBar(props: { window: RateWindow; showRemaining: boolean }) { + const { theme } = useTheme() + const w = () => props.window + + const barWidth = 50 + + // When showing remaining, we show remaining % as filled (green = more remaining = good) + // When showing used, we show used % as filled (green = less used = good) + const displayPercent = createMemo(() => { + const used = Math.min(100, Math.max(0, w().usedPercent)) + return props.showRemaining ? 100 - used : used + }) + + const filledWidth = createMemo(() => Math.round((displayPercent() / 100) * barWidth)) + const emptyWidth = createMemo(() => barWidth - filledWidth()) + + const displayPct = createMemo(() => Math.round(displayPercent())) + const label = createMemo(() => (props.showRemaining ? "remaining" : "used")) + + const barColor = createMemo(() => { + const pct = w().usedPercent + // Color is always based on usage (not remaining) + if (pct >= 90) return theme.error + if (pct >= 75) return theme.warning + return theme.success + }) + + const resetText = createMemo(() => { + if (!w().resetsAt) return null + const resetDate = new Date(w().resetsAt!) + if (Number.isNaN(resetDate.getTime())) return null + const now = new Date() + const diffMs = resetDate.getTime() - now.getTime() + if (diffMs <= 0) return "Resets soon" + + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffMins < 60) return `Resets in ${diffMins}m` + if (diffHours < 24) return `Resets in ${diffHours}h ${diffMins % 60}m` + if (diffDays === 1) return `Resets tomorrow` + return `Resets in ${diffDays} days` + }) + + return ( + + + {w().label} + + {displayPct()}% {label()} + + + + + {"█".repeat(filledWidth())} + {"░".repeat(emptyWidth())} + + + + {resetText()} + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index ebc7514d723a..ad20ba3fe875 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,6 +1,7 @@ import { useSync } from "@tui/context/sync" -import { createMemo, For, Show, Switch, Match } from "solid-js" +import { createMemo, createSignal, createEffect, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" +import { Usage, type ProviderUsage, type RateWindow } from "@/usage" import { useTheme } from "../../context/theme" import { Locale } from "@/util/locale" import path from "path" @@ -10,11 +11,29 @@ import { Installation } from "@/installation" import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" +import { useLocal } from "../../context/local" import { TodoItem } from "../../component/todo-item" +// Map OpenCode provider IDs to usage provider IDs +const PROVIDER_MAP: Record = { + "github-copilot": "github-copilot", + "github-copilot-enterprise": "github-copilot", + openai: "openai", + anthropic: "anthropic", + google: "antigravity", + "google-vertex": "antigravity", + opencode: "opencode", +} + +// Providers that are pay-per-use with no rate limits +const UNLIMITED_PROVIDERS: Record = { + opencode: "OpenCode Zen", +} + export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() const { theme } = useTheme() + const local = useLocal() const session = createMemo(() => sync.session.get(props.sessionID)!) const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) @@ -27,6 +46,110 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { lsp: true, }) + // Usage tracking + const [usageProviders, setUsageProviders] = createSignal([]) + const [usageLoading, setUsageLoading] = createSignal(true) + + // Get current provider from the selected model (not last assistant message) + const currentProviderID = createMemo(() => { + const model = local.model.current() + if (!model) return null + return PROVIDER_MAP[model.providerID] ?? model.providerID + }) + + const currentUsage = createMemo((): ProviderUsage | null => { + const id = currentProviderID() + if (!id) return null + + // If it's an unlimited provider, create a synthetic entry + if (UNLIMITED_PROVIDERS[id]) { + return { + providerId: id, + providerLabel: UNLIMITED_PROVIDERS[id], + status: "unlimited", + } + } + + // Return usage data for this provider (including error states) + const found = usageProviders().find((p) => p.providerId === id) + return found ?? null + }) + + const fetchUsage = async () => { + const providerID = currentProviderID() + if (!providerID) { + setUsageProviders([]) + setUsageLoading(false) + return + } + + if (usageHidden()) return + + if (UNLIMITED_PROVIDERS[providerID]) { + setUsageProviders([]) + setUsageLoading(false) + return + } + + setUsageLoading(true) + try { + const snapshot = await Usage.fetch({ providers: [providerID] }) + setUsageProviders(snapshot.providers) + } catch { + // Silently fail - usage section will just not show + } finally { + setUsageLoading(false) + } + } + + // Refetch usage when an assistant turn completes + // Track the last completed assistant message ID + const lastCompletedAssistantId = createMemo(() => { + const assistantMsgs = messages().filter((x) => x.role === "assistant" && x.time.completed) + if (assistantMsgs.length === 0) return null + return assistantMsgs[assistantMsgs.length - 1]?.id + }) + + let prevCompletedId: string | null = null + createEffect(() => { + const currentId = lastCompletedAssistantId() + if (currentId && currentId !== prevCompletedId) { + prevCompletedId = currentId + // Debounce slightly to avoid rapid refetches + setTimeout(fetchUsage, 100) + } + }) + + // Refetch usage when the selected provider changes + let prevProviderID: string | null = null + createEffect(() => { + const providerID = currentProviderID() + if (!providerID) { + prevProviderID = null + setUsageProviders([]) + setUsageLoading(false) + return + } + if (providerID !== prevProviderID) { + prevProviderID = providerID + fetchUsage() + } + }) + + // Refetch usage when the section is shown again + let prevHidden: boolean | null = null + createEffect(() => { + const hidden = usageHidden() + if (prevHidden === null) { + prevHidden = hidden + return + } + if (prevHidden && !hidden) { + fetchUsage() + } + prevHidden = hidden + }) + // Sort MCP servers alphabetically for consistent display order const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b))) @@ -67,6 +190,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), ) const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false)) + const usageHidden = createMemo(() => kv.get("hidden_sidebar_usage", false)) return ( @@ -96,7 +220,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {context()?.tokens ?? 0} tokens {context()?.percentage ?? 0}% used {cost()} spent + + kv.set("hidden_sidebar_usage", false)}> + Show usage + + + + kv.set("hidden_sidebar_usage", true)} /> + 0}> ) } + +function SidebarUsageSection(props: { usage: ProviderUsage; onHide: () => void }) { + const { theme } = useTheme() + const u = () => props.usage + + // Compact progress bar for sidebar (narrower than dialog) + const barWidth = 12 + + const formatResetTime = (resetsAt: string) => { + const resetDate = new Date(resetsAt) + if (Number.isNaN(resetDate.getTime())) return null + const now = new Date() + const diffMs = resetDate.getTime() - now.getTime() + if (diffMs <= 0) return "soon" + + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffMins < 60) return `${diffMins}m` + if (diffHours < 24) return `${diffHours}h ${diffMins % 60}m` + if (diffDays === 1) return "tomorrow" + return `${diffDays}d` + } + + const formatSidebarLabel = (label: string) => label.replace(/\s*\([^)]*\)\s*$/, "") + + const renderCompactBar = (w: RateWindow) => { + const usedPct = Math.min(100, Math.max(0, w.usedPercent)) + const filledWidth = Math.round((usedPct / 100) * barWidth) + const emptyWidth = barWidth - filledWidth + + const barColor = usedPct >= 90 ? theme.error : usedPct >= 75 ? theme.warning : theme.success + + const resetText = w.resetsAt ? formatResetTime(w.resetsAt) : null + const labelText = Locale.truncate(formatSidebarLabel(w.label), 12) + const label = labelText.padEnd(12) + + return ( + + + {label} + + + {"█".repeat(filledWidth)} + {"░".repeat(emptyWidth)} + + + {Math.round(usedPct)}%{resetText ? ` (${resetText})` : ""} + + + ) + } + + return ( + + + + Usage + + + ✕ + + + + + Unlimited + + + {renderCompactBar(u().primary!)} + {renderCompactBar(u().secondary!)} + + + {u().error ?? "Unable to fetch"} + + + Not available + + + + ) +} diff --git a/packages/opencode/src/usage/index.ts b/packages/opencode/src/usage/index.ts new file mode 100644 index 000000000000..3f787f5db88b --- /dev/null +++ b/packages/opencode/src/usage/index.ts @@ -0,0 +1,87 @@ +import { Auth } from "@/auth" +import type { ProviderUsage, UsageSnapshot } from "./types" +import { fetchOpenAIUsage } from "./providers/openai" +import { fetchAnthropicUsage } from "./providers/anthropic" +import { fetchCopilotUsage } from "./providers/copilot" +import { fetchAntigravityUsage } from "./providers/antigravity" + +export type { ProviderUsage, RateWindow, UsageSnapshot } from "./types" + +export interface UsageFetchOptions { + providers?: string[] +} + +// Providers that use API keys and have no rate limits (pay-per-use) +const UNLIMITED_PROVIDERS: Record = { + opencode: "OpenCode Zen", +} + +export namespace Usage { + export async function fetch(options: UsageFetchOptions = {}): Promise { + const authMap = await Auth.all() + const providers: ProviderUsage[] = [] + const filter = options.providers && options.providers.length > 0 ? new Set(options.providers) : null + const shouldInclude = (id: string) => !filter || filter.has(id) + + const fetchers: Array<{ id: string; fn: () => Promise }> = [] + + // Check for unlimited providers first + for (const [providerId, label] of Object.entries(UNLIMITED_PROVIDERS)) { + if (!shouldInclude(providerId)) continue + if (authMap[providerId]) { + providers.push({ + providerId, + providerLabel: label, + status: "unlimited", + }) + } + } + + if (shouldInclude("openai") && authMap["openai"]) { + fetchers.push({ id: "openai", fn: () => fetchOpenAIUsage(authMap["openai"]!) }) + } + + if (shouldInclude("anthropic") && authMap["anthropic"]) { + fetchers.push({ id: "anthropic", fn: () => fetchAnthropicUsage(authMap["anthropic"]!) }) + } + + if (shouldInclude("github-copilot")) { + // Always try Copilot - it has its own token storage for usage + const copilotAuth = authMap["github-copilot-enterprise"] ?? authMap["github-copilot"] ?? null + fetchers.push({ id: "github-copilot", fn: () => fetchCopilotUsage(copilotAuth) }) + } + + if (shouldInclude("antigravity")) { + fetchers.push({ id: "antigravity", fn: () => fetchAntigravityUsage() }) + } + + const results = await Promise.allSettled(fetchers.map((f) => f.fn())) + + for (let i = 0; i < fetchers.length; i++) { + const result = results[i] + const fetcher = fetchers[i] + if (result.status === "fulfilled" && result.value) { + providers.push(result.value) + } else if (result.status === "rejected") { + providers.push({ + providerId: fetcher.id, + providerLabel: getLabelForProvider(fetcher.id), + status: "error", + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }) + } + } + + return { providers, fetchedAt: new Date().toISOString() } + } +} + +function getLabelForProvider(id: string): string { + const labels: Record = { + openai: "OpenAI/Codex", + anthropic: "Anthropic/Claude", + "github-copilot": "GitHub Copilot", + antigravity: "Antigravity", + } + return labels[id] ?? id +} diff --git a/packages/opencode/src/usage/providers/anthropic.ts b/packages/opencode/src/usage/providers/anthropic.ts new file mode 100644 index 000000000000..2708ebd5e8bc --- /dev/null +++ b/packages/opencode/src/usage/providers/anthropic.ts @@ -0,0 +1,85 @@ +import type { Auth } from "@/auth" +import type { ProviderUsage, RateWindow } from "../types" + +const USAGE_URL = "https://api.anthropic.com/api/oauth/usage" +const BETA_HEADER = "oauth-2025-04-20" + +interface OAuthUsageResponse { + five_hour?: OAuthUsageWindow + seven_day?: OAuthUsageWindow + seven_day_oauth_apps?: OAuthUsageWindow + seven_day_opus?: OAuthUsageWindow + seven_day_sonnet?: OAuthUsageWindow +} + +interface OAuthUsageWindow { + utilization?: number + resets_at?: string +} + +export async function fetchAnthropicUsage(auth: Auth.Info): Promise { + if (auth.type !== "oauth") { + return { providerId: "anthropic", providerLabel: "Anthropic/Claude", status: "unsupported", error: "Requires OAuth" } + } + + if (typeof auth.expires === "number" && auth.expires > 0 && Date.now() >= auth.expires) { + return { providerId: "anthropic", providerLabel: "Anthropic/Claude", status: "error", error: "Token expired. Run /connect to refresh." } + } + + const response = await fetch(USAGE_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${auth.access}`, + Accept: "application/json", + "Content-Type": "application/json", + "anthropic-beta": BETA_HEADER, + "User-Agent": "opencode", + }, + }) + + if (response.status === 401 || response.status === 403) { + return { + providerId: "anthropic", + providerLabel: "Anthropic/Claude", + status: "error", + error: "Token expired or invalid. Run /connect to refresh.", + } + } + + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new Error(`Anthropic usage request failed (${response.status}): ${body || response.statusText}`) + } + + const data = (await response.json()) as OAuthUsageResponse + + // Determine tertiary label based on which model limit is present + let tertiaryWindow: OAuthUsageWindow | undefined + let tertiaryLabel = "Weekly (model)" + if (data.seven_day_opus) { + tertiaryWindow = data.seven_day_opus + tertiaryLabel = "Weekly (Opus)" + } else if (data.seven_day_sonnet) { + tertiaryWindow = data.seven_day_sonnet + tertiaryLabel = "Weekly (Sonnet)" + } + + return { + providerId: "anthropic", + providerLabel: "Anthropic/Claude", + status: "ok", + primary: toRateWindow(data.five_hour, "5-hour window"), + secondary: toRateWindow(data.seven_day ?? data.seven_day_oauth_apps, "7-day window"), + tertiary: toRateWindow(tertiaryWindow, tertiaryLabel), + } +} + +function toRateWindow(window: OAuthUsageWindow | undefined, label: string): RateWindow | undefined { + if (!window) return undefined + const utilization = typeof window.utilization === "number" ? window.utilization : 0 + return { + label, + usedPercent: Math.max(0, Math.min(100, utilization * 100)), + resetsAt: window.resets_at || undefined, + } +} diff --git a/packages/opencode/src/usage/providers/antigravity.ts b/packages/opencode/src/usage/providers/antigravity.ts new file mode 100644 index 000000000000..607993a76fb3 --- /dev/null +++ b/packages/opencode/src/usage/providers/antigravity.ts @@ -0,0 +1,223 @@ +import { Global } from "@/global" +import path from "path" +import type { ProviderUsage, RateWindow } from "../types" + +const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token" +const ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com" +const ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com" +const ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com" +const LOAD_ENDPOINTS = [ENDPOINT_PROD, ENDPOINT_DAILY, ENDPOINT_AUTOPUSH] +const FETCH_MODELS_URL = `${ENDPOINT_PROD}/v1internal:fetchAvailableModels` + +const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" +const CLIENT_SECRET = "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" +const LOAD_USER_AGENT = "antigravity/windows/amd64" +const QUOTA_USER_AGENT = "antigravity/1.11.3 Darwin/arm64" +const CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}' +const X_GOOG_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1" +const FALLBACK_PROJECT = "rising-fact-p41fc" + +interface AntigravityAccount { + email?: string + refreshToken: string + projectId?: string + managedProjectId?: string +} + +interface AccountsFile { + version: number + accounts: AntigravityAccount[] + activeIndex?: number +} + +interface LoadCodeAssistResponse { + cloudaicompanionProject?: string | { id?: string } + currentTier?: { id?: string } + paidTier?: { id?: string } +} + +interface FetchModelsResponse { + models?: Record +} + +interface ModelQuota { + modelId: string + percentRemaining: number // 0-100, where 0 = fully used, 100 = fresh + resetTime?: string +} + +export async function fetchAntigravityUsage(): Promise { + const accountsFile = await loadAccountsFile() + if (!accountsFile?.accounts?.length) return null + + const account = accountsFile.accounts[Math.max(0, Math.min(accountsFile.activeIndex ?? 0, accountsFile.accounts.length - 1))] + if (!account) return null + + try { + const refreshParts = parseRefreshToken(account.refreshToken) + const accessToken = await refreshAccessToken(refreshParts.refreshToken) + const fallbackProjectId = account.managedProjectId ?? refreshParts.managedProjectId ?? account.projectId ?? refreshParts.projectId ?? FALLBACK_PROJECT + const { projectId, subscriptionTier } = await loadCodeAssist(accessToken, fallbackProjectId) + const quotaResponse = await fetchAvailableModels(accessToken, projectId ?? fallbackProjectId) + + const quotas = extractModelQuotas(quotaResponse.models ?? {}) + + // Find Gemini and Claude quotas + const geminiQuota = resolveModelQuota(quotas, "gemini") + const claudeQuota = resolveModelQuota(quotas, "claude") + + return { + providerId: "antigravity", + providerLabel: "Antigravity", + status: "ok", + primary: geminiQuota ? toRateWindow(geminiQuota) : undefined, + secondary: claudeQuota ? toRateWindow(claudeQuota) : undefined, + accountEmail: account.email, + plan: subscriptionTier, + } + } catch (error) { + return { + providerId: "antigravity", + providerLabel: "Antigravity", + status: "error", + error: error instanceof Error ? error.message : String(error), + } + } +} + +async function loadAccountsFile(): Promise { + const filePath = path.join(Global.Path.config, "antigravity-accounts.json") + const file = Bun.file(filePath) + if (!(await file.exists())) return null + return file.json().catch(() => null) +} + +function parseRefreshToken(raw: string): { refreshToken: string; projectId?: string; managedProjectId?: string } { + const [refreshToken = "", projectId, managedProjectId] = (raw ?? "").split("|") + return { refreshToken, projectId: projectId || undefined, managedProjectId: managedProjectId || undefined } +} + +async function refreshAccessToken(refreshToken: string): Promise { + if (!refreshToken) throw new Error("Antigravity refresh token missing") + const response = await fetch(OAUTH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID, client_secret: CLIENT_SECRET }), + }) + if (!response.ok) throw new Error(`Token refresh failed (${response.status})`) + const payload = (await response.json()) as { access_token?: string } + if (!payload.access_token) throw new Error("No access token returned") + return payload.access_token +} + +async function loadCodeAssist(accessToken: string, projectId: string): Promise<{ projectId?: string; subscriptionTier?: string }> { + const metadata = { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI", duetProject: projectId } + for (const endpoint of LOAD_ENDPOINTS) { + const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": LOAD_USER_AGENT, "X-Goog-Api-Client": X_GOOG_API_CLIENT, "Client-Metadata": CLIENT_METADATA }, + body: JSON.stringify({ metadata }), + }) + if (!response.ok) continue + const data = (await response.json()) as LoadCodeAssistResponse + const proj = typeof data.cloudaicompanionProject === "string" ? data.cloudaicompanionProject : data.cloudaicompanionProject?.id + return { projectId: proj, subscriptionTier: data.paidTier?.id ?? data.currentTier?.id } + } + return {} +} + +async function fetchAvailableModels(accessToken: string, projectId: string): Promise { + const response = await fetch(FETCH_MODELS_URL, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "User-Agent": QUOTA_USER_AGENT, "X-Goog-Api-Client": X_GOOG_API_CLIENT, "Client-Metadata": CLIENT_METADATA }, + body: JSON.stringify({ project: projectId }), + }) + if (!response.ok) throw new Error(`Quota request failed (${response.status})`) + return (await response.json()) as FetchModelsResponse +} + +function extractModelQuotas(models: Record): ModelQuota[] { + const quotas: ModelQuota[] = [] + for (const [name, info] of Object.entries(models)) { + // Only include models with quotaInfo (gemini or claude) + if (!info.quotaInfo) continue + if (!name.includes("gemini") && !name.includes("claude")) continue + + const fraction = info.quotaInfo.remainingFraction + // If remainingFraction is null/undefined, it means 0% remaining (fully used) + // This matches Antigravity-Manager behavior: .unwrap_or(0) + const percentRemaining = typeof fraction === "number" && !Number.isNaN(fraction) ? fraction * 100 : 0 + + quotas.push({ + modelId: name, + percentRemaining, + resetTime: info.quotaInfo.resetTime, + }) + } + return quotas +} + +function resolveModelQuota(quotas: ModelQuota[], type: "gemini" | "claude"): { label: string; quota: ModelQuota } | null { + const matches = quotas.filter((q) => q.modelId.includes(type)) + if (!matches.length) return null + + let quota: ModelQuota + + if (type === "gemini") { + const preferred = matches.find((q) => q.modelId.includes("gemini-3-pro")) + quota = preferred ?? matches.reduce((a, b) => (b.percentRemaining < a.percentRemaining ? b : a)) + } else { + // Pick the model with lowest remaining (highest usage) - most relevant limit + quota = matches.reduce((a, b) => (b.percentRemaining < a.percentRemaining ? b : a)) + } + + // Generate friendly label + const label = formatModelLabel(quota.modelId) + + return { label, quota } +} + +function formatModelLabel(modelId: string): string { + // Map model IDs to friendly names + const id = modelId.toLowerCase() + + if (id.includes("claude-opus-4-5") || id.includes("claude-opus-4.5")) return "Claude Opus 4.5" + if (id.includes("claude-opus-4")) return "Claude Opus 4" + if (id.includes("claude-sonnet-4-5") || id.includes("claude-sonnet-4.5")) return "Claude Sonnet 4.5" + if (id.includes("claude-sonnet-4")) return "Claude Sonnet 4" + if (id.includes("claude-opus")) return "Claude Opus" + if (id.includes("claude-sonnet")) return "Claude Sonnet" + if (id.includes("claude")) return "Claude" + + if (id.includes("gemini-3-pro")) return "Gemini 3 Pro" + if (id.includes("gemini-3-flash")) return "Gemini 3 Flash" + if (id.includes("gemini-2.5-pro")) return "Gemini 2.5 Pro" + if (id.includes("gemini-2.5-flash")) return "Gemini 2.5 Flash" + if (id.includes("gemini")) return "Gemini" + + return modelId +} + +function toRateWindow(match: { label: string; quota: ModelQuota }): RateWindow { + const { label, quota } = match + const usedPercent = Math.max(0, 100 - quota.percentRemaining) + + // Build label with window info + const windowLabel = buildWindowLabel(label, quota.resetTime) + + return { + label: windowLabel, + usedPercent, + resetsAt: quota.resetTime ? new Date(quota.resetTime).toISOString() : undefined, + } +} + +function buildWindowLabel(modelLabel: string, resetsAt?: string): string { + if (!resetsAt) return modelLabel + const resetDate = new Date(resetsAt) + if (Number.isNaN(resetDate.getTime())) return modelLabel + const diffHours = (resetDate.getTime() - Date.now()) / (1000 * 60 * 60) + if (diffHours <= 0) return modelLabel + const windowType = diffHours <= 6 ? "5h window" : diffHours <= 26 ? "daily" : diffHours <= 180 ? "weekly" : `${Math.ceil(diffHours / 24)}d window` + return `${modelLabel} (${windowType})` +} diff --git a/packages/opencode/src/usage/providers/copilot-auth.ts b/packages/opencode/src/usage/providers/copilot-auth.ts new file mode 100644 index 000000000000..52a9e2f96967 --- /dev/null +++ b/packages/opencode/src/usage/providers/copilot-auth.ts @@ -0,0 +1,149 @@ +import fs from "fs/promises" +import path from "path" +import { Global } from "@/global" + +// This client ID is specifically for Copilot and grants access to the usage API +// It's different from OpenCode's OAuth client ID +const COPILOT_CLIENT_ID = "Iv1.b507a08c87ecfe98" +const COPILOT_SCOPE = "read:user" + +export interface CopilotUsageToken { + accessToken: string + scope?: string + createdAt: string +} + +export interface CopilotDeviceCode { + deviceCode: string + userCode: string + verificationUri: string + expiresIn: number + interval: number +} + +interface DeviceCodeResponse { + device_code: string + user_code: string + verification_uri: string + expires_in: number + interval: number +} + +interface AccessTokenResponse { + access_token: string + token_type: string + scope: string +} + +interface ErrorResponse { + error: string + error_description?: string +} + +function tokenFilePath(): string { + return path.join(Global.Path.data, "usage-copilot.json") +} + +export async function loadCopilotUsageToken(): Promise { + try { + const data = await fs.readFile(tokenFilePath(), "utf8") + const parsed = JSON.parse(data) as CopilotUsageToken + if (!parsed.accessToken) return null + return parsed + } catch { + return null + } +} + +export async function saveCopilotUsageToken(token: CopilotUsageToken): Promise { + const filePath = tokenFilePath() + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(token, null, 2), "utf8") +} + +export async function requestCopilotDeviceCode(): Promise { + const response = await fetch("https://github.com/login/device/code", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formEncode({ + client_id: COPILOT_CLIENT_ID, + scope: COPILOT_SCOPE, + }), + }) + + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new Error(`Copilot device code request failed (${response.status}): ${body || response.statusText}`) + } + + const data = (await response.json()) as DeviceCodeResponse + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + expiresIn: data.expires_in, + interval: data.interval, + } +} + +export async function pollCopilotAccessToken(options: { + deviceCode: string + interval: number + expiresIn: number + onPending?: () => void +}): Promise { + const deadline = Date.now() + options.expiresIn * 1000 + let intervalMs = Math.max(1, options.interval) * 1000 + + while (Date.now() < deadline) { + await Bun.sleep(intervalMs) + options.onPending?.() + + const response = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formEncode({ + client_id: COPILOT_CLIENT_ID, + device_code: options.deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }) + + const data = (await response.json()) as AccessTokenResponse | ErrorResponse + + if ("access_token" in data) { + return data + } + + if (data.error === "authorization_pending") { + continue + } + + if (data.error === "slow_down") { + intervalMs += 5000 + continue + } + + if (data.error === "expired_token") { + throw new Error("Copilot device code expired") + } + + throw new Error(data.error_description ?? data.error ?? "Copilot device flow failed") + } + + throw new Error("Copilot device flow timed out") +} + +function formEncode(params: Record): string { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + search.set(key, value) + } + return search.toString() +} diff --git a/packages/opencode/src/usage/providers/copilot.ts b/packages/opencode/src/usage/providers/copilot.ts new file mode 100644 index 000000000000..8e1d27084d9b --- /dev/null +++ b/packages/opencode/src/usage/providers/copilot.ts @@ -0,0 +1,118 @@ +import type { Auth } from "@/auth" +import type { ProviderUsage, RateWindow } from "../types" +import { loadCopilotUsageToken } from "./copilot-auth" + +const USAGE_URL = "https://api.github.com/copilot_internal/user" + +interface CopilotUsageResponse { + quota_snapshots: { + premium_interactions?: CopilotQuotaSnapshot + chat?: CopilotQuotaSnapshot + } + copilot_plan?: string + quota_reset_date?: string +} + +interface CopilotQuotaSnapshot { + entitlement: number + remaining: number + percent_remaining: number + quota_id: string +} + +async function tryFetchWithToken(token: string): Promise<{ ok: true; data: CopilotUsageResponse } | { ok: false; status: number }> { + const response = await fetch(USAGE_URL, { + method: "GET", + headers: { + Authorization: `token ${token}`, + Accept: "application/json", + "User-Agent": "GitHubCopilotChat/0.26.7", + "Editor-Version": "vscode/1.96.2", + "Editor-Plugin-Version": "copilot-chat/0.26.7", + "X-Github-Api-Version": "2025-04-01", + }, + }) + + if (response.status === 401 || response.status === 403 || response.status === 404) { + return { ok: false, status: response.status } + } + + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new Error(`Copilot usage request failed (${response.status}): ${body || response.statusText}`) + } + + const data = (await response.json()) as CopilotUsageResponse + return { ok: true, data } +} + +export async function fetchCopilotUsage(auth: Auth.Info | null): Promise { + // Collect all token candidates + const tokens: string[] = [] + + // 1. Try the usage-specific token first (stored by our device flow) + const usageToken = await loadCopilotUsageToken() + if (usageToken?.accessToken) { + tokens.push(usageToken.accessToken) + } + + // 2. Try tokens from OpenCode's auth system + if (auth?.type === "oauth") { + if (auth.access && !tokens.includes(auth.access)) { + tokens.push(auth.access) + } + if (auth.refresh && !tokens.includes(auth.refresh)) { + tokens.push(auth.refresh) + } + } + + if (tokens.length === 0) { + return { + providerId: "github-copilot", + providerLabel: "GitHub Copilot", + status: "error", + error: "copilot_reauth_required", + } + } + + // Try each token until one works + for (const token of tokens) { + const result = await tryFetchWithToken(token) + if (result.ok) { + const data = result.data + const resetAt = data.quota_reset_date ? new Date(data.quota_reset_date).toISOString() : undefined + return { + providerId: "github-copilot", + providerLabel: "GitHub Copilot", + status: "ok", + primary: toRateWindow(data.quota_snapshots?.premium_interactions, "Premium", resetAt), + secondary: toRateWindow(data.quota_snapshots?.chat, "Chat", resetAt), + plan: formatPlan(data.copilot_plan), + } + } + // If this token failed with auth error, try next one + } + + // All tokens failed + return { + providerId: "github-copilot", + providerLabel: "GitHub Copilot", + status: "error", + error: "copilot_reauth_required", + } +} + +function toRateWindow(snapshot: CopilotQuotaSnapshot | undefined, label: string, resetAt?: string): RateWindow | undefined { + if (!snapshot) return undefined + return { + label, + usedPercent: Math.max(0, 100 - snapshot.percent_remaining), + resetsAt: resetAt, + } +} + +function formatPlan(plan?: string): string | undefined { + if (!plan) return undefined + const trimmed = plan.trim() + return trimmed ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1) : undefined +} diff --git a/packages/opencode/src/usage/providers/openai.ts b/packages/opencode/src/usage/providers/openai.ts new file mode 100644 index 000000000000..d7a9bd1b9bb6 --- /dev/null +++ b/packages/opencode/src/usage/providers/openai.ts @@ -0,0 +1,68 @@ +import type { Auth } from "@/auth" +import type { ProviderUsage, RateWindow } from "../types" + +const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" + +interface CodexUsageResponse { + plan_type?: string + rate_limit?: { + primary_window?: WindowSnapshot + secondary_window?: WindowSnapshot + } +} + +interface WindowSnapshot { + used_percent: number + reset_at: number + limit_window_seconds: number +} + +export async function fetchOpenAIUsage(auth: Auth.Info): Promise { + if (auth.type !== "oauth") { + return { providerId: "openai", providerLabel: "OpenAI/Codex", status: "unsupported", error: "Requires OAuth" } + } + + const response = await fetch(USAGE_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${auth.access}`, + Accept: "application/json", + "User-Agent": "opencode", + ...(auth.accountId ? { "ChatGPT-Account-Id": auth.accountId } : {}), + }, + }) + + if (response.status === 401 || response.status === 403) { + return { + providerId: "openai", + providerLabel: "OpenAI/Codex", + status: "error", + error: "Token expired or invalid. Run /connect to refresh.", + } + } + + if (!response.ok) { + const body = await response.text().catch(() => "") + throw new Error(`OpenAI usage request failed (${response.status}): ${body || response.statusText}`) + } + + const data = (await response.json()) as CodexUsageResponse + return { + providerId: "openai", + providerLabel: "OpenAI/Codex", + status: "ok", + primary: toRateWindow(data.rate_limit?.primary_window, "Current session"), + secondary: toRateWindow(data.rate_limit?.secondary_window, "Current week"), + plan: data.plan_type, + } +} + +function toRateWindow(snapshot: WindowSnapshot | undefined, label: string): RateWindow | undefined { + if (!snapshot) return undefined + return { + label, + usedPercent: snapshot.used_percent ?? 0, + windowMinutes: snapshot.limit_window_seconds ? snapshot.limit_window_seconds / 60 : undefined, + resetsAt: Number.isFinite(snapshot.reset_at) ? new Date(snapshot.reset_at * 1000).toISOString() : undefined, + } +} diff --git a/packages/opencode/src/usage/types.ts b/packages/opencode/src/usage/types.ts new file mode 100644 index 000000000000..2bcd9e203852 --- /dev/null +++ b/packages/opencode/src/usage/types.ts @@ -0,0 +1,23 @@ +export interface RateWindow { + label: string + usedPercent: number + windowMinutes?: number + resetsAt?: string +} + +export interface ProviderUsage { + providerId: string + providerLabel: string + status: "ok" | "error" | "unsupported" | "unlimited" + primary?: RateWindow + secondary?: RateWindow + tertiary?: RateWindow + error?: string + plan?: string + accountEmail?: string +} + +export interface UsageSnapshot { + providers: ProviderUsage[] + fetchedAt: string +} From 9fdedde1d49a04364adc3fc4fef85ce824564e81 Mon Sep 17 00:00:00 2001 From: improdead Date: Mon, 19 Jan 2026 10:39:01 -0500 Subject: [PATCH 2/2] docs(tui): add sidebar documentation with image --- packages/web/src/assets/sidebar.png | Bin 0 -> 32170 bytes packages/web/src/content/docs/tui.mdx | 12 ++++++++++++ 2 files changed, 12 insertions(+) create mode 100644 packages/web/src/assets/sidebar.png diff --git a/packages/web/src/assets/sidebar.png b/packages/web/src/assets/sidebar.png new file mode 100644 index 0000000000000000000000000000000000000000..9d5126a3a8c3c45d8a81e9470e8f8d75c5230777 GIT binary patch literal 32170 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 literal 0 HcmV?d00001 diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 085eb6169f83..5b760f92f8bc 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -376,6 +376,18 @@ You can customize TUI behavior through your OpenCode config file. --- +## Sidebar + +The TUI includes a session sidebar that displays useful information about your current session. You can toggle the sidebar visibility using the keybind. + +![OpenCode TUI Sidebar](../../assets/sidebar.png) + +**Keybind:** `ctrl+x b` + +The sidebar shows session details and can be toggled on or off based on your preference. + +--- + ## Customization You can customize various aspects of the TUI view using the command palette (`ctrl+x h` or `/help`). These settings persist across restarts.