fix(ai): map Cursor plan rails to dashboard percents

Cursor Pro/Pro+ usage-summary still uses individualUsage.plan, but the
web dashboard percent is autoPercentUsed / apiPercentUsed — not
plan.used/limit cents. Prefer those rails for Cursor Models / Other
Models, keep overall+cents fallbacks, floor monthly status-line %, and
repaint after async usage fetch so mo N% does not stay blank.
This commit is contained in:
dnth
2026-08-08 17:26:40 +08:00
parent cc3835342e
commit 69f790fa6e
5 changed files with 140 additions and 66 deletions
+96 -15
View File
@@ -131,6 +131,54 @@ function parseCursorCentsBucket(bucket: Record<string, unknown>): 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<string, unknown>): {
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
+34 -48
View File
@@ -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", () => {
@@ -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);
@@ -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")})`)
@@ -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");