Skip to content
Closed
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,13 +90,18 @@ struct CursorProviderImplementation: ProviderImplementation {

@MainActor
func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) {
// Show on-demand usage separately
guard let cost = context.snapshot?.providerCost, cost.currencyCode != "Quota" else { return }
let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
if cost.limit > 0 {
let limitStr = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
entries.append(.text("On-Demand: \(used) / \(limitStr)", .primary))
} else {
entries.append(.text("On-Demand: \(used)", .primary))

// Only show on-demand if there's actual usage
if cost.used > 0 {
let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
if cost.limit > 0 {
let limitStr = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
entries.append(.text("On-Demand: \(used) / \(limitStr)", .primary))
} else {
entries.append(.text("On-Demand: \(used)", .primary))
}
}
}
}
41 changes: 41 additions & 0 deletions Sources/CodexBarCore/Providers/Cursor/CursorEffectiveUsage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation

/// Effective usage snapshot for Cursor plans with tier-aware budget calculations.
/// This provides accurate usage percentages for higher-tier plans (Pro+, Ultra)
/// that have effective budgets much larger than their nominal prices.
public struct CursorEffectiveUsage: Codable, Sendable {
/// Total usage (plan + on-demand) in USD
public let totalUsedUSD: Double
/// Effective budget (tier budget + on-demand limit) in USD
public let effectiveBudgetUSD: Double
/// Plan tier for display purposes
public let planTier: CursorPlanTier
/// Whether on-demand usage has started (plan allowance exhausted)
public let isPlanExhausted: Bool
/// On-demand usage in USD (for separate display when plan is exhausted)
public let onDemandUsedUSD: Double
/// On-demand limit in USD (nil if unlimited)
public let onDemandLimitUSD: Double?

public init(
totalUsedUSD: Double,
effectiveBudgetUSD: Double,
planTier: CursorPlanTier,
isPlanExhausted: Bool,
onDemandUsedUSD: Double,
onDemandLimitUSD: Double?)
{
self.totalUsedUSD = totalUsedUSD
self.effectiveBudgetUSD = effectiveBudgetUSD
self.planTier = planTier
self.isPlanExhausted = isPlanExhausted
self.onDemandUsedUSD = onDemandUsedUSD
self.onDemandLimitUSD = onDemandLimitUSD
}

/// Effective percentage used based on total usage / effective budget
public var effectivePercentUsed: Double {
guard self.effectiveBudgetUSD > 0 else { return 0 }
return min((self.totalUsedUSD / self.effectiveBudgetUSD) * 100, 100)
}
}
81 changes: 81 additions & 0 deletions Sources/CodexBarCore/Providers/Cursor/CursorPlanTier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Foundation

