Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2209a9b
feat(cursor): add token cost report with Cursor-metered total
EClinick Jun 24, 2026
a3aaa75
style(cursor): wrap long Preferences subtitle to satisfy swiftformat
EClinick Jun 24, 2026
891c2fe
fix(cursor): default TokenUsageSection.meteredLine to keep call sites…
EClinick Jun 24, 2026
070ec1b
fix(cursor): honor cookie-source setting when fetching cost
EClinick Jun 24, 2026
8f650d9
fix(cursor): correct lenient count decoding and fetcher test day-grou…
EClinick Jun 24, 2026
baacfb5
fix(cursor): render cost inline with a windowed history chart
EClinick Jun 24, 2026
6729df8
fix(cursor): tie cost session line to the current local day
EClinick Jun 25, 2026
f92c3d6
fix(cursor): show dashboard cost hint on the menu and inline cards
EClinick Jun 25, 2026
8632505
fix(cursor): honor cookie source in CLI cost and gate to macOS
EClinick Jun 25, 2026
51c8bab
fix(cursor): snap cost window start to the local day boundary
EClinick Jun 25, 2026
9a8c8e9
fix(cursor): key cost cache on the manual cookie header
EClinick Jun 25, 2026
05fd50b
test(cursor): cover the cost window day-boundary snap
EClinick Jun 25, 2026
51dd178
refactor(cursor): extract shared cost cookie-gating helpers
EClinick Jun 25, 2026
933e16d
fix(cursor): honor cookie source in serve /cost route
EClinick Jun 25, 2026
02bcded
Merge origin/main into feat/cursor-token-cost
EClinick Jun 29, 2026
43c8755
fix: report disabled Cursor cost source
steipete Jul 1, 2026
6bdb63a
Merge origin/main into feat/cursor-token-cost
EClinick Jul 6, 2026
718963b
Include Cursor auto session identity in cost cache scope
EClinick Jul 6, 2026
613342a
docs: describe Cursor dashboard cost source and CLI output
EClinick Jul 6, 2026
fb5c0b4
Merge remote-tracking branch 'origin/main' into feat/cursor-token-cost
EClinick Jul 10, 2026
079da53
Merge remote-tracking branch 'origin/main' into feat/cursor-token-cost
EClinick Jul 11, 2026
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
2 changes: 1 addition & 1 deletion Sources/CodexBar/InlineUsageDashboardContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ extension UsageMenuCardView.Model {
{
return Self.poeInlineDashboard(usage, now: input.now)
}
if [.codex, .claude, .vertexai, .bedrock].contains(input.provider),
if [.codex, .claude, .vertexai, .bedrock, .cursor].contains(input.provider),
input.tokenCostInlineDashboardEnabled,
let tokenSnapshot = input.tokenSnapshot,
!tokenSnapshot.daily.isEmpty
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/MenuCardHeightFingerprint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ extension UsageMenuCardView.Model.TokenUsageSection {
MenuCardHeightFingerprint.join([
MenuCardHeightFingerprint.field("session", self.sessionLine),
MenuCardHeightFingerprint.field("month", self.monthLine),
MenuCardHeightFingerprint.field("metered", self.meteredLine),
MenuCardHeightFingerprint.field("comparisons", self.comparisonLines.joined(separator: "|")),
MenuCardHeightFingerprint.field("hint", self.hintLine),
MenuCardHeightFingerprint.field("error", self.errorLine),
Expand Down
9 changes: 9 additions & 0 deletions Sources/CodexBar/MenuCardView+Costs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,17 @@ extension UsageMenuCardView.Model {
}
return "\(windowLabel): \(monthCost)"
}()
// Plan-metered spend over the same window (what the provider actually deducts);
// only providers that report it (currently Cursor) populate `meteredCostUSD`.
let meteredLine: String? = snapshot.meteredCostUSD.map {
let amount = UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode)
return String(format: L("Cursor-metered: %@ (%@)"), amount, windowLabel.lowercased())
}
let err = (error?.isEmpty ?? true) ? nil : error
return TokenUsageSection(
sessionLine: sessionLine,
monthLine: monthLine,
meteredLine: meteredLine,
comparisonLines: comparisonPeriodsEnabled
? snapshot.comparisonSummaries().map {
Self.costWindowLine(summary: $0, currencyCode: snapshot.currencyCode)
Expand Down Expand Up @@ -174,6 +181,8 @@ extension UsageMenuCardView.Model {
L("Estimated from local Codex logs for the selected account.")
case .claude:
UsageFormatter.costEstimateHint(provider: provider)
case .cursor:
UsageFormatter.costEstimateHint(provider: provider)
case .vertexai:
L("cost_estimate_hint")
case .bedrock:
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/MenuCardView+ModelHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ extension UsageMenuCardView.Model {
case let (current?, candidate?):
current.hintLine == candidate.hintLine &&
current.errorLine == candidate.errorLine &&
(current.meteredLine == nil) == (candidate.meteredLine == nil) &&
current.comparisonLines.count == candidate.comparisonLines.count
default:
false
Expand Down
122 changes: 56 additions & 66 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,21 +86,26 @@ struct UsageMenuCardView: View {
struct TokenUsageSection {
let sessionLine: String
let monthLine: String
let meteredLine: String?
Comment thread
EClinick marked this conversation as resolved.
let comparisonLines: [String]
let hintLine: String?
let errorLine: String?
let errorCopyText: String?

/// Explicit initializer so `meteredLine`/`comparisonLines` default to empty: callers
/// that predate them (and providers that never report them) keep their call sites.
init(
sessionLine: String,
monthLine: String,
meteredLine: String? = nil,
comparisonLines: [String] = [],
hintLine: String?,
errorLine: String?,
errorCopyText: String?)
{
self.sessionLine = sessionLine
self.monthLine = monthLine
self.meteredLine = meteredLine
self.comparisonLines = comparisonLines
self.hintLine = hintLine
self.errorLine = errorLine
Expand Down Expand Up @@ -212,39 +217,7 @@ struct UsageMenuCardView: View {
Divider()
}
if let tokenUsage = liveModel.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
Text(L("cost_header_estimated"))
.font(.body)
.fontWeight(.medium)
Text(tokenUsage.sessionLine)
.font(.footnote)
.lineLimit(1)
Text(tokenUsage.monthLine)
.font(.footnote)
.lineLimit(1)
ForEach(tokenUsage.comparisonLines, id: \.self) { line in
Text(line)
.font(.footnote)
.lineLimit(1)
}
if let hint = tokenUsage.hintLine, !hint.isEmpty {
Text(hint)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
if let error = tokenUsage.errorLine, !error.isEmpty {
Text(error)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.error(self.isHighlighted))
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
.overlay {
ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error)
}
}
}
TokenUsageSectionContent(tokenUsage: tokenUsage, lineFont: .footnote)
}
}
}
Expand Down Expand Up @@ -422,6 +395,55 @@ private struct CopyIconButton: View {
}
}

/// Shared token-cost block (header, Today/window/metered/comparison lines, hint, error) used by
/// both the inline card body and the standalone cost section; only the value-line font differs.
private struct TokenUsageSectionContent: View {
let tokenUsage: UsageMenuCardView.Model.TokenUsageSection
let lineFont: Font
@Environment(\.menuItemHighlighted) private var isHighlighted

var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(L("cost_header_estimated"))
.font(.body)
.fontWeight(.medium)
Text(self.tokenUsage.sessionLine)
.font(self.lineFont)
.lineLimit(1)
Text(self.tokenUsage.monthLine)
.font(self.lineFont)
.lineLimit(1)
if let metered = self.tokenUsage.meteredLine, !metered.isEmpty {
Text(metered)
.font(self.lineFont)
.lineLimit(1)
}
ForEach(self.tokenUsage.comparisonLines, id: \.self) { line in
Text(line)
.font(self.lineFont)
.lineLimit(1)
}
if let hint = self.tokenUsage.hintLine, !hint.isEmpty {
Text(hint)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
if let error = self.tokenUsage.errorLine, !error.isEmpty {
Text(error)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.error(self.isHighlighted))
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
.overlay {
ClickToCopyOverlay(copyText: self.tokenUsage.errorCopyText ?? error)
}
}
}
}
}

private struct ProviderCostContent: View {
let section: UsageMenuCardView.Model.ProviderCostSection
let progressColor: Color
Expand Down Expand Up @@ -747,39 +769,7 @@ struct UsageMenuCardCostSectionView: View {
if hasTokenCost {
VStack(alignment: .leading, spacing: 10) {
if let tokenUsage = liveModel.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
Text(L("cost_header_estimated"))
.font(.body)
.fontWeight(.medium)
Text(tokenUsage.sessionLine)
.font(.caption)
.lineLimit(1)
Text(tokenUsage.monthLine)
.font(.caption)
.lineLimit(1)
ForEach(tokenUsage.comparisonLines, id: \.self) { line in
Text(line)
.font(.caption)
.lineLimit(1)
}
if let hint = tokenUsage.hintLine, !hint.isEmpty {
Text(hint)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
if let error = tokenUsage.errorLine, !error.isEmpty {
Text(error)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.error(self.isHighlighted))
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
.overlay {
ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error)
}
}
}
TokenUsageSectionContent(tokenUsage: tokenUsage, lineFont: .caption)
}
}
.padding(.horizontal, UsageMenuCardLayout.horizontalPadding)
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/PreferencesMenuPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ struct CostSummarySettingsSection: View {
Text(L("cost_auto_refresh_info"))
self.costStatusLine(provider: .claude)
self.costStatusLine(provider: .codex)
self.costStatusLine(provider: .cursor)
}
}
}
Expand All @@ -133,7 +134,7 @@ struct CostSummarySettingsSection: View {
private func costStatusLine(provider: UsageProvider) -> Text {
let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName

guard provider == .claude || provider == .codex else {
guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else {
return Text(String(format: L("cost_status_unsupported"), name))
}

Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/StatusItemController+CostMenuCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ extension StatusItemController {
let lines = [
tokenUsage?.sessionLine,
tokenUsage?.monthLine,
tokenUsage?.meteredLine,
]
.compactMap(\.self)
+ (tokenUsage?.comparisonLines ?? [])
Expand All @@ -106,6 +107,7 @@ extension StatusItemController {
let primaryLines = ([
tokenUsage?.sessionLine,
tokenUsage?.monthLine,
tokenUsage?.meteredLine,
]
.compactMap(\.self)
+ (tokenUsage?.comparisonLines ?? [])
Expand Down
12 changes: 12 additions & 0 deletions Sources/CodexBar/UsageStore+TokenCost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ extension UsageStore {
}
}

/// Fingerprint of the Cursor account behind auto/cached cookie resolution, derived from the
/// latest status snapshot's identity (via an in-process hash, never the raw email). Used in the
/// cost cache scope so a silent account switch invalidates the TTL.
nonisolated static func cursorAutoIdentityFingerprint(_ snapshot: UsageSnapshot?) -> String {
guard let email = snapshot?.identity?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines),
!email.isEmpty
else {
return "none"
}
return "\(email.lowercased().hashValue)"
}

nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool {
switch provider {
case .mistral, .openai:
Expand Down
69 changes: 49 additions & 20 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1426,13 +1426,22 @@ extension UsageStore {
}
}

func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async {
guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else {
self.tokenSnapshots.removeValue(forKey: provider)
self.tokenErrors[provider] = nil
self.tokenFailureGates[provider]?.reset()
/// Clears cached cost/token state for a provider so an ineligible or disabled provider stops
/// surfacing stale data. Pass `resetFetchMarkers: false` to keep the TTL fetch markers (used
/// when a provider snapshot simply lacks token data for this cycle).
private func resetTokenState(for provider: UsageProvider, resetFetchMarkers: Bool = true) {
self.tokenSnapshots.removeValue(forKey: provider)
self.tokenErrors[provider] = nil
self.tokenFailureGates[provider]?.reset()
if resetFetchMarkers {
self.lastTokenFetchAt.removeValue(forKey: provider)
self.lastTokenFetchScope.removeValue(forKey: provider)
}
}

func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async {
guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else {
self.resetTokenState(for: provider)
return
}

Expand All @@ -1443,37 +1452,56 @@ extension UsageStore {
self.tokenFailureGates[provider]?.recordSuccess()
self.persistWidgetSnapshot(reason: "token-usage")
} else {
self.tokenSnapshots.removeValue(forKey: provider)
self.tokenErrors[provider] = nil
self.tokenFailureGates[provider]?.reset()
self.resetTokenState(for: provider, resetFetchMarkers: false)
}
return
}

guard self.settings.costUsageEnabled else {
self.tokenSnapshots.removeValue(forKey: provider)
self.tokenErrors[provider] = nil
self.tokenFailureGates[provider]?.reset()
self.lastTokenFetchAt.removeValue(forKey: provider)
self.lastTokenFetchScope.removeValue(forKey: provider)
self.resetTokenState(for: provider)
return
}

guard self.isEnabled(provider) else {
self.tokenSnapshots.removeValue(forKey: provider)
self.tokenErrors[provider] = nil
self.tokenFailureGates[provider]?.reset()
self.lastTokenFetchAt.removeValue(forKey: provider)
self.lastTokenFetchScope.removeValue(forKey: provider)
self.resetTokenState(for: provider)
return
}

// Cursor cost honors the same cookie policy as status: when the user set the cookie source
// to Off, skip the network fetch entirely (mirrors CursorProviderDescriptor.checkStatus).
if provider == .cursor, self.settings.cursorCookieSource == .off {
self.resetTokenState(for: provider)
return
}

guard !self.tokenRefreshInFlight.contains(provider) else { return }

let now = Date()
let historyDays = self.settings.costUsageHistoryDays
// Cursor cost reuses the status cookie policy: a Manual source forwards the manual header so
// cost and status share the same session; other sources fall back to auto resolution.
let cursorCookieSource = self.settings.cursorCookieSource
let cursorCookieHeaderOverride: String? = provider == .cursor && cursorCookieSource == .manual
? CookieHeaderNormalizer.normalize(self.settings.cursorCookieHeader)
: nil
let costScope = self.tokenCostScope(for: provider)
let costScopeSignature = "\(costScope.signature)|historyDays=\(historyDays)"
// Fold the manual cookie into the cache key (via an in-process hash, never the raw header) so
// pasting a different Cursor cookie invalidates a snapshot fetched within the TTL instead of
// showing the previous account's data.
let cursorScopeSuffix = if provider != .cursor {
""
} else if let override = cursorCookieHeaderOverride {
"|cursorCookie=manual:\(override.hashValue)"
} else {
// Auto/cached resolution can silently switch Cursor accounts (e.g. the old cookie is
// rejected and a status refresh imports a new browser session). Fold the resolved
// account identity into the scope so an account change invalidates the TTL instead of
// returning the previous account's cost snapshot.
"|cursorCookie=\(cursorCookieSource.rawValue):" +
Self.cursorAutoIdentityFingerprint(self.snapshots[.cursor])
}
let costScopeSignature =
"\(costScope.signature)|historyDays=\(historyDays)\(cursorScopeSuffix)"
if !force,
let last = self.lastTokenFetchAt[provider],
self.lastTokenFetchScope[provider] == costScopeSignature,
Expand Down Expand Up @@ -1524,7 +1552,8 @@ extension UsageStore {
forceRefresh: force,
allowVertexClaudeFallback: !self.isEnabled(.claude),
codexHomePath: costScope.codexHomePath,
historyDays: historyDays)
historyDays: historyDays,
cursorCookieHeaderOverride: cursorCookieHeaderOverride)
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
Expand Down
Loading