diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6cef743d9..26918ae60 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed Cursor personal usage reporting for current Pro / Pro+ / Ultra `/api/usage-summary` payloads that expose `individualUsage.plan` (and optional `onDemand`) instead of the older `individualUsage.overall` bucket. +- Fixed Cursor personal usage reporting for current Pro / Pro+ / Ultra `/api/usage-summary` payloads that expose `individualUsage.plan` (and optional `onDemand`) instead of the older `individualUsage.overall` bucket ([#7998](https://github.com/can1357/oh-my-pi/pull/7998) by [@dnth](https://github.com/dnth)). ## [17.2.11] - 2026-08-07 diff --git a/packages/ai/src/usage/cursor.ts b/packages/ai/src/usage/cursor.ts index 5f3f650fe..7cbae3ef6 100644 --- a/packages/ai/src/usage/cursor.ts +++ b/packages/ai/src/usage/cursor.ts @@ -74,27 +74,9 @@ function deriveResetsAt(payload: Record): number | undefined { } /** - * Cursor's `/api/usage-summary` has shipped two personal-bucket shapes: - * - Enterprise/team dashboards historically exposed `individualUsage.overall` - * - Current Pro / Pro+ / Ultra dashboards expose `individualUsage.plan` - * (plus optional `onDemand`) - * - * Prefer `overall` when present so older fixtures keep working, then fall - * back to `plan`. Optionally emit a second on-demand meter when Cursor - * reports one with a positive limit. + * Parse a Cursor cents bucket (`used`/`limit`/`remaining` in USD cents). + * Returns null for disabled or non-positive / malformed buckets. */ -function pickCursorIndividualBucket( - individualUsage: Record, -): { key: "overall" | "plan"; bucket: Record } | null { - if (isRecord(individualUsage.overall)) { - return { key: "overall", bucket: individualUsage.overall }; - } - if (isRecord(individualUsage.plan)) { - return { key: "plan", bucket: individualUsage.plan }; - } - return null; -} - function parseCursorCentsBucket(bucket: Record): UsageAmount | null { if (bucket.enabled === false) return null; @@ -144,6 +126,8 @@ function parseCursorPlanDashboardAmounts(bucket: Record): { api?: UsageAmount; fallback?: UsageAmount; } { + if (bucket.enabled === false) return {}; + const limitCents = toNumber(bucket.limit); const limitUsd = limitCents !== undefined && limitCents > 0 ? limitCents / 100 : undefined; const autoPct = toNumber(bucket.autoPercentUsed); @@ -180,12 +164,57 @@ function parseCursorPlanDashboardAmounts(bucket: Record): { return result; } +function pushCursorPlanRails(limits: UsageLimit[], bucket: Record, window: UsageWindow): void { + const rails = parseCursorPlanDashboardAmounts(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) } + : {}), + }); + } +} + +/** + * Cursor's `/api/usage-summary` has shipped two personal-bucket shapes: + * - Enterprise/team dashboards historically exposed `individualUsage.overall` + * - Current Pro / Pro+ / Ultra dashboards expose `individualUsage.plan` + * (plus optional `onDemand`) + * + * Prefer a *usable* overall bucket; if overall is absent/disabled/malformed, + * fall through to plan rails (`autoPercentUsed` / `apiPercentUsed`). Always + * consider on-demand afterward so a valid on-demand meter is not dropped when + * the included plan bucket is empty. + */ export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null { if (!isRecord(payload) || !isRecord(payload.individualUsage)) { return null; } - const picked = pickCursorIndividualBucket(payload.individualUsage); - if (!picked) return null; const resetsAt = deriveResetsAt(payload); const window: UsageWindow = { @@ -195,57 +224,30 @@ export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.no }; const limits: UsageLimit[] = []; - if (picked.key === "plan") { - const rails = parseCursorPlanDashboardAmounts(picked.bucket); - if (rails.auto) { + const overall = isRecord(payload.individualUsage.overall) ? payload.individualUsage.overall : null; + const plan = isRecord(payload.individualUsage.plan) ? payload.individualUsage.plan : null; + + // Prefer a usable overall bucket; if it is disabled/malformed, fall through to plan. + let usedOverall = false; + if (overall) { + const amount = parseCursorCentsBucket(overall); + if (amount) { + usedOverall = true; 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", + id: "cursor:usd:individual-overall", label: "Personal Usage", scope: { provider: "cursor", windowId: window.id }, window, - amount: rails.fallback, - ...(rails.fallback.usedFraction !== undefined - ? { status: usageStatus(rails.fallback.usedFraction) } - : {}), + amount, + ...(amount.usedFraction !== undefined ? { status: usageStatus(amount.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 }, - window, - amount, - ...(amount.usedFraction !== undefined ? { status: usageStatus(amount.usedFraction) } : {}), - }); + } + if (!usedOverall && plan) { + pushCursorPlanRails(limits, plan, window); } - if (limits.length === 0) return null; - + // Keep on-demand even when the included plan/overall bucket is absent or unusable. if (isRecord(payload.individualUsage.onDemand)) { const onDemandAmount = parseCursorCentsBucket(payload.individualUsage.onDemand); if (onDemandAmount && onDemandAmount.limit !== undefined && onDemandAmount.limit > 0) { @@ -262,6 +264,8 @@ export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.no } } + if (limits.length === 0) return null; + return { provider: "cursor", fetchedAt, diff --git a/packages/ai/test/cursor-usage.test.ts b/packages/ai/test/cursor-usage.test.ts index d66303a5e..5de0fd279 100644 --- a/packages/ai/test/cursor-usage.test.ts +++ b/packages/ai/test/cursor-usage.test.ts @@ -260,6 +260,52 @@ describe("cursor usage provider", () => { expect(report?.limits.map(limit => limit.id)).toEqual(["cursor:usd:individual-overall"]); }); + it("falls back to plan when overall is present but disabled", () => { + const report = parseCursorIndividualUsage({ + individualUsage: { + overall: { enabled: false, used: 100, limit: 1000, remaining: 900 }, + plan: { + enabled: true, + used: 1504, + limit: 7000, + remaining: 5496, + autoPercentUsed: 1.85, + apiPercentUsed: 0, + }, + }, + }); + expect(report?.limits.map(limit => limit.id)).toEqual([ + "cursor:usd:individual-auto", + "cursor:usd:individual-api", + ]); + }); + + it("rejects disabled plan buckets even when stale percent fields remain", () => { + expect( + parseCursorIndividualUsage({ + individualUsage: { + plan: { + enabled: false, + used: 1504, + limit: 7000, + autoPercentUsed: 1.85, + apiPercentUsed: 0, + }, + }, + }), + ).toBeNull(); + }); + + it("keeps on-demand when the included plan bucket is unusable", () => { + const report = parseCursorIndividualUsage({ + individualUsage: { + plan: { enabled: false, used: 1504, limit: 7000, autoPercentUsed: 1.85 }, + onDemand: { enabled: true, used: 0, limit: 2000, remaining: 2000 }, + }, + }); + expect(report?.limits.map(limit => limit.id)).toEqual(["cursor:usd:individual-ondemand"]); + }); + it("rejects disabled, malformed, and non-positive personal usage buckets", () => { expect( parseCursorIndividualUsage({ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dcbebb408..4516d99a6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Status-line `usage` now renders monthly Cursor quotas (`mo N%`) in addition to the existing `5h` / `7d` windows. +- Status-line `usage` now renders monthly Cursor quotas (`mo N%`) in addition to the existing `5h` / `7d` windows ([#7998](https://github.com/can1357/oh-my-pi/pull/7998) by [@dnth](https://github.com/dnth)). ## [17.2.11] - 2026-08-07 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 0bb3954e6..228d4d124 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -1382,7 +1382,16 @@ export class StatusLineComponent implements Component { let fiveHourTier: string | undefined; let sevenDayTier: string | undefined; let monthlyTier: string | undefined; + let monthlyPriority = Number.POSITIVE_INFINITY; const now = Date.now(); + const cursorMonthlyPriority = (limitId: unknown): number => { + // When /auth/usage and /api/usage-summary are merged, prefer the personal + // dashboard rails over legacy per-model request fractions. + if (limitId === "cursor:usd:individual-auto") return 0; + if (limitId === "cursor:usd:individual-plan" || limitId === "cursor:usd:individual-overall") return 1; + if (typeof limitId === "string" && limitId.startsWith("cursor:usd:individual-")) return 2; + return 3; + }; for (const report of reports) { if (!report || typeof report !== "object") continue; const provider = (report as { provider?: unknown }).provider; @@ -1396,6 +1405,7 @@ export class StatusLineComponent implements Component { continue; } const l = limit as { + id?: string; scope?: { windowId?: string; tier?: string }; window?: { resetsAt?: number }; amount?: { usedFraction?: number }; @@ -1423,16 +1433,29 @@ export class StatusLineComponent implements Component { }; sevenDayTier = tier || undefined; } + // Conservatively gate monthly status-line rendering to Cursor for now — + // Copilot/OpenCode also emit monthly windows, but their multi-bucket + // shape needs a dedicated selector before we surface `mo N%` for them. if ( - (windowId === "monthly" || windowId === "30d") && - (!monthly || (monthlyTier !== undefined && !tier)) + activeProvider === "cursor" && + (windowId === "monthly" || windowId === "30d") ) { - monthly = { - percent: fraction * 100, - resetHours: - typeof resetsAt === "number" ? Math.max(0, Math.round((resetsAt - now) / 3_600_000)) : undefined, - }; - monthlyTier = tier || undefined; + const priority = cursorMonthlyPriority(l.id); + const shouldReplace = + !monthly || + priority < monthlyPriority || + (priority === monthlyPriority && monthlyTier !== undefined && !tier); + if (shouldReplace) { + monthly = { + percent: fraction * 100, + resetHours: + typeof resetsAt === "number" + ? Math.max(0, Math.round((resetsAt - now) / 3_600_000)) + : undefined, + }; + monthlyTier = tier || undefined; + monthlyPriority = priority; + } } } } 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 62dd19d3b..66dcb3a2c 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -667,6 +667,7 @@ const usageSegment: StatusLineSegment = { } if (u.monthly) { const pct = u.monthly.percent; + // Cursor-only today (normalize gates monthly to provider === "cursor"). // Cursor's web dashboard floors included-usage percents (1.88 → "1% used"). const pctText = theme.fg(pickUsageColor(pct), `${Math.floor(pct)}%`); const reset = diff --git a/packages/coding-agent/test/status-line-usage.test.ts b/packages/coding-agent/test/status-line-usage.test.ts index a93c80860..4f6ea8c46 100644 --- a/packages/coding-agent/test/status-line-usage.test.ts +++ b/packages/coding-agent/test/status-line-usage.test.ts @@ -369,6 +369,56 @@ describe("usage status-line segment", () => { expect(content).toContain("13%"); }); + it("prefers Cursor personal dashboard rails over legacy monthly request limits", async () => { + const component = makeComponent( + [ + { + provider: "cursor", + limits: [ + { + id: "cursor:requests:gpt-4", + scope: { windowId: "monthly" }, + amount: { usedFraction: 0.9 }, + }, + { + id: "cursor:usd:individual-auto", + scope: { windowId: "monthly" }, + amount: { usedFraction: 0.0185 }, + }, + ], + }, + ], + { provider: "cursor" }, + ); + + component.refreshUsageInBackground(); + await flushUsageRefresh(); + const content = stripVTControlCharacters(component.getTopBorder(200).content); + + expect(content).toContain("mo"); + expect(content).toContain("1%"); + expect(content).not.toContain("90%"); + }); + + it("does not render monthly usage for non-Cursor providers", async () => { + const component = makeComponent( + [ + { + provider: "opencode-go", + limits: [{ id: "opencode-go:usd:monthly", scope: { windowId: "monthly" }, amount: { usedFraction: 0.42 } }], + }, + ], + { provider: "opencode-go" }, + ); + + component.refreshUsageInBackground(); + await flushUsageRefresh(); + const content = stripVTControlCharacters(component.getTopBorder(200).content); + + expect(content).not.toContain("mo"); + expect(content).not.toContain("42%"); + }); + it("uses a distinct error color at the eighty-percent threshold", () => { const high = renderSegment("usage", { usage: { fiveHour: { percent: 80 } } } as unknown as SegmentContext); const low = renderSegment("usage", { usage: { fiveHour: { percent: 24 } } } as unknown as SegmentContext);