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..780af0d37 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2754,4 +2754,134 @@ 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); + 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", () => { + 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/); + }); + + 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 7fb0c4787..5e19334c0 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,60 @@ 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; + 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(trimmed); + } 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."); + 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") { + 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)] }; @@ -4183,6 +4255,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)) { @@ -4272,6 +4346,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.test.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.test.ts index b1375a905..32867c08e 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,38 @@ 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; + } + }); + + 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 b1c691245..32664464b 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( @@ -94,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.` @@ -102,7 +105,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,48 +175,66 @@ export async function buildProviderConnections( }); const cursorCli = cliStatuses.find((entry) => entry.cli === "cursor") ?? null; - const cursorEnvAuth = Boolean(process.env.CURSOR_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); - let cursorCredsSource: "cursor-env" | "cursor-api-key-store" | undefined; + 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"; + 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 = { runtimeDetected: true, cliAuthenticated: false, cliExplicitlyUnauthenticated: false, - localCredsDetected: cursorSdkAuth, - authAvailable: cursorSdkAuth, + localCredsDetected: cursorAuthAvailable, + authAvailable: cursorAuthAvailable, runtimeAvailable: cursorSdkAuth, }; - const cursorBlocker: string | null = cursorSdkAuth - ? null - : 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 (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 { + cursorBlocker = "Enter a Cursor API key from https://cursor.com/dashboard/integrations."; + } const cursor: AiProviderConnectionStatus = { ...createUnavailableStatus("cursor", checkedAt), authAvailable: cursorFlags.authAvailable, runtimeDetected: cursorFlags.runtimeDetected, runtimeAvailable: cursorFlags.runtimeAvailable, - usageAvailable: cursorFlags.runtimeAvailable, + usageAvailable: cursorUsageAuth, path: "@cursor/sdk", sources: [ { kind: "local-credentials", - detected: cursorSdkAuth, + detected: cursorAuthAvailable, source: cursorCredsSource, }, { 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/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..64346e1c9 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -30,6 +30,9 @@ const { isTokenExpiredOrExpiring, parseClaudeWindows, parseCodexRateLimitWindows, + parseCursorSpendUsage, + pollCursorUsage, + calculatePacingByProvider, pollCodexViaCliRpc, resolveTokenPrice, } = _testing; @@ -456,6 +459,114 @@ 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); + }); + + 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", () => { const originalPlatform = process.platform; const originalComSpec = process.env.ComSpec; @@ -598,6 +709,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 +764,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..36a11cf3b 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,8 @@ import { readCodexCredentials, 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"; @@ -49,6 +51,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 +68,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 +256,154 @@ 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 (isCursorAdminApiKey(adminEnvKey)) return { key: adminEnvKey!, source: "cursor-admin-env" }; + const envKey = process.env.CURSOR_API_KEY?.trim(); + if (isCursorAdminApiKey(envKey)) return { key: envKey!, source: "cursor-env" }; + try { + const stored = getAllApiKeys().cursor?.trim(); + 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. + } + return null; +} + +function finiteOrZero(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function addOneMonth(timestampMs: number): number { + const next = new Date(timestampMs); + if (!Number.isFinite(next.getTime())) return 0; + 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 }; + + // overallSpendCents = on-demand + included usage (the real total spend); + // spendCents alone captures only on-demand pay-as-you-go. + const memberSpendCents = (member: CursorSpendMember): number => { + 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 + // 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; + 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); + + // 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() : ""; + const windowDurationMs = resetMs > 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 response = result.data as CursorSpendResponse; + if (!Array.isArray(response.teamMemberSpend)) { + return { + windows: [], + extraUsage: null, + errors: ["cursor: usage response contained no recognized spend data"], + }; + } + const parsed = parseCursorSpendUsage(response); + 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 +877,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 +888,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; + } +} - if (!weeklyWindow) return empty; +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 + ); +} - 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 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"; +} - // Expected usage if consumption were perfectly linear over the week - const expectedPercent = weekElapsedPercent; // 100% budget / 100% time = linear +function calculatePacingForWindow(window: UsageWindow): UsagePacing { + if (!window.resetsAt || window.resetsInMs <= 0) return emptyPacing(); + + 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 +972,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 +1001,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 +1029,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 +1079,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 +1090,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 +1197,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..9c159deaa 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"]; @@ -44,6 +42,7 @@ const TAB_ALIASES: Record = { onboarding: "general", help: "general", tours: "general", + usage: "general", }; function padIndex(i: number): string { @@ -575,7 +574,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.test.tsx b/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.test.tsx deleted file mode 100644 index d782cd487..000000000 --- a/apps/desktop/src/renderer/components/settings/UsageGuardrailsSection.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* @vitest-environment jsdom */ - -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"; - -function makeSnapshot(): UsageSnapshot { - return { - windows: [], - pacing: { - status: "on-track", - projectedWeeklyPercent: 0, - weekElapsedPercent: 0, - expectedPercent: 0, - deltaPercent: 0, - etaHours: null, - willLastToReset: true, - resetsInHours: 0, - }, - costs: [], - extraUsage: [], - lastPolledAt: "2026-05-08T07:00:00.000Z", - errors: [], - }; -} - -describe("UsageGuardrailsSection", () => { - const originalAde = globalThis.window.ade; - - beforeEach(() => { - globalThis.window.ade = { - usage: { - getSnapshot: vi.fn().mockResolvedValue(makeSnapshot()), - refresh: vi.fn().mockResolvedValue(makeSnapshot()), - getBudgetConfig: vi.fn().mockResolvedValue({}), - saveBudgetConfig: vi.fn().mockResolvedValue({}), - onUpdate: vi.fn(() => () => {}), - }, - ai: { - getStatus: vi.fn().mockResolvedValue({ - providerConnections: { - claude: null, - codex: null, - cursor: null, - droid: null, - }, - }), - }, - } as any; - }); - - afterEach(() => { - cleanup(); - globalThis.window.ade = originalAde; - }); - - it("hydrates from the cached snapshot on mount instead of forcing a live usage poll", 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(); - }); - - it("keeps live provider polling available through the manual refresh button", async () => { - render(); - - await waitFor(() => { - const refreshButton = screen.getByRole("button", { name: /refresh/i }) as HTMLButtonElement; - expect(refreshButton.disabled).toBe(false); - }); - - fireEvent.click(screen.getByRole("button", { name: /refresh/i })); - - await waitFor(() => { - expect(window.ade.usage.refresh).toHaveBeenCalledTimes(1); - }); - }); -}); 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..9312e841c 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,11 +14,28 @@ export function UsageMeter({ sublabel?: string; modelBreakdown?: Record; mode?: "used" | "remaining"; + toneColor?: string; className?: string; }) { 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 @@ -29,7 +47,7 @@ export function UsageMeter({ ? "#EF4444" : clamped > 70 ? "#F59E0B" - : "#A78BFA"; + : toneColor; return (
@@ -43,20 +61,38 @@ export function UsageMeter({
- {hasBreakdown ? ( - - ) : ( -
- )} +
+ {hasBreakdown + ? breakdownMarkers.map((marker) => { + if (!marker) return null; + return ( + + ); + }) + : null}
{sublabel && ( @@ -69,7 +105,7 @@ export function UsageMeter({
{model} {pct.toFixed(1)}% {mode} @@ -82,34 +118,4 @@ 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 ( -
- ); - })} - - ); -} +const MODEL_COLORS = ["#A78BFA", "#38BDF8", "#F59E0B", "#22C55E", "#F472B6", "#EAB308"]; 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..9f05bbdfa --- /dev/null +++ b/apps/desktop/src/renderer/components/usage/HeaderUsageControl.tsx @@ -0,0 +1,241 @@ +import { useCallback, useEffect, 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); + }); + return () => { + cancelled = true; + }; + }, []); + + 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; + 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 = usageTone(percent, hasErrors); + 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/usage/UsageQuotaPanel.test.tsx b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx new file mode 100644 index 000000000..20aa5143c --- /dev/null +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.test.tsx @@ -0,0 +1,223 @@ +/* @vitest-environment jsdom */ + +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 { + 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: [ + { + 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: 63, + weekElapsedPercent: 50, + expectedPercent: 50, + deltaPercent: 13, + etaHours: null, + willLastToReset: true, + resetsInHours: 24, + }, + pacingByProvider: { + codex: { + status: "ahead", + projectedWeeklyPercent: 70, + weekElapsedPercent: 50, + expectedPercent: 50, + deltaPercent: 13, + etaHours: null, + willLastToReset: true, + resetsInHours: 24, + }, + }, + costs: [], + extraUsage: [], + lastPolledAt: "2026-05-08T07:00:00.000Z", + errors: [], + }; +} + +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(); + const bridge = { + usage: { + 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<[], Promise>(async () => makeAiStatus()), + }, + } satisfies UsageQuotaPanelTestBridge; + Object.assign(globalThis.window, { ade: bridge }); + }); + + afterEach(() => { + cleanup(); + globalThis.window.ade = originalAde; + }); + + it("shows Codex as percent used, not percent remaining", async () => { + render(); + + 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(); + + await waitFor(() => { + const refreshButton = screen.getByRole("button", { name: /refresh/i }) as HTMLButtonElement; + expect(refreshButton.disabled).toBe(false); + }); + + fireEvent.click(screen.getByRole("button", { name: /refresh/i })); + + await waitFor(() => { + 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(makeAiStatus({ + cursor: makeProviderConnection("cursor", { + authAvailable: true, + runtimeDetected: true, + runtimeAvailable: false, + usageAvailable: true, + }), + })); + + render(); + + 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(makeAiStatus({ + cursor: makeProviderConnection("cursor", { + authAvailable: true, + runtimeDetected: true, + runtimeAvailable: false, + usageAvailable: true, + }), + })); + + render(); + + 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(makeAiStatus({ + claude: makeProviderConnection("claude", { + authAvailable: true, + runtimeDetected: true, + runtimeAvailable: false, + usageAvailable: false, + blocker: "Claude runtime reported that login is still required.", + }), + })); + + 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 new file mode 100644 index 000000000..91961b69c --- /dev/null +++ b/apps/desktop/src/renderer/components/usage/UsageQuotaPanel.tsx @@ -0,0 +1,377 @@ +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(applySnapshot); + return unsubscribe; + }, [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 hasAnyExtraUsage = (snapshot?.extraUsage.length ?? 0) > 0; + const showEmptyQuotaWarning = + PROVIDER_ORDER.some((provider) => providerConnection(providerConnections, provider)?.authAvailable) && + !hasAnyWindow && + !hasAnyExtraUsage && + (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 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; + 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() }); + + return ( +
+
+ + + {title} + +
+ +
+
+ + Monthly spend + + + {formatUsd(usedUsd)}{limitUsd > 0 ? ` / ${formatUsd(limitUsd)}` : ""} + +
+ + {limitUsd > 0 ? ( +
+
+
+ ) : ( +
+ No monthly limit configured +
+ )} +
+
+ ); +} + +function AuthChip({ + label, + entry, +}: { + label: string; + entry: AiProviderConnectionStatus | null; +}) { + 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 && !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" }; + } + + 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/ARCHITECTURE.md b/docs/ARCHITECTURE.md index af073b65e..0bf53328a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1015,7 +1015,7 @@ Post-packaging hardening (`apps/desktop/scripts/`): - **IPC tracing** — every handler emits `ipc.invoke.begin` / `ipc.invoke.done` / `ipc.invoke.failed` with call ID, channel, window ID, duration, summarized args. Mandatory for new handlers. - **Renderer lifecycle** — `renderer.route_change`, `renderer.tab_change`, `renderer.window_error`, `renderer.unhandled_rejection`, `renderer.event_loop_stall`. Mandatory for new surfaces that introduce novel lifecycle transitions. - **Startup tasks** — `project.startup_task_enabled`, `project.startup_task_skipped`, `project.startup_task_begin`, `project.startup_task_done` with durations. -- **Usage tracking** — `usageTrackingService.ts` + `budgetCapService.ts` account for tokens and cost per provider/model/call-type; surfaced in Missions UI + Settings. +- **Usage tracking** — `usageTrackingService.ts` + `budgetCapService.ts` account for tokens and cost per provider/model/call-type; surfaced in Missions UI and the top-bar Usage popup (`HeaderUsageControl` → `UsageQuotaPanel` + collapsible `BudgetCapEditor`). - **No external telemetry** — ADE does not ship analytics to any cloud service. All telemetry is local. ### 15.3 Error surfaces 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`,