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) +}