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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user