opencode-ccgo-provider/src/tui.tsx
nhat.nguyenhong 7e77c3c6fe Fix sidebar quota stuck on loading; forward upstream cost
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).
2026-09-22 08:39:15 +00:00

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 };