Skip to content

Commit c280059

Browse files
plugins: Conditional primary metrics + Cursor credits balance (#68)
* feat(cursor): add credits balance retrieval and display in progress lines - Introduced a new API endpoint to fetch credit grants balance. - Updated the progress display to include credits usage alongside plan usage. - Enhanced error handling for credit grants fetch failures. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(plugins): implement primary candidates for progress lines - Updated plugin.json files to replace the "primary" flag with "primaryOrder" for defining primary metrics. - Enhanced the cursor plugin to prioritize "Credits" in the display order when available. - Adjusted the plugin types to support an array of primary candidates, allowing for more flexible metric selection. - Updated tests to validate the new primary candidates functionality. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): update state database path for testing and adjust plan usage calculation - Changed the state database path to a test location for debugging purposes. - Updated the plan usage calculation to handle cases where totalSpend may not be directly available, ensuring accurate display of usage metrics. * fix(cursor): update state database path for production use - Changed the state database path to the production location for proper functionality. - Removed the temporary test path comment for clarity. * fix: address PR review feedback - Fix trailing zeros dropped in currency formatting (C2): set minimumFractionDigits for non-integer values - Add fail-loud null check for pu.limit (C3): throw explicit error when API response is missing required plan usage data instead of silently returning NaN - Add tests for new validation paths Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0aff5a3 commit c280059

File tree

13 files changed

+319
-107
lines changed

13 files changed

+319
-107
lines changed

plugins/claude/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"icon": "icon.svg",
88
"brandColor": "#DE7356",
99
"lines": [
10-
{ "type": "progress", "label": "Session", "scope": "overview", "primary": true },
10+
{ "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 },
1111
{ "type": "progress", "label": "Weekly", "scope": "overview" },
1212
{ "type": "progress", "label": "Sonnet", "scope": "detail" },
1313
{ "type": "progress", "label": "Extra usage", "scope": "detail" }

plugins/codex/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"icon": "icon.svg",
88
"brandColor": "#74AA9C",
99
"lines": [
10-
{ "type": "progress", "label": "Session", "scope": "overview", "primary": true },
10+
{ "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 },
1111
{ "type": "progress", "label": "Weekly", "scope": "overview" },
1212
{ "type": "progress", "label": "Reviews", "scope": "detail" },
1313
{ "type": "progress", "label": "Credits", "scope": "detail" }

plugins/cursor/plugin.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
const USAGE_URL = BASE_URL + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage"
66
const PLAN_URL = BASE_URL + "/aiserver.v1.DashboardService/GetPlanInfo"
77
const REFRESH_URL = BASE_URL + "/oauth/token"
8+
const CREDITS_URL = BASE_URL + "/aiserver.v1.DashboardService/GetCreditGrantsBalance"
89
const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"
910
const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration
1011

@@ -237,6 +238,16 @@
237238
ctx.host.log.warn("plan info fetch failed: " + String(e))
238239
}
239240

241+
let creditGrants = null
242+
try {
243+
const creditsResp = connectPost(ctx, CREDITS_URL, accessToken)
244+
if (creditsResp.status >= 200 && creditsResp.status < 300) {
245+
creditGrants = ctx.util.tryParseJson(creditsResp.bodyText)
246+
}
247+
} catch (e) {
248+
ctx.host.log.warn("credit grants fetch failed: " + String(e))
249+
}
250+
240251
let plan = null
241252
if (planName) {
242253
const planLabel = ctx.fmt.planLabel(planName)
@@ -247,9 +258,32 @@
247258

248259
const lines = []
249260
const pu = usage.planUsage
261+
262+
// Credits first (if available) - highest priority primary metric
263+
if (creditGrants && creditGrants.hasCreditGrants === true) {
264+
const total = parseInt(creditGrants.totalCents, 10)
265+
const used = parseInt(creditGrants.usedCents, 10)
266+
if (total > 0 && !isNaN(total) && !isNaN(used)) {
267+
lines.push(ctx.line.progress({
268+
label: "Credits",
269+
used: ctx.fmt.dollars(used),
270+
limit: ctx.fmt.dollars(total),
271+
format: { kind: "dollars" },
272+
}))
273+
}
274+
}
275+
276+
// Plan usage (always present) - fallback primary metric
277+
// API may return totalSpend directly, or we calculate from limit - remaining
278+
if (typeof pu.limit !== "number") {
279+
throw "Plan usage limit missing from API response."
280+
}
281+
const planUsed = typeof pu.totalSpend === "number"
282+
? pu.totalSpend
283+
: pu.limit - (pu.remaining ?? 0)
250284
lines.push(ctx.line.progress({
251285
label: "Plan usage",
252-
used: ctx.fmt.dollars(pu.totalSpend),
286+
used: ctx.fmt.dollars(planUsed),
253287
limit: ctx.fmt.dollars(pu.limit),
254288
format: { kind: "dollars" },
255289
resetsAt: ctx.util.toIso(usage.billingCycleEnd),
@@ -259,6 +293,7 @@
259293
lines.push(ctx.line.text({ label: "Bonus spend", value: "$" + String(ctx.fmt.dollars(pu.bonusSpend)) }))
260294
}
261295

296+
// On-demand (if available) - not a primary candidate
262297
const su = usage.spendLimitUsage
263298
if (su) {
264299
const limit = su.individualLimit ?? su.pooledLimit ?? 0

plugins/cursor/plugin.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"icon": "icon.svg",
88
"brandColor": "#000000",
99
"lines": [
10-
{ "type": "progress", "label": "Plan usage", "scope": "overview", "primary": true },
10+
{ "type": "progress", "label": "Credits", "scope": "overview", "primaryOrder": 1 },
11+
{ "type": "progress", "label": "Plan usage", "scope": "overview", "primaryOrder": 2 },
1112
{ "type": "progress", "label": "On-demand", "scope": "detail" }
1213
]
1314
}

plugins/cursor/plugin.test.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,38 @@ describe("cursor plugin", () => {
5151
expect(() => plugin.probe(ctx)).toThrow("Usage tracking disabled")
5252
})
5353

54+
it("throws on missing plan usage limit", async () => {
55+
const ctx = makeCtx()
56+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
57+
ctx.host.http.request.mockReturnValue({
58+
status: 200,
59+
bodyText: JSON.stringify({
60+
enabled: true,
61+
planUsage: { totalSpend: 1200 }, // missing limit
62+
}),
63+
})
64+
const plugin = await loadPlugin()
65+
expect(() => plugin.probe(ctx)).toThrow("Plan usage limit missing")
66+
})
67+
68+
it("calculates planUsed from limit - remaining when totalSpend missing", async () => {
69+
const ctx = makeCtx()
70+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
71+
ctx.host.http.request.mockReturnValue({
72+
status: 200,
73+
bodyText: JSON.stringify({
74+
enabled: true,
75+
planUsage: { limit: 2400, remaining: 1200 }, // no totalSpend
76+
}),
77+
})
78+
const plugin = await loadPlugin()
79+
const result = plugin.probe(ctx)
80+
const planLine = result.lines.find((l) => l.label === "Plan usage")
81+
expect(planLine).toBeTruthy()
82+
// used = limit - remaining = 2400 - 1200 = 1200
83+
expect(planLine.used).toBe(12) // ctx.fmt.dollars divides by 100
84+
})
85+
5486
it("renders usage + plan info", async () => {
5587
const ctx = makeCtx()
5688
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
@@ -182,6 +214,71 @@ describe("cursor plugin", () => {
182214
expect(result.lines.find((line) => line.label === "Plan usage")).toBeTruthy()
183215
})
184216

217+
it("outputs Credits first when available", async () => {
218+
const ctx = makeCtx()
219+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
220+
ctx.host.http.request.mockImplementation((opts) => {
221+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
222+
return {
223+
status: 200,
224+
bodyText: JSON.stringify({
225+
enabled: true,
226+
planUsage: { totalSpend: 1200, limit: 2400 },
227+
spendLimitUsage: { individualLimit: 5000, individualRemaining: 1000 },
228+
}),
229+
}
230+
}
231+
if (String(opts.url).includes("GetCreditGrantsBalance")) {
232+
return {
233+
status: 200,
234+
bodyText: JSON.stringify({
235+
hasCreditGrants: true,
236+
totalCents: 10000,
237+
usedCents: 500,
238+
}),
239+
}
240+
}
241+
return { status: 200, bodyText: "{}" }
242+
})
243+
const plugin = await loadPlugin()
244+
const result = plugin.probe(ctx)
245+
246+
// Credits should be first in the lines array
247+
expect(result.lines[0].label).toBe("Credits")
248+
expect(result.lines[1].label).toBe("Plan usage")
249+
// On-demand should come after
250+
const onDemandIndex = result.lines.findIndex((l) => l.label === "On-demand")
251+
expect(onDemandIndex).toBeGreaterThan(1)
252+
})
253+
254+
it("outputs Plan usage first when Credits not available", async () => {
255+
const ctx = makeCtx()
256+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
257+
ctx.host.http.request.mockImplementation((opts) => {
258+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
259+
return {
260+
status: 200,
261+
bodyText: JSON.stringify({
262+
enabled: true,
263+
planUsage: { totalSpend: 1200, limit: 2400 },
264+
}),
265+
}
266+
}
267+
if (String(opts.url).includes("GetCreditGrantsBalance")) {
268+
return {
269+
status: 200,
270+
bodyText: JSON.stringify({ hasCreditGrants: false }),
271+
}
272+
}
273+
return { status: 200, bodyText: "{}" }
274+
})
275+
const plugin = await loadPlugin()
276+
const result = plugin.probe(ctx)
277+
278+
// Plan usage should be first when Credits not available
279+
expect(result.lines[0].label).toBe("Plan usage")
280+
})
281+
185282
it("refreshes token when expired and persists new access token", async () => {
186283
const ctx = makeCtx()
187284

plugins/mock/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"lines": [
1010
{ "type": "text", "label": "Config", "scope": "overview" },
1111
{ "type": "badge", "label": "Case", "scope": "overview" },
12-
{ "type": "progress", "label": "Percent", "scope": "overview" },
12+
{ "type": "progress", "label": "Percent", "scope": "overview", "primaryOrder": 1 },
1313
{ "type": "progress", "label": "Dollars", "scope": "detail" },
1414
{ "type": "text", "label": "Now", "scope": "detail" },
1515
{ "type": "badge", "label": "Warning", "scope": "detail" }

src-tauri/src/lib.rs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ pub struct PluginMeta {
2727
pub icon_url: String,
2828
pub brand_color: Option<String>,
2929
pub lines: Vec<ManifestLineDto>,
30-
pub primary_progress_label: Option<String>,
30+
/// Ordered list of primary metric candidates (sorted by primaryOrder).
31+
/// Frontend picks the first one that exists in runtime data.
32+
pub primary_candidates: Vec<String>,
3133
}
3234

3335
#[derive(Debug, Clone, Serialize)]
@@ -214,12 +216,16 @@ fn list_plugins(state: tauri::State<'_, Mutex<AppState>>) -> Vec<PluginMeta> {
214216
plugins
215217
.into_iter()
216218
.map(|plugin| {
217-
let primary_progress_label = plugin
219+
// Extract primary candidates: progress lines with primary_order, sorted by order
220+
let mut candidates: Vec<_> = plugin
218221
.manifest
219222
.lines
220223
.iter()
221-
.find(|line| line.primary && line.line_type == "progress")
222-
.map(|line| line.label.clone());
224+
.filter(|line| line.line_type == "progress" && line.primary_order.is_some())
225+
.collect();
226+
candidates.sort_by_key(|line| line.primary_order.unwrap());
227+
let primary_candidates: Vec<String> =
228+
candidates.iter().map(|line| line.label.clone()).collect();
223229

224230
PluginMeta {
225231
id: plugin.manifest.id,
@@ -236,7 +242,7 @@ fn list_plugins(state: tauri::State<'_, Mutex<AppState>>) -> Vec<PluginMeta> {
236242
scope: line.scope.clone(),
237243
})
238244
.collect(),
239-
primary_progress_label,
245+
primary_candidates,
240246
}
241247
})
242248
.collect()

0 commit comments

Comments
 (0)