Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
22 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
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 @@ -202,7 +202,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.tokenCostUsageEnabled,
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("hint", self.hintLine),
MenuCardHeightFingerprint.field("error", self.errorLine),
MenuCardHeightFingerprint.field("errorCopy", self.errorCopyText),
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 @@ -107,10 +107,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,
hintLine: Self.tokenUsageHint(provider: provider),
errorLine: err,
errorCopyText: (error?.isEmpty ?? true) ? nil : error)
Expand All @@ -122,6 +129,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
3 changes: 2 additions & 1 deletion Sources/CodexBar/MenuCardView+ModelHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ extension UsageMenuCardView.Model {
true
case let (current?, candidate?):
current.hintLine == candidate.hintLine &&
current.errorLine == candidate.errorLine
current.errorLine == candidate.errorLine &&
(current.meteredLine == nil) == (candidate.meteredLine == nil)
default:
false
}
Expand Down
29 changes: 29 additions & 0 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,28 @@ struct UsageMenuCardView: View {
struct TokenUsageSection {
let sessionLine: String
let monthLine: String
let meteredLine: String?
Comment thread
EClinick marked this conversation as resolved.
let hintLine: String?
let errorLine: String?
let errorCopyText: String?

/// Explicit initializer so `meteredLine` defaults to nil: callers that predate the
/// Cursor-metered line (and providers that never report it) keep their call sites.
init(
sessionLine: String,
monthLine: String,
meteredLine: String? = nil,
hintLine: String?,
errorLine: String?,
errorCopyText: String?)
{
self.sessionLine = sessionLine
self.monthLine = monthLine
self.meteredLine = meteredLine
self.hintLine = hintLine
self.errorLine = errorLine
self.errorCopyText = errorCopyText
}
}

struct ProviderCostSection {
Expand Down Expand Up @@ -197,6 +216,11 @@ struct UsageMenuCardView: View {
Text(tokenUsage.monthLine)
.font(.footnote)
.lineLimit(1)
if let metered = tokenUsage.meteredLine, !metered.isEmpty {
Text(metered)
.font(.footnote)
.lineLimit(1)
}
if let hint = tokenUsage.hintLine, !hint.isEmpty {
Text(hint)
.font(.footnote)
Expand Down Expand Up @@ -712,6 +736,11 @@ struct UsageMenuCardCostSectionView: View {
Text(tokenUsage.monthLine)
.font(.caption)
.lineLimit(1)
if let metered = tokenUsage.meteredLine, !metered.isEmpty {
Text(metered)
.font(.caption)
.lineLimit(1)
}
if let hint = tokenUsage.hintLine, !hint.isEmpty {
Text(hint)
.font(.footnote)
Expand Down
5 changes: 4 additions & 1 deletion Sources/CodexBar/PreferencesGeneralPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ struct GeneralPane: View {

self.costStatusLine(provider: .claude)
self.costStatusLine(provider: .codex)
self.costStatusLine(provider: .cursor)
}
}
}
Expand Down Expand Up @@ -224,7 +225,9 @@ struct GeneralPane: View {
private func costStatusLine(provider: UsageProvider) -> some View {
let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName

guard provider == .claude || provider == .codex else {
// Any provider whose descriptor reports token-cost support gets a real status line; only
// providers that genuinely cannot report cost fall through to "unsupported".
guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else {
return Text(String(format: L("cost_status_unsupported"), name))
.font(.footnote)
.foregroundStyle(.tertiary)
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 {
[
tokenUsage?.sessionLine,
tokenUsage?.monthLine,
tokenUsage?.meteredLine,
tokenUsage?.hintLine,
tokenUsage?.errorLine,
]
Expand All @@ -106,6 +107,7 @@ extension StatusItemController {
let primaryLines = [
tokenUsage?.sessionLine,
tokenUsage?.monthLine,
tokenUsage?.meteredLine,
tokenUsage?.errorLine,
]
.compactMap(\.self)
Expand Down
24 changes: 22 additions & 2 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1489,12 +1489,31 @@ extension UsageStore {
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.tokenSnapshots.removeValue(forKey: provider)
self.tokenErrors[provider] = nil
self.tokenFailureGates[provider]?.reset()
self.lastTokenFetchAt.removeValue(forKey: provider)
self.lastTokenFetchScope.removeValue(forKey: 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)"
let cursorScopeSuffix = provider == .cursor ? "|cursorCookie=\(cursorCookieSource.rawValue)" : ""
let costScopeSignature =
"\(costScope.signature)|historyDays=\(historyDays)\(cursorScopeSuffix)"
Comment thread
EClinick marked this conversation as resolved.
Outdated
if !force,
let last = self.lastTokenFetchAt[provider],
self.lastTokenFetchScope[provider] == costScopeSignature,
Expand Down Expand Up @@ -1545,7 +1564,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
65 changes: 60 additions & 5 deletions Sources/CodexBarCLI/CLICostCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ import Commander
import Foundation

extension CodexBarCLI {
private static let costSupportedProviders: Set<UsageProvider> = [.claude, .codex]
private static let costSupportedProviders: Set<UsageProvider> = {
#if os(macOS)
[.claude, .codex, .cursor]
#else
// Cursor cost relies on the macOS-only dashboard fetch path; `supportsTokenSnapshot(.cursor)`
// is false elsewhere, so don't advertise Cursor cost where it can only fail.
[.claude, .codex]
#endif
}()

static func runCost(_ values: ParsedValues) async {
let output = CLIOutputPreferences.from(values: values)
Expand All @@ -21,9 +29,13 @@ extension CodexBarCLI {
}
}
guard !providers.isEmpty else {
let supportedNames = Self.costSupportedProviders
.map { ProviderDescriptorRegistry.descriptor(for: $0).metadata.displayName }
.sorted()
.joined(separator: ", ")
Self.exit(
code: .failure,
message: "Error: cost is only supported for Claude and Codex.",
message: "Error: cost is only supported for \(supportedNames).",
output: output,
kind: .args)
}
Expand All @@ -32,19 +44,35 @@ extension CodexBarCLI {
let forceRefresh = values.flags.contains("refresh")
let useColor = Self.shouldUseColor(noColor: values.flags.contains("noColor"), format: format)
let historyDays = Self.decodeCostHistoryDays(from: values)
// Cursor cost reuses the same cookie-source policy as usage fetches: skip the fetch when the
// user set Cursor cookies to Off, and forward the Manual header so the dashboard request uses
// the configured session instead of auto-resolving a different one.
let cursorCookieSettings = Self.cursorCookieSettings(config: config, providers: providers)

let fetcher = CostUsageFetcher()
var sections: [String] = []
var payload: [CostPayload] = []
var exitCode: ExitCode = .success

for provider in providers {
if provider == .cursor, cursorCookieSettings?.cookieSource == .off {
if !output.jsonOnly {
Self.writeStderr("Cursor cost skipped: cookie source is set to Off.\n")
}
continue
}
do {
// Cost usage is local-only; it does not require web/CLI provider fetches.
let cursorCookieHeaderOverride: String? =
provider == .cursor && cursorCookieSettings?.cookieSource == .manual
? CookieHeaderNormalizer.normalize(cursorCookieSettings?.manualCookieHeader)
: nil
// Claude/Codex cost comes from local logs; Cursor cost is fetched from its
// cookie-authenticated dashboard API via the shared session resolution.
let snapshot = try await fetcher.loadTokenSnapshot(
provider: provider,
forceRefresh: forceRefresh,
historyDays: historyDays,
cursorCookieHeaderOverride: cursorCookieHeaderOverride,
refreshPricingInBackground: false)
switch format {
case .text:
Expand Down Expand Up @@ -98,8 +126,17 @@ extension CodexBarCLI {
"\(historyLabel): \(monthCost) · \($0) tokens"
} ?? "\(historyLabel): \(monthCost)"

// Plan-metered spend over the same window (what Cursor actually deducts), shown
// alongside the API-rate estimate. Only providers like Cursor report it.
let meteredLine: String? = snapshot.meteredCostUSD.map {
let amount = UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode)
return "Cursor-metered: \(amount) (\(historyLabel.lowercased()))"
}

let hintLine = UsageFormatter.costEstimateHint(provider: provider)
return [header, todayLine, monthLine, hintLine].joined(separator: "\n")
return [header, todayLine, monthLine, meteredLine, hintLine]
.compactMap(\.self)
.joined(separator: "\n")
}

private static func costHeaderLine(_ header: String, useColor: Bool) -> String {
Expand Down Expand Up @@ -136,14 +173,15 @@ extension CodexBarCLI {

return CostPayload(
provider: provider.rawValue,
source: "local",
source: provider == .cursor ? "web" : "local",
updatedAt: snapshot?.updatedAt ?? (error == nil ? nil : Date()),
currencyCode: snapshot?.currencyCode,
sessionTokens: snapshot?.sessionTokens,
sessionCostUSD: snapshot?.sessionCostUSD,
historyDays: snapshot?.historyDays,
last30DaysTokens: snapshot?.last30DaysTokens,
last30DaysCostUSD: snapshot?.last30DaysCostUSD,
meteredCostUSD: snapshot?.meteredCostUSD,
daily: daily,
totals: snapshot.flatMap(Self.costTotals(from:)),
error: error.map { Self.makeErrorPayload($0) })
Expand Down Expand Up @@ -218,6 +256,20 @@ extension CodexBarCLI {
else { return 30 }
return max(1, min(365, parsed))
}

/// Resolve the configured Cursor cookie settings (source + manual header) the same way the CLI
/// usage path does, so Cursor cost honors Off/Manual instead of always auto-resolving a session.
private static func cursorCookieSettings(
config: CodexBarConfig,
providers: [UsageProvider]) -> ProviderSettingsSnapshot.CursorProviderSettings?
{
guard providers.contains(.cursor) else { return nil }
let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false)
guard let context = try? TokenAccountCLIContext(selection: selection, config: config, verbose: false)
else { return nil }
let account = (try? context.resolvedAccounts(for: .cursor))?.first
return context.settingsSnapshot(for: .cursor, account: account)?.cursor
}
}

struct CostOptions: CommanderParsable {
Expand Down Expand Up @@ -267,6 +319,7 @@ struct CostPayload: Encodable {
let historyDays: Int?
let last30DaysTokens: Int?
let last30DaysCostUSD: Double?
let meteredCostUSD: Double?
let daily: [CostDailyEntryPayload]
let totals: CostTotalsPayload?
let error: ProviderErrorPayload?
Expand All @@ -281,6 +334,7 @@ struct CostPayload: Encodable {
historyDays: Int?,
last30DaysTokens: Int?,
last30DaysCostUSD: Double?,
meteredCostUSD: Double? = nil,
daily: [CostDailyEntryPayload],
totals: CostTotalsPayload?,
error: ProviderErrorPayload?)
Expand All @@ -294,6 +348,7 @@ struct CostPayload: Encodable {
self.historyDays = historyDays
self.last30DaysTokens = last30DaysTokens
self.last30DaysCostUSD = last30DaysCostUSD
self.meteredCostUSD = meteredCostUSD
self.daily = daily
self.totals = totals
self.error = error
Expand Down
Loading