diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
index b85cd5c6542..c4a593085a6 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx
@@ -1,16 +1,36 @@
-import { TextAttributes } from "@opentui/core"
+import { TextAttributes, RGBA } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useSync } from "@tui/context/sync"
-import { For, Match, Switch, Show, createMemo } from "solid-js"
+import { For, Match, Switch, Show, createMemo, createResource } from "solid-js"
+import { AnthropicUsage } from "@/usage/anthropic"
+import { OpenAIUsage } from "@/usage/openai"
+import { getUsageColor, clampPercent } from "@/usage/utils"
export type DialogStatusProps = {}
+function UsageBar(props: { percent: number; fg: RGBA; bgMuted: RGBA }) {
+ const width = 20
+ const clamped = clampPercent(props.percent)
+ const filled = Math.round((clamped / 100) * width)
+ const empty = width - filled
+ return (
+
+ {"█".repeat(filled)}
+ {"░".repeat(empty)}
+
+ )
+}
+
export function DialogStatus() {
const sync = useSync()
const { theme } = useTheme()
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
+ const [anthropicUsage] = createResource(() => AnthropicUsage.fetch(), { initialValue: null })
+
+ const [openaiUsage] = createResource(() => OpenAIUsage.fetch(), { initialValue: null })
+
const plugins = createMemo(() => {
const list = sync.data.config.plugin ?? []
const result = list.map((value) => {
@@ -36,6 +56,8 @@ export function DialogStatus() {
return result.toSorted((a, b) => a.name.localeCompare(b.name))
})
+ const colorFor = (percent: number) => getUsageColor(percent, theme)
+
return (
@@ -44,6 +66,106 @@ export function DialogStatus() {
esc
+
+
+ {(usage) => (
+
+
+ Anthropic Usage
+
+
+ {(fiveHour) => (
+
+ 5h:
+
+ {fiveHour().utilization}%
+ (reset: {AnthropicUsage.formatResetTime(fiveHour().resets_at)})
+
+ )}
+
+
+ {(sevenDay) => (
+
+ 7d:
+
+ {sevenDay().utilization}%
+ (reset: {AnthropicUsage.formatResetTime(sevenDay().resets_at)})
+
+ )}
+
+
+ {(opus) => (
+
+ Opus 7d:
+
+ {opus().utilization}%
+ (reset: {AnthropicUsage.formatResetTime(opus().resets_at)})
+
+ )}
+
+
+ )}
+
+
+
+ {(usage) => (
+
+
+ OpenAI Usage ({OpenAIUsage.getPlanDisplayName(usage().plan_type)})
+
+
+ {(primary) => (
+
+ {OpenAIUsage.formatWindowDuration(primary().limit_window_seconds)}:
+
+ {Math.round(primary().used_percent)}%
+ (reset: {OpenAIUsage.formatResetTime(primary().reset_at)})
+
+ )}
+
+
+ {(secondary) => (
+
+ {OpenAIUsage.formatWindowDuration(secondary().limit_window_seconds)}:
+
+ {Math.round(secondary().used_percent)}%
+ (reset: {OpenAIUsage.formatResetTime(secondary().reset_at)})
+
+ )}
+
+
+ {(credits) => (
+
+ Credits:
+ {OpenAIUsage.formatCredits(credits().balance)}}
+ >
+ Unlimited
+
+
+ )}
+
+
+ )}
+
+
0} fallback={No MCP Servers}>
{Object.keys(sync.data.mcp).length} MCP Servers
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 a9ed042d1bb..cfcf3075b39 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -1,5 +1,5 @@
import { useSync } from "@tui/context/sync"
-import { createMemo, For, Show, Switch, Match } from "solid-js"
+import { createMemo, For, Show, Switch, Match, createResource, createEffect, on } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../../context/theme"
import { Locale } from "@/util/locale"
@@ -11,6 +11,9 @@ import { useKeybind } from "../../context/keybind"
import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
import { TodoItem } from "../../component/todo-item"
+import { AnthropicUsage } from "@/usage/anthropic"
+import { OpenAIUsage } from "@/usage/openai"
+import { getUsageColor, usageBarString } from "@/usage/utils"
export function Sidebar(props: { sessionID: string }) {
const sync = useSync()
@@ -25,8 +28,26 @@ export function Sidebar(props: { sessionID: string }) {
diff: true,
todo: true,
lsp: true,
+ usage: true,
})
+ const [anthropicUsage, { refetch: refetchAnthropic }] = createResource(() => AnthropicUsage.fetch(), { initialValue: null })
+ const [openaiUsage, { refetch: refetchOpenAI }] = createResource(() => OpenAIUsage.fetch(), { initialValue: null })
+
+ createEffect(on(
+ () => messages().length,
+ () => {
+ refetchAnthropic()
+ refetchOpenAI()
+ },
+ { defer: true }
+ ))
+
+ const colorFor = (percent: number) => getUsageColor(percent, theme)
+ const usageBar = (percent: number) => usageBarString(percent, 10)
+
+ const hasUsageData = createMemo(() => anthropicUsage() || openaiUsage())
+
// Sort MCP servers alphabetically for consistent display order
const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b)))
@@ -96,6 +117,68 @@ export function Sidebar(props: { sessionID: string }) {
{context()?.percentage ?? 0}% used
{cost()} spent
+
+
+ setExpanded("usage", !expanded.usage)}
+ >
+ {expanded.usage ? "▼" : "▶"}
+
+ Usage
+
+
+
+
+ {(usage) => (
+ <>
+ Anthropic
+
+ {(w) => (
+
+ 5h {usageBar(w().utilization)}
+ {" "}{w().utilization}% ({AnthropicUsage.formatResetTime(w().resets_at)})
+
+ )}
+
+
+ {(w) => (
+
+ 7d {usageBar(w().utilization)}
+ {" "}{w().utilization}% ({AnthropicUsage.formatResetTime(w().resets_at)})
+
+ )}
+
+ >
+ )}
+
+
+ {(usage) => (
+ <>
+ OpenAI ({OpenAIUsage.getPlanDisplayName(usage().plan_type)})
+
+ {(w) => (
+
+ {OpenAIUsage.formatWindowDuration(w().limit_window_seconds)} {usageBar(w().used_percent)}
+ {" "}{Math.round(w().used_percent)}% ({OpenAIUsage.formatResetTime(w().reset_at)})
+
+ )}
+
+
+ {(w) => (
+
+ {OpenAIUsage.formatWindowDuration(w().limit_window_seconds)} {usageBar(w().used_percent)}
+ {" "}{Math.round(w().used_percent)}% ({OpenAIUsage.formatResetTime(w().reset_at)})
+
+ )}
+
+ >
+ )}
+
+
+
+
0}>
+
+ export async function fetch(): Promise {
+ const auth = await Auth.get("anthropic")
+ if (!auth || auth.type !== "oauth") {
+ return null
+ }
+
+ try {
+ const response = await globalThis.fetch("https://api.anthropic.com/api/oauth/usage", {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${auth.access}`,
+ "anthropic-beta": "oauth-2025-04-20",
+ Accept: "application/json",
+ },
+ signal: AbortSignal.timeout(10_000),
+ })
+
+ if (!response.ok) {
+ console.error(`Anthropic usage API error: ${response.status}`)
+ return null
+ }
+
+ const data = await response.json()
+ const parsed = UsageData.safeParse(data)
+ if (!parsed.success) {
+ console.error("Failed to parse Anthropic usage data:", parsed.error)
+ return null
+ }
+
+ return parsed.data
+ } catch (error) {
+ console.error("Failed to fetch Anthropic usage:", error)
+ return null
+ }
+ }
+
+ export function formatResetTime(isoString: string | null): string {
+ if (!isoString) return "N/A"
+ const date = new Date(isoString)
+ const now = new Date()
+ const diffMs = date.getTime() - now.getTime()
+
+ if (diffMs <= 0) return "refreshing"
+
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMins / 60)
+
+ if (diffHours >= 24) {
+ const days = Math.floor(diffHours / 24)
+ return `${days}d ${diffHours % 24}h`
+ }
+ if (diffHours > 0) {
+ return `${diffHours}h ${diffMins % 60}m`
+ }
+ if (diffMins > 0) {
+ return `${diffMins}m`
+ }
+ return "soon"
+ }
+}
diff --git a/packages/opencode/src/usage/openai.ts b/packages/opencode/src/usage/openai.ts
new file mode 100644
index 00000000000..23cee116f0d
--- /dev/null
+++ b/packages/opencode/src/usage/openai.ts
@@ -0,0 +1,150 @@
+import { Auth } from "@/auth"
+import z from "zod"
+
+export namespace OpenAIUsage {
+ export const PlanType = z.enum([
+ "free",
+ "plus",
+ "pro",
+ "team",
+ "business",
+ "enterprise",
+ "edu",
+ "education",
+ "guest",
+ "go",
+ "free_workspace",
+ "quorum",
+ "k12",
+ ])
+ export type PlanType = z.infer
+
+ export const RateLimitWindow = z.object({
+ used_percent: z.number(),
+ limit_window_seconds: z.number(),
+ reset_at: z.number(),
+ })
+
+ export const RateLimitDetails = z.object({
+ allowed: z.boolean().optional(),
+ limit_reached: z.boolean().optional(),
+ primary_window: RateLimitWindow.optional().nullable(),
+ secondary_window: RateLimitWindow.optional().nullable(),
+ })
+
+ export const CreditStatus = z.object({
+ has_credits: z.boolean().optional(),
+ unlimited: z.boolean().optional(),
+ balance: z.union([z.string(), z.number()]).optional().nullable(),
+ })
+
+ export const UsageData = z.object({
+ plan_type: PlanType,
+ rate_limit: RateLimitDetails.optional().nullable(),
+ credits: CreditStatus.optional().nullable(),
+ })
+ export type UsageData = z.infer
+
+ export function getPlanDisplayName(planType: PlanType): string {
+ const names: Record = {
+ free: "Free",
+ plus: "Plus",
+ pro: "Pro",
+ team: "Team",
+ business: "Business",
+ enterprise: "Enterprise",
+ edu: "Education",
+ education: "Education",
+ guest: "Guest",
+ go: "Go",
+ free_workspace: "Free Workspace",
+ quorum: "Quorum",
+ k12: "K-12",
+ }
+ return names[planType] || planType
+ }
+
+ export async function fetch(): Promise {
+ const auth = await Auth.get("openai")
+ if (!auth || auth.type !== "oauth") {
+ return null
+ }
+
+ try {
+ const response = await globalThis.fetch("https://chatgpt.com/backend-api/wham/usage", {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${auth.access}`,
+ Accept: "application/json",
+ "User-Agent": "opencode-cli",
+ },
+ signal: AbortSignal.timeout(10_000),
+ })
+
+ if (!response.ok) {
+ console.error(`OpenAI usage API error: ${response.status}`)
+ return null
+ }
+
+ const data = await response.json()
+ const parsed = UsageData.safeParse(data)
+ if (!parsed.success) {
+ console.error("Failed to parse OpenAI usage data:", parsed.error)
+ return null
+ }
+
+ return parsed.data
+ } catch (error) {
+ console.error("Failed to fetch OpenAI usage:", error)
+ return null
+ }
+ }
+
+ export function formatWindowDuration(seconds: number): string {
+ const minutes = Math.floor(seconds / 60)
+ const hours = Math.floor(minutes / 60)
+ const days = Math.floor(hours / 24)
+
+ if (days >= 1) {
+ return `${days}d`
+ }
+ if (hours >= 1) {
+ return `${hours}h`
+ }
+ return `${minutes}m`
+ }
+
+ export function formatResetTime(unixTimestamp: number): string {
+ const date = new Date(unixTimestamp * 1000)
+ const now = new Date()
+ const diffMs = date.getTime() - now.getTime()
+
+ if (diffMs <= 0) return "refreshing"
+
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMins / 60)
+
+ if (diffHours >= 24) {
+ const days = Math.floor(diffHours / 24)
+ return `${days}d ${diffHours % 24}h`
+ }
+ if (diffHours > 0) {
+ return `${diffHours}h ${diffMins % 60}m`
+ }
+ if (diffMins > 0) {
+ return `${diffMins}m`
+ }
+ return "soon"
+ }
+
+ export function formatCredits(balance: string | number | null | undefined): string {
+ if (balance === null || balance === undefined) {
+ return "N/A"
+ }
+ const num = typeof balance === "string" ? Number(balance) : balance
+ if (typeof num !== "number" || !Number.isFinite(num)) {
+ return "N/A"
+ }
+ return `$${num.toFixed(2)}`
+ }
+}
diff --git a/packages/opencode/src/usage/utils.ts b/packages/opencode/src/usage/utils.ts
new file mode 100644
index 00000000000..a0983e9ee71
--- /dev/null
+++ b/packages/opencode/src/usage/utils.ts
@@ -0,0 +1,17 @@
+import type { RGBA } from "@opentui/core"
+
+export function getUsageColor(percent: number, theme: { error: RGBA; warning: RGBA; success: RGBA }): RGBA {
+ if (percent >= 90) return theme.error
+ if (percent >= 70) return theme.warning
+ return theme.success
+}
+
+export function clampPercent(percent: number): number {
+ return Math.max(0, Math.min(100, percent))
+}
+
+export function usageBarString(percent: number, width: number = 10): string {
+ const clamped = clampPercent(percent)
+ const filled = Math.round((clamped / 100) * width)
+ return "\u2588".repeat(filled) + "\u2591".repeat(width - filled)
+}