add usage tracking to sidebar

This commit is contained in:
nhat.nguyenhong 2026-09-16 15:30:12 +00:00
parent a52efe1f70
commit ef9a944924
7 changed files with 772 additions and 25 deletions

View File

@ -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 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 build # tsc -> dist/ (provider only; the TUI plugin is loaded from source)
npm run smoke # live request against CommandCode (needs a key) 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 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/redact.ts Credential scrubbing for error surfaces.
src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file. src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file.
src/toggles.ts Shared toggle file (~/.config/opencode/commandcode-toggles.json). 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. 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. src/constants.ts Defaults, paths, headers, passthrough params, static config block.
scripts/smoke.mjs Live end-to-end check. 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). scripts/sync-models.mjs Catalog -> provider.commandcode.models generator (with vision probing).
server.py Reference Python proxy (do not modify unless explicitly asked). 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 `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` > without restarting opencode. Precedence for `zdr`: `providerOptions.commandcode.zdr` >
`x-cmd-zdr` header > toggle file > `COMMANDCODE_ZDR`. Do not reintroduce load-time consts. `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 ## Change workflow
@ -161,6 +172,12 @@ Automated:
npm run typecheck; npm run build; npm run smoke 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): Inside opencode (restart it first if config changed):
```powershell ```powershell

View File

@ -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 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 `--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 renders a **CommandCode panel in the session sidebar** (`zdr` / `debug`, plus live quota, above the
panels) that updates live as you toggle. Note that in opencode 1.x the slash menu and the `Ctrl+P` built-in panels) that updates live as you toggle. Note that in opencode 1.x the slash menu and the
palette read the same command registry, so these entries appear in both. If you run opencode `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 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. 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 ## Features
@ -224,7 +258,8 @@ the env vars or `providerOptions` there.
| Retry with backoff | Yes — 429/5xx and network errors, honouring `Retry-After` | | Retry with backoff | Yes — 429/5xx and network errors, honouring `Retry-After` |
| Credential redaction | Yes — error bodies are scrubbed before surfacing | | 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 | | 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 ### `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/redact.ts Credential scrubbing for error surfaces.
src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file. 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/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. src/constants.ts Defaults, header names, passthrough params, static config block.
scripts/smoke.mjs Live end-to-end check against api.commandcode.ai. 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. 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 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 build # tsc -> dist/ (provider only; the TUI plugin loads from source)
npm run smoke # live request against CommandCode 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 npm run sync-models # regenerate provider.commandcode.models from the catalog
``` ```

View File

