diff --git a/packages/ai/src/usage/cursor.ts b/packages/ai/src/usage/cursor.ts index 4b99462fb..b05220dec 100644 --- a/packages/ai/src/usage/cursor.ts +++ b/packages/ai/src/usage/cursor.ts @@ -131,6 +131,54 @@ function parseCursorCentsBucket(bucket: Record): UsageAmount | }; } +/** + * Cursor's dashboard does not treat plan.used / plan.limit as the visible %. + * Pro+ shows separate rails: + * - Cursor Models ← autoPercentUsed + * - Other Models ← apiPercentUsed (against the included $ pool) + * Prefer those fractions when present; fall back to cents only for older overall buckets. + */ +function parseCursorPlanDashboardAmounts(bucket: Record): { + auto?: UsageAmount; + api?: UsageAmount; + fallback?: UsageAmount; +} { + const limitCents = toNumber(bucket.limit); + const limitUsd = limitCents !== undefined && limitCents > 0 ? limitCents / 100 : undefined; + const autoPct = toNumber(bucket.autoPercentUsed); + const apiPct = toNumber(bucket.apiPercentUsed); + const totalPct = toNumber(bucket.totalPercentUsed); + + const fromPercent = (pct: number, withLimit: boolean): UsageAmount => { + const usedFraction = Math.max(0, pct) / 100; + if (withLimit && limitUsd !== undefined) { + const used = limitUsd * usedFraction; + return { + used, + limit: limitUsd, + remaining: Math.max(0, limitUsd - used), + usedFraction, + remainingFraction: Math.max(0, 1 - usedFraction), + unit: "usd", + }; + } + return { used: usedFraction * 100, usedFraction, unit: "percent" }; + }; + + const result: { auto?: UsageAmount; api?: UsageAmount; fallback?: UsageAmount } = {}; + if (autoPct !== undefined) result.auto = fromPercent(autoPct, false); + if (apiPct !== undefined) result.api = fromPercent(apiPct, true); + if (!result.auto && !result.api) { + if (totalPct !== undefined) { + result.fallback = fromPercent(totalPct, true); + } else { + const cents = parseCursorCentsBucket(bucket); + if (cents) result.fallback = cents; + } + } + return result; +} + export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null { if (!isRecord(payload) || !isRecord(payload.individualUsage)) { return null; @@ -138,28 +186,64 @@ export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.no const picked = pickCursorIndividualBucket(payload.individualUsage); if (!picked) return null; - const amount = parseCursorCentsBucket(picked.bucket); - if (!amount) return null; - const resetsAt = deriveResetsAt(payload); const window: UsageWindow = { id: "monthly", label: "Monthly", ...(resetsAt !== undefined ? { resetsAt } : {}), }; - const limits: UsageLimit[] = [ - { + const limits: UsageLimit[] = []; + + if (picked.key === "plan") { + const rails = parseCursorPlanDashboardAmounts(picked.bucket); + if (rails.auto) { + limits.push({ + id: "cursor:usd:individual-auto", + label: "Cursor Models", + scope: { provider: "cursor", windowId: window.id }, + window, + amount: rails.auto, + ...(rails.auto.usedFraction !== undefined + ? { status: usageStatus(rails.auto.usedFraction) } + : {}), + }); + } + if (rails.api) { + limits.push({ + id: "cursor:usd:individual-api", + label: "Other Models", + scope: { provider: "cursor", windowId: window.id }, + window, + amount: rails.api, + ...(rails.api.usedFraction !== undefined ? { status: usageStatus(rails.api.usedFraction) } : {}), + }); + } + if (rails.fallback) { + limits.push({ + id: "cursor:usd:individual-plan", + label: "Personal Usage", + scope: { provider: "cursor", windowId: window.id }, + window, + amount: rails.fallback, + ...(rails.fallback.usedFraction !== undefined + ? { status: usageStatus(rails.fallback.usedFraction) } + : {}), + }); + } + } else { + const amount = parseCursorCentsBucket(picked.bucket); + if (!amount) return null; + limits.push({ id: `cursor:usd:individual-${picked.key}`, label: "Personal Usage", - scope: { - provider: "cursor", - windowId: window.id, - }, + scope: { provider: "cursor", windowId: window.id }, window, amount, ...(amount.usedFraction !== undefined ? { status: usageStatus(amount.usedFraction) } : {}), - }, - ]; + }); + } + + if (limits.length === 0) return null; if (isRecord(payload.individualUsage.onDemand)) { const onDemandAmount = parseCursorCentsBucket(payload.individualUsage.onDemand); @@ -167,10 +251,7 @@ export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.no limits.push({ id: "cursor:usd:individual-ondemand", label: "On-Demand Usage", - scope: { - provider: "cursor", - windowId: window.id, - }, + scope: { provider: "cursor", windowId: window.id }, window, amount: onDemandAmount, ...(onDemandAmount.usedFraction !== undefined diff --git a/packages/ai/test/cursor-usage.test.ts b/packages/ai/test/cursor-usage.test.ts index 3e8649da1..d66303a5e 100644 --- a/packages/ai/test/cursor-usage.test.ts +++ b/packages/ai/test/cursor-usage.test.ts @@ -196,14 +196,17 @@ describe("cursor usage provider", () => { }); }); - it("falls back to individualUsage.plan for current Pro/Pro+/Ultra payloads", () => { + it("maps plan.auto/api percent rails to Cursor Models / Other Models", () => { const payload = { individualUsage: { plan: { enabled: true, - used: 924, + used: 1504, limit: 7000, - remaining: 6076, + remaining: 5496, + autoPercentUsed: 1.85, + apiPercentUsed: 0, + totalPercentUsed: 1.63, }, onDemand: { enabled: true, @@ -216,52 +219,35 @@ describe("cursor usage provider", () => { }; const report = parseCursorIndividualUsage(payload, 123); - expect(report?.limits).toEqual([ - { - id: "cursor:usd:individual-plan", - label: "Personal Usage", - scope: { - provider: "cursor", - windowId: "monthly", - }, - window: { - id: "monthly", - label: "Monthly", - resetsAt: Date.parse("2026-09-08T08:00:31.000Z"), - }, - amount: { - used: 9.24, - limit: 70, - remaining: 60.76, - usedFraction: 0.132, - remainingFraction: 0.868, - unit: "usd", - }, - status: "ok", - }, - { - id: "cursor:usd:individual-ondemand", - label: "On-Demand Usage", - scope: { - provider: "cursor", - windowId: "monthly", - }, - window: { - id: "monthly", - label: "Monthly", - resetsAt: Date.parse("2026-09-08T08:00:31.000Z"), - }, - amount: { - used: 0, - limit: 20, - remaining: 20, - usedFraction: 0, - remainingFraction: 1, - unit: "usd", - }, - status: "ok", - }, + expect(report?.limits.map(limit => ({ id: limit.id, label: limit.label }))).toEqual([ + { id: "cursor:usd:individual-auto", label: "Cursor Models" }, + { id: "cursor:usd:individual-api", label: "Other Models" }, + { id: "cursor:usd:individual-ondemand", label: "On-Demand Usage" }, ]); + const auto = report?.limits[0]?.amount; + const api = report?.limits[1]?.amount; + const onDemand = report?.limits[2]?.amount; + expect(auto?.unit).toBe("percent"); + expect(auto?.used).toBeCloseTo(1.85); + expect(auto?.usedFraction).toBeCloseTo(0.0185); + // Critically: do NOT trust plan.used/limit cents as the dashboard %. + expect(auto?.usedFraction).not.toBeCloseTo(1504 / 7000); + expect(api).toEqual({ + used: 0, + limit: 70, + remaining: 70, + usedFraction: 0, + remainingFraction: 1, + unit: "usd", + }); + expect(onDemand).toEqual({ + used: 0, + limit: 20, + remaining: 20, + usedFraction: 0, + remainingFraction: 1, + unit: "usd", + }); }); it("prefers individualUsage.overall when both overall and plan exist", () => { diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index d6ca173eb..0bb3954e6 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -1259,8 +1259,12 @@ export class StatusLineComponent implements Component { const normalized = this.#normalizeUsageReports(reports, activeProvider, activeIdentity); const resetSnapshot = activeProvider === "openai-codex" ? this.#normalizeCodexResetSnapshot(reports, activeIdentity) : null; + const usageChanged = this.#cachedUsage !== normalized; this.#cachedUsage = normalized; this.#usageFetchedAt = Date.now(); + // Usage fetch is async; without a repaint the top border stays blank until + // some unrelated event (git resolve, keystroke, …) rebuilds it. + if (usageChanged) this.#onBranchChange?.(); if (!resetSnapshot) return; const contextKey = this.#formatUsageContextKey(activeProvider, activeIdentity); const previous = this.#codexResetSnapshots.get(contextKey); diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index 06a5b8a0f..62dd19d3b 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -667,7 +667,8 @@ const usageSegment: StatusLineSegment = { } if (u.monthly) { const pct = u.monthly.percent; - const pctText = theme.fg(pickUsageColor(pct), `${Math.round(pct)}%`); + // Cursor's web dashboard floors included-usage percents (1.88 → "1% used"). + const pctText = theme.fg(pickUsageColor(pct), `${Math.floor(pct)}%`); const reset = u.monthly.resetHours !== undefined ? theme.fg("muted", ` (${formatUsageReset(u.monthly.resetHours, "h")})`) diff --git a/packages/coding-agent/test/status-line-usage.test.ts b/packages/coding-agent/test/status-line-usage.test.ts index c8153c048..a93c80860 100644 --- a/packages/coding-agent/test/status-line-usage.test.ts +++ b/packages/coding-agent/test/status-line-usage.test.ts @@ -329,13 +329,15 @@ describe("usage status-line segment", () => { it("renders monthly Cursor usage when five-hour and seven-day windows are absent", () => { const result = renderSegment("usage", { - usage: { monthly: { percent: 13.2, resetHours: 743 } }, + usage: { monthly: { percent: 1.88, resetHours: 743 } }, } as unknown as SegmentContext); const content = stripVTControlCharacters(result.content); expect(result.visible).toBe(true); expect(content).toContain("mo"); - expect(content).toContain("13%"); + // Match Cursor web dashboard flooring (1.88 → 1%), not Math.round → 2%. + expect(content).toContain("1%"); + expect(content).not.toContain("2%"); expect(content).toContain("30d 23h"); expect(content).not.toContain("5h"); expect(content).not.toContain("7d");