Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
6343b1a
Merge remote-tracking branch 'origin/main' into feat/cursor-token-cost
EClinick Jul 11, 2026
413e91f
Merge remote-tracking branch 'origin/main' into feat/cursor-token-cost
steipete Jul 17, 2026
c0f0ac1
Merge remote-tracking branch 'origin/main' into feat/cursor-token-cost
steipete Jul 17, 2026
2a421d6
Merge remote-tracking branch 'origin/main' into feat/cursor-token-cost
steipete Jul 17, 2026
a0365e2
Merge remote-tracking branch 'origin/main' into feat/cursor-token-cost
steipete Jul 17, 2026
d8e8696
Merge remote-tracking branch 'origin/main' into feat/cursor-token-cost
steipete Jul 17, 2026
6be538d
Merge remote-tracking branch 'origin/main' into feat/cursor-token-cost
steipete Jul 17, 2026
3eac7e2
Merge remote-tracking branch 'origin/main' into feat/cursor-token-cost
steipete Jul 17, 2026
275626f
test: include Cursor in spend dashboard contract
steipete Jul 17, 2026
4ba8cb1
Merge remote-tracking branch 'origin/main' into feat/cursor-token-cost
steipete Jul 17, 2026
5a1a873
Merge remote-tracking branch 'origin/main' into feat/cursor-token-cost
steipete Jul 17, 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
63 changes: 40 additions & 23 deletions Sources/CodexBar/InlineUsageDashboardContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,10 @@ 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
!tokenSnapshot.daily.isEmpty || tokenSnapshot.meteredCostUSD != nil
{
return Self.costHistoryInlineDashboard(
provider: input.provider,
Expand Down Expand Up @@ -425,26 +425,35 @@ extension UsageMenuCardView.Model {
} else {
"\(providerName) \(periodLabel) cost trend"
}
var kpis = [
InlineUsageDashboardModel.KPI(
title: provider == .codex
? "\(L("Today")) · \(L("codex_api_estimate_header"))"
: usesLatestPrimary ? L("Latest") : L("Today"),
value: primaryCostUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—",
emphasis: true),
.init(
title: historyTitle,
value: snapshot.last30DaysCostUSD
.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—",
emphasis: false),
.init(
title: tokenHistoryTitle,
value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—",
emphasis: false),
] + Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest)
if provider == .cursor, let meteredCostUSD = snapshot.meteredCostUSD {
kpis.insert(
.init(
title: "Cursor-metered",
value: Self.costString(meteredCostUSD, currencyCode: snapshot.currencyCode),
emphasis: true),
at: 0)
}
var model = InlineUsageDashboardModel(
accessibilityLabel: accessibilityLabel,
valueStyle: Self.costValueStyle(currencyCode: snapshot.currencyCode),
kpis: [
.init(
title: provider == .codex
? "\(L("Today")) · \(L("codex_api_estimate_header"))"
: usesLatestPrimary ? L("Latest") : L("Today"),
value: primaryCostUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—",
emphasis: true),
.init(
title: historyTitle,
value: snapshot.last30DaysCostUSD
.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—",
emphasis: false),
.init(
title: tokenHistoryTitle,
value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—",
emphasis: false),
] + Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest),
kpis: kpis,
points: points,
detailLines: details)
model.currencyCode = snapshot.currencyCode
Expand Down Expand Up @@ -707,7 +716,9 @@ extension UsageMenuCardView.Model {
}
}
return tokens.max {
if $0.value == $1.value { return $0.key > $1.key }
if $0.value == $1.value {
return $0.key > $1.key
}
return $0.value < $1.value
}?.key
}
Expand All @@ -720,7 +731,9 @@ extension UsageMenuCardView.Model {
}
}
return tokens.max {
if $0.value == $1.value { return $0.key > $1.key }
if $0.value == $1.value {
return $0.key > $1.key
}
return $0.value < $1.value
}?.key
}
Expand All @@ -738,7 +751,9 @@ extension UsageMenuCardView.Model {
}

private static func costValueStyle(currencyCode: String) -> InlineUsageDashboardModel.ValueStyle {
if currencyCode == "USD" { return .currencyUSD }
if currencyCode == "USD" {
return .currencyUSD
}
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currencyCode
Expand Down Expand Up @@ -770,7 +785,9 @@ extension UsageMenuCardView.Model {
}
}
return scores.max {
if $0.value.cost == $1.value.cost { return $0.value.tokens < $1.value.tokens }
if $0.value.cost == $1.value.cost {
return $0.value.tokens < $1.value.tokens
}
return $0.value.cost < $1.value.cost
}?.key
}
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: 8 additions & 1 deletion Sources/CodexBar/MenuCardView+Costs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,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 @@ -194,7 +201,7 @@ extension UsageMenuCardView.Model {
L("codex_api_estimate_not_billed"),
L("codex_api_estimate_hint"),
]
case .claude:
case .claude, .cursor:
[UsageFormatter.costEstimateHint(provider: provider)]
case .vertexai:
[L("cost_estimate_hint")]
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 @@ -253,6 +253,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
129 changes: 63 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,10 @@ struct UsageMenuCardView: View {
Divider()
}
if let tokenUsage = liveModel.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
Text(UsageMenuCardView.Model.tokenUsageHeader(provider: liveModel.provider))
.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(
provider: liveModel.provider,
tokenUsage: tokenUsage,
lineFont: .footnote)
}
}
}
Expand Down Expand Up @@ -428,6 +404,56 @@ 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 provider: UsageProvider
let tokenUsage: UsageMenuCardView.Model.TokenUsageSection
let lineFont: Font
@Environment(\.menuItemHighlighted) private var isHighlighted

var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(UsageMenuCardView.Model.tokenUsageHeader(provider: self.provider))
.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 @@ -757,39 +783,10 @@ struct UsageMenuCardCostSectionView: View {
if hasTokenCost {
VStack(alignment: .leading, spacing: 10) {
if let tokenUsage = liveModel.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
Text(UsageMenuCardView.Model.tokenUsageHeader(provider: liveModel.provider))
.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(
provider: liveModel.provider,
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 @@ -126,6 +126,7 @@ struct CostSummarySettingsSection: View {
Text(L("cost_auto_refresh_info"))
self.costStatusLine(provider: .claude)
self.costStatusLine(provider: .codex)
self.costStatusLine(provider: .cursor)
}
}
}
Expand All @@ -135,7 +136,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 @@ -102,6 +102,7 @@ extension StatusItemController {
let lines = [
tokenUsage?.sessionLine,
tokenUsage?.monthLine,
tokenUsage?.meteredLine,
]
.compactMap(\.self)
+ (tokenUsage?.comparisonLines ?? [])
Expand All @@ -127,6 +128,7 @@ extension StatusItemController {
let primaryLines = ([
tokenUsage?.sessionLine,
tokenUsage?.monthLine,
tokenUsage?.meteredLine,
]
.compactMap(\.self)
+ (tokenUsage?.comparisonLines ?? [])
Expand Down
Loading