From 08d000ae87aee93e29d668101a6e6836a0d7d91c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 10 May 2026 03:25:02 -0500 Subject: [PATCH 1/8] Audit follow-ups: cleaner header Usage popup + Cursor Admin API fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cursor: prefer overallSpendCents (total) over spendCents (on-demand only) and fall back to monthlyLimitDollars when no per-user override is set. - HeaderUsageControl: per-provider tooltip, glow + pulse on critical/error states, accurate aria-label, runtime tone reflects warnings only when no active percentage takes priority. - UsageMeter: replace the stacked sub-quota bar (sub-quotas were independent percentages, not contributions) with a single fill plus per-model ticks. - Move automation guardrails into the header Usage popup as a collapsible section; remove Settings → Usage tab, SettingsUsageSection, UsageGuardrailsSection, and stale references in CommandPalette, RuleEditorPanel, ctoStateService, and the feature docs. - ade-cli: add `ade usage snapshot|refresh|budget` plan plus help entry. - Tests cover the new Cursor parsing fallbacks; typecheck and affected vitest suites pass. --- apps/ade-cli/src/cli.ts | 67 +++ .../services/ai/providerConnectionStatus.ts | 14 +- .../src/main/services/cto/ctoStateService.ts | 2 +- .../usage/usageTrackingService.test.ts | 86 ++++ .../services/usage/usageTrackingService.ts | 300 +++++++++++-- .../components/app/CommandPalette.tsx | 1 - .../renderer/components/app/SettingsPage.tsx | 5 +- .../src/renderer/components/app/TopBar.tsx | 3 + .../components/RuleEditorPanel.tsx | 2 +- .../components/missions/MissionHeader.tsx | 8 +- .../settings/SettingsUsageSection.tsx | 42 -- .../settings/UsageGuardrailsSection.tsx | 412 ------------------ .../components/settings/UsageMeter.tsx | 75 ++-- .../components/usage/HeaderUsageControl.tsx | 241 ++++++++++ .../UsageQuotaPanel.test.tsx} | 62 ++- .../components/usage/UsageQuotaPanel.tsx | 372 ++++++++++++++++ apps/desktop/src/shared/types/config.ts | 1 + apps/desktop/src/shared/types/usage.ts | 18 +- docs/features/automations/README.md | 4 +- docs/features/automations/guardrails.md | 2 +- .../onboarding-and-settings/README.md | 17 +- 21 files changed, 1139 insertions(+), 595 deletions(-) delete mode 100644 apps/desktop/src/renderer/components/settings/SettingsUsageSection.tsx delete mode 100644 apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.tsx create mode 100644 apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx rename apps/desktop/src/renderer/components/{settings/UsageGuardrailsSection.test.tsx => usage/UsageQuotaPanel.test.tsx} (53%) create mode 100644 apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 7fb0c4787..9827843d5 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -369,6 +369,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade macos-vm status | start | guide Run lane-tied macOS VMs for agent work $ ade browser open | tabs | screenshot Use ADE's built-in browser pane $ ade memory add | search | pin Use ADE memory + $ ade usage snapshot | refresh | budget Read provider quota usage and edit automation guardrails $ ade settings action Call project config actions $ ade update status | check | install | dismiss Read auto-update state and drive install $ ade actions list | run | status Escape hatch for every ADE service action @@ -1143,6 +1144,23 @@ const HELP_BY_COMMAND: Record = { $ ade memory search -q "release process" --text $ ade memory pin $ ade memory core --arg projectSummary="Current focus" +`, + usage: `${ADE_BANNER} + Usage and provider quotas + + Reads live provider quota usage (Claude five-hour + weekly, Codex five-hour + + weekly, Cursor monthly via the team Admin API), pacing, costs, and budget + guardrails. The desktop app surfaces this same data in the top-bar Usage popup. + + $ ade usage snapshot --text Cached snapshot (windows, pacing, costs, errors) + $ ade usage refresh --text Force a fresh poll (invalidates cost cache) + $ ade usage budget get --text Read automation guardrail config + $ ade usage budget set --from-file budget.json Save automation guardrail config + $ ade usage budget check --provider claude --scope global + $ ade usage budget cumulative --scope global Cumulative spend for the current week + + Cursor uses the Admin API (https://api.cursor.com/teams/spend) — set + CURSOR_ADMIN_API_KEY (or CURSOR_API_KEY) so the poll can authenticate. `, cto: `${ADE_BANNER} CTO and Work state @@ -3661,6 +3679,54 @@ function buildSettingsPlan(args: string[]): CliPlan { return { kind: "execute", label: `settings ${sub}`, steps: [actionStep("result", "project_config", sub, collectGenericObjectArgs(args))] }; } +function buildUsagePlan(args: string[]): CliPlan { + const sub = firstPositional(args) ?? "snapshot"; + if (sub === "actions") return { kind: "execute", label: "usage actions", steps: [listActionsStep("actions", "usage")] }; + if (sub === "action") return { kind: "execute", label: "usage action", steps: [buildActionRunStep(["usage", ...args])] }; + if (sub === "snapshot" || sub === "get" || sub === "status") { + return { kind: "execute", label: "usage snapshot", steps: [actionStep("result", "usage", "getUsageSnapshot", {})] }; + } + if (sub === "refresh" || sub === "poll") { + return { kind: "execute", label: "usage refresh", steps: [actionStep("result", "usage", "forceRefresh", {})] }; + } + if (sub === "budget") { + const mode = firstPositional(args) ?? "get"; + if (mode === "get") { + return { kind: "execute", label: "usage budget get", steps: [actionStep("result", "budget", "getConfig", {})] }; + } + if (mode === "set" || mode === "update") { + const text = readFileTextInput(args); + let parsed: unknown = null; + if (text != null && text.trim().length > 0) { + try { parsed = JSON.parse(text); } + catch (error) { + throw new CliUsageError(`Failed to parse budget config: ${error instanceof Error ? error.message : String(error)}`); + } + } else { + parsed = collectGenericObjectArgs(args); + } + if (!isRecord(parsed)) throw new CliUsageError("Budget config must be a JSON object."); + return { kind: "execute", label: "usage budget update", steps: [actionStep("result", "budget", "updateConfig", parsed as JsonObject)] }; + } + if (mode === "check") { + return { kind: "execute", label: "usage budget check", steps: [actionStep("result", "budget", "checkBudget", collectGenericObjectArgs(args, { + scope: readValue(args, ["--scope"]) ?? "global", + scopeId: readValue(args, ["--scope-id"]), + provider: readValue(args, ["--provider"]) ?? "any", + }))] }; + } + if (mode === "cumulative" || mode === "totals") { + return { kind: "execute", label: "usage budget cumulative", steps: [actionStep("result", "budget", "getCumulativeUsage", collectGenericObjectArgs(args, { + scope: readValue(args, ["--scope"]) ?? "global", + scopeId: readValue(args, ["--scope-id"]), + provider: readValue(args, ["--provider"]), + }))] }; + } + throw new CliUsageError("usage budget supports get, set, check, or cumulative."); + } + return { kind: "execute", label: `usage ${sub}`, steps: [actionStep("result", "usage", sub, collectGenericObjectArgs(args))] }; +} + function buildActionsPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "list"; if (sub === "list" || sub === "ls") return { kind: "execute", label: "actions list", steps: [listActionsStep("result", readValue(args, ["--domain"]) ?? firstPositional(args) ?? undefined)] }; @@ -4272,6 +4338,7 @@ function buildCliPlan(command: string[]): CliPlan { if (primary === "macos-vm" || primary === "macos" || primary === "mac-vm" || primary === "macvm") return buildMacosVmPlan(args); if (primary === "browser" || primary === "ade-browser" || primary === "built-in-browser" || primary === "builtin-browser") return buildBrowserPlan(args); if (primary === "memory") return buildMemoryPlan(args); + if (primary === "usage" || primary === "quota" || primary === "quotas") return buildUsagePlan(args); if (primary === "settings" || primary === "config" || primary === "setting") return buildSettingsPlan(args); if (primary === "actions" || primary === "action") return buildActionsPlan(args); if (primary === "update" || primary === "auto-update" || primary === "updates") return buildUpdatePlan(args); diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index b1c691245..1e7b5aa64 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -174,6 +174,7 @@ export async function buildProviderConnections( const cursorCli = cliStatuses.find((entry) => entry.cli === "cursor") ?? null; const cursorEnvAuth = Boolean(process.env.CURSOR_API_KEY?.trim()); + const cursorAdminEnvAuth = Boolean(process.env.CURSOR_ADMIN_API_KEY?.trim()); let cursorStoredAuth = false; let cursorStoreUnavailable = false; try { @@ -184,6 +185,7 @@ export async function buildProviderConnections( cursorStoreUnavailable = true; } const cursorSdkAuth = Boolean(cursorEnvAuth || cursorStoredAuth); + const cursorUsageAuth = Boolean(cursorSdkAuth || cursorAdminEnvAuth); let cursorCredsSource: "cursor-env" | "cursor-api-key-store" | undefined; if (cursorEnvAuth) cursorCredsSource = "cursor-env"; else if (cursorStoredAuth) cursorCredsSource = "cursor-api-key-store"; @@ -193,13 +195,15 @@ export async function buildProviderConnections( runtimeDetected: true, cliAuthenticated: false, cliExplicitlyUnauthenticated: false, - localCredsDetected: cursorSdkAuth, - authAvailable: cursorSdkAuth, + localCredsDetected: cursorUsageAuth, + authAvailable: cursorUsageAuth, runtimeAvailable: cursorSdkAuth, }; const cursorBlocker: string | null = cursorSdkAuth ? null + : cursorAdminEnvAuth + ? "Cursor Admin API key is configured for usage; add a Cursor agent API key for Cursor runtime access." : cursorStoreUnavailable ? "ADE could not read the Cursor API key store yet. Retry after the key store is ready." : "Enter a Cursor API key from https://cursor.com/dashboard/integrations."; @@ -209,13 +213,13 @@ export async function buildProviderConnections( authAvailable: cursorFlags.authAvailable, runtimeDetected: cursorFlags.runtimeDetected, runtimeAvailable: cursorFlags.runtimeAvailable, - usageAvailable: cursorFlags.runtimeAvailable, + usageAvailable: cursorUsageAuth, path: "@cursor/sdk", sources: [ { kind: "local-credentials", - detected: cursorSdkAuth, - source: cursorCredsSource, + detected: cursorUsageAuth, + source: cursorCredsSource ?? (cursorAdminEnvAuth ? "cursor-admin-env" : undefined), }, { kind: "cli", diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index 507100430..96cf8a809 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -191,7 +191,7 @@ function buildCtoEnvironmentKnowledge(): string { " /graph — Workspace dependency graph visualization showing lane relationships.", " /history — Operation history timeline showing all past actions.", " /automations — Automation rule builder: create rules triggered by events (PR opened, test failed, etc.).", - " /settings — App settings: AI providers, GitHub token, Linear integration, keybindings, usage budgets, and external connectors.", + " /settings — App settings: AI providers, GitHub token, Linear integration, keybindings, and external connectors. Live provider usage and automation guardrails are now in the header usage popup.", " When an action should be opened in ADE, return a navigation suggestion. Never silently switch tabs.", "", ...buildCtoModelSelectionKnowledge(), diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 389145b26..97c83190a 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -30,6 +30,8 @@ const { isTokenExpiredOrExpiring, parseClaudeWindows, parseCodexRateLimitWindows, + parseCursorSpendUsage, + calculatePacingByProvider, pollCodexViaCliRpc, resolveTokenPrice, } = _testing; @@ -456,6 +458,70 @@ describe("parseCodexRateLimitWindows", () => { }); }); +describe("parseCursorSpendUsage", () => { + it("normalizes Cursor team spend into a monthly used-percent window", () => { + const cycleStart = Date.UTC(2026, 4, 1); + const result = parseCursorSpendUsage({ + subscriptionCycleStart: cycleStart, + teamMemberSpend: [ + { spendCents: 2500, hardLimitOverrideDollars: 100, fastPremiumRequests: 50 }, + { spendCents: 500, hardLimitOverrideDollars: 50, fastPremiumRequests: 20 }, + ], + }); + + expect(result.windows).toHaveLength(1); + expect(result.windows[0]?.provider).toBe("cursor"); + expect(result.windows[0]?.windowType).toBe("monthly"); + expect(result.windows[0]?.percentUsed).toBe(20); + expect(result.windows[0]?.windowDurationMs).toBeGreaterThan(0); + expect(result.extraUsage?.usedCreditsUsd).toBe(30); + expect(result.extraUsage?.monthlyLimitUsd).toBe(150); + expect(result.extraUsage?.utilization).toBe(20); + }); + + it("keeps Cursor spend as extra usage when no monthly limit is configured", () => { + const result = parseCursorSpendUsage({ + teamMemberSpend: [ + { spendCents: 1250, hardLimitOverrideDollars: 0, fastPremiumRequests: 10 }, + ], + }); + + expect(result.windows).toEqual([]); + expect(result.extraUsage?.provider).toBe("cursor"); + expect(result.extraUsage?.usedCreditsUsd).toBe(12.5); + expect(result.extraUsage?.monthlyLimitUsd).toBe(0); + expect(result.extraUsage?.utilization).toBeNull(); + }); + + it("prefers overallSpendCents over on-demand spendCents when both are present", () => { + const cycleStart = Date.UTC(2026, 4, 1); + const result = parseCursorSpendUsage({ + subscriptionCycleStart: cycleStart, + teamMemberSpend: [ + { spendCents: 1000, overallSpendCents: 5000, hardLimitOverrideDollars: 100 }, + ], + }); + + expect(result.extraUsage?.usedCreditsUsd).toBe(50); + expect(result.windows[0]?.percentUsed).toBe(50); + }); + + it("falls back to monthlyLimitDollars when no hard-limit override is set", () => { + const cycleStart = Date.UTC(2026, 4, 1); + const result = parseCursorSpendUsage({ + subscriptionCycleStart: cycleStart, + teamMemberSpend: [ + { overallSpendCents: 2500, monthlyLimitDollars: 100 }, + { overallSpendCents: 0, monthlyLimitDollars: 100 }, + ], + }); + + expect(result.extraUsage?.monthlyLimitUsd).toBe(200); + expect(result.extraUsage?.usedCreditsUsd).toBe(25); + expect(result.windows[0]?.percentUsed).toBe(12.5); + }); +}); + describe("pollCodexViaCliRpc", () => { const originalPlatform = process.platform; const originalComSpec = process.env.ComSpec; @@ -598,6 +664,7 @@ describe("createUsageTrackingService", () => { const createFastDependencies = () => ({ pollClaudeUsage: vi.fn(async () => ({ windows: [] as never[], extraUsage: null, errors: [] as never[] })), pollCodexUsage: vi.fn(async () => ({ windows: [] as never[], errors: [] as never[] })), + pollCursorUsage: vi.fn(async () => ({ windows: [] as never[], extraUsage: null, errors: [] as never[] })), scanClaudeLogs: vi.fn(async () => [] as never[]), scanCodexLogs: vi.fn(async () => [] as never[]), }); @@ -652,6 +719,25 @@ describe("createUsageTrackingService", () => { service.dispose(); }); + it("calculates pacing separately for Claude, Codex, and Cursor windows", async () => { + const now = Date.now(); + const weeklyResetMs = 3.5 * 24 * 60 * 60 * 1000; + const monthlyResetMs = 24 * 24 * 60 * 60 * 1000; + const weeklyReset = new Date(now + weeklyResetMs).toISOString(); + const monthlyReset = new Date(now + monthlyResetMs).toISOString(); + const windows = [ + { provider: "claude" as const, windowType: "weekly" as const, percentUsed: 40, resetsAt: weeklyReset, resetsInMs: weeklyResetMs }, + { provider: "codex" as const, windowType: "weekly" as const, percentUsed: 65, resetsAt: weeklyReset, resetsInMs: weeklyResetMs }, + { provider: "cursor" as const, windowType: "monthly" as const, percentUsed: 15, resetsAt: monthlyReset, resetsInMs: monthlyResetMs, windowDurationMs: 30 * 24 * 60 * 60 * 1000 }, + ]; + + const pacing = calculatePacingByProvider(windows); + + expect(pacing?.claude?.status).toBe("behind"); + expect(pacing?.codex?.status).toBe("far-ahead"); + expect(pacing?.cursor?.status).toBe("slightly-behind"); + }); + it("forceRefresh invalidates cost cache and re-polls", async () => { const logger = createLogger(); const dependencies = createFastDependencies(); diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index dfcedf320..2f8ca0751 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -1,9 +1,9 @@ /** * usageTrackingService.ts * - * Polls live usage data from Claude and Codex providers. + * Polls live usage data from Claude, Codex, and Cursor providers. * Scans local JSONL logs for cost/token aggregation. - * Computes pacing relative to weekly reset windows. + * Computes pacing relative to provider reset windows. */ import fs from "node:fs"; @@ -29,6 +29,7 @@ import { readCodexCredentials, refreshClaudeCredentials, } from "../ai/providerCredentialSources"; +import { getAllApiKeys } from "../ai/apiKeyStore"; import { resolveCodexExecutable } from "../ai/codexExecutable"; import { resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; @@ -49,6 +50,7 @@ function isBenignStdinCloseError(error: unknown): boolean { const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; +const CURSOR_SPEND_URL = "https://api.cursor.com/teams/spend"; // Per-million token prices for cost estimation const TOKEN_PRICES: Record = { @@ -65,14 +67,16 @@ const TOKEN_PRICES: Record = { async function fetchJson( url: string, headers: Record, - timeoutMs = 15_000 + timeoutMs = 15_000, + init?: { method?: string; body?: string }, ): Promise<{ ok: boolean; status: number; data: unknown }> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const resp = await fetch(url, { - method: "GET", + method: init?.method ?? "GET", headers, + ...(init?.body != null ? { body: init.body } : {}), signal: controller.signal, }); const data = await resp.json(); @@ -251,6 +255,159 @@ function parseCodexRateLimitWindows(data: Record): UsageWindow[ return windows; } +// ── Cursor Usage Polling ───────────────────────────────────────── + +type CursorSpendMember = { + spendCents?: number; + overallSpendCents?: number; + fastPremiumRequests?: number; + hardLimitOverrideDollars?: number; + monthlyLimitDollars?: number; +}; + +type CursorSpendResponse = { + teamMemberSpend?: CursorSpendMember[]; + subscriptionCycleStart?: number; +}; + +function getCursorApiKey(): { key: string; source: "cursor-admin-env" | "cursor-env" | "cursor-api-key-store" } | null { + const adminEnvKey = process.env.CURSOR_ADMIN_API_KEY?.trim(); + if (adminEnvKey) return { key: adminEnvKey, source: "cursor-admin-env" }; + const envKey = process.env.CURSOR_API_KEY?.trim(); + if (envKey?.startsWith("key_")) return { key: envKey, source: "cursor-env" }; + try { + const stored = getAllApiKeys().cursor?.trim(); + if (stored?.startsWith("key_")) return { key: stored, source: "cursor-api-key-store" }; + } catch { + // The API key store can be unavailable during early startup. Treat this as + // "no key" for usage polling; provider status surfaces the store issue. + } + return null; +} + +function addOneMonth(timestampMs: number): number { + const date = new Date(timestampMs); + if (!Number.isFinite(date.getTime())) return 0; + const next = new Date(date.getTime()); + next.setMonth(next.getMonth() + 1); + return next.getTime(); +} + +function parseCursorSpendUsage(data: CursorSpendResponse): { + windows: UsageWindow[]; + extraUsage: ExtraUsage | null; +} { + const members = Array.isArray(data.teamMemberSpend) ? data.teamMemberSpend : []; + if (members.length === 0) return { windows: [], extraUsage: null }; + + const memberSpendCents = (member: CursorSpendMember): number => { + // overallSpendCents = on-demand + included usage (the real total spend) + // spendCents alone captures only on-demand pay-as-you-go. + const overall = typeof member.overallSpendCents === "number" && Number.isFinite(member.overallSpendCents) + ? member.overallSpendCents + : null; + if (overall != null) return overall; + return typeof member.spendCents === "number" && Number.isFinite(member.spendCents) ? member.spendCents : 0; + }; + const memberLimitCents = (member: CursorSpendMember): number => { + // hardLimitOverrideDollars is the per-user override; fall back to the + // team-wide monthlyLimitDollars when no override is configured. + const override = + typeof member.hardLimitOverrideDollars === "number" && Number.isFinite(member.hardLimitOverrideDollars) && member.hardLimitOverrideDollars > 0 + ? member.hardLimitOverrideDollars + : 0; + if (override > 0) return override * 100; + const monthly = + typeof member.monthlyLimitDollars === "number" && Number.isFinite(member.monthlyLimitDollars) && member.monthlyLimitDollars > 0 + ? member.monthlyLimitDollars + : 0; + return monthly > 0 ? monthly * 100 : 0; + }; + + const totalSpendCents = members.reduce((sum, member) => sum + memberSpendCents(member), 0); + const totalLimitCents = members.reduce((sum, member) => sum + memberLimitCents(member), 0); + + const cycleStartMs = + typeof data.subscriptionCycleStart === "number" && Number.isFinite(data.subscriptionCycleStart) + ? data.subscriptionCycleStart + : 0; + const resetMs = cycleStartMs > 0 ? addOneMonth(cycleStartMs) : 0; + const resetsAt = resetMs > 0 ? new Date(resetMs).toISOString() : ""; + const windowDurationMs = resetMs > 0 && cycleStartMs > 0 ? Math.max(0, resetMs - cycleStartMs) : undefined; + + const windows: UsageWindow[] = []; + const utilization = totalLimitCents > 0 ? Math.min(100, (totalSpendCents / totalLimitCents) * 100) : null; + if (utilization != null) { + windows.push({ + provider: "cursor", + windowType: "monthly", + percentUsed: Math.round(utilization * 10) / 10, + resetsAt, + resetsInMs: computeResetsInMs(resetsAt), + ...(windowDurationMs ? { windowDurationMs } : {}), + }); + } + + const extraUsage: ExtraUsage | null = + totalSpendCents > 0 || totalLimitCents > 0 + ? { + provider: "cursor", + isEnabled: true, + usedCreditsUsd: Math.round(totalSpendCents) / 100, + monthlyLimitUsd: Math.round(totalLimitCents) / 100, + utilization, + currency: "usd", + } + : null; + + return { windows, extraUsage }; +} + +async function pollCursorUsage(): Promise<{ windows: UsageWindow[]; extraUsage: ExtraUsage | null; errors: string[] }> { + const credential = getCursorApiKey(); + if (!credential) { + return { windows: [], extraUsage: null, errors: [] }; + } + + try { + const auth = Buffer.from(`${credential.key}:`, "utf8").toString("base64"); + const result = await fetchJson( + CURSOR_SPEND_URL, + { + Authorization: `Basic ${auth}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + 15_000, + { method: "POST", body: JSON.stringify({ pageSize: 100 }) }, + ); + + if (!result.ok) { + return { + windows: [], + extraUsage: null, + errors: [`cursor: Admin API returned ${result.status}`], + }; + } + + const parsed = parseCursorSpendUsage(result.data as CursorSpendResponse); + if (parsed.windows.length === 0 && !parsed.extraUsage) { + return { + windows: [], + extraUsage: null, + errors: ["cursor: usage response contained no recognized spend data"], + }; + } + return { ...parsed, errors: [] }; + } catch (err) { + return { + windows: [], + extraUsage: null, + errors: [`cursor: ${getErrorMessage(err)}`], + }; + } +} + async function pollClaudeUsage(logger: Logger): Promise<{ windows: UsageWindow[]; extraUsage: ExtraUsage | null; errors: string[] }> { const windows: UsageWindow[] = []; const errors: string[] = []; @@ -724,8 +881,8 @@ function aggregateCosts( // ── Pacing Calculation ─────────────────────────────────────────── -function calculatePacing(windows: UsageWindow[]): UsagePacing { - const empty: UsagePacing = { +function emptyPacing(): UsagePacing { + return { status: "on-track", projectedWeeklyPercent: 0, weekElapsedPercent: 0, @@ -735,70 +892,80 @@ function calculatePacing(windows: UsageWindow[]): UsagePacing { willLastToReset: true, resetsInHours: 0, }; +} - // Find the weekly window (prefer Claude, then Codex) - const weeklyWindow = - windows.find((w) => w.windowType === "weekly" && w.provider === "claude") ?? - windows.find((w) => w.windowType === "weekly"); +function defaultWindowDurationMs(windowType: UsageWindow["windowType"]): number { + switch (windowType) { + case "five_hour": + return 5 * 60 * 60 * 1000; + case "monthly": + return 30 * 24 * 60 * 60 * 1000; + case "weekly": + case "weekly_oauth_apps": + case "weekly_cowork": + default: + return 7 * 24 * 60 * 60 * 1000; + } +} + +function selectPacingWindow(windows: UsageWindow[]): UsageWindow | null { + return ( + windows.find((w) => w.windowType === "weekly") ?? + windows.find((w) => w.windowType === "monthly") ?? + windows.find((w) => w.windowType === "five_hour") ?? + windows.find((w) => w.resetsInMs > 0) ?? + null + ); +} - if (!weeklyWindow) return empty; +function stageForDelta(deltaPercent: number): UsagePacing["status"] { + const absDelta = Math.abs(deltaPercent); + if (absDelta <= 2) return "on-track"; + if (absDelta <= 6) return deltaPercent >= 0 ? "slightly-ahead" : "slightly-behind"; + if (absDelta <= 12) return deltaPercent >= 0 ? "ahead" : "behind"; + return deltaPercent >= 0 ? "far-ahead" : "far-behind"; +} - const totalWindowMs = 7 * 24 * 60 * 60 * 1000; - const elapsedMs = totalWindowMs - weeklyWindow.resetsInMs; - const weekElapsedPercent = Math.min(100, Math.max(0, (elapsedMs / totalWindowMs) * 100)); - const resetsInHours = weeklyWindow.resetsInMs / 3_600_000; +function calculatePacingForWindow(window: UsageWindow): UsagePacing { + if (!window.resetsAt || window.resetsInMs <= 0) return emptyPacing(); - // Expected usage if consumption were perfectly linear over the week - const expectedPercent = weekElapsedPercent; // 100% budget / 100% time = linear + const totalWindowMs = window.windowDurationMs && window.windowDurationMs > 0 + ? window.windowDurationMs + : defaultWindowDurationMs(window.windowType); + const elapsedMs = totalWindowMs - window.resetsInMs; + const weekElapsedPercent = Math.min(100, Math.max(0, (elapsedMs / totalWindowMs) * 100)); + const resetsInHours = window.resetsInMs / 3_600_000; - // Delta: positive = consuming faster than pace, negative = under pace - const deltaPercent = weeklyWindow.percentUsed - expectedPercent; + const expectedPercent = weekElapsedPercent; + const deltaPercent = window.percentUsed - expectedPercent; - // Project usage to end of week + // Project usage to the end of the tracked quota window. let projectedWeeklyPercent: number; let etaHours: number | null = null; let willLastToReset = true; if (weekElapsedPercent < 1) { - projectedWeeklyPercent = weeklyWindow.percentUsed; + projectedWeeklyPercent = window.percentUsed; } else { - const ratePerMs = weeklyWindow.percentUsed / elapsedMs; + const ratePerMs = window.percentUsed / elapsedMs; projectedWeeklyPercent = Math.min(300, ratePerMs * totalWindowMs); - // ETA to 100% at current rate + // ETA to 100% at current rate. if (ratePerMs > 0) { - const remainingPercent = 100 - weeklyWindow.percentUsed; + const remainingPercent = 100 - window.percentUsed; if (remainingPercent <= 0) { etaHours = 0; // Already exhausted willLastToReset = false; } else { const msTo100 = remainingPercent / ratePerMs; etaHours = Math.round((msTo100 / 3_600_000) * 10) / 10; - willLastToReset = msTo100 >= weeklyWindow.resetsInMs; + willLastToReset = msTo100 >= window.resetsInMs; } } } - // Status with more granularity (based on delta) - let status: UsagePacing["status"]; - if (deltaPercent <= -20) { - status = "far-behind"; - } else if (deltaPercent <= -10) { - status = "behind"; - } else if (deltaPercent <= -4) { - status = "slightly-behind"; - } else if (deltaPercent <= 4) { - status = "on-track"; - } else if (deltaPercent <= 10) { - status = "slightly-ahead"; - } else if (deltaPercent <= 20) { - status = "ahead"; - } else { - status = "far-ahead"; - } - return { - status, + status: stageForDelta(deltaPercent), projectedWeeklyPercent: Math.round(projectedWeeklyPercent * 10) / 10, weekElapsedPercent: Math.round(weekElapsedPercent * 10) / 10, expectedPercent: Math.round(expectedPercent * 10) / 10, @@ -809,6 +976,28 @@ function calculatePacing(windows: UsageWindow[]): UsagePacing { }; } +function calculatePacing(windows: UsageWindow[]): UsagePacing { + // Preserve the legacy aggregate preference for consumers that still expect a + // single app-level badge, then expose per-provider pacing separately below. + const legacyWindow = + windows.find((w) => w.windowType === "weekly" && w.provider === "claude") ?? + windows.find((w) => w.windowType === "weekly") ?? + windows.find((w) => w.windowType === "monthly") ?? + windows.find((w) => w.windowType === "five_hour") ?? + null; + return legacyWindow ? calculatePacingForWindow(legacyWindow) : emptyPacing(); +} + +function calculatePacingByProvider(windows: UsageWindow[]): UsageSnapshot["pacingByProvider"] { + const providers = Array.from(new Set(windows.map((window) => window.provider))); + const out: UsageSnapshot["pacingByProvider"] = {}; + for (const provider of providers) { + const selected = selectPacingWindow(windows.filter((window) => window.provider === provider)); + if (selected) out[provider] = calculatePacingForWindow(selected); + } + return out; +} + // ── Service Factory ────────────────────────────────────────────── export type UsageTrackingService = ReturnType; @@ -816,6 +1005,7 @@ export type UsageTrackingService = ReturnType type UsageTrackingDependencies = { pollClaudeUsage?: () => Promise<{ windows: UsageWindow[]; extraUsage: ExtraUsage | null; errors: string[] }>; pollCodexUsage?: () => Promise<{ windows: UsageWindow[]; errors: string[] }>; + pollCursorUsage?: () => Promise<{ windows: UsageWindow[]; extraUsage: ExtraUsage | null; errors: string[] }>; scanClaudeLogs?: () => Promise; scanCodexLogs?: () => Promise; }; @@ -843,12 +1033,14 @@ export function createUsageTrackingService({ let inFlightPoll: Promise | null = null; const runClaudeUsagePoll = dependencies?.pollClaudeUsage ?? (() => pollClaudeUsage(logger)); const runCodexUsagePoll = dependencies?.pollCodexUsage ?? (() => pollCodexUsage(logger)); + const runCursorUsagePoll = dependencies?.pollCursorUsage ?? pollCursorUsage; const scanClaudeCostLogs = dependencies?.scanClaudeLogs ?? scanClaudeLogs; const scanCodexCostLogs = dependencies?.scanCodexLogs ?? scanCodexLogs; const emptySnapshot = (): UsageSnapshot => ({ windows: [], - pacing: { status: "on-track", projectedWeeklyPercent: 0, weekElapsedPercent: 0, expectedPercent: 0, deltaPercent: 0, etaHours: null, willLastToReset: true, resetsInHours: 0 }, + pacing: emptyPacing(), + pacingByProvider: {}, costs: [], extraUsage: [], lastPolledAt: nowIso(), @@ -891,7 +1083,7 @@ export function createUsageTrackingService({ let allWindows: UsageWindow[] = []; try { - const [claudeResult, codexResult, costs] = await Promise.all([ + const [claudeResult, codexResult, cursorResult, costs] = await Promise.all([ runClaudeUsagePoll().catch((err) => { const msg = `claude: poll failed: ${getErrorMessage(err)}`; logger.warn("usage.poll.claude_failed", { error: msg }); @@ -902,19 +1094,27 @@ export function createUsageTrackingService({ logger.warn("usage.poll.codex_failed", { error: msg }); return { windows: [] as UsageWindow[], errors: [msg] }; }), + runCursorUsagePoll().catch((err) => { + const msg = `cursor: poll failed: ${getErrorMessage(err)}`; + logger.warn("usage.poll.cursor_failed", { error: msg }); + return { windows: [] as UsageWindow[], extraUsage: null as ExtraUsage | null, errors: [msg] }; + }), pollCosts(), ]); - allWindows = [...claudeResult.windows, ...codexResult.windows]; - errors.push(...claudeResult.errors, ...codexResult.errors); + allWindows = [...claudeResult.windows, ...codexResult.windows, ...cursorResult.windows]; + errors.push(...claudeResult.errors, ...codexResult.errors, ...cursorResult.errors); const pacing = calculatePacing(allWindows); + const pacingByProvider = calculatePacingByProvider(allWindows); const extraUsage: ExtraUsage[] = []; if (claudeResult.extraUsage) extraUsage.push(claudeResult.extraUsage); + if (cursorResult.extraUsage) extraUsage.push(cursorResult.extraUsage); const snapshot: UsageSnapshot = { windows: allWindows, pacing, + pacingByProvider, costs, extraUsage, lastPolledAt: nowIso(), @@ -1001,12 +1201,16 @@ export const _testing = { refreshClaudeCredentials, parseClaudeWindows, parseCodexRateLimitWindows, + parseCursorSpendUsage, pollClaudeUsage, pollCodexUsage, + pollCursorUsage, scanClaudeLogs, scanCodexLogs, aggregateCosts, calculatePacing, + calculatePacingByProvider, + calculatePacingForWindow, fetchJson, findJsonlFiles, resolveTokenPrice, diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index ffb9744b1..f226e3b1a 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -298,7 +298,6 @@ export function CommandPalette({ { id: "go-settings-ai", title: "Go to AI Settings", hint: "Providers, models, AI defaults", group: "Settings", run: () => navigate("/settings?tab=ai") }, { id: "go-settings-integrations", title: "Go to Integrations", hint: "GitHub, Linear, computer use", group: "Settings", run: () => navigate("/settings?tab=integrations") }, { id: "go-settings-workspace", title: "Go to Workspace Settings", hint: "Project health and docs generation", group: "Settings", run: () => navigate("/settings?tab=workspace") }, - { id: "go-settings-usage", title: "Go to Usage", hint: "Token usage, cost breakdown", group: "Settings", run: () => navigate("/settings?tab=usage") }, { id: "action-create-lane", title: "Create Lane", diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index bf9dbe510..0f8f1bee1 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -1,13 +1,12 @@ import React, { useState, useCallback, useEffect } from "react"; import { useSearchParams, useLocation } from "react-router-dom"; -import { Brain, GearSix, Lightning, Stack, Database, FolderSimple, Plus, Plugs, Palette, DeviceMobile } from "@phosphor-icons/react"; +import { Brain, GearSix, Stack, Database, FolderSimple, Plus, Plugs, Palette, DeviceMobile } from "@phosphor-icons/react"; import { GeneralSection } from "../settings/GeneralSection"; import { AppearanceSection } from "../settings/AppearanceSection"; import { LaneTemplatesSection } from "../settings/LaneTemplatesSection"; import { LaneBehaviorSection } from "../settings/LaneBehaviorSection"; import { MemoryHealthTab } from "../settings/MemoryHealthTab"; import { AiSettingsSection } from "../settings/AiSettingsSection"; -import { SettingsUsageSection } from "../settings/SettingsUsageSection"; import { WorkspaceSettingsSection } from "../settings/WorkspaceSettingsSection"; import { IntegrationsSettingsSection } from "../settings/IntegrationsSettingsSection"; import { MobilePushPanel } from "../settings/MobilePushPanel"; @@ -25,7 +24,6 @@ const SECTIONS = [ { id: "integrations", label: "Integrations", icon: Plugs }, { id: "memory", label: "Memory", icon: Database }, { id: "lane-templates", label: "Lane Templates", icon: Stack }, - { id: "usage", label: "Usage", icon: Lightning }, ] as const; type SectionId = (typeof SECTIONS)[number]["id"]; @@ -575,7 +573,6 @@ export function SettingsPage() { )} - {section === "usage" && } ); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index fb1578ce4..88a8e755a 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -21,6 +21,7 @@ import { HelpMenu } from "../onboarding/HelpMenu"; import { LinearQuickViewButton } from "./LinearQuickViewButton"; import { PublishToGitHubDialog } from "../projects/PublishToGitHubDialog"; import { SyncDevicesSection } from "../settings/SyncDevicesSection"; +import { HeaderUsageControl } from "../usage/HeaderUsageControl"; const RUNNING_LANE_PROCESS_STATES: ProcessRuntime["status"][] = ["starting", "running", "degraded"]; @@ -1156,6 +1157,8 @@ export function TopBar() { ) : null} + + diff --git a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx index ae5b24b03..d49c90bb2 100644 --- a/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx +++ b/apps/desktop/src/renderer/components/automations/components/RuleEditorPanel.tsx @@ -1067,7 +1067,7 @@ export function RuleEditorPanel({ />
- Budget caps live in Settings → Usage and apply to every rule. + Budget caps live in the header Usage popup → Automation guardrails and apply to every rule.
diff --git a/apps/desktop/src/renderer/components/missions/MissionHeader.tsx b/apps/desktop/src/renderer/components/missions/MissionHeader.tsx index 3fc6595d0..f1f3585a3 100644 --- a/apps/desktop/src/renderer/components/missions/MissionHeader.tsx +++ b/apps/desktop/src/renderer/components/missions/MissionHeader.tsx @@ -446,6 +446,7 @@ function CompactUsageMeter() { const claudeWindows = snapshot.windows.filter((w) => w.provider === "claude"); const codexWindows = snapshot.windows.filter((w) => w.provider === "codex"); + const cursorWindows = snapshot.windows.filter((w) => w.provider === "cursor"); // Hide the meter entirely when all windows report 0% and there's no mission cost — it's not useful yet const allZero = snapshot.windows.every((w) => w.percentUsed === 0) && perMissionCost <= 0; @@ -464,6 +465,9 @@ function CompactUsageMeter() { {codexWindows.map((w) => ( ))} + {cursorWindows.map((w) => ( + + ))} {/* Per-mission cost from missionBudgetService (VAL-USAGE-005) */} {perMissionCost > 0 && ( 0 ? formatResetCountdown(resetsInMs) : null; return ( {provider[0]}/{windowLabel} {Math.round(pct)}% {/* Reset countdown shown inline (VAL-USAGE-004 scrutiny fix) */} diff --git a/apps/desktop/src/renderer/components/settings/SettingsUsageSection.tsx b/apps/desktop/src/renderer/components/settings/SettingsUsageSection.tsx deleted file mode 100644 index 7e918be4c..000000000 --- a/apps/desktop/src/renderer/components/settings/SettingsUsageSection.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useEffect, useState } from "react"; -import type { AiDetectedAuth } from "../../../shared/types"; -import { UsageGuardrailsSection } from "./UsageGuardrailsSection"; - -function hasApiAuth(entries: AiDetectedAuth[]): boolean { - return entries.some((entry) => entry.type === "api-key" || entry.type === "openrouter"); -} - -export function SettingsUsageSection() { - const [detectedAuth, setDetectedAuth] = useState([]); - - useEffect(() => { - let cancelled = false; - window.ade.ai.getStatus() - .then((status) => { - if (!cancelled) setDetectedAuth(status.detectedAuth ?? []); - }) - .catch(() => { - if (!cancelled) setDetectedAuth([]); - }); - return () => { - cancelled = true; - }; - }, []); - - const apiConfigured = hasApiAuth(detectedAuth); - - return ( -
- - {!apiConfigured ? ( -
-
API cost tracking is hidden
-
- This workspace is using CLI subscriptions, not API keys. Settings → Usage now shows provider quota usage like CodexBar. - Dollar cost should only appear once API-key providers are configured and ADE can distinguish API-billed runs. -
-
- ) : null} -
- ); -} diff --git a/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.tsx b/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.tsx deleted file mode 100644 index 8011eeb57..000000000 --- a/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.tsx +++ /dev/null @@ -1,412 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { ArrowClockwise as RefreshCw, Lightning } from "@phosphor-icons/react"; -import type { - AiProviderConnectionStatus, - AiProviderConnections, - BudgetCapConfig, - ExtraUsage, - UsageSnapshot, - UsageWindow, -} from "../../../shared/types"; -import { Button } from "../ui/Button"; -import { cn } from "../ui/cn"; -import { BudgetCapEditor } from "./BudgetCapEditor"; -import { CostSummaryCard } from "./CostSummaryCard"; -import { UsageMeter } from "./UsageMeter"; -import { UsagePacingBadge } from "./UsagePacingBadge"; - -const CARD_SHADOW_STYLE: React.CSSProperties = { - background: "linear-gradient(180deg, rgba(20, 31, 45, 0.96) 0%, rgba(10, 18, 28, 0.94) 100%)", - border: "1px solid rgba(87, 108, 128, 0.22)", - boxShadow: "0 18px 40px -24px rgba(0, 0, 0, 0.78), inset 0 1px 0 rgba(255,255,255,0.04)", -}; - -function extractError(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} - -function computeResetsInMs(resetsAt: string, nowMs: number): number { - if (!resetsAt) return 0; - return Math.max(0, new Date(resetsAt).getTime() - nowMs); -} - -function formatResetTime(ms: number): string { - if (ms <= 0) return "resets now"; - const hours = Math.floor(ms / 3_600_000); - const mins = Math.floor((ms % 3_600_000) / 60_000); - if (hours > 0) return `resets in ${hours}h ${mins}m`; - return `resets in ${mins}m`; -} - -function displayPercent(window: UsageWindow, mode: "used" | "remaining", nowMs: number): number { - const resetsInMs = computeResetsInMs(window.resetsAt, nowMs); - const effectiveUsed = resetsInMs <= 0 ? 0 : window.percentUsed; - return mode === "remaining" ? 100 - effectiveUsed : effectiveUsed; -} - -function formatPolledAt(iso: string | null): string { - if (!iso) return "--"; - const parsed = new Date(iso); - if (Number.isNaN(parsed.getTime())) return "--"; - return parsed.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); -} - -export function UsageGuardrailsSection({ - showApiCost = false, -}: { - showApiCost?: boolean; -}) { - const [snapshot, setSnapshot] = useState(null); - const [budgetConfig, setBudgetConfig] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [budgetSaving, setBudgetSaving] = useState(false); - const [budgetError, setBudgetError] = useState(null); - const [providerConnections, setProviderConnections] = useState(null); - const [nowMs, setNowMs] = useState(() => Date.now()); - - const load = useCallback(async () => { - if (!window.ade?.usage) { - setError("Usage bridge unavailable."); - return; - } - setLoading(true); - setError(null); - try { - const [nextSnapshot, nextBudgetConfig] = await Promise.all([ - window.ade.usage.getSnapshot(), - window.ade.usage.getBudgetConfig(), - ]); - setSnapshot(nextSnapshot); - setBudgetConfig(nextBudgetConfig); - } catch (err) { - setError(extractError(err)); - } finally { - setLoading(false); - } - }, []); - - const manualRefresh = useCallback(async () => { - if (!window.ade?.usage) return; - setLoading(true); - setError(null); - try { - const nextSnapshot = await window.ade.usage.refresh(); - setSnapshot(nextSnapshot); - } catch (err) { - setError(extractError(err)); - } finally { - setLoading(false); - } - }, []); - - const saveBudgetConfig = useCallback(async (nextConfig: BudgetCapConfig) => { - if (!window.ade?.usage?.saveBudgetConfig) { - setBudgetError("Budget save bridge unavailable."); - return; - } - setBudgetSaving(true); - setBudgetError(null); - try { - const saved = await window.ade.usage.saveBudgetConfig(nextConfig); - setBudgetConfig(saved); - } catch (err) { - setBudgetError(extractError(err)); - } finally { - setBudgetSaving(false); - } - }, []); - - useEffect(() => { - void load(); - if (!window.ade?.usage) return; - const unsubscribe = window.ade.usage.onUpdate((nextSnapshot) => setSnapshot(nextSnapshot)); - return () => { - try { - unsubscribe(); - } catch { - // noop - } - }; - }, [load]); - - useEffect(() => { - let cancelled = false; - window.ade.ai.getStatus() - .then((status) => { - if (!cancelled) setProviderConnections(status.providerConnections ?? null); - }) - .catch(() => { - if (!cancelled) setProviderConnections(null); - }); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - const timer = window.setInterval(() => setNowMs(Date.now()), 15_000); - return () => window.clearInterval(timer); - }, []); - - const claudeWindows = snapshot?.windows.filter((window) => window.provider === "claude") ?? []; - const codexWindows = snapshot?.windows.filter((window) => window.provider === "codex") ?? []; - const claudeCost = snapshot?.costs.find((cost) => cost.provider === "claude"); - const codexCost = snapshot?.costs.find((cost) => cost.provider === "codex"); - const cliAuth = useMemo( - () => ({ - claude: providerConnections?.claude ?? null, - codex: providerConnections?.codex ?? null, - cursor: providerConnections?.cursor ?? null, - droid: providerConnections?.droid ?? null, - }), - [providerConnections] - ); - // The empty-quota warning is gated on Claude/Codex windows being absent. - // Cursor and Droid don't expose quota windows, so an authenticated Droid- or - // Cursor-only user would otherwise always see this warning. - const showEmptyQuotaWarning = - (cliAuth.claude?.authAvailable || cliAuth.codex?.authAvailable) && - claudeWindows.length === 0 && - codexWindows.length === 0 && - (snapshot?.errors.length ?? 0) === 0; - - return ( -
-
-
-
Provider limits & automation guardrails
-
- Live CLI provider quota polling plus the shared budget rules that govern automation runs. -
-
- {snapshot?.pacing ? ( - - ) : null} - - Last polled: {formatPolledAt(snapshot?.lastPolledAt ?? null)} - -
-
- -
- -
- - - - -
- - {error ? ( -
{error}
- ) : null} - - {showEmptyQuotaWarning ? ( -
- Provider login is detected, but the quota poll returned no provider windows. If you just pulled these changes, - restart the ADE app fully so the Electron main process picks up the updated usage parser. -
- ) : null} - - {snapshot?.errors.map((entry, index) => ( -
- {entry} -
- ))} - -
- - -
- - {(snapshot?.extraUsage ?? []).map((extra) => ( - - ))} - -
- {showApiCost && claudeCost ? ( - - ) : null} - {showApiCost && codexCost ? ( - - ) : null} - -
- - {!snapshot && !loading && !error ? ( -
- -
No provider usage data yet
-
- This panel will fill in once Claude or Codex auth is configured and ADE has quota data to poll. -
-
- ) : null} -
- ); -} - -function ProviderUsageCard({ - provider, - windows, - nowMs, -}: { - provider: string; - windows: UsageWindow[]; - nowMs: number; -}) { - const fiveHour = windows.find((window) => window.windowType === "five_hour"); - const weekly = windows.find((window) => window.windowType === "weekly"); - const mode = provider === "Codex" ? "remaining" as const : "used" as const; - const fiveHourPercent = fiveHour ? displayPercent(fiveHour, mode, nowMs) : null; - const weeklyPercent = weekly ? displayPercent(weekly, mode, nowMs) : null; - const fiveHourReset = fiveHour ? formatResetTime(computeResetsInMs(fiveHour.resetsAt, nowMs)) : undefined; - const weeklyReset = weekly ? formatResetTime(computeResetsInMs(weekly.resetsAt, nowMs)) : undefined; - const weeklyBreakdown = - mode === "remaining" && weekly?.modelBreakdown - ? Object.fromEntries(Object.entries(weekly.modelBreakdown).map(([label, value]) => [label, Math.max(0, 100 - value)])) - : weekly?.modelBreakdown; - - return ( -
-
- - - {provider} - -
- - {fiveHour ? ( - - ) : ( -
No short-window data available.
- )} - - {weekly ? ( - - ) : ( -
No weekly data available.
- )} -
- ); -} - -function ExtraUsageCard({ extra }: { extra: ExtraUsage }) { - if (!extra.isEnabled) return null; - - const usedUsd = extra.usedCreditsUsd; - const limitUsd = extra.monthlyLimitUsd; - const percent = limitUsd > 0 ? Math.min(100, (usedUsd / limitUsd) * 100) : 0; - const fillColor = percent > 90 ? "#EF4444" : percent > 70 ? "#F59E0B" : "#A78BFA"; - - const formatUsd = (v: number) => v.toLocaleString("en-US", { style: "currency", currency: extra.currency.toUpperCase() }); - - return ( -
-
- - - {extra.provider === "claude" ? "Claude" : "Codex"} Extra Usage - -
- -
-
- - Monthly spend - - - {formatUsd(usedUsd)}{limitUsd > 0 ? ` / ${formatUsd(limitUsd)}` : ""} - -
- - {limitUsd > 0 ? ( -
-
-
- ) : ( -
- No monthly limit configured -
- )} -
-
- ); -} - -function AuthChip({ - label, - entry, -}: { - label: string; - entry: AiProviderConnectionStatus | null; -}) { - const tone = entry?.runtimeAvailable - ? { border: "rgba(34,197,94,0.3)", bg: "rgba(34,197,94,0.12)", text: "#22C55E", copy: "runtime ready" } - : entry?.authAvailable - ? { border: "rgba(59,130,246,0.3)", bg: "rgba(59,130,246,0.12)", text: "#60A5FA", copy: entry.runtimeDetected ? "sign-in required" : "auth found locally" } - : { border: "rgba(113,113,122,0.3)", bg: "rgba(113,113,122,0.12)", text: "#A1A1AA", copy: "not detected" }; - - return ( -
- {label} - {tone.copy} -
- ); -} diff --git a/apps/desktop/src/renderer/components/settings/UsageMeter.tsx b/apps/desktop/src/renderer/components/settings/UsageMeter.tsx index 06abc99f1..4e8caf008 100644 --- a/apps/desktop/src/renderer/components/settings/UsageMeter.tsx +++ b/apps/desktop/src/renderer/components/settings/UsageMeter.tsx @@ -6,6 +6,7 @@ export function UsageMeter({ sublabel, modelBreakdown, mode = "used", + toneColor = "#A78BFA", className, }: { label: string; @@ -13,6 +14,7 @@ export function UsageMeter({ sublabel?: string; modelBreakdown?: Record; mode?: "used" | "remaining"; + toneColor?: string; className?: string; }) { const clamped = Math.max(0, Math.min(100, percent)); @@ -29,7 +31,7 @@ export function UsageMeter({ ? "#EF4444" : clamped > 70 ? "#F59E0B" - : "#A78BFA"; + : toneColor; return (
@@ -43,20 +45,35 @@ export function UsageMeter({
- {hasBreakdown ? ( - - ) : ( -
- )} +
+ {hasBreakdown + ? breakdownEntries.map(([model, pct], i) => { + const subClamped = Math.max(0, Math.min(100, pct)); + if (subClamped <= 0) return null; + const tickLeft = Math.min(99, subClamped); + return ( + + ); + }) + : null}
{sublabel && ( @@ -69,7 +86,7 @@ export function UsageMeter({
{model} {pct.toFixed(1)}% {mode} @@ -83,33 +100,3 @@ export function UsageMeter({ } const MODEL_COLORS = ["#A78BFA", "#7C3AED", "#C4B5FD", "#6D28D9"]; - -function StackedBar({ - entries, - total, -}: { - entries: [string, number][]; - total: number; -}) { - let offset = 0; - return ( - <> - {entries.map(([model, pct], i) => { - const width = total > 0 ? (pct / 100) * 100 : 0; - const left = offset; - offset += width; - return ( -
- ); - })} - - ); -} diff --git a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx new file mode 100644 index 000000000..3b273e353 --- /dev/null +++ b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx @@ -0,0 +1,241 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CaretDown, CaretRight, Gauge, X } from "@phosphor-icons/react"; +import type { + BudgetCapConfig, + UsageProvider, + UsageSnapshot, +} from "../../../shared/types"; +import { cn } from "../ui/cn"; +import { BudgetCapEditor } from "../settings/BudgetCapEditor"; +import { UsageQuotaPanel } from "./UsageQuotaPanel"; + +function extractError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function usageTone(percent: number, hasErrors: boolean): string { + if (percent >= 90) return "#EF4444"; + if (percent >= 70) return "#F59E0B"; + if (percent > 0) return "#4ADE80"; + if (hasErrors) return "#F59E0B"; + return "var(--color-muted-fg)"; +} + +function summaryPercent(snapshot: UsageSnapshot | null): number { + if (!snapshot || snapshot.windows.length === 0) return 0; + return Math.max(...snapshot.windows.map((window) => Math.max(0, Math.min(100, window.percentUsed)))); +} + +const PROVIDER_LABEL: Record = { + claude: "Claude", + codex: "Codex", + cursor: "Cursor", +}; + +function summaryTitle(snapshot: UsageSnapshot | null, percent: number, hasErrors: boolean): string { + if (!snapshot || snapshot.windows.length === 0) { + return hasErrors ? "Usage — provider polling has warnings" : "Usage"; + } + const byProvider = new Map(); + for (const window of snapshot.windows) { + const prev = byProvider.get(window.provider) ?? 0; + byProvider.set(window.provider, Math.max(prev, Math.max(0, Math.min(100, window.percentUsed)))); + } + const lines = Array.from(byProvider.entries()) + .map(([provider, value]) => `${PROVIDER_LABEL[provider]} ${Math.round(value)}%`); + const head = `Usage ${Math.round(percent)}% peak`; + const detail = lines.length > 0 ? ` (${lines.join(" · ")})` : ""; + const tail = hasErrors ? " — warnings" : ""; + return `${head}${detail}${tail}`; +} + +export function HeaderUsageControl() { + const [open, setOpen] = useState(false); + const [snapshot, setSnapshot] = useState(null); + const [budgetConfig, setBudgetConfig] = useState(null); + const [budgetSaving, setBudgetSaving] = useState(false); + const [budgetError, setBudgetError] = useState(null); + const [guardrailsOpen, setGuardrailsOpen] = useState(false); + const panelRef = useRef(null); + + useEffect(() => { + if (!window.ade?.usage) return; + let cancelled = false; + window.ade.usage.getSnapshot() + .then((nextSnapshot) => { + if (!cancelled) setSnapshot(nextSnapshot); + }) + .catch(() => { + if (!cancelled) setSnapshot(null); + }); + const unsubscribe = window.ade.usage.onUpdate((nextSnapshot) => { + if (!cancelled) setSnapshot(nextSnapshot); + }); + return () => { + cancelled = true; + try { + unsubscribe(); + } catch { + // noop + } + }; + }, []); + + useEffect(() => { + if (!open || !window.ade?.usage?.getBudgetConfig) return; + let cancelled = false; + window.ade.usage + .getBudgetConfig() + .then((config) => { + if (!cancelled) setBudgetConfig(config); + }) + .catch((err) => { + if (!cancelled) setBudgetError(extractError(err)); + }); + return () => { + cancelled = true; + }; + }, [open]); + + const saveBudget = useCallback(async (next: BudgetCapConfig) => { + if (!window.ade?.usage?.saveBudgetConfig) { + setBudgetError("Budget save bridge unavailable."); + return; + } + setBudgetSaving(true); + setBudgetError(null); + try { + const saved = await window.ade.usage.saveBudgetConfig(next); + setBudgetConfig(saved); + } catch (err) { + setBudgetError(extractError(err)); + } finally { + setBudgetSaving(false); + } + }, []); + + useEffect(() => { + if (!open) return; + const frame = window.requestAnimationFrame(() => { + panelRef.current?.focus(); + }); + return () => window.cancelAnimationFrame(frame); + }, [open]); + + const percent = summaryPercent(snapshot); + const hasErrors = (snapshot?.errors.length ?? 0) > 0; + const tone = useMemo(() => usageTone(percent, hasErrors), [hasErrors, percent]); + const title = summaryTitle(snapshot, percent, hasErrors); + const showDot = percent > 0 || hasErrors; + + return ( + <> + + + {open ? ( +
setOpen(false)} + > +
event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + } + }} + > +
+
+ +
+ Usage +
+
+ +
+
+ + +
+ + + {guardrailsOpen ? ( +
+ +
+ ) : null} +
+
+
+
+ ) : null} + + ); +} diff --git a/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.test.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx similarity index 53% rename from apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.test.tsx rename to apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx index d782cd487..91e53517c 100644 --- a/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.test.tsx +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx @@ -4,20 +4,47 @@ import React from "react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { UsageSnapshot } from "../../../shared/types"; -import { UsageGuardrailsSection } from "./UsageGuardrailsSection"; +import { UsageQuotaPanel } from "./UsageQuotaPanel"; function makeSnapshot(): UsageSnapshot { return { - windows: [], + windows: [ + { + provider: "codex", + windowType: "weekly", + percentUsed: 63, + resetsAt: "2099-05-15T07:00:00.000Z", + resetsInMs: 86_400_000, + }, + { + provider: "claude", + windowType: "weekly", + percentUsed: 20, + resetsAt: "2099-05-15T07:00:00.000Z", + resetsInMs: 86_400_000, + }, + ], pacing: { status: "on-track", - projectedWeeklyPercent: 0, - weekElapsedPercent: 0, - expectedPercent: 0, - deltaPercent: 0, + projectedWeeklyPercent: 63, + weekElapsedPercent: 50, + expectedPercent: 50, + deltaPercent: 13, etaHours: null, willLastToReset: true, - resetsInHours: 0, + resetsInHours: 24, + }, + pacingByProvider: { + codex: { + status: "ahead", + projectedWeeklyPercent: 70, + weekElapsedPercent: 50, + expectedPercent: 50, + deltaPercent: 13, + etaHours: null, + willLastToReset: true, + resetsInHours: 24, + }, }, costs: [], extraUsage: [], @@ -26,14 +53,15 @@ function makeSnapshot(): UsageSnapshot { }; } -describe("UsageGuardrailsSection", () => { +describe("UsageQuotaPanel", () => { const originalAde = globalThis.window.ade; beforeEach(() => { + const snapshot = makeSnapshot(); globalThis.window.ade = { usage: { - getSnapshot: vi.fn().mockResolvedValue(makeSnapshot()), - refresh: vi.fn().mockResolvedValue(makeSnapshot()), + getSnapshot: vi.fn().mockResolvedValue(snapshot), + refresh: vi.fn().mockResolvedValue(snapshot), getBudgetConfig: vi.fn().mockResolvedValue({}), saveBudgetConfig: vi.fn().mockResolvedValue({}), onUpdate: vi.fn(() => () => {}), @@ -56,18 +84,16 @@ describe("UsageGuardrailsSection", () => { globalThis.window.ade = originalAde; }); - it("hydrates from the cached snapshot on mount instead of forcing a live usage poll", async () => { - render(); + it("shows Codex as percent used, not percent remaining", async () => { + render(); - await waitFor(() => { - expect(window.ade.usage.getSnapshot).toHaveBeenCalledTimes(1); - expect(window.ade.usage.getBudgetConfig).toHaveBeenCalledTimes(1); - }); - expect(window.ade.usage.refresh).not.toHaveBeenCalled(); + expect((await screen.findAllByText("Codex")).length).toBeGreaterThan(0); + expect(await screen.findByText("63.0% used")).toBeTruthy(); + expect(screen.queryByText("37.0% remaining")).toBeNull(); }); it("keeps live provider polling available through the manual refresh button", async () => { - render(); + render(); await waitFor(() => { const refreshButton = screen.getByRole("button", { name: /refresh/i }) as HTMLButtonElement; diff --git a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx new file mode 100644 index 000000000..3ebb80d33 --- /dev/null +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx @@ -0,0 +1,372 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ArrowClockwise as RefreshCw, Gauge } from "@phosphor-icons/react"; +import type { + AiProviderConnectionStatus, + AiProviderConnections, + ExtraUsage, + UsageProvider, + UsageSnapshot, + UsageWindow, +} from "../../../shared/types"; +import { Button } from "../ui/Button"; +import { cn } from "../ui/cn"; +import { UsageMeter } from "../settings/UsageMeter"; +import { UsagePacingBadge } from "../settings/UsagePacingBadge"; + +const CARD_SHADOW_STYLE: React.CSSProperties = { + background: "linear-gradient(180deg, rgba(20, 31, 45, 0.96) 0%, rgba(10, 18, 28, 0.94) 100%)", + border: "1px solid rgba(87, 108, 128, 0.22)", + boxShadow: "0 18px 40px -24px rgba(0, 0, 0, 0.78), inset 0 1px 0 rgba(255,255,255,0.04)", +}; + +const PROVIDER_ORDER: UsageProvider[] = ["claude", "codex", "cursor"]; + +const PROVIDER_META: Record = { + claude: { label: "Claude", color: "#D97757" }, + codex: { label: "Codex", color: "#4ADE80" }, + cursor: { label: "Cursor", color: "#00BFA5" }, +}; + +function extractError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function computeResetsInMs(resetsAt: string, nowMs: number): number { + if (!resetsAt) return 0; + return Math.max(0, new Date(resetsAt).getTime() - nowMs); +} + +function formatResetTime(ms: number): string { + if (ms <= 0) return "resets now"; + const hours = Math.floor(ms / 3_600_000); + const mins = Math.floor((ms % 3_600_000) / 60_000); + if (hours > 0) return `resets in ${hours}h ${mins}m`; + return `resets in ${mins}m`; +} + +function formatPolledAt(iso: string | null): string { + if (!iso) return "--"; + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return "--"; + return parsed.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function displayPercent(window: UsageWindow, nowMs: number): number { + const resetsInMs = computeResetsInMs(window.resetsAt, nowMs); + return resetsInMs <= 0 ? 0 : window.percentUsed; +} + +function windowLabel(window: UsageWindow): string { + switch (window.windowType) { + case "five_hour": + return "5-hour window"; + case "weekly": + return "Weekly window"; + case "monthly": + return "Monthly window"; + case "weekly_oauth_apps": + return "OAuth apps"; + case "weekly_cowork": + return "Cowork"; + default: + return window.windowType; + } +} + +function providerConnection( + connections: AiProviderConnections | null, + provider: UsageProvider, +): AiProviderConnectionStatus | null { + return connections?.[provider] ?? null; +} + +export function UsageQuotaPanel({ + className, + onSnapshotChange, +}: { + className?: string; + onSnapshotChange?: (snapshot: UsageSnapshot | null) => void; +}) { + const [snapshot, setSnapshot] = useState(null); + const [providerConnections, setProviderConnections] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [nowMs, setNowMs] = useState(() => Date.now()); + + const applySnapshot = useCallback((nextSnapshot: UsageSnapshot | null) => { + setSnapshot(nextSnapshot); + onSnapshotChange?.(nextSnapshot); + }, [onSnapshotChange]); + + const load = useCallback(async () => { + if (!window.ade?.usage) { + setError("Usage bridge unavailable."); + return; + } + setLoading(true); + setError(null); + try { + applySnapshot(await window.ade.usage.getSnapshot()); + } catch (err) { + setError(extractError(err)); + } finally { + setLoading(false); + } + }, [applySnapshot]); + + const manualRefresh = useCallback(async () => { + if (!window.ade?.usage) return; + setLoading(true); + setError(null); + try { + applySnapshot(await window.ade.usage.refresh()); + } catch (err) { + setError(extractError(err)); + } finally { + setLoading(false); + } + }, [applySnapshot]); + + useEffect(() => { + void load(); + if (!window.ade?.usage) return; + const unsubscribe = window.ade.usage.onUpdate((nextSnapshot) => applySnapshot(nextSnapshot)); + return () => { + try { + unsubscribe(); + } catch { + // noop + } + }; + }, [applySnapshot, load]); + + useEffect(() => { + let cancelled = false; + if (!window.ade?.ai?.getStatus) return; + window.ade.ai.getStatus() + .then((status) => { + if (!cancelled) setProviderConnections(status.providerConnections ?? null); + }) + .catch(() => { + if (!cancelled) setProviderConnections(null); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const timer = window.setInterval(() => setNowMs(Date.now()), 15_000); + return () => window.clearInterval(timer); + }, []); + + const windowsByProvider = useMemo(() => { + const grouped: Partial> = {}; + for (const provider of PROVIDER_ORDER) { + grouped[provider] = snapshot?.windows.filter((window) => window.provider === provider) ?? []; + } + return grouped; + }, [snapshot?.windows]); + + const hasAnyWindow = PROVIDER_ORDER.some((provider) => (windowsByProvider[provider]?.length ?? 0) > 0); + const showEmptyQuotaWarning = + PROVIDER_ORDER.some((provider) => providerConnection(providerConnections, provider)?.authAvailable) && + !hasAnyWindow && + (snapshot?.errors.length ?? 0) === 0; + + return ( +
+
+
+
+ + Provider usage +
+
+ Last polled: {formatPolledAt(snapshot?.lastPolledAt ?? null)} +
+
+ +
+ + {error ? ( +
{error}
+ ) : null} + + {showEmptyQuotaWarning ? ( +
+ Provider login is detected, but the quota poll returned no provider windows. Restart ADE if the main process has stale usage polling code. +
+ ) : null} + + {snapshot?.errors.map((entry, index) => ( +
+ {entry} +
+ ))} + +
+ {PROVIDER_ORDER.map((provider) => ( + + ))} +
+ + {(snapshot?.extraUsage ?? []).map((extra) => ( + + ))} + + {!snapshot && !loading && !error ? ( +
+ +
No provider usage data yet
+
+ This fills in once provider auth is configured and ADE has quota data to poll. +
+
+ ) : null} +
+ ); +} + +function ProviderUsageCard({ + provider, + windows, + connection, + pacing, + nowMs, +}: { + provider: UsageProvider; + windows: UsageWindow[]; + connection: AiProviderConnectionStatus | null; + pacing: UsageSnapshot["pacing"] | null; + nowMs: number; +}) { + const meta = PROVIDER_META[provider]; + + return ( +
+
+
+ + + {meta.label} + +
+ +
+ + {pacing ? ( + + ) : null} + + {windows.length > 0 ? ( +
+ {windows.map((window) => { + const resetMs = computeResetsInMs(window.resetsAt, nowMs); + return ( + + ); + })} +
+ ) : ( +
+ No quota window data available. +
+ )} +
+ ); +} + +function ExtraUsageCard({ extra }: { extra: ExtraUsage }) { + if (!extra.isEnabled) return null; + + const meta = PROVIDER_META[extra.provider]; + const usedUsd = extra.usedCreditsUsd; + const limitUsd = extra.monthlyLimitUsd; + const percent = limitUsd > 0 ? Math.min(100, (usedUsd / limitUsd) * 100) : 0; + const fillColor = percent > 90 ? "#EF4444" : percent > 70 ? "#F59E0B" : meta.color; + + const formatUsd = (v: number) => v.toLocaleString("en-US", { style: "currency", currency: extra.currency.toUpperCase() }); + + return ( +
+
+ + + {meta.label} extra usage + +
+ +
+
+ + Monthly spend + + + {formatUsd(usedUsd)}{limitUsd > 0 ? ` / ${formatUsd(limitUsd)}` : ""} + +
+ + {limitUsd > 0 ? ( +
+
+
+ ) : ( +
+ No monthly limit configured +
+ )} +
+
+ ); +} + +function AuthChip({ + label, + entry, +}: { + label: string; + entry: AiProviderConnectionStatus | null; +}) { + const tone = entry?.runtimeAvailable + ? { border: "rgba(34,197,94,0.3)", bg: "rgba(34,197,94,0.12)", text: "#22C55E", copy: "runtime ready" } + : entry?.authAvailable + ? { border: "rgba(59,130,246,0.3)", bg: "rgba(59,130,246,0.12)", text: "#60A5FA", copy: entry.runtimeDetected ? "sign-in required" : "auth found locally" } + : { border: "rgba(113,113,122,0.3)", bg: "rgba(113,113,122,0.12)", text: "#A1A1AA", copy: "not detected" }; + + return ( +
+ {label} + {tone.copy} +
+ ); +} diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 69eadad12..7b19ce2f3 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -984,6 +984,7 @@ export type AiProviderCredentialSource = | "macos-keychain" | "claude-credentials-file" | "codex-auth-file" + | "cursor-admin-env" | "cursor-env" | "cursor-api-key-store" | "factory-env"; diff --git a/apps/desktop/src/shared/types/usage.ts b/apps/desktop/src/shared/types/usage.ts index b3ed0c782..d26775b26 100644 --- a/apps/desktop/src/shared/types/usage.ts +++ b/apps/desktop/src/shared/types/usage.ts @@ -62,12 +62,12 @@ export type GetAggregatedUsageArgs = { }; // --------------------------------------------------------------------------- -// Live usage tracking types (Claude + Codex provider polling) +// Live usage tracking types (Claude, Codex, and Cursor provider polling) // --------------------------------------------------------------------------- -export type UsageProvider = "claude" | "codex"; +export type UsageProvider = "claude" | "codex" | "cursor"; -export type UsageWindowType = "five_hour" | "weekly" | "weekly_oauth_apps" | "weekly_cowork"; +export type UsageWindowType = "five_hour" | "weekly" | "monthly" | "weekly_oauth_apps" | "weekly_cowork"; export type UsageWindow = { provider: UsageProvider; @@ -76,6 +76,7 @@ export type UsageWindow = { percentUsed: number; resetsAt: string; resetsInMs: number; + windowDurationMs?: number; }; export type UsagePacingStatus = @@ -89,9 +90,9 @@ export type UsagePacingStatus = export type UsagePacing = { status: UsagePacingStatus; - /** Projected usage % at end of the weekly window */ + /** Projected usage % at the end of the tracked quota window. */ projectedWeeklyPercent: number; - /** % of the weekly window that has elapsed */ + /** % of the tracked quota window that has elapsed. */ weekElapsedPercent: number; /** Expected usage % at this point if usage were perfectly linear */ expectedPercent: number; @@ -99,12 +100,14 @@ export type UsagePacing = { deltaPercent: number; /** Hours until 100% at current rate, null if rate is ~0 */ etaHours: number | null; - /** Whether current rate will last until the weekly reset */ + /** Whether current rate will last until the tracked reset */ willLastToReset: boolean; - /** Hours until the weekly window resets */ + /** Hours until the tracked window resets */ resetsInHours: number; }; +export type UsagePacingByProvider = Partial>; + export type CostSnapshot = { provider: UsageProvider; last30dCostUsd: number; @@ -124,6 +127,7 @@ export type ExtraUsage = { export type UsageSnapshot = { windows: UsageWindow[]; pacing: UsagePacing; + pacingByProvider?: UsagePacingByProvider; costs: CostSnapshot[]; extraUsage: ExtraUsage[]; lastPolledAt: string; diff --git a/docs/features/automations/README.md b/docs/features/automations/README.md index 06344f4a9..6a751f5ee 100644 --- a/docs/features/automations/README.md +++ b/docs/features/automations/README.md @@ -30,7 +30,7 @@ Automations never duplicate Linear issue intake — the CTO owns that. Automatio - `RuleHistoryPanel.tsx` — per-rule run history (replaces the old cross-rule `HistoryTab`). - `TemplatesTab.tsx` / `AutomationsTemplatesPage.tsx` — template picker that seeds a new draft on `/automations` via router state. - `EmptyStateHint.tsx` — empty-state copy shared across tabs. -- `apps/desktop/src/renderer/components/settings/` — usage/budget/cost UI for automations and missions (shared with Settings > Usage). `UsageGuardrailsSection`, `BudgetCapEditor`, `CostSummaryCard`, `UsageMeter`, `UsagePacingBadge` all live here; they no longer sit on the Automations page. +- `apps/desktop/src/renderer/components/usage/` — header Usage popup (`HeaderUsageControl`, `UsageQuotaPanel`) that hosts live provider quotas + the collapsible automation guardrails. `BudgetCapEditor`, `UsageMeter`, `UsagePacingBadge`, and `CostSummaryCard` continue to live under `components/settings/` but are rendered from the popup; Settings no longer has a Usage tab. - `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` — agent-session execution surfaces as a chat thread filtered by automation owner. ### IPC @@ -159,7 +159,7 @@ Automations route outputs based on `outputs.disposition`: - `memory.mode` controls scope: `automation-plus-project` (default), `automation-only`, `project-only`. - `memory.ruleScopeKey` defaults to the rule id. -- Budget caps come from Settings > Usage (shared with Missions). Rule-level caps via `guardrails.maxDurationMin` prevent runaway runs. +- Budget caps come from the header Usage popup → Automation guardrails (shared with Missions). Rule-level caps via `guardrails.maxDurationMin` prevent runaway runs. - Usage telemetry respects `billingCode` so operators can slice spend per rule. ## Boundaries diff --git a/docs/features/automations/guardrails.md b/docs/features/automations/guardrails.md index 5423cd92e..5374a952f 100644 --- a/docs/features/automations/guardrails.md +++ b/docs/features/automations/guardrails.md @@ -135,7 +135,7 @@ Mission-execution rules inherit the mission runtime's sandbox (`WorkerSandboxCon ## Budget caps -Shared with Missions via Settings > Usage. The usage/budget UI (`UsageGuardrailsSection`, `BudgetCapEditor`, `CostSummaryCard`, `UsageMeter`, `UsagePacingBadge`) lives under `apps/desktop/src/renderer/components/settings/` — it is not rendered from the Automations page. The Automations page focuses on rules, history, and ingress. +Shared with Missions via the top-bar Usage popup. The popup (`HeaderUsageControl`, `UsageQuotaPanel`) renders the live provider quotas plus a collapsible Automation guardrails section that mounts `BudgetCapEditor`. Supporting widgets (`CostSummaryCard`, `UsageMeter`, `UsagePacingBadge`) still live under `apps/desktop/src/renderer/components/settings/` but are reused from the popup. Settings no longer has a Usage tab; the Automations page continues to focus on rules, history, and ingress. Automations also support rule-level caps: diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index ccadb3a24..5307d4e3a 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -180,12 +180,13 @@ Renderer — settings: the Tailscale MagicDNS discovery status (`svc:ade-sync` publication via `tailscale serve`), and the per-device connection panel used to forget paired phones. -- `apps/desktop/src/renderer/components/settings/SettingsUsageSection.tsx` - and `UsageGuardrailsSection.tsx` — cost and usage. The guardrails - section's mount-time hydrate calls `ade.usage.getSnapshot` (cached - read), not `ade.usage.refresh` (which forces a recompute); the user - still gets the live numbers via the section's explicit Refresh - control. +- `apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx` + and `UsageQuotaPanel.tsx` — header usage popup. Live provider quotas + for Claude / Codex / Cursor and the automation budget guardrails are + now consolidated here; Settings no longer has a Usage tab. The popup + hydrates from `ade.usage.getSnapshot` and re-fetches via the explicit + Refresh control. Budget caps round-trip through + `ade.usage.getBudgetConfig` / `saveBudgetConfig`. - `apps/desktop/src/renderer/components/settings/ProxyAndPreviewSection.tsx` — proxy/preview configuration UI. - `apps/desktop/src/renderer/components/settings/DiagnosticsDashboardSection.tsx` @@ -291,7 +292,9 @@ changing rather than which service backs it: | Integrations | `IntegrationsSettingsSection.tsx`, `GitHubSection.tsx`, `LinearSection.tsx` | GitHub, Linear, and computer-use backend readiness. The GitHub section reads `status.connected` (the backend's single "GitHub is usable" gate) to decide between CONNECTED / LIMITED ACCESS / NOT CONNECTED, surfaces a dedicated repo-probe error when a fine-grained token authenticates as a user but cannot access the active repo, and the REFRESH button calls `getStatus({ forceRefresh: true })` so users who fix permissions on github.com see the change immediately. See [`pull-requests/README.md`](../pull-requests/README.md#github-connectivity-model) for the full status-shape and `connected` derivation. | | Memory | `MemoryHealthTab.tsx` | Memory health, browser, embedding health | | Lane Templates | `LaneTemplatesSection.tsx`, `LaneBehaviorSection.tsx` | Lane init recipes and lane lifecycle policy | -| Usage | `SettingsUsageSection.tsx`, `UsageGuardrailsSection.tsx` | Cost visibility and guardrails | + +> Live provider usage and automation guardrails moved out of Settings. They are now in the top-bar Usage popup (`HeaderUsageControl.tsx` → `UsageQuotaPanel.tsx` + collapsible `BudgetCapEditor`). + The Settings page itself (`SettingsPage.tsx`) has a legacy alias table (`TAB_ALIASES`) that forwards deep links (`?tab=context`, From 67665027d196ea9418a668265e577ae04858f0c1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 10 May 2026 09:18:04 -0500 Subject: [PATCH 2/8] ship: prepare lane for review (automate + finalize passes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - automate: extend ade-cli/cli.test.ts with 5 cases for the new `ade usage snapshot|refresh|budget` plan (123 → 123 incl. usage paths). - automate: refresh docs/ARCHITECTURE.md telemetry pointer to the header Usage popup (HeaderUsageControl + UsageQuotaPanel). - finalize/simplify: extract `finiteOrZero` for the Cursor spend reducers, flatten the cursorBlocker ternary into an if/else cascade, collapse the inline budget-set try block, drop unnecessary useMemo + try/catch on the ipc unsubscribe callbacks, replace nested ternaries in ExtraUsageCard / AuthChip / fillColor with explicit branches. - finalize/cli-parity: add `ade usage` examples to apps/ade-cli/README CLI surface inventory. Local gate: typecheck (desktop/ade-cli/web), eslint, vitest shards 1-8, and ade-cli tests all green. --- apps/ade-cli/README.md | 6 ++ apps/ade-cli/src/cli.test.ts | 85 +++++++++++++++++++ apps/ade-cli/src/cli.ts | 10 ++- .../services/ai/providerConnectionStatus.ts | 22 +++-- .../services/usage/usageTrackingService.ts | 47 +++++----- .../components/usage/HeaderUsageControl.tsx | 10 +-- .../components/usage/UsageQuotaPanel.tsx | 28 +++--- docs/ARCHITECTURE.md | 2 +- 8 files changed, 148 insertions(+), 62 deletions(-) diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index e2589931e..118d58046 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -91,6 +91,12 @@ ade --socket macos-vm click --lane lane-id --x 120 --y 420 --text ade --socket update status --text ade --socket update check --text ade --socket update install --text +ade usage snapshot --text +ade usage refresh --text +ade usage budget get --text +ade usage budget set --from-file budget.json +ade usage budget check --provider claude --scope global +ade usage budget cumulative --scope global --text ade actions list ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts ade cursor cloud agents list --text diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 840f4a7c5..ea39943bd 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2754,4 +2754,89 @@ describe("ADE CLI", () => { expect((summarized as any).visual).toContain("\\- main (id: main) [main]"); expect((summarized as any).visual).toContain("\\- child (id: child) [feature]"); }); + + it("usage snapshot routes to the usage.getUsageSnapshot action with no args", () => { + const plan = buildCliPlan(["usage", "snapshot"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.label).toBe("usage snapshot"); + expect(plan.steps).toHaveLength(1); + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { domain: "usage", action: "getUsageSnapshot", args: {} }, + }); + + // The `quota`/`quotas` aliases must dispatch to the same plan. + const aliased = buildCliPlan(["quota", "snapshot"]); + expect(aliased.kind).toBe("execute"); + if (aliased.kind !== "execute") return; + expect(aliased.steps[0]?.params).toEqual(plan.steps[0]?.params); + }); + + it("usage refresh routes to the usage.forceRefresh action", () => { + const plan = buildCliPlan(["usage", "refresh"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.label).toBe("usage refresh"); + expect(plan.steps).toHaveLength(1); + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { domain: "usage", action: "forceRefresh", args: {} }, + }); + // `poll` is the documented alias. + const polled = buildCliPlan(["usage", "poll"]); + expect(polled.kind).toBe("execute"); + if (polled.kind !== "execute") return; + expect(polled.steps[0]?.params).toEqual(plan.steps[0]?.params); + }); + + it("usage budget get routes to the budget.getConfig action", () => { + const plan = buildCliPlan(["usage", "budget", "get"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.label).toBe("usage budget get"); + expect(plan.steps).toHaveLength(1); + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { domain: "budget", action: "getConfig", args: {} }, + }); + }); + + it("usage budget set --from-file parses the JSON body and forwards it as args", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-usage-budget-")); + const budgetPath = path.join(root, "budget.json"); + const config = { caps: [{ provider: "claude", scope: "global", limitUsd: 25 }] }; + fs.writeFileSync(budgetPath, JSON.stringify(config)); + + const plan = buildCliPlan(["usage", "budget", "set", "--from-file", budgetPath]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.label).toBe("usage budget update"); + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { domain: "budget", action: "updateConfig", args: config }, + }); + + // Empty body must surface as a CLI usage error, not silently send `{}`. + expect(() => buildCliPlan(["usage", "budget", "set", "--text", "[1,2,3]"])) + .toThrow(/must be a JSON object/i); + }); + + it("usage budget check defaults scope to global and forwards --provider", () => { + const plan = buildCliPlan(["usage", "budget", "check", "--provider", "claude"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.label).toBe("usage budget check"); + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "budget", + action: "checkBudget", + args: { scope: "global", scopeId: null, provider: "claude" }, + }, + }); + + expect(() => buildCliPlan(["usage", "budget", "bogus"])) + .toThrow(/usage budget supports get, set, check, or cumulative/); + }); }); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 9827843d5..16191a851 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -3696,10 +3696,12 @@ function buildUsagePlan(args: string[]): CliPlan { } if (mode === "set" || mode === "update") { const text = readFileTextInput(args); - let parsed: unknown = null; - if (text != null && text.trim().length > 0) { - try { parsed = JSON.parse(text); } - catch (error) { + const hasInlineBody = text != null && text.trim().length > 0; + let parsed: unknown; + if (hasInlineBody) { + try { + parsed = JSON.parse(text); + } catch (error) { throw new CliUsageError(`Failed to parse budget config: ${error instanceof Error ? error.message : String(error)}`); } } else { diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index 1e7b5aa64..af1df3fc1 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -186,9 +186,10 @@ export async function buildProviderConnections( } const cursorSdkAuth = Boolean(cursorEnvAuth || cursorStoredAuth); const cursorUsageAuth = Boolean(cursorSdkAuth || cursorAdminEnvAuth); - let cursorCredsSource: "cursor-env" | "cursor-api-key-store" | undefined; + let cursorCredsSource: "cursor-env" | "cursor-api-key-store" | "cursor-admin-env" | undefined; if (cursorEnvAuth) cursorCredsSource = "cursor-env"; else if (cursorStoredAuth) cursorCredsSource = "cursor-api-key-store"; + else if (cursorAdminEnvAuth) cursorCredsSource = "cursor-admin-env"; // Runtime is bundled with the app — it always exists. Only auth-related // fields should depend on whether a Cursor API key is present. const cursorFlags = { @@ -200,13 +201,16 @@ export async function buildProviderConnections( runtimeAvailable: cursorSdkAuth, }; - const cursorBlocker: string | null = cursorSdkAuth - ? null - : cursorAdminEnvAuth - ? "Cursor Admin API key is configured for usage; add a Cursor agent API key for Cursor runtime access." - : cursorStoreUnavailable - ? "ADE could not read the Cursor API key store yet. Retry after the key store is ready." - : "Enter a Cursor API key from https://cursor.com/dashboard/integrations."; + let cursorBlocker: string | null; + if (cursorSdkAuth) { + cursorBlocker = null; + } else if (cursorAdminEnvAuth) { + cursorBlocker = "Cursor Admin API key is configured for usage; add a Cursor agent API key for Cursor runtime access."; + } else if (cursorStoreUnavailable) { + cursorBlocker = "ADE could not read the Cursor API key store yet. Retry after the key store is ready."; + } else { + cursorBlocker = "Enter a Cursor API key from https://cursor.com/dashboard/integrations."; + } const cursor: AiProviderConnectionStatus = { ...createUnavailableStatus("cursor", checkedAt), @@ -219,7 +223,7 @@ export async function buildProviderConnections( { kind: "local-credentials", detected: cursorUsageAuth, - source: cursorCredsSource ?? (cursorAdminEnvAuth ? "cursor-admin-env" : undefined), + source: cursorCredsSource, }, { kind: "cli", diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index 2f8ca0751..105d9ec6f 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -285,10 +285,13 @@ function getCursorApiKey(): { key: string; source: "cursor-admin-env" | "cursor- return null; } +function finiteOrZero(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + function addOneMonth(timestampMs: number): number { - const date = new Date(timestampMs); - if (!Number.isFinite(date.getTime())) return 0; - const next = new Date(date.getTime()); + const next = new Date(timestampMs); + if (!Number.isFinite(next.getTime())) return 0; next.setMonth(next.getMonth() + 1); return next.getTime(); } @@ -300,40 +303,30 @@ function parseCursorSpendUsage(data: CursorSpendResponse): { const members = Array.isArray(data.teamMemberSpend) ? data.teamMemberSpend : []; if (members.length === 0) return { windows: [], extraUsage: null }; + // overallSpendCents = on-demand + included usage (the real total spend); + // spendCents alone captures only on-demand pay-as-you-go. const memberSpendCents = (member: CursorSpendMember): number => { - // overallSpendCents = on-demand + included usage (the real total spend) - // spendCents alone captures only on-demand pay-as-you-go. - const overall = typeof member.overallSpendCents === "number" && Number.isFinite(member.overallSpendCents) - ? member.overallSpendCents - : null; - if (overall != null) return overall; - return typeof member.spendCents === "number" && Number.isFinite(member.spendCents) ? member.spendCents : 0; + if (typeof member.overallSpendCents === "number" && Number.isFinite(member.overallSpendCents)) { + return member.overallSpendCents; + } + return finiteOrZero(member.spendCents); }; + // hardLimitOverrideDollars is the per-user override; fall back to the + // team-wide monthlyLimitDollars when no override is configured. const memberLimitCents = (member: CursorSpendMember): number => { - // hardLimitOverrideDollars is the per-user override; fall back to the - // team-wide monthlyLimitDollars when no override is configured. - const override = - typeof member.hardLimitOverrideDollars === "number" && Number.isFinite(member.hardLimitOverrideDollars) && member.hardLimitOverrideDollars > 0 - ? member.hardLimitOverrideDollars - : 0; - if (override > 0) return override * 100; - const monthly = - typeof member.monthlyLimitDollars === "number" && Number.isFinite(member.monthlyLimitDollars) && member.monthlyLimitDollars > 0 - ? member.monthlyLimitDollars - : 0; - return monthly > 0 ? monthly * 100 : 0; + const overrideDollars = finiteOrZero(member.hardLimitOverrideDollars); + if (overrideDollars > 0) return overrideDollars * 100; + const monthlyDollars = finiteOrZero(member.monthlyLimitDollars); + return monthlyDollars > 0 ? monthlyDollars * 100 : 0; }; const totalSpendCents = members.reduce((sum, member) => sum + memberSpendCents(member), 0); const totalLimitCents = members.reduce((sum, member) => sum + memberLimitCents(member), 0); - const cycleStartMs = - typeof data.subscriptionCycleStart === "number" && Number.isFinite(data.subscriptionCycleStart) - ? data.subscriptionCycleStart - : 0; + const cycleStartMs = finiteOrZero(data.subscriptionCycleStart); const resetMs = cycleStartMs > 0 ? addOneMonth(cycleStartMs) : 0; const resetsAt = resetMs > 0 ? new Date(resetMs).toISOString() : ""; - const windowDurationMs = resetMs > 0 && cycleStartMs > 0 ? Math.max(0, resetMs - cycleStartMs) : undefined; + const windowDurationMs = resetMs > 0 ? Math.max(0, resetMs - cycleStartMs) : undefined; const windows: UsageWindow[] = []; const utilization = totalLimitCents > 0 ? Math.min(100, (totalSpendCents / totalLimitCents) * 100) : null; diff --git a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx index 3b273e353..340a40784 100644 --- a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx +++ b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { CaretDown, CaretRight, Gauge, X } from "@phosphor-icons/react"; import type { BudgetCapConfig, @@ -73,11 +73,7 @@ export function HeaderUsageControl() { }); return () => { cancelled = true; - try { - unsubscribe(); - } catch { - // noop - } + unsubscribe(); }; }, []); @@ -124,7 +120,7 @@ export function HeaderUsageControl() { const percent = summaryPercent(snapshot); const hasErrors = (snapshot?.errors.length ?? 0) > 0; - const tone = useMemo(() => usageTone(percent, hasErrors), [hasErrors, percent]); + const tone = usageTone(percent, hasErrors); const title = summaryTitle(snapshot, percent, hasErrors); const showDot = percent > 0 || hasErrors; diff --git a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx index 3ebb80d33..440aea687 100644 --- a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx @@ -130,14 +130,8 @@ export function UsageQuotaPanel({ useEffect(() => { void load(); if (!window.ade?.usage) return; - const unsubscribe = window.ade.usage.onUpdate((nextSnapshot) => applySnapshot(nextSnapshot)); - return () => { - try { - unsubscribe(); - } catch { - // noop - } - }; + const unsubscribe = window.ade.usage.onUpdate(applySnapshot); + return unsubscribe; }, [applySnapshot, load]); useEffect(() => { @@ -306,7 +300,9 @@ function ExtraUsageCard({ extra }: { extra: ExtraUsage }) { const usedUsd = extra.usedCreditsUsd; const limitUsd = extra.monthlyLimitUsd; const percent = limitUsd > 0 ? Math.min(100, (usedUsd / limitUsd) * 100) : 0; - const fillColor = percent > 90 ? "#EF4444" : percent > 70 ? "#F59E0B" : meta.color; + let fillColor = meta.color; + if (percent > 90) fillColor = "#EF4444"; + else if (percent > 70) fillColor = "#F59E0B"; const formatUsd = (v: number) => v.toLocaleString("en-US", { style: "currency", currency: extra.currency.toUpperCase() }); @@ -353,11 +349,15 @@ function AuthChip({ label: string; entry: AiProviderConnectionStatus | null; }) { - const tone = entry?.runtimeAvailable - ? { border: "rgba(34,197,94,0.3)", bg: "rgba(34,197,94,0.12)", text: "#22C55E", copy: "runtime ready" } - : entry?.authAvailable - ? { border: "rgba(59,130,246,0.3)", bg: "rgba(59,130,246,0.12)", text: "#60A5FA", copy: entry.runtimeDetected ? "sign-in required" : "auth found locally" } - : { border: "rgba(113,113,122,0.3)", bg: "rgba(113,113,122,0.12)", text: "#A1A1AA", copy: "not detected" }; + let tone: { border: string; bg: string; text: string; copy: string }; + if (entry?.runtimeAvailable) { + tone = { border: "rgba(34,197,94,0.3)", bg: "rgba(34,197,94,0.12)", text: "#22C55E", copy: "runtime ready" }; + } else if (entry?.authAvailable) { + const copy = entry.runtimeDetected ? "sign-in required" : "auth found locally"; + tone = { border: "rgba(59,130,246,0.3)", bg: "rgba(59,130,246,0.12)", text: "#60A5FA", copy }; + } else { + tone = { border: "rgba(113,113,122,0.3)", bg: "rgba(113,113,122,0.12)", text: "#A1A1AA", copy: "not detected" }; + } return (
Date: Sun, 10 May 2026 09:50:32 -0500 Subject: [PATCH 3/8] ship: iteration 1 fix usage review feedback --- apps/ade-cli/src/cli.test.ts | 41 +++++++++++++++++++ apps/ade-cli/src/cli.ts | 11 +++-- .../ai/providerConnectionStatus.test.ts | 27 ++++++++++++ .../services/ai/providerConnectionStatus.ts | 31 ++++++++++---- .../services/usage/usageTrackingService.ts | 13 ++++-- .../renderer/components/app/SettingsPage.tsx | 1 + .../components/settings/UsageMeter.tsx | 37 +++++++++++++---- .../components/usage/UsageQuotaPanel.test.tsx | 26 ++++++++++++ .../components/usage/UsageQuotaPanel.tsx | 2 +- 9 files changed, 163 insertions(+), 26 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index ea39943bd..b9ece14c5 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2820,6 +2820,8 @@ describe("ADE CLI", () => { // Empty body must surface as a CLI usage error, not silently send `{}`. expect(() => buildCliPlan(["usage", "budget", "set", "--text", "[1,2,3]"])) .toThrow(/must be a JSON object/i); + expect(() => buildCliPlan(["usage", "budget", "set", "--text", " \n "])) + .toThrow(/non-empty JSON object/i); }); it("usage budget check defaults scope to global and forwards --provider", () => { @@ -2839,4 +2841,43 @@ describe("ADE CLI", () => { expect(() => buildCliPlan(["usage", "budget", "bogus"])) .toThrow(/usage budget supports get, set, check, or cumulative/); }); + + it("usage budget cumulative routes with scope parameters", () => { + const plan = buildCliPlan(["usage", "budget", "cumulative", "--scope", "global"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.label).toBe("usage budget cumulative"); + expect(plan.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "budget", + action: "getCumulativeUsage", + args: { scope: "global", scopeId: null, provider: null }, + }, + }); + + const aliased = buildCliPlan(["quota", "budget", "totals", "--provider", "cursor"]); + expect(aliased.kind).toBe("execute"); + if (aliased.kind !== "execute") return; + expect(aliased.steps[0]?.params).toEqual({ + name: "run_ade_action", + arguments: { + domain: "budget", + action: "getCumulativeUsage", + args: { scope: "global", scopeId: null, provider: "cursor" }, + }, + }); + }); + + it("usage command aliases resolve to usage help", () => { + const direct = buildCliPlan(["usage", "--help"]); + const quota = buildCliPlan(["quota", "--help"]); + const helpQuota = buildCliPlan(["help", "quota"]); + expect(direct.kind).toBe("help"); + expect(quota.kind).toBe("help"); + expect(helpQuota.kind).toBe("help"); + if (direct.kind !== "help" || quota.kind !== "help" || helpQuota.kind !== "help") return; + expect(quota.text).toBe(direct.text); + expect(helpQuota.text).toBe(direct.text); + }); }); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 16191a851..824d0f29e 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -3696,11 +3696,14 @@ function buildUsagePlan(args: string[]): CliPlan { } if (mode === "set" || mode === "update") { const text = readFileTextInput(args); - const hasInlineBody = text != null && text.trim().length > 0; let parsed: unknown; - if (hasInlineBody) { + if (text != null) { + const trimmed = text.trim(); + if (!trimmed.length) { + throw new CliUsageError("Budget config must be a non-empty JSON object."); + } try { - parsed = JSON.parse(text); + parsed = JSON.parse(trimmed); } catch (error) { throw new CliUsageError(`Failed to parse budget config: ${error instanceof Error ? error.message : String(error)}`); } @@ -4251,6 +4254,8 @@ function buildCliPlan(command: string[]): CliPlan { automation: "automations", "auto-update": "update", updates: "update", + quota: "usage", + quotas: "usage", }; const primaryHelpKey = aliases[primary] ?? primary; if (hasHelpFlag(args)) { diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts index b1375a905..9ecffe766 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts @@ -231,7 +231,9 @@ describe("buildProviderConnections", () => { it("marks Cursor runtime available through the SDK when an env API key is set", async () => { const prevKey = process.env.CURSOR_API_KEY; + const prevAdminKey = process.env.CURSOR_ADMIN_API_KEY; process.env.CURSOR_API_KEY = "test-key"; + delete process.env.CURSOR_ADMIN_API_KEY; try { const result = await buildProviderConnections( mergeCliStatuses([ @@ -247,17 +249,40 @@ describe("buildProviderConnections", () => { expect(result.cursor.authAvailable).toBe(true); expect(result.cursor.runtimeDetected).toBe(true); expect(result.cursor.runtimeAvailable).toBe(true); + expect(result.cursor.usageAvailable).toBe(false); expect(result.cursor.path).toBe("@cursor/sdk"); expect(result.cursor.blocker).toBeNull(); } finally { if (prevKey === undefined) delete process.env.CURSOR_API_KEY; else process.env.CURSOR_API_KEY = prevKey; + if (prevAdminKey === undefined) delete process.env.CURSOR_ADMIN_API_KEY; + else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey; + } + }); + + it("marks Cursor usage available only for Admin API-shaped keys", async () => { + const prevKey = process.env.CURSOR_API_KEY; + const prevAdminKey = process.env.CURSOR_ADMIN_API_KEY; + process.env.CURSOR_API_KEY = "key_cursor_admin_test"; + delete process.env.CURSOR_ADMIN_API_KEY; + try { + const result = await buildProviderConnections(mergeCliStatuses([])); + expect(result.cursor.authAvailable).toBe(true); + expect(result.cursor.runtimeAvailable).toBe(true); + expect(result.cursor.usageAvailable).toBe(true); + } finally { + if (prevKey === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = prevKey; + if (prevAdminKey === undefined) delete process.env.CURSOR_ADMIN_API_KEY; + else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey; } }); it("downgrades Cursor runtime availability when SDK model access rejects the key", async () => { const prevKey = process.env.CURSOR_API_KEY; + const prevAdminKey = process.env.CURSOR_ADMIN_API_KEY; process.env.CURSOR_API_KEY = "test-key"; + delete process.env.CURSOR_ADMIN_API_KEY; mockState.getProviderRuntimeHealth.mockImplementation((provider: string) => { if (provider === "cursor") { return { @@ -278,6 +303,8 @@ describe("buildProviderConnections", () => { } finally { if (prevKey === undefined) delete process.env.CURSOR_API_KEY; else process.env.CURSOR_API_KEY = prevKey; + if (prevAdminKey === undefined) delete process.env.CURSOR_ADMIN_API_KEY; + else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey; } }); }); diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index af1df3fc1..948b31321 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -26,6 +26,10 @@ function createUnavailableStatus( }; } +function isCursorAdminApiKey(value: string | null | undefined): boolean { + return Boolean(value?.trim().startsWith("key_")); +} + export async function buildProviderConnections( cliStatuses: CliAuthStatus[], ): Promise { @@ -102,7 +106,6 @@ export async function buildProviderConnections( } else if (health.state === "ready") { status.runtimeAvailable = true; status.authAvailable = true; - if (status.provider === "cursor") status.usageAvailable = true; status.blocker = null; } } @@ -173,19 +176,27 @@ export async function buildProviderConnections( }); const cursorCli = cliStatuses.find((entry) => entry.cli === "cursor") ?? null; - const cursorEnvAuth = Boolean(process.env.CURSOR_API_KEY?.trim()); - const cursorAdminEnvAuth = Boolean(process.env.CURSOR_ADMIN_API_KEY?.trim()); + const cursorEnvKey = process.env.CURSOR_API_KEY?.trim() ?? ""; + const cursorAdminEnvKey = process.env.CURSOR_ADMIN_API_KEY?.trim() ?? ""; + const cursorEnvAuth = Boolean(cursorEnvKey); + const cursorEnvUsageAuth = isCursorAdminApiKey(cursorEnvKey); + const cursorAdminEnvAuth = Boolean(cursorAdminEnvKey); + const cursorAdminUsageAuth = isCursorAdminApiKey(cursorAdminEnvKey); let cursorStoredAuth = false; + let cursorStoredUsageAuth = false; let cursorStoreUnavailable = false; try { - cursorStoredAuth = Boolean(getAllApiKeys().cursor?.trim()); + const storedCursorKey = getAllApiKeys().cursor?.trim() ?? ""; + cursorStoredAuth = Boolean(storedCursorKey); + cursorStoredUsageAuth = isCursorAdminApiKey(storedCursorKey); } catch { // API key store may not be initialized yet (or read failed); surface as a // distinct state so the blocker copy doesn't lie about the absence of a key. cursorStoreUnavailable = true; } const cursorSdkAuth = Boolean(cursorEnvAuth || cursorStoredAuth); - const cursorUsageAuth = Boolean(cursorSdkAuth || cursorAdminEnvAuth); + const cursorUsageAuth = Boolean(cursorEnvUsageAuth || cursorStoredUsageAuth || cursorAdminUsageAuth); + const cursorAuthAvailable = Boolean(cursorSdkAuth || cursorUsageAuth); let cursorCredsSource: "cursor-env" | "cursor-api-key-store" | "cursor-admin-env" | undefined; if (cursorEnvAuth) cursorCredsSource = "cursor-env"; else if (cursorStoredAuth) cursorCredsSource = "cursor-api-key-store"; @@ -196,16 +207,18 @@ export async function buildProviderConnections( runtimeDetected: true, cliAuthenticated: false, cliExplicitlyUnauthenticated: false, - localCredsDetected: cursorUsageAuth, - authAvailable: cursorUsageAuth, + localCredsDetected: cursorAuthAvailable, + authAvailable: cursorAuthAvailable, runtimeAvailable: cursorSdkAuth, }; let cursorBlocker: string | null; if (cursorSdkAuth) { cursorBlocker = null; - } else if (cursorAdminEnvAuth) { + } else if (cursorAdminUsageAuth) { cursorBlocker = "Cursor Admin API key is configured for usage; add a Cursor agent API key for Cursor runtime access."; + } else if (cursorAdminEnvAuth) { + cursorBlocker = "CURSOR_ADMIN_API_KEY is set but does not look like a Cursor Admin API key."; } else if (cursorStoreUnavailable) { cursorBlocker = "ADE could not read the Cursor API key store yet. Retry after the key store is ready."; } else { @@ -222,7 +235,7 @@ export async function buildProviderConnections( sources: [ { kind: "local-credentials", - detected: cursorUsageAuth, + detected: cursorAuthAvailable, source: cursorCredsSource, }, { diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index 105d9ec6f..d47ff59e2 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -270,14 +270,18 @@ type CursorSpendResponse = { subscriptionCycleStart?: number; }; +function isCursorAdminApiKey(value: string | null | undefined): boolean { + return Boolean(value?.trim().startsWith("key_")); +} + function getCursorApiKey(): { key: string; source: "cursor-admin-env" | "cursor-env" | "cursor-api-key-store" } | null { const adminEnvKey = process.env.CURSOR_ADMIN_API_KEY?.trim(); - if (adminEnvKey) return { key: adminEnvKey, source: "cursor-admin-env" }; + if (isCursorAdminApiKey(adminEnvKey)) return { key: adminEnvKey!, source: "cursor-admin-env" }; const envKey = process.env.CURSOR_API_KEY?.trim(); - if (envKey?.startsWith("key_")) return { key: envKey, source: "cursor-env" }; + if (isCursorAdminApiKey(envKey)) return { key: envKey!, source: "cursor-env" }; try { const stored = getAllApiKeys().cursor?.trim(); - if (stored?.startsWith("key_")) return { key: stored, source: "cursor-api-key-store" }; + if (isCursorAdminApiKey(stored)) return { key: stored!, source: "cursor-api-key-store" }; } catch { // The API key store can be unavailable during early startup. Treat this as // "no key" for usage polling; provider status surfaces the store issue. @@ -312,7 +316,7 @@ function parseCursorSpendUsage(data: CursorSpendResponse): { return finiteOrZero(member.spendCents); }; // hardLimitOverrideDollars is the per-user override; fall back to the - // team-wide monthlyLimitDollars when no override is configured. + // per-member default monthlyLimitDollars when no override is configured. const memberLimitCents = (member: CursorSpendMember): number => { const overrideDollars = finiteOrZero(member.hardLimitOverrideDollars); if (overrideDollars > 0) return overrideDollars * 100; @@ -323,6 +327,7 @@ function parseCursorSpendUsage(data: CursorSpendResponse): { const totalSpendCents = members.reduce((sum, member) => sum + memberSpendCents(member), 0); const totalLimitCents = members.reduce((sum, member) => sum + memberLimitCents(member), 0); + // Cursor documents subscriptionCycleStart as epoch milliseconds. const cycleStartMs = finiteOrZero(data.subscriptionCycleStart); const resetMs = cycleStartMs > 0 ? addOneMonth(cycleStartMs) : 0; const resetsAt = resetMs > 0 ? new Date(resetMs).toISOString() : ""; diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index 0f8f1bee1..9c159deaa 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -42,6 +42,7 @@ const TAB_ALIASES: Record = { onboarding: "general", help: "general", tours: "general", + usage: "general", }; function padIndex(i: number): string { diff --git a/apps/desktop/src/renderer/components/settings/UsageMeter.tsx b/apps/desktop/src/renderer/components/settings/UsageMeter.tsx index 4e8caf008..9312e841c 100644 --- a/apps/desktop/src/renderer/components/settings/UsageMeter.tsx +++ b/apps/desktop/src/renderer/components/settings/UsageMeter.tsx @@ -20,6 +20,22 @@ export function UsageMeter({ const clamped = Math.max(0, Math.min(100, percent)); const breakdownEntries = modelBreakdown ? Object.entries(modelBreakdown) : []; const hasBreakdown = breakdownEntries.length > 0; + const modelPalette = MODEL_COLORS.filter((color) => color.toLowerCase() !== toneColor.toLowerCase()); + const modelColor = (index: number) => { + if (index === 0) return toneColor; + return modelPalette[(index - 1) % modelPalette.length] ?? toneColor; + }; + let cumulativeBreakdownPct = 0; + const breakdownMarkers = breakdownEntries.map(([model, pct], i) => { + const subClamped = Math.max(0, Math.min(100, pct)); + if (subClamped <= 0) return null; + cumulativeBreakdownPct = Math.min(100, cumulativeBreakdownPct + subClamped); + return { + model, + left: Math.min(99, cumulativeBreakdownPct), + color: modelColor(i), + }; + }); const fillColor = mode === "remaining" ? clamped <= 10 @@ -47,6 +63,11 @@ export function UsageMeter({
{hasBreakdown - ? breakdownEntries.map(([model, pct], i) => { - const subClamped = Math.max(0, Math.min(100, pct)); - if (subClamped <= 0) return null; - const tickLeft = Math.min(99, subClamped); + ? breakdownMarkers.map((marker) => { + if (!marker) return null; return (
{model} {pct.toFixed(1)}% {mode} @@ -99,4 +118,4 @@ export function UsageMeter({ ); } -const MODEL_COLORS = ["#A78BFA", "#7C3AED", "#C4B5FD", "#6D28D9"]; +const MODEL_COLORS = ["#A78BFA", "#38BDF8", "#F59E0B", "#22C55E", "#F472B6", "#EAB308"]; diff --git a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx index 91e53517c..b45836458 100644 --- a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx @@ -106,4 +106,30 @@ describe("UsageQuotaPanel", () => { expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1); }); }); + + it("labels Cursor Admin API-only auth as usage auth", async () => { + vi.mocked(window.ade.ai.getStatus).mockResolvedValue({ + providerConnections: { + claude: null, + codex: null, + droid: null, + cursor: { + provider: "cursor", + authAvailable: true, + runtimeDetected: true, + runtimeAvailable: false, + usageAvailable: true, + path: null, + blocker: null, + sources: [], + lastCheckedAt: "2026-05-08T07:00:00.000Z", + }, + }, + } as any); + + render(); + + expect(await screen.findByText("usage auth only")).toBeTruthy(); + expect(screen.queryByText("sign-in required")).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx index 440aea687..23f6a361b 100644 --- a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx @@ -353,7 +353,7 @@ function AuthChip({ if (entry?.runtimeAvailable) { tone = { border: "rgba(34,197,94,0.3)", bg: "rgba(34,197,94,0.12)", text: "#22C55E", copy: "runtime ready" }; } else if (entry?.authAvailable) { - const copy = entry.runtimeDetected ? "sign-in required" : "auth found locally"; + const copy = entry.runtimeDetected && !entry.runtimeAvailable ? "usage auth only" : "auth found locally"; tone = { border: "rgba(59,130,246,0.3)", bg: "rgba(59,130,246,0.12)", text: "#60A5FA", copy }; } else { tone = { border: "rgba(113,113,122,0.3)", bg: "rgba(113,113,122,0.12)", text: "#A1A1AA", copy: "not detected" }; From 7d78dac201227b81ecafdec857382cdf38c299f9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 10 May 2026 10:05:09 -0500 Subject: [PATCH 4/8] ship: iteration 2 share cursor usage key helper --- .../desktop/src/main/services/ai/providerConnectionStatus.ts | 5 +---- apps/desktop/src/main/services/ai/utils.ts | 4 ++++ apps/desktop/src/main/services/usage/usageTrackingService.ts | 5 +---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index 948b31321..397753ea1 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -7,6 +7,7 @@ import { } from "./providerCredentialSources"; import { getAllApiKeys } from "./apiKeyStore"; import { getProviderRuntimeHealth } from "./providerRuntimeHealth"; +import { isCursorAdminApiKey } from "./utils"; import { nowIso } from "../shared/utils"; function createUnavailableStatus( @@ -26,10 +27,6 @@ function createUnavailableStatus( }; } -function isCursorAdminApiKey(value: string | null | undefined): boolean { - return Boolean(value?.trim().startsWith("key_")); -} - export async function buildProviderConnections( cliStatuses: CliAuthStatus[], ): Promise { diff --git a/apps/desktop/src/main/services/ai/utils.ts b/apps/desktop/src/main/services/ai/utils.ts index 94efd959f..9e1579bd7 100644 --- a/apps/desktop/src/main/services/ai/utils.ts +++ b/apps/desktop/src/main/services/ai/utils.ts @@ -13,6 +13,10 @@ export function commandExists(command: string): boolean { } } +export function isCursorAdminApiKey(value: string | null | undefined): boolean { + return Boolean(value?.trim().startsWith("key_")); +} + export function extractFirstJsonObject(text: string): string | null { const raw = String(text ?? "").trim(); if (!raw) return null; diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index d47ff59e2..d6edb161f 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -30,6 +30,7 @@ import { refreshClaudeCredentials, } from "../ai/providerCredentialSources"; import { getAllApiKeys } from "../ai/apiKeyStore"; +import { isCursorAdminApiKey } from "../ai/utils"; import { resolveCodexExecutable } from "../ai/codexExecutable"; import { resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; @@ -270,10 +271,6 @@ type CursorSpendResponse = { subscriptionCycleStart?: number; }; -function isCursorAdminApiKey(value: string | null | undefined): boolean { - return Boolean(value?.trim().startsWith("key_")); -} - function getCursorApiKey(): { key: string; source: "cursor-admin-env" | "cursor-env" | "cursor-api-key-store" } | null { const adminEnvKey = process.env.CURSOR_ADMIN_API_KEY?.trim(); if (isCursorAdminApiKey(adminEnvKey)) return { key: adminEnvKey!, source: "cursor-admin-env" }; From 87b772ed4f732340bd37b64e7b32fae39e427b9a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 10 May 2026 10:22:12 -0500 Subject: [PATCH 5/8] ship: iteration 3 handle quota-only cursor usage --- .../usage/usageTrackingService.test.ts | 45 +++++++++++++ .../services/usage/usageTrackingService.ts | 5 +- .../components/usage/UsageQuotaPanel.test.tsx | 65 +++++++++++++++++++ .../components/usage/UsageQuotaPanel.tsx | 6 +- 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 97c83190a..64346e1c9 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -31,6 +31,7 @@ const { parseClaudeWindows, parseCodexRateLimitWindows, parseCursorSpendUsage, + pollCursorUsage, calculatePacingByProvider, pollCodexViaCliRpc, resolveTokenPrice, @@ -520,6 +521,50 @@ describe("parseCursorSpendUsage", () => { expect(result.extraUsage?.usedCreditsUsd).toBe(25); expect(result.windows[0]?.percentUsed).toBe(12.5); }); + + it("allows quota-only Cursor member responses without spend data", async () => { + const originalFetch = globalThis.fetch; + const prevAdminKey = process.env.CURSOR_ADMIN_API_KEY; + process.env.CURSOR_ADMIN_API_KEY = "key_cursor_admin_test"; + globalThis.fetch = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + teamMemberSpend: [ + { fastPremiumRequests: 250, spendCents: 0, overallSpendCents: 0 }, + ], + }), + } as Response)); + try { + const result = await pollCursorUsage(); + expect(result.windows).toEqual([]); + expect(result.extraUsage).toBeNull(); + expect(result.errors).toEqual([]); + } finally { + globalThis.fetch = originalFetch; + if (prevAdminKey === undefined) delete process.env.CURSOR_ADMIN_API_KEY; + else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey; + } + }); + + it("reports malformed Cursor spend responses with no member array", async () => { + const originalFetch = globalThis.fetch; + const prevAdminKey = process.env.CURSOR_ADMIN_API_KEY; + process.env.CURSOR_ADMIN_API_KEY = "key_cursor_admin_test"; + globalThis.fetch = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ subscriptionCycleStart: Date.UTC(2026, 4, 1) }), + } as Response)); + try { + const result = await pollCursorUsage(); + expect(result.errors).toEqual(["cursor: usage response contained no recognized spend data"]); + } finally { + globalThis.fetch = originalFetch; + if (prevAdminKey === undefined) delete process.env.CURSOR_ADMIN_API_KEY; + else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey; + } + }); }); describe("pollCodexViaCliRpc", () => { diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index d6edb161f..19599f75e 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -385,8 +385,9 @@ async function pollCursorUsage(): Promise<{ windows: UsageWindow[]; extraUsage: }; } - const parsed = parseCursorSpendUsage(result.data as CursorSpendResponse); - if (parsed.windows.length === 0 && !parsed.extraUsage) { + const response = result.data as CursorSpendResponse; + const parsed = parseCursorSpendUsage(response); + if (!Array.isArray(response.teamMemberSpend)) { return { windows: [], extraUsage: null, diff --git a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx index b45836458..dab92729e 100644 --- a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx @@ -132,4 +132,69 @@ describe("UsageQuotaPanel", () => { expect(await screen.findByText("usage auth only")).toBeTruthy(); expect(screen.queryByText("sign-in required")).toBeNull(); }); + + it("keeps the empty-window warning hidden when Cursor extra usage exists", async () => { + const snapshot: UsageSnapshot = { + ...makeSnapshot(), + windows: [], + extraUsage: [{ + provider: "cursor", + isEnabled: true, + usedCreditsUsd: 12.5, + monthlyLimitUsd: 0, + utilization: null, + currency: "usd", + }], + }; + vi.mocked(window.ade.usage.getSnapshot).mockResolvedValue(snapshot); + vi.mocked(window.ade.ai.getStatus).mockResolvedValue({ + providerConnections: { + claude: null, + codex: null, + droid: null, + cursor: { + provider: "cursor", + authAvailable: true, + runtimeDetected: true, + runtimeAvailable: false, + usageAvailable: true, + path: null, + blocker: null, + sources: [], + lastCheckedAt: "2026-05-08T07:00:00.000Z", + }, + }, + } as any); + + render(); + + expect(await screen.findByText("Cursor extra usage")).toBeTruthy(); + expect(screen.queryByText(/Restart ADE/)).toBeNull(); + }); + + it("keeps sign-in copy for non-Cursor auth failures", async () => { + vi.mocked(window.ade.ai.getStatus).mockResolvedValue({ + providerConnections: { + codex: null, + cursor: null, + droid: null, + claude: { + provider: "claude", + authAvailable: true, + runtimeDetected: true, + runtimeAvailable: false, + usageAvailable: false, + path: null, + blocker: "Claude runtime reported that login is still required.", + sources: [], + lastCheckedAt: "2026-05-08T07:00:00.000Z", + }, + }, + } as any); + + render(); + + expect(await screen.findByText("sign-in required")).toBeTruthy(); + expect(screen.queryByText("usage auth only")).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx index 23f6a361b..c2b28e2f2 100644 --- a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx @@ -163,9 +163,11 @@ export function UsageQuotaPanel({ }, [snapshot?.windows]); const hasAnyWindow = PROVIDER_ORDER.some((provider) => (windowsByProvider[provider]?.length ?? 0) > 0); + const hasAnyExtraUsage = (snapshot?.extraUsage.length ?? 0) > 0; const showEmptyQuotaWarning = PROVIDER_ORDER.some((provider) => providerConnection(providerConnections, provider)?.authAvailable) && !hasAnyWindow && + !hasAnyExtraUsage && (snapshot?.errors.length ?? 0) === 0; return ( @@ -353,7 +355,9 @@ function AuthChip({ if (entry?.runtimeAvailable) { tone = { border: "rgba(34,197,94,0.3)", bg: "rgba(34,197,94,0.12)", text: "#22C55E", copy: "runtime ready" }; } else if (entry?.authAvailable) { - const copy = entry.runtimeDetected && !entry.runtimeAvailable ? "usage auth only" : "auth found locally"; + const copy = entry.runtimeDetected && !entry.runtimeAvailable + ? entry.usageAvailable ? "usage auth only" : "sign-in required" + : "auth found locally"; tone = { border: "rgba(59,130,246,0.3)", bg: "rgba(59,130,246,0.12)", text: "#60A5FA", copy }; } else { tone = { border: "rgba(113,113,122,0.3)", bg: "rgba(113,113,122,0.12)", text: "#A1A1AA", copy: "not detected" }; From 8ea41cdea35f87a5b2461e5a5f332aea2b03ef0e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 10 May 2026 10:56:59 -0500 Subject: [PATCH 6/8] ship: iteration 4 clean usage panel review feedback --- .../components/usage/HeaderUsageControl.tsx | 12 +- .../components/usage/UsageQuotaPanel.test.tsx | 139 ++++++++++-------- .../components/usage/UsageQuotaPanel.tsx | 3 +- 3 files changed, 91 insertions(+), 63 deletions(-) diff --git a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx index 340a40784..9f05bbdfa 100644 --- a/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx +++ b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx @@ -68,15 +68,19 @@ export function HeaderUsageControl() { .catch(() => { if (!cancelled) setSnapshot(null); }); - const unsubscribe = window.ade.usage.onUpdate((nextSnapshot) => { - if (!cancelled) setSnapshot(nextSnapshot); - }); return () => { cancelled = true; - unsubscribe(); }; }, []); + useEffect(() => { + if (open || !window.ade?.usage) return; + const unsubscribe = window.ade.usage.onUpdate((nextSnapshot) => { + setSnapshot(nextSnapshot); + }); + return unsubscribe; + }, [open]); + useEffect(() => { if (!open || !window.ade?.usage?.getBudgetConfig) return; let cancelled = false; diff --git a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx index dab92729e..20aa5143c 100644 --- a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx @@ -3,9 +3,23 @@ import React from "react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { UsageSnapshot } from "../../../shared/types"; +import type { + AiProviderConnectionStatus, + AiProviderConnections, + AiSettingsStatus, + BudgetCapConfig, + UsageSnapshot, +} from "../../../shared/types"; import { UsageQuotaPanel } from "./UsageQuotaPanel"; +type UsageQuotaPanelTestBridge = { + usage: Pick< + Window["ade"]["usage"], + "getSnapshot" | "refresh" | "getBudgetConfig" | "saveBudgetConfig" | "onUpdate" + >; + ai: Pick; +}; + function makeSnapshot(): UsageSnapshot { return { windows: [ @@ -53,30 +67,68 @@ function makeSnapshot(): UsageSnapshot { }; } +function makeProviderConnection( + provider: AiProviderConnectionStatus["provider"], + overrides: Partial = {}, +): AiProviderConnectionStatus { + return { + provider, + authAvailable: false, + runtimeDetected: false, + runtimeAvailable: false, + usageAvailable: false, + path: null, + blocker: null, + lastCheckedAt: "2026-05-08T07:00:00.000Z", + sources: [], + ...overrides, + }; +} + +function makeAiStatus(providerConnections: Partial = {}): AiSettingsStatus { + return { + mode: "guest", + availableProviders: { + claude: false, + codex: false, + cursor: false, + droid: false, + }, + models: { + claude: [], + codex: [], + cursor: [], + droid: [], + }, + features: [], + providerConnections: { + claude: makeProviderConnection("claude"), + codex: makeProviderConnection("codex"), + cursor: makeProviderConnection("cursor"), + droid: makeProviderConnection("droid"), + ...providerConnections, + }, + }; +} + describe("UsageQuotaPanel", () => { const originalAde = globalThis.window.ade; beforeEach(() => { const snapshot = makeSnapshot(); - globalThis.window.ade = { + const bridge = { usage: { - getSnapshot: vi.fn().mockResolvedValue(snapshot), - refresh: vi.fn().mockResolvedValue(snapshot), - getBudgetConfig: vi.fn().mockResolvedValue({}), - saveBudgetConfig: vi.fn().mockResolvedValue({}), - onUpdate: vi.fn(() => () => {}), + getSnapshot: vi.fn<[], Promise>(async () => snapshot), + refresh: vi.fn<[], Promise>(async () => snapshot), + getBudgetConfig: vi.fn<[], Promise>(async () => ({})), + saveBudgetConfig: vi.fn<[BudgetCapConfig], Promise>(async (config) => config), + onUpdate: vi.fn<[(snapshot: UsageSnapshot) => void], () => void>(() => () => {}), }, ai: { - getStatus: vi.fn().mockResolvedValue({ - providerConnections: { - claude: null, - codex: null, - cursor: null, - droid: null, - }, - }), + getStatus: vi.fn<[], Promise>(async () => makeAiStatus()), }, - } as any; + } satisfies UsageQuotaPanelTestBridge; + Object.assign(globalThis.window, { ade: bridge }); }); afterEach(() => { @@ -108,24 +160,14 @@ describe("UsageQuotaPanel", () => { }); it("labels Cursor Admin API-only auth as usage auth", async () => { - vi.mocked(window.ade.ai.getStatus).mockResolvedValue({ - providerConnections: { - claude: null, - codex: null, - droid: null, - cursor: { - provider: "cursor", + vi.mocked(window.ade.ai.getStatus).mockResolvedValue(makeAiStatus({ + cursor: makeProviderConnection("cursor", { authAvailable: true, runtimeDetected: true, runtimeAvailable: false, usageAvailable: true, - path: null, - blocker: null, - sources: [], - lastCheckedAt: "2026-05-08T07:00:00.000Z", - }, - }, - } as any); + }), + })); render(); @@ -147,50 +189,31 @@ describe("UsageQuotaPanel", () => { }], }; vi.mocked(window.ade.usage.getSnapshot).mockResolvedValue(snapshot); - vi.mocked(window.ade.ai.getStatus).mockResolvedValue({ - providerConnections: { - claude: null, - codex: null, - droid: null, - cursor: { - provider: "cursor", + vi.mocked(window.ade.ai.getStatus).mockResolvedValue(makeAiStatus({ + cursor: makeProviderConnection("cursor", { authAvailable: true, runtimeDetected: true, runtimeAvailable: false, usageAvailable: true, - path: null, - blocker: null, - sources: [], - lastCheckedAt: "2026-05-08T07:00:00.000Z", - }, - }, - } as any); + }), + })); render(); - expect(await screen.findByText("Cursor extra usage")).toBeTruthy(); + expect(await screen.findByText("Cursor monthly spend")).toBeTruthy(); expect(screen.queryByText(/Restart ADE/)).toBeNull(); }); it("keeps sign-in copy for non-Cursor auth failures", async () => { - vi.mocked(window.ade.ai.getStatus).mockResolvedValue({ - providerConnections: { - codex: null, - cursor: null, - droid: null, - claude: { - provider: "claude", + vi.mocked(window.ade.ai.getStatus).mockResolvedValue(makeAiStatus({ + claude: makeProviderConnection("claude", { authAvailable: true, runtimeDetected: true, runtimeAvailable: false, usageAvailable: false, - path: null, blocker: "Claude runtime reported that login is still required.", - sources: [], - lastCheckedAt: "2026-05-08T07:00:00.000Z", - }, - }, - } as any); + }), + })); render(); diff --git a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx index c2b28e2f2..91961b69c 100644 --- a/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx @@ -299,6 +299,7 @@ function ExtraUsageCard({ extra }: { extra: ExtraUsage }) { if (!extra.isEnabled) return null; const meta = PROVIDER_META[extra.provider]; + const title = extra.provider === "cursor" ? `${meta.label} monthly spend` : `${meta.label} extra usage`; const usedUsd = extra.usedCreditsUsd; const limitUsd = extra.monthlyLimitUsd; const percent = limitUsd > 0 ? Math.min(100, (usedUsd / limitUsd) * 100) : 0; @@ -313,7 +314,7 @@ function ExtraUsageCard({ extra }: { extra: ExtraUsage }) {
- {meta.label} extra usage + {title}
From e118c1c69e4cca554ac7036b2b5579ce9caa898c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 10 May 2026 11:10:56 -0500 Subject: [PATCH 7/8] ship: iteration 5 guard cursor spend parsing --- apps/desktop/src/main/services/usage/usageTrackingService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index 19599f75e..36a11cf3b 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -386,7 +386,6 @@ async function pollCursorUsage(): Promise<{ windows: UsageWindow[]; extraUsage: } const response = result.data as CursorSpendResponse; - const parsed = parseCursorSpendUsage(response); if (!Array.isArray(response.teamMemberSpend)) { return { windows: [], @@ -394,6 +393,7 @@ async function pollCursorUsage(): Promise<{ windows: UsageWindow[]; extraUsage: errors: ["cursor: usage response contained no recognized spend data"], }; } + const parsed = parseCursorSpendUsage(response); return { ...parsed, errors: [] }; } catch (err) { return { From 524823793cdc089183b56e5f2595f4ffff8915cc Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 10 May 2026 11:25:49 -0500 Subject: [PATCH 8/8] ship: iteration 6 preserve cursor usage auth --- apps/ade-cli/src/cli.test.ts | 4 +++ apps/ade-cli/src/cli.ts | 1 + .../ai/providerConnectionStatus.test.ts | 30 +++++++++++++++++++ .../services/ai/providerConnectionStatus.ts | 4 ++- 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index b9ece14c5..780af0d37 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2822,6 +2822,10 @@ describe("ADE CLI", () => { .toThrow(/must be a JSON object/i); expect(() => buildCliPlan(["usage", "budget", "set", "--text", " \n "])) .toThrow(/non-empty JSON object/i); + expect(() => buildCliPlan(["usage", "budget", "set"])) + .toThrow(/at least one field/i); + expect(() => buildCliPlan(["usage", "budget", "set", "--text", "{}"])) + .toThrow(/at least one field/i); }); it("usage budget check defaults scope to global and forwards --provider", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 824d0f29e..5e19334c0 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -3711,6 +3711,7 @@ function buildUsagePlan(args: string[]): CliPlan { parsed = collectGenericObjectArgs(args); } if (!isRecord(parsed)) throw new CliUsageError("Budget config must be a JSON object."); + if (Object.keys(parsed).length === 0) throw new CliUsageError("Budget config must contain at least one field."); return { kind: "execute", label: "usage budget update", steps: [actionStep("result", "budget", "updateConfig", parsed as JsonObject)] }; } if (mode === "check") { diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts index 9ecffe766..32867c08e 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts @@ -307,4 +307,34 @@ describe("buildProviderConnections", () => { else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey; } }); + + it("preserves Cursor usage availability when SDK auth fails but an admin key is configured", async () => { + const prevKey = process.env.CURSOR_API_KEY; + const prevAdminKey = process.env.CURSOR_ADMIN_API_KEY; + process.env.CURSOR_API_KEY = "test-key"; + process.env.CURSOR_ADMIN_API_KEY = "key_cursor_admin_test"; + mockState.getProviderRuntimeHealth.mockImplementation((provider: string) => { + if (provider === "cursor") { + return { + provider: "cursor", + state: "auth-failed", + message: "Cursor rejected the configured API key for agent/model access.", + checkedAt: "2026-05-01T12:00:00.000Z", + }; + } + return null; + }); + try { + const result = await buildProviderConnections(mergeCliStatuses([])); + expect(result.cursor.authAvailable).toBe(true); + expect(result.cursor.runtimeAvailable).toBe(false); + expect(result.cursor.usageAvailable).toBe(true); + expect(result.cursor.blocker).toBe("Cursor rejected the configured API key for agent/model access."); + } finally { + if (prevKey === undefined) delete process.env.CURSOR_API_KEY; + else process.env.CURSOR_API_KEY = prevKey; + if (prevAdminKey === undefined) delete process.env.CURSOR_ADMIN_API_KEY; + else process.env.CURSOR_ADMIN_API_KEY = prevAdminKey; + } + }); }); diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index 397753ea1..32664464b 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -95,7 +95,9 @@ export async function buildProviderConnections( if (!health) return; if (health.state === "auth-failed" || health.state === "runtime-failed") { status.runtimeAvailable = false; - status.usageAvailable = false; + if (status.provider !== "cursor") { + status.usageAvailable = false; + } status.blocker = health.message ?? (health.state === "auth-failed" ? `${status.provider} runtime was detected, but ADE chat reported that login is still required.`