@ -18,12 +18,16 @@
"dist", "dist",
"src/tui.tsx", "src/tui.tsx",
"src/tui-shims.d.ts", "src/tui-shims.d.ts",
"src/toggles.ts" "src/toggles.ts",
"src/quota.ts",
"src/redact.ts",
"src/constants.ts"
], ],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"typecheck": "tsc --noEmit && tsc -p tsconfig.tui.json", "typecheck": "tsc --noEmit && tsc -p tsconfig.tui.json",
"smoke": "node scripts/smoke.mjs", "smoke": "node scripts/smoke.mjs",
"quota": "node scripts/quota.mjs",
"sync-models": "node scripts/sync-models.mjs" "sync-models": "node scripts/sync-models.mjs"
}, },
"devDependencies": { "devDependencies": {

91
scripts/quota.mjs Normal file
View File

@ -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 <path>] [--base-url <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 <path> opencode config to read (default ~/.config/opencode/opencode.json)",
" --base-url <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));

431
src/quota.ts Normal file
View File

@ -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<string, unknown> {
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<QuotaResult> {
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<string, string | undefined>): Promise<unknown> => {
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");
}

View File

@ -1,7 +1,8 @@
// opencode TUI plugin exposing /cc-zdr, /cc-debug and /cc-status, plus a sidebar panel // opencode TUI plugin exposing /cc-zdr, /cc-debug, /cc-status and /cc-usage, plus a sidebar
// showing the current toggle state. It flips the shared toggle file (see ./toggles.ts) // panel showing the current toggle state and live CommandCode quota. It flips the shared toggle
// that the provider reads on every request, so a change takes effect without restarting // file (see ./toggles.ts) that the provider reads on every request, so a change takes effect
// opencode. // 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 // 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 // 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 { 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"; import { readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js";
type ToastVariant = "info" | "success" | "warning" | "error"; type ToastVariant = "info" | "success" | "warning" | "error";
@ -33,23 +35,37 @@ type TuiCommand = {
run: () => void | Promise<void>; run: () => void | Promise<void>;
}; };
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 TuiSlotHandler = (ctx: { theme: TuiTheme }, props: { session_id: string }) => unknown;
type TuiSlotPlugin = { order?: number; slots: Record<string, TuiSlotHandler> }; type TuiSlotPlugin = { order?: number; slots: Record<string, TuiSlotHandler> };
type TuiProvider = { id?: string; key?: string; options?: Record<string, unknown> };
type TuiProviderConfig = { options?: Record<string, unknown> };
type TuiState = {
config?: { provider?: Record<string, TuiProviderConfig | undefined> };
provider?: ReadonlyArray<TuiProvider>;
};
type TuiApi = { type TuiApi = {
keymap: { registerLayer(layer: { commands?: readonly TuiCommand[] }): () => void }; keymap: { registerLayer(layer: { commands?: readonly TuiCommand[] }): () => void };
ui: { toast(input: { title?: string; message: string; variant?: ToastVariant; duration?: number }): void }; ui: { toast(input: { title?: string; message: string; variant?: ToastVariant; duration?: number }): void };
slots: { register(plugin: TuiSlotPlugin): string }; slots: { register(plugin: TuiSlotPlugin): string };
event: { on(type: string, handler: () => void): () => void };
theme: TuiTheme; theme: TuiTheme;
state: TuiState;
lifecycle: { onDispose(fn: () => void): () => void }; lifecycle: { onDispose(fn: () => void): () => void };
}; };
const ID = "commandcode-toggles"; const ID = "commandcode-toggles";
const CATEGORY = "CommandCode"; const CATEGORY = "CommandCode";
const SIDEBAR_ORDER = 90; const SIDEBAR_ORDER = 90;
const DEFAULT_QUOTA_INTERVAL_MS = 180_000;
function state(value: boolean | undefined): string { function state(value: boolean | undefined): string {
return value === true ? "on" : "off"; return value === true ? "on" : "off";
@ -59,11 +75,103 @@ function summary(toggles: Toggles = readToggles()): string {
return `zdr=${state(toggles.zdr)}, debug=${state(toggles.debug)}`; 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<string, unknown> | 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<string, unknown> | 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<void> => { export const tui = async (api: TuiApi): Promise<void> => {
const [toggles, setToggles] = createSignal(readToggles()); const [toggles, setToggles] = createSignal(readToggles());
const [quota, setQuota] = createSignal<QuotaResult | null>(null);
const [now, setNow] = createSignal(Date.now());
let inflight = false;
let pending = false;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
const refresh = (): void => setToggles(readToggles()); const refresh = (): void => setToggles(readToggles());
const refreshQuota = async (): Promise<void> => {
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 flip = (name: ToggleName): void => {
const next = toggle(name); const next = toggle(name);
setToggles(next); setToggles(next);
@ -74,15 +182,35 @@ export const tui = async (api: TuiApi): Promise<void> => {
}); });
}; };
const Status = () => ( 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 (
<box flexDirection="column" gap={0}> <box flexDirection="column" gap={0}>
<text fg={api.theme.current.text}> <text fg={theme.current.text}>
<b>CommandCode</b> <b>CommandCode</b>
</text> </text>
<text fg={api.theme.current.textMuted}>zdr: {state(toggles().zdr)}</text> <text fg={theme.current.textMuted}>
<text fg={api.theme.current.textMuted}>debug: {state(toggles().debug)}</text> zdr:{state(toggles().zdr)} debug:{state(toggles().debug)}
</text>
{windows.map((w) => (
<text fg={tone(w, theme)}>{shortWindow(w, now())}</text>
))}
{result?.ok === false ? (
<text fg={theme.current.error}>quota: {result.error.message}</text>
) : result === null ? (
<text fg={theme.current.textMuted}>quota: loading…</text>
) : null}
</box> </box>
); );
};
api.slots.register({ api.slots.register({
order: SIDEBAR_ORDER, order: SIDEBAR_ORDER,
@ -122,11 +250,49 @@ export const tui = async (api: TuiApi): Promise<void> => {
slashName: "cc-status", slashName: "cc-status",
run: () => api.ui.toast({ title: CATEGORY, message: summary(), variant: "info" }), 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); void refreshQuota();
api.lifecycle.onDispose(() => clearInterval(timer)); 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; export const id = ID;

View File

@ -14,5 +14,5 @@
"jsx": "preserve", "jsx": "preserve",
"noEmit": true "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"]
} }