From e7c4355e539540c04f0d222de6ab0fc5825eaad2 Mon Sep 17 00:00:00 2001 From: William Wang Date: Mon, 3 Aug 2026 20:39:49 +0800 Subject: [PATCH 1/8] fix: cancel sends session/stop immediately instead of deferring to turn loop Plus thinking-phase feedback hint and usage_update session id fix. --- src/handlers/dispatch.ts | 6 ++- src/handlers/session.ts | 94 +++++++++++++++++++++++++++++++++------- 2 files changed, 83 insertions(+), 17 deletions(-) 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..45014ca 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -667,10 +667,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, @@ -681,6 +695,10 @@ export async function cancel( for (const [, turn] of server.pendingTurns) { if (turn.zcodeSid === zcodeSid) { turn.cancelled = true; + if (!turn.stopSent) { + stopBackendTurn(server, zcodeSid); + turn.stopSent = true; + } break; // one turn per session at a time } } @@ -769,14 +787,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 +811,11 @@ 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; + } break; } } @@ -798,9 +823,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)) { @@ -1034,6 +1060,16 @@ async function runEventTurn( 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 +1105,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 @@ -1105,6 +1161,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, From 012d4c662ac155cf8cf141edc4933ddddd166842 Mon Sep 17 00:00:00 2001 From: William Wang Date: Tue, 4 Aug 2026 13:53:43 +0800 Subject: [PATCH 2/8] fix: fast-fail prompts stalled in backend's cancel recovery window --- src/handlers/session.ts | 21 +++++++++++++++++++++ src/server.ts | 7 +++++++ 2 files changed, 28 insertions(+) diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 45014ca..e8b6ae4 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -699,6 +699,9 @@ export async function cancel( 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()); break; // one turn per session at a time } } @@ -816,6 +819,8 @@ async function preemptInFlightTurn( stopBackendTurn(server, zcodeSid); turn.stopSent = true; } + // Record cancel time (same recovery-window rationale as cancel()). + server.lastCancelledAt.set(zcodeSid, Date.now()); break; } } @@ -1056,6 +1061,10 @@ 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; @@ -1152,6 +1161,18 @@ async function runEventTurn( return { stopReason: "end_turn" }; } 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 and end the + // turn so the user can retry once the window passes. + 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); + return { stopReason: "end_turn" }; + } lastProgress = Date.now(); await listener.resubscribe(() => server.nextId()); } diff --git a/src/server.ts b/src/server.ts index 2c57cc4..ac89809 100644 --- a/src/server.ts +++ b/src/server.ts @@ -90,6 +90,13 @@ export class ZcodeAcpServer { readonly titleEligibleSessions = new Set(); /** Last mode id advertised to the client (acp_sid → modeId), for change detection. */ readonly lastMode = new Map(); + /** + * Timestamp of the last cancel (user stop or preempt), keyed by zcodeSid. + * Set in cancel() and preemptInFlightTurn(); read in runEventTurn's stall + * reconciliation to fast-fail turns that collide with the backend's + * ~20s model-connection recovery window after a mid-stream abort. + */ + readonly lastCancelledAt = new Map(); /** Per-session ProjectionDiffers (persists across turns). */ readonly differs = new Map< string, From 3064dc3938dbd803c664639dd7c4abad6081c56f Mon Sep 17 00:00:00 2001 From: William Wang Date: Tue, 4 Aug 2026 14:04:52 +0800 Subject: [PATCH 3/8] refactor: cancel all session turns and add silent drain during backend recovery --- src/handlers/session.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/handlers/session.ts b/src/handlers/session.ts index e8b6ae4..16ddc33 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -692,6 +692,11 @@ 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; @@ -702,7 +707,6 @@ export async function cancel( // 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()); - break; // one turn per session at a time } } log(`session/cancel → ${zcodeSid}`); @@ -1165,13 +1169,21 @@ async function runEventTurn( // 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 and end the - // turn so the user can retry once the window passes. + // 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); - return { stopReason: "end_turn" }; + 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()); @@ -1198,7 +1210,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 From 209840ab28882dec362fecfa2b30098b7cb8d005 Mon Sep 17 00:00:00 2001 From: William Wang Date: Tue, 4 Aug 2026 19:22:52 +0800 Subject: [PATCH 4/8] fix: block messaging during auto-compact by restoring await --- src/backend/listener.ts | 13 ++++++++- src/config/auto-compact.ts | 53 +++++++++++++++++++------------------ src/handlers/session.ts | 54 +++++++++++++++++++++++++++++--------- src/server.ts | 7 +++++ 4 files changed, 87 insertions(+), 40 deletions(-) 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/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/session.ts b/src/handlers/session.ts index 16ddc33..9bc7e02 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); } @@ -1151,18 +1153,44 @@ 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" }; + } + // 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()); } - return { stopReason: "end_turn" }; + continue; } if (proj?.status === "running") { // Cancel-recovery fast-fail: if this session was cancelled recently diff --git a/src/server.ts b/src/server.ts index ac89809..5d499f4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -33,6 +33,13 @@ export interface PendingTurn { cancelled: boolean; /** Set once session/stop has been fired for this turn, to avoid re-sending. */ stopSent?: boolean; + /** + * Set when the turn was ended by the stall-recovery heuristic (backend + * reported idle after a silence) rather than a real turn.completed event. + * prompt() skips auto-compact for such turns — the completion was inferred, + * and compressing an in-flight task's context would destroy the work. + */ + stallRecovered?: boolean; } export class ZcodeAcpServer { From acd6b45b3b6a8f4d08ede8f47f01462c765631fd Mon Sep 17 00:00:00 2001 From: William Wang Date: Wed, 5 Aug 2026 15:40:06 +0800 Subject: [PATCH 5/8] fix: rewrite binary drag-drop resources to local file links instead of erroring --- src/handlers/session.ts | 21 +++++++++++++------ tests/extract-prompt.test.ts | 39 ++++++++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 9bc7e02..4a70c79 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -866,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); @@ -880,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(); 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(""); From 3bb68d8ee0284613813dd0b3e006576530b6c3f6 Mon Sep 17 00:00:00 2001 From: William Wang Date: Wed, 5 Aug 2026 16:52:24 +0800 Subject: [PATCH 6/8] feat: add Opencode Go quota to zcode-quota CLI with combined dual-provider view Add Opencode Go subscription usage scraping (dashboard HTML parse via SSR hydration regex) alongside the existing GLM Coding Plan query. Default mode shows both providers in one card; `zcode-quota glm`/`go` subcommands focus on one. Go credentials resolve from env vars with ~/.pi/agent/opencode-go.json fallback. Watch-mode countdown now rides on the first section header line. --- README.md | 48 +++- README.zh-CN.md | 36 ++- src/bin/quota.ts | 176 ++++++++---- src/quota/combined.ts | 204 ++++++++++++++ src/quota/format.ts | 114 ++++++-- src/quota/opencode-go/cache.ts | 38 +++ src/quota/opencode-go/client.ts | 56 ++++ src/quota/opencode-go/config.ts | 72 +++++ src/quota/opencode-go/format.ts | 92 ++++++ src/quota/opencode-go/index.ts | 113 ++++++++ src/quota/opencode-go/parse.ts | 103 +++++++ src/quota/opencode-go/types.ts | 49 ++++ tests/cli-quota.test.ts | 25 ++ tests/combined.test.ts | 269 ++++++++++++++++++ tests/opencode-go-client.test.ts | 37 +++ tests/opencode-go.test.ts | 462 +++++++++++++++++++++++++++++++ tests/quota.test.ts | 67 ++++- 17 files changed, 1879 insertions(+), 82 deletions(-) create mode 100644 src/quota/combined.ts create mode 100644 src/quota/opencode-go/cache.ts create mode 100644 src/quota/opencode-go/client.ts create mode 100644 src/quota/opencode-go/config.ts create mode 100644 src/quota/opencode-go/format.ts create mode 100644 src/quota/opencode-go/index.ts create mode 100644 src/quota/opencode-go/parse.ts create mode 100644 src/quota/opencode-go/types.ts create mode 100644 tests/combined.test.ts create mode 100644 tests/opencode-go-client.test.ts create mode 100644 tests/opencode-go.test.ts diff --git a/README.md b/README.md index af1352f..8124d67 100644 --- a/README.md +++ b/README.md @@ -91,15 +91,25 @@ 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 @@ -116,6 +126,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..8485b0e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -90,15 +90,23 @@ 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 @@ -114,6 +122,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/bin/quota.ts b/src/bin/quota.ts index fee7e65..2bf167e 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,40 @@ 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; } /** 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. -Query GLM Coding Plan usage from the terminal. Reads credentials from -~/.zcode/v2/config.json (created by the ZCode app) — no server needed. +Providers: + (none) Both GLM + Opencode Go (Go: rolling + weekly). + glm GLM Coding Plan only. + go Opencode Go only (rolling + weekly + monthly). + +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). -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 + 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`; /** * Clamp a raw interval (seconds, optional) to a valid ms value. Returns the @@ -83,13 +110,17 @@ 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 interval: number | undefined; + let provider: Provider = "all"; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -124,20 +155,25 @@ 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, + provider, + intervalMs: resolved.ms, + intervalClamped: resolved.clamped, + }; } /** @@ -162,30 +198,46 @@ 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`; } /** - * 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 appended to the first section header. */ -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, + refresh?: string, +): string { + return formatCombinedCardPlain(combined, { + provider, + glm: { detail }, + goWindows: defaultGoWindows(provider), + refreshSuffix: refresh, + }); +} + +/** + * Full redraw of one watch frame: clear screen, then the card (with the + * refresh countdown on the first header line, counting down from intervalSec). + */ +function renderFrame(plain: string): string { + return `${ANSI.clearScreen}${plain}`; } /** - * 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): Promise { const intervalSec = Math.round(intervalMs / 1000); const controller = new AbortController(); const restore = (): void => { @@ -203,19 +255,24 @@ 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). + clearAllCaches(); // bypass caches — always show live values + const combined = await queryCombined(provider); + // Full redraw with the countdown starting at the interval max. process.stdout.write( - renderFrame(formatQuotaPlain(result, { detail }), updatedAt, intervalSec), + renderFrame(renderCard(combined, provider, detail, refreshSuffix(intervalSec))), ); - // Countdown: each second rewrite only the footer line, leaving the card - // untouched. \r returns to column 0; \x1B[2K clears the line. + // Per-second countdown: rewrite only the first line (the header with the + // countdown suffix), leaving the card body untouched. Re-rendering the + // whole card each second would be wasteful; instead we re-render just to + // grab line 1, then blast it to row 1 via cursor-home + 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 firstLine = renderCard(combined, provider, detail, refreshSuffix(remaining)).split( + "\n", + 1, + )[0]!; + process.stdout.write(`\x1B[H${ANSI.clearLine}${firstLine}`); } } } finally { @@ -224,14 +281,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): Promise { + const combined = await queryCombined(provider); + const out = renderCard(combined, provider, detail); + + // 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 { @@ -247,9 +321,9 @@ async function main(): Promise { } if (opts.watch) { - await runWatch(opts.intervalMs, opts.detail); + await runWatch(opts.intervalMs, opts.provider, opts.detail); } else { - await runOnce(opts.detail); + await runOnce(opts.provider, opts.detail); } } diff --git a/src/quota/combined.ts b/src/quota/combined.ts new file mode 100644 index 0000000..492cd6f --- /dev/null +++ b/src/quota/combined.ts @@ -0,0 +1,204 @@ +/** + * 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, per provider selection: + * - `all` / `glm` mode → rolling + weekly (the user's requested default) + * - `go` mode → rolling + weekly + monthly (full detail) + */ +export function defaultGoWindows(provider: Provider): GoWindowKey[] { + return provider === "go" ? ["rolling", "weekly", "monthly"] : ["rolling", "weekly"]; +} + +/** + * 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[]; + /** 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.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[]; + refreshSuffix?: string; + } = { provider: "all" }, +): string { + return renderCombinedLines( + combined, + opts.provider, + opts.glm, + opts.goWindows, + 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. + */ +function renderCombinedLines( + combined: CombinedResult, + provider: Provider, + glmOpts?: FormatOptions, + goWindows?: GoWindowKey[], + /** Optional trailing annotation appended to the first section header. */ + refreshSuffix?: string, +): string[] { + const { glm, go } = combined; + const suffix = refreshSuffix ? ` · ${refreshSuffix}` : ""; + + // 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") { + return renderSingleGlm(glm, glmOpts, suffix); + } + // Single-provider Go: header + body, no banner/divider. + if (provider === "go") { + const section = formatGoSection(go, goWindows ?? defaultGoWindows("go")); + return [`${section.header}${suffix}`, ...section.body]; + } + + // 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, glmOpts); + const showGo = shouldShowGo("all", go); + const goSection = showGo ? formatGoSection(go, goWindows ?? defaultGoWindows("all")) : null; + + const hasGlm = glmSection.body.length > 0; + const hasGo = !!goSection && goSection.body.length > 0; + + const sections: string[][] = []; + // The refresh suffix rides on whichever section renders FIRST — GLM if it + // has data, otherwise Go. + if (hasGlm) { + sections.push([` ${glmSection.header}${suffix}`, ...glmSection.body]); + } + if (hasGo) { + const goSuffix = hasGlm ? "" : suffix; // GLM absent → suffix on Go header + sections.push([` ${goSection!.header}${goSuffix}`, ...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. + // Sections are separated by a single blank line. + const body: string[] = []; + sections.forEach((sec, i) => { + if (i > 0) body.push(""); + body.push(...sec); + }); + return body; +} + +/** + * Render a single GLM provider as header + divider + body (the classic card, + * minus the fence). Used for `zcode-quota glm`. The optional suffix is appended + * to the header line (watch-mode countdown). + */ +function renderSingleGlm(result: QuotaResult, opts?: FormatOptions, suffix = ""): 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}${suffix}`, divider, ...section.body]; +} diff --git a/src/quota/format.ts b/src/quota/format.ts index 25753d2..abc324d 100644 --- a/src/quota/format.ts +++ b/src/quota/format.ts @@ -45,8 +45,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 +71,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,16 +79,11 @@ 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(" · ")}` : ""; @@ -97,14 +98,20 @@ 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. */ export interface FormatOptions { detail?: boolean; + compact?: 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 } { + return { detail: opts?.detail ?? true, compact: opts?.compact ?? false }; } /** Render one quota item line (+ indented detail sub-lines if present). */ @@ -133,6 +140,75 @@ 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 } = 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)[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)[0]!); + } + return { header: title, body }; + } + + const body = result.items.flatMap((item) => formatItem(item, detail)); + 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 +227,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..5f0377c --- /dev/null +++ b/src/quota/opencode-go/format.ts @@ -0,0 +1,92 @@ +/** + * 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 { 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: "Mon" }, +]; + +/** + * 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. + */ +export function formatGoSection( + result: GoQueryResult, + windows: readonly GoWindowKey[], + now: number = Date.now(), +): 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"; + 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/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..e0a0880 --- /dev/null +++ b/tests/opencode-go.test.ts @@ -0,0 +1,462 @@ +/** + * 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("Mon"); + 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("Mon"))).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("Mon"))?.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); + }); +}); + +// --- 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..8fcf38e 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,58 @@ 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("cache", () => { beforeEach(() => { clearCache(); From 2140153485ec5dfcd6cd65207f699daefebaef82 Mon Sep 17 00:00:00 2001 From: William Wang Date: Wed, 5 Aug 2026 18:56:34 +0800 Subject: [PATCH 7/8] feat: add heat-colored progress bars with in-bar overlay to zcode-quota CLI --- README.md | 9 +++ README.zh-CN.md | 7 ++ src/bin/quota.ts | 57 ++++++++++--- src/quota/color.ts | 121 ++++++++++++++++++++++++++++ src/quota/combined.ts | 19 ++++- src/quota/format.ts | 58 +++++++++++--- src/quota/opencode-go/format.ts | 12 +++ tests/cli-quota.test.ts | 24 ++++++ tests/color.test.ts | 138 ++++++++++++++++++++++++++++++++ tests/combined.test.ts | 24 ++++++ tests/opencode-go.test.ts | 27 +++++++ tests/quota.test.ts | 87 ++++++++++++++++++++ 12 files changed, 556 insertions(+), 27 deletions(-) create mode 100644 src/quota/color.ts create mode 100644 tests/color.test.ts diff --git a/README.md b/README.md index 8124d67..ed41e2f 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,17 @@ 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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 8485b0e..bd3650f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -110,8 +110,15 @@ 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 缓存——更短的间隔只会一直 返回过期的缓存值,没有意义。 diff --git a/src/bin/quota.ts b/src/bin/quota.ts index 2bf167e..188554c 100644 --- a/src/bin/quota.ts +++ b/src/bin/quota.ts @@ -60,6 +60,8 @@ export interface CliOptions { 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. */ @@ -84,14 +86,18 @@ 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 (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 # both providers, print once and exit + 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 -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 @@ -119,6 +125,7 @@ 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"; @@ -133,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; @@ -170,6 +181,7 @@ export function parseArgs(argv: readonly string[]): CliOptions { watch, detail, help, + plain, provider, intervalMs: resolved.ms, intervalClamped: resolved.clamped, @@ -207,17 +219,23 @@ function refreshSuffix(remainingSec: number): string { * 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 appended to the first section header. + * + * `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 renderCard( combined: Parameters[0], provider: Provider, detail: boolean, + color: boolean, refresh?: string, ): string { return formatCombinedCardPlain(combined, { provider, glm: { detail }, goWindows: defaultGoWindows(provider), + color, refreshSuffix: refresh, }); } @@ -237,7 +255,12 @@ function renderFrame(plain: string): string { * 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, provider: Provider, 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 => { @@ -259,7 +282,7 @@ async function runWatch(intervalMs: number, provider: Provider, detail: boolean) const combined = await queryCombined(provider); // Full redraw with the countdown starting at the interval max. process.stdout.write( - renderFrame(renderCard(combined, provider, detail, refreshSuffix(intervalSec))), + renderFrame(renderCard(combined, provider, detail, color, refreshSuffix(intervalSec))), ); // Per-second countdown: rewrite only the first line (the header with the // countdown suffix), leaving the card body untouched. Re-rendering the @@ -268,10 +291,13 @@ async function runWatch(intervalMs: number, provider: Provider, detail: boolean) for (let remaining = intervalSec - 1; remaining > 0; remaining--) { await sleep(1000, controller.signal).catch(() => undefined); if (controller.signal.aborted) break; - const firstLine = renderCard(combined, provider, detail, refreshSuffix(remaining)).split( - "\n", - 1, - )[0]!; + const firstLine = renderCard( + combined, + provider, + detail, + color, + refreshSuffix(remaining), + ).split("\n", 1)[0]!; process.stdout.write(`\x1B[H${ANSI.clearLine}${firstLine}`); } } @@ -287,9 +313,9 @@ async function runWatch(intervalMs: number, provider: Provider, detail: boolean) * result (at least one provider succeeded or is merely not_configured) goes * to stdout with exit 0. */ -async function runOnce(provider: Provider, detail: boolean): Promise { +async function runOnce(provider: Provider, detail: boolean, color: boolean): Promise { const combined = await queryCombined(provider); - const out = renderCard(combined, provider, detail); + const out = renderCard(combined, provider, detail, color); // Failure = every selected provider ended up unavailable (not merely // not_configured, which is a deliberate "skip me" state). @@ -320,10 +346,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.provider, opts.detail); + await runWatch(opts.intervalMs, opts.provider, opts.detail, color); } else { - await runOnce(opts.provider, opts.detail); + await runOnce(opts.provider, opts.detail, color); } } 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 index 492cd6f..f39278f 100644 --- a/src/quota/combined.ts +++ b/src/quota/combined.ts @@ -88,6 +88,8 @@ export function formatCombinedCard( 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; @@ -99,6 +101,7 @@ export function formatCombinedCard( provider, opts.glm, opts.goWindows, + opts.color, opts.refreshSuffix, ); return ["```text", ...lines, "```"].join("\n"); @@ -111,6 +114,7 @@ export function formatCombinedCardPlain( provider: Provider; glm?: FormatOptions; goWindows?: GoWindowKey[]; + color?: boolean; refreshSuffix?: string; } = { provider: "all" }, ): string { @@ -119,6 +123,7 @@ export function formatCombinedCardPlain( opts.provider, opts.glm, opts.goWindows, + opts.color, opts.refreshSuffix, ).join("\n"); } @@ -135,29 +140,35 @@ function renderCombinedLines( provider: Provider, glmOpts?: FormatOptions, goWindows?: GoWindowKey[], + color = false, /** Optional trailing annotation appended to the first section header. */ refreshSuffix?: string, ): string[] { const { glm, go } = combined; const suffix = refreshSuffix ? ` · ${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") { - return renderSingleGlm(glm, glmOpts, suffix); + return renderSingleGlm(glm, glmOptsColor, suffix); } // Single-provider Go: header + body, no banner/divider. if (provider === "go") { - const section = formatGoSection(go, goWindows ?? defaultGoWindows("go")); + const section = formatGoSection(go, goWindows ?? defaultGoWindows("go"), Date.now(), color); return [`${section.header}${suffix}`, ...section.body]; } // 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, glmOpts); + const glmSection = renderGlmSection(glm, glmOptsColor); const showGo = shouldShowGo("all", go); - const goSection = showGo ? formatGoSection(go, goWindows ?? defaultGoWindows("all")) : null; + 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; diff --git a/src/quota/format.ts b/src/quota/format.ts index abc324d..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"; /** @@ -89,6 +90,16 @@ function formatTrailing(item: QuotaItem): string { 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; @@ -103,24 +114,46 @@ const DETAIL_LABEL_WIDTH = 14; * 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 complete flags. */ -function resolveOptions(opts?: FormatOptions): { detail: boolean; compact: boolean } { - return { detail: opts?.detail ?? true, compact: opts?.compact ?? false }; +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; @@ -171,7 +204,7 @@ export function renderGlmSection(result: QuotaResult, opts?: FormatOptions): Ren if (result.kind !== "success") { return { header, body: [STATUS_MESSAGES[result.kind]] }; } - const { detail, compact } = resolveOptions(opts); + const { detail, compact, color } = resolveOptions(opts); const title = `${header}${result.level ? ` · ${capitalise(result.level)}` : ""}`; if (compact) { @@ -181,18 +214,21 @@ export function renderGlmSection(result: QuotaResult, opts?: FormatOptions): Ren const body = result.items .filter((it) => it.key !== "mcp") .map((it) => - formatItem(it, false)[0]!.replace(/$/, mcpNote && it.key === "token_5h" ? mcpNote : ""), + 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)[0]!); + body.push(formatItem(mcp, false, color)[0]!); } return { header: title, body }; } - const body = result.items.flatMap((item) => formatItem(item, detail)); + const body = result.items.flatMap((item) => formatItem(item, detail, color)); return { header: title, body }; } diff --git a/src/quota/opencode-go/format.ts b/src/quota/opencode-go/format.ts index 5f0377c..d08254f 100644 --- a/src/quota/opencode-go/format.ts +++ b/src/quota/opencode-go/format.ts @@ -7,6 +7,7 @@ * formatter for visual consistency. */ +import { pickOverlay, renderColorBar } from "../color.js"; import { formatResetTime, renderBar } from "../format.js"; import type { GoQueryResult, GoWindowKey } from "./types.js"; @@ -53,11 +54,16 @@ export interface RenderedSection { * 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"; @@ -82,6 +88,12 @@ export function formatGoSection( 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. diff --git a/tests/cli-quota.test.ts b/tests/cli-quota.test.ts index 8dce00b..74ab6b1 100644 --- a/tests/cli-quota.test.ts +++ b/tests/cli-quota.test.ts @@ -124,3 +124,27 @@ describe("parseArgs", () => { expect(parseArgs(["-w", "unknown"]).provider).toBe("all"); }); }); + +describe("parseArgs — plain flag", () => { + it("empty argv → plain defaults to false", () => { + expect(parseArgs([]).plain).toBe(false); + }); + + it("-p / --plain set plain to true", () => { + expect(parseArgs(["-p"]).plain).toBe(true); + expect(parseArgs(["--plain"]).plain).toBe(true); + }); + + it("plain combines with provider and watch flags in any order", () => { + expect(parseArgs(["--plain", "go"]).plain).toBe(true); + expect(parseArgs(["go", "--plain"]).plain).toBe(true); + expect(parseArgs(["-w", "-p"]).plain).toBe(true); + expect(parseArgs(["-p", "-w", "glm"]).plain).toBe(true); + }); + + it("plain is independent of detail (-d)", () => { + const opts = parseArgs(["-p", "-d"]); + expect(opts.plain).toBe(true); + expect(opts.detail).toBe(true); + }); +}); diff --git a/tests/color.test.ts b/tests/color.test.ts new file mode 100644 index 0000000..7d0a685 --- /dev/null +++ b/tests/color.test.ts @@ -0,0 +1,138 @@ +/** + * Tests for the 24-bit color progress bar used by the `zcode-quota` CLI's + * default (heat) mode. The `/quota` slash command never touches this module — + * it stays on the plain `renderBar`. + */ + +import { describe, expect, it } from "vitest"; + +import { heatColor, pickOverlay, renderColorBar, RESET } from "../src/quota/color.js"; + +// ANSI ESC character. Constructed via charCode so regex literals don't trip the +// `no-control-regex` lint rule (which flags literal \x1b in patterns). +const ESC = String.fromCharCode(27); +// Strip every ANSI escape sequence (SGR etc.) from a string, leaving only the +// visible characters. Used to assert on what the user actually sees. +const stripAnsi = (s: string): string => s.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), ""); + +describe("heatColor", () => { + it("0% → green", () => { + expect(heatColor(0)).toEqual([34, 197, 94]); + }); + + it("100% → red", () => { + expect(heatColor(100)).toEqual([239, 68, 68]); + }); + + it("50% → yellow (the midpoint)", () => { + expect(heatColor(50)).toEqual([234, 179, 8]); + }); + + it("interpolates linearly in the lower half (25% between green and yellow)", () => { + // t = 0.5 → halfway between green (34,197,94) and yellow (234,179,8). + expect(heatColor(25)).toEqual([ + Math.round((34 + 234) / 2), + Math.round((197 + 179) / 2), + Math.round((94 + 8) / 2), + ]); + }); + + it("clamps inputs outside [0, 100]", () => { + expect(heatColor(150)).toEqual([239, 68, 68]); + expect(heatColor(-10)).toEqual([34, 197, 94]); + }); +}); + +describe("pickOverlay", () => { + it("returns used/total when both counters are finite numbers", () => { + expect(pickOverlay({ usedPercent: 14, usedCount: 237, totalCount: 1000 })).toBe("237/1000"); + }); + + it("returns NN% when no counters are present", () => { + expect(pickOverlay({ usedPercent: 73 })).toBe("73%"); + }); + + it("returns NN% when only usedCount is present (no total)", () => { + // A partial counter pair carries no more info than the percent. + expect(pickOverlay({ usedPercent: 42, usedCount: 237 })).toBe("42%"); + }); + + it("returns NN% when only totalCount is present", () => { + expect(pickOverlay({ usedPercent: 42, totalCount: 1000 })).toBe("42%"); + }); + + it("rounds the percent to the nearest integer", () => { + expect(pickOverlay({ usedPercent: 72.6 })).toBe("73%"); + expect(pickOverlay({ usedPercent: 72.4 })).toBe("72%"); + }); + + it("clamps percent outside [0, 100] before rounding", () => { + expect(pickOverlay({ usedPercent: 150 })).toBe("100%"); + expect(pickOverlay({ usedPercent: -10 })).toBe("0%"); + }); + + it("treats non-finite counters as absent (falls back to NN%)", () => { + expect(pickOverlay({ usedPercent: 50, usedCount: NaN, totalCount: 1000 })).toBe("50%"); + expect(pickOverlay({ usedPercent: 50, usedCount: 237, totalCount: Infinity })).toBe("50%"); + }); +}); + +describe("renderColorBar", () => { + it("emits 24-bit background and foreground escapes plus a reset", () => { + const bar = renderColorBar(50); + expect(bar).toContain(`${ESC}[48;2;`); // bg color + expect(bar).toContain(`${ESC}[38;2;`); // fg color + expect(bar.endsWith(RESET)).toBe(true); + }); + + it("uses the fill (heat) color on used cells and the empty color on the rest", () => { + // At 50% with width 20: cells 0–9 are fill (yellow 234,179,8), 10–19 are + // empty (40,40,48). Both background escapes must appear. + const bar = renderColorBar(50); + expect(bar).toContain(`${ESC}[48;2;234;179;8m`); // yellow fill bg + expect(bar).toContain(`${ESC}[48;2;40;40;48m`); // empty bg + }); + + it("at 0% every cell is empty (no fill color present)", () => { + const bar = renderColorBar(0); + expect(bar).not.toContain(`${ESC}[48;2;34;197;94m`); // green fill (0% heat) + expect(bar).toContain(`${ESC}[48;2;40;40;48m`); + }); + + it("at 100% every cell is fill (no empty color present)", () => { + const bar = renderColorBar(100); + expect(bar).not.toContain(`${ESC}[48;2;40;40;48m`); + expect(bar).toContain(`${ESC}[48;2;239;68;68m`); // red fill (100% heat) + }); + + it("places the overlay text centered across the bar width", () => { + // width 20, overlay "73%" (len 3) → start at floor((20-3)/2) = 8. + // Cell 8 carries '7', cell 9 '3', cell 10 '%'. We strip every ANSI escape + // and check the remaining visible characters are the centered overlay with + // space padding around it. + const visible = stripAnsi(renderColorBar(73, { overlay: "73%" })); + expect(visible).toHaveLength(20); + expect(visible.slice(8, 11)).toBe("73%"); + expect(visible.slice(0, 8)).toBe(" ".repeat(8)); + expect(visible.slice(11)).toBe(" ".repeat(9)); + }); + + it("default width is 20 (matches the plain renderBar)", () => { + expect(stripAnsi(renderColorBar(42))).toHaveLength(20); + }); + + it("honors a custom width", () => { + expect(stripAnsi(renderColorBar(50, { width: 10 }))).toHaveLength(10); + }); + + it("omits overlay text when none is given (visible chars are all spaces)", () => { + const visible = stripAnsi(renderColorBar(42)); + expect(visible).toBe(" ".repeat(20)); + }); + + it("clamps percent outside [0, 100]", () => { + // 150% should render identically to 100% (all fill, red). + expect(renderColorBar(150)).toBe(renderColorBar(100)); + expect(renderColorBar(-10)).toBe(renderColorBar(0)); + }); +}); diff --git a/tests/combined.test.ts b/tests/combined.test.ts index 1cee51e..9acc9bc 100644 --- a/tests/combined.test.ts +++ b/tests/combined.test.ts @@ -140,6 +140,30 @@ describe("formatCombinedCard — all mode", () => { expect(fenced.startsWith("```text\n")).toBe(true); expect(fenced.endsWith("\n```")).toBe(true); }); + + it("color mode paints both sections with ANSI escapes", () => { + const ESC = String.fromCharCode(27); + const out = formatCombinedCardPlain( + { glm: GLM_SUCCESS, go: GO_SUCCESS }, + { provider: "all", color: true }, + ); + // Both the GLM bar (24%) and the Go bar (42%) must carry ANSI bg escapes. + expect(out).toContain(`${ESC}[48;2;`); + expect(out).toContain(`${ESC}[0m`); + // Section headers are still present and plain (no escapes in headers). + expect(out).toContain("GLM Coding Plan"); + expect(out).toContain("Opencode Go"); + }); + + it("color mode respects provider=glm (paints GLM, no Go section)", () => { + const ESC = String.fromCharCode(27); + const out = formatCombinedCardPlain( + { glm: GLM_SUCCESS, go: GO_SUCCESS }, + { provider: "glm", color: true }, + ); + expect(out).toContain(`${ESC}[48;2;`); + expect(out).not.toContain("Opencode Go"); + }); }); describe("formatCombinedCard — glm mode", () => { diff --git a/tests/opencode-go.test.ts b/tests/opencode-go.test.ts index e0a0880..2367e07 100644 --- a/tests/opencode-go.test.ts +++ b/tests/opencode-go.test.ts @@ -231,6 +231,33 @@ describe("formatGoSection", () => { 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 ------------------------------------------ diff --git a/tests/quota.test.ts b/tests/quota.test.ts index 8fcf38e..976f1fb 100644 --- a/tests/quota.test.ts +++ b/tests/quota.test.ts @@ -378,6 +378,93 @@ describe("renderGlmSection", () => { ); 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", () => { From b817a1609e6d4454a1dbfdf691b3f9938947336a Mon Sep 17 00:00:00 2001 From: William Wang Date: Wed, 5 Aug 2026 19:07:18 +0800 Subject: [PATCH 8/8] feat: place refresh countdown on fixed separator row and show monthly Go window by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the watch-mode refresh countdown off the section header and onto the separator row after the first section, right-aligned to width 34. Its position is now fixed whether or not a second section renders, and watch re-renders only that single row per second (cursor-position + clear-line). - defaultGoWindows now returns all three windows (rolling + weekly + monthly) for every provider, not just the go subcommand — the compact color layout leaves room for the monthly bar. - Rename the monthly Go window label from Mon to Month (still fits the 5-char label column, aligns with Week). - Update HELP_TEXT provider descriptions and combined/cli tests accordingly. --- src/bin/quota.ts | 63 +++++++++++++++++---------- src/quota/combined.ts | 75 ++++++++++++++++++++++----------- src/quota/opencode-go/format.ts | 2 +- tests/combined.test.ts | 73 ++++++++++++++++++++++++++++---- tests/opencode-go.test.ts | 6 +-- 5 files changed, 160 insertions(+), 59 deletions(-) diff --git a/src/bin/quota.ts b/src/bin/quota.ts index 188554c..38d1700 100644 --- a/src/bin/quota.ts +++ b/src/bin/quota.ts @@ -71,7 +71,7 @@ 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 (Go: rolling + weekly). + (none) Both GLM + Opencode Go (rolling + weekly + monthly). glm GLM Coding Plan only. go Opencode Go only (rolling + weekly + monthly). @@ -215,10 +215,18 @@ 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; +} + /** * 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 appended to the first section header. + * 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 @@ -230,22 +238,32 @@ function renderCard( detail: boolean, color: boolean, refresh?: string, -): string { - return formatCombinedCardPlain(combined, { +): 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 first header line, counting down from intervalSec). + * refresh countdown on the separator row after the first section). */ -function renderFrame(plain: string): string { - return `${ANSI.clearScreen}${plain}`; +function renderFrame(text: string): string { + return `${ANSI.clearScreen}${text}`; } /** @@ -281,24 +299,23 @@ async function runWatch( clearAllCaches(); // bypass caches — always show live values const combined = await queryCombined(provider); // Full redraw with the countdown starting at the interval max. - process.stdout.write( - renderFrame(renderCard(combined, provider, detail, color, refreshSuffix(intervalSec))), - ); - // Per-second countdown: rewrite only the first line (the header with the - // countdown suffix), leaving the card body untouched. Re-rendering the - // whole card each second would be wasteful; instead we re-render just to - // grab line 1, then blast it to row 1 via cursor-home + clear-line. + 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; - const firstLine = renderCard( - combined, - provider, - detail, - color, - refreshSuffix(remaining), - ).split("\n", 1)[0]!; - process.stdout.write(`\x1B[H${ANSI.clearLine}${firstLine}`); + 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 { @@ -315,7 +332,7 @@ async function runWatch( */ async function runOnce(provider: Provider, detail: boolean, color: boolean): Promise { const combined = await queryCombined(provider); - const out = renderCard(combined, provider, detail, color); + 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). diff --git a/src/quota/combined.ts b/src/quota/combined.ts index f39278f..40d0ff3 100644 --- a/src/quota/combined.ts +++ b/src/quota/combined.ts @@ -32,12 +32,17 @@ export interface CombinedResult { } /** - * Which Opencode Go windows to render, per provider selection: - * - `all` / `glm` mode → rolling + weekly (the user's requested default) - * - `go` mode → rolling + weekly + monthly (full detail) + * 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[] { - return provider === "go" ? ["rolling", "weekly", "monthly"] : ["rolling", "weekly"]; + // 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"]; } /** @@ -135,17 +140,38 @@ export function formatCombinedCardPlain( * (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 trailing annotation appended to the first section header. */ + /** 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 suffix = refreshSuffix ? ` · ${refreshSuffix}` : ""; + 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 }; @@ -154,12 +180,15 @@ function renderCombinedLines( // body) minus the fence, so `zcode-quota glm` looks identical to today's // `zcode-quota`. if (provider === "glm") { - return renderSingleGlm(glm, glmOptsColor, suffix); + 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); - return [`${section.header}${suffix}`, ...section.body]; + const lines = [section.header, ...section.body]; + return sep ? [...lines, sep] : lines; } // Combined `all` mode. GLM renders full (MCP on its own line) — the layout @@ -173,43 +202,41 @@ function renderCombinedLines( 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[][] = []; - // The refresh suffix rides on whichever section renders FIRST — GLM if it - // has data, otherwise Go. - if (hasGlm) { - sections.push([` ${glmSection.header}${suffix}`, ...glmSection.body]); - } - if (hasGo) { - const goSuffix = hasGlm ? "" : suffix; // GLM absent → suffix on Go header - sections.push([` ${goSection!.header}${goSuffix}`, ...goSection!.body]); - } + 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. - // Sections are separated by a single blank line. + // 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(""); + 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`. The optional suffix is appended - * to the header line (watch-mode countdown). + * minus the fence). Used for `zcode-quota glm`. */ -function renderSingleGlm(result: QuotaResult, opts?: FormatOptions, suffix = ""): string[] { +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}${suffix}`, divider, ...section.body]; + return [section.header, divider, ...section.body]; } diff --git a/src/quota/opencode-go/format.ts b/src/quota/opencode-go/format.ts index d08254f..6c82e75 100644 --- a/src/quota/opencode-go/format.ts +++ b/src/quota/opencode-go/format.ts @@ -15,7 +15,7 @@ import type { GoQueryResult, GoWindowKey } from "./types.js"; const WINDOW_META: Array<{ key: GoWindowKey; label: string }> = [ { key: "rolling", label: "5h" }, { key: "weekly", label: "Week" }, - { key: "monthly", label: "Mon" }, + { key: "monthly", label: "Month" }, ]; /** diff --git a/tests/combined.test.ts b/tests/combined.test.ts index 9acc9bc..07069ed 100644 --- a/tests/combined.test.ts +++ b/tests/combined.test.ts @@ -78,13 +78,13 @@ const GO_SUCCESS: GoQueryResult = { }; describe("defaultGoWindows", () => { - it("all/glm → rolling + weekly (the user's requested default)", () => { - expect(defaultGoWindows("all")).toEqual(["rolling", "weekly"]); - expect(defaultGoWindows("glm")).toEqual(["rolling", "weekly"]); + it("all/go → rolling + weekly + monthly (all three windows)", () => { + expect(defaultGoWindows("all")).toEqual(["rolling", "weekly", "monthly"]); + expect(defaultGoWindows("go")).toEqual(["rolling", "weekly", "monthly"]); }); - it("go → rolling + weekly + monthly (full detail)", () => { - expect(defaultGoWindows("go")).toEqual(["rolling", "weekly", "monthly"]); + it("glm also returns all three (glm mode never renders Go, so unused)", () => { + expect(defaultGoWindows("glm")).toEqual(["rolling", "weekly", "monthly"]); }); }); @@ -102,10 +102,10 @@ describe("formatCombinedCard — all mode", () => { expect(out).toContain(" Opencode Go"); // The two sections are separated by exactly one blank line. expect(out).toContain("\n\n Opencode Go"); - // Both Go default windows present, monthly absent by default. + // All three Go windows present by default (room for monthly in the layout). expect(out).toMatch(/5h.*42%/); expect(out).toMatch(/Week.*17%/); - expect(out).not.toMatch(/Mon.*8%/); + expect(out).toMatch(/Month.*8%/); }); it("silently drops the Go section when Go is not_configured", () => { @@ -164,6 +164,63 @@ describe("formatCombinedCard — all mode", () => { expect(out).toContain(`${ESC}[48;2;`); expect(out).not.toContain("Opencode Go"); }); + + describe("refresh line (refreshSuffix)", () => { + it("places the refresh countdown on the separator row between sections, right-aligned", () => { + const out = formatCombinedCardPlain( + { glm: GLM_SUCCESS, go: GO_SUCCESS }, + { provider: "all", refreshSuffix: "refresh in 25s" }, + ); + const lines = out.split("\n"); + // GLM section = [header, 5h]; the refresh line sits right after it on the + // separator row (no extra blank line — the refresh text IS the separator), + // then the Go header follows directly. + expect(lines[2]).toBe("refresh in 25s".padStart(34)); + expect(lines[3]).toBe(" Opencode Go"); + }); + + it("keeps the refresh row position even when Go is not_configured (only GLM)", () => { + // Only GLM renders → refresh still trails the first section at the same row. + const out = formatCombinedCardPlain( + { glm: GLM_SUCCESS, go: { kind: "not_configured" } }, + { provider: "all", refreshSuffix: "refresh in 25s" }, + ); + const lines = out.split("\n"); + expect(lines[0]).toBe(" GLM Coding Plan · Pro"); + expect(lines[1]).toMatch(/5h/); + expect(lines[2]).toBe("refresh in 25s".padStart(34)); + expect(lines[3]).toBeUndefined(); + }); + + it("omits the refresh line entirely when no refreshSuffix is given", () => { + const out = formatCombinedCardPlain( + { glm: GLM_SUCCESS, go: GO_SUCCESS }, + { provider: "all" }, + ); + expect(out).not.toContain("refresh"); + // The separator between sections is still a blank line. + expect(out).toContain("\n\n Opencode Go"); + }); + + it("appends the refresh line after the GLM card in glm mode", () => { + const out = formatCombinedCardPlain( + { glm: GLM_SUCCESS, go: GO_SUCCESS }, + { provider: "glm", refreshSuffix: "refresh in 3s" }, + ); + const lines = out.split("\n"); + // glm card = [header, divider, 5h]; refresh is the last line. + expect(lines[lines.length - 1]).toBe("refresh in 3s".padStart(34)); + }); + + it("appends the refresh line after the Go card in go mode", () => { + const out = formatCombinedCardPlain( + { glm: GLM_SUCCESS, go: GO_SUCCESS }, + { provider: "go", refreshSuffix: "refresh in 9s" }, + ); + const lines = out.split("\n"); + expect(lines[lines.length - 1]).toBe("refresh in 9s".padStart(34)); + }); + }); }); describe("formatCombinedCard — glm mode", () => { @@ -199,7 +256,7 @@ describe("formatCombinedCard — go mode", () => { expect(out).not.toContain("GLM Coding Plan"); expect(out).toMatch(/5h.*42%/); expect(out).toMatch(/Week.*17%/); - expect(out).toMatch(/Mon.*8%/); + expect(out).toMatch(/Month.*8%/); }); it("Go not_configured in go mode surfaces the help line (not silently dropped)", () => { diff --git a/tests/opencode-go.test.ts b/tests/opencode-go.test.ts index 2367e07..82bcbcb 100644 --- a/tests/opencode-go.test.ts +++ b/tests/opencode-go.test.ts @@ -187,14 +187,14 @@ describe("formatGoSection", () => { 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("Mon"); + 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("Mon"))).toBeUndefined(); + expect(sec.body.find((l) => l.includes("Month"))).toBeUndefined(); }); it("shows the reset time advancing as elapsed time grows (live ticker)", () => { @@ -221,7 +221,7 @@ describe("formatGoSection", () => { 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("Mon"))?.includes("(no data)")).toBe(true); + expect(sec.body.find((l) => l.includes("Month"))?.includes("(no data)")).toBe(true); }); it("non-success kinds render a single explanation line", () => {