/// Represents Cursor subscription plan tiers with their effective usage budgets.
///
/// Higher-tier plans provide more effective capacity than their nominal price suggests.
/// For example, Ultra ($200/mo) provides approximately 20x the value of a baseline Pro plan,
/// meaning users can consume ~$800 worth of API usage before hitting limits.
public enum CursorPlanTier: String, Codable, Sendable {
case hobby
case pro
case proPlus
case ultra
case team
case enterprise
case unknown

/// Initialize plan tier from the API's membership type string.
/// - Parameter membershipType: The membership type from Cursor's API (e.g., "pro", "pro+", "ultra")
public init(membershipType: String?) {
switch membershipType?.lowercased() {
case "hobby":
self = .hobby
case "pro":
self = .pro
case "pro+", "pro_plus", "proplus":
self = .proPlus
case "ultra":
self = .ultra
case "team":
self = .team
case "enterprise":
self = .enterprise
default:
self = .unknown
}
}

/// Effective budget in USD based on plan tier.
///
/// These values approximate the actual usage capacity provided by each tier,
/// calibrated against a ~$40 Pro baseline:
/// - Pro ($20/mo) provides ~$40 effective (1x baseline)
/// - Pro+ ($60/mo) provides ~$120 effective (3x baseline)
/// - Ultra ($200/mo) provides ~$800 effective (20x baseline)
public var effectiveBudgetUSD: Double {
switch self {
case .hobby:
return 20
case .pro:
return 40
case .proPlus:
return 120 // 3x baseline
case .ultra:
return 800 // 20x baseline
case .team:
return 60
case .enterprise, .unknown:
return 40 // Conservative default
}
}

/// Display name for the plan tier.
public var displayName: String {
switch self {
case .hobby:
return "Hobby"
case .pro:
return "Pro"
case .proPlus:
return "Pro+"
case .ultra:
return "Ultra"
case .team:
return "Team"
case .enterprise:
return "Enterprise"
case .unknown:
return "Unknown"
}
}
}
58 changes: 54 additions & 4 deletions Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ public struct CursorStatusSnapshot: Sendable {
public let accountName: String?
/// Raw API response for debugging
public let rawJSON: String?
/// Plan tier for effective budget calculation
public let planTier: CursorPlanTier

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

Expand All @@ -211,6 +213,29 @@ public struct CursorStatusSnapshot: Sendable {
self.requestsLimit != nil
}

// MARK: - Effective Budget Calculations

/// Total usage combining plan and on-demand in USD
public var totalUsedUSD: Double {
self.planUsedUSD + self.onDemandUsedUSD
}

/// Effective budget combining plan tier budget and on-demand limit in USD
public var effectiveBudgetUSD: Double {
self.planTier.effectiveBudgetUSD + (self.onDemandLimitUSD ?? 0)
}

/// Effective percentage used based on total usage / effective budget
public var effectivePercentUsed: Double {
guard self.effectiveBudgetUSD > 0 else { return 0 }
return min((self.totalUsedUSD / self.effectiveBudgetUSD) * 100, 100)
}

/// Whether the plan allowance is exhausted and on-demand is being used
public var isPlanExhausted: Bool {
self.onDemandUsedUSD > 0
}

public init(
planPercentUsed: Double,
planUsedUSD: Double,
Expand All @@ -225,7 +250,8 @@ public struct CursorStatusSnapshot: Sendable {
accountName: String?,
rawJSON: String?,
requestsUsed: Int? = nil,
requestsLimit: Int? = nil)
requestsLimit: Int? = nil,
planTier: CursorPlanTier? = nil)
{
self.planPercentUsed = planPercentUsed
self.planUsedUSD = planUsedUSD
Expand All @@ -241,19 +267,21 @@ public struct CursorStatusSnapshot: Sendable {
self.rawJSON = rawJSON
self.requestsUsed = requestsUsed
self.requestsLimit = requestsLimit
self.planTier = planTier ?? CursorPlanTier(membershipType: membershipType)
}

/// 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
// Primary: For legacy request-based plans, use request usage; otherwise use effective percentage
// Effective percentage accounts for tier-based capacity (Ultra gets 20x, Pro+ gets 3x, etc.)
let primaryUsedPercent: Double = if self.isLegacyRequestPlan,
let used = self.requestsUsed,
let limit = self.requestsLimit,
limit > 0
{
(Double(used) / Double(limit)) * 100
} else {
self.planPercentUsed
self.effectivePercentUsed
Comment on lines 282 to +284

Choose a reason for hiding this comment

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

P2 Badge Preserve on-demand fallback when plan is exhausted

Switching the primary meter to effectivePercentUsed means the primary now includes on‑demand capacity, so primary.remainingPercent will stay > 0 until both plan and on‑demand limits are fully consumed. The remaining‑view logic for Cursor explicitly relies on primary.remainingPercent <= 0 to fall back to the on‑demand window (see UsageSnapshot.switcherWeeklyWindow in Sources/CodexBarCore/UsageFetcher.swift lines 146‑161), so after this change a plan can be exhausted and on‑demand in use but the UI will never switch to the on‑demand remaining view. Example: plan used $40 with a $100 on‑demand limit yields ~35.7% primary usage, so the fallback never triggers. Consider using isPlanExhausted or the legacy plan percent for that switch so remaining‑mode still shows on‑demand when the plan is exhausted.

Useful? React with 👍 / 👎.

}

let primary = RateWindow(
Expand Down Expand Up @@ -302,6 +330,19 @@ public struct CursorStatusSnapshot: Sendable {
nil
}

// Effective usage for tier-aware display (non-legacy plans only)
let cursorEffectiveUsage: CursorEffectiveUsage? = if !self.isLegacyRequestPlan {
CursorEffectiveUsage(
totalUsedUSD: self.totalUsedUSD,
effectiveBudgetUSD: self.effectiveBudgetUSD,
planTier: self.planTier,
isPlanExhausted: self.isPlanExhausted,
onDemandUsedUSD: resolvedOnDemandUsed,
onDemandLimitUSD: resolvedOnDemandLimit)
} else {
nil
}

let identity = ProviderIdentitySnapshot(
providerID: .cursor,
accountEmail: self.accountEmail,
Expand All @@ -313,6 +354,7 @@ public struct CursorStatusSnapshot: Sendable {
tertiary: nil,
providerCost: providerCost,
cursorRequests: cursorRequests,
cursorEffectiveUsage: cursorEffectiveUsage,
updatedAt: Date(),
identity: identity)
}
Expand All @@ -330,6 +372,10 @@ public struct CursorStatusSnapshot: Sendable {
"Cursor Enterprise"
case "pro":
"Cursor Pro"
case "pro+", "pro_plus", "proplus":
"Cursor Pro+"
case "ultra":
"Cursor Ultra"
case "hobby":
"Cursor Hobby"
case "team":
Expand Down Expand Up @@ -714,6 +760,9 @@ public struct CursorStatusProbe: Sendable {
let requestsUsed: Int? = requestUsage?.gpt4?.numRequestsTotal ?? requestUsage?.gpt4?.numRequests
let requestsLimit: Int? = requestUsage?.gpt4?.maxRequestUsage

// Detect plan tier from membership type
let planTier = CursorPlanTier(membershipType: summary.membershipType)

return CursorStatusSnapshot(
planPercentUsed: planPercentUsed,
planUsedUSD: planUsed,
Expand All @@ -728,7 +777,8 @@ public struct CursorStatusProbe: Sendable {
accountName: userInfo?.name,
rawJSON: rawJSON,
requestsUsed: requestsUsed,
requestsLimit: requestsLimit)
requestsLimit: requestsLimit,
planTier: planTier)
}
}

Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBarCore/UsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public struct UsageSnapshot: Codable, Sendable {
public let zaiUsage: ZaiUsageSnapshot?
public let minimaxUsage: MiniMaxUsageSnapshot?
public let cursorRequests: CursorRequestUsage?
public let cursorEffectiveUsage: CursorEffectiveUsage?
public let updatedAt: Date
public let identity: ProviderIdentitySnapshot?

Expand All @@ -78,6 +79,7 @@ public struct UsageSnapshot: Codable, Sendable {
zaiUsage: ZaiUsageSnapshot? = nil,
minimaxUsage: MiniMaxUsageSnapshot? = nil,
cursorRequests: CursorRequestUsage? = nil,
cursorEffectiveUsage: CursorEffectiveUsage? = nil,
updatedAt: Date,
identity: ProviderIdentitySnapshot? = nil)
{
Expand All @@ -88,6 +90,7 @@ public struct UsageSnapshot: Codable, Sendable {
self.zaiUsage = zaiUsage
self.minimaxUsage = minimaxUsage
self.cursorRequests = cursorRequests
self.cursorEffectiveUsage = cursorEffectiveUsage
self.updatedAt = updatedAt
self.identity = identity
}
Expand All @@ -101,6 +104,7 @@ public struct UsageSnapshot: Codable, Sendable {
self.zaiUsage = nil // Not persisted, fetched fresh each time
self.minimaxUsage = nil // Not persisted, fetched fresh each time
self.cursorRequests = nil // Not persisted, fetched fresh each time
self.cursorEffectiveUsage = 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 Expand Up @@ -184,6 +188,7 @@ public struct UsageSnapshot: Codable, Sendable {
zaiUsage: self.zaiUsage,
minimaxUsage: self.minimaxUsage,
cursorRequests: self.cursorRequests,
cursorEffectiveUsage: self.cursorEffectiveUsage,
updatedAt: self.updatedAt,
identity: scopedIdentity)
}
Expand Down
Loading