diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index 4b177e292cf..62d3eeacc16 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 00000000000..089ab2d203d
--- /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 ebc7514d723..ad20ba3fe87 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 00000000000..3f787f5db88
--- /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 00000000000..2708ebd5e8b
--- /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 00000000000..607993a76fb
--- /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 00000000000..52a9e2f9696
--- /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 00000000000..8e1d27084d9
--- /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 00000000000..d7a9bd1b9bb
--- /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 00000000000..2bcd9e20385
--- /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
+}
diff --git a/packages/web/src/assets/sidebar.png b/packages/web/src/assets/sidebar.png
new file mode 100644
index 00000000000..9d5126a3a8c
Binary files /dev/null and b/packages/web/src/assets/sidebar.png differ
diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx
index 085eb6169f8..5b760f92f8b 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.
+
+
+
+**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.