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..caa7dd1a59e 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -1,8 +1,9 @@
import { useSync } from "@tui/context/sync"
-import { createMemo, For, Show, Switch, Match } from "solid-js"
+import { createMemo, For, Show, Switch, Match, createResource, createEffect } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../../context/theme"
import { Locale } from "@/util/locale"
+import { fetchPlanUsageWithCache, formatResetTime, getPercentage } from "@/provider/plan-usage"
import path from "path"
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { Global } from "@/global"
@@ -48,8 +49,12 @@ export function Sidebar(props: { sessionID: string }) {
}).format(total)
})
+ const lastAssistantMessage = createMemo(
+ () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage | undefined,
+ )
+
const context = createMemo(() => {
- const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
+ const last = lastAssistantMessage()
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
@@ -57,9 +62,21 @@ export function Sidebar(props: { sessionID: string }) {
return {
tokens: total.toLocaleString(),
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
+ providerID: last.providerID,
}
})
+ const [planUsage, { refetch }] = createResource(
+ () => context()?.providerID,
+ (providerID) => fetchPlanUsageWithCache(providerID, sync.data.provider),
+ )
+
+ // Refetch plan usage when messages are added (user or assistant)
+ createEffect(() => {
+ messages().length
+ refetch()
+ })
+
const directory = useDirectory()
const kv = useKV()
@@ -96,6 +113,17 @@ export function Sidebar(props: { sessionID: string }) {
{context()?.percentage ?? 0}% used
{cost()} spent
+
+ {(data) => (
+
+
+ Plan Usage
+
+ {getPercentage(data())}% used
+ {formatResetTime(data().resetAt)}
+
+ )}
+
0}>
0 ? Math.round((planUsage.used / planUsage.total) * 100) : 0
+}
+
+export interface PlanUsageData {
+ planUsage?: PlanUsage
+ error?: string
+}
+
+export type PlanUsageHandler = (config: { token: string; baseURL: string; timeout: number }) => Promise
+
+const HANDLERS: Record = {}
+
+export function registerProvider(id: string, handler: PlanUsageHandler) {
+ HANDLERS[id] = handler
+}
+
+// Load built-in providers
+import("./plan-usage/zai")
+
+type CacheEntry = { data: PlanUsageData; time: number }
+const cache = new Map()
+const CACHE_TTL_MS = 60 * 1000
+
+export async function fetchPlanUsage(
+ providerID: string,
+ providers: Array>,
+ timeout = 5000,
+): Promise {
+ const config = await getProviderConfig(providerID, providers)
+ if (!config.token) {
+ log.debug("No API key configured for provider", { providerID })
+ return { error: `No API key configured for provider: ${providerID}` }
+ }
+ if (!config.baseURL) {
+ log.debug("No base URL configured for provider", { providerID })
+ return { error: `No base URL configured for provider: ${providerID}` }
+ }
+
+ const handler = HANDLERS[providerID]
+ if (!handler) {
+ log.debug("No plan usage handler for provider", { providerID })
+ return { error: `No plan usage handler for provider: ${providerID}` }
+ }
+
+ return handler({ token: config.token, baseURL: config.baseURL, timeout })
+}
+
+export async function fetchPlanUsageWithCache(
+ providerID: string,
+ providers: Array>,
+ timeout = 5000,
+): Promise {
+ const key = `planUsage:${providerID}`
+ const cached = cache.get(key)
+ if (cached && Date.now() - cached.time < CACHE_TTL_MS) return cached.data
+
+ const data = await fetchPlanUsage(providerID, providers, timeout)
+ cache.set(key, { data, time: Date.now() })
+ return data
+}
+
+export function formatResetTime(date?: Date): string | null {
+ if (!date) return null
+
+ const now = Date.now()
+ const remaining = Math.max(0, date.getTime() - now) / 1000
+ const h = Math.floor(remaining / 3600)
+ const m = Math.floor((remaining % 3600) / 60)
+ const s = Math.floor(remaining % 60)
+
+ if (h > 24) return `Resets in ${Math.floor(h / 24)}d ${h % 24}h`
+ if (h > 0) return `Resets in ${h}h ${m}m`
+ if (m > 0) return `Resets in ${m}m`
+ if (s > 0) return `Resets in ${s}s`
+ return "Resets soon"
+}
+
+async function getProviderConfig(providerID: string, providers: Array>) {
+ const { Auth } = await import("@/auth")
+ const auth = await Auth.get(providerID)
+ const token = auth?.type === "api" ? auth.key : null
+
+ const provider = providers.find((p) => p.id === providerID)
+ const models = provider?.models as Record | undefined
+ const firstModel = models ? Object.values(models)[0] : undefined
+ const baseURL = firstModel?.api?.url ?? null
+
+ return { token, baseURL }
+}
diff --git a/packages/opencode/src/provider/plan-usage/zai.ts b/packages/opencode/src/provider/plan-usage/zai.ts
new file mode 100644
index 00000000000..23727849eb7
--- /dev/null
+++ b/packages/opencode/src/provider/plan-usage/zai.ts
@@ -0,0 +1,115 @@
+import { Log } from "@/util/log"
+import { registerProvider, type PlanUsageHandler } from "../plan-usage"
+
+const log = Log.create({ service: "zai-plan-usage" })
+
+// z.ai specific types
+interface ZaiPlanUsageLimit {
+ type: string
+ unit: number
+ number: number
+ usage: number
+ currentValue: number
+ remaining: number
+ percentage: number
+ nextResetTime?: number
+}
+
+interface ZaiPlanUsageResponse {
+ data?: { limits?: ZaiPlanUsageLimit[] }
+ limits?: ZaiPlanUsageLimit[]
+}
+
+function selectLimit(limits: ZaiPlanUsageLimit[]): ZaiPlanUsageLimit | null {
+ return limits.find((l) => l.type === "TOKENS_LIMIT") ?? null
+}
+
+function fetchWithTimeout(url: string, headers: Record, timeout: number): Promise {
+ const controller = new AbortController()
+ const id = setTimeout(() => controller.abort(), timeout)
+ return fetch(url, {
+ method: "GET",
+ headers,
+ signal: controller.signal,
+ }).then((response) => {
+ clearTimeout(id)
+ return response
+ })
+}
+
+async function parsePlanUsageResponse(
+ response: Response,
+): Promise<{ used: number; total: number; resetAt?: Date; percentage?: number } | { error: string }> {
+ if (!response.ok) {
+ const text = await response.text()
+ return { error: `HTTP ${response.status}: ${text}` }
+ }
+
+ const json = (await response.json()) as unknown
+ const limits = extractLimits(json)
+
+ if (!limits || !Array.isArray(limits) || limits.length === 0) {
+ return { error: "Invalid plan usage response format" }
+ }
+
+ const limit = selectLimit(limits)
+ if (!limit) {
+ return { error: "No token limit found in response" }
+ }
+
+ return {
+ used: limit.usage,
+ total: limit.number,
+ resetAt: limit.nextResetTime ? new Date(limit.nextResetTime) : undefined,
+ percentage: limit.percentage,
+ }
+}
+
+function extractLimits(json: unknown): ZaiPlanUsageLimit[] | undefined {
+ if (typeof json !== "object" || json === null) return undefined
+
+ const candidate = json as Record
+
+ // Check for { data: { limits: [...] } }
+ if ("data" in candidate && typeof candidate.data === "object" && candidate.data !== null) {
+ const data = candidate.data as Record
+ if ("limits" in data && Array.isArray(data.limits)) {
+ return data.limits as ZaiPlanUsageLimit[]
+ }
+ }
+
+ // Check for { limits: [...] }
+ if ("limits" in candidate && Array.isArray(candidate.limits)) {
+ return candidate.limits as ZaiPlanUsageLimit[]
+ }
+
+ return undefined
+}
+
+const zaiPlanUsageHandler: PlanUsageHandler = async ({ token, baseURL, timeout }) => {
+ if (!token) return { error: "No API key configured" }
+ if (!baseURL) return { error: "No base URL configured" }
+
+ const url = new URL(baseURL)
+ const endpoint = `${url.origin}/api/monitor/usage/quota/limit`
+
+ return fetchWithTimeout(
+ endpoint,
+ {
+ Authorization: token,
+ "Accept-Language": "en-US,en",
+ "Content-Type": "application/json",
+ },
+ timeout,
+ )
+ .then(parsePlanUsageResponse)
+ .then((result) => ("error" in result ? { error: result.error } : { planUsage: result }))
+ .catch((err) => {
+ const msg = err instanceof Error ? err.message : String(err)
+ log.debug("Failed to fetch plan usage", { provider: "zai-coding-plan", error: msg })
+ return { error: msg }
+ })
+}
+
+// Register on import
+registerProvider("zai-coding-plan", zaiPlanUsageHandler)