diff --git a/README.md b/README.md index af1352f..ed41e2f 100644 --- a/README.md +++ b/README.md @@ -91,20 +91,39 @@ automatically. Point `ZCODE_BIN` at the bundled `zcode.cjs`: ## Standalone Quota CLI Besides the ACP server, the package ships a `zcode-quota` bin that queries -your GLM Coding Plan usage **from the terminal** — no editor or running server -needed. It reads the same `~/.zcode/v2/config.json` for credentials. +your usage **from the terminal** — no editor or running server needed. By +default it shows both **GLM Coding Plan** and **Opencode Go** in one card; +pass a provider to focus on one. + +GLM credentials are read from `~/.zcode/v2/config.json`. Opencode Go +credentials come from environment variables (the dashboard needs a browser +cookie — see [Opencode Go setup](#opencode-go-setup) below). ```bash -# One-shot: print the card and exit +# Both providers (default): GLM + Opencode Go in one card zcode-quota +# Focus on one provider +zcode-quota glm # GLM Coding Plan only +zcode-quota go # Opencode Go only (rolling + weekly + monthly) + # Live monitor: clear the screen and refresh every 30s (default) zcode-quota -w +zcode-quota go -w # watch Opencode Go only # Refresh at a custom interval (seconds; minimum 10) zcode-quota --watch --interval 60 + +# Plain monochrome bars (color is the default on a terminal) +zcode-quota --plain ``` +By default the CLI renders heat-colored (green→yellow→red) progress bars with +the usage numbers overlaid inside the bar, so each line stays short. Pass +`--plain` (or `-p`) for the classic monochrome `█`/`░` layout. Color is also +disabled automatically when stdout is piped or redirected, so captured output +stays clean. + The watch mode clears and redraws the card in place, like `top`/`htop`. Press `Ctrl-C` to exit. The 10s minimum exists because the quota API is cached for 10s internally — a shorter interval would just keep returning the stale cached @@ -116,6 +135,38 @@ When the package isn't globally installed, run the built file directly: node dist/bin/quota.js -w ``` +### Opencode Go setup + +Opencode Go has no JSON API for subscription usage — the CLI scrapes the +authenticated dashboard at `opencode.ai/workspace//go`, so it needs your +browser `auth` cookie. Credentials are read from two sources, **merged +field-by-field with environment variables taking precedence** over the config +file: + +- **Config file**: `~/.pi/agent/opencode-go.json` — same convention as the + `@beyona/pi-zai-usage` Pi extension, so if you already configured it there + you're done. + ```json + { "workspaceId": "wrk_your_workspace_id", "authCookie": "Fe26.2**your_cookie_value" } + ``` +- **Environment variables** (override the matching file field): + ```bash + export OPENCODE_GO_WORKSPACE_ID="wrk_your_workspace_id" + export OPENCODE_GO_AUTH_COOKIE="Fe26.2**your_cookie_value" + ``` + +How to get the values: + +1. **Workspace ID** — open `https://opencode.ai`, navigate to your Go + workspace, and copy the `wrk_…` id from the URL + (`https://opencode.ai/workspace//go`). +2. **Auth cookie** — open browser DevTools (F12) → Application → Cookies → + `opencode.ai` → copy the value of the cookie named `auth` (it starts with + `Fe26.2**`). + +Without credentials, the default dual-provider mode silently shows GLM only +(no error). Running `zcode-quota go` without credentials prints a setup hint. + ## ACP Registry This server is compatible with the [ACP Registry](https://agentclientprotocol.com/get-started/registry). It advertises a single `agent`-type auth method at `initialize` time — the GLM API key is read from `~/.zcode/v2/config.json` by the ZCode backend, so **no editor-side credentials are required**. diff --git a/README.zh-CN.md b/README.zh-CN.md index 27b0d84..bd3650f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -90,20 +90,35 @@ ZCode CLI 内置于桌面应用中,默认不会加到 `PATH`。用 `ZCODE_BIN` ## 独立配额查询 CLI(zcode-quota) 除了 ACP server,本包还附带一个 `zcode-quota` 命令,可在**终端**里直接查询 -GLM Coding Plan 用量——无需编辑器,也无需 server 运行。它读取同一个 -`~/.zcode/v2/config.json` 获取凭证。 +用量——无需编辑器,也无需 server 运行。默认在一张卡片里同时显示 +**GLM Coding Plan** 和 **Opencode Go**;传入 provider 参数可只看其中一个。 + +GLM 凭证读取自 `~/.zcode/v2/config.json`。Opencode Go 凭证来自环境变量 +(dashboard 需要浏览器 cookie——见下方 [Opencode Go 配置](#opencode-go-配置))。 ```bash -# 一次性:打印卡片后退出 +# 双平台(默认):GLM + Opencode Go 合并为一张卡片 zcode-quota +# 只看某一个 provider +zcode-quota glm # 仅 GLM Coding Plan +zcode-quota go # 仅 Opencode Go(rolling + weekly + monthly 三窗口) + # 常驻监控:清屏并每 30s 刷新(默认) zcode-quota -w +zcode-quota go -w # 只监控 Opencode Go # 自定义刷新间隔(秒,最小 10) zcode-quota --watch --interval 60 + +# 纯文本单色进度条(终端默认是彩色) +zcode-quota --plain ``` +默认情况下 CLI 会渲染热力配色(绿→黄→红)的进度条,并把用量数字叠在条内, +这样每行更紧凑。传 `--plain`(或 `-p`)切回经典的 `█`/`░` 单色布局。当 stdout +被管道或重定向时,彩色也会自动关闭,保证捕获到的输出干净。 + watch 模式会原地清屏重绘卡片,效果类似 `top`/`htop`。按 `Ctrl-C` 退出。 之所以设最小间隔 10s,是因为配额 API 内部有 10s 缓存——更短的间隔只会一直 返回过期的缓存值,没有意义。 @@ -114,6 +129,28 @@ watch 模式会原地清屏重绘卡片,效果类似 `top`/`htop`。按 `Ctrl- node dist/bin/quota.js -w ``` +### Opencode Go 配置 + +Opencode Go 订阅用量没有 JSON API——CLI 抓取的是登录后的 dashboard 页面 +`opencode.ai/workspace//go`,因此需要你的浏览器 `auth` cookie。设置两个 +环境变量: + +```bash +export OPENCODE_GO_WORKSPACE_ID="wrk_你的工作区id" +export OPENCODE_GO_AUTH_COOKIE="Fe26.2**你的cookie值" +``` + +获取方式: + +1. **Workspace ID**——打开 `https://opencode.ai`,进入你的 Go 工作区,从 URL + 里复制 `wrk_…` id(`https://opencode.ai/workspace//go`)。 +2. **Auth cookie**——打开浏览器开发者工具(F12)→ Application → Cookies → + `opencode.ai` → 复制名为 `auth` 的 cookie 值(以 `Fe26.2**` 开头)。 + +未设置这两个变量时,默认的双平台模式会**静默退化为只显示 GLM**(不报错)。 +若明确运行 `zcode-quota go` 但未配置,会打印一条配置提示。把它们加到 shell +配置文件(`~/.zshrc` / `~/.bashrc`)即可持久化。 + ## ACP Registry 本服务端兼容 [ACP Registry](https://agentclientprotocol.com/get-started/registry)。它在 `initialize` 时声明一个 `agent` 类型的认证方法——GLM API key 由 ZCode 后端从 `~/.zcode/v2/config.json` 读取,**编辑器侧无需配置任何凭据**。 diff --git a/src/backend/listener.ts b/src/backend/listener.ts index ba035cb..78cc4f3 100644 --- a/src/backend/listener.ts +++ b/src/backend/listener.ts @@ -95,7 +95,9 @@ export class EventStreamListener { const isTimeout = resp.error.message === "timeout"; if (!isTimeout || attempt === MAX_ATTEMPTS) { if (isTimeout) { - warn(`subscribe: all ${MAX_ATTEMPTS} attempts timed out (backend unresponsive for ~${Math.round((MAX_ATTEMPTS * 5000 + 500) / 1000)}s)`); + warn( + `subscribe: all ${MAX_ATTEMPTS} attempts timed out (backend unresponsive for ~${Math.round((MAX_ATTEMPTS * 5000 + 500) / 1000)}s)`, + ); } throw new Error(formatSubscribeError(resp)); } @@ -126,6 +128,15 @@ export class EventStreamListener { } } + /** + * True if events are queued waiting for a poll (non-destructive). Lets the + * turn loop check liveness without consuming an event — used by the stall + * reconciliation to confirm a turn is still alive before ending it. + */ + hasQueuedEvents(): boolean { + return this.queue.length > 0; + } + /** * Wait for the next event, resolving once one arrives or `timeoutMs` elapses * (resolves null on timeout). Events arriving with no active waiter are diff --git a/src/bin/quota.ts b/src/bin/quota.ts index fee7e65..38d1700 100644 --- a/src/bin/quota.ts +++ b/src/bin/quota.ts @@ -21,8 +21,20 @@ import process from "node:process"; -import { clearCache } from "../quota/cache.js"; -import { formatQuotaPlain, queryQuota } from "../quota/index.js"; +import { clearCache as clearGlmCache } from "../quota/cache.js"; +import { + defaultGoWindows, + formatCombinedCardPlain, + queryCombined, + type Provider, +} from "../quota/combined.js"; +import { clearGoCache } from "../quota/opencode-go/index.js"; + +/** Clear both provider caches — used by watch mode per tick for live values. */ +function clearAllCaches(): void { + clearGlmCache(); + clearGoCache(); +} /** Minimum watch interval (ms). Equals the quota cache TTL. */ const MIN_INTERVAL_MS = 10_000; @@ -46,25 +58,46 @@ export interface CliOptions { /** True when the user wants per-model MCP detail sub-lines shown. */ detail: boolean; help: boolean; + /** Which provider(s) to query — first positional arg (`glm`/`go`), else `all`. */ + provider: Provider; + /** True when the user explicitly asked for the plain monochrome layout. */ + plain: boolean; } /** Human-readable usage text. */ -const HELP_TEXT = `Usage: zcode-quota [options] +const HELP_TEXT = `Usage: zcode-quota [provider] [options] + +Query usage from the terminal. By default shows both GLM Coding Plan and +Opencode Go in one card; pass a provider to focus on one. + +Providers: + (none) Both GLM + Opencode Go (rolling + weekly + monthly). + glm GLM Coding Plan only. + go Opencode Go only (rolling + weekly + monthly). -Query GLM Coding Plan usage from the terminal. Reads credentials from -~/.zcode/v2/config.json (created by the ZCode app) — no server needed. +GLM credentials: read from ~/.zcode/v2/config.json (created by the ZCode app). +Opencode Go credentials (env vars override the config file, field by field): + Config file ~/.pi/agent/opencode-go.json {"workspaceId":"wrk_…","authCookie":"Fe26.2**…"} + OPENCODE_GO_WORKSPACE_ID e.g. wrk_abc123 (from the opencode.ai workspace URL) + OPENCODE_GO_AUTH_COOKIE the "auth" cookie value (starts with Fe26.2**) + Get the cookie via browser DevTools → Application → Cookies → opencode.ai. Options: -w, --watch Watch mode: clear the screen and refresh periodically. -i, --interval Refresh interval for watch mode (default 30, min 10). - -d, --detail Show per-model MCP usage detail sub-lines. + -d, --detail Show per-model MCP usage detail sub-lines (GLM only). + -p, --plain Plain monochrome bars (no color, no in-bar overlay). + Color is the default on a terminal; disabled + automatically when stdout is piped or redirected. -h, --help Show this help and exit. Examples: - zcode-quota # print once and exit - zcode-quota -w # live monitor, refresh every 30s - zcode-quota -w -i 60 # refresh every 60s - zcode-quota -d # include per-model MCP breakdown`; + zcode-quota # both providers, print once and exit (color bars) + zcode-quota go # Opencode Go only (3 windows) + zcode-quota glm -w # GLM only, live monitor every 30s + zcode-quota -w -i 60 # both, refresh every 60s + zcode-quota -d # both, include per-model MCP breakdown + zcode-quota --plain # both, classic monochrome bars`; /** * Clamp a raw interval (seconds, optional) to a valid ms value. Returns the @@ -83,13 +116,18 @@ export function resolveIntervalMs(seconds: number | undefined): { ms: number; cl /** * Parse argv into {@link CliOptions}. Supports `-w`/`--watch`, `-h`/`--help`, * `-i `/`--interval ` (space), `--interval=`, and `-i` (attached). - * Unknown flags are ignored. Exported for unit testing. + * The first non-flag positional arg is the provider (`glm`/`go`); any other + * value is ignored (treated as `all`). Unknown flags are ignored. + * + * Exported for unit testing. */ export function parseArgs(argv: readonly string[]): CliOptions { let watch = false; let help = false; let detail = false; + let plain = false; let interval: number | undefined; + let provider: Provider = "all"; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -102,6 +140,10 @@ export function parseArgs(argv: readonly string[]): CliOptions { case "--detail": detail = true; break; + case "-p": + case "--plain": + plain = true; + break; case "-h": case "--help": help = true; @@ -124,20 +166,26 @@ export function parseArgs(argv: readonly string[]): CliOptions { } else if (arg?.startsWith("-i") && arg.length > 2) { const n = Number(arg.slice(2)); if (Number.isFinite(n)) interval = n; + } else if (arg === "glm" || arg === "go") { + // First positional provider token. Only honor the first; a second + // (e.g. `zcode-quota glm go`) is ignored to keep parsing simple. + if (provider === "all") provider = arg; } + // Other non-flag tokens are ignored (forward-compat / typos). break; } } const resolved = resolveIntervalMs(interval); - return { watch, detail, help, intervalMs: resolved.ms, intervalClamped: resolved.clamped }; -} - -/** Format the current wall-clock as `HH:MM:SS` for the watch freshness stamp. */ -function timestamp(): string { - const d = new Date(); - const p = (n: number) => String(n).padStart(2, "0"); - return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; + return { + watch, + detail, + help, + plain, + provider, + intervalMs: resolved.ms, + intervalClamped: resolved.clamped, + }; } /** @@ -162,30 +210,75 @@ function sleep(ms: number, signal?: AbortSignal): Promise { }); } -/** Render the footer line (the last line of a watch frame). */ -function renderFooter(updatedAt: string, remainingSec: number): string { - return ` updated ${updatedAt} · refresh in ${remainingSec}s … Ctrl-C to exit`; +/** Format the watch-mode countdown suffix appended to the first header. */ +function refreshSuffix(remainingSec: number): string { + return `refresh in ${remainingSec}s`; +} + +/** A rendered card plus the 0-based index of the refresh (countdown) line. */ +interface RenderedCard { + text: string; + /** 0-based row of the refresh countdown line, or null when there is none. */ + refreshRow: number | null; } /** - * Full redraw of one watch frame: clear screen, card body, blank line, and the - * initial footer (counting down from `intervalSec`). `updatedAt` is captured - * at query time so it stays fixed while only the countdown ticks. + * Render the card for a given provider selection. Centralises the + * combined-card formatting so watch and one-shot share one code path. In watch + * mode the refresh countdown is rendered on the separator row after the first + * section (its position is fixed whether or not a second section appears). + * + * `color` switches the bars to a heat-colored (green→red) 24-bit ANSI layout + * with the usage numbers overlaid inside. The CLI caller gates this on + * `stdout.isTTY && !plain`. */ -function renderFrame(plain: string, updatedAt: string, intervalSec: number): string { - return `${ANSI.clearScreen}${plain}\n\n${renderFooter(updatedAt, intervalSec)}`; +function renderCard( + combined: Parameters[0], + provider: Provider, + detail: boolean, + color: boolean, + refresh?: string, +): RenderedCard { + const text = formatCombinedCardPlain(combined, { + provider, + glm: { detail }, + goWindows: defaultGoWindows(provider), + color, + refreshSuffix: refresh, + }); + // The refresh line is the one carrying the countdown text. Without a refresh + // suffix there is no such line. When present it sits right after the first + // section, so we can find it by matching the suffix. + let refreshRow: number | null = null; + if (refresh) { + const lines = text.split("\n"); + const idx = lines.findIndex((l) => l.includes(refresh)); + refreshRow = idx >= 0 ? idx : null; + } + return { text, refreshRow }; +} + +/** + * Full redraw of one watch frame: clear screen, then the card (with the + * refresh countdown on the separator row after the first section). + */ +function renderFrame(text: string): string { + return `${ANSI.clearScreen}${text}`; } /** - * Run the watch loop until the process is interrupted. Each tick clears the - * cache (so the displayed value is fresh, not a stale cache hit), queries, and - * redraws. Between ticks a per-second countdown rewrites only the footer line - * so the card body doesn't flicker. Errors from queryQuota are shown in-frame - * and retried on the next tick rather than crashing the monitor (queryQuota - * itself never throws — it degrades to `unavailable` — so this is defence in - * depth). + * Run the watch loop until the process is interrupted. Each tick clears both + * caches (so the displayed values are fresh, not stale cache hits), queries, + * and redraws. Between ticks a per-second countdown rewrites only the footer + * line so the card body doesn't flicker. The combined query never throws — + * each provider degrades internally — so this loop is robust. */ -async function runWatch(intervalMs: number, detail: boolean): Promise { +async function runWatch( + intervalMs: number, + provider: Provider, + detail: boolean, + color: boolean, +): Promise { const intervalSec = Math.round(intervalMs / 1000); const controller = new AbortController(); const restore = (): void => { @@ -203,19 +296,26 @@ async function runWatch(intervalMs: number, detail: boolean): Promise { process.stdout.write(ANSI.hideCursor); try { while (!controller.signal.aborted) { - clearCache(); // bypass cache — always show the live value - const result = await queryQuota(); - const updatedAt = timestamp(); - // Full redraw of the whole frame (card + footer counting down from max). - process.stdout.write( - renderFrame(formatQuotaPlain(result, { detail }), updatedAt, intervalSec), - ); - // Countdown: each second rewrite only the footer line, leaving the card - // untouched. \r returns to column 0; \x1B[2K clears the line. + clearAllCaches(); // bypass caches — always show live values + const combined = await queryCombined(provider); + // Full redraw with the countdown starting at the interval max. + const first = renderCard(combined, provider, detail, color, refreshSuffix(intervalSec)); + process.stdout.write(renderFrame(first.text)); + // Per-second countdown: rewrite only the refresh row (the separator line + // after the first section), leaving the card body untouched. Its row is + // fixed for the lifetime of this `combined` result, so we re-render the + // card with the new countdown purely to extract the refreshed line text, + // then blast it to that row via cursor-position + clear-line. for (let remaining = intervalSec - 1; remaining > 0; remaining--) { await sleep(1000, controller.signal).catch(() => undefined); if (controller.signal.aborted) break; - process.stdout.write(`\r${ANSI.clearLine}${renderFooter(updatedAt, remaining)}`); + const next = renderCard(combined, provider, detail, color, refreshSuffix(remaining)); + if (first.refreshRow !== null && next.refreshRow !== null) { + const row = first.refreshRow + 1; // ANSI rows are 1-based + const line = next.text.split("\n")[next.refreshRow] ?? ""; + // Move to (row, col 1), clear the line, write the refreshed countdown. + process.stdout.write(`\x1B[${row};1H${ANSI.clearLine}${line}`); + } } } } finally { @@ -224,14 +324,31 @@ async function runWatch(intervalMs: number, detail: boolean): Promise { } } -/** Print the card once and exit. Non-success → stderr + exit 1. */ -async function runOnce(detail: boolean): Promise { - const result = await queryQuota(); - if (result.kind !== "success") { - process.stderr.write(formatQuotaPlain(result, { detail }) + "\n"); +/** + * Print the card once and exit. A fully-unavailable result (no provider could + * produce data) → stderr + exit 1, so scripts can detect failure. A partial + * result (at least one provider succeeded or is merely not_configured) goes + * to stdout with exit 0. + */ +async function runOnce(provider: Provider, detail: boolean, color: boolean): Promise { + const combined = await queryCombined(provider); + const out = renderCard(combined, provider, detail, color).text; + + // Failure = every selected provider ended up unavailable (not merely + // not_configured, which is a deliberate "skip me" state). + const glmFailed = combined.glm.kind === "unavailable"; + const goFailed = combined.go.kind === "unavailable"; + const glmSelected = provider === "all" || provider === "glm"; + const goSelected = provider === "all" || provider === "go"; + const selectedFailed = + (glmSelected && glmFailed && (!goSelected || goFailed)) || + (goSelected && goFailed && (!glmSelected || glmFailed)); + + if (selectedFailed) { + process.stderr.write(out + "\n"); process.exit(1); } - process.stdout.write(formatQuotaPlain(result, { detail }) + "\n"); + process.stdout.write(out + "\n"); } async function main(): Promise { @@ -246,10 +363,17 @@ async function main(): Promise { process.stderr.write(`zcode-quota: interval below 10s raised to 10s (cache TTL is 10s)\n`); } + // Color is on by default on a real terminal; turn it off when piped/ + // redirected (isTTY is only `true` on a real TTY — Node leaves it + // `undefined` for pipes/files) or when the user asks for --plain. This + // matches the common CLI convention (ls, git, grep) and keeps raw escape + // codes out of captured output. + const color = process.stdout.isTTY === true && !opts.plain; + if (opts.watch) { - await runWatch(opts.intervalMs, opts.detail); + await runWatch(opts.intervalMs, opts.provider, opts.detail, color); } else { - await runOnce(opts.detail); + await runOnce(opts.provider, opts.detail, color); } } diff --git a/src/config/auto-compact.ts b/src/config/auto-compact.ts index 01ce1bf..3f6b1eb 100644 --- a/src/config/auto-compact.ts +++ b/src/config/auto-compact.ts @@ -40,35 +40,36 @@ export async function maybeAutoCompact( const threshold = autoCompactThreshold(); if (threshold <= 0) return; // disabled - // Read current context usage via session/read. - let used = 0; + const msgId = randomUUID(); try { - const backend = server.ensureBackend(); - const resp = await backend.request( - server.nextId(), - "session/read", - { sessionId: zcodeSid }, - 5000, - ); - if (resp.error) return; - const result = (resp.result ?? {}) as { projection?: { contextUsed?: number } }; - used = result.projection?.contextUsed ?? 0; - } catch (e) { - warn(`auto-compact: session/read failed (${e instanceof Error ? e.message : String(e)})`); - return; - } + // Read current context usage via session/read. + let used = 0; + try { + const backend = server.ensureBackend(); + const resp = await backend.request( + server.nextId(), + "session/read", + { sessionId: zcodeSid }, + 5000, + ); + if (resp.error) return; + const result = (resp.result ?? {}) as { projection?: { contextUsed?: number } }; + used = result.projection?.contextUsed ?? 0; + } catch (e) { + warn(`auto-compact: session/read failed (${e instanceof Error ? e.message : String(e)})`); + return; + } - if (used < threshold) return; + if (used < threshold) return; + + log(`auto-compact: contextUsed=${used} >= threshold=${threshold}, compacting…`); + await sendTextChunk( + cx, + acpSid, + `🔄 auto-compact: context usage ${used.toLocaleString()} ≥ threshold ${threshold.toLocaleString()}, compressing…`, + msgId, + ); - log(`auto-compact: contextUsed=${used} >= threshold=${threshold}, compacting…`); - const msgId = randomUUID(); - await sendTextChunk( - cx, - acpSid, - `🔄 auto-compact: context usage ${used.toLocaleString()} ≥ threshold ${threshold.toLocaleString()}, compressing…`, - msgId, - ); - try { // compact() handles: session/compact → waitForTurnIdle → emitInitialUsage. const result = (await compact(server, { sessionId: acpSid }, cx)) as { __lockTimeout?: boolean; diff --git a/src/handlers/dispatch.ts b/src/handlers/dispatch.ts index 9160b03..8209a8f 100644 --- a/src/handlers/dispatch.ts +++ b/src/handlers/dispatch.ts @@ -312,7 +312,11 @@ async function dispatchUsageDelta( // config.json limit.context so the editor can render the context bar. let size = ev.size; if (!size) { - const { providerId, modelId } = parseModelValue(await currentModelCached(server, acpSid)); + // Resolve to the real backend session id — `acpSid` may be a lazy + // session/new placeholder that the backend rejects with "Session is not + // active", wasting a 5s request timeout on every usage_update. + const zcodeSid = server.resolveSid(acpSid) ?? acpSid; + const { providerId, modelId } = parseModelValue(await currentModelCached(server, zcodeSid)); size = modelContextWindow(providerId, modelId); } await sendSessionUpdate(cx, acpSid, { diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 23e08e4..4a70c79 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -600,9 +600,11 @@ export async function prompt( // returning so the next prompt has room. Configured via // ZCODE_ACP_AUTO_COMPACT_THRESHOLD (absolute token count; 0/unset = // disabled). Only on end_turn — cancelled/max_turn_requests skips - // compaction. Best-effort: failures are logged inside - // maybeAutoCompact, never thrown. - if (result.stopReason === "end_turn") { + // compaction, as does a stall-recovered end_turn (the completion was + // inferred by the stall heuristic, not confirmed by turn.completed — + // compressing an in-flight task's context would destroy the work). + // Best-effort: failures are logged inside maybeAutoCompact, never thrown. + if (result.stopReason === "end_turn" && !turn.stallRecovered) { const { maybeAutoCompact } = await import("../config/auto-compact.js"); await maybeAutoCompact(server, cx, params.sessionId, zcodeSid); } @@ -667,10 +669,24 @@ export async function setConfigOptionHandler( } /** - * `session/cancel` → mark the pending turn cancelled. The turn loop observes - * the flag and forwards `session/stop` itself (mirrors Python: cancel only - * sets the flag; stop is sent by `_run_event_turn`). Eagerly sending stop - * here would race with a turn that already completed. + * `session/cancel` → stop the in-flight turn immediately. Mirrors the ZCode + * App's stop button, which sends a stop command directly (there is no + * "cancel" concept on the client — only stop). + * + * We fire `session/stop` here instead of deferring it to the turn loop. The + * loop is blocked for seconds at a time behind awaits (handleServerRequests + * waiting on a permission popup; dispatchEvent running per-event; the + * tool-result path awaiting dispatchEditDiff/dispatchPlanIfChanged backend + * calls with up to 8s timeouts). A deferred stop only fires once the loop + * finishes whatever await it is stuck in, so the user's press of stop can lag + * by the full remaining await window — the turn visibly "keeps running". + * `session/stop` is fire-and-forget and fully idempotent (the backend no-ops + * on a session with no active turn, and on a turn already aborted), so firing + * it eagerly is safe; the loop's `stopSent` guard prevents a second send. + * + * `turn.cancelled` is still set so the turn loop switches to its silent-drain + * path (translate to detect turnDone, but discard every internal event — no + * text/tool/usage is pushed after the user stopped). */ export async function cancel( server: ZcodeAcpServer, @@ -678,10 +694,21 @@ export async function cancel( ): Promise { const zcodeSid = server.resolveSid(params.sessionId); if (!zcodeSid) return; + // Cancel ALL matching turns for this session (not just the first). During a + // preempt-wait, pendingTurns holds both the old turn (already cancelled by + // preempt) and the new queued prompt; breaking on the first match would + // leave the queued prompt running. The stopSent guard dedupes the backend + // stop call across turns and repeated cancels. for (const [, turn] of server.pendingTurns) { if (turn.zcodeSid === zcodeSid) { turn.cancelled = true; - break; // one turn per session at a time + if (!turn.stopSent) { + stopBackendTurn(server, zcodeSid); + turn.stopSent = true; + } + // Record cancel time so a prompt arriving in the backend's ~20s + // model-connection recovery window can fast-fail instead of hanging. + server.lastCancelledAt.set(zcodeSid, Date.now()); } } log(`session/cancel → ${zcodeSid}`); @@ -769,14 +796,17 @@ function withPreemptLock( * registered itself in pendingTurns), so a concurrent prompt entering its own * section is guaranteed to see this caller's turn and cancel it. * - * We do NOT fire stop here — the old turn's own loop detects turn.cancelled - * and fires stop itself (turn-loop cancel site), then keeps looping until the - * backend emits turn.completed/turn.failed. Since the turn loop now waits for - * that backend completion event before exiting, the pendingTurns cleanup in - * its finally block is the reliable "backend is done, lock released" signal. - * Waiting on pendingTurns deletion therefore blocks until the backend has - * truly finished — far more reliable than probing session/goal show (which - * times out during the backend's stop-finalization window). + * `session/stop` is fired here, immediately, for the same reason the cancel + * handler fires it eagerly: the old turn loop is blocked behind long awaits + * (permission popups, per-event dispatch, tool-result backend calls), so a + * deferred stop would lag by the remaining await window and the old turn + * would visibly keep running. The loop's `stopSent` guard skips a second + * send. See `cancel()` for the idempotency rationale. + * + * We then wait on pendingTurns deletion (the old turn's prompt() finally + * block) — that only runs after runEventTurn returns, which only happens once + * the backend emits turn.completed/turn.failed. With stop already sent, the + * backend aborts in milliseconds, so this wait is short. * * Best-effort: never throws. On timeout, continues anyway. */ @@ -790,7 +820,13 @@ async function preemptInFlightTurn( for (const [reqId, turn] of server.pendingTurns) { if (turn.zcodeSid === zcodeSid && reqId !== selfRequestId) { oldRequestId = reqId; - turn.cancelled = true; // signal the old turn loop to fire stop + wait + turn.cancelled = true; // signal the old turn loop to silent-drain + if (!turn.stopSent) { + stopBackendTurn(server, zcodeSid); + turn.stopSent = true; + } + // Record cancel time (same recovery-window rationale as cancel()). + server.lastCancelledAt.set(zcodeSid, Date.now()); break; } } @@ -798,9 +834,10 @@ async function preemptInFlightTurn( log(` [preempt] in-flight turn ${oldRequestId} found, cancelling`); - // Wait for the old turn to fully exit. The turn loop's cancel handling fires - // stop and keeps looping until the backend emits turn.completed/turn.failed, - // so pendingTurns deletion only happens once the backend is truly done. + // Wait for the old turn to fully exit. With stop already fired above, the + // backend aborts quickly and emits turn.completed; the old turn loop sees + // translator.turnDone and returns, then prompt()'s finally deletes the + // pendingTurns entry — which is what we are waiting on here. const PREEMPT_TIMEOUT_MS = 120_000; const t0 = Date.now(); while (server.pendingTurns.has(oldRequestId)) { @@ -829,7 +866,7 @@ export function extractPromptText(blocks: acp.ContentBlock[] | undefined): strin text?: string; name?: string; uri?: string; - resource?: { text?: string; uri?: string }; + resource?: { text?: string; blob?: string; uri?: string }; }; if (b.type === "text" && b.text) { parts.push(b.text); @@ -843,11 +880,20 @@ export function extractPromptText(blocks: acp.ContentBlock[] | undefined): strin const label = b.name || path; parts.push(`[related resource: ${label}](${path})`); } else if (b.type === "resource" && b.resource) { - // Embedded resource (TextResourceContents). We don't advertise - // embeddedContext, but accept text payloads defensively in case a client - // sends them anyway. - const text = b.resource.text; - if (text) parts.push(text); + // Embedded resource. We don't advertise embeddedContext, but accept text + // payloads defensively in case a client sends them anyway. Binary + // payloads (BlobResourceContents) are never decoded — the base64 blob is + // useless to the model — so rewrite the resource uri into a readable + // filesystem location (same treatment as resource_link). Dropping it + // entirely left the prompt empty, which errored on a binary-only drag. + const r = b.resource; + if (r.text) { + parts.push(r.text); + } else if (r.blob && r.uri) { + const path = r.uri.startsWith("file://") ? fileUriToPath(r.uri) : r.uri; + const label = basename(path) || path; + parts.push(`[related resource: ${label}](${path})`); + } } } return parts.join("\n").trim(); @@ -1030,10 +1076,24 @@ async function runEventTurn( const translator = new EventTranslator(); differ.resetTurn(); const NO_PROGRESS_MS = 120_000; + // Backend's GLM API connection cleanup window after a mid-stream abort. + // Measured: a prompt sent <18s after cancel stalls 80-120s; ≥20s recovers + // to normal. 25s covers the tail of the recovery distribution. + const CANCEL_RECOVERY_WINDOW_MS = 25_000; let lastProgress = Date.now(); let lastStallCheck = Date.now(); let emittedText = false; let emittedOutput = false; + // Thinking-phase feedback: GLM models spend seconds in CoT before emitting + // any model.streaming event, during which the backend is silent and the + // editor shows nothing — users perceive this as "frozen". To bridge that + // gap we emit ONE agent_thought_chunk hint shortly after the turn starts, + // but only if no real output (text / reasoning / tool) has arrived yet. + // It uses a dedicated messageId so it never collides with the real reasoning + // stream (thought_) and is naturally superseded once content flows. + let turnStartedAt: number | null = null; + let thinkingHintSent = false; + const THINKING_HINT_DELAY_MS = 1200; while (Date.now() - lastProgress < NO_PROGRESS_MS) { // Drain + handle server→client requests (interaction/*). Refreshes the @@ -1069,6 +1129,26 @@ async function runEventTurn( const ev = await listener.pollEvent(500); if (ev === null) { + // Thinking-phase hint: if the turn has started but produced no output + // yet (no text/reasoning/tool streamed), and we've been silent longer + // than the threshold, emit a single "thinking" thought chunk so the + // editor shows activity instead of a frozen screen. Skipped once any + // real output has been dispatched, and never sent after cancellation. + if ( + !turn.cancelled && + !thinkingHintSent && + turnStartedAt !== null && + !emittedText && + !emittedOutput && + Date.now() - turnStartedAt > THINKING_HINT_DELAY_MS + ) { + thinkingHintSent = true; + await sendSessionUpdate(cx, acpSid, { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "正在思考…" }, + messageId: `thinking_${chunkMsgId}`, + }); + } // Stall reconciliation: probe authoritative status after 15s of silence. // Skipped while cancelled: we've already fired stop, so the backend will // emit its own completion event, and the reconciliation branch's @@ -1082,20 +1162,66 @@ async function runEventTurn( lastStallCheck = Date.now(); const proj = await monitor.pollOnce(); if (proj?.status === "idle") { - // Turn completed but the event was lost. - if (!emittedText) { - const reply = await fetchLastReply(server, turn.zcodeSid, differ); - if (reply) { - await sendTextChunk(cx, acpSid, reply, chunkMsgId); - } else if (!emittedOutput) { - // No text and no output → suspected failure. - stopBackendTurn(server, turn.zcodeSid); - throw new RequestError(-32603, "turn produced no output"); + // A single idle probe can also fire mid-work: the backend is silent + // during the model's thinking/connection phase and may report idle + // while the turn is still alive. Confirm before trusting it — wait + // briefly, then probe once more. Only a second idle WITH no queued + // events ends the turn: an event arriving in the window proves the + // turn is alive (it stays queued for the next poll). + await sleep(1500); + if (listener.hasQueuedEvents()) { + lastProgress = Date.now(); + continue; // alive — events will be consumed by the next poll + } + const proj2 = await monitor.pollOnce(); + if (proj2?.status === "idle" && !listener.hasQueuedEvents()) { + // Turn completed but the event was lost (double-confirmed). + if (!emittedText) { + const reply = await fetchLastReply(server, turn.zcodeSid, differ); + if (reply) { + await sendTextChunk(cx, acpSid, reply, chunkMsgId); + } else if (!emittedOutput) { + // No text and no output → suspected failure. + stopBackendTurn(server, turn.zcodeSid); + throw new RequestError(-32603, "turn produced no output"); + } } + // Heuristic ending: prompt() must skip auto-compact for this + // turn — the completion was inferred, and compressing an + // in-flight task's context would destroy the work. + turn.stallRecovered = true; + return { stopReason: "end_turn" }; } - return { stopReason: "end_turn" }; + // Second probe says the backend is still working (or events arrived + // mid-probe) — keep waiting; queued events are consumed by the next + // poll iteration. + lastProgress = Date.now(); + if (proj2?.status === "running") { + await listener.resubscribe(() => server.nextId()); + } + continue; } if (proj?.status === "running") { + // Cancel-recovery fast-fail: if this session was cancelled recently + // and the new turn has stalled (turn.started emitted, then silence), + // the backend is in its model-connection recovery window — the model + // request is queued but won't produce output for tens of seconds. + // Rather than hanging 80-120s, surface a recovery hint, stop the + // stalled turn, then switch to silent drain (keep looping until the + // backend emits turn.completed) so pendingTurns isn't cleaned up + // while the backend is still finalizing — preserving the invariant + // that preemptInFlightTurn relies on. + const lastCancel = server.lastCancelledAt.get(turn.zcodeSid); + if (lastCancel && Date.now() - lastCancel < CANCEL_RECOVERY_WINDOW_MS) { + await sendTextChunk(cx, acpSid, "[后端正在从停止中恢复,请稍后重试。]", randomUUID()); + stopBackendTurn(server, turn.zcodeSid); + turn.cancelled = true; + turn.stopSent = true; + // Fall through: the cancelled branch at the top of the next + // iteration + silent drain below will wait for the backend's + // completion event before returning. + continue; + } lastProgress = Date.now(); await listener.resubscribe(() => server.nextId()); } @@ -1105,6 +1231,12 @@ async function runEventTurn( lastProgress = Date.now(); const internalEvents = translator.translate(ev); + // Capture the turn-start timestamp for the thinking-phase hint above. + // Done after translate so the flag flip on the turn.started event is + // observed on the same iteration that processes it. + if (turnStartedAt === null && translator.turnStarted) { + turnStartedAt = Date.now(); + } if (turn.cancelled) { // Silent drain: translate advances the state machine (needed to detect // turnDone below) but we discard every internal event. No text, reasoning, @@ -1115,7 +1247,7 @@ async function runEventTurn( continue; } for (const iev of internalEvents) { - if (iev.kind === "TextDelta") emittedText = true; + if (iev.kind === "TextDelta" || iev.kind === "ReasoningDelta") emittedText = true; if (iev.kind === "ToolCallNew" || iev.kind === "ToolCallUpdate") emittedOutput = true; // Sync usage to the differ so the turn-completion diff doesn't re-emit a // UsageDelta for the same value (the differ's lastUsage baseline is diff --git a/src/quota/color.ts b/src/quota/color.ts new file mode 100644 index 0000000..84dcdcb --- /dev/null +++ b/src/quota/color.ts @@ -0,0 +1,121 @@ +/** + * Color progress-bar rendering for the `zcode-quota` CLI. + * + * The default CLI view overlays the usage numbers directly onto a heat-colored + * (green→yellow→red) bar using 24-bit ANSI background colors, so the percentage + * or used/total counter reads from inside the bar and the right margin stays + * short (just the reset time). `--plain` and the `/quota` slash command bypass + * this module entirely and keep the classic monochrome `█`/`░` layout from + * {@link renderBar}. + * + * Only real terminals render 24-bit color; the CLI caller gates this on + * `process.stdout.isTTY` so piping to a file or another command never emits + * raw escape codes. + */ + +/** ANSI reset (cancel all attributes). */ +export const RESET = "\x1b[0m"; + +/** 24-bit RGB triple. */ +type Rgb = readonly [number, number, number]; + +/** ANSI 24-bit background-color escape. */ +function bg(r: number, g: number, b: number): string { + return `\x1b[48;2;${r};${g};${b}m`; +} + +/** ANSI 24-bit foreground (text) color escape. */ +function fg(r: number, g: number, b: number): string { + return `\x1b[38;2;${r};${g};${b}m`; +} + +/** Linear interpolation between two numbers, rounded to an 8-bit channel. */ +function lerp(a: number, b: number, t: number): number { + return Math.round(a + (b - a) * t); +} + +/** Empty-cell background (constant dark gray, independent of usage). */ +const EMPTY_BG: Rgb = [40, 40, 48]; +/** Empty-cell foreground (muted gray text). */ +const EMPTY_FG: Rgb = [120, 120, 130]; +/** Fill-cell foreground (white text — high contrast on saturated fill). */ +const FILL_FG: Rgb = [255, 255, 255]; + +/** + * Green→yellow→red heat color for a usage percent. + * + * 0% → green `(34,197,94)`, 50% → yellow `(234,179,8)`, 100% → red + * `(239,68,68)`, piecewise-linearly interpolated. Input is clamped to [0, 100]. + */ +export function heatColor(pct: number): Rgb { + const p = Math.max(0, Math.min(100, pct)); + if (p < 50) { + const t = p / 50; + return [lerp(34, 234, t), lerp(197, 179, t), lerp(94, 8, t)]; + } + const t = (p - 50) / 50; + return [lerp(234, 239, t), lerp(179, 68, t), lerp(8, 68, t)]; +} + +/** + * Pick the overlay text drawn inside the bar. + * + * Returns `"used/total"` when the item carries both absolute counters (e.g. the + * MCP limit), otherwise `"NN%"` (the rounded used percent). This lets + * counter-bearing limits show their exact counts in-bar while counter-less + * limits (5h, Opencode Go windows) show the percent. + */ +export function pickOverlay(item: { + usedPercent: number; + usedCount?: number; + totalCount?: number; +}): string { + if ( + typeof item.usedCount === "number" && + typeof item.totalCount === "number" && + Number.isFinite(item.usedCount) && + Number.isFinite(item.totalCount) + ) { + return `${item.usedCount}/${item.totalCount}`; + } + return `${Math.round(Math.max(0, Math.min(100, item.usedPercent)))}%`; +} + +/** Options for {@link renderColorBar}. */ +export interface ColorBarOptions { + /** Text drawn centered inside the bar (e.g. `"73%"` or `"237/1000"`). */ + overlay?: string; + /** Total cell count; defaults to 20 to match the plain {@link renderBar}. */ + width?: number; +} + +/** + * Render a heat-colored progress bar with optional centered overlay text. + * + * Each cell is one background color: the fill cells use {@link heatColor} (so + * low usage reads green, high usage red), the empty cells use a constant dark + * gray. When an overlay string is supplied its characters are written over the + * bar with a contrasting foreground (white on fill, gray on empty), centered + * across the `width` cells. The result always ends with {@link RESET} so the + * color never leaks into the text that follows. + */ +export function renderColorBar(usedPercent: number, opts?: ColorBarOptions): string { + const width = opts?.width ?? 20; + const pct = Math.max(0, Math.min(100, usedPercent)); + const filled = Math.round((pct / 100) * width); + const [fr, fgg, fb] = heatColor(pct); + + const overlay = opts?.overlay ?? ""; + const overlayStart = Math.floor((width - overlay.length) / 2); + + let out = ""; + for (let i = 0; i < width; i++) { + const isFill = i < filled; + const [br, bgg, bb] = isFill ? [fr, fgg, fb] : EMPTY_BG; + const idx = i - overlayStart; + const ch = idx >= 0 && idx < overlay.length ? overlay[idx]! : " "; + const [tr, tg, tb] = isFill ? FILL_FG : EMPTY_FG; + out += `${bg(br, bgg, bb)}${fg(tr, tg, tb)}${ch}`; + } + return out + RESET; +} diff --git a/src/quota/combined.ts b/src/quota/combined.ts new file mode 100644 index 0000000..40d0ff3 --- /dev/null +++ b/src/quota/combined.ts @@ -0,0 +1,242 @@ +/** + * Combined multi-provider quota — the orchestration layer used by the + * `zcode-quota` CLI when no provider subcommand is given (default mode). + * + * Queries GLM Coding Plan and Opencode Go in parallel and renders a single + * merged card with one section per provider. The `/quota` slash command does + * NOT use this — it stays on the single-provider GLM {@link formatQuota}. + * + * Design notes: + * - `Promise.all` so a slow Opencode Go scrape doesn't delay the GLM card. + * - A `not_configured` Opencode Go result is silently dropped (no header, + * no error line) in `all` mode, so GLM-only users see no noise. In `go` + * mode it surfaces as a help line because the user explicitly asked. + * - The divider width is computed from the widest body line so the frame + * stays balanced regardless of which windows/counts are present. + */ + +import type { FormatOptions } from "./format.js"; +import { renderGlmSection } from "./format.js"; +import { queryQuota } from "./index.js"; +import { formatGoSection, queryGoUsage } from "./opencode-go/index.js"; +import type { GoQueryResult, GoWindowKey } from "./opencode-go/types.js"; +import type { QuotaResult } from "./types.js"; + +/** Which provider(s) to query. */ +export type Provider = "all" | "glm" | "go"; + +/** The combined result of both providers. */ +export interface CombinedResult { + glm: QuotaResult; + go: GoQueryResult; +} + +/** + * Which Opencode Go windows to render. All three (rolling + weekly + monthly) + * are shown in every mode that renders Go at all — the compact color layout + * leaves room for the monthly bar. + */ +export function defaultGoWindows(provider: Provider): GoWindowKey[] { + // All three windows everywhere now — the compact color layout leaves room + // for the monthly bar. `provider` is accepted to keep the call sites + // self-documenting (and to allow per-provider trimming again later) even + // though every branch currently returns the same set. + void provider; + return ["rolling", "weekly", "monthly"]; +} + +/** + * Query both providers in parallel. + * + * Each provider degrades internally (GLM → `unavailable`, Go → + * `not_configured`/`unavailable`); neither ever throws, so `Promise.all` + * always resolves. + */ +export async function queryCombined(provider: Provider): Promise { + // For `glm`-only we skip the Go fetch entirely; for `go`-only we skip GLM. + // Skipping is cheaper and avoids touching Go credentials the user may not + // have set. We still return both fields so the formatter's shape is uniform. + const tasks: [Promise, Promise] = + provider === "glm" + ? [queryQuota(), Promise.resolve({ kind: "not_configured" })] + : provider === "go" + ? [Promise.resolve({ kind: "unavailable" }), queryGoUsage()] + : [queryQuota(), queryGoUsage()]; + + const [glm, go] = await Promise.all(tasks); + return { glm, go }; +} + +/** + * Decide whether the Go section should appear at all in `all` mode. + * + * `not_configured` is silently dropped (the user hasn't set credentials and + * didn't explicitly ask for Go). All other kinds — including `unavailable` + * and `auth_error` — render so the user sees that something is wrong. + */ +function shouldShowGo(provider: Provider, go: GoQueryResult): boolean { + if (provider === "glm") return false; + if (go.kind === "not_configured" && provider === "all") return false; + return true; +} + +/** + * Render the combined result as a single fenced ```text card. + * + * Sections are separated by a blank line; each section has a ` Header` line + * (indented one space so it reads as a sub-heading) followed by its body. + * The divider spans the widest line in the card. + */ +export function formatCombinedCard( + combined: CombinedResult, + opts: { + provider: Provider; + glm?: FormatOptions; + goWindows?: GoWindowKey[]; + /** Render heat-colored 24-bit ANSI bars with overlaid numbers. */ + color?: boolean; + /** Optional trailing annotation appended to the first section header + * (e.g. ` · refresh in 29s`). Watch mode uses it to show the countdown. */ + refreshSuffix?: string; + } = { provider: "all" }, +): string { + const provider = opts.provider; + const lines = renderCombinedLines( + combined, + provider, + opts.glm, + opts.goWindows, + opts.color, + opts.refreshSuffix, + ); + return ["```text", ...lines, "```"].join("\n"); +} + +/** {@link formatCombinedCard} without the fence — for raw terminal output. */ +export function formatCombinedCardPlain( + combined: CombinedResult, + opts: { + provider: Provider; + glm?: FormatOptions; + goWindows?: GoWindowKey[]; + color?: boolean; + refreshSuffix?: string; + } = { provider: "all" }, +): string { + return renderCombinedLines( + combined, + opts.provider, + opts.glm, + opts.goWindows, + opts.color, + opts.refreshSuffix, + ).join("\n"); +} + +/** + * Render the card body lines (no fence). Shared by fenced/plain variants. + * + * Single-provider modes render just that provider's section header + body + * (no `Quota Overview` banner, no divider). `all` mode renders the banner, + * divider, and both sections. + */ +/** + * Width the refresh line is right-aligned to. Matches the classic GLM divider + * width so the countdown lines up with the card's visual frame. + */ +const REFRESH_LINE_WIDTH = 34; + +/** + * Build the separator line that follows the first rendered section. + * + * In watch mode this carries the refresh countdown, right-aligned to + * {@link REFRESH_LINE_WIDTH} so its position is stable regardless of how many + * sections follow (or whether Go is configured). When no refresh suffix is + * supplied the line is blank — it still occupies the row so the layout below + * it never shifts. + */ +function separatorLine(refreshSuffix?: string): string { + if (!refreshSuffix) return ""; + return refreshSuffix.padStart(REFRESH_LINE_WIDTH); +} + +function renderCombinedLines( + combined: CombinedResult, + provider: Provider, + glmOpts?: FormatOptions, + goWindows?: GoWindowKey[], + color = false, + /** Optional refresh countdown rendered on the separator line after the + * first section (e.g. `refresh in 29s`). */ + refreshSuffix?: string, +): string[] { + const { glm, go } = combined; + const sep = separatorLine(refreshSuffix); + // Fold the color flag into the GLM FormatOptions so it reaches renderGlmSection + // alongside detail/compact without each caller having to set it. + const glmOptsColor: FormatOptions = { ...glmOpts, color }; + + // Single-provider GLM: behave like the original card (header + divider + + // body) minus the fence, so `zcode-quota glm` looks identical to today's + // `zcode-quota`. + if (provider === "glm") { + const lines = renderSingleGlm(glm, glmOptsColor); + // The separator rides after the (only) section, then nothing follows. + return sep ? [...lines, sep] : lines; + } + // Single-provider Go: header + body, no banner/divider. + if (provider === "go") { + const section = formatGoSection(go, goWindows ?? defaultGoWindows("go"), Date.now(), color); + const lines = [section.header, ...section.body]; + return sep ? [...lines, sep] : lines; + } + + // Combined `all` mode. GLM renders full (MCP on its own line) — the layout + // is short enough now that compact mode isn't worth the lost detail. + const glmSection = renderGlmSection(glm, glmOptsColor); + const showGo = shouldShowGo("all", go); + const goSection = showGo + ? formatGoSection(go, goWindows ?? defaultGoWindows("all"), Date.now(), color) + : null; + + const hasGlm = glmSection.body.length > 0; + const hasGo = !!goSection && goSection.body.length > 0; + + // The separator line always follows the FIRST rendered section, so its row + // is fixed whether or not a second section appears. + const sections: string[][] = []; + if (hasGlm) sections.push([` ${glmSection.header}`, ...glmSection.body]); + if (hasGo) sections.push([` ${goSection!.header}`, ...goSection!.body]); + + if (sections.length === 0) { + return [" ⚠ no usage data available"]; + } + + // No banner or top divider — the section headers themselves identify each + // provider, and an extra banner line adds noise without information. The + // refresh countdown sits on the separator row between sections (or after + // the only section), right-aligned. + const body: string[] = []; + sections.forEach((sec, i) => { + if (i > 0) body.push(sep); + body.push(...sec); + }); + // If there's only one section, the separator still trails it so the refresh + // line keeps its fixed position. + if (sections.length === 1 && sep) body.push(sep); + return body; +} + +/** + * Render a single GLM provider as header + divider + body (the classic card, + * minus the fence). Used for `zcode-quota glm`. + */ +function renderSingleGlm(result: QuotaResult, opts?: FormatOptions): string[] { + const section = renderGlmSection(result, opts); + if (result.kind !== "success") { + // Non-success → just the prose line, no header/divider (matches formatQuota). + return section.body; + } + const divider = "─".repeat(34); + return [section.header, divider, ...section.body]; +} diff --git a/src/quota/format.ts b/src/quota/format.ts index 25753d2..f4b7b7c 100644 --- a/src/quota/format.ts +++ b/src/quota/format.ts @@ -15,6 +15,7 @@ * fill for used quota, with the light shade marking the remainder. */ +import { pickOverlay, renderColorBar } from "./color.js"; import type { QuotaItem, QuotaResult } from "./types.js"; /** @@ -45,8 +46,14 @@ export function renderBar(usedPercent: number): string { return CHAR_FULL.repeat(filled) + CHAR_EMPTY.repeat(empty); } -/** Format a reset timestamp (ms) as a local `MM-DD HH:MM` string, or `null`. */ -function formatResetTime(nextResetTime?: number): string | null { +/** + * Format a reset timestamp (ms) as a local `MM-DD HH:MM` string, or `null`. + * + * Exported so the Opencode Go formatter can reuse the same layout (Go windows + * carry only a relative `resetInSec`; the caller converts that to an absolute + * ms timestamp against the fetch snapshot first). + */ +export function formatResetTime(nextResetTime?: number): string | null { if (nextResetTime === undefined || !Number.isFinite(nextResetTime)) return null; const d = new Date(nextResetTime); if (Number.isNaN(d.getTime())) return null; @@ -65,7 +72,7 @@ function capitalise(s: string): string { /** * Build the trailing annotation for an item: reset time first (so all items - * align), then `(used/total)` last (only some limits carry absolute counters). + * align), then the absolute used counter last (only some limits carry it). */ function formatTrailing(item: QuotaItem): string { const parts: string[] = []; @@ -73,21 +80,26 @@ function formatTrailing(item: QuotaItem): string { const reset = formatResetTime(item.nextResetTime); if (reset) parts.push(reset); - // Absolute counters — only some limit kinds carry them (MCP/TIME does, - // legacy TOKENS_LIMIT often does not). Placed last so the reset times of - // counter-less items (e.g. 5h) stay left-aligned with counter-bearing ones. - if ( - typeof item.usedCount === "number" && - typeof item.totalCount === "number" && - Number.isFinite(item.usedCount) && - Number.isFinite(item.totalCount) - ) { - parts.push(`(${item.usedCount}/${item.totalCount})`); + // Absolute used counter — only some limit kinds carry it (MCP/TIME_LIMIT). + // The total is intentionally omitted: it is a fixed allowance already + // expressed by the percentage bar, so showing `/1000` adds no information. + if (typeof item.usedCount === "number" && Number.isFinite(item.usedCount)) { + parts.push(`${item.usedCount}`); } return parts.length > 0 ? ` · ${parts.join(" · ")}` : ""; } +/** + * Color-mode trailing annotation: reset time only. The percent and the + * used/total counter are already overlaid inside the colored bar (see + * {@link pickOverlay}), so the right margin just carries the reset stamp. + */ +function formatTrailingColor(item: QuotaItem): string { + const reset = formatResetTime(item.nextResetTime); + return reset ? ` · ${reset}` : ""; +} + /** Right-pad a model code for aligned detail sub-lines. */ const DETAIL_LABEL_WIDTH = 14; @@ -97,23 +109,51 @@ const DETAIL_LABEL_WIDTH = 14; * - `detail` (default `true`): show per-model usage breakdown sub-lines * (`├ search-prime …`). Set to `false` for compact terminal output where the * aggregate bar is enough. + * - `compact` (default `false`): collapse the MCP item into a trailing + * annotation on the 5h line (`· MCP (used/total)`) and drop its standalone + * bar line + detail sub-lines. Used only by the combined dual-provider CLI + * view to keep the merged card short; the standalone `glm` subcommand and + * the `/quota` slash command keep the full layout. + * - `color` (default `false`): render the bar as a heat-colored (green→red) + * 24-bit ANSI bar with the usage numbers overlaid inside (see + * {@link pickOverlay}), leaving only the reset time on the right margin. + * Only the `zcode-quota` CLI sets this (gated on `stdout.isTTY`); the + * `/quota` slash command never does, so its fenced ```text card stays plain + * and copy-paste-safe. */ export interface FormatOptions { detail?: boolean; + compact?: boolean; + color?: boolean; } -/** Resolve partial options into a complete flag. */ -function resolveOptions(opts?: FormatOptions): { detail: boolean } { - return { detail: opts?.detail ?? true }; +/** Resolve partial options into complete flags. */ +function resolveOptions(opts?: FormatOptions): { + detail: boolean; + compact: boolean; + color: boolean; +} { + return { + detail: opts?.detail ?? true, + compact: opts?.compact ?? false, + color: opts?.color ?? false, + }; } /** Render one quota item line (+ indented detail sub-lines if present). */ -function formatItem(item: QuotaItem, showDetail: boolean): string[] { +function formatItem(item: QuotaItem, showDetail: boolean, color = false): string[] { const lines: string[] = []; - const bar = renderBar(item.usedPercent); - lines.push( - `${item.label.padEnd(5)} ${bar} ${padPercent(item.usedPercent)}%${formatTrailing(item)}`, - ); + if (color) { + // Color mode: the percent (or used/total) is overlaid inside the heat bar, + // so the right margin carries only the reset stamp. + const bar = renderColorBar(item.usedPercent, { overlay: pickOverlay(item) }); + lines.push(`${item.label.padEnd(5)} ${bar}${formatTrailingColor(item)}`); + } else { + const bar = renderBar(item.usedPercent); + lines.push( + `${item.label.padEnd(5)} ${bar} ${padPercent(item.usedPercent)}%${formatTrailing(item)}`, + ); + } if (showDetail && item.detail && item.detail.length > 0) { const last = item.detail.length - 1; @@ -133,6 +173,78 @@ const STATUS_MESSAGES: Record, string> = unavailable: "⚠ Quota info unavailable", }; +/** + * A rendered GLM section — a header (plan name) and the bar lines. + * + * Exported so the combined CLI view can compose the GLM section alongside the + * Opencode Go section inside one card, without duplicating the per-item + * formatting logic. + */ +export interface RenderedGlmSection { + header: string; + body: string[]; +} + +/** + * Render the GLM section (header + one bar line per item) without the fence + * or divider. + * + * Non-success kinds return a header of `"GLM Coding Plan"` and a single + * explanatory body line, mirroring {@link formatQuota}'s non-success prose + * (just split into header/body for composability). + * + * In `compact` mode the MCP item is collapsed into a trailing annotation on + * the 5h line (`· MCP (used/total)`) and its own bar line + detail sub-lines + * are dropped — used by the combined dual-provider CLI view to keep the merged + * card short. The standalone `glm` subcommand and `/quota` slash command use + * the full layout. + */ +export function renderGlmSection(result: QuotaResult, opts?: FormatOptions): RenderedGlmSection { + const header = "GLM Coding Plan"; + if (result.kind !== "success") { + return { header, body: [STATUS_MESSAGES[result.kind]] }; + } + const { detail, compact, color } = resolveOptions(opts); + const title = `${header}${result.level ? ` · ${capitalise(result.level)}` : ""}`; + + if (compact) { + // Find the MCP item to fold into the 5h line as a trailing note. + const mcp = result.items.find((it) => it.key === "mcp"); + const mcpNote = formatMcpNote(mcp); + const body = result.items + .filter((it) => it.key !== "mcp") + .map((it) => + formatItem(it, false, color)[0]!.replace( + /$/, + mcpNote && it.key === "token_5h" ? mcpNote : "", + ), + ); + // If 5h is somehow absent but MCP exists, surface MCP as its own line so + // the data isn't lost. + const has5h = result.items.some((it) => it.key === "token_5h"); + if (mcp && !has5h && mcpNote) { + body.push(formatItem(mcp, false, color)[0]!); + } + return { header: title, body }; + } + + const body = result.items.flatMap((item) => formatItem(item, detail, color)); + return { header: title, body }; +} + +/** + * Build the MCP trailing note for compact mode: ` · MCP N` when the item + * carries an absolute counter, ` · MCP NN%` when it only has a percentage, or + * `""` when there is no MCP item. + */ +function formatMcpNote(mcp: QuotaItem | undefined): string { + if (!mcp) return ""; + if (typeof mcp.usedCount === "number" && Number.isFinite(mcp.usedCount)) { + return ` · MCP ${mcp.usedCount}`; + } + return ` · MCP ${padPercent(mcp.usedPercent)}%`; +} + /** * Render a {@link QuotaResult} as a multi-line plain-text card wrapped in a * ```text fenced block. @@ -151,13 +263,11 @@ export function formatQuota(result: QuotaResult, opts?: FormatOptions): string { return STATUS_MESSAGES[result.kind]; } - const { detail } = resolveOptions(opts); - const header = `GLM Coding Plan${result.level ? ` · ${capitalise(result.level)}` : ""}`; + const section = renderGlmSection(result, opts); // Divider spans the longest line so the frame looks balanced; 34 ≈ label(5) // + space(1) + bar(20) + spaces(2) + "NN%"(3) + trailing-room(3). const divider = "─".repeat(34); - const body = result.items.flatMap((item) => formatItem(item, detail)); - return ["```text", header, divider, ...body, "```"].join("\n"); + return ["```text", section.header, divider, ...section.body, "```"].join("\n"); } /** diff --git a/src/quota/opencode-go/cache.ts b/src/quota/opencode-go/cache.ts new file mode 100644 index 0000000..0df7d87 --- /dev/null +++ b/src/quota/opencode-go/cache.ts @@ -0,0 +1,38 @@ +/** + * In-memory TTL cache for Opencode Go results. + * + * Mirrors {@link ../../cache.ts} but typed for {@link GoQueryResult}. Same + * 10s TTL — short enough that the countdown stays accurate in watch mode, + * long enough to debounce rapid repeats. No file persistence, no sharding. + */ + +import type { GoQueryResult } from "./types.js"; + +/** How long a cached result is served before re-querying. */ +const TTL_MS = 10_000; + +let slot: { result: GoQueryResult; at: number } | null = null; + +/** Inject a fake clock — only tests should need this. */ +let now: () => number = () => Date.now(); + +/** Return the cached result if still fresh, else `null`. */ +export function getCached(): GoQueryResult | null { + if (slot && now() - slot.at < TTL_MS) return slot.result; + return null; +} + +/** Store a fresh result, stamping it with the current time. */ +export function setCached(result: GoQueryResult): void { + slot = { result, at: now() }; +} + +/** Clear the cache (test helper, and used by CLI watch mode per tick). */ +export function clearCache(): void { + slot = null; +} + +/** Override the clock (test-only). Pass `undefined` to restore real time. */ +export function setClock(fn?: () => number): void { + now = fn ?? (() => Date.now()); +} diff --git a/src/quota/opencode-go/client.ts b/src/quota/opencode-go/client.ts new file mode 100644 index 0000000..7c9dfb2 --- /dev/null +++ b/src/quota/opencode-go/client.ts @@ -0,0 +1,56 @@ +/** + * Opencode Go dashboard HTTP client. + * + * There is no JSON API for Go subscription usage. The only data source is the + * authenticated web dashboard at `https://opencode.ai/workspace//go`, + * which serves an HTML page with usage embedded in a SolidJS SSR hydration + * payload. We fetch the HTML here and hand it to the parser. + * + * Credentials come from environment variables (set by the user) — not from + * `~/.zcode/v2/config.json`, since Opencode Go is unrelated to the ZCode + * provider the bridge talks to. + */ + +/** Request timeout (ms). The dashboard is a full HTML page; allow a bit more. */ +const TIMEOUT_MS = 10_000; + +/** + * Browser-like User-Agent. opencode.ai returns a login redirect for + * unauthenticated requests; sending a real browser UA avoids any + * bot-detection short-circuit that would bypass the dashboard route. + */ +const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0"; + +/** Build the dashboard URL for a workspace. */ +export function dashboardUrl(workspaceId: string): string { + return `https://opencode.ai/workspace/${workspaceId}/go`; +} + +/** + * Fetch the Go dashboard HTML. + * + * @throws on non-2xx responses, network errors, or timeout. The caller maps + * these to `unavailable`. A redirect-to-login is NOT thrown here — + * the final URL is returned so the orchestrator can classify it as + * `auth_error`. + */ +export async function fetchGoDashboard( + workspaceId: string, + authCookie: string, + fetchImpl: typeof globalThis.fetch = globalThis.fetch, +): Promise<{ status: number; text: string; finalUrl: string }> { + const url = dashboardUrl(workspaceId); + const resp = await fetchImpl(url, { + method: "GET", + headers: { + Cookie: `auth=${authCookie}`, + "User-Agent": USER_AGENT, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + redirect: "follow", + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + const text = await resp.text(); + return { status: resp.status, text, finalUrl: resp.url }; +} diff --git a/src/quota/opencode-go/config.ts b/src/quota/opencode-go/config.ts new file mode 100644 index 0000000..502aeba --- /dev/null +++ b/src/quota/opencode-go/config.ts @@ -0,0 +1,72 @@ +/** + * Opencode Go credential discovery. + * + * Credentials may come from two sources, merged field-by-field with + * **environment variables taking precedence** over the config file: + * 1. `OPENCODE_GO_WORKSPACE_ID` / `OPENCODE_GO_AUTH_COOKIE` env vars + * (best for CI / scripts / temporary overrides). + * 2. `~/.pi/agent/opencode-go.json` — `{ workspaceId, authCookie }` + * (the convention used by the @beyona/pi-zai-usage Pi extension, so users + * who already configured it there get reuse for free). + * + * A field present in env overrides the same field from the file; a field only + * in the file is still used. This lets a user keep their stable workspaceId in + * the file while rotating the cookie via env, etc. Both fields must resolve to + * a valid pair — a missing/invalid one yields `not_configured`. + */ + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { log } from "../../utils.js"; + +/** Env var names — documented in the CLI help and README. */ +export const ENV_WORKSPACE_ID = "OPENCODE_GO_WORKSPACE_ID"; +export const ENV_AUTH_COOKIE = "OPENCODE_GO_AUTH_COOKIE"; + +/** Config file path (matches the @beyona/pi-zai-usage convention). */ +export const CONFIG_PATH = path.join( + process.env.HOME || process.env.USERPROFILE || "~", + ".pi", + "agent", + "opencode-go.json", +); + +/** Shape of the JSON config file. */ +interface OpencodeGoConfig { + workspaceId?: string; + authCookie?: string; +} + +/** + * Read (best-effort) the JSON config file. Returns an empty object on any + * error — missing file, parse error, or wrong shape all degrade to "no fields + * contributed", which the caller treats as `not_configured`. + * + * Best-effort mirrors {@link ../../backend/credentials.ts}: a missing or + * corrupt config must never crash the bridge, only log. + */ +export function readConfigFile(filePath: string = CONFIG_PATH): OpencodeGoConfig { + try { + const raw = readFileSync(filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + log(`opencode-go: config file is not a JSON object (${filePath})`); + return {}; + } + const cfg = parsed as Record; + return { + workspaceId: typeof cfg.workspaceId === "string" ? cfg.workspaceId : undefined, + authCookie: typeof cfg.authCookie === "string" ? cfg.authCookie : undefined, + }; + } catch (e) { + // ENOENT (most common — user hasn't created the file) is silent; other + // read/parse errors are logged but still non-fatal. + const msg = e instanceof Error ? e.message : String(e); + if (!/ENOENT/.test(msg)) { + log(`opencode-go: config read failed (${filePath}): ${msg}`); + } + return {}; + } +} diff --git a/src/quota/opencode-go/format.ts b/src/quota/opencode-go/format.ts new file mode 100644 index 0000000..6c82e75 --- /dev/null +++ b/src/quota/opencode-go/format.ts @@ -0,0 +1,104 @@ +/** + * Opencode Go usage formatting. + * + * Renders the three windows (rolling 5h / weekly 7d / monthly 30d) as progress + * bars identical in style to the GLM card, so the two sections read as one + * cohesive card in the combined view. Reuses {@link renderBar} from the GLM + * formatter for visual consistency. + */ + +import { pickOverlay, renderColorBar } from "../color.js"; +import { formatResetTime, renderBar } from "../format.js"; +import type { GoQueryResult, GoWindowKey } from "./types.js"; + +/** Label + window-key metadata, in display order. */ +const WINDOW_META: Array<{ key: GoWindowKey; label: string }> = [ + { key: "rolling", label: "5h" }, + { key: "weekly", label: "Week" }, + { key: "monthly", label: "Month" }, +]; + +/** + * Format a duration in seconds as a compact countdown. + * + * - `>= 1d` → `Xd Yh` (e.g. `6d 8h`) + * - `>= 1h` → `Yh Zm` (e.g. `2h 30m`) + * - `>= 1m` → `Zm` (e.g. `45m`) + * - `< 1m` → `<1m` + */ +export function formatDuration(sec: number): string { + if (!Number.isFinite(sec) || sec < 60) return "<1m"; + const s = Math.floor(sec); + const days = Math.floor(s / 86_400); + const hours = Math.floor((s % 86_400) / 3_600); + const mins = Math.floor((s % 3_600) / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +/** Pad a percent to 2 chars (right-aligned), matching the GLM card. */ +function padPercent(n: number): string { + return String(n).padStart(2); +} + +/** A rendered section: a header line and zero or more body lines. */ +export interface RenderedSection { + header: string; + body: string[]; +} + +/** + * Render the Opencode Go section for the requested windows. + * + * Used by the combined formatter. The header is always `Opencode Go`; body + * has one bar line per window. Non-success kinds return a header + a single + * explanatory line. + * + * When `color` is true the bar is a heat-colored 24-bit ANSI bar with the + * percent overlaid inside (Go windows carry no absolute counters, so the + * overlay is always `NN%`), mirroring the GLM color layout. + */ +export function formatGoSection( + result: GoQueryResult, + windows: readonly GoWindowKey[], + now: number = Date.now(), + color = false, +): RenderedSection { + const header = "Opencode Go"; + + if (result.kind !== "success") { + const msg = + result.kind === "not_configured" + ? "not configured (set OPENCODE_GO_WORKSPACE_ID + OPENCODE_GO_AUTH_COOKIE)" + : result.kind === "auth_error" + ? "auth expired — refresh your opencode.ai cookie" + : "unavailable"; + return { header, body: [msg] }; + } + + const elapsedSec = Math.max(0, (now - result.fetchedAt) / 1000); + const body = WINDOW_META.filter((m) => windows.includes(m.key)).map((m) => { + const w = result[m.key]; + if (!w) { + return `${m.label.padEnd(5)} (no data)`; + } + // Live countdown: subtract elapsed time since the fetch snapshot, then + // convert to an absolute reset timestamp (matches the GLM card's layout). + const remainingSec = Math.max(0, w.resetInSec - elapsedSec); + const resetAt = result.fetchedAt + remainingSec * 1000; + const reset = formatResetTime(resetAt) ?? "<1m"; + if (color) { + const bar = renderColorBar(w.usagePercent, { + overlay: pickOverlay({ usedPercent: w.usagePercent }), + }); + return `${m.label.padEnd(5)} ${bar} · ${reset}`; + } + const bar = renderBar(w.usagePercent); + // No leading indent — the bar lines align with GLM's so the two sections + // read as one card. The section header carries the ` Opencode Go` indent. + return `${m.label.padEnd(5)} ${bar} ${padPercent(w.usagePercent)}% · ${reset}`; + }); + + return { header, body }; +} diff --git a/src/quota/opencode-go/index.ts b/src/quota/opencode-go/index.ts new file mode 100644 index 0000000..ff5e7d5 --- /dev/null +++ b/src/quota/opencode-go/index.ts @@ -0,0 +1,113 @@ +/** + * Opencode Go usage orchestration — the entry point used by the + * `zcode-quota` CLI. + * + * Flow: credentials (env + config file) → cache check → fetch → redirect/auth + * check → parse → cache write. Any thrown error degrades to `unavailable` + * rather than propagating, so the CLI always produces output. + * + * A missing/invalid credential pair yields `not_configured`, which the + * combined view silently skips (vs. `unavailable`, which renders an error + * line) — so users who only care about GLM see no noise. + */ + +import { log } from "../../utils.js"; +import { getCached, setCached } from "./cache.js"; +import { ENV_AUTH_COOKIE, ENV_WORKSPACE_ID, readConfigFile } from "./config.js"; +import { fetchGoDashboard } from "./client.js"; +import { parseGoDashboard } from "./parse.js"; +import type { GoQueryResult } from "./types.js"; + +/** Format validators (match pi-go-bars conventions). */ +const RE_WORKSPACE = /^wrk_[A-Za-z0-9]+$/; +const COOKIE_PREFIX = "Fe26.2**"; + +/** + * Resolve & validate credentials. + * + * Environment variables take precedence over `~/.pi/agent/opencode-go.json`, + * merged field-by-field: env overrides the same field from the file, but a + * field present only in the file still counts. Returns `null` when the + * resolved pair is incomplete or malformed — `queryGoUsage` maps that to + * `not_configured`. + */ +function loadCredentials(): { workspaceId: string; authCookie: string } | null { + const file = readConfigFile(); + const workspaceId = process.env[ENV_WORKSPACE_ID] ?? file.workspaceId; + const authCookie = process.env[ENV_AUTH_COOKIE] ?? file.authCookie; + + if (!workspaceId || !authCookie) return null; + if (!RE_WORKSPACE.test(workspaceId)) { + log(`opencode-go: invalid workspaceId format (expected wrk_…)`); + return null; + } + if (!authCookie.startsWith(COOKIE_PREFIX)) { + log(`opencode-go: authCookie does not start with ${COOKIE_PREFIX}`); + return null; + } + return { workspaceId, authCookie }; +} + +/** + * Query the Opencode Go dashboard and return a normalised {@link GoQueryResult}. + * + * - No credentials / invalid → `not_configured`. + * - Serves a cached result when fresh (< 10s). + * - HTTP redirect to login (final URL no longer contains the workspace path) + * → `auth_error`. + * - Network/timeout/parse failure → `unavailable`. + */ +export async function queryGoUsage(): Promise { + const cached = getCached(); + if (cached) { + log("opencode-go: serving cached result"); + return cached; + } + + const creds = loadCredentials(); + if (!creds) return { kind: "not_configured" }; + + let result: GoQueryResult; + try { + const resp = await fetchGoDashboard(creds.workspaceId, creds.authCookie); + + // Redirect-to-login detection: an expired cookie silently bounces to the + // login page with a 200, so we check the final URL rather than the status. + if (!resp.finalUrl.includes(`/workspace/${creds.workspaceId}/go`)) { + result = { kind: "auth_error" }; + } else { + const parsed = parseGoDashboard(resp.text); + if (parsed.parserOutdated) { + log("opencode-go: dashboard HTML recognised but no windows parsed (parser outdated)"); + result = { kind: "unavailable" }; + } else if (!parsed.rolling && !parsed.weekly && !parsed.monthly) { + // Not a dashboard page at all (e.g. error page the redirect check missed). + result = { kind: "unavailable" }; + } else { + result = { + kind: "success", + // Rolling and weekly are the two windows the CLI shows by default; + // fall back to zeroes if somehow absent so the type stays simple. + rolling: parsed.rolling ?? { usagePercent: 0, resetInSec: 0 }, + weekly: parsed.weekly ?? { usagePercent: 0, resetInSec: 0 }, + monthly: parsed.monthly, + fetchedAt: Date.now(), + }; + } + } + } catch (e) { + log(`opencode-go: fetch failed (${e instanceof Error ? e.message : String(e)})`); + result = { kind: "unavailable" }; + } + + setCached(result); + return result; +} + +// Re-exports for consumers (CLI + tests). +export { clearCache, clearCache as clearGoCache } from "./cache.js"; +export { fetchGoDashboard, dashboardUrl } from "./client.js"; +export { parseGoDashboard, looksLikeDashboard } from "./parse.js"; +export { formatDuration, formatGoSection } from "./format.js"; +export { readConfigFile, CONFIG_PATH, ENV_WORKSPACE_ID, ENV_AUTH_COOKIE } from "./config.js"; +export type { GoQueryResult, GoWindow, GoWindowKey, GoDashboardResponse } from "./types.js"; diff --git a/src/quota/opencode-go/parse.ts b/src/quota/opencode-go/parse.ts new file mode 100644 index 0000000..5997591 --- /dev/null +++ b/src/quota/opencode-go/parse.ts @@ -0,0 +1,103 @@ +/** + * Opencode Go dashboard HTML parser. + * + * The dashboard is a SolidJS SSR page. Usage data is embedded as hydration + * assignments inside a `", + finalUrl: "https://opencode.ai/workspace/wrk_abc/go", + }); + + const combined = await queryCombined("all"); + expect(combined.glm.kind).toBe("success"); + expect(combined.go.kind).toBe("success"); + }); + + it("skips the Go fetch entirely in glm mode (no Go client call)", async () => { + process.env.OPENCODE_GO_WORKSPACE_ID = "wrk_abc"; + process.env.OPENCODE_GO_AUTH_COOKIE = "Fe26.2**x"; + glmFetchSpy.mockResolvedValue( + new Response(JSON.stringify({ success: true, data: { limits: [] } }), { status: 200 }), + ); + await queryCombined("glm"); + expect(mockedGoFetch).not.toHaveBeenCalled(); + }); + + it("skips the GLM fetch entirely in go mode", async () => { + process.env.OPENCODE_GO_WORKSPACE_ID = "wrk_abc"; + process.env.OPENCODE_GO_AUTH_COOKIE = "Fe26.2**x"; + mockedGoFetch.mockResolvedValue({ + status: 200, + text: "", + finalUrl: "https://opencode.ai/workspace/wrk_abc/go", + }); + await queryCombined("go"); + expect(glmFetchSpy).not.toHaveBeenCalled(); + }); + + it("returns not_configured Go without throwing when env is unset (all mode)", async () => { + delete process.env.OPENCODE_GO_WORKSPACE_ID; + glmFetchSpy.mockResolvedValue( + new Response(JSON.stringify({ success: true, data: { limits: [] } }), { status: 200 }), + ); + const combined = await queryCombined("all"); + expect(combined.go.kind).toBe("not_configured"); + }); +}); diff --git a/tests/extract-prompt.test.ts b/tests/extract-prompt.test.ts index f858954..cff23f9 100644 --- a/tests/extract-prompt.test.ts +++ b/tests/extract-prompt.test.ts @@ -116,11 +116,46 @@ describe("extractPromptText", () => { expect(out).toBe("embedded body"); }); - it("skips embedded resources without a text payload", () => { + it("rewrites a binary blob resource to a local file link", () => { + // Dragging a binary file (PDF/zip/…) arrives as BlobResourceContents. + // The base64 blob is never inlined — the uri becomes a readable link so + // a binary-only drop doesn't produce an empty prompt (which errored). const out = extractPromptText([ { type: "resource", - resource: { uri: "file:///x", blob: "aGVsbG8=" }, + resource: { uri: "file:///Users/william/report.pdf", blob: "aGVsbG8=" }, + }, + ] as never); + expect(out).toBe("[related resource: report.pdf](/Users/william/report.pdf)"); + }); + + it("decodes percent-encoded binary resource uris", () => { + const out = extractPromptText([ + { + type: "resource", + resource: { uri: "file:///tmp/my%20doc.pdf", blob: "AAAA" }, + }, + ] as never); + expect(out).toBe("[related resource: my doc.pdf](/tmp/my doc.pdf)"); + }); + + it("keeps non-file:// binary resource uris as-is", () => { + const out = extractPromptText([ + { + type: "resource", + resource: { uri: "https://example.com/file.zip", blob: "AAAA" }, + }, + ] as never); + expect(out).toBe("[related resource: file.zip](https://example.com/file.zip)"); + }); + + it("still skips a blob resource with no uri", () => { + // Defensive: the ACP schema requires uri, but a non-compliant client + // must not crash the prompt. + const out = extractPromptText([ + { + type: "resource", + resource: { blob: "AAAA" }, }, ] as never); expect(out).toBe(""); diff --git a/tests/opencode-go-client.test.ts b/tests/opencode-go-client.test.ts new file mode 100644 index 0000000..c1470e5 --- /dev/null +++ b/tests/opencode-go-client.test.ts @@ -0,0 +1,37 @@ +/** + * Header-shape test for the real Opencode Go HTTP client. + * + * Lives in its own file (no `vi.mock`) so `fetchGoDashboard` is the real + * implementation and we can assert on the exact request headers passed to + * global fetch. Other opencode-go tests mock the client module so they can + * control the (text, finalUrl) pair deterministically. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { fetchGoDashboard } from "../src/quota/opencode-go/client.js"; + +describe("fetchGoDashboard request headers", () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + afterEach(() => { + fetchSpy.mockReset(); + }); + + it("sends Cookie: auth= and a browser User-Agent", async () => { + fetchSpy.mockResolvedValue(new Response("ok", { status: 200 })); + + await fetchGoDashboard("wrk_x", "Fe26.2**secret"); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const call = fetchSpy.mock.calls[0]!; + const url = call[0]; + const init = (call[1] ?? {}) as RequestInit; + const headers = init.headers as Record; + + expect(String(url)).toBe("https://opencode.ai/workspace/wrk_x/go"); + expect(init.method).toBe("GET"); + expect(headers.Cookie).toBe("auth=Fe26.2**secret"); + expect(headers["User-Agent"]).toMatch(/Firefox\//); + }); +}); diff --git a/tests/opencode-go.test.ts b/tests/opencode-go.test.ts new file mode 100644 index 0000000..82bcbcb --- /dev/null +++ b/tests/opencode-go.test.ts @@ -0,0 +1,489 @@ +/** + * Tests for the Opencode Go usage feature: dashboard HTML parsing (both field + * orderings, missing windows, parser rot), the HTTP client (cookie/UA + * headers), query orchestration (env-driven credentials, cache TTL, error + * degradation), duration formatting, and section rendering. + * + * Parser/formatter tests are pure-function. The client test spies on global + * fetch. The orchestration test mocks the client module so we control the + * (finalUrl, html) pair deterministically — undici's Response does not honour + * the `url` init option, so mocking at the client boundary is cleaner than + * constructing real Response objects. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import path from "node:path"; +import process from "node:process"; + +// Mock the client so queryGoUsage orchestration can inject a deterministic +// (status, text, finalUrl) without depending on undici's Response.url. +vi.mock("../src/quota/opencode-go/client.js", () => ({ + fetchGoDashboard: vi.fn(), + dashboardUrl: (id: string) => `https://opencode.ai/workspace/${id}/go`, +})); + +// Compute the config path here (not via import) so the fs mock factory below +// can reference it without worrying about vitest mock-hoist ordering. This +// must match src/quota/opencode-go/config.ts::CONFIG_PATH exactly. +const CONFIG_PATH_MOCK = path.join( + process.env.HOME || process.env.USERPROFILE || "~", + ".pi", + "agent", + "opencode-go.json", +); + +// Mock node:fs so readConfigFile tests can supply a fake config file without +// touching the real ~/.pi/agent/opencode-go.json (which may exist on the dev +// machine). The config path is fully intercepted: a hit returns the mock +// content, a miss throws ENOENT — it never falls through to the real fs, so +// tests are hermetic regardless of the host environment. Other paths fall +// through unchanged. +const mockFiles = new Map(); +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + return { + ...actual, + readFileSync: (p: string, ...rest: unknown[]) => { + if (p === CONFIG_PATH_MOCK) { + if (mockFiles.has(p)) return mockFiles.get(p)!; + const err = new Error(`ENOENT, no such file or directory '${p}'`) as NodeJS.ErrnoException; + err.code = "ENOENT"; + throw err; + } + return actual.readFileSync(p, ...(rest as [string])); + }, + }; +}); + +import { formatDuration, formatGoSection } from "../src/quota/opencode-go/format.js"; +import { looksLikeDashboard, parseGoDashboard } from "../src/quota/opencode-go/parse.js"; +import type { GoQueryResult } from "../src/quota/opencode-go/types.js"; +import { clearCache, setClock } from "../src/quota/opencode-go/cache.js"; +import { fetchGoDashboard } from "../src/quota/opencode-go/client.js"; +import { CONFIG_PATH, readConfigFile } from "../src/quota/opencode-go/config.js"; +import { queryGoUsage } from "../src/quota/opencode-go/index.js"; + +// `fetchGoDashboard` is mocked (see vi.mock above) for orchestration tests. +// The real HTTP-client header test lives in tests/combined.test.ts, which does +// not mock the client module and so can spy on global fetch directly. +void fetchGoDashboard; + +// --- parser -------------------------------------------------------------- + +/** + * Build a synthetic dashboard ``; +} + +describe("parseGoDashboard", () => { + it("extracts all three windows (usagePercent-first ordering)", () => { + const html = dashboardHtml({ + rolling: { usagePercent: 42, resetInSec: 3600 }, + weekly: { usagePercent: 17, resetInSec: 604800 }, + monthly: { usagePercent: 8, resetInSec: 2592000 }, + }); + const r = parseGoDashboard(html); + expect(r.rolling).toEqual({ usagePercent: 42, resetInSec: 3600 }); + expect(r.weekly).toEqual({ usagePercent: 17, resetInSec: 604800 }); + expect(r.monthly).toEqual({ usagePercent: 8, resetInSec: 2592000 }); + expect(r.parserOutdated).toBe(false); + }); + + it("extracts windows when fields are in resetInSec-first order", () => { + // Solid may emit fields in either order; the regexes cover both. + const html = + ``; + const r = parseGoDashboard(html); + expect(r.rolling).toEqual({ usagePercent: 50, resetInSec: 7200 }); + }); + + it("returns nulls for absent windows (no parserOutdated when nothing looks like dashboard)", () => { + const r = parseGoDashboard("nothing here"); + expect(r.rolling).toBeNull(); + expect(r.weekly).toBeNull(); + expect(r.monthly).toBeNull(); + expect(r.parserOutdated).toBe(false); + }); + + it("flags parserOutdated when HTML looks like a dashboard but no window matched", () => { + // The variable names are present but the object shape is unrecognised — + // signals the SolidJS hydration format has drifted. + const html = ``; + const r = parseGoDashboard(html); + expect(r.rolling).toBeNull(); + expect(r.parserOutdated).toBe(true); + }); + + it("looksLikeDashboard detects the window variable names", () => { + expect(looksLikeDashboard("rollingUsage:$R[2]={}")).toBe(true); + expect(looksLikeDashboard("weeklyUsage:$R[3]={}")).toBe(true); + expect(looksLikeDashboard("monthlyUsage:$R[4]={}")).toBe(true); + expect(looksLikeDashboard("login page")).toBe(false); + }); +}); + +// --- formatDuration ------------------------------------------------------ + +describe("formatDuration", () => { + it("< 60s → <1m", () => { + expect(formatDuration(0)).toBe("<1m"); + expect(formatDuration(30)).toBe("<1m"); + expect(formatDuration(59.9)).toBe("<1m"); + }); + + it("minutes only", () => { + expect(formatDuration(60)).toBe("1m"); + expect(formatDuration(45 * 60)).toBe("45m"); + }); + + it("hours + minutes", () => { + expect(formatDuration(2 * 3600 + 30 * 60)).toBe("2h 30m"); + }); + + it("days + hours", () => { + expect(formatDuration(6 * 86_400 + 8 * 3600)).toBe("6d 8h"); + }); + + it("non-finite → <1m (defensive)", () => { + expect(formatDuration(Number.NaN)).toBe("<1m"); + expect(formatDuration(Number.POSITIVE_INFINITY)).toBe("<1m"); + }); +}); + +// --- formatGoSection ----------------------------------------------------- + +describe("formatGoSection", () => { + const success: GoQueryResult = { + kind: "success", + rolling: { usagePercent: 42, resetInSec: 3600 }, + weekly: { usagePercent: 17, resetInSec: 604800 }, + monthly: { usagePercent: 8, resetInSec: 2592000 }, + fetchedAt: 1000, + }; + + it("renders all three windows when requested", () => { + const sec = formatGoSection(success, ["rolling", "weekly", "monthly"], 1000); + expect(sec.header).toBe("Opencode Go"); + expect(sec.body).toHaveLength(3); + expect(sec.body[0]).toContain("5h"); + expect(sec.body[0]).toContain("42%"); + // Reset time renders as an absolute MM-DD HH:MM stamp (same layout as GLM). + expect(sec.body[0]).toMatch(/\d{2}-\d{2} \d{2}:\d{2}/); + expect(sec.body[1]).toContain("Week"); + expect(sec.body[1]).toContain("17%"); + expect(sec.body[2]).toContain("Month"); + expect(sec.body[2]).toContain("8%"); + }); + + it("renders only requested windows (rolling + weekly)", () => { + const sec = formatGoSection(success, ["rolling", "weekly"], 1000); + expect(sec.body).toHaveLength(2); + expect(sec.body.find((l) => l.includes("Month"))).toBeUndefined(); + }); + + it("shows the reset time advancing as elapsed time grows (live ticker)", () => { + // Reset stamp = fetchedAt + remainingSec*1000. As `now` advances, remaining + // shrinks, so the stamp moves earlier. The rolling window (resetInSec=3600) + // at now=1000 → resets at fetchedAt+3600s; at now=31000 → fetchedAt+3570s. + const early = formatGoSection(success, ["rolling"], 1000).body[0]!; + const later = formatGoSection(success, ["rolling"], 31_000).body[0]!; + // Both must be valid MM-DD HH:MM stamps. + expect(early).toMatch(/\d{2}-\d{2} \d{2}:\d{2}/); + expect(later).toMatch(/\d{2}-\d{2} \d{2}:\d{2}/); + // The later fetch's reset is ~30s sooner (3570s vs 3600s of remaining). + expect(later).not.toBe(early); + }); + + it("clamps the remaining time at 0 (reset stamp stays at fetchedAt, never negative)", () => { + // When elapsed far exceeds resetInSec, remaining is clamped to 0 → the + // reset stamp equals fetchedAt (1ms into epoch). It must not throw and must + // still render a valid-looking stamp or the "<1m" fallback. + const sec = formatGoSection(success, ["rolling"], 1000 + 10_000_000); + expect(sec.body[0]).toMatch(/(\d{2}-\d{2} \d{2}:\d{2}|<1m)/); + }); + + it("renders '(no data)' when a requested window is null", () => { + const noMonthly: GoQueryResult = { ...success, monthly: null }; + const sec = formatGoSection(noMonthly, ["rolling", "weekly", "monthly"], 1000); + expect(sec.body.find((l) => l.includes("Month"))?.includes("(no data)")).toBe(true); + }); + + it("non-success kinds render a single explanation line", () => { + expect(formatGoSection({ kind: "not_configured" }, ["rolling"]).body[0]).toMatch( + /not configured/i, + ); + expect(formatGoSection({ kind: "auth_error" }, ["rolling"]).body[0]).toMatch(/auth expired/i); + expect(formatGoSection({ kind: "unavailable" }, ["rolling"]).body[0]).toMatch(/unavailable/i); + }); + + describe("color mode", () => { + const ESC = String.fromCharCode(27); + const stripAnsi = (s: string): string => s.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), ""); + + it("emits ANSI escapes and overlays NN% inside the bar; reset stays on the right", () => { + const sec = formatGoSection(success, ["rolling", "weekly"], 1000, true); + const rolling = sec.body[0]!; + expect(rolling).toContain(`${ESC}[48;2;`); // bg color + expect(rolling).toContain(`${ESC}[0m`); // reset + // Overlay percent is inside the bar; visible right margin keeps reset only. + const visible = stripAnsi(rolling); + expect(visible).toContain("42%"); + expect(visible).toMatch(/\d{2}-\d{2} \d{2}:\d{2}/); // reset stamp + // Color mode renders the bar with ANSI bg on space cells, NOT with the + // plain █/░ block characters — so they must be absent. + expect(visible).not.toContain("█"); + expect(visible).not.toContain("░"); + }); + + it("color=false keeps the classic plain layout (no ANSI)", () => { + const sec = formatGoSection(success, ["rolling"], 1000, false); + const line = sec.body[0]!; + expect(line).not.toContain("\x1b["); + expect(line).toMatch(/5h\s+█+░*\s+42%/); + }); + }); +}); + +// --- queryGoUsage orchestration ------------------------------------------ + +// The client module is mocked at the top of this file. We drive queryGoUsage +// by controlling fetchGoDashboard's return/reject per-test, which lets us feed +// a deterministic finalUrl (the basis for redirect-to-login detection). +const mockedFetch = vi.mocked(fetchGoDashboard); + +describe("queryGoUsage orchestration", () => { + beforeEach(() => { + clearCache(); + setClock(() => 5000); + mockedFetch.mockReset(); + mockFiles.clear(); + }); + afterEach(() => { + clearCache(); + setClock(undefined); + delete process.env.OPENCODE_GO_WORKSPACE_ID; + delete process.env.OPENCODE_GO_AUTH_COOKIE; + mockFiles.clear(); + }); + + it("returns not_configured when env vars are absent", async () => { + delete process.env.OPENCODE_GO_WORKSPACE_ID; + delete process.env.OPENCODE_GO_AUTH_COOKIE; + expect((await queryGoUsage()).kind).toBe("not_configured"); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + it("returns not_configured when workspaceId format is invalid", async () => { + process.env.OPENCODE_GO_WORKSPACE_ID = "bad-id"; + process.env.OPENCODE_GO_AUTH_COOKIE = "Fe26.2**x"; + expect((await queryGoUsage()).kind).toBe("not_configured"); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + it("returns not_configured when cookie prefix is wrong", async () => { + process.env.OPENCODE_GO_WORKSPACE_ID = "wrk_abc"; + process.env.OPENCODE_GO_AUTH_COOKIE = "not-the-right-prefix"; + expect((await queryGoUsage()).kind).toBe("not_configured"); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + it("parses a successful dashboard response", async () => { + process.env.OPENCODE_GO_WORKSPACE_ID = "wrk_abc"; + process.env.OPENCODE_GO_AUTH_COOKIE = "Fe26.2**secret"; + mockedFetch.mockResolvedValue({ + status: 200, + text: dashboardHtml({ + rolling: { usagePercent: 42, resetInSec: 3600 }, + weekly: { usagePercent: 17, resetInSec: 604800 }, + monthly: { usagePercent: 8, resetInSec: 2592000 }, + }), + finalUrl: "https://opencode.ai/workspace/wrk_abc/go", + }); + const result = await queryGoUsage(); + expect(result.kind).toBe("success"); + if (result.kind !== "success") return; + expect(result.rolling.usagePercent).toBe(42); + expect(result.weekly.usagePercent).toBe(17); + expect(result.monthly?.usagePercent).toBe(8); + }); + + it("detects redirect-to-login as auth_error (final URL changed)", async () => { + process.env.OPENCODE_GO_WORKSPACE_ID = "wrk_abc"; + process.env.OPENCODE_GO_AUTH_COOKIE = "Fe26.2**expired"; + // opencode.ai bounces expired cookies to /login with a 200. + mockedFetch.mockResolvedValue({ + status: 200, + text: "please log in", + finalUrl: "https://opencode.ai/login", + }); + expect((await queryGoUsage()).kind).toBe("auth_error"); + }); + + it("degrades to unavailable on network failure", async () => { + process.env.OPENCODE_GO_WORKSPACE_ID = "wrk_abc"; + process.env.OPENCODE_GO_AUTH_COOKIE = "Fe26.2**secret"; + mockedFetch.mockRejectedValue(new Error("network down")); + expect((await queryGoUsage()).kind).toBe("unavailable"); + }); + + it("degrades to unavailable on parser rot (dashboard but no windows)", async () => { + process.env.OPENCODE_GO_WORKSPACE_ID = "wrk_abc"; + process.env.OPENCODE_GO_AUTH_COOKIE = "Fe26.2**secret"; + mockedFetch.mockResolvedValue({ + status: 200, + text: "", + finalUrl: "https://opencode.ai/workspace/wrk_abc/go", + }); + expect((await queryGoUsage()).kind).toBe("unavailable"); + }); + + it("serves a cached result within the TTL window", async () => { + process.env.OPENCODE_GO_WORKSPACE_ID = "wrk_abc"; + process.env.OPENCODE_GO_AUTH_COOKIE = "Fe26.2**secret"; + mockedFetch.mockResolvedValue({ + status: 200, + text: dashboardHtml({ rolling: { usagePercent: 1, resetInSec: 1 } }), + finalUrl: "https://opencode.ai/workspace/wrk_abc/go", + }); + await queryGoUsage(); + expect(mockedFetch).toHaveBeenCalledTimes(1); + setClock(() => 5000 + 9_000); // 9s later — still fresh + await queryGoUsage(); + expect(mockedFetch).toHaveBeenCalledTimes(1); // cached — no new fetch + }); + + it("re-fetches once the TTL expires", async () => { + process.env.OPENCODE_GO_WORKSPACE_ID = "wrk_abc"; + process.env.OPENCODE_GO_AUTH_COOKIE = "Fe26.2**secret"; + mockedFetch.mockResolvedValue({ + status: 200, + text: dashboardHtml({ rolling: { usagePercent: 1, resetInSec: 1 } }), + finalUrl: "https://opencode.ai/workspace/wrk_abc/go", + }); + await queryGoUsage(); + setClock(() => 5000 + 10_001); // expired + await queryGoUsage(); + expect(mockedFetch).toHaveBeenCalledTimes(2); + }); +}); + +// --- readConfigFile (mocked fs) ------------------------------------------ + +describe("readConfigFile", () => { + afterEach(() => mockFiles.clear()); + + it("parses a valid {workspaceId, authCookie} JSON file", () => { + mockFiles.set(CONFIG_PATH, JSON.stringify({ workspaceId: "wrk_x", authCookie: "Fe26.2**y" })); + expect(readConfigFile()).toEqual({ workspaceId: "wrk_x", authCookie: "Fe26.2**y" }); + }); + + it("returns empty object when the file is missing (ENOENT — silent)", () => { + mockFiles.clear(); + expect(readConfigFile()).toEqual({}); + }); + + it("returns empty object on invalid JSON (logged, non-fatal)", () => { + mockFiles.set(CONFIG_PATH, "{not valid json"); + expect(readConfigFile()).toEqual({}); + }); + + it("ignores non-string / unknown fields", () => { + mockFiles.set( + CONFIG_PATH, + JSON.stringify({ workspaceId: "wrk_x", authCookie: 123, extra: "ignored" }), + ); + // authCookie is a number → treated as absent. + expect(readConfigFile()).toEqual({ workspaceId: "wrk_x", authCookie: undefined }); + }); + + it("rejects a top-level non-object (array / primitive)", () => { + mockFiles.set(CONFIG_PATH, JSON.stringify(["nope"])); + expect(readConfigFile()).toEqual({}); + mockFiles.set(CONFIG_PATH, JSON.stringify("nope")); + expect(readConfigFile()).toEqual({}); + }); +}); + +// --- queryGoUsage credential merging (env + config file) ----------------- + +describe("queryGoUsage credential merging", () => { + beforeEach(() => { + clearCache(); + setClock(() => 5000); + mockedFetch.mockReset(); + mockFiles.clear(); + // Dynamic mock: finalUrl must contain the workspaceId passed in, or the + // orchestrator's redirect-to-login check will misfire. + mockedFetch.mockImplementation(async (workspaceId: string) => ({ + status: 200, + text: dashboardHtml({ rolling: { usagePercent: 1, resetInSec: 1 } }), + finalUrl: `https://opencode.ai/workspace/${workspaceId}/go`, + })); + }); + afterEach(() => { + clearCache(); + setClock(undefined); + delete process.env.OPENCODE_GO_WORKSPACE_ID; + delete process.env.OPENCODE_GO_AUTH_COOKIE; + mockFiles.clear(); + }); + + it("uses the config file when env is absent", async () => { + mockFiles.set( + CONFIG_PATH, + JSON.stringify({ workspaceId: "wrk_FILE0", authCookie: "Fe26.2**file" }), + ); + const result = await queryGoUsage(); + expect(result.kind).toBe("success"); + // The fetch is called with the workspaceId from the file. + expect(mockedFetch).toHaveBeenCalledWith("wrk_FILE0", "Fe26.2**file"); + }); + + it("env overrides the file field-by-field", async () => { + mockFiles.set( + CONFIG_PATH, + JSON.stringify({ workspaceId: "wrk_FILE0", authCookie: "Fe26.2**file" }), + ); + process.env.OPENCODE_GO_WORKSPACE_ID = "wrk_ENV0"; // override only workspaceId + const result = await queryGoUsage(); + expect(result.kind).toBe("success"); + expect(mockedFetch).toHaveBeenCalledWith("wrk_ENV0", "Fe26.2**file"); + }); + + it("env fills a field the file lacks", async () => { + mockFiles.set(CONFIG_PATH, JSON.stringify({ workspaceId: "wrk_FILE0" })); // no cookie + process.env.OPENCODE_GO_AUTH_COOKIE = "Fe26.2**env"; // provide the cookie + const result = await queryGoUsage(); + expect(result.kind).toBe("success"); + expect(mockedFetch).toHaveBeenCalledWith("wrk_FILE0", "Fe26.2**env"); + }); + + it("returns not_configured when neither env nor file supplies both fields", async () => { + // File has only workspaceId; no env. Both fields incomplete. + mockFiles.set(CONFIG_PATH, JSON.stringify({ workspaceId: "wrk_FILE0" })); + expect((await queryGoUsage()).kind).toBe("not_configured"); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + it("returns not_configured when the file is corrupt and env is absent", async () => { + mockFiles.set(CONFIG_PATH, "{broken"); + expect((await queryGoUsage()).kind).toBe("not_configured"); + expect(mockedFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/quota.test.ts b/tests/quota.test.ts index f3d5fd7..976f1fb 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -21,7 +21,7 @@ vi.mock("../src/backend/credentials.js", () => ({ })); import { clearCache, getCached, setCached, setClock } from "../src/quota/cache.js"; -import { formatQuota, formatQuotaPlain, renderBar } from "../src/quota/format.js"; +import { formatQuota, formatQuotaPlain, renderBar, renderGlmSection } from "../src/quota/format.js"; import { parseLimit, parseQuotaEnvelope } from "../src/quota/parse.js"; import type { QuotaResult } from "../src/quota/types.js"; import { resolveQuotaHost } from "../src/quota/client.js"; @@ -230,11 +230,14 @@ describe("formatQuota", () => { expect(lines[3]).toContain("5h"); expect(lines[3]).toContain("5%"); expect(lines[3]).not.toContain("resets"); - expect(lines[3]).not.toMatch(/\(\d+\/\d+\)/); - // MCP line: percent + absolute counts + reset time. + expect(lines[3]).not.toMatch(/\/\d+/); // no (used/total) on counter-less items + // MCP line: percent + used counter + reset time (total omitted — it's a + // fixed allowance already conveyed by the percentage bar). expect(lines[4]).toContain("MCP"); expect(lines[4]).toContain("24%"); - expect(lines[4]).toContain("(237/1000)"); + expect(lines[4]).toContain("237"); + expect(lines[4]).not.toMatch(/\/\d+/); + expect(lines[4]).not.toContain("used"); expect(lines[4]).not.toContain("resets"); // Detail branches (now padded model codes). expect(lines[5]).toMatch(/├ search-prime\s+\d+/); @@ -248,7 +251,9 @@ describe("formatQuota", () => { level: "pro", items: [{ key: "token_5h", label: "5h", usedPercent: 18, leftPercent: 82 }], }); - expect(out).not.toMatch(/\(\d+\/\d+\)/); + // The only trailing annotation on a counter-less item is the reset time + // (MM-DD HH:MM) — no bare ` · N` used counter. + expect(out).not.toMatch(/ · \d+$/m); }); it("renders auth_error / rate_limited / unavailable fallbacks", () => { @@ -323,6 +328,145 @@ describe("formatQuotaPlain", () => { }); }); +describe("renderGlmSection", () => { + it("returns header + body lines for a success result (no fence, no divider)", () => { + const result: QuotaResult = { + kind: "success", + level: "pro", + items: [ + { key: "token_5h", label: "5h", usedPercent: 5, leftPercent: 95 }, + { + key: "mcp", + label: "MCP", + usedPercent: 24, + leftPercent: 76, + detail: [{ modelCode: "search-prime", usage: 169 }], + }, + ], + }; + const sec = renderGlmSection(result); + expect(sec.header).toBe("GLM Coding Plan · Pro"); + expect(sec.body[0]).toContain("5h"); + expect(sec.body.length).toBeGreaterThanOrEqual(2); + // Body must NOT include a fence or divider — those are formatQuota's job. + expect(sec.body.join("\n")).not.toContain("```"); + expect(sec.body.join("\n")).not.toMatch(/^─+$/m); + }); + + it("returns the fallback message as the body for non-success kinds", () => { + expect(renderGlmSection({ kind: "auth_error" }).body[0]).toMatch(/auth expired/i); + expect(renderGlmSection({ kind: "unavailable" }).body[0]).toMatch(/unavailable/i); + expect(renderGlmSection({ kind: "rate_limited" }).body[0]).toMatch(/busy/i); + }); + + it("respects the detail flag (omits sub-lines when false)", () => { + const result: QuotaResult = { + kind: "success", + level: "pro", + items: [ + { + key: "mcp", + label: "MCP", + usedPercent: 24, + leftPercent: 76, + detail: [{ modelCode: "search-prime", usage: 169 }], + }, + ], + }; + expect(renderGlmSection(result, { detail: false }).body.join("\n")).not.toContain( + "search-prime", + ); + expect(renderGlmSection(result, { detail: true }).body.join("\n")).toContain("search-prime"); + }); + + describe("color mode (opts.color)", () => { + const RESULT: QuotaResult = { + kind: "success", + level: "pro", + items: [ + { + key: "token_5h", + label: "5h", + usedPercent: 73, + leftPercent: 27, + nextResetTime: 1783436462284, + }, + { + key: "mcp", + label: "MCP", + usedPercent: 14, + leftPercent: 86, + usedCount: 237, + totalCount: 1000, + nextResetTime: 1784166659961, + }, + ], + }; + // ESC via charCode so regex avoid literal control chars (no-control-regex). + const ESC = String.fromCharCode(27); + const stripAnsi = (s: string): string => s.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), ""); + + it("emits 24-bit ANSI escapes on bar lines when color is true", () => { + const sec = renderGlmSection(RESULT, { color: true }); + const body = sec.body.join("\n"); + expect(body).toContain(`${ESC}[48;2;`); // bg color escape + expect(body).toContain(`${ESC}[38;2;`); // fg color escape + expect(body).toContain(`${ESC}[0m`); // reset + }); + + it("overlays used/total inside the MCP bar and drops it from the margin", () => { + const sec = renderGlmSection(RESULT, { color: true }); + const mcpLine = sec.body.find((l) => l.includes("MCP"))!; + // The overlay characters are interleaved with ANSI escapes per cell, so + // strip escapes first to read the visible bar text. + const visible = stripAnsi(mcpLine); + // The used/total counter rides inside the colored bar. + expect(visible).toContain("237/1000"); + // The right margin must not repeat the percent or the bare used counter. + expect(visible).not.toMatch(/\b14%/); // no right-margin percent + expect(visible).not.toMatch(/ · 237(?!\d)/); // no bare ` · 237` counter + }); + + it("overlays NN% inside the 5h bar (no counters) and keeps the reset time", () => { + const sec = renderGlmSection(RESULT, { color: true }); + const fiveLine = sec.body.find((l) => l.includes("5h"))!; + const visible = stripAnsi(fiveLine); + expect(visible).toContain("73%"); + expect(visible).toMatch(/\d{2}-\d{2} \d{2}:\d{2}/); // reset stamp present + // Color mode renders the bar with ANSI bg on space cells, not █/░. + expect(visible).not.toContain("█"); + expect(visible).not.toContain("░"); + }); + + it("color=true leaves plain (default) output untouched", () => { + // Sanity: default renderGlmSection has no ANSI escapes. + const plain = renderGlmSection(RESULT).body.join("\n"); + expect(plain).not.toContain(ESC); + }); + + it("color mode still renders detail sub-lines when detail is true", () => { + const sec = renderGlmSection( + { + kind: "success", + level: "pro", + items: [ + { + key: "mcp", + label: "MCP", + usedPercent: 24, + leftPercent: 76, + detail: [{ modelCode: "search-prime", usage: 169 }], + }, + ], + }, + { color: true, detail: true }, + ); + expect(sec.body.join("\n")).toContain("search-prime"); + expect(sec.body.join("\n")).toMatch(/[├└]/); + }); + }); +}); + describe("cache", () => { beforeEach(() => { clearCache();