Sidebar (src/tui.tsx):
- Read quota()/now() signals inside the returned JSX instead of hoisting
quota() into a const above the return. The hoisted read subscribed once
under a non-tracking Solid owner, freezing the panel after first paint;
the slot handler re-runs on session change, which masked the bug as
'/session fixes it'.
- Harden refreshQuota: whole refresh (key resolve + fetch) inside
try/catch so any throw surfaces inline instead of wedging on loading.
Defensive api.state access (non-array provider tolerated) and
process.env access via safeEnv; key source tracked by name, never logged.
- Refresh triggers: keep session.idle, add session.status/session.updated/
message.updated (debounced) plus refresh on sidebar session_id change.
- Debug-gated tui-quota trace lines to the shared debug file (silent
unless debug is on); /cc-usage hardened the same way.
Provider cost (src/model.ts, src/usage.ts):
- Forward per-request USD from finish-step (usage.raw cost/market_cost/
gateway_cost) and provider-metadata (gateway.cost/marketCost) into
providerMetadata.commandcode.{cost,marketCost} on the terminal finish
part and doGenerate result; also stash into usage.raw. A reported 0 is
preserved; only undefined means absent.
Docs: README sidebar interval corrected (3 min, not 60 s) and trigger
list updated; AGENTS.md invariants 10 (cost) and 13 (Solid gotcha).
393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
// 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
|
|
// the JSX and maps `solid-js` / `@opentui/solid` to its internal modules; that keeps this
|
|
// package free of runtime and build dependencies. The ambient types in ./tui-shims.d.ts
|
|
// back the JSX and `createSignal` references for `tsc -p tsconfig.tui.json`.
|
|
//
|
|
// Note: in this opencode version slash commands and the Ctrl+P palette read the same
|
|
// `namespace: "palette"` command list, so these entries appear in both; `hidden: true`
|
|
// would remove them from both.
|
|
|
|
import { appendFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { createSignal } from "solid-js";
|
|
|
|
import { fetchQuota, formatQuota, formatReset, percent, quotaBar, type QuotaResult, type QuotaWindow } from "./quota.js";
|
|
import { redact } from "./redact.js";
|
|
import { debugFromEnvOrFile, readToggles, toggle, type ToggleName, type Toggles } from "./toggles.js";
|
|
|
|
type ToastVariant = "info" | "success" | "warning" | "error";
|
|
|
|
type TuiCommand = {
|
|
name: string;
|
|
title?: string;
|
|
desc?: string;
|
|
category?: string;
|
|
namespace?: string;
|
|
slashName?: string;
|
|
slashAliases?: string[];
|
|
suggested?: boolean;
|
|
hidden?: boolean;
|
|
enabled?: boolean | (() => boolean);
|
|
run: () => void | Promise<void>;
|
|
};
|
|
|
|
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<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 = {
|
|
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: (event?: { type?: string }) => 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;
|
|
// Extra TUI-bus signals that mark the end of a turn. `session.idle` is a server
|
|
// plugin event and may never fire here; these keep the panel fresh regardless.
|
|
const QUOTA_TRIGGER_EVENTS = ["session.idle", "session.status", "session.updated", "message.updated"] as const;
|
|
|
|
function safeEnv(name: string): string | undefined {
|
|
try {
|
|
return typeof process === "undefined" ? undefined : process.env?.[name];
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Debug-only trace to the shared debug file. Silent unless debug is on; never logs the key. */
|
|
function trace(...args: unknown[]): void {
|
|
if (!debugFromEnvOrFile()) return;
|
|
const file = safeEnv("COMMANDCODE_DEBUG_FILE") ?? join(tmpdir(), "commandcode-debug.log");
|
|
const parts = args.map((value) => (typeof value === "string" ? value : safeJson(value)));
|
|
const line = `[commandcode] ${new Date().toISOString()} [tui-quota] ${parts.join(" ")}`;
|
|
try {
|
|
appendFileSync(file, redact(line) + "\n", "utf8");
|
|
} catch {
|
|
// Never let tracing break the panel.
|
|
}
|
|
}
|
|
|
|
function safeJson(value: unknown): string {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function state(value: boolean | undefined): string {
|
|
return value === true ? "on" : "off";
|
|
}
|
|
|
|
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] ? safeEnv(template[1]) : undefined;
|
|
if (/^\{.*\}$/.test(trimmed)) return undefined;
|
|
return trimmed;
|
|
}
|
|
|
|
function commandCodeOptions(api: TuiApi): Record<string, unknown> | undefined {
|
|
try {
|
|
return api.state?.config?.provider?.["commandcode"]?.options;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function providersOf(api: TuiApi): TuiProvider[] {
|
|
try {
|
|
const list = api.state?.provider;
|
|
if (Array.isArray(list)) return list as TuiProvider[];
|
|
// Tolerate a non-array provider state shape instead of throwing.
|
|
if (list && typeof list === "object") return Object.values(list) as TuiProvider[];
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function resolveApiKeySource(api: TuiApi): { key?: string; source: string } {
|
|
const provider = providersOf(api).find((entry) => entry?.id === "commandcode");
|
|
const options = commandCodeOptions(api);
|
|
const headers = options?.["headers"] as Record<string, unknown> | undefined;
|
|
const named: Array<[string, unknown]> = [
|
|
["provider.key", provider?.key],
|
|
["provider.options.apiKey", provider?.options?.["apiKey"]],
|
|
["headers.Authorization", headers?.["Authorization"] ?? headers?.["authorization"]],
|
|
["options.apiKey", options?.["apiKey"]],
|
|
["COMMANDCODE_API_KEY", safeEnv("COMMANDCODE_API_KEY")],
|
|
];
|
|
for (const [source, candidate] of named) {
|
|
const value = resolveRef(candidate);
|
|
if (value) return { key: value.replace(/^Bearer\s+/i, ""), source };
|
|
}
|
|
return { source: "none" };
|
|
}
|
|
|
|
function resolveBaseURL(api: TuiApi): string | undefined {
|
|
return resolveRef(commandCodeOptions(api)?.["baseURL"]);
|
|
}
|
|
|
|
function quotaIntervalMs(): number {
|
|
const raw = Number(safeEnv("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> => {
|
|
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;
|
|
let lastSessionId: string | undefined;
|
|
|
|
const refresh = (): void => setToggles(readToggles());
|
|
|
|
const refreshQuota = async (): Promise<void> => {
|
|
if (inflight) {
|
|
pending = true;
|
|
trace("coalesced", "inflight");
|
|
return;
|
|
}
|
|
inflight = true;
|
|
try {
|
|
const { key: apiKey, source } = resolveApiKeySource(api);
|
|
if (!apiKey) {
|
|
setQuota({ ok: false, error: { kind: "config", message: "No API key found" } });
|
|
setNow(Date.now());
|
|
trace("no-key", `source=${source}`);
|
|
return;
|
|
}
|
|
trace("fetch-start", `source=${source}`);
|
|
const result = await fetchQuota({ apiKey, baseURL: resolveBaseURL(api) });
|
|
setQuota(result);
|
|
if (result.ok) {
|
|
trace(
|
|
"fetch-ok",
|
|
`windows=${result.quota.windows.map((w) => `${w.id}:${w.used}/${w.cap}`).join(",")}`,
|
|
);
|
|
} else {
|
|
trace("fetch-error", `kind=${result.error.kind}`);
|
|
}
|
|
} catch (error) {
|
|
// Key resolution, state access, and fetch can all throw (e.g. unsynced
|
|
// api.state); surface it instead of wedging the panel on `loading…`.
|
|
const message = redact(error instanceof Error ? error.message : String(error));
|
|
setQuota({ ok: false, error: { kind: "network", message } });
|
|
trace("fetch-throw", message);
|
|
} finally {
|
|
inflight = false;
|
|
setNow(Date.now());
|
|
if (pending) {
|
|
pending = false;
|
|
trace("flush-pending");
|
|
void refreshQuota();
|
|
}
|
|
}
|
|
};
|
|
|
|
const scheduleQuotaRefresh = (reason: string, delayMs = 800): void => {
|
|
trace("scheduled", `reason=${reason}`, `delayMs=${delayMs}`);
|
|
if (debounceTimer !== undefined) clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => {
|
|
debounceTimer = undefined;
|
|
void refreshQuota();
|
|
}, delayMs);
|
|
};
|
|
|
|
const flip = (name: ToggleName): void => {
|
|
const next = toggle(name);
|
|
setToggles(next);
|
|
api.ui.toast({
|
|
title: CATEGORY,
|
|
message: `${name} ${state(next[name])} (${summary(next)})`,
|
|
variant: next[name] === true ? "success" : "info",
|
|
});
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
// Reads quota()/now() inside the JSX so Solid tracks the signals. Hoisting
|
|
// `quota()` into a `const` above the return would subscribe once under a
|
|
// non-tracking owner (the slot re-invokes the handler on session change,
|
|
// which is why /session "fixed" it) and freeze the panel after first paint.
|
|
const windowsOf = (result: QuotaResult | null): QuotaWindow[] =>
|
|
result?.ok === true ? result.quota.windows : [];
|
|
|
|
const errorOf = (result: QuotaResult | null): string | undefined =>
|
|
result?.ok === false ? result.error.message : undefined;
|
|
|
|
const Status = () => {
|
|
const theme = api.theme;
|
|
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>
|
|
{windowsOf(quota()).map((w) => (
|
|
<text fg={tone(w, theme)}>{shortWindow(w, now())}</text>
|
|
))}
|
|
{errorOf(quota()) !== undefined ? (
|
|
<text fg={theme.current.error}>quota: {errorOf(quota())}</text>
|
|
) : quota() === null ? (
|
|
<text fg={theme.current.textMuted}>quota: loading…</text>
|
|
) : null}
|
|
</box>
|
|
);
|
|
};
|
|
|
|
api.slots.register({
|
|
order: SIDEBAR_ORDER,
|
|
slots: {
|
|
sidebar_content(_ctx, props?: { session_id?: string }) {
|
|
const id = props?.session_id;
|
|
if (id !== undefined && id !== lastSessionId) {
|
|
lastSessionId = id;
|
|
scheduleQuotaRefresh("session-change");
|
|
}
|
|
return <Status />;
|
|
},
|
|
},
|
|
});
|
|
|
|
api.keymap.registerLayer({
|
|
commands: [
|
|
{
|
|
name: `${ID}.zdr`,
|
|
title: "CommandCode: toggle ZDR header",
|
|
desc: "Send the x-cmd-zdr header on requests",
|
|
category: CATEGORY,
|
|
namespace: "palette",
|
|
slashName: "cc-zdr",
|
|
run: () => flip("zdr"),
|
|
},
|
|
{
|
|
name: `${ID}.debug`,
|
|
title: "CommandCode: toggle debug tracing",
|
|
desc: "Write a redacted request and stream trace to a log file",
|
|
category: CATEGORY,
|
|
namespace: "palette",
|
|
slashName: "cc-debug",
|
|
run: () => flip("debug"),
|
|
},
|
|
{
|
|
name: `${ID}.status`,
|
|
title: "CommandCode: show toggle status",
|
|
desc: "Show the current zdr and debug state",
|
|
category: CATEGORY,
|
|
namespace: "palette",
|
|
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 { key: apiKey } = resolveApiKeySource(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;
|
|
}
|
|
let result: QuotaResult;
|
|
try {
|
|
result = await fetchQuota({ apiKey, baseURL: resolveBaseURL(api) });
|
|
} catch (error) {
|
|
const message = redact(error instanceof Error ? error.message : String(error));
|
|
setQuota({ ok: false, error: { kind: "network", message } });
|
|
trace("usage-throw", message);
|
|
api.ui.toast({ title: CATEGORY, message, variant: "error" });
|
|
return;
|
|
}
|
|
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 });
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
void refreshQuota();
|
|
const toggleTimer = setInterval(refresh, 1500);
|
|
const clockTimer = setInterval(() => setNow(Date.now()), 30_000);
|
|
const quotaTimer = setInterval(() => void refreshQuota(), quotaIntervalMs());
|
|
const unsubs = QUOTA_TRIGGER_EVENTS.map((type) =>
|
|
api.event.on(type, (event) => scheduleQuotaRefresh(event?.type ?? type)),
|
|
);
|
|
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 default { id: ID, tui };
|