From 926e2461543bf67353e8673aec141b7379430546 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 10:35:57 +0500 Subject: [PATCH 01/22] feat(usage): add server usage sync module for /zen/go/v1/usage Pure, unit-testable module for the official OpenCode Go usage endpoint (upstream anomalyco/opencode#16513, verified live in production): - fetchGoUsage(): GET /zen/go/v1/usage with the existing opencode-go key as a Bearer token; classifies failures (unauthorized / no-subscription / not-found / network / invalid) so callers can fall back to local estimates. The key is never logged or persisted. - mergeServerUsage(): overlays the server-accurate rolling/weekly/monthly percent + resetsAt onto a locally-computed UsageSummary. The endpoint does not return raw spend, so spent is derived as limit x percent / 100 to keep the status bar and tooltip consistent; today/yesterday and per-session spend stay local. - GO_LIMITS stays the source of the display limits (they match the server's ZEN_LIMITS). --- src/goUsageSync.ts | 125 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/goUsageSync.ts diff --git a/src/goUsageSync.ts b/src/goUsageSync.ts new file mode 100644 index 0000000..cff6278 --- /dev/null +++ b/src/goUsageSync.ts @@ -0,0 +1,125 @@ +import type { UsageSummary } from "./goUsageTracker"; +import { GO_LIMITS } from "./goUsageTracker"; + +/** + * Official OpenCode Go usage endpoint (upstream anomalyco/opencode#16513, + * live since 2026-08-11). Authenticates with the same `opencode-go` API key + * the extension already stores for models/chat; returns server-accurate, + * account-wide rolling/weekly/monthly percentages and reset times instead of + * the per-machine estimates the extension computes locally. + * + * Verified against the upstream route source (packages/console/app/src/routes/ + * zen/go/v1/usage.ts): 401 = missing/invalid key, 403 = no Go subscription, + * 200 = `{ usage: { rolling, weekly, monthly: { status, percent, resetsAt } } }` + * where `percent` is an integer 0–100 computed server-side and `resetsAt` is + * an ISO timestamp. + */ +export const GO_USAGE_API_URL = "https://opencode.ai/zen/go/v1/usage"; +/** How long a successful server-usage snapshot is reused before refetching. */ +export const GO_USAGE_SYNC_TTL_MS = 60_000; +/** Hard timeout for a single usage fetch. */ +export const GO_USAGE_FETCH_TIMEOUT_MS = 10_000; + +export type GoUsagePeriodStatus = "ok" | "rate-limited"; + +export interface GoUsagePeriod { + status: GoUsagePeriodStatus; + percent: number; + resetsAt: string; +} + +export interface GoUsageApiResponse { + usage: { + rolling: GoUsagePeriod; + weekly: GoUsagePeriod; + monthly: GoUsagePeriod; + }; +} + +export type GoUsageSyncFailureReason = "no-key" | "unauthorized" | "no-subscription" | "not-found" | "network" | "invalid"; + +export type GoUsageSyncResult = + | { ok: true; data: GoUsageApiResponse } + | { ok: false; reason: GoUsageSyncFailureReason }; + +/** Minimal structural guard for the endpoint payload. */ +function isGoUsageApiResponse(value: unknown): value is GoUsageApiResponse { + if (typeof value !== "object" || value === null) return false; + const usage = (value as { usage?: unknown }).usage; + if (typeof usage !== "object" || usage === null) return false; + const periods: unknown[] = ["rolling", "weekly", "monthly"].map((key) => (usage as Record)[key]); + return periods.every((period) => { + if (typeof period !== "object" || period === null) return false; + const p = period as Record; + return (p.status === "ok" || p.status === "rate-limited") && typeof p.percent === "number" && typeof p.resetsAt === "string"; + }); +} + +/** + * Fetch server-accurate Go usage for an API key. + * + * CONTRACT: + * - Never logs or persists the key; it is only sent as the Authorization + * header of this request. + * - Failures are classified so callers can fall back to local estimates: + * 401 → unauthorized, 403 → no subscription, 404 → endpoint not deployed, + * network/timeout errors → network, malformed payloads → invalid. + */ +export async function fetchGoUsage( + apiKey: string, + fetcher: typeof fetch = fetch, + timeoutMs: number = GO_USAGE_FETCH_TIMEOUT_MS, +): Promise { + if (!apiKey) { + return { ok: false, reason: "no-key" }; + } + let response: Response; + try { + response = await fetcher(GO_USAGE_API_URL, { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + return { ok: false, reason: "network" }; + } + + if (response.status === 401) return { ok: false, reason: "unauthorized" }; + if (response.status === 403) return { ok: false, reason: "no-subscription" }; + if (response.status === 404) return { ok: false, reason: "not-found" }; + if (!response.ok) return { ok: false, reason: "network" }; + + try { + const payload: unknown = await response.json(); + if (!isGoUsageApiResponse(payload)) { + return { ok: false, reason: "invalid" }; + } + return { ok: true, data: payload }; + } catch { + return { ok: false, reason: "invalid" }; + } +} + +/** + * Merge server-accurate meters onto a locally-computed summary. + * + * The endpoint reports percent + resetsAt but not raw spend, so `spent` is + * derived as `limit × percent / 100` to keep the status bar and tooltip + * internally consistent (the percent itself is authoritative). Today / + * Yesterday / per-session spend stay local — the API does not return them. + */ +export function mergeServerUsage(summary: UsageSummary, api: GoUsageApiResponse): UsageSummary { + const period = (server: GoUsagePeriod, limit: number): UsageSummary["session"] => ({ + spent: Math.round(limit * (server.percent / 100) * 100) / 100, + limit, + percent: server.percent, + resetsAt: new Date(server.resetsAt), + }); + + return { + ...summary, + session: period(api.usage.rolling, GO_LIMITS.session), + weekly: period(api.usage.weekly, GO_LIMITS.weekly), + monthly: period(api.usage.monthly, GO_LIMITS.monthly), + }; +} From 93feb07872af02d520d20381c75635648d4171f7 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 10:36:55 +0500 Subject: [PATCH 02/22] feat(usage): merge server-accurate meters into the tracker summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoUsageTracker gains syncServerUsage(apiKey): fetches the official usage snapshot and caches it for GO_USAGE_SYNC_TTL_MS, so it is safe to call on every request or status-bar refresh. getSummary() (still synchronous) overlays the cached snapshot onto the local estimate via mergeServerUsage — the status bar, tooltip, quick-pick and webview all pick up the account-wide percentages and reset times automatically. A failed fetch keeps the previous snapshot and falls back to the existing local estimates; today/yesterday and per-session spend stay local. --- src/goUsageTracker.ts | 44 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index 4d4c666..1ac105a 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -6,6 +6,7 @@ import { execFileSync } from "child_process"; import { GO_VENDOR } from "./providerTypes"; import type { ModelCost } from "./metadata"; import type { TransportRequestSummary } from "./streaming"; +import { fetchGoUsage, mergeServerUsage, GO_USAGE_SYNC_TTL_MS, type GoUsageApiResponse } from "./goUsageSync"; /** Callback to resolve live model cost from the models.dev metadata cache. */ export type CostResolver = (modelId: string) => ModelCost | undefined; @@ -295,6 +296,10 @@ export class GoUsageTracker { private costResolver?: CostResolver; /** Per-chat-session cost accumulator. Key = sessionId. */ private sessionCosts = new Map(); + /** Latest server-accurate usage snapshot (account-wide meters). */ + private serverUsage: GoUsageApiResponse | undefined; + /** Unix ms of the last successful {@link syncServerUsage} fetch. */ + private serverUsageFetchedAt = 0; private static readonly SESSION_IDLE_MS = 2 * 60 * 60 * 1000; // 2h private static readonly MAX_SESSIONS = 50; @@ -435,15 +440,48 @@ export class GoUsageTracker { // When namespaced (per-profile), skip the shared SQLite — it has no // key column, so reading it would mix quota from all accounts. const isPerProfile = this.storageKeySuffix.length > 0; + let summary: UsageSummary; if (!isPerProfile) { const sqliteRows = readOpenCodeHistory(); if (sqliteRows) { - return this.buildSqliteEnrichedSummary(nowMs, sqliteRows, clamp); + summary = this.buildSqliteEnrichedSummary(nowMs, sqliteRows, clamp); + } else { + // Fall back to extension-tracked data (works without CLI). + summary = this.buildSummaryFromTracked(nowMs, clamp); } + } else { + summary = this.buildSummaryFromTracked(nowMs, clamp); } - // Fall back to extension-tracked data (works without CLI). - return this.buildSummaryFromTracked(nowMs, clamp); + // Overlay the server-accurate meters when a recent snapshot exists + // (fetched via syncServerUsage). Today / Yesterday / per-session spend + // remain local. + return this.serverUsage ? mergeServerUsage(summary, this.serverUsage) : summary; + } + + /** + * Fetch server-accurate account-wide usage for this profile's key and + * cache it for {@link GO_USAGE_SYNC_TTL_MS}. Safe to call on every + * request/status-bar refresh: the TTL guard makes it a no-op while a + * fresh snapshot exists. Failures keep the previous snapshot (stale + * beats nothing) and the local estimates remain the fallback. + * + * @returns true when a new snapshot was fetched. + */ + async syncServerUsage(apiKey: string): Promise { + const now = Date.now(); + if (this.serverUsage && now - this.serverUsageFetchedAt < GO_USAGE_SYNC_TTL_MS) { + return false; + } + const result = await fetchGoUsage(apiKey); + if (!result.ok) { + this.log?.(`[go-usage] Server usage sync skipped (${result.reason}); keeping local estimates.`); + return false; + } + this.serverUsage = result.data; + this.serverUsageFetchedAt = Date.now(); + this.log?.("[go-usage] Server usage synced from /zen/go/v1/usage."); + return true; } /** Build summary from SQLite, enriched with token/request counts from tracked entries. */ From 5edc094334018df3c466a393e2bb6fb23a79598c Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 10:38:21 +0500 Subject: [PATCH 03/22] feat(usage): wire server usage sync into activation and per-request refresh - Startup: fetch the server meters once with the stored key (TTL-guarded). - Per request: after recording a Go transport summary, re-sync with the exact key that request ran under (covers native BYOK group keys that differ from the extension secret). - Status bar refresh: background sync with the stored key; when a fresh snapshot lands the status bar repaints with the account-wide numbers. - syncTrackerUsage helper: sync + repaint, used by all three paths. --- src/extension.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index 13a9326..4dc440c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -720,6 +720,12 @@ export function activate(context: vscode.ExtensionContext) { ensureUsageStatusBar(context); ensureGoUsageStatusBar(context); + // Pull the server-accurate account meters once at startup (TTL-guarded). + void (async () => { + const apiKey = await context.secrets.get(SECRET_KEY); + if (!apiKey) return; + await syncTrackerUsage(getOrCreateTracker(keyFingerprint(apiKey)), apiKey); + })(); // Read from the root configuration with the FULL setting key: section-scoped // reads (getConfiguration("opencodego")) resolve keys relative to the // section, which would misread the Zen flag as opencodego.opencodezen.enabled. @@ -1244,6 +1250,25 @@ function refreshGoUsageStatusBar(): void { goUsageStatusBarItem.tooltip = buildUsageTooltip(s, tracker.getCurrentSessionCost()); goUsageStatusBarItem.show(); updateWebviewContent(); + + // Refresh the server-accurate meters in the background (TTL-guarded); when + // a new snapshot lands, rebuild the status bar with it. + void (async () => { + const apiKey = await _extensionContext?.secrets.get(SECRET_KEY); + if (!apiKey || !tracker) return; + const changed = await tracker.syncServerUsage(apiKey); + if (changed) refreshGoUsageStatusBar(); + })(); +} + +/** + * Fetch server-accurate usage for a key and repaint the status bar when a new + * snapshot arrived. Uses the tracker owning that key (creating its profile on + * first use), so multi-account setups keep per-key meters. + */ +async function syncTrackerUsage(tracker: GoUsageTracker, apiKey: string): Promise { + const changed = await tracker.syncServerUsage(apiKey); + if (changed) refreshGoUsageStatusBar(); } function showUsageWebview(context: vscode.ExtensionContext): void { @@ -2375,6 +2400,9 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider Date: Wed, 12 Aug 2026 10:41:11 +0500 Subject: [PATCH 04/22] test(usage): cover server usage mapper and failure classification 7 tests for goUsageSync (pure module, no vscode dependency): - sends the key as a Bearer header to the official endpoint URL - parses a 200 payload (including rate-limited status) - classifies 401/403/404/5xx for fallback - refuses a missing key without issuing a request - classifies malformed payloads and network errors - overlays server percent + resetsAt per period - derives spent from the authoritative percent (full limit at 100%) - keeps local today/yesterday and summary metadata --- src/test/goUsageSync.test.ts | 113 +++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/test/goUsageSync.test.ts diff --git a/src/test/goUsageSync.test.ts b/src/test/goUsageSync.test.ts new file mode 100644 index 0000000..1f4991e --- /dev/null +++ b/src/test/goUsageSync.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fetchGoUsage, mergeServerUsage, GO_USAGE_API_URL, type GoUsageApiResponse } from "../goUsageSync"; +import type { UsageSummary } from "../goUsageTracker"; + +/** Mirrors GO_LIMITS — kept literal so the test never loads goUsageTracker (vscode). */ +const LIMITS = { session: 12, weekly: 30, monthly: 60 }; + +/** Minimal locally-computed summary used as the merge input. */ +function localSummary(): UsageSummary { + return { + session: { spent: 1, limit: LIMITS.session, percent: 8.3, resetsAt: new Date("2026-08-11T12:00:00Z") }, + weekly: { spent: 5, limit: LIMITS.weekly, percent: 16.7, resetsAt: new Date("2026-08-17T00:00:00Z") }, + monthly: { spent: 9, limit: LIMITS.monthly, percent: 15, resetsAt: new Date("2026-08-31T00:00:00Z") }, + today: { cost: 0.4, requests: 3, tokens: 4200 }, + yesterday: { cost: 1.1, requests: 8, tokens: 9800 }, + hasData: true, + sqliteAvailable: false, + }; +} + +function apiResponse(): GoUsageApiResponse { + return { + usage: { + rolling: { status: "ok", percent: 27, resetsAt: "2026-08-11T14:32:10.000Z" }, + weekly: { status: "ok", percent: 62, resetsAt: "2026-08-17T00:00:00.000Z" }, + monthly: { status: "rate-limited", percent: 100, resetsAt: "2026-08-31T00:00:00.000Z" }, + }, + }; +} + +function stubFetch(status: number, body: unknown): typeof fetch { + return (async () => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + })) as unknown as typeof fetch; +} + +test("fetchGoUsage — sends the key as Bearer to the official endpoint", async () => { + let requestedUrl = ""; + let authHeader = ""; + const fetcher = (async (url: RequestInfo | URL, init?: RequestInit) => { + requestedUrl = String(url); + authHeader = String((init?.headers && (init.headers as Record).Authorization) ?? ""); + return new Response(JSON.stringify(apiResponse()), { status: 200 }); + }) as unknown as typeof fetch; + + const result = await fetchGoUsage("sk-test", fetcher); + assert.equal(requestedUrl, GO_USAGE_API_URL); + assert.equal(authHeader, "Bearer sk-test"); + assert.equal(result.ok, true); +}); + +test("fetchGoUsage — parses a 200 payload", async () => { + const result = await fetchGoUsage("sk-test", stubFetch(200, apiResponse())); + assert.ok(result.ok); + if (result.ok) { + assert.equal(result.data.usage.rolling.percent, 27); + assert.equal(result.data.usage.monthly.status, "rate-limited"); + } +}); + +test("fetchGoUsage — classifies failures so callers can fall back", async () => { + assert.deepEqual(await fetchGoUsage("sk-test", stubFetch(401, {})), { ok: false, reason: "unauthorized" }); + assert.deepEqual(await fetchGoUsage("sk-test", stubFetch(403, {})), { ok: false, reason: "no-subscription" }); + assert.deepEqual(await fetchGoUsage("sk-test", stubFetch(404, {})), { ok: false, reason: "not-found" }); + assert.deepEqual(await fetchGoUsage("sk-test", stubFetch(500, {})), { ok: false, reason: "network" }); +}); + +test("fetchGoUsage — missing key is refused without any request", async () => { + let called = false; + const fetcher = (async () => { + called = true; + return new Response("", { status: 200 }); + }) as unknown as typeof fetch; + assert.deepEqual(await fetchGoUsage("", fetcher), { ok: false, reason: "no-key" }); + assert.equal(called, false); +}); + +test("fetchGoUsage — malformed payloads and network errors are classified", async () => { + assert.deepEqual(await fetchGoUsage("sk-test", stubFetch(200, { nope: true })), { ok: false, reason: "invalid" }); + assert.deepEqual(await fetchGoUsage("sk-test", stubFetch(200, "not json")), { ok: false, reason: "invalid" }); + const throwing = (async () => { + throw new TypeError("fetch failed"); + }) as unknown as typeof fetch; + assert.deepEqual(await fetchGoUsage("sk-test", throwing), { ok: false, reason: "network" }); +}); + +test("mergeServerUsage — overlays server percent and resetsAt per period", () => { + const merged = mergeServerUsage(localSummary(), apiResponse(), LIMITS); + assert.equal(merged.session.percent, 27); + assert.equal(merged.session.resetsAt.toISOString(), "2026-08-11T14:32:10.000Z"); + assert.equal(merged.weekly.percent, 62); + assert.equal(merged.monthly.percent, 100); + assert.equal(merged.monthly.resetsAt.toISOString(), "2026-08-31T00:00:00.000Z"); +}); + +test("mergeServerUsage — derives spent from the authoritative percent", () => { + const merged = mergeServerUsage(localSummary(), apiResponse(), LIMITS); + assert.equal(merged.session.spent, Math.round(LIMITS.session * 0.27 * 100) / 100); + assert.equal(merged.weekly.spent, Math.round(LIMITS.weekly * 0.62 * 100) / 100); + // rate-limited → 100% → full limit + assert.equal(merged.monthly.spent, LIMITS.monthly); +}); + +test("mergeServerUsage — keeps local today/yesterday and metadata", () => { + const merged = mergeServerUsage(localSummary(), apiResponse(), LIMITS); + assert.deepEqual(merged.today, localSummary().today); + assert.deepEqual(merged.yesterday, localSummary().yesterday); + assert.equal(merged.hasData, true); + assert.equal(merged.sqliteAvailable, false); +}); From f4cb6ebd733cb302968e521354e8c749ccd27726 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 10:42:54 +0500 Subject: [PATCH 05/22] docs(usage): document server-synced Go meters + changelog entry README Usage Tracking section: meters now come from the official /zen/go/v1/usage endpoint (account-wide, includes CLI/other devices), with local fallback; today/yesterday + per-session spend stay device local. CHANGELOG [Unreleased] entry for #130. Also pace retries after failed syncs too: an invalid key or unreachable endpoint must not hammer the API on every request (the TTL now covers failures as well as successes). --- CHANGELOG.md | 2 +- README.md | 9 ++++++++- src/goUsageTracker.ts | 8 +++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a7565..e667827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Added -- _Nothing yet._ +- **`[Usage]` Server-accurate Go meters via the official `/zen/go/v1/usage` endpoint (#130).** The status bar, tooltip, quick-pick and usage webview previously showed locally estimated Session/Weekly/Monthly percentages that drifted from opencode.ai (issue #23) because they missed CLI, cross-device and pre-install usage. The tracker now pulls the official endpoint (upstream anomalyco/opencode#16513, verified live) with the existing Go key on startup and after each request (60s TTL cache): rolling/weekly/monthly percent + reset times are server-computed and account-wide, `spent` is derived from the authoritative percent, and Today/Yesterday + per-session spend stay device-local. Failures (401/403/404/network) fall back to the existing SQLite → tracked estimates. The key is only ever sent as the Authorization header and never logged or persisted. New pure module `src/goUsageSync.ts` with unit tests. ## [0.5.2] — 2026-08-11 diff --git a/README.md b/README.md index 5229fe7..7a5626c 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,14 @@ Per-model reasoning configuration, dynamically enhanced with `reasoning_options` - **Go Usage Tracker** — real-time burn-rate of OpenCode Go subscription: - Tracks **5-hour rolling** ($12), **weekly** ($30), **monthly** ($60) tiers. - - Client-side cost calc: token usage × per-model pricing (input/output/cache_read). + - **Server-synced meters** — on startup and after each request the tracker + pulls the official `/zen/go/v1/usage` endpoint with your Go key, so the + Session/Weekly/Monthly percentages and "resets in" timers are + account-wide and server-accurate (includes CLI, other devices, anything + before the extension was installed). Falls back to local estimates when + the endpoint is unreachable or the key/subscription is invalid. + - Today / Yesterday and per-session spend stay **device-local** (the API + does not return them). - Status bar: `Go: 27%·62%·75%` — ⚠ warning when any tier exceeds 80%. - Persisted in VS Code `globalState` — survives restarts. - **Response usage bar** — latest prompt/output/total/cache summary after each response. diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index 1ac105a..a9d1b1b 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -456,7 +456,7 @@ export class GoUsageTracker { // Overlay the server-accurate meters when a recent snapshot exists // (fetched via syncServerUsage). Today / Yesterday / per-session spend // remain local. - return this.serverUsage ? mergeServerUsage(summary, this.serverUsage) : summary; + return this.serverUsage ? mergeServerUsage(summary, this.serverUsage, GO_LIMITS) : summary; } /** @@ -470,16 +470,18 @@ export class GoUsageTracker { */ async syncServerUsage(apiKey: string): Promise { const now = Date.now(); - if (this.serverUsage && now - this.serverUsageFetchedAt < GO_USAGE_SYNC_TTL_MS) { + if (this.serverUsageFetchedAt > 0 && now - this.serverUsageFetchedAt < GO_USAGE_SYNC_TTL_MS) { return false; } const result = await fetchGoUsage(apiKey); + // Pace retries after failures too — an invalid key or unreachable + // endpoint must not hammer the API on every request. + this.serverUsageFetchedAt = Date.now(); if (!result.ok) { this.log?.(`[go-usage] Server usage sync skipped (${result.reason}); keeping local estimates.`); return false; } this.serverUsage = result.data; - this.serverUsageFetchedAt = Date.now(); this.log?.("[go-usage] Server usage synced from /zen/go/v1/usage."); return true; } From fd50f248360228ac36fd0a89296ff05acc5d1d96 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 11:10:04 +0500 Subject: [PATCH 06/22] fix(usage): wire the Reset tracked data and Open console quick-pick actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usage quick-pick listed "Reset tracked usage data" and "Open OpenCode console" but neither item carried an action marker, so picking them did nothing (handler only knew setUsageTargets/showUsageDetails/switchProfile). - Reset now asks for modal confirmation, calls tracker.clear() (which also clears session costs now), repaints the status bar and webview, and confirms — the 6 device-local values (Today/Yesterday/session) reset, while server-synced meters are unaffected. - Open console opens https://opencode.ai in the browser. - The quick-pick also shows a "Synced from opencode.ai" note when server-accurate meters are in effect. --- src/extension.ts | 15 ++++++++++++++- src/goUsageSync.ts | 13 ++++++++----- src/goUsageTracker.ts | 22 +++++++++++++++++++--- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 4dc440c..eaf0442 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -770,7 +770,7 @@ export function activate(context: vscode.ExtensionContext) { const tracker = activeGoUsageTracker(); if (!tracker) return; const summary = tracker.getSummary(); - const items = buildUsageQuickPickItems(summary); + const items = buildUsageQuickPickItems(summary, tracker.hasServerUsage); const sessionCost = tracker.getCurrentSessionCost(); if (sessionCost && sessionCost.cost > 0) { @@ -832,6 +832,19 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.executeCommand("opencodego.setUsageTargets"); } else if (action === "showUsageDetails") { vscode.commands.executeCommand("opencodego.showUsageDetails"); + } else if (action === "openConsole") { + void vscode.env.openExternal(vscode.Uri.parse("https://opencode.ai")); + } else if (action === "resetTracked") { + const confirm = await vscode.window.showWarningMessage( + "Reset all locally tracked usage data (Today, Yesterday, session spend)? Server-synced meters are unaffected.", + { modal: true }, + "Reset", + ); + if (confirm !== "Reset") return; + tracker.clear(); + refreshGoUsageStatusBar(); + updateWebviewContent(); + vscode.window.showInformationMessage("Locally tracked usage data cleared."); } else if (action === "switchProfile" && "_fp" in picked) { setActiveProfile((picked as { _fp: string })._fp); } diff --git a/src/goUsageSync.ts b/src/goUsageSync.ts index cff6278..7e443e3 100644 --- a/src/goUsageSync.ts +++ b/src/goUsageSync.ts @@ -1,5 +1,4 @@ import type { UsageSummary } from "./goUsageTracker"; -import { GO_LIMITS } from "./goUsageTracker"; /** * Official OpenCode Go usage endpoint (upstream anomalyco/opencode#16513, @@ -108,7 +107,11 @@ export async function fetchGoUsage( * internally consistent (the percent itself is authoritative). Today / * Yesterday / per-session spend stay local — the API does not return them. */ -export function mergeServerUsage(summary: UsageSummary, api: GoUsageApiResponse): UsageSummary { +export function mergeServerUsage( + summary: UsageSummary, + api: GoUsageApiResponse, + limits: { session: number; weekly: number; monthly: number }, +): UsageSummary { const period = (server: GoUsagePeriod, limit: number): UsageSummary["session"] => ({ spent: Math.round(limit * (server.percent / 100) * 100) / 100, limit, @@ -118,8 +121,8 @@ export function mergeServerUsage(summary: UsageSummary, api: GoUsageApiResponse) return { ...summary, - session: period(api.usage.rolling, GO_LIMITS.session), - weekly: period(api.usage.weekly, GO_LIMITS.weekly), - monthly: period(api.usage.monthly, GO_LIMITS.monthly), + session: period(api.usage.rolling, limits.session), + weekly: period(api.usage.weekly, limits.weekly), + monthly: period(api.usage.monthly, limits.monthly), }; } diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index a9d1b1b..f859b67 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -811,10 +811,16 @@ export class GoUsageTracker { clear(): void { this.entries = []; this.baseline = {}; + this.sessionCosts.clear(); this.persist(); this.persistBaseline(); } + /** Whether a server-accurate usage snapshot is currently in effect. */ + get hasServerUsage(): boolean { + return this.serverUsage !== undefined; + } + private prune(): void { const cutoff = Date.now() - 31 * 24 * 60 * 60 * 1000; // 31 days this.entries = this.entries.filter((e) => e.timestamp > cutoff).slice(-MAX_LOG_ENTRIES); @@ -1024,7 +1030,7 @@ export function formatGoUsageLanguageStatusDetail(summary: UsageSummary): string } /** Build Quick Pick items for the usage panel */ -export function buildUsageQuickPickItems(summary: UsageSummary): vscode.QuickPickItem[] { +export function buildUsageQuickPickItems(summary: UsageSummary, syncedFromServer = false): vscode.QuickPickItem[] { const now = new Date(); const isEmpty = !summary.hasData; @@ -1051,6 +1057,14 @@ export function buildUsageQuickPickItems(summary: UsageSummary): vscode.QuickPic }); } + if (syncedFromServer) { + items.push({ + label: "$(cloud) Synced from opencode.ai", + detail: "Session/Weekly/Monthly meters are account-wide and server-accurate.", + alwaysShow: true, + }); + } + // ── Period bars ────────────────────────────────────────────────────────── items.push({ label: "Subscription Limits", kind: vscode.QuickPickItemKind.Separator }); @@ -1095,13 +1109,15 @@ export function buildUsageQuickPickItems(summary: UsageSummary): vscode.QuickPic label: "$(link-external) Open OpenCode console", description: "View usage at opencode.ai", alwaysShow: true, - }); + _action: "openConsole", + } as vscode.QuickPickItem & { _action: string }); items.push({ label: "$(trash) Reset tracked usage data", description: "Clears all locally tracked data", alwaysShow: true, - }); + _action: "resetTracked", + } as vscode.QuickPickItem & { _action: string }); return items; } From 0d46bcef8624505a4b7eaf691361f978aaf656e5 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 11:34:16 +0500 Subject: [PATCH 07/22] =?UTF-8?q?fix(usage):=20keep=20the=20usage=20card?= =?UTF-8?q?=20after=20reset=20=E2=80=94=20zeroed=20local=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tracker.clear() emptied the entries, which made getSummary() report hasData=false and collapsed the whole card into the first-run "No data yet" state — the reset wiped the display instead of zeroing the 6 local values. Add a persisted everTracked flag (set on first record and on reset, restored from globalState): after a reset the card stays up with Today/Yesterday/session at $0 · 0 req · 0 tokens while the meters keep their values (server-synced or local). Fresh installs still get the onboarding "No data" card. --- src/goUsageTracker.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index f859b67..28db49b 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -15,6 +15,7 @@ export type CostResolver = (modelId: string) => ModelCost | undefined; const STORAGE_KEY = "opencodego.usageLog.v1"; const BASELINE_STORAGE_KEY = "opencodego.usageBaseline.v1"; +const EVER_TRACKED_KEY = "opencodego.everTracked.v1"; const SESSION_COSTS_KEY = "opencodego.sessionCosts.v1"; const MAX_LOG_ENTRIES = 2000; @@ -32,7 +33,7 @@ const WEEK_MS = 7 * 24 * 60 * 60 * 1000; // This table is a static snapshot kept as a last resort. The primary source // is the live models.dev metadata cache injected via CostResolver. -const GO_MODEL_PRICING: Record = { +const GO_MODEL_PRICING: Record = { "glm-5.1": { input: 1.4, output: 4.4, cache_read: 0.26 }, "glm-5": { input: 1.0, output: 3.2, cache_read: 0.2 }, "kimi-k2.6": { input: 0.95, output: 4.0, cache_read: 0.16 }, @@ -291,6 +292,12 @@ function readOpenCodeHistory(): HistoryRow[] | null { export class GoUsageTracker { private entries: UsageLogEntry[] = []; + /** + * Whether this profile has ever recorded (or had cleared) local usage. + * Kept true after a reset so the usage card shows zeroed local values + * instead of collapsing into the first-run "no data" state. + */ + private everTracked = false; private baseline: UsageBaseline = {}; private readonly log?: (msg: string) => void; private costResolver?: CostResolver; @@ -406,6 +413,7 @@ export class GoUsageTracker { sessionId: summary.sessionId, copilotCredits, }); + this.markEverTracked(); // Accumulate per-session cost if (summary.sessionId) { @@ -696,7 +704,7 @@ export class GoUsageTracker { requests: yestReq, tokens: yestTokens, }, - hasData: this.entries.length > 0, + hasData: this.entries.length > 0 || this.everTracked, sqliteAvailable: false, }; } @@ -814,6 +822,16 @@ export class GoUsageTracker { this.sessionCosts.clear(); this.persist(); this.persistBaseline(); + // Keep the usage card alive with zeroed values instead of falling back + // to the first-run "no data" state. + this.markEverTracked(); + } + + /** Mark (and persist) that this profile has local usage history. */ + private markEverTracked(): void { + if (this.everTracked) return; + this.everTracked = true; + void this.context.globalState.update(this.storageKey(EVER_TRACKED_KEY), true); } /** Whether a server-accurate usage snapshot is currently in effect. */ @@ -887,6 +905,8 @@ export class GoUsageTracker { this.entries = stored.filter((e) => typeof e.timestamp === "number" && typeof e.cost === "number"); } + this.everTracked = this.context.globalState.get(this.storageKey(EVER_TRACKED_KEY), this.entries.length > 0); + const baseline = this.context.globalState.get(this.storageKey(BASELINE_STORAGE_KEY), {}); if (baseline && typeof baseline === "object") { this.baseline = baseline; From 17d90a728f06092fed272948f712346ee580343e Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 11:34:17 +0500 Subject: [PATCH 08/22] fix(tooling): resolve always-true/false TS hints flagged in editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sleep(): drop the settled/redundant post-subscribe check — VS Code's cancellation tokens invoke listeners registered after cancellation (shortcutEvent), so one subscription suffices and Promises ignore double settlement. - signalFromToken(): same redundant race-check removed. - onTransportSummary: remove the always-true if (tracker) guard (ensureProfileForApiKey is non-optional). - GO_MODEL_PRICING: Record — the table is genuinely incomplete, so the guard was right and the type was wrong. - CachedModelMetadataSnapshot.providers: Record<..., | undefined> with ?. at the two direct access sites — partial snapshots are real. - goUsageSync.test.ts: drop the always-true if (result.ok) after assert.ok() narrowing. --- src/extension.ts | 43 ++++++++++++++++-------------------- src/metadata.ts | 4 ++-- src/test/goUsageSync.test.ts | 6 ++--- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index eaf0442..19b8df9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -305,18 +305,19 @@ function isTransientFetchError(error: unknown): boolean { * * Used to back off between model-list fetch retries without leaking * CancellationToken subscriptions. + * + * A single subscription suffices for already-cancelled tokens: VS Code's + * cancellation tokens invoke listeners registered after cancellation + * (shortcutEvent), and Promises ignore double settlement. */ function sleep(ms: number, token?: vscode.CancellationToken): Promise { if (token?.isCancellationRequested) { return Promise.reject(new DOMException("Aborted", "AbortError")); } return new Promise((resolve, reject) => { - let settled = false; - const state: { subscription?: vscode.Disposable } = {}; + const state: { timer?: ReturnType; subscription?: vscode.Disposable } = {}; const finish = (cancelled: boolean) => { - if (settled) return; - settled = true; - clearTimeout(timer); + if (state.timer) clearTimeout(state.timer); state.subscription?.dispose(); if (cancelled) { reject(new DOMException("Aborted", "AbortError")); @@ -324,14 +325,9 @@ function sleep(ms: number, token?: vscode.CancellationToken): Promise { resolve(); } }; - const timer = setTimeout(() => finish(false), ms); + state.timer = setTimeout(() => finish(false), ms); if (token) { state.subscription = token.onCancellationRequested(() => finish(true)); - if (settled) { - state.subscription.dispose(); - } else if (token.isCancellationRequested) { - finish(true); - } } }); } @@ -2406,17 +2402,15 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider controller.abort()); - if (token.isCancellationRequested) controller.abort(); } return { signal: controller.signal, @@ -2790,7 +2785,7 @@ async function refreshOpenCodeModelMetadata( modelMetadataSnapshot = snapshot; await context.globalState.update(MODEL_METADATA_CACHE_KEY, snapshot); output?.appendLine( - `[metadata] refreshed models.dev cache go=${Object.keys(snapshot.providers[GO_VENDOR]).length} zen=${Object.keys(snapshot.providers[ZEN_VENDOR]).length}`, + `[metadata] refreshed models.dev cache go=${Object.keys(snapshot.providers[GO_VENDOR] ?? {}).length} zen=${Object.keys(snapshot.providers[ZEN_VENDOR] ?? {}).length}`, ); return snapshot; })() diff --git a/src/metadata.ts b/src/metadata.ts index 83b21af..51d6978 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -54,7 +54,7 @@ export interface ModelMetadataFields { export interface CachedModelMetadataSnapshot { fetchedAt: number; - providers: Record>; + providers: Record | undefined>; } export interface ResolvedModelMetadata extends BaseModelLimits { @@ -356,7 +356,7 @@ export function resolveModelMetadata( snapshot: CachedModelMetadataSnapshot, liveModelMetadataById: Map, ): ResolvedModelMetadata { - const cachedMetadata = snapshot.providers[vendor][modelId]; + const cachedMetadata = snapshot.providers[vendor]?.[modelId]; const liveMetadata = liveModelMetadataById.get(modelId); const fallbackMetadata = fallbackModelMetadata(modelId, vendor); diff --git a/src/test/goUsageSync.test.ts b/src/test/goUsageSync.test.ts index 1f4991e..ca5b388 100644 --- a/src/test/goUsageSync.test.ts +++ b/src/test/goUsageSync.test.ts @@ -55,10 +55,8 @@ test("fetchGoUsage — sends the key as Bearer to the official endpoint", async test("fetchGoUsage — parses a 200 payload", async () => { const result = await fetchGoUsage("sk-test", stubFetch(200, apiResponse())); assert.ok(result.ok); - if (result.ok) { - assert.equal(result.data.usage.rolling.percent, 27); - assert.equal(result.data.usage.monthly.status, "rate-limited"); - } + assert.equal(result.data.usage.rolling.percent, 27); + assert.equal(result.data.usage.monthly.status, "rate-limited"); }); test("fetchGoUsage — classifies failures so callers can fall back", async () => { From 2a92daf549db981e46b44483549f1c70cdffffc7 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 12:08:16 +0500 Subject: [PATCH 09/22] fix(usage): stable, consistently spaced usage card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container fixes for the usage dialog: - Today and Yesterday rows are always rendered (zeros included), so the card no longer shrinks when a day has no usage. - Fixed 440px card width and fixed value columns — previously the card widened and the cost column shifted when session data appeared, making the layout jump between renders. - Uniform spacing: content starts at the same 14px gutter on every side (title top, left/right), right-aligned "Resets in" and percent now sit at the card's right gutter (was 410, leaving a 30px right margin), and the meter blocks, divider and device rows follow a consistent rhythm. - No-data card sized with the same gutters. - Webview renders the card at its natural 440px size instead of stretching it to the container width. - Hover renders the image at its natural 440px width (was 420). Hover action links keep their command-link form (markdown hovers cannot render styled buttons) but now sit behind a divider with even spacing. --- src/extension.ts | 102 +++++++++++++++++++++++------------------------ 1 file changed, 50 insertions(+), 52 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 19b8df9..b9ac6fc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1336,7 +1336,7 @@ function updateWebviewContent(): void { } .container { width: 100%; - max-width: 560px; + max-width: 440px; display: flex; flex-direction: column; align-items: center; @@ -1344,6 +1344,7 @@ function updateWebviewContent(): void { } svg { width: 100%; + max-width: 440px; height: auto; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); @@ -1376,10 +1377,10 @@ function buildUsageTooltip( } (md as vscode.MarkdownString & { supportedCommands?: string[] }).supportedCommands = commands; - md.appendMarkdown(`Go usage summary`); - md.appendMarkdown("\n\n[$(pencil) Set spent targets](command:opencodego.setUsageTargets)"); + md.appendMarkdown(`Go usage summary`); + md.appendMarkdown("\n\n---\n\n[$(pencil) Set spent targets](command:opencodego.setUsageTargets)"); if (nonLegacyCount(profilesCache) > 0) { - md.appendMarkdown(" \u00B7 [$(pencil) Rename](command:opencodego.renameActiveProfile)"); + md.appendMarkdown(" [$(pencil) Rename](command:opencodego.renameActiveProfile)"); } return md; } @@ -1503,11 +1504,11 @@ function buildUsageTooltipSvg( profileLabel?: string, ): string { const hasSession = sc && sc.cost > 0; - // Session label is longer ("Session (est):") so widen the card and shift - // the cost column right when session data is present. - const cx = hasSession ? 120 : 80; // cost value column - const width = hasSession ? 440 : 420; - const height = s.hasData ? (hasSession ? 310 : 286) : 78; + // Stable geometry: fixed card width and fixed columns, so the layout never + // shifts when session data appears or a day has no usage yet. + const width = 440; + const padX = 14; + const right = width - padX; const bg = "#1e1e1e"; const fg = "#d4d4d4"; const muted = "#a6a6a6"; @@ -1531,60 +1532,57 @@ function buildUsageTooltipSvg( ].join(""); }; + // Meter block with a uniform 14px gutter between blocks: label row with the + // reset time right-aligned at the card's right padding, then the bar and + // the spent/limit line below it. const period = (label: string, p: _UsageSummary["session"], y: number): string => [ - text(label, 14, y, 14, 700), - text(`Resets in ${rel(p.resetsAt)}`, 410, y, 12, 400, muted, "end"), - bar(p.percent, 14, y + 12, 340), - text(`${p.percent.toFixed(1)}%`, 410, y + 19, 14, 700, fg, "end"), - text(`${usd(p.spent)} / ${usd(p.limit)} used`, 14, y + 34, 13, 400, fg), + text(label, padX, y, 14, 700), + text(`Resets in ${rel(p.resetsAt)}`, right, y, 12, 400, muted, "end"), + bar(p.percent, padX, y + 14, 340), + text(`${p.percent.toFixed(1)}%`, right, y + 21, 14, 700, fg, "end"), + text(`${usd(p.spent)} / ${usd(p.limit)} used`, padX, y + 36, 13, 400, fg), + ].join(""); + + // Device-local rows share one fixed column grid: label, cost, requests, + // tokens. Always rendered (zeros included) so the card height is stable. + const deviceRow = (label: string, cost: number, requests: number, tokenCount: number, y: number): string => + [ + text(label, padX, y, 13, 400, muted), + text(usd(cost), 120, y, 13, 700), + text("Requests:", 190, y, 13, 400, muted), + text(String(requests), 262, y, 13, 700), + text("Tokens:", 305, y, 13, 400, muted), + text(tokens(tokenCount), 385, y, 13, 700), ].join(""); if (!s.hasData) { - return ` + return ` -${text(svgTitle, 14, 26, 16, 700)} -${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", 14, 50, 12, 400, muted)} +${text(svgTitle, padX, 28, 16, 700)} +${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", padX, 52, 12, 400, muted)} `; } + // Title starts at the same 14px gutter as the sides. Meter rows, the + // divider and the device rows keep a consistent 14px rhythm. + const meterRows = [["Session (5h rolling)", s.session, 56], ["Weekly", s.weekly, 116], ["Monthly", s.monthly, 176]] as const; + const dividerY = 226; + const firstRowY = 248; + const rowGap = 24; + const deviceRows: Array<[string, number, number, number, number]> = []; + if (hasSession) deviceRows.push(["Session (est):", sc!.cost, sc!.requests, sc!.promptTokens + sc!.completionTokens, firstRowY]); + deviceRows.push(["Today:", s.today.cost, s.today.requests, s.today.tokens, firstRowY + (hasSession ? rowGap : 0)]); + deviceRows.push(["Yesterday:", s.yesterday.cost, s.yesterday.requests, s.yesterday.tokens, firstRowY + 2 * rowGap]); + + const height = firstRowY + 2 * rowGap + 14; + return ` -${text(svgTitle, 14, 26, 16, 700)} -${period("Session (5h rolling)", s.session, 54)} -${period("Weekly", s.weekly, 116)} -${period("Monthly", s.monthly, 178)} - -${ - hasSession - ? [ - text("Session (est):", 14, 250, 13, 400, muted), - text(`$${sc!.cost.toFixed(4)}`, cx, 250, 13, 700), - text("Requests:", 200, 250, 13, 400, muted), - text(String(sc!.requests), 280, 250, 13, 700), - text("Tokens:", 320, 250, 13, 400, muted), - text(tokens(sc!.promptTokens + sc!.completionTokens), 400, 250, 13, 700), - ].join("") - : "" -} -${text("Today:", 14, hasSession ? 274 : 256, 13, 400, muted)} -${text(usd(s.today.cost), cx, hasSession ? 274 : 256, 13, 700)} -${text("Requests:", 200, hasSession ? 274 : 256, 13, 400, muted)} -${text(String(s.today.requests), 280, hasSession ? 274 : 256, 13, 700)} -${text("Tokens:", 320, hasSession ? 274 : 256, 13, 400, muted)} -${text(tokens(s.today.tokens), 400, hasSession ? 274 : 256, 13, 700)} -${ - s.yesterday.requests > 0 - ? [ - text("Yesterday:", 14, hasSession ? 298 : 278, 13, 400, muted), - text(usd(s.yesterday.cost), cx, hasSession ? 298 : 278, 13, 700), - text("Requests:", 200, hasSession ? 298 : 278, 13, 400, muted), - text(String(s.yesterday.requests), 280, hasSession ? 298 : 278, 13, 700), - text("Tokens:", 320, hasSession ? 298 : 278, 13, 400, muted), - text(tokens(s.yesterday.tokens), 400, hasSession ? 298 : 278, 13, 700), - ].join("") - : "" -} +${text(svgTitle, padX, 28, 16, 700)} +${meterRows.map(([label, periodValue, y]) => period(label, periodValue, y)).join("")} + +${deviceRows.map(([label, cost, requests, tokenCount, y]) => deviceRow(label, cost, requests, tokenCount, y)).join("")} `; } From d74e77f817973a6f1be821f7d13d264193ec0168 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 12:31:02 +0500 Subject: [PATCH 10/22] feat(usage): usage dialog as a clean 320px HTML card The usage webview now renders a compact card: 320px, 6px radius, 14/16px padding, neutral widget colors, three meter sections (label + resets row, 4px progress bar, used/percent row), a divider, Today/Yesterday as three-column stats, and a footer with Set spent targets / Rename links. Theme-aware via VS Code CSS variables. --- src/extension.ts | 137 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 114 insertions(+), 23 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index b9ac6fc..1a09613 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1310,7 +1310,6 @@ function updateWebviewContent(): void { return; } const s = tracker.getSummary(); - const sc = tracker.getCurrentSessionCost(); const activeProfile = findProfile(profilesCache, activeProfileFingerprint); const profileLabel = activeProfile?.label ?? "OpenCode Go"; @@ -1322,45 +1321,137 @@ function updateWebviewContent(): void { OpenCode Usage Summary — ${escapeSvg(profileLabel)} -
- ${buildUsageTooltipSvg(s, sc)} +
+
${escapeSvg(profileLabel)} - Usage
+ + ${usageCardSectionHtml("Session (5h rolling)", s.session)} + ${usageCardSectionHtml("Weekly", s.weekly)} + ${usageCardSectionHtml("Monthly", s.monthly)} + +
+ + ${usageCardStatsHtml("Today", s.today)} + ${usageCardStatsHtml("Yesterday", s.yesterday)} + +
`; } +/** One meter section: label + resets, bar, used + percent. */ +function usageCardSectionHtml(label: string, p: _UsageSummary["session"]): string { + const pct = p.percent.toFixed(1); + const width = Math.min(Math.max(p.percent, 0), 100); + return [ + '
', + '
', + `${escapeSvg(label)}`, + `Resets in ${escapeSvg(rel(p.resetsAt))}`, + "
", + `
`, + '
', + `${escapeSvg(`${usd(p.spent)} / ${usd(p.limit)} used`)}`, + `${pct}%`, + "
", + "
", + ].join(""); +} + +/** One stats row: three label-over-value columns. */ +function usageCardStatsHtml(label: string, day: _UsageSummary["today"]): string { + return [ + '
', + `
${escapeSvg(label)}
${escapeSvg(usd(day.cost))}
`, + `
Requests
${day.requests}
`, + `
Tokens
${escapeSvg(tokens(day.tokens))}
`, + "
", + ].join(""); +} + function buildUsageTooltip( s: ReturnType, sessionCost?: { cost: number; requests: number; promptTokens: number; completionTokens: number }, From a2ad33d723948ac2f8408a506870b9986b57d65a Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 12:31:42 +0500 Subject: [PATCH 11/22] style(usage): match the hover card to the usage panel design The status-bar hover card now uses the same compact 320px layout and palette as the usage panel: 16px gutters, 13px/600 title, meter sections with a 4px progress bar (blue fill) and a used/percent row, a full-width divider, and Today/Yesterday as three-column stats. --- src/extension.ts | 90 +++++++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 50 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 1a09613..8ff160d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1594,19 +1594,17 @@ function buildUsageTooltipSvg( sc?: { cost: number; requests: number; promptTokens: number; completionTokens: number }, profileLabel?: string, ): string { - const hasSession = sc && sc.cost > 0; - // Stable geometry: fixed card width and fixed columns, so the layout never - // shifts when session data appears or a day has no usage yet. - const width = 440; - const padX = 14; + // Compact 320px card with a uniform 16px gutter, matching the usage panel. + const width = 320; + const padX = 16; const right = width - padX; - const bg = "#1e1e1e"; - const fg = "#d4d4d4"; - const muted = "#a6a6a6"; + const bg = "#252526"; + const fg = "#cccccc"; + const muted = "#9d9d9d"; const track = "#3c3c3c"; - const accent = "#73c991"; - const line = "#333333"; - const font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + const accent = "#3794ff"; + const line = "#3c3c3c"; + const font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Ubuntu, sans-serif"; const svgTitle = escapeSvg(profileLabel ? `${profileLabel} - Usage` : "OpenCode Go - Usage"); const noDataMsg = s.hasData ? null : nonLegacyCount(profilesCache) > 0 ? "No data yet for this profile." : "No usage data yet."; @@ -1618,62 +1616,54 @@ function buildUsageTooltipSvg( const clamped = Math.min(Math.max(pct, 0), 100); const fillWidth = Math.max(0, Math.round((clamped / 100) * barWidth)); return [ - ``, - fillWidth > 0 ? `` : "", + ``, + fillWidth > 0 ? `` : "", ].join(""); }; - // Meter block with a uniform 14px gutter between blocks: label row with the - // reset time right-aligned at the card's right padding, then the bar and - // the spent/limit line below it. + // Meter section: label + resets row (6px), 4px bar (6px), used + percent + // row (16px), next section 2px later — a 38px pitch. const period = (label: string, p: _UsageSummary["session"], y: number): string => [ - text(label, padX, y, 14, 700), - text(`Resets in ${rel(p.resetsAt)}`, right, y, 12, 400, muted, "end"), - bar(p.percent, padX, y + 14, 340), - text(`${p.percent.toFixed(1)}%`, right, y + 21, 14, 700, fg, "end"), - text(`${usd(p.spent)} / ${usd(p.limit)} used`, padX, y + 36, 13, 400, fg), + text(label, padX, y, 13, 600), + text(`Resets in ${rel(p.resetsAt)}`, right, y, 11, 400, muted, "end"), + bar(p.percent, padX, y + 10, right - padX), + text(`${usd(p.spent)} / ${usd(p.limit)} used`, padX, y + 22, 12, 400, muted), + text(`${p.percent.toFixed(1)}%`, right, y + 22, 12, 600), ].join(""); - // Device-local rows share one fixed column grid: label, cost, requests, - // tokens. Always rendered (zeros included) so the card height is stable. - const deviceRow = (label: string, cost: number, requests: number, tokenCount: number, y: number): string => + // Stats row: three label-over-value columns (Today / Yesterday). + const statsRow = (label: string, cost: number, requests: number, tokenCount: number, labelY: number, valueY: number): string => [ - text(label, padX, y, 13, 400, muted), - text(usd(cost), 120, y, 13, 700), - text("Requests:", 190, y, 13, 400, muted), - text(String(requests), 262, y, 13, 700), - text("Tokens:", 305, y, 13, 400, muted), - text(tokens(tokenCount), 385, y, 13, 700), + text(label, padX, labelY, 11, 400, muted), + text(usd(cost), padX, valueY, 13, 600), + text("Requests", 140, labelY, 11, 400, muted), + text(String(requests), 140, valueY, 13, 600), + text("Tokens", right, labelY, 11, 400, muted, "end"), + text(tokens(tokenCount), right, valueY, 13, 600, "end"), ].join(""); if (!s.hasData) { - return ` - -${text(svgTitle, padX, 28, 16, 700)} -${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", padX, 52, 12, 400, muted)} + return ` + +${text(svgTitle, padX, 24, 13, 600)} +${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", padX, 48, 12, 400, muted)} `; } - // Title starts at the same 14px gutter as the sides. Meter rows, the - // divider and the device rows keep a consistent 14px rhythm. - const meterRows = [["Session (5h rolling)", s.session, 56], ["Weekly", s.weekly, 116], ["Monthly", s.monthly, 176]] as const; - const dividerY = 226; - const firstRowY = 248; - const rowGap = 24; - const deviceRows: Array<[string, number, number, number, number]> = []; - if (hasSession) deviceRows.push(["Session (est):", sc!.cost, sc!.requests, sc!.promptTokens + sc!.completionTokens, firstRowY]); - deviceRows.push(["Today:", s.today.cost, s.today.requests, s.today.tokens, firstRowY + (hasSession ? rowGap : 0)]); - deviceRows.push(["Yesterday:", s.yesterday.cost, s.yesterday.requests, s.yesterday.tokens, firstRowY + 2 * rowGap]); - - const height = firstRowY + 2 * rowGap + 14; + // Title (y=24), three sections at 38/76/114, divider at 150, stats rows + // with a 26px pitch, and a 14px bottom gutter. + const meterRows = [["Session (5h rolling)", s.session, 38], ["Weekly", s.weekly, 76], ["Monthly", s.monthly, 114]] as const; + const dividerY = 150; + const height = 215; return ` - -${text(svgTitle, padX, 28, 16, 700)} + +${text(svgTitle, padX, 24, 13, 600)} ${meterRows.map(([label, periodValue, y]) => period(label, periodValue, y)).join("")} - -${deviceRows.map(([label, cost, requests, tokenCount, y]) => deviceRow(label, cost, requests, tokenCount, y)).join("")} + +${statsRow("Today", s.today.cost, s.today.requests, s.today.tokens, 162, 176)} +${statsRow("Yesterday", s.yesterday.cost, s.yesterday.requests, s.yesterday.tokens, 188, 202)} `; } From 6fc76cf7b71e0714ff497c55fe7b57b8bca6bada Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 13:03:30 +0500 Subject: [PATCH 12/22] revert(usage): restore previous hover card styling The 320px reference-style hover card gets wrapped by VS Code's hover widget chrome and cannot look like the standalone design. Restore the previous hover card (stable 440px geometry, always-visible Today and Yesterday rows, consistent 14px gutters, divider and right-aligned resets/percent) while keeping the usage panel as the reference HTML card. --- src/extension.ts | 90 +++++++++++++++++++++++++++--------------------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 8ff160d..1a09613 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1594,17 +1594,19 @@ function buildUsageTooltipSvg( sc?: { cost: number; requests: number; promptTokens: number; completionTokens: number }, profileLabel?: string, ): string { - // Compact 320px card with a uniform 16px gutter, matching the usage panel. - const width = 320; - const padX = 16; + const hasSession = sc && sc.cost > 0; + // Stable geometry: fixed card width and fixed columns, so the layout never + // shifts when session data appears or a day has no usage yet. + const width = 440; + const padX = 14; const right = width - padX; - const bg = "#252526"; - const fg = "#cccccc"; - const muted = "#9d9d9d"; + const bg = "#1e1e1e"; + const fg = "#d4d4d4"; + const muted = "#a6a6a6"; const track = "#3c3c3c"; - const accent = "#3794ff"; - const line = "#3c3c3c"; - const font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Ubuntu, sans-serif"; + const accent = "#73c991"; + const line = "#333333"; + const font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; const svgTitle = escapeSvg(profileLabel ? `${profileLabel} - Usage` : "OpenCode Go - Usage"); const noDataMsg = s.hasData ? null : nonLegacyCount(profilesCache) > 0 ? "No data yet for this profile." : "No usage data yet."; @@ -1616,54 +1618,62 @@ function buildUsageTooltipSvg( const clamped = Math.min(Math.max(pct, 0), 100); const fillWidth = Math.max(0, Math.round((clamped / 100) * barWidth)); return [ - ``, - fillWidth > 0 ? `` : "", + ``, + fillWidth > 0 ? `` : "", ].join(""); }; - // Meter section: label + resets row (6px), 4px bar (6px), used + percent - // row (16px), next section 2px later — a 38px pitch. + // Meter block with a uniform 14px gutter between blocks: label row with the + // reset time right-aligned at the card's right padding, then the bar and + // the spent/limit line below it. const period = (label: string, p: _UsageSummary["session"], y: number): string => [ - text(label, padX, y, 13, 600), - text(`Resets in ${rel(p.resetsAt)}`, right, y, 11, 400, muted, "end"), - bar(p.percent, padX, y + 10, right - padX), - text(`${usd(p.spent)} / ${usd(p.limit)} used`, padX, y + 22, 12, 400, muted), - text(`${p.percent.toFixed(1)}%`, right, y + 22, 12, 600), + text(label, padX, y, 14, 700), + text(`Resets in ${rel(p.resetsAt)}`, right, y, 12, 400, muted, "end"), + bar(p.percent, padX, y + 14, 340), + text(`${p.percent.toFixed(1)}%`, right, y + 21, 14, 700, fg, "end"), + text(`${usd(p.spent)} / ${usd(p.limit)} used`, padX, y + 36, 13, 400, fg), ].join(""); - // Stats row: three label-over-value columns (Today / Yesterday). - const statsRow = (label: string, cost: number, requests: number, tokenCount: number, labelY: number, valueY: number): string => + // Device-local rows share one fixed column grid: label, cost, requests, + // tokens. Always rendered (zeros included) so the card height is stable. + const deviceRow = (label: string, cost: number, requests: number, tokenCount: number, y: number): string => [ - text(label, padX, labelY, 11, 400, muted), - text(usd(cost), padX, valueY, 13, 600), - text("Requests", 140, labelY, 11, 400, muted), - text(String(requests), 140, valueY, 13, 600), - text("Tokens", right, labelY, 11, 400, muted, "end"), - text(tokens(tokenCount), right, valueY, 13, 600, "end"), + text(label, padX, y, 13, 400, muted), + text(usd(cost), 120, y, 13, 700), + text("Requests:", 190, y, 13, 400, muted), + text(String(requests), 262, y, 13, 700), + text("Tokens:", 305, y, 13, 400, muted), + text(tokens(tokenCount), 385, y, 13, 700), ].join(""); if (!s.hasData) { - return ` - -${text(svgTitle, padX, 24, 13, 600)} -${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", padX, 48, 12, 400, muted)} + return ` + +${text(svgTitle, padX, 28, 16, 700)} +${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", padX, 52, 12, 400, muted)} `; } - // Title (y=24), three sections at 38/76/114, divider at 150, stats rows - // with a 26px pitch, and a 14px bottom gutter. - const meterRows = [["Session (5h rolling)", s.session, 38], ["Weekly", s.weekly, 76], ["Monthly", s.monthly, 114]] as const; - const dividerY = 150; - const height = 215; + // Title starts at the same 14px gutter as the sides. Meter rows, the + // divider and the device rows keep a consistent 14px rhythm. + const meterRows = [["Session (5h rolling)", s.session, 56], ["Weekly", s.weekly, 116], ["Monthly", s.monthly, 176]] as const; + const dividerY = 226; + const firstRowY = 248; + const rowGap = 24; + const deviceRows: Array<[string, number, number, number, number]> = []; + if (hasSession) deviceRows.push(["Session (est):", sc!.cost, sc!.requests, sc!.promptTokens + sc!.completionTokens, firstRowY]); + deviceRows.push(["Today:", s.today.cost, s.today.requests, s.today.tokens, firstRowY + (hasSession ? rowGap : 0)]); + deviceRows.push(["Yesterday:", s.yesterday.cost, s.yesterday.requests, s.yesterday.tokens, firstRowY + 2 * rowGap]); + + const height = firstRowY + 2 * rowGap + 14; return ` - -${text(svgTitle, padX, 24, 13, 600)} + +${text(svgTitle, padX, 28, 16, 700)} ${meterRows.map(([label, periodValue, y]) => period(label, periodValue, y)).join("")} - -${statsRow("Today", s.today.cost, s.today.requests, s.today.tokens, 162, 176)} -${statsRow("Yesterday", s.yesterday.cost, s.yesterday.requests, s.yesterday.tokens, 188, 202)} + +${deviceRows.map(([label, cost, requests, tokenCount, y]) => deviceRow(label, cost, requests, tokenCount, y)).join("")} `; } From e7e031017e8552681d54f9ecf5bd5fc17922ce84 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 15:28:24 +0500 Subject: [PATCH 13/22] style(usage): uniform card padding in the usage panel The card used 14px top/bottom padding against 16px left/right. Make the padding uniform (16px) so the spacing around the content matches on all four sides. --- src/extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extension.ts b/src/extension.ts index 7044a55..5f62973 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1377,7 +1377,7 @@ function updateWebviewContent(): void { border-radius: 6px; color: var(--text); font-size: 13px; - padding: 14px 16px; + padding: 16px; } .title { font-size: 13px; From a562879b8c187da33170b6839334fd29cda0218f Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 19:30:05 +0500 Subject: [PATCH 14/22] style(usage): transparent hover card, tighter rows, aligned links - Remove the card's background rect: the hover widget's own background shows through, so there is no visible container edge and the fixed 4px/8px hover padding no longer looks inconsistent (the hover's padding is VS Code chrome and cannot be changed from the extension). - Tighten the Today/Yesterday/session row gap in the stats block. - The Set spent targets / Rename links start at the same left position as the card content (drop the extra indent) so the dialog uses one consistent left margin throughout. --- src/extension.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 5f62973..e22a8f3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1633,7 +1633,6 @@ function buildUsageTooltipSvg( const width = 440; const padX = 14; const right = width - padX; - const bg = "#1e1e1e"; const fg = "#d4d4d4"; const muted = "#a6a6a6"; const track = "#3c3c3c"; @@ -1681,8 +1680,7 @@ function buildUsageTooltipSvg( ].join(""); if (!s.hasData) { - return ` -${text(svgTitle, padX, 28, 16, 700)} + return `${text(svgTitle, padX, 28, 16, 700)} ${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", padX, 52, 12, 400, muted)} `; } @@ -1696,7 +1694,7 @@ ${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", ] as const; const dividerY = 226; const firstRowY = 248; - const rowGap = 24; + const rowGap = 10; const session = sc && sc.cost > 0 ? sc : undefined; const deviceRows: Array<[string, number, number, number, number]> = []; if (session) @@ -1707,7 +1705,6 @@ ${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", const height = firstRowY + 2 * rowGap + 14; return ` - ${text(svgTitle, padX, 28, 16, 700)} ${meterRows.map(([label, periodValue, y]) => period(label, periodValue, y)).join("")} From a7280e5ddaf6825ed0f2ec4017becd0d79e0cce7 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 19:47:51 +0500 Subject: [PATCH 15/22] =?UTF-8?q?chore(hooks):=20lint-staged=20runs=20chec?= =?UTF-8?q?k-only=20=E2=80=94=20never=20rewrites=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-commit gate previously auto-fixed staged files (prettier --write, eslint --fix, markdownlint --fix). Per policy, linters must never modify or rename files at commit time: a violation now fails the commit with a clear error and the user fixes it (or runs npm run format / lint:fix) explicitly. lint-staged is now prettier --check + eslint --max-warnings 0 + markdownlint without --fix; scripts/staged-lint.ts and npm run lint were already check-only. Verified: an unformatted staged file blocks the commit and stays byte-identical. --- .husky/pre-commit | 9 ++++----- package.json | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 1a4e69c..e35db6b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -15,10 +15,9 @@ if ! command -v npx >/dev/null 2>&1; then fi # Zero-tolerance gate on exactly what this change can affect: the staged -# files plus the files that import them (see scripts/staged-lint.ts). Runs -# BEFORE formatting so the linter sees the file as written. The full-tree -# lint (including tests) runs in CI and on demand via `npm run lint`. +# files plus the files that import them (see scripts/staged-lint.ts). All +# linters run in CHECK mode only — nothing is ever rewritten or renamed at +# commit time; a violation blocks the commit with an error and the user +# fixes it (or runs `npm run format` / `npm run lint:fix`) themselves. npx tsx scripts/staged-lint.ts - -# Format changed files (code + non-code) after linting passes. npx lint-staged diff --git a/package.json b/package.json index 2c0b517..c1f3a96 100644 --- a/package.json +++ b/package.json @@ -381,9 +381,9 @@ "prepare": "husky" }, "lint-staged": { - "*": "prettier --write --ignore-unknown", - "*.{js,cjs,ts}": "eslint --fix --max-warnings 0", - "*.md": "markdownlint-cli2 --config .markdownlint-cli2.json --fix", + "*": "prettier --check --ignore-unknown", + "*.{js,cjs,ts}": "eslint --max-warnings 0", + "*.md": "markdownlint-cli2 --config .markdownlint-cli2.json", ".husky/*": "shellcheck" }, "devDependencies": { From 54b5bcd06bad5ec4041690286fe3b306c46f4a66 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 20:01:09 +0500 Subject: [PATCH 16/22] style(usage): restore the stats row gap, keep the transparent card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit bundled two UI changes: the hover card's background removal (keep — it resolves the hover padding mismatch) and a tightened Today/Yesterday/session row gap (revert to 24px). --- src/extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extension.ts b/src/extension.ts index e22a8f3..49e7b14 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1694,7 +1694,7 @@ ${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", ] as const; const dividerY = 226; const firstRowY = 248; - const rowGap = 10; + const rowGap = 24; const session = sc && sc.cost > 0 ? sc : undefined; const deviceRows: Array<[string, number, number, number, number]> = []; if (session) From 7602d8e3648c4ca6406a14a0d4daf2ff678fa5ec Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 20:11:49 +0500 Subject: [PATCH 17/22] style(usage): drop the hover's bottom action section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both actions are reachable from the Command Palette (opencodego.setUsageTargets, opencodego.renameActiveProfile), so the divider + Set spent targets / Rename links below the summary card are removed — the hover now shows the card only, without the bottom section. --- src/extension.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 49e7b14..d0cfdba 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1492,17 +1492,10 @@ function buildUsageTooltip( const activeProfile = findProfile(profilesCache, activeProfileFingerprint); const profileLabel = activeProfile?.label ?? "OpenCode Go"; - const commands = ["opencodego.setUsageTargets"]; - if (nonLegacyCount(profilesCache) > 0) { - commands.push("opencodego.renameActiveProfile"); - } - (md as vscode.MarkdownString & { supportedCommands?: string[] }).supportedCommands = commands; - + // The hover shows the summary card only; Set spent targets / Rename are + // available from the Command Palette (opencodego.setUsageTargets, + // opencodego.renameActiveProfile). md.appendMarkdown(`Go usage summary`); - md.appendMarkdown("\n\n---\n\n[$(pencil) Set spent targets](command:opencodego.setUsageTargets)"); - if (nonLegacyCount(profilesCache) > 0) { - md.appendMarkdown(" [$(pencil) Rename](command:opencodego.renameActiveProfile)"); - } return md; } From c77d7f393bd1a700136081ddad5b22464eb4678d Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 20:23:21 +0500 Subject: [PATCH 18/22] style(usage): always render all three stats rows in the hover card The Session (est) row was hidden while no session was active, which made the card height and layout shift. All three rows (Session, Today, Yesterday) are now always rendered with zeros, keeping the card stable. --- src/extension.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index d0cfdba..97fcd81 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1620,7 +1620,6 @@ function buildUsageTooltipSvg( sc?: { cost: number; requests: number; promptTokens: number; completionTokens: number }, profileLabel?: string, ): string { - const hasSession = sc && sc.cost > 0; // Stable geometry: fixed card width and fixed columns, so the layout never // shifts when session data appears or a day has no usage yet. const width = 440; @@ -1688,11 +1687,18 @@ ${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", const dividerY = 226; const firstRowY = 248; const rowGap = 24; - const session = sc && sc.cost > 0 ? sc : undefined; + // All three rows are always rendered (zeros included) so the card is + // stable regardless of whether a session is currently active. + const sessionCost = sc && sc.cost > 0 ? sc : { cost: 0, requests: 0, promptTokens: 0, completionTokens: 0 }; const deviceRows: Array<[string, number, number, number, number]> = []; - if (session) - deviceRows.push(["Session (est):", session.cost, session.requests, session.promptTokens + session.completionTokens, firstRowY]); - deviceRows.push(["Today:", s.today.cost, s.today.requests, s.today.tokens, firstRowY + (hasSession ? rowGap : 0)]); + deviceRows.push([ + "Session (est):", + sessionCost.cost, + sessionCost.requests, + sessionCost.promptTokens + sessionCost.completionTokens, + firstRowY, + ]); + deviceRows.push(["Today:", s.today.cost, s.today.requests, s.today.tokens, firstRowY + rowGap]); deviceRows.push(["Yesterday:", s.yesterday.cost, s.yesterday.requests, s.yesterday.tokens, firstRowY + 2 * rowGap]); const height = firstRowY + 2 * rowGap + 14; From e82e55d50fb005b59ab7103cf650016ab06f6a39 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 20:26:15 +0500 Subject: [PATCH 19/22] style(usage): center the usage panel card vertically too The panel body now fills the viewport and centers the card on both axes (align-items: center + height: 100vh), so the usage card is centered in the panel instead of pinned to the top. --- src/extension.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/extension.ts b/src/extension.ts index 97fcd81..53a32b8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1363,9 +1363,10 @@ function updateWebviewContent(): void { * { box-sizing: border-box; } body { margin: 0; + height: 100vh; background: var(--vscode-editor-background, #1e1e1e); display: flex; - align-items: flex-start; + align-items: center; justify-content: center; padding: 16px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ubuntu, sans-serif; From 172c2e9c5a34497337dda46f97293b6d7077e9bb Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 20:34:50 +0500 Subject: [PATCH 20/22] fix(usage): never report 'no data' when server meters exist A fresh install with CLI usage had no local tracking, so the status bar fell back to 'OpenCode Go' / 'No usage data yet' even though the server snapshot carried real account-wide meters. mergeServerUsage now flips hasData to true whenever a server snapshot is applied; regression test covers the fresh-install case. --- src/goUsageSync.ts | 3 +++ src/test/goUsageSync.test.ts | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/src/goUsageSync.ts b/src/goUsageSync.ts index 64cfed7..645eacc 100644 --- a/src/goUsageSync.ts +++ b/src/goUsageSync.ts @@ -122,5 +122,8 @@ export function mergeServerUsage( session: period(api.usage.rolling, limits.session), weekly: period(api.usage.weekly, limits.weekly), monthly: period(api.usage.monthly, limits.monthly), + // Server meters are real account-wide data — never report "no data" + // when a snapshot exists (e.g. a fresh install with CLI usage). + hasData: true, }; } diff --git a/src/test/goUsageSync.test.ts b/src/test/goUsageSync.test.ts index 60c7468..6409b60 100644 --- a/src/test/goUsageSync.test.ts +++ b/src/test/goUsageSync.test.ts @@ -111,3 +111,10 @@ test("mergeServerUsage — keeps local today/yesterday and metadata", () => { assert.equal(merged.hasData, true); assert.equal(merged.sqliteAvailable, false); }); + +test("mergeServerUsage — server meters imply hasData (fresh install with CLI usage)", () => { + const empty: UsageSummary = { ...localSummary(), hasData: false }; + const merged = mergeServerUsage(empty, apiResponse(), LIMITS); + assert.equal(merged.hasData, true, "status bar must not say 'no data' when server meters exist"); + assert.equal(merged.monthly.percent, 100); +}); From 98efb6bf1f4954a2fa58a724e8435823568d59fb Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 20:35:01 +0500 Subject: [PATCH 21/22] fix(usage): dedupe concurrent server usage syncs Startup and a status-bar refresh can fire syncServerUsage at the same moment, issuing two identical fetches. Concurrent calls for the same key now share a single in-flight promise; failures stay TTL-paced. --- src/goUsageTracker.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index ae7ad17..944898d 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -308,6 +308,8 @@ export class GoUsageTracker { private serverUsage: GoUsageApiResponse | undefined; /** Unix ms of the last successful {@link syncServerUsage} fetch. */ private serverUsageFetchedAt = 0; + /** In-flight sync promise per key — prevents duplicate concurrent fetches. */ + private syncInFlight: { apiKey: string; promise: Promise } | undefined; private static readonly SESSION_IDLE_MS = 2 * 60 * 60 * 1000; // 2h private static readonly MAX_SESSIONS = 50; @@ -478,6 +480,23 @@ export class GoUsageTracker { * @returns true when a new snapshot was fetched. */ async syncServerUsage(apiKey: string): Promise { + // Dedupe concurrent calls for the same key (startup + status-bar refresh + // can fire at the same moment) — a single in-flight fetch is enough. + if (this.syncInFlight && this.syncInFlight.apiKey === apiKey) { + return this.syncInFlight.promise; + } + const promise = this.performServerUsageSync(apiKey); + this.syncInFlight = { apiKey, promise }; + try { + return await promise; + } finally { + if (this.syncInFlight.promise === promise) { + this.syncInFlight = undefined; + } + } + } + + private async performServerUsageSync(apiKey: string): Promise { const now = Date.now(); if (this.serverUsageFetchedAt > 0 && now - this.serverUsageFetchedAt < GO_USAGE_SYNC_TTL_MS) { return false; From 0bff8b744064fa1188b3b9f01f28fb233df0b4ba Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Wed, 12 Aug 2026 20:35:12 +0500 Subject: [PATCH 22/22] fix(usage): sync the active profile's meters with its own key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status-bar background sync read the extension secret, which can belong to a different account once multiple profiles exist — switching profiles without a request in between pulled the wrong account's meters onto the active profile, and the refresh could race the per-request sync. Profile keys are now remembered per fingerprint (ensureProfileForApiKey), and the refresh path uses the active profile's own key, falling back to the extension secret. --- src/extension.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 53a32b8..c097eb4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -114,6 +114,8 @@ let goUsageStatusBarItem: vscode.StatusBarItem | undefined; let goUsageTracker: GoUsageTracker | undefined; /** Per-profile trackers indexed by key fingerprint. */ const goUsageTrackers = new Map(); +/** API key per profile fingerprint — lets refreshes sync the active profile's own key. */ +const profileApiKeys = new Map(); let usageWebviewPanel: vscode.WebviewPanel | undefined; let profilesCache: UsageProfile[] = []; @@ -192,6 +194,10 @@ function ensureProfileSync(apiKey: string): void { */ function ensureProfileForApiKey(apiKey: string): GoUsageTracker { ensureProfileSync(apiKey); + // Remember which API key owns each profile, so status-bar refreshes can + // sync the ACTIVE profile's meters with its own key instead of the + // extension secret (which may belong to another account). + profileApiKeys.set(keyFingerprint(apiKey), apiKey); return getOrCreateTracker(keyFingerprint(apiKey)); } @@ -1291,9 +1297,10 @@ function refreshGoUsageStatusBar(): void { updateWebviewContent(); // Refresh the server-accurate meters in the background (TTL-guarded); when - // a new snapshot lands, rebuild the status bar with it. + // a new snapshot lands, rebuild the status bar with it. Use the active + // profile's own key when known, falling back to the extension secret. void (async () => { - const apiKey = await _extensionContext?.secrets.get(SECRET_KEY); + const apiKey = profileApiKeys.get(activeProfileFingerprint) ?? (await _extensionContext?.secrets.get(SECRET_KEY)); if (!apiKey) return; const changed = await tracker.syncServerUsage(apiKey); if (changed) refreshGoUsageStatusBar();