add usage tracking to sidebar
This commit is contained in:
parent
a52efe1f70
commit
ef9a944924
21
AGENTS.md
21
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 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
|
||||||
|
|||||||
48
README.md
48
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
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -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
91
scripts/quota.mjs
Normal 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
431
src/quota.ts
Normal 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");
|
||||||
|
}
|
||||||
198
src/tui.tsx
198
src/tui.tsx
@ -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 {
|
||||||
<box flexDirection="column" gap={0}>
|
if (w.exceeded || percent(w.used, w.cap) >= 100) return theme.current.error;
|
||||||
<text fg={api.theme.current.text}>
|
if (percent(w.used, w.cap) >= 80) return theme.current.warning;
|
||||||
<b>CommandCode</b>
|
return theme.current.success;
|
||||||
</text>
|
}
|
||||||
<text fg={api.theme.current.textMuted}>zdr: {state(toggles().zdr)}</text>
|
|
||||||
<text fg={api.theme.current.textMuted}>debug: {state(toggles().debug)}</text>
|
const Status = () => {
|
||||||
</box>
|
const result = quota();
|
||||||
);
|
const theme = api.theme;
|
||||||
|
const windows: QuotaWindow[] = result?.ok === true ? result.quota.windows : [];
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={0}>
|
||||||
|
<text fg={theme.current.text}>
|
||||||
|
<b>CommandCode</b>
|
||||||
|
</text>
|
||||||
|
<text fg={theme.current.textMuted}>
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user