Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,25 @@ struct CursorProviderImplementation: ProviderImplementation {

@MainActor
func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) {
// Auto / API pool breakdown (token-based pro plans)
if let pool = context.snapshot?.cursorPoolUsage {
let autoUsedPct = Int(pool.autoPercentUsed.rounded())
let apiUsedPct = Int(pool.apiPercentUsed.rounded())

if let autoTotal = pool.autoPoolTotal, let autoUsed = pool.autoPoolUsed {
entries.append(.text("Auto: \(autoUsed) / \(autoTotal) used (\(autoUsedPct)%)", .primary))
} else {
entries.append(.text("Auto: \(autoUsedPct)% used", .primary))
}

if let apiTotal = pool.apiPoolTotal, let apiUsed = pool.apiPoolUsed {
entries.append(.text("API: \(apiUsed) / \(apiTotal) used (\(apiUsedPct)%)", .primary))
} else {
entries.append(.text("API: \(apiUsedPct)% used", .primary))
}
}

// On-demand (pay-as-you-go) cost
guard let cost = context.snapshot?.providerCost, cost.currencyCode != "Quota" else { return }
let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
if cost.limit > 0 {
Expand Down
40 changes: 40 additions & 0 deletions Sources/CodexBarCore/Providers/Cursor/CursorPoolUsage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

/// Usage snapshot for Cursor's Auto and API model pools (token-based pro plans).
///
/// Cursor separates quota into two buckets:
/// - **Auto pool** – includes base quota + bonus credits; used when Cursor picks the model automatically.
/// - **API pool** – base quota only; used when a specific named/external model is selected.
public struct CursorPoolUsage: Codable, Sendable {
/// Percentage of Auto pool consumed (0-100). Accounts for bonus credits.
public let autoPercentUsed: Double
/// Percentage of API pool consumed (0-100). Base plan limit only.
public let apiPercentUsed: Double
/// Total Auto pool size in requests (included base + bonus credits).
public let autoPoolTotal: Int?
/// Total API pool size in requests (base plan limit).
public let apiPoolTotal: Int?
/// Requests consumed from the Auto pool.
public let autoPoolUsed: Int?
/// Requests consumed from the API pool (estimated from percent × total).
public let apiPoolUsed: Int?

public init(
autoPercentUsed: Double,
apiPercentUsed: Double,
autoPoolTotal: Int?,
apiPoolTotal: Int?,
autoPoolUsed: Int?,
apiPoolUsed: Int?)
{
self.autoPercentUsed = autoPercentUsed
self.apiPercentUsed = apiPercentUsed
self.autoPoolTotal = autoPoolTotal
self.apiPoolTotal = apiPoolTotal
self.autoPoolUsed = autoPoolUsed
self.apiPoolUsed = apiPoolUsed
}

public var autoRemainingPercent: Double { max(0, 100 - self.autoPercentUsed) }
public var apiRemainingPercent: Double { max(0, 100 - self.apiPercentUsed) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ public enum CursorProviderDescriptor {
metadata: ProviderMetadata(
id: .cursor,
displayName: "Cursor",
sessionLabel: "Plan",
weeklyLabel: "On-Demand",
sessionLabel: "Auto",
weeklyLabel: "API",
opusLabel: nil,
supportsOpus: false,
supportsCredits: true,
Expand Down
96 changes: 86 additions & 10 deletions Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,23 @@ public struct CursorStatusSnapshot: Sendable {
/// Raw API response for debugging
public let rawJSON: String?

// MARK: - Auto / API Pool Fields

/// Percentage of Auto model pool used (includes bonus credits, 0-100).
/// Shown as primary bar in the menu.
public let autoPercentUsed: Double?
/// Percentage of API model pool used (named/external models quota, 0-100).
/// Shown as secondary bar in the menu.
public let apiPercentUsed: Double?
/// Total Auto pool size in requests (included + bonus)
public let autoPoolTotal: Int?
/// Total API pool size in requests
public let apiPoolTotal: Int?
/// Requests used in Auto pool
public let autoPoolUsed: Int?
/// Requests used in API pool (derived from percent × total)
public let apiPoolUsed: Int?

// MARK: - Legacy Plan (Request-Based) Fields

/// Requests used this billing cycle (legacy plans only)
Expand All @@ -224,6 +241,12 @@ public struct CursorStatusSnapshot: Sendable {
accountEmail: String?,
accountName: String?,
rawJSON: String?,
autoPercentUsed: Double? = nil,
apiPercentUsed: Double? = nil,
autoPoolTotal: Int? = nil,
apiPoolTotal: Int? = nil,
autoPoolUsed: Int? = nil,
apiPoolUsed: Int? = nil,
requestsUsed: Int? = nil,
requestsLimit: Int? = nil)
{
Expand All @@ -239,19 +262,30 @@ public struct CursorStatusSnapshot: Sendable {
self.accountEmail = accountEmail
self.accountName = accountName
self.rawJSON = rawJSON
self.autoPercentUsed = autoPercentUsed
self.apiPercentUsed = apiPercentUsed
self.autoPoolTotal = autoPoolTotal
self.apiPoolTotal = apiPoolTotal
self.autoPoolUsed = autoPoolUsed
self.apiPoolUsed = apiPoolUsed
self.requestsUsed = requestsUsed
self.requestsLimit = requestsLimit
}

/// Convert to UsageSnapshot for the common provider interface
public func toUsageSnapshot() -> UsageSnapshot {
// Primary: For legacy request-based plans, use request usage; otherwise use plan percentage
let resetDesc = self.billingCycleEnd.map { Self.formatResetDate($0) }

// Primary: Auto model pool (includes bonus credits).
// For legacy request-based plans, fall back to raw request ratio.
let primaryUsedPercent: Double = if self.isLegacyRequestPlan,
let used = self.requestsUsed,
let limit = self.requestsLimit,
limit > 0
{
(Double(used) / Double(limit)) * 100
} else if let auto = self.autoPercentUsed {
auto
} else {
self.planPercentUsed
}
Expand All @@ -260,22 +294,29 @@ public struct CursorStatusSnapshot: Sendable {
usedPercent: primaryUsedPercent,
windowMinutes: nil,
resetsAt: self.billingCycleEnd,
resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) })
resetDescription: resetDesc)

// Always use individual on-demand values (what users see in their Cursor dashboard).
// Team values are aggregates across all members, not useful for individual tracking.
// Secondary: API model pool (named/external models quota).
let secondary: RateWindow? = if let api = self.apiPercentUsed {
RateWindow(
usedPercent: api,
windowMinutes: nil,
resetsAt: self.billingCycleEnd,
resetDescription: resetDesc)
} else {
nil
}

// Tertiary: On-demand (pay-as-you-go) usage as percentage of limit.
let resolvedOnDemandUsed = self.onDemandUsedUSD
let resolvedOnDemandLimit = self.onDemandLimitUSD

// Secondary: On-demand usage as percentage of individual limit
let secondary: RateWindow? = if let limit = resolvedOnDemandLimit,
limit > 0
{
let tertiary: RateWindow? = if let limit = resolvedOnDemandLimit, limit > 0 {
RateWindow(
usedPercent: (resolvedOnDemandUsed / limit) * 100,
Comment on lines +314 to 316
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep on-demand usage in a rendered Cursor window

This moves on-demand usage into tertiary, but Cursor’s UI only renders tertiary when supportsOpus is true (checked in MenuDescriptor.accountSection and MenuCardView.makeMetrics), while Cursor metadata keeps supportsOpus false. As a result, on-demand percent/limit is no longer shown as a usage bar (especially when apiPercentUsed is absent, such as legacy/non-token responses), which is a regression from the previous secondary-window behavior.

Useful? React with 👍 / 👎.

windowMinutes: nil,
resetsAt: self.billingCycleEnd,
resetDescription: self.billingCycleEnd.map { Self.formatResetDate($0) })
resetDescription: resetDesc)
} else {
nil
}
Expand All @@ -302,6 +343,21 @@ public struct CursorStatusSnapshot: Sendable {
nil
}

// Auto/API pool breakdown (only for token-based plans that expose these percentages)
let cursorPoolUsage: CursorPoolUsage? = if let auto = self.autoPercentUsed,
let api = self.apiPercentUsed
{
CursorPoolUsage(
autoPercentUsed: auto,
apiPercentUsed: api,
autoPoolTotal: self.autoPoolTotal,
apiPoolTotal: self.apiPoolTotal,
autoPoolUsed: self.autoPoolUsed,
apiPoolUsed: self.apiPoolUsed)
} else {
nil
}

let identity = ProviderIdentitySnapshot(
providerID: .cursor,
accountEmail: self.accountEmail,
Expand All @@ -310,9 +366,10 @@ public struct CursorStatusSnapshot: Sendable {
return UsageSnapshot(
primary: primary,
secondary: secondary,
tertiary: nil,
tertiary: tertiary,
providerCost: providerCost,
cursorRequests: cursorRequests,
cursorPoolUsage: cursorPoolUsage,
updatedAt: Date(),
identity: identity)
}
Expand Down Expand Up @@ -713,6 +770,19 @@ public struct CursorStatusProbe: Sendable {
let requestsUsed: Int? = requestUsage?.gpt4?.numRequestsTotal ?? requestUsage?.gpt4?.numRequests
let requestsLimit: Int? = requestUsage?.gpt4?.maxRequestUsage

// Auto pool: uses autoPercentUsed from the API (accounts for bonus credits on top of included quota).
// API pool: uses apiPercentUsed from the API (named/external model quota).
let planData = summary.individualUsage?.plan
let autoPercentUsed: Double? = planData?.autoPercentUsed
let apiPercentUsed: Double? = planData?.apiPercentUsed
let autoPoolTotal: Int? = planData?.breakdown?.total // included + bonus
let apiPoolTotal: Int? = planData?.limit // base plan limit (API cap)
let autoPoolUsed: Int? = planData?.used
let apiPoolUsed: Int? = apiPercentUsed.flatMap { pct -> Int? in
guard let total = apiPoolTotal, total > 0 else { return nil }
return Int((pct / 100.0) * Double(total))
}

return CursorStatusSnapshot(
planPercentUsed: planPercentUsed,
planUsedUSD: planUsed,
Expand All @@ -726,6 +796,12 @@ public struct CursorStatusProbe: Sendable {
accountEmail: userInfo?.email,
accountName: userInfo?.name,
rawJSON: rawJSON,
autoPercentUsed: autoPercentUsed,
apiPercentUsed: apiPercentUsed,
autoPoolTotal: autoPoolTotal,
apiPoolTotal: apiPoolTotal,
autoPoolUsed: autoPoolUsed,
apiPoolUsed: apiPoolUsed,
requestsUsed: requestsUsed,
requestsLimit: requestsLimit)
}
Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBarCore/UsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ public struct UsageSnapshot: Codable, Sendable {
public let minimaxUsage: MiniMaxUsageSnapshot?
public let openRouterUsage: OpenRouterUsageSnapshot?
public let cursorRequests: CursorRequestUsage?
/// Auto + API pool breakdown for token-based Cursor pro plans.
public let cursorPoolUsage: CursorPoolUsage?
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve cursor pool breakdown across snapshot relabeling

The new cursorPoolUsage field is added to UsageSnapshot, but the withIdentity copy path is not updated to pass it through, so any flow that rebuilds a snapshot with a new identity (for example account labeling/scoping) silently drops the Auto/API pool data. That makes the newly added Cursor pool breakdown disappear in multi-account/relabel scenarios even though the fetch parsed it correctly.

Useful? React with 👍 / 👎.

public let updatedAt: Date
public let identity: ProviderIdentitySnapshot?

Expand All @@ -81,6 +83,7 @@ public struct UsageSnapshot: Codable, Sendable {
minimaxUsage: MiniMaxUsageSnapshot? = nil,
openRouterUsage: OpenRouterUsageSnapshot? = nil,
cursorRequests: CursorRequestUsage? = nil,
cursorPoolUsage: CursorPoolUsage? = nil,
updatedAt: Date,
identity: ProviderIdentitySnapshot? = nil)
{
Expand All @@ -92,6 +95,7 @@ public struct UsageSnapshot: Codable, Sendable {
self.minimaxUsage = minimaxUsage
self.openRouterUsage = openRouterUsage
self.cursorRequests = cursorRequests
self.cursorPoolUsage = cursorPoolUsage
self.updatedAt = updatedAt
self.identity = identity
}
Expand All @@ -106,6 +110,7 @@ public struct UsageSnapshot: Codable, Sendable {
self.minimaxUsage = nil // Not persisted, fetched fresh each time
self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage)
self.cursorRequests = nil // Not persisted, fetched fresh each time
self.cursorPoolUsage = nil // Not persisted, fetched fresh each time
self.updatedAt = try container.decode(Date.self, forKey: .updatedAt)
if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) {
self.identity = identity
Expand Down