From ef9a944924a6189f74f542bdfac6e657fb59dc24 Mon Sep 17 00:00:00 2001 From: "nhat.nguyenhong" Date: Wed, 16 Sep 2026 15:30:12 +0000 Subject: [PATCH] add usage tracking to sidebar --- AGENTS.md | 21 ++- README.md | 48 +++++- package.json | 6 +- scripts/quota.mjs | 91 ++++++++++ src/quota.ts | 431 ++++++++++++++++++++++++++++++++++++++++++++++ src/tui.tsx | 198 +++++++++++++++++++-- tsconfig.tui.json | 2 +- 7 files changed, 772 insertions(+), 25 deletions(-) create mode 100644 scripts/quota.mjs create mode 100644 src/quota.ts diff --git a/AGENTS.md b/AGENTS.md index 4e9ee5e..4f32041 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ npm install # dev deps: typescript, @types/node, @ai-sdk/provider npm run typecheck # tsc --noEmit (provider) + tsc -p tsconfig.tui.json (TUI plugin) npm run build # tsc -> dist/ (provider only; the TUI plugin is loaded from source) npm run smoke # live request against CommandCode (needs a key) +npm run quota # live 5-hour/weekly/monthly quota (needs a key) npm run sync-models # regenerate provider.commandcode.models from the live catalog ``` @@ -46,11 +47,13 @@ src/usage.ts finish event -> LanguageModelV3Usage; finish-reason unificatio src/redact.ts Credential scrubbing for error surfaces. src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file. src/toggles.ts Shared toggle file (~/.config/opencode/commandcode-toggles.json). -src/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status + sidebar status panel. +src/quota.ts Live 5-hour/weekly/monthly quota from the CommandCode alpha billing API. +src/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar. src/tui-shims.d.ts Ambient types for the opencode-provided solid-js/@opentui/solid runtime. -tsconfig.tui.json Typechecks src/tui.tsx against the shim (no dependencies). +tsconfig.tui.json Typechecks src/tui.tsx (+ quota/toggles) against the shim (no dependencies). src/constants.ts Defaults, paths, headers, passthrough params, static config block. scripts/smoke.mjs Live end-to-end check. +scripts/quota.mjs Print the live quota headlessly (reads the same config as smoke). scripts/sync-models.mjs Catalog -> provider.commandcode.models generator (with vision probing). server.py Reference Python proxy (do not modify unless explicitly asked). ``` @@ -139,6 +142,14 @@ These are load-bearing. Breaking one causes silent failures in opencode. `model.ts`/`log.ts` on every call from the toggle file / env, so `/cc-*` changes take effect without restarting opencode. Precedence for `zdr`: `providerOptions.commandcode.zdr` > `x-cmd-zdr` header > toggle file > `COMMANDCODE_ZDR`. Do not reintroduce load-time consts. +15. **Quota is live from the alpha billing API, not `server.py`.** `src/quota.ts` reads + `/alpha/whoami`, `/alpha/billing/credits`, `/alpha/billing/subscriptions`, and + `/alpha/usage/summary` (the same endpoints the `cmd` CLI `/usage` uses). `server.py` predates + these and is not the guide here. `resetAt` has shipped as both seconds and epoch ms — normalize + (>= 1e12 = ms). The monthly meter is **derived** (spend + remaining credits), so it works on any + plan; never hardcode a plan→cap table. The TUI resolves the key from `api.state` (provider + entry, then `config.provider.commandcode.options`, then `COMMANDCODE_API_KEY`), expanding + `{env:VAR}` itself, and must never log the key — quota errors go through `redact()`. ## Change workflow @@ -161,6 +172,12 @@ Automated: npm run typecheck; npm run build; npm run smoke ``` +Quota changes additionally need a live check (needs a key): + +```powershell +npm run quota +``` + Inside opencode (restart it first if config changed): ```powershell diff --git a/README.md b/README.md index ea7d194..10c1e70 100644 --- a/README.md +++ b/README.md @@ -198,12 +198,46 @@ opencode plugin file:///C:/DevTools/pienv/ccprovider/src/tui.tsx That writes a `tui.json` `plugin` entry (project-local `.opencode/tui.json`, or global with `--global`). Restart opencode after installing; then type `/cc-` for autocomplete. The plugin also -renders a **CommandCode panel in the session sidebar** (`zdr` / `debug`, above the built-in -panels) that updates live as you toggle. Note that in opencode 1.x the slash menu and the `Ctrl+P` -palette read the same command registry, so these entries appear in both. If you run opencode +renders a **CommandCode panel in the session sidebar** (`zdr` / `debug`, plus live quota, above the +built-in panels) that updates live as you toggle. Note that in opencode 1.x the slash menu and the +`Ctrl+P` palette read the same command registry, so these entries appear in both. If you run opencode against a remote server, the TUI-side file is not visible to the provider on the server host — use the env vars or `providerOptions` there. +## Quota tracking + +`/cc-usage` and the sidebar show CommandCode's **5-hour**, **weekly** and **monthly** usage for the +account behind `provider.commandcode.options.apiKey`. The numbers come live from the same alpha +billing endpoints the `cmd` CLI `/usage` command uses: + +| Endpoint | Used for | +| --- | --- | +| `GET /alpha/whoami` | account identity and org id | +| `GET /alpha/billing/credits` | 5-hour/weekly windows (`used`, `cap`, `resetAt`) and credit balances | +| `GET /alpha/billing/subscriptions` | plan id, status, billing period | +| `GET /alpha/usage/summary` | requests/tokens/cost for the current billing period | + +The **5-hour** and **weekly** windows show `used / cap` and a live reset countdown. The **monthly** +meter is derived: `usage/summary` cost vs. that cost plus all remaining credits, so it works on any +plan (including org and pay-as-you-go) without a maintained price table. Free and pay-as-you-go +accounts can return no `windowLimits`; those rows stay hidden and the missing section is reported +instead of being shown as zero. + +The sidebar refreshes after each completed turn (`session.idle`, debounced) plus every +60 seconds as a fallback (`COMMANDCODE_QUOTA_INTERVAL_MS` overrides it) and the +countdown ticks every 30 seconds. Fetch failures are redacted and shown inline; the panel never +blocks the provider. + +Headless check (no TUI), reading the key from `COMMANDCODE_API_KEY` or the opencode config: + +```powershell +npm run build +npm run quota # formatted +npm run quota -- --json # raw quota object +``` + +The API key is never logged; quota errors pass through `redact()` like every other surface. + ## Features @@ -224,7 +258,8 @@ the env vars or `providerOptions` there. | Retry with backoff | Yes — 429/5xx and network errors, honouring `Retry-After` | | Credential redaction | Yes — error bodies are scrubbed before surfacing | | Runtime toggles | Yes — `/cc-zdr` and `/cc-debug` flip the shared toggle file without a restart | -| Sidebar status panel | Yes — a `sidebar_content` panel shows `zdr`/`debug` and updates live | +| Sidebar status panel | Yes — a `sidebar_content` panel shows `zdr`/`debug` and live quota | +| Quota tracking | Yes — 5-hour/weekly/monthly meters via `/cc-usage`, the sidebar, and `npm run quota` | ### `tool_choice` handling @@ -259,9 +294,11 @@ src/usage.ts finish event -> V3 usage; finish-reason unification. src/redact.ts Credential scrubbing for error surfaces. src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file. src/toggles.ts Shared toggle file read by the provider and written by the TUI plugin. -src/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status + sidebar status panel. +src/quota.ts Live 5-hour/weekly/monthly quota from the alpha billing endpoints. +src/tui.tsx opencode TUI plugin: /cc-zdr, /cc-debug, /cc-status, /cc-usage + sidebar panel. src/constants.ts Defaults, header names, passthrough params, static config block. scripts/smoke.mjs Live end-to-end check against api.commandcode.ai. +scripts/quota.mjs Print the live quota headlessly. scripts/sync-models.mjs Generate provider.commandcode.models from the live catalog. ``` @@ -290,6 +327,7 @@ npm install # installs typescript, @types/node, @ai-sdk/provider npm run typecheck # tsc --noEmit (provider) + tsc -p tsconfig.tui.json (TUI plugin) npm run build # tsc -> dist/ (provider only; the TUI plugin loads from source) npm run smoke # live request against CommandCode +npm run quota # live 5-hour/weekly/monthly quota npm run sync-models # regenerate provider.commandcode.models from the catalog ``` diff --git a/package.json b/package.json index 6baeb7c..2eadab9 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,16 @@ "dist", "src/tui.tsx", "src/tui-shims.d.ts", - "src/toggles.ts" + "src/toggles.ts", + "src/quota.ts", + "src/redact.ts", + "src/constants.ts" ], "scripts": { "build": "tsc", "typecheck": "tsc --noEmit && tsc -p tsconfig.tui.json", "smoke": "node scripts/smoke.mjs", + "quota": "node scripts/quota.mjs", "sync-models": "node scripts/sync-models.mjs" }, "devDependencies": { diff --git a/scripts/quota.mjs b/scripts/quota.mjs new file mode 100644 index 0000000..00a145a --- /dev/null +++ b/scripts/quota.mjs @@ -0,0 +1,91 @@ +// Print the live CommandCode quota (5-hour, weekly, monthly) from the alpha billing +// endpoints. The same data drives the TUI sidebar and /cc-usage command; this script is +// the headless check and does not need opencode running. +// +// Run: node scripts/quota.mjs [--config ] [--base-url ] [--json] (build first: npm run build) +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fetchQuota, formatQuota } from "../dist/quota.js"; + +function parseArgs(argv) { + const opts = { + config: join(homedir(), ".config", "opencode", "opencode.json"), + baseURL: undefined, + json: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const value = () => { + const next = argv[++i]; + if (next === undefined) throw new Error(`Missing value for ${arg}`); + return next; + }; + switch (arg) { + case "--config": + opts.config = value(); + break; + case "--base-url": + opts.baseURL = value(); + break; + case "--json": + opts.json = true; + break; + case "-h": + case "--help": + console.log( + [ + "Print CommandCode quota from the alpha billing endpoints.", + "", + "Usage: node scripts/quota.mjs [options]", + " --config opencode config to read (default ~/.config/opencode/opencode.json)", + " --base-url upstream origin (default from config or api.commandcode.ai)", + " --json print the raw quota object instead of formatted text", + ].join("\n"), + ); + process.exit(0); + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + return opts; +} + +function readConfig(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return {}; + } +} + +function resolveKey(config, env) { + const fromEnv = env.COMMANDCODE_API_KEY; + if (fromEnv && fromEnv.trim()) return fromEnv.trim(); + const options = config?.provider?.commandcode?.options ?? {}; + const fromConfig = options.apiKey; + if (typeof fromConfig === "string" && fromConfig.trim()) return fromConfig.trim(); + const auth = options.headers?.Authorization ?? options.headers?.authorization; + if (typeof auth === "string" && auth.trim()) return auth.replace(/^Bearer\s+/i, "").trim(); + return undefined; +} + +const opts = parseArgs(process.argv.slice(2)); +const config = readConfig(opts.config); +const apiKey = resolveKey(config, process.env); +if (!apiKey) { + console.error( + `No CommandCode API key found. Set COMMANDCODE_API_KEY or provider.commandcode.options.apiKey in ${opts.config}.`, + ); + process.exit(1); +} + +const baseURL = opts.baseURL ?? config?.provider?.commandcode?.options?.baseURL; +const result = await fetchQuota({ apiKey, baseURL: typeof baseURL === "string" ? baseURL : undefined }); +if (!result.ok) { + console.error(result.error.message); + process.exit(1); +} + +console.log(opts.json ? JSON.stringify(result.quota, null, 2) : formatQuota(result.quota)); diff --git a/src/quota.ts b/src/quota.ts new file mode 100644 index 0000000..e2bfd20 --- /dev/null +++ b/src/quota.ts @@ -0,0 +1,431 @@ +// Live Command Code quota from the alpha billing API. Shared by the TUI plugin +// (loaded from source) and scripts/quota.mjs. Endpoints, validated against +// api.commandcode.ai: +// +// GET /alpha/whoami +// GET /alpha/billing/credits?orgId= +// GET /alpha/billing/subscriptions?orgId= +// GET /alpha/usage/summary?orgId= +// +// These are the same endpoints the `cmd` CLI `/usage` command reads. This module uses +// global fetch only, so the built provider stays runtime-dependency-free. + +import { DEFAULT_BASE_URL } from "./constants.js"; +import { redact } from "./redact.js"; + +export const QUOTA_TIMEOUT_MS = 15_000; + +export type QuotaWindowId = "fiveHour" | "weekly" | "monthly"; + +export type QuotaWindow = { + id: QuotaWindowId; + label: string; + used: number; + cap: number; + resetAtMs: number | null; + exceeded: boolean; +}; + +export type QuotaCredits = { + monthly: number; + purchased: number; + free: number; + remaining: number; +}; + +export type QuotaAccount = { + login: string; + keyName?: string; + orgId: string | null; +}; + +export type QuotaPlan = { + id: string; + status: string; + currentPeriodStart?: string; + currentPeriodEnd?: string; +}; + +export type QuotaSummary = { + totalCost: number; + totalCount: number; + totalTokens?: number; + periodBasis?: string; +}; + +export type Quota = { + account: QuotaAccount; + plan: QuotaPlan | null; + credits: QuotaCredits | null; + summary: QuotaSummary | null; + windows: QuotaWindow[]; + unavailable: QuotaWindowId[]; + fetchedAt: number; +}; + +export type QuotaErrorKind = "config" | "http" | "network" | "timeout"; + +export type QuotaResult = + | { ok: true; quota: Quota } + | { ok: false; error: { kind: QuotaErrorKind; message: string } }; + +export type QuotaFetchOptions = { + apiKey: string; + baseURL?: string; + orgId?: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** Epoch ms. Upstream has shipped both seconds and ms; >= 1e12 is treated as ms. */ +function resetAtMs(value: unknown): number | null { + let n: number | undefined; + if (typeof value === "number" && Number.isFinite(value)) n = value; + else if (typeof value === "string" && value.trim().length > 0) { + const text = value.trim(); + n = /^\d+$/.test(text) ? Number(text) : Date.parse(text); + } + if (n === undefined || !Number.isFinite(n) || n < 0) return null; + return n >= 1e12 ? Math.round(n) : Math.round(n * 1000); +} + +function parseWindow(id: QuotaWindowId, label: string, value: unknown): QuotaWindow | null { + if (!isRecord(value)) return null; + const used = numberValue(value.used); + const cap = numberValue(value.cap); + if (used === undefined || cap === undefined || (used === 0 && cap === 0)) return null; + return { + id, + label, + used, + cap, + resetAtMs: resetAtMs(value.resetAt), + exceeded: value.exceeded === true || (cap > 0 && used >= cap), + }; +} + +function parseCredits(value: unknown): { credits: QuotaCredits; windows: QuotaWindow[] } | null { + if (!isRecord(value) || !isRecord(value.credits)) return null; + const c = value.credits; + const monthly = numberValue(c.monthlyCredits) ?? 0; + const purchased = numberValue(c.purchasedCredits) ?? 0; + const free = numberValue(c.freeCredits) ?? 0; + const limits = isRecord(value.windowLimits) ? value.windowLimits : {}; + const windows: QuotaWindow[] = []; + const fiveHour = parseWindow("fiveHour", "5-hour", limits.fiveHour); + const weekly = parseWindow("weekly", "Weekly", limits.weekly); + if (fiveHour) windows.push(fiveHour); + if (weekly) windows.push(weekly); + return { credits: { monthly, purchased, free, remaining: monthly + purchased + free }, windows }; +} + +function parseAccount(value: unknown): QuotaAccount | null { + if (!isRecord(value)) return null; + const org = isRecord(value.org) ? value.org : undefined; + const user = isRecord(value.user) ? value.user : undefined; + const login = + (user ? (stringValue(user.userName) ?? stringValue(user.name)) : undefined) ?? + (org ? stringValue(org.login) : undefined); + if (!login) return null; + const keyName = user ? (stringValue(user.keyName) ?? stringValue(user.displayName)) : undefined; + return { login, orgId: org ? (stringValue(org.id) ?? null) : null, ...(keyName ? { keyName } : {}) }; +} + +function parsePlan(value: unknown): QuotaPlan | null { + if (!isRecord(value) || !isRecord(value.data)) return null; + const data = value.data; + const id = stringValue(data.planId); + const status = stringValue(data.status); + if (!id && !status) return null; + const start = stringValue(data.currentPeriodStart); + const end = stringValue(data.currentPeriodEnd); + return { + id: id ?? "unknown", + status: status ?? "unknown", + ...(start ? { currentPeriodStart: start } : {}), + ...(end ? { currentPeriodEnd: end } : {}), + }; +} + +function parseSummary(value: unknown): QuotaSummary | null { + if (!isRecord(value)) return null; + const totalCost = numberValue(value.totalCost); + const totalCount = numberValue(value.totalCount); + if (totalCost === undefined || totalCount === undefined) return null; + const totalTokens = numberValue(value.totalTokens); + const periodBasis = stringValue(value.periodBasis); + return { + totalCost, + totalCount, + ...(totalTokens === undefined ? {} : { totalTokens }), + ...(periodBasis === undefined ? {} : { periodBasis }), + }; +} + +function periodEndMs(plan: QuotaPlan | null): number | null { + if (!plan?.currentPeriodEnd) return null; + return resetAtMs(plan.currentPeriodEnd); +} + +class QuotaHttpError extends Error { + readonly status: number; + readonly body: string; + constructor(status: number, body: string) { + super(`HTTP ${status}`); + this.name = "QuotaHttpError"; + this.status = status; + this.body = body; + } +} + +class QuotaTimeoutError extends Error { + constructor() { + super("Command Code quota request timed out"); + this.name = "QuotaTimeoutError"; + } +} + +function isAuthError(error: unknown): boolean { + return error instanceof QuotaHttpError && (error.status === 401 || error.status === 403); +} + +export async function fetchQuota(options: QuotaFetchOptions): Promise { + if (!options.apiKey) { + return { ok: false, error: { kind: "config", message: "No Command Code API key found" } }; + } + + const baseURL = (options.baseURL ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = options.timeoutMs ?? QUOTA_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const headers = { + accept: "application/json", + authorization: /^Bearer\s/i.test(options.apiKey) ? options.apiKey : `Bearer ${options.apiKey}`, + }; + + const request = async (path: string, params?: Record): Promise => { + if (controller.signal.aborted) throw new QuotaTimeoutError(); + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params ?? {})) if (value) search.set(key, value); + const query = search.toString(); + let response: Response; + try { + response = await fetchImpl(`${baseURL}${path}${query ? `?${query}` : ""}`, { + method: "GET", + headers, + signal: controller.signal, + }); + } catch (error) { + if (controller.signal.aborted) throw new QuotaTimeoutError(); + throw error; + } + if (!response.ok) throw new QuotaHttpError(response.status, await response.text().catch(() => "")); + return (await response.json()) as unknown; + }; + + try { + const whoamiRaw = await request("/alpha/whoami"); + const account = parseAccount(whoamiRaw); + if (!account) { + return { + ok: false, + error: { kind: "http", message: "Command Code returned an unrecognized account response" }, + }; + } + + const orgId = options.orgId ?? account.orgId ?? undefined; + const unavailable: QuotaWindowId[] = []; + + let credits: QuotaCredits | null = null; + let windows: QuotaWindow[] = []; + try { + const parsed = parseCredits(await request("/alpha/billing/credits", { orgId })); + if (parsed) { + credits = parsed.credits; + windows = parsed.windows; + } + } catch (error) { + if (isAuthError(error) || error instanceof QuotaTimeoutError) { + return { ok: false, error: quotaError(error) }; + } + } + + let plan: QuotaPlan | null = null; + try { + plan = parsePlan(await request("/alpha/billing/subscriptions", { orgId })); + } catch (error) { + if (isAuthError(error) || error instanceof QuotaTimeoutError) { + return { ok: false, error: quotaError(error) }; + } + } + + let summary: QuotaSummary | null = null; + try { + summary = parseSummary(await request("/alpha/usage/summary", { orgId, since: plan?.currentPeriodStart })); + } catch (error) { + if (isAuthError(error) || error instanceof QuotaTimeoutError) { + return { ok: false, error: quotaError(error) }; + } + } + + if (credits) { + const spent = summary?.totalCost; + if (spent !== undefined) { + windows.push({ + id: "monthly", + label: "Monthly", + used: spent, + cap: spent + credits.remaining, + resetAtMs: periodEndMs(plan), + exceeded: credits.remaining <= 0, + }); + } else { + unavailable.push("monthly"); + } + } else { + unavailable.push("monthly"); + } + + if (!credits && !summary) { + return { + ok: false, + error: { + kind: "http", + message: "Command Code returned no recognized usage data for the account", + }, + }; + } + + return { + ok: true, + quota: { + account, + plan, + credits, + summary, + windows, + unavailable, + fetchedAt: Date.now(), + }, + }; + } catch (error) { + return { ok: false, error: quotaError(error) }; + } finally { + clearTimeout(timer); + } +} + +function quotaError(error: unknown): { kind: QuotaErrorKind; message: string } { + if (error instanceof QuotaTimeoutError) return { kind: "timeout", message: error.message }; + if (error instanceof QuotaHttpError) { + const detail = error.body.trim().slice(0, 200); + const hint = error.status === 401 || error.status === 403 ? " (check the API key)" : ""; + return { + kind: "http", + message: redact(`Command Code quota request failed (${error.status})${hint}: ${detail}`), + }; + } + return { + kind: "network", + message: redact(`Command Code quota request failed: ${error instanceof Error ? error.message : String(error)}`), + }; +} + +export function percent(used: number, cap: number): number { + if (!(cap > 0)) return 0; + return Math.round((used / cap) * 100); +} + +export function quotaBar(used: number, cap: number, width = 10): string { + const fraction = cap > 0 ? Math.min(1, Math.max(0, used / cap)) : 0; + const filled = Math.round(fraction * width); + return `${"█".repeat(filled)}${"░".repeat(width - filled)}`; +} + +/** Compact countdown, e.g. `3h 12m` / `2d 4h`. Empty when there is no reset. */ +export function formatReset(resetAtMs: number | null, now: number = Date.now()): string { + if (resetAtMs === null) return ""; + const diff = resetAtMs - now; + if (diff <= 0) return "soon"; + const minutes = Math.ceil(diff / 60_000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hours < 24) return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + const days = Math.floor(hours / 24); + const rem = hours % 24; + return rem > 0 ? `${days}d ${rem}h` : `${days}d`; +} + +function formatTokens(tokens: number): string { + if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(1)}B`; + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; + return String(tokens); +} + +function formatWindowLine(w: QuotaWindow, now: number): string { + const pct = Math.min(percent(w.used, w.cap), 999); + const reset = formatReset(w.resetAtMs, now); + const amount = + w.id === "monthly" + ? `$${w.used.toFixed(2)} / $${w.cap.toFixed(2)}` + : `${w.used.toFixed(2)} / ${w.cap.toFixed(2)} credits`; + return `${w.label.padEnd(8)} ${quotaBar(w.used, w.cap)} ${String(pct).padStart(3)}% ${amount}${ + reset ? ` (resets in ${reset})` : "" + }`; +} + +function planLine(plan: QuotaPlan, now: number): string { + const name = plan.id.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + const status = plan.status ? ` · ${plan.status}` : ""; + const end = periodEndMs(plan); + if (end === null) return `Plan: ${name}${status}`; + const remaining = end - now; + const days = Math.ceil(remaining / 86_400_000); + const date = new Date(end).toISOString().slice(0, 10); + const when = days > 0 ? `renews ${date} (${days}d)` : days === 0 ? `renews ${date} (today)` : `renewed ${date}`; + return `Plan: ${name}${status} · ${when}`; +} + +/** Human-readable multi-line quota summary for the `/cc-usage` command and CLI. */ +export function formatQuota(quota: Quota, now: number = Date.now()): string { + const lines: string[] = []; + lines.push(quota.account.keyName ?? quota.account.login); + if (quota.plan) lines.push(planLine(quota.plan, now)); + + if (quota.windows.length > 0) { + lines.push(""); + for (const w of quota.windows) lines.push(formatWindowLine(w, now)); + } + + if (quota.credits) { + const parts = [`monthly $${quota.credits.monthly.toFixed(2)}`, `purchased $${quota.credits.purchased.toFixed(2)}`]; + if (quota.credits.free > 0) parts.push(`free $${quota.credits.free.toFixed(2)}`); + lines.push("", `Credits: ${parts.join(" / ")}`); + } + + if (quota.summary) { + const period = quota.plan?.currentPeriodStart ? "this billing period" : "total"; + const tokens = quota.summary.totalTokens === undefined ? "" : ` · ${formatTokens(quota.summary.totalTokens)} tokens`; + lines.push( + `Usage: ${quota.summary.totalCount.toLocaleString("en-US")} requests · $${quota.summary.totalCost.toFixed(2)} (${period})${tokens}`, + ); + } + + if (quota.unavailable.length > 0) lines.push("", `Unavailable: ${quota.unavailable.join(", ")}`); + return lines.join("\n"); +} diff --git a/src/tui.tsx b/src/tui.tsx index f154c98..fb0c607 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -1,7 +1,8 @@ -// opencode TUI plugin exposing /cc-zdr, /cc-debug and /cc-status, plus a sidebar panel -// showing the current toggle state. It flips the shared toggle file (see ./toggles.ts) -// that the provider reads on every request, so a change takes effect without restarting -// opencode. +// opencode TUI plugin exposing /cc-zdr, /cc-debug, /cc-status and /cc-usage, plus a sidebar +// panel showing the current toggle state and live CommandCode quota. It flips the shared toggle +// file (see ./toggles.ts) that the provider reads on every request, so a change takes effect +// without restarting opencode, and reads the alpha billing endpoints (see ./quota.ts) with the +// provider's API key for the 5-hour, weekly and monthly meters. // // opencode loads this module's default export (`{ id, tui }`) from a `tui.json` plugin // entry. It is loaded from source (not dist) so opencode's Bun/Solid transform compiles @@ -15,6 +16,7 @@ import { createSignal } from "solid-js"; +import { fetchQuota, formatQuota, formatReset, percent, quotaBar, type QuotaResult, type QuotaWindow } from "./quota.js"; import { readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js"; type ToastVariant = "info" | "success" | "warning" | "error"; @@ -33,23 +35,37 @@ type TuiCommand = { run: () => void | Promise; }; -type TuiTheme = { current: { text: unknown; textMuted: unknown; success: unknown } }; +type TuiTheme = { + current: { text: unknown; textMuted: unknown; success: unknown; warning: unknown; error: unknown }; +}; type TuiSlotHandler = (ctx: { theme: TuiTheme }, props: { session_id: string }) => unknown; type TuiSlotPlugin = { order?: number; slots: Record }; +type TuiProvider = { id?: string; key?: string; options?: Record }; + +type TuiProviderConfig = { options?: Record }; + +type TuiState = { + config?: { provider?: Record }; + provider?: ReadonlyArray; +}; + type TuiApi = { keymap: { registerLayer(layer: { commands?: readonly TuiCommand[] }): () => void }; ui: { toast(input: { title?: string; message: string; variant?: ToastVariant; duration?: number }): void }; slots: { register(plugin: TuiSlotPlugin): string }; + event: { on(type: string, handler: () => void): () => void }; theme: TuiTheme; + state: TuiState; lifecycle: { onDispose(fn: () => void): () => void }; }; const ID = "commandcode-toggles"; const CATEGORY = "CommandCode"; const SIDEBAR_ORDER = 90; +const DEFAULT_QUOTA_INTERVAL_MS = 180_000; function state(value: boolean | undefined): string { return value === true ? "on" : "off"; @@ -59,11 +75,103 @@ function summary(toggles: Toggles = readToggles()): string { return `zdr=${state(toggles.zdr)}, debug=${state(toggles.debug)}`; } +/** Expands an `{env:NAME}` config reference; other templates are treated as unset. */ +function resolveRef(value: unknown): string | undefined { + if (typeof value !== "string" || value.length === 0) return undefined; + const trimmed = value.trim(); + const template = /^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(trimmed); + if (template) return template[1] ? process.env[template[1]] : undefined; + if (/^\{.*\}$/.test(trimmed)) return undefined; + return trimmed; +} + +function commandCodeOptions(api: TuiApi): Record | undefined { + return api.state.config?.provider?.["commandcode"]?.options; +} + +function resolveApiKey(api: TuiApi): string | undefined { + const provider = api.state.provider?.find((entry) => entry.id === "commandcode"); + const options = commandCodeOptions(api); + const headers = options?.["headers"] as Record | undefined; + const candidates: unknown[] = [ + provider?.key, + provider?.options?.["apiKey"], + headers?.["Authorization"], + headers?.["authorization"], + options?.["apiKey"], + process.env["COMMANDCODE_API_KEY"], + ]; + for (const candidate of candidates) { + const value = resolveRef(candidate); + if (value) return value.replace(/^Bearer\s+/i, ""); + } + return undefined; +} + +function resolveBaseURL(api: TuiApi): string | undefined { + return resolveRef(commandCodeOptions(api)?.["baseURL"]); +} + +function quotaIntervalMs(): number { + const raw = Number(process.env["COMMANDCODE_QUOTA_INTERVAL_MS"]); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_QUOTA_INTERVAL_MS; +} + +function shortWindow(w: QuotaWindow, nowMs: number): string { + const label = w.id === "fiveHour" ? "5h" : w.id === "weekly" ? "7d" : "mo"; + const reset = formatReset(w.resetAtMs, nowMs); + const tail = reset ? ` · ${reset}` : ""; + const amount = w.id === "monthly" ? `$${w.used.toFixed(2)}/$${w.cap.toFixed(2)}` : `${percent(w.used, w.cap)}%`; + return `${label} ${quotaBar(w.used, w.cap, 8)} ${amount}${tail}`; +} + export const tui = async (api: TuiApi): Promise => { const [toggles, setToggles] = createSignal(readToggles()); + const [quota, setQuota] = createSignal(null); + const [now, setNow] = createSignal(Date.now()); + + let inflight = false; + let pending = false; + let debounceTimer: ReturnType | undefined; const refresh = (): void => setToggles(readToggles()); + const refreshQuota = async (): Promise => { + if (inflight) { + pending = true; + return; + } + const apiKey = resolveApiKey(api); + if (!apiKey) { + setQuota({ ok: false, error: { kind: "config", message: "No API key found" } }); + return; + } + inflight = true; + try { + setQuota(await fetchQuota({ apiKey, baseURL: resolveBaseURL(api) })); + } catch (error) { + setQuota({ + ok: false, + error: { kind: "network", message: error instanceof Error ? error.message : String(error) }, + }); + } finally { + inflight = false; + setNow(Date.now()); + if (pending) { + pending = false; + void refreshQuota(); + } + } + }; + + const scheduleQuotaRefresh = (delayMs = 800): void => { + if (debounceTimer !== undefined) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = undefined; + void refreshQuota(); + }, delayMs); + }; + const flip = (name: ToggleName): void => { const next = toggle(name); setToggles(next); @@ -74,15 +182,35 @@ export const tui = async (api: TuiApi): Promise => { }); }; - const Status = () => ( - - - CommandCode - - zdr: {state(toggles().zdr)} - debug: {state(toggles().debug)} - - ); + function tone(w: QuotaWindow, theme: TuiTheme): unknown { + if (w.exceeded || percent(w.used, w.cap) >= 100) return theme.current.error; + if (percent(w.used, w.cap) >= 80) return theme.current.warning; + return theme.current.success; + } + + const Status = () => { + const result = quota(); + const theme = api.theme; + const windows: QuotaWindow[] = result?.ok === true ? result.quota.windows : []; + return ( + + + CommandCode + + + zdr:{state(toggles().zdr)} debug:{state(toggles().debug)} + + {windows.map((w) => ( + {shortWindow(w, now())} + ))} + {result?.ok === false ? ( + quota: {result.error.message} + ) : result === null ? ( + quota: loading… + ) : null} + + ); + }; api.slots.register({ order: SIDEBAR_ORDER, @@ -122,11 +250,49 @@ export const tui = async (api: TuiApi): Promise => { slashName: "cc-status", run: () => api.ui.toast({ title: CATEGORY, message: summary(), variant: "info" }), }, + { + name: `${ID}.usage`, + title: "CommandCode: show usage and quota", + desc: "Show the 5-hour, weekly and monthly quota from the CommandCode API", + category: CATEGORY, + namespace: "palette", + slashName: "cc-usage", + run: async () => { + const apiKey = resolveApiKey(api); + if (!apiKey) { + api.ui.toast({ + title: CATEGORY, + message: "No API key found. Set provider.commandcode.options.apiKey or COMMANDCODE_API_KEY.", + variant: "warning", + }); + return; + } + const result = await fetchQuota({ apiKey, baseURL: resolveBaseURL(api) }); + if (!result.ok) { + setQuota(result); + api.ui.toast({ title: CATEGORY, message: result.error.message, variant: "error" }); + return; + } + setQuota(result); + setNow(Date.now()); + api.ui.toast({ title: CATEGORY, message: formatQuota(result.quota), variant: "info", duration: 15000 }); + }, + }, ], }); - const timer = setInterval(refresh, 1500); - api.lifecycle.onDispose(() => clearInterval(timer)); + void refreshQuota(); + const toggleTimer = setInterval(refresh, 1500); + const clockTimer = setInterval(() => setNow(Date.now()), 30_000); + const quotaTimer = setInterval(() => void refreshQuota(), quotaIntervalMs()); + const unsubs = [api.event.on("session.idle", () => scheduleQuotaRefresh())]; + api.lifecycle.onDispose(() => { + for (const unsub of unsubs) unsub(); + if (debounceTimer !== undefined) clearTimeout(debounceTimer); + clearInterval(toggleTimer); + clearInterval(clockTimer); + clearInterval(quotaTimer); + }); }; export const id = ID; diff --git a/tsconfig.tui.json b/tsconfig.tui.json index 59edd9d..faf9105 100644 --- a/tsconfig.tui.json +++ b/tsconfig.tui.json @@ -14,5 +14,5 @@ "jsx": "preserve", "noEmit": true }, - "include": ["src/tui.tsx", "src/tui-shims.d.ts"] + "include": ["src/tui.tsx", "src/tui-shims.d.ts", "src/quota.ts", "src/toggles.ts", "src/redact.ts", "src/constants.ts"] }