From 8909956edbb36e8dcc08673c9bf869c1f37ea721 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:54:11 +0800 Subject: [PATCH 1/6] feat: multi-currency cost display with live exchange rates (Issue #2449) - Add CurrencyExchange service with cross-currency conversion via open.er-api.com - 24h rate caching with hardcoded fallbacks for offline use - UsageFormatter: smart conversion methods (auto-follow provider vs explicit currency) - Settings: preferredCurrencyCode preference with Picker UI - Thread preferredCurrency through providerCostSection, ampCreditsLine, sakanaPayAsYouGoSection - Update MenuDescriptor, MenuCardView, MenuBarLayout cost paths - Add zh-Hans localization for currency_auto - Trigger rate fetch on app launch and settings change - 43 tests pass including cross-currency conversion tests --- Sources/CodexBar/CodexbarApp.swift | 3 + Sources/CodexBar/MenuCardView+Costs.swift | 77 ++++++---- Sources/CodexBar/MenuCardView+Kiro.swift | 2 +- .../CodexBar/MenuCardView+ModelHelpers.swift | 7 +- .../CodexBar/MenuCardView+ModelInput.swift | 5 +- Sources/CodexBar/MenuCardView.swift | 13 +- .../MenuDescriptor+ProviderUsage.swift | 36 +++-- Sources/CodexBar/MenuDescriptor.swift | 14 +- Sources/CodexBar/PreferencesGeneralPane.swift | 48 ++++++ .../Resources/en.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 3 + Sources/CodexBar/SettingsStore+Defaults.swift | 8 + Sources/CodexBar/SettingsStore.swift | 4 +- Sources/CodexBar/SettingsStoreState.swift | 1 + .../StatusItemController+Animation.swift | 2 +- .../StatusItemController+MenuBarLayout.swift | 11 +- .../StatusItemController+MenuCardModel.swift | 1 + Sources/CodexBarCore/CurrencyExchange.swift | 144 ++++++++++++++++++ Sources/CodexBarCore/UsageFormatter.swift | 47 ++++++ Tests/CodexBarTests/UsageFormatterTests.swift | 37 +++++ 20 files changed, 400 insertions(+), 66 deletions(-) create mode 100644 Sources/CodexBarCore/CurrencyExchange.swift diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 64b5755a77..d761a2ac58 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -422,6 +422,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let settings = self?.settings else { return } AdaptiveActivityConsentPresenter.presentIfNeeded(settings: settings) AppNotifications.shared.requestAuthorizationOnStartup() + // Prefetch exchange rates at launch so conversion is instant when the user + // switches to a non-USD currency. Uses a 24 h cache so subsequent calls are no-ops. + await CurrencyExchange.shared.fetchLatestRatesIfNeeded() } KeyboardShortcuts.onKeyUp(for: .openMenu) { [weak self] in // KeyboardShortcuts dispatches both normal and menu-tracking hotkeys on the main event loop. diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 12312714f3..224076e803 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -90,13 +90,16 @@ extension UsageMenuCardView.Model.ProviderCostSection { } extension UsageMenuCardView.Model { - static func sakanaPayAsYouGoSection(_ usage: SakanaPayAsYouGoSnapshot?) -> ProviderCostSection? { + static func sakanaPayAsYouGoSection(_ usage: SakanaPayAsYouGoSnapshot?, + preferredCurrencyCode: String = "auto") -> ProviderCostSection? { guard let usage else { return nil } return ProviderCostSection( title: L("Extra usage"), percentUsed: nil, spendLine: "\(L("Balance")): \(usage.balanceDetail)", - percentLine: usage.periodUsageTotal.map { "\(L("Usage")): \(UsageFormatter.usdString($0))" }) + percentLine: usage.periodUsageTotal.map { + "\(L("Usage")): \(UsageFormatter.convertedCostString($0, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))" + }) } static func isRequiredOpenCodeZenBalance(_ snapshot: UsageSnapshot?) -> Bool { @@ -116,13 +119,14 @@ extension UsageMenuCardView.Model { metadata: ProviderMetadata, snapshot: UsageSnapshot?, credits: CreditsSnapshot?, - error: String?) -> String? + error: String?, + preferredCurrencyCode: String = "auto") -> String? { guard metadata.supportsCredits else { return nil } if metadata.id == .codex, credits == nil, error == nil { return nil } if metadata.id == .amp, let ampUsage = snapshot?.ampUsage, - let ampCredits = self.ampCreditsLine(ampUsage) + let ampCredits = self.ampCreditsLine(ampUsage, preferredCurrencyCode: preferredCurrencyCode) { return ampCredits } @@ -158,15 +162,16 @@ extension UsageMenuCardView.Model { return parts.joined(separator: " · ") } - private static func ampCreditsLine(_ usage: AmpUsageDetails) -> String? { + private static func ampCreditsLine(_ usage: AmpUsageDetails, + preferredCurrencyCode: String = "auto") -> String? { var lines: [String] = [] if let individualCredits = usage.individualCredits { lines.append( - "\(L("Individual credits")): \(UsageFormatter.currencyString(individualCredits, currencyCode: "USD"))") + "\(L("Individual credits")): \(UsageFormatter.convertedCostString(individualCredits, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))") } lines.append(contentsOf: usage.workspaceBalances.map { workspace in "\(L("Workspace")) \(workspace.name): " + - UsageFormatter.currencyString(workspace.remaining, currencyCode: "USD") + UsageFormatter.convertedCostString(workspace.remaining, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") }) return lines.isEmpty ? nil : lines.joined(separator: "\n") } @@ -176,7 +181,8 @@ extension UsageMenuCardView.Model { enabled: Bool, comparisonPeriodsEnabled: Bool, snapshot: CostUsageTokenSnapshot?, - error: String?) -> TokenUsageSection? + error: String?, + preferredCurrencyCode: String = "auto") -> TokenUsageSection? { guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { return nil @@ -184,8 +190,12 @@ extension UsageMenuCardView.Model { guard enabled else { return nil } guard let snapshot else { return nil } + let effectiveCurrencyCode = preferredCurrencyCode != "auto" && !preferredCurrencyCode.isEmpty + ? preferredCurrencyCode + : snapshot.currencyCode + let sessionCost = snapshot.sessionCostUSD.map { - UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + UsageFormatter.convertedCostString($0, targetCurrency: effectiveCurrencyCode) } ?? "—" let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let sessionLabel = if provider == .bedrock || provider == .mistral { @@ -201,7 +211,7 @@ extension UsageMenuCardView.Model { }() let monthCost = snapshot.last30DaysCostUSD.map { - UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + UsageFormatter.convertedCostString($0, targetCurrency: effectiveCurrencyCode) } ?? "—" let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) @@ -225,7 +235,7 @@ extension UsageMenuCardView.Model { // 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) + let amount = UsageFormatter.convertedCostString($0, targetCurrency: effectiveCurrencyCode) return String(format: L("Cursor-metered: %@ (%@)"), amount, windowLabel.lowercased()) } let err = (error?.isEmpty ?? true) ? nil : error @@ -235,7 +245,7 @@ extension UsageMenuCardView.Model { meteredLine: meteredLine, comparisonLines: comparisonPeriodsEnabled ? snapshot.comparisonSummaries().map { - Self.costWindowLine(summary: $0, currencyCode: snapshot.currencyCode) + Self.costWindowLine(summary: $0, currencyCode: effectiveCurrencyCode) } : [], hintLine: Self.tokenUsageHint(provider: provider), @@ -246,7 +256,7 @@ extension UsageMenuCardView.Model { static func costWindowLine(summary: CostUsageWindowSummary, currencyCode: String) -> String { let label = Self.costHistoryWindowLabel(days: summary.days) let cost = summary.totalCostUSD.map { - UsageFormatter.currencyString($0, currencyCode: currencyCode) + UsageFormatter.convertedCostString($0, targetCurrency: currencyCode) } ?? "—" guard let totalTokens = summary.totalTokens else { return "\(label): \(cost)" } return String( @@ -374,7 +384,8 @@ extension UsageMenuCardView.Model { static func providerCostSection( provider: UsageProvider, cost: ProviderCostSnapshot?, - isClaudeAdminAPI: Bool = false) -> ProviderCostSection? + isClaudeAdminAPI: Bool = false, + preferredCurrencyCode: String = "auto") -> ProviderCostSection? { if provider == .manus { return nil @@ -382,8 +393,16 @@ extension UsageMenuCardView.Model { guard let cost else { return nil } guard provider != .synthetic else { return nil } + /// Formats a cost value using the user's currency preference. + func formatCost(_ value: Double, providerCurrency: String? = nil) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: providerCurrency ?? cost.currencyCode) + } + if provider == .factory || provider == .devin, cost.period == "Extra usage balance" { - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let balance = formatCost(cost.used) return ProviderCostSection( title: L("Extra usage"), percentUsed: nil, @@ -392,7 +411,7 @@ extension UsageMenuCardView.Model { } if provider == .opencodego, cost.period == "Zen balance" { - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let balance = formatCost(cost.used) return ProviderCostSection( title: L("Zen balance"), percentUsed: nil, @@ -410,7 +429,7 @@ extension UsageMenuCardView.Model { } if provider == .zenmux || provider == .neuralwatt { - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let balance = formatCost(cost.used) return ProviderCostSection( title: L("metric_mistral_payg"), percentUsed: nil, @@ -420,7 +439,7 @@ extension UsageMenuCardView.Model { if provider == .claude { if isClaudeAdminAPI { - let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let spend = formatCost(cost.used) let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") return ProviderCostSection( title: L("API spend"), @@ -431,7 +450,7 @@ extension UsageMenuCardView.Model { if cost.limit <= 0 { guard let balance = cost.balance else { return nil } - let value = UsageFormatter.currencyString(balance, currencyCode: cost.currencyCode) + let value = formatCost(balance) return ProviderCostSection( title: L("Credits"), percentUsed: nil, @@ -441,12 +460,12 @@ extension UsageMenuCardView.Model { showsInProviderDetails: false) } - let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + let used = formatCost(cost.used) + let limit = formatCost(cost.limit) let percentUsed = Self.clamped((cost.used / cost.limit) * 100) let periodLabel = Self.localizedPeriodLabel(cost.period ?? "This month") let balanceLine = cost.balance.map { - "\(L("Balance")): \(UsageFormatter.currencyString($0, currencyCode: cost.currencyCode))" + "\(L("Balance")): \(formatCost($0))" } return ProviderCostSection( title: L("Extra usage"), @@ -460,7 +479,7 @@ extension UsageMenuCardView.Model { if provider == .openai || provider == .litellm || provider == .aiand, cost.limit <= 0 { - let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let spend = formatCost(cost.used) let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") return ProviderCostSection( title: L("API spend"), @@ -474,7 +493,7 @@ extension UsageMenuCardView.Model { } if provider == .clawrouter, cost.limit <= 0 { - let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let spend = formatCost(cost.used) return ProviderCostSection( title: "ClawRouter spend", percentUsed: nil, @@ -490,16 +509,16 @@ extension UsageMenuCardView.Model { if provider == .clawrouter { title = "Monthly budget" - used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + used = formatCost(cost.used) + limit = formatCost(cost.limit) } else if cost.currencyCode == "Quota" { title = L("Quota usage") used = String(format: "%.0f", cost.used) limit = String(format: "%.0f", cost.limit) } else { title = L("Extra usage") - used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + used = formatCost(cost.used) + limit = formatCost(cost.limit) } let percentUsed = Self.clamped((cost.used / cost.limit) * 100) @@ -509,7 +528,7 @@ extension UsageMenuCardView.Model { // account's own contribution underneath it. let personalSpendLine: String? = cost.personalUsed.flatMap { personal in personal > 0 - ? "\(L("Your spend")): \(UsageFormatter.currencyString(personal, currencyCode: cost.currencyCode))" + ? "\(L("Your spend")): \(formatCost(personal))" : nil } diff --git a/Sources/CodexBar/MenuCardView+Kiro.swift b/Sources/CodexBar/MenuCardView+Kiro.swift index f9f61a9c03..c2d83b5be1 100644 --- a/Sources/CodexBar/MenuCardView+Kiro.swift +++ b/Sources/CodexBar/MenuCardView+Kiro.swift @@ -29,7 +29,7 @@ extension UsageMenuCardView.Model { if overagesEnabled, let estimatedOverageCostUSD = input.snapshot?.kiroUsage?.estimatedOverageCostUSD { - notes.append("\(L("Overage cost")): \(UsageFormatter.usdString(estimatedOverageCostUSD))") + notes.append("\(L("Overage cost")): \(UsageFormatter.convertedCostString(estimatedOverageCostUSD, preferredCurrency: input.preferredCurrencyCode, providerCurrency: "USD"))") } return notes } diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index e57091fd52..7beb361687 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -868,7 +868,8 @@ extension UsageMenuCardView.Model { return nil } - static func openRouterQuotaDetail(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { + static func openRouterQuotaDetail(provider: UsageProvider, snapshot: UsageSnapshot, + preferredCurrencyCode: String = "auto") -> String? { guard provider == .openrouter, let usage = snapshot.openRouterUsage, usage.hasValidKeyQuota, @@ -878,8 +879,8 @@ extension UsageMenuCardView.Model { return nil } - let remaining = UsageFormatter.usdString(keyRemaining) - let limit = UsageFormatter.usdString(keyLimit) + let remaining = UsageFormatter.convertedCostString(keyRemaining, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let limit = UsageFormatter.convertedCostString(keyLimit, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") return String(format: L("%@/%@ left"), remaining, limit) } diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift index 12d5ab9fbe..b3f75642ec 100644 --- a/Sources/CodexBar/MenuCardView+ModelInput.swift +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -38,6 +38,7 @@ extension UsageMenuCardView.Model { let quotaWarningThresholds: [QuotaWarningWindow: [Int]] let workDaysPerWeek: Int? let usesLiveSubtitle: Bool + let preferredCurrencyCode: String let now: Date init( @@ -76,6 +77,7 @@ extension UsageMenuCardView.Model { quotaWarningThresholds: [QuotaWarningWindow: [Int]] = [:], workDaysPerWeek: Int? = nil, usesLiveSubtitle: Bool = false, + preferredCurrencyCode: String = "auto", now: Date) { self.provider = provider @@ -99,7 +101,7 @@ extension UsageMenuCardView.Model { self.tokenCostUsageEnabled = tokenCostUsageEnabled self.codexLocalSessionCostLedgerEnabled = codexLocalSessionCostLedgerEnabled self.tokenCostInlineDashboardEnabled = tokenCostInlineDashboardEnabled ?? tokenCostUsageEnabled - self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? tokenCostUsageEnabled + self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? (tokenCostUsageEnabled && snapshot != nil) self.costComparisonPeriodsEnabled = costComparisonPeriodsEnabled self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage self.claudeDailyRoutinesUsageVisible = claudeDailyRoutinesUsageVisible @@ -113,6 +115,7 @@ extension UsageMenuCardView.Model { self.quotaWarningThresholds = quotaWarningThresholds self.workDaysPerWeek = workDaysPerWeek self.usesLiveSubtitle = usesLiveSubtitle + self.preferredCurrencyCode = preferredCurrencyCode self.now = now } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 62e05813fc..27c5109db2 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -895,7 +895,8 @@ extension UsageMenuCardView.Model { metadata: input.metadata, snapshot: input.snapshot, credits: input.credits, - error: input.creditsError) + error: input.creditsError, + preferredCurrencyCode: input.preferredCurrencyCode) } let creditsText = PersonalInfoRedactor.redactEmails(in: rawCreditsText, isEnabled: input.hidePersonalInfo) let creditsProgressPercent = Self.creditsProgressPercent(credits: input.credits) @@ -911,7 +912,7 @@ extension UsageMenuCardView.Model { !input.showOptionalCreditsAndExtraUsage let providerCost: ProviderCostSection? = if input.provider == .sakana { input.showOptionalCreditsAndExtraUsage - ? Self.sakanaPayAsYouGoSection(input.snapshot?.sakanaPayAsYouGo) + ? Self.sakanaPayAsYouGoSection(input.snapshot?.sakanaPayAsYouGo, preferredCurrencyCode: input.preferredCurrencyCode) : nil } else if hidesOptionalProviderCost || (input.provider == .openai && openAIAPIUsage != nil) @@ -921,7 +922,8 @@ extension UsageMenuCardView.Model { Self.providerCostSection( provider: input.provider, cost: input.snapshot?.providerCost, - isClaudeAdminAPI: isClaudeAdminAPI) + isClaudeAdminAPI: isClaudeAdminAPI, + preferredCurrencyCode: input.preferredCurrencyCode) } let tokenUsageSnapshot = Self.tokenUsageSnapshot(input: input) let tokenUsage = Self.tokenUsageSection( @@ -929,7 +931,8 @@ extension UsageMenuCardView.Model { enabled: input.tokenCostMenuSectionEnabled, comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled, snapshot: tokenUsageSnapshot, - error: input.tokenError) + error: input.tokenError, + preferredCurrencyCode: input.preferredCurrencyCode) let subtitle = Self.subtitle( snapshot: input.snapshot, isRefreshing: input.isRefreshing, @@ -1172,7 +1175,7 @@ extension UsageMenuCardView.Model { let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit) let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit) let zaiSessionDetail = Self.zaiLimitDetailText(limit: zaiUsage?.sessionTokenLimit) - let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot) + let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot, preferredCurrencyCode: input.preferredCurrencyCode) let labels = Self.rateWindowLabels(input: input, snapshot: snapshot) if input.provider == .mistral, let credits = snapshot.mistralUsage?.credits { metrics.append(Metric( diff --git a/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift b/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift index c3269c5a5c..d0ded07e8e 100644 --- a/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift +++ b/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift @@ -4,7 +4,8 @@ import Foundation extension MenuDescriptor { static func appendOpenAIAPIUsageSummary( entries: inout [Entry], - usage: OpenAIAPIUsageSnapshot) + usage: OpenAIAPIUsageSnapshot, + preferredCurrencyCode: String = "auto") { let today = usage.currentDay let last7 = usage.last7Days @@ -12,15 +13,15 @@ extension MenuDescriptor { let historyLabel = usage.historyWindowLabel entries.append(.text( - "\(L("Today")): \(UsageFormatter.usdString(today.costUSD)) · " + + "\(L("Today")): \(UsageFormatter.convertedCostString(today.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD")) · " + "\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))", .secondary)) entries.append(.text( - "7d: \(UsageFormatter.usdString(last7.costUSD)) · " + + "7d: \(UsageFormatter.convertedCostString(last7.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD")) · " + "\(UsageFormatter.tokenCountString(last7.requests)) \(L("requests"))", .secondary)) entries.append(.text( - "\(historyLabel): \(UsageFormatter.usdString(last30.costUSD)) · " + + "\(historyLabel): \(UsageFormatter.convertedCostString(last30.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD")) · " + "\(UsageFormatter.tokenCountString(last30.requests)) \(L("requests"))", .secondary)) if let topModel = usage.topModels.first?.name { @@ -30,22 +31,23 @@ extension MenuDescriptor { static func appendClaudeAdminAPIUsageSummary( entries: inout [Entry], - usage: ClaudeAdminAPIUsageSnapshot) + usage: ClaudeAdminAPIUsageSnapshot, + preferredCurrencyCode: String = "auto") { let today = usage.currentDay let last7 = usage.last7Days let last30 = usage.last30Days entries.append(.text( - "\(L("Today")): \(UsageFormatter.usdString(today.costUSD)) · " + + "\(L("Today")): \(UsageFormatter.convertedCostString(today.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD")) · " + "\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))", .secondary)) entries.append(.text( - "7d: \(UsageFormatter.usdString(last7.costUSD)) · " + + "7d: \(UsageFormatter.convertedCostString(last7.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD")) · " + "\(UsageFormatter.tokenCountString(last7.totalTokens)) \(L("tokens"))", .secondary)) entries.append(.text( - "30d: \(UsageFormatter.usdString(last30.costUSD)) · " + + "30d: \(UsageFormatter.convertedCostString(last30.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD")) · " + "\(UsageFormatter.tokenCountString(last30.totalTokens)) \(L("tokens"))", .secondary)) if let topModel = usage.topModels.first?.name { @@ -55,16 +57,17 @@ extension MenuDescriptor { static func appendOpenRouterUsageSummary( entries: inout [Entry], - usage: OpenRouterUsageSnapshot) + usage: OpenRouterUsageSnapshot, + preferredCurrencyCode: String = "auto") { if let daily = usage.keyUsageDaily { - entries.append(.text("\(L("Today")): \(UsageFormatter.usdString(daily))", .secondary)) + entries.append(.text("\(L("Today")): \(UsageFormatter.convertedCostString(daily, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))", .secondary)) } if let weekly = usage.keyUsageWeekly { - entries.append(.text("\(L("Week")): \(UsageFormatter.usdString(weekly))", .secondary)) + entries.append(.text("\(L("Week")): \(UsageFormatter.convertedCostString(weekly, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))", .secondary)) } if let monthly = usage.keyUsageMonthly { - entries.append(.text("\(L("Month")): \(UsageFormatter.usdString(monthly))", .secondary)) + entries.append(.text("\(L("Month")): \(UsageFormatter.convertedCostString(monthly, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))", .secondary)) } } @@ -91,14 +94,15 @@ extension MenuDescriptor { static func appendPoeUsageSummary( entries: inout [Entry], - usage: PoeUsageHistorySnapshot) + usage: PoeUsageHistorySnapshot, + preferredCurrencyCode: String = "auto") { let today = usage.currentDay() let week = usage.last7Days let month = usage.last30Days - let todayCostSuffix = today.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? "" - let weekCostSuffix = week.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? "" - let monthCostSuffix = month.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? "" + let todayCostSuffix = today.costUSD.map { " · \(UsageFormatter.convertedCostString($0, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))" } ?? "" + let weekCostSuffix = week.costUSD.map { " · \(UsageFormatter.convertedCostString($0, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))" } ?? "" + let monthCostSuffix = month.costUSD.map { " · \(UsageFormatter.convertedCostString($0, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))" } ?? "" entries.append(.text( "\(L("Today")): \(Self.pointsString(today.points)) · " + "\(UsageFormatter.tokenCountString(today.requests)) \(L("requests"))\(todayCostSuffix)", diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 09175f5f31..144898c625 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -326,7 +326,8 @@ struct MenuDescriptor { Self.appendProviderUsageSummaries( entries: &entries, snapshot: snap, - showOptionalUsage: settings.showOptionalCreditsAndExtraUsage) + showOptionalUsage: settings.showOptionalCreditsAndExtraUsage, + preferredCurrencyCode: settings.preferredCurrencyCode) if snap.rateLimitsUnavailable(for: provider) { entries.append(.text(L("Limits not available"), .secondary)) } @@ -353,7 +354,8 @@ struct MenuDescriptor { private static func appendProviderUsageSummaries( entries: inout [Entry], snapshot: UsageSnapshot, - showOptionalUsage: Bool) + showOptionalUsage: Bool, + preferredCurrencyCode: String = "auto") { if let cost = snapshot.providerCost { if cost.currencyCode == "Quota" { @@ -363,13 +365,13 @@ struct MenuDescriptor { } } if let openAIAPIUsage = snapshot.openAIAPIUsage { - Self.appendOpenAIAPIUsageSummary(entries: &entries, usage: openAIAPIUsage) + Self.appendOpenAIAPIUsageSummary(entries: &entries, usage: openAIAPIUsage, preferredCurrencyCode: preferredCurrencyCode) } if let claudeAdminAPIUsage = snapshot.claudeAdminAPIUsage { - Self.appendClaudeAdminAPIUsageSummary(entries: &entries, usage: claudeAdminAPIUsage) + Self.appendClaudeAdminAPIUsageSummary(entries: &entries, usage: claudeAdminAPIUsage, preferredCurrencyCode: preferredCurrencyCode) } if let openRouterUsage = snapshot.openRouterUsage { - Self.appendOpenRouterUsageSummary(entries: &entries, usage: openRouterUsage) + Self.appendOpenRouterUsageSummary(entries: &entries, usage: openRouterUsage, preferredCurrencyCode: preferredCurrencyCode) } if let clawRouterUsage = snapshot.clawRouterUsage { entries.append(.text( @@ -387,7 +389,7 @@ struct MenuDescriptor { Self.appendWayfinderUsageSummary(entries: &entries, usage: wayfinderUsage) } if let poeUsage = snapshot.poeUsage, !poeUsage.daily.isEmpty { - Self.appendPoeUsageSummary(entries: &entries, usage: poeUsage) + Self.appendPoeUsageSummary(entries: &entries, usage: poeUsage, preferredCurrencyCode: preferredCurrencyCode) } if let mistralUsage = snapshot.mistralUsage, !mistralUsage.daily.isEmpty { Self.appendMistralUsageSummary(entries: &entries, usage: mistralUsage) diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index fd2f373447..026849b596 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -75,6 +75,40 @@ enum AppLanguage: String, CaseIterable, Identifiable { } } +enum PreferredCurrencyOption: String, CaseIterable, Identifiable { + case auto = "auto" + case usd = "USD" + case gbp = "GBP" + case eur = "EUR" + case cny = "CNY" + case jpy = "JPY" + case cad = "CAD" + case aud = "AUD" + case hkd = "HKD" + case twd = "TWD" + case sgd = "SGD" + case inr = "INR" + + var id: String { self.rawValue } + + var label: String { + switch self { + case .auto: L("currency_auto") + case .usd: "USD ($)" + case .gbp: "GBP (£)" + case .eur: "EUR (€)" + case .cny: "CNY (¥)" + case .jpy: "JPY (¥)" + case .cad: "CAD ($)" + case .aud: "AUD ($)" + case .hkd: "HKD ($)" + case .twd: "TWD (NT$)" + case .sgd: "SGD ($)" + case .inr: "INR (₹)" + } + } +} + @MainActor struct GeneralPane: View { @Bindable var settings: SettingsStore @@ -92,6 +126,20 @@ struct GeneralPane: View { Text(verbatim: AppLanguage(rawValue: rawValue)?.label ?? rawValue) }) + SettingsMenuPicker( + selection: self.$settings.preferredCurrencyCode, + options: PreferredCurrencyOption.allCases.map(\.rawValue), + label: { + SettingsRowLabel(L("currency_title"), subtitle: L("currency_subtitle")) + }, + optionLabel: { rawValue in + Text(verbatim: PreferredCurrencyOption(rawValue: rawValue)?.label ?? rawValue) + }) + .onChange(of: self.settings.preferredCurrencyCode) { _, newValue in + guard newValue != "auto" else { return } + Task { await CurrencyExchange.shared.fetchLatestRatesIfNeeded() } + } + SettingsMenuPicker( selection: self.$settings.terminalApp, options: GeneralSettingsMenuOptions.terminalApps(selected: self.settings.terminalApp), diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index c9fc3150cd..18b42e8801 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "Agent sessions"; "language_title" = "Language"; "language_subtitle" = "Change the display language. Requires app restart to take full effect."; +"currency_title" = "Preferred Currency"; +"currency_subtitle" = "Currency for cost estimates and spend metrics. Uses live exchange rates updated daily."; +"currency_auto" = "Auto (Follow Provider / USD)"; "language_system" = "System"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 3e02a4cc05..06fc0d4755 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -435,6 +435,9 @@ "section_agent_sessions" = "智能体会话"; "language_title" = "语言"; "language_subtitle" = "更改显示语言。需要重启应用才能完全生效。"; +"currency_title" = "首选货币"; +"currency_subtitle" = "用于费用估算和支出指标的货币。使用每日更新的实时汇率。"; +"currency_auto" = "自动(跟随提供商 / USD)"; "language_system" = "跟随系统"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 23afa6fa17..c051d5b39b 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -995,6 +995,14 @@ extension SettingsStore { self.userDefaults.set(newValue, forKey: "agentSessionsManualHosts") } } + + var preferredCurrencyCode: String { + get { self.defaultsState.preferredCurrencyCode } + set { + self.defaultsState.preferredCurrencyCode = newValue + self.userDefaults.set(newValue, forKey: "preferredCurrencyCode") + } + } } extension SettingsStore { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index b6bb983c6b..3deb4f6fe0 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -530,6 +530,7 @@ extension SettingsStore { let agentSessionLabelStyleRaw = userDefaults.string(forKey: "agentSessionLabelStyle") ?? AgentSessionLabelStyle.project.rawValue let agentSessionsManualHosts = userDefaults.string(forKey: "agentSessionsManualHosts") ?? "" + let preferredCurrencyCode = userDefaults.string(forKey: "preferredCurrencyCode") ?? "auto" return SettingsDefaultsState( refreshFrequency: refreshFrequency, adaptiveActivityScanConsent: adaptiveActivityScanConsent, @@ -603,7 +604,8 @@ extension SettingsStore { terminalAppRaw: userDefaults.string(forKey: "terminalApp"), agentSessionsEnabled: agentSessionsEnabled, agentSessionLabelStyleRaw: agentSessionLabelStyleRaw, - agentSessionsManualHosts: agentSessionsManualHosts) + agentSessionsManualHosts: agentSessionsManualHosts, + preferredCurrencyCode: preferredCurrencyCode) } private static func hadPreviousAppLaunch(userDefaults: UserDefaults) -> Bool { diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 65ae4cb605..9f72aeaaa0 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -74,4 +74,5 @@ struct SettingsDefaultsState { var agentSessionsEnabled: Bool var agentSessionLabelStyleRaw: String var agentSessionsManualHosts: String + var preferredCurrencyCode: String } diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index d2efd57f76..b3098ac649 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -852,7 +852,7 @@ extension StatusItemController { self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .automatic, let balance = snapshot?.openRouterUsage?.balance { - return UsageFormatter.usdString(balance) + return UsageFormatter.convertedCostString(balance, preferredCurrency: self.settings.preferredCurrencyCode, providerCurrency: "USD") } if provider == .opencodego, let balance = Self.openCodeGoZenBalanceDisplayText(snapshot: snapshot) diff --git a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift index 1899bce455..0fa18f2cf5 100644 --- a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift +++ b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift @@ -90,12 +90,17 @@ extension StatusItemController { -> (today: String?, last30Days: String?) { let snapshot = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot - let currencyCode = snapshot?.currencyCode ?? "USD" + let providerCostCurrency = self.store.snapshot(for: provider)?.providerCost?.currencyCode + let preferred = self.settings.preferredCurrencyCode + let currencyCode = preferred != "auto" && !preferred.isEmpty + ? preferred + : (providerCostCurrency ?? snapshot?.currencyCode ?? "USD") + let today = MenuBarLayoutCostResolver.todayCostUSD(snapshot: snapshot, now: now).map { - UsageFormatter.currencyString($0, currencyCode: currencyCode) + UsageFormatter.convertedCostString($0, targetCurrency: currencyCode) } let last30Days = snapshot?.last30DaysCostUSD.map { - UsageFormatter.currencyString($0, currencyCode: currencyCode) + UsageFormatter.convertedCostString($0, targetCurrency: currencyCode) } return (today, last30Days) } diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 43d02f638e..498c0acaea 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -187,6 +187,7 @@ extension StatusItemController { ], workDaysPerWeek: self.settings.weeklyProgressWorkDays, usesLiveSubtitle: surface == .liveCard, + preferredCurrencyCode: self.settings.preferredCurrencyCode, now: now) return UsageMenuCardView.Model.make(input) } diff --git a/Sources/CodexBarCore/CurrencyExchange.swift b/Sources/CodexBarCore/CurrencyExchange.swift new file mode 100644 index 0000000000..181405fb8d --- /dev/null +++ b/Sources/CodexBarCore/CurrencyExchange.swift @@ -0,0 +1,144 @@ +import Foundation + +/// Manages currency exchange rates for converting USD-denominated AI model token estimates +/// into user-preferred currencies (GBP, EUR, CNY, JPY, CAD, AUD, etc.). +/// +/// Rates are sourced from the ExchangeRate-API (open.er-api.com), a free service +/// aggregating data from central banks and market sources. Rates are updated daily +/// and cached locally for 24 hours. Hardcoded fallback rates are used when the +/// network is unavailable (e.g. first launch offline). +public final class CurrencyExchange: @unchecked Sendable { + public static let shared = CurrencyExchange() + + /// All currency codes supported by the converter. + public static let supportedCurrencies: [String] = [ + "USD", "GBP", "EUR", "CNY", "JPY", "CAD", "AUD", "HKD", "TWD", "SGD", "INR", + ] + + private let lock = NSLock() + // Hardcoded fallback rates (approximate mid-market rates as of 2025-07). + // These are only used when no cached or live rates are available. + private var rates: [String: Double] = [ + "USD": 1.0, + "GBP": 0.79, + "EUR": 0.92, + "CNY": 7.27, + "JPY": 154.0, + "CAD": 1.38, + "AUD": 1.55, + "HKD": 7.80, + "TWD": 32.30, + "SGD": 1.34, + "INR": 84.50, + ] + private var lastFetchTime: Date? + + private static let userDefaultsKey = "CodexBar.CurrencyExchangeRates" + private static let lastFetchKey = "CodexBar.CurrencyExchangeLastFetch" + + public init() { + self.loadCachedRates() + } + + /// Converts a USD amount to the specified target currency code. + public func convert(usdAmount: Double, to currencyCode: String) -> Double { + let code = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard !code.isEmpty, code != "USD" else { return usdAmount } + + self.lock.lock() + let rate = self.rates[code] + self.lock.unlock() + + guard let rate else { return usdAmount } + return usdAmount * rate + } + + /// Converts an amount from one currency to another via USD as the pivot. + /// For example, `convert(amount: 10, from: "GBP", to: "CNY")` converts £10 to yuan. + /// Falls back to the original amount when either currency rate is unavailable. + public func convert(amount: Double, from sourceCurrency: String, to targetCurrency: String) -> Double { + let source = sourceCurrency.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let target = targetCurrency.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard !source.isEmpty, !target.isEmpty, source != target else { return amount } + + self.lock.lock() + let sourceRate = source == "USD" ? 1.0 : self.rates[source] + let targetRate = target == "USD" ? 1.0 : self.rates[target] + self.lock.unlock() + + guard let sourceRate, let targetRate, sourceRate > 0 else { return amount } + let usdAmount = amount / sourceRate + return usdAmount * targetRate + } + + /// Returns the exchange rate for a given currency code relative to USD. + public func rate(for currencyCode: String) -> Double? { + let code = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard !code.isEmpty else { return 1.0 } + self.lock.lock() + defer { self.lock.unlock() } + return self.rates[code] + } + + private func getLastFetchTime() -> Date? { + self.lock.lock() + defer { self.lock.unlock() } + return self.lastFetchTime + } + + private func updateRates(_ newRates: [String: Double]) { + self.lock.lock() + for (code, rate) in newRates { + self.rates[code] = rate + } + self.lastFetchTime = Date() + self.lock.unlock() + } + + /// Loads cached rates from `UserDefaults`. + private func loadCachedRates() { + if let data = UserDefaults.standard.dictionary(forKey: Self.userDefaultsKey) as? [String: Double] { + self.lock.lock() + for (key, val) in data { + self.rates[key] = val + } + self.lock.unlock() + } + if let timestamp = UserDefaults.standard.object(forKey: Self.lastFetchKey) as? Date { + self.lastFetchTime = timestamp + } + } + + /// Asynchronously fetches latest rates from open.er-api.com if cache is older than 24 hours. + /// + /// The ExchangeRate-API free tier provides daily-updated rates sourced from central banks + /// and financial data providers. This is sufficient for cost estimation purposes. + /// On failure, the previously cached (or hardcoded fallback) rates remain in use. + public func fetchLatestRatesIfNeeded() async { + if let lastFetch = self.getLastFetchTime(), Date().timeIntervalSince(lastFetch) < 86400 { + return + } + + guard let url = URL(string: "https://open.er-api.com/v6/latest/USD") else { return } + + do { + let (data, response) = try await URLSession.shared.data(from: url) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { return } + + struct ExchangeResponse: Decodable { + let result: String + let rates: [String: Double]? + } + + let decoded = try JSONDecoder().decode(ExchangeResponse.self, from: data) + if decoded.result == "success", let newRates = decoded.rates { + self.updateRates(newRates) + + UserDefaults.standard.set(newRates, forKey: Self.userDefaultsKey) + UserDefaults.standard.set(Date(), forKey: Self.lastFetchKey) + } + } catch { + // Ignore fetch errors, keep using fallback / cached rates. + } + } +} diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index c8674fab5f..77523d8fb9 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -224,6 +224,53 @@ public enum UsageFormatter { return String(format: "%.2f", value) } + /// Formats a USD value into a target currency code with exchange rate conversion applied. + public static func convertedCostString(_ usdValue: Double, targetCurrency: String) -> String { + let converted = CurrencyExchange.shared.convert(usdAmount: usdValue, to: targetCurrency) + return self.currencyString(converted, currencyCode: targetCurrency) + } + + /// Formats a value from one currency into another via USD pivot conversion. + /// Useful when displaying provider costs that are denominated in non-USD currencies + /// (e.g., Anthropic extra usage returned in GBP) under the user's preferred currency. + public static func convertedCostString( + _ value: Double, + fromCurrency: String, + targetCurrency: String) -> String + { + let converted = CurrencyExchange.shared.convert(amount: value, from: fromCurrency, to: targetCurrency) + return self.currencyString(converted, currencyCode: targetCurrency) + } + + /// Resolves the effective currency code for cost display given user preference + /// and an optional provider currency. Returns the provider currency when preference + /// is "auto", otherwise returns the explicit preference. + public static func effectiveCurrencyCode( + preferred: String, + providerCurrency: String?) -> String + { + guard preferred != "auto", !preferred.isEmpty else { + return providerCurrency ?? "USD" + } + return preferred + } + + /// Formats a cost value with smart currency conversion. + /// - When `preferredCurrency` is "auto", renders in `providerCurrency` (or USD fallback) without conversion. + /// - When `preferredCurrency` is an explicit code, converts from `providerCurrency` to the target. + public static func convertedCostString( + _ value: Double, + preferredCurrency: String, + providerCurrency: String?) -> String + { + let effective = Self.effectiveCurrencyCode(preferred: preferredCurrency, providerCurrency: providerCurrency) + let sourceCurrency = providerCurrency ?? "USD" + guard effective == sourceCurrency else { + return Self.convertedCostString(value, fromCurrency: sourceCurrency, targetCurrency: effective) + } + return Self.currencyString(value, currencyCode: effective) + } + /// Formats a USD value with proper negative handling and thousand separators. /// Uses Swift's modern FormatStyle API (iOS 15+/macOS 12+) for robust, locale-aware formatting. public static func usdString(_ value: Double) -> String { diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index c70e7969dd..53f7143a4b 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -469,6 +469,43 @@ struct UsageFormatterTests { #expect(UsageFormatter.byteCountStringLong(.min) == "-8589934592 [byte_unit_gigabytes]") } + @Test + func `currency exchange converts rates and formats correctly`() { + let exchange = CurrencyExchange.shared + let epsilon = 1e-9 + // USD → USD is identity + #expect(abs(exchange.convert(usdAmount: 10.0, to: "USD") - 10.0) < epsilon) + + // Cross-currency conversion via USD pivot + let gbpRate = exchange.rate(for: "GBP") ?? 0.79 + let eurRate = exchange.rate(for: "EUR") ?? 0.92 + #expect(abs(exchange.convert(usdAmount: 10.0, to: "GBP") - 10.0 * gbpRate) < epsilon) + #expect(abs(exchange.convert(usdAmount: 10.0, to: "EUR") - 10.0 * eurRate) < epsilon) + + // Cross-currency: GBP → EUR + let gbpToEur = exchange.convert(amount: 10.0, from: "GBP", to: "EUR") + let expectedGbpToEur = 10.0 / gbpRate * eurRate + #expect(abs(gbpToEur - expectedGbpToEur) < epsilon) + + // GBP → USD cross-currency + let gbpToUsd = exchange.convert(amount: 10.0, from: "GBP", to: "USD") + #expect(abs(gbpToUsd - 10.0 / gbpRate) < epsilon) + + // Formatting + let gbpFormatted = UsageFormatter.convertedCostString(10.0, targetCurrency: "GBP") + #expect(gbpFormatted.contains("£")) + + let usdFormatted = UsageFormatter.convertedCostString(10.0, targetCurrency: "USD") + #expect(usdFormatted == "$10.00") + + // Smart conversion with preferred currency + let autoResult = UsageFormatter.convertedCostString(10.0, preferredCurrency: "auto", providerCurrency: "GBP") + #expect(autoResult.contains("£")) + + let explicitCNY = UsageFormatter.convertedCostString(10.0, preferredCurrency: "CNY", providerCurrency: "USD") + #expect(explicitCNY.contains("¥")) + } + @Test func `usage formatter localization keys exist in en and zh Hans with matching placeholders`() throws { let root = URL(fileURLWithPath: #filePath) From d0f79d40981f3f80f32bc0b94dbcaa7e5c0ad7f0 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:09:56 +0800 Subject: [PATCH 2/6] fix: resolve swiftlint warnings for currency exchange feature - Fix line_length violations by extracting cost variables - Fix multiline_parameters / multiline_arguments formatting - Fix unused_closure_parameter by restructuring closures - Fix function_body_length by extracting resolvePaceAndForecast helper - Fix nested string interpolation causing parse errors --- Sources/CodexBar/MenuCardView+Costs.swift | 14 ++- Sources/CodexBar/MenuCardView+Kiro.swift | 6 +- .../CodexBar/MenuCardView+ModelHelpers.swift | 13 ++- Sources/CodexBar/MenuCardView.swift | 9 +- .../MenuDescriptor+ProviderUsage.swift | 54 +++++++--- Sources/CodexBar/MenuDescriptor.swift | 20 +++- .../StatusItemController+Animation.swift | 5 +- .../StatusItemController+MenuCardModel.swift | 101 +++++++++++------- 8 files changed, 153 insertions(+), 69 deletions(-) diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 224076e803..f626562fc8 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -97,8 +97,10 @@ extension UsageMenuCardView.Model { title: L("Extra usage"), percentUsed: nil, spendLine: "\(L("Balance")): \(usage.balanceDetail)", - percentLine: usage.periodUsageTotal.map { - "\(L("Usage")): \(UsageFormatter.convertedCostString($0, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))" + percentLine: usage.periodUsageTotal.map { value in + let cost = UsageFormatter.convertedCostString( + value, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + return "\(L("Usage")): \(cost)" }) } @@ -166,12 +168,14 @@ extension UsageMenuCardView.Model { preferredCurrencyCode: String = "auto") -> String? { var lines: [String] = [] if let individualCredits = usage.individualCredits { - lines.append( - "\(L("Individual credits")): \(UsageFormatter.convertedCostString(individualCredits, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))") + let cost = UsageFormatter.convertedCostString( + individualCredits, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + lines.append("\(L("Individual credits")): \(cost)") } lines.append(contentsOf: usage.workspaceBalances.map { workspace in "\(L("Workspace")) \(workspace.name): " + - UsageFormatter.convertedCostString(workspace.remaining, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + UsageFormatter.convertedCostString( + workspace.remaining, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") }) return lines.isEmpty ? nil : lines.joined(separator: "\n") } diff --git a/Sources/CodexBar/MenuCardView+Kiro.swift b/Sources/CodexBar/MenuCardView+Kiro.swift index c2d83b5be1..c9262caa30 100644 --- a/Sources/CodexBar/MenuCardView+Kiro.swift +++ b/Sources/CodexBar/MenuCardView+Kiro.swift @@ -29,7 +29,11 @@ extension UsageMenuCardView.Model { if overagesEnabled, let estimatedOverageCostUSD = input.snapshot?.kiroUsage?.estimatedOverageCostUSD { - notes.append("\(L("Overage cost")): \(UsageFormatter.convertedCostString(estimatedOverageCostUSD, preferredCurrency: input.preferredCurrencyCode, providerCurrency: "USD"))") + let costStr = UsageFormatter.convertedCostString( + estimatedOverageCostUSD, + preferredCurrency: input.preferredCurrencyCode, + providerCurrency: "USD") + notes.append("\(L("Overage cost")): \(costStr)") } return notes } diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 7beb361687..c2f072fbc2 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -868,8 +868,11 @@ extension UsageMenuCardView.Model { return nil } - static func openRouterQuotaDetail(provider: UsageProvider, snapshot: UsageSnapshot, - preferredCurrencyCode: String = "auto") -> String? { + static func openRouterQuotaDetail( + provider: UsageProvider, + snapshot: UsageSnapshot, + preferredCurrencyCode: String = "auto") -> String? + { guard provider == .openrouter, let usage = snapshot.openRouterUsage, usage.hasValidKeyQuota, @@ -879,8 +882,10 @@ extension UsageMenuCardView.Model { return nil } - let remaining = UsageFormatter.convertedCostString(keyRemaining, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") - let limit = UsageFormatter.convertedCostString(keyLimit, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let remaining = UsageFormatter.convertedCostString( + keyRemaining, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let limit = UsageFormatter.convertedCostString( + keyLimit, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") return String(format: L("%@/%@ left"), remaining, limit) } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 27c5109db2..5ec01f1df6 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -912,7 +912,9 @@ extension UsageMenuCardView.Model { !input.showOptionalCreditsAndExtraUsage let providerCost: ProviderCostSection? = if input.provider == .sakana { input.showOptionalCreditsAndExtraUsage - ? Self.sakanaPayAsYouGoSection(input.snapshot?.sakanaPayAsYouGo, preferredCurrencyCode: input.preferredCurrencyCode) + ? Self.sakanaPayAsYouGoSection( + input.snapshot?.sakanaPayAsYouGo, + preferredCurrencyCode: input.preferredCurrencyCode) : nil } else if hidesOptionalProviderCost || (input.provider == .openai && openAIAPIUsage != nil) @@ -1175,7 +1177,10 @@ extension UsageMenuCardView.Model { let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit) let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit) let zaiSessionDetail = Self.zaiLimitDetailText(limit: zaiUsage?.sessionTokenLimit) - let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot, preferredCurrencyCode: input.preferredCurrencyCode) + let openRouterQuotaDetail = Self.openRouterQuotaDetail( + provider: input.provider, + snapshot: snapshot, + preferredCurrencyCode: input.preferredCurrencyCode) let labels = Self.rateWindowLabels(input: input, snapshot: snapshot) if input.provider == .mistral, let credits = snapshot.mistralUsage?.credits { metrics.append(Metric( diff --git a/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift b/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift index d0ded07e8e..640ab2b599 100644 --- a/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift +++ b/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift @@ -11,17 +11,23 @@ extension MenuDescriptor { let last7 = usage.last7Days let last30 = usage.last30Days let historyLabel = usage.historyWindowLabel + let todayCost = UsageFormatter.convertedCostString( + today.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let last7Cost = UsageFormatter.convertedCostString( + last7.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let last30Cost = UsageFormatter.convertedCostString( + last30.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") entries.append(.text( - "\(L("Today")): \(UsageFormatter.convertedCostString(today.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD")) · " + + "\(L("Today")): \(todayCost) · " + "\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))", .secondary)) entries.append(.text( - "7d: \(UsageFormatter.convertedCostString(last7.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD")) · " + + "7d: \(last7Cost) · " + "\(UsageFormatter.tokenCountString(last7.requests)) \(L("requests"))", .secondary)) entries.append(.text( - "\(historyLabel): \(UsageFormatter.convertedCostString(last30.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD")) · " + + "\(historyLabel): \(last30Cost) · " + "\(UsageFormatter.tokenCountString(last30.requests)) \(L("requests"))", .secondary)) if let topModel = usage.topModels.first?.name { @@ -37,17 +43,23 @@ extension MenuDescriptor { let today = usage.currentDay let last7 = usage.last7Days let last30 = usage.last30Days + let todayCost = UsageFormatter.convertedCostString( + today.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let last7Cost = UsageFormatter.convertedCostString( + last7.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + let last30Cost = UsageFormatter.convertedCostString( + last30.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") entries.append(.text( - "\(L("Today")): \(UsageFormatter.convertedCostString(today.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD")) · " + + "\(L("Today")): \(todayCost) · " + "\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))", .secondary)) entries.append(.text( - "7d: \(UsageFormatter.convertedCostString(last7.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD")) · " + + "7d: \(last7Cost) · " + "\(UsageFormatter.tokenCountString(last7.totalTokens)) \(L("tokens"))", .secondary)) entries.append(.text( - "30d: \(UsageFormatter.convertedCostString(last30.costUSD, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD")) · " + + "30d: \(last30Cost) · " + "\(UsageFormatter.tokenCountString(last30.totalTokens)) \(L("tokens"))", .secondary)) if let topModel = usage.topModels.first?.name { @@ -61,13 +73,19 @@ extension MenuDescriptor { preferredCurrencyCode: String = "auto") { if let daily = usage.keyUsageDaily { - entries.append(.text("\(L("Today")): \(UsageFormatter.convertedCostString(daily, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))", .secondary)) + let cost = UsageFormatter.convertedCostString( + daily, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + entries.append(.text("\(L("Today")): \(cost)", .secondary)) } if let weekly = usage.keyUsageWeekly { - entries.append(.text("\(L("Week")): \(UsageFormatter.convertedCostString(weekly, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))", .secondary)) + let cost = UsageFormatter.convertedCostString( + weekly, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + entries.append(.text("\(L("Week")): \(cost)", .secondary)) } if let monthly = usage.keyUsageMonthly { - entries.append(.text("\(L("Month")): \(UsageFormatter.convertedCostString(monthly, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))", .secondary)) + let cost = UsageFormatter.convertedCostString( + monthly, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + entries.append(.text("\(L("Month")): \(cost)", .secondary)) } } @@ -100,9 +118,21 @@ extension MenuDescriptor { let today = usage.currentDay() let week = usage.last7Days let month = usage.last30Days - let todayCostSuffix = today.costUSD.map { " · \(UsageFormatter.convertedCostString($0, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))" } ?? "" - let weekCostSuffix = week.costUSD.map { " · \(UsageFormatter.convertedCostString($0, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))" } ?? "" - let monthCostSuffix = month.costUSD.map { " · \(UsageFormatter.convertedCostString($0, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD"))" } ?? "" + let todayCostSuffix = today.costUSD.map { value in + let cost = UsageFormatter.convertedCostString( + value, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + return " · \(cost)" + } ?? "" + let weekCostSuffix = week.costUSD.map { value in + let cost = UsageFormatter.convertedCostString( + value, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + return " · \(cost)" + } ?? "" + let monthCostSuffix = month.costUSD.map { value in + let cost = UsageFormatter.convertedCostString( + value, preferredCurrency: preferredCurrencyCode, providerCurrency: "USD") + return " · \(cost)" + } ?? "" entries.append(.text( "\(L("Today")): \(Self.pointsString(today.points)) · " + "\(UsageFormatter.tokenCountString(today.requests)) \(L("requests"))\(todayCostSuffix)", diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 144898c625..c1a0907e08 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -365,13 +365,22 @@ struct MenuDescriptor { } } if let openAIAPIUsage = snapshot.openAIAPIUsage { - Self.appendOpenAIAPIUsageSummary(entries: &entries, usage: openAIAPIUsage, preferredCurrencyCode: preferredCurrencyCode) + Self.appendOpenAIAPIUsageSummary( + entries: &entries, + usage: openAIAPIUsage, + preferredCurrencyCode: preferredCurrencyCode) } if let claudeAdminAPIUsage = snapshot.claudeAdminAPIUsage { - Self.appendClaudeAdminAPIUsageSummary(entries: &entries, usage: claudeAdminAPIUsage, preferredCurrencyCode: preferredCurrencyCode) + Self.appendClaudeAdminAPIUsageSummary( + entries: &entries, + usage: claudeAdminAPIUsage, + preferredCurrencyCode: preferredCurrencyCode) } if let openRouterUsage = snapshot.openRouterUsage { - Self.appendOpenRouterUsageSummary(entries: &entries, usage: openRouterUsage, preferredCurrencyCode: preferredCurrencyCode) + Self.appendOpenRouterUsageSummary( + entries: &entries, + usage: openRouterUsage, + preferredCurrencyCode: preferredCurrencyCode) } if let clawRouterUsage = snapshot.clawRouterUsage { entries.append(.text( @@ -389,7 +398,10 @@ struct MenuDescriptor { Self.appendWayfinderUsageSummary(entries: &entries, usage: wayfinderUsage) } if let poeUsage = snapshot.poeUsage, !poeUsage.daily.isEmpty { - Self.appendPoeUsageSummary(entries: &entries, usage: poeUsage, preferredCurrencyCode: preferredCurrencyCode) + Self.appendPoeUsageSummary( + entries: &entries, + usage: poeUsage, + preferredCurrencyCode: preferredCurrencyCode) } if let mistralUsage = snapshot.mistralUsage, !mistralUsage.daily.isEmpty { Self.appendMistralUsageSummary(entries: &entries, usage: mistralUsage) diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index b3098ac649..209a4b2626 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -852,7 +852,10 @@ extension StatusItemController { self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .automatic, let balance = snapshot?.openRouterUsage?.balance { - return UsageFormatter.convertedCostString(balance, preferredCurrency: self.settings.preferredCurrencyCode, providerCurrency: "USD") + return UsageFormatter.convertedCostString( + balance, + preferredCurrency: self.settings.preferredCurrencyCode, + providerCurrency: "USD") } if provider == .opencodego, let balance = Self.openCodeGoZenBalanceDisplayText(snapshot: snapshot) diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 498c0acaea..06e5cbcead 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -88,48 +88,13 @@ extension StatusItemController { let sourceLabel = surface == .liveCard ? self.store.sourceLabel(for: target) : nil let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto - // Abacus and Kimi carry their long-cadence window in primary rather than secondary. - let paceWindow = target == .abacus || target == .kimi ? snapshot?.primary : snapshot?.secondary - let sessionEquivalentHistorySelection = self.sessionEquivalentHistorySelection( - provider: target, + let (weeklyPace, sessionEquivalentForecast) = self.resolvePaceAndForecast( + target: target, snapshot: snapshot, + codexProjection: codexProjection, usesOverrideCard: surface == .overrideCard, - override: historySelectionOverride) - let weeklyPace = if let codexProjection, - let weekly = codexProjection.rateWindow(for: .weekly) - { - self.store.weeklyPace(provider: target, window: weekly, now: now) - } else { - paceWindow.flatMap { window in - self.store.weeklyPace(provider: target, window: window, now: now) - } - } - let sessionEquivalentForecast: SessionEquivalentForecast? = if let codexProjection, - let session = codexProjection - .rateWindow(for: .session), - let weekly = codexProjection - .rateWindow(for: .weekly) - { - self.store.sessionEquivalentForecast( - provider: target, - sessionWindow: session, - weeklyWindow: weekly, - historySelection: sessionEquivalentHistorySelection, - now: now) - } else if let snapshot, - let windows = self.store.sessionEquivalentWindows(provider: target, snapshot: snapshot) - { - self.store.sessionEquivalentForecast( - provider: target, - sessionWindow: windows.session, - weeklyWindow: windows.weekly, - weeklyWindowID: windows.weeklyWindowID, - historyIdentity: windows.historyIdentity, - historySelection: sessionEquivalentHistorySelection, - now: now) - } else { - nil - } + historySelectionOverride: historySelectionOverride, + now: now) let fallbackAccount = accountOverride ?? (metadata.usesAccountFallback ? self.store.accountInfo(for: target) @@ -192,6 +157,62 @@ extension StatusItemController { return UsageMenuCardView.Model.make(input) } + // swiftlint:disable:next function_parameter_count + private func resolvePaceAndForecast( + target: UsageProvider, + snapshot: UsageSnapshot?, + codexProjection: CodexConsumerProjection?, + usesOverrideCard: Bool, + historySelectionOverride: PlanUtilizationHistorySelection?, + now: Date) + -> (weeklyPace: UsagePace?, sessionEquivalentForecast: SessionEquivalentForecast?) + { + let paceWindow = target == .abacus || target == .kimi + ? snapshot?.primary : snapshot?.secondary + let historySelection = self.sessionEquivalentHistorySelection( + provider: target, + snapshot: snapshot, + usesOverrideCard: usesOverrideCard, + override: historySelectionOverride) + let weeklyPace = if let codexProjection, + let weekly = codexProjection.rateWindow(for: .weekly) + { + self.store.weeklyPace(provider: target, window: weekly, now: now) + } else { + paceWindow.flatMap { window in + self.store.weeklyPace(provider: target, window: window, now: now) + } + } + let forecast: SessionEquivalentForecast? = if let codexProjection, + let session = codexProjection + .rateWindow(for: .session), + let weekly = codexProjection + .rateWindow(for: .weekly) + { + self.store.sessionEquivalentForecast( + provider: target, + sessionWindow: session, + weeklyWindow: weekly, + historySelection: historySelection, + now: now) + } else if let snapshot, + let windows = self.store.sessionEquivalentWindows( + provider: target, snapshot: snapshot) + { + self.store.sessionEquivalentForecast( + provider: target, + sessionWindow: windows.session, + weeklyWindow: windows.weekly, + weeklyWindowID: windows.weeklyWindowID, + historyIdentity: windows.historyIdentity, + historySelection: historySelection, + now: now) + } else { + nil + } + return (weeklyPace, forecast) + } + private func sessionEquivalentHistorySelection( provider: UsageProvider, snapshot: UsageSnapshot?, From e1e2cc20272a2c8faf195cc969a7cfb14b01a213 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:56:50 +0800 Subject: [PATCH 3/6] fix: complete preferred currency support --- Scripts/check-site-locales.mjs | 6 +- .../InlineUsageDashboardContent.swift | 134 +++++++++++++----- Sources/CodexBar/MenuCardView+Costs.swift | 49 +++++-- .../MenuDescriptor+ProviderUsage.swift | 15 +- Sources/CodexBar/MenuDescriptor.swift | 11 +- Sources/CodexBar/PreferencesGeneralPane.swift | 6 +- .../Claude/ClaudeProviderImplementation.swift | 12 +- .../Cursor/CursorProviderImplementation.swift | 10 +- .../Devin/DevinProviderImplementation.swift | 5 +- .../FactoryProviderImplementation.swift | 5 +- .../Sub2APIProviderImplementation.swift | 31 +++- .../Resources/ar.lproj/Localizable.strings | 3 + .../Resources/ca.lproj/Localizable.strings | 3 + .../Resources/de.lproj/Localizable.strings | 3 + .../Resources/es.lproj/Localizable.strings | 3 + .../Resources/fa.lproj/Localizable.strings | 3 + .../Resources/fr.lproj/Localizable.strings | 3 + .../Resources/gl.lproj/Localizable.strings | 3 + .../Resources/id.lproj/Localizable.strings | 3 + .../Resources/it.lproj/Localizable.strings | 3 + .../Resources/ja.lproj/Localizable.strings | 3 + .../Resources/ko.lproj/Localizable.strings | 3 + .../Resources/nl.lproj/Localizable.strings | 3 + .../Resources/pl.lproj/Localizable.strings | 3 + .../Resources/pt-BR.lproj/Localizable.strings | 3 + .../Resources/ru.lproj/Localizable.strings | 3 + .../Resources/sv.lproj/Localizable.strings | 3 + .../Resources/th.lproj/Localizable.strings | 3 + .../Resources/tr.lproj/Localizable.strings | 3 + .../Resources/uk.lproj/Localizable.strings | 3 + .../Resources/vi.lproj/Localizable.strings | 3 + .../zh-Hant.lproj/Localizable.strings | 3 + .../CodexBar/SpendDashboardController.swift | 7 +- Sources/CodexBar/SpendDashboardModel.swift | 48 +++++-- .../StatusItemController+MenuBarLayout.swift | 17 ++- .../StatusItemController+MenuCardModel.swift | 6 +- Sources/CodexBarCore/CurrencyExchange.swift | 16 ++- Sources/CodexBarCore/UsageFormatter.swift | 44 +++++- ...InlineCostHistoryDashboardLabelTests.swift | 57 ++++++++ .../SpendDashboardDateTruthTests.swift | 23 +++ Tests/CodexBarTests/UsageFormatterTests.swift | 18 ++- 41 files changed, 471 insertions(+), 112 deletions(-) diff --git a/Scripts/check-site-locales.mjs b/Scripts/check-site-locales.mjs index c44d81df85..ed145cb9a9 100644 --- a/Scripts/check-site-locales.mjs +++ b/Scripts/check-site-locales.mjs @@ -59,7 +59,11 @@ assertEqual( localeCatalog.filter((locale) => locale.direction === "rtl").map((locale) => locale.code), ["ar", "fa"], "RTL locale catalog"); -const appCatalogCodes = [...appLanguageSource.matchAll(/case \w+ = "([^"]+)"/g)] +const appLanguageEnumBody = appLanguageSource.match( + /enum AppLanguage:[^{]+\{([\s\S]*?)\n\}/, +)?.[1]; +assert(appLanguageEnumBody, "could not locate AppLanguage cases"); +const appCatalogCodes = [...appLanguageEnumBody.matchAll(/case \w+ = "([^"]+)"/g)] .map((match) => match[1]) .filter(Boolean) .map((code) => ({ "zh-Hans": "zh-CN", "zh-Hant": "zh-TW", ja: "ja-JP" })[code] ?? code); diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index bdc5c21cc4..7e0f1420ee 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -216,17 +216,22 @@ extension UsageMenuCardView.Model { return self.costHistoryInlineDashboard( provider: input.provider, snapshot: tokenSnapshot, - comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled) + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled, + preferredCurrencyCode: input.preferredCurrencyCode) } if input.provider == .claude, let usage = input.snapshot?.claudeAdminAPIUsage { - return Self.claudeAdminAPIInlineDashboard(usage) + return Self.claudeAdminAPIInlineDashboard( + usage, + preferredCurrencyCode: input.preferredCurrencyCode) } if input.provider == .openrouter, let usage = input.snapshot?.openRouterUsage { - return Self.openRouterInlineDashboard(usage) + return Self.openRouterInlineDashboard( + usage, + preferredCurrencyCode: input.preferredCurrencyCode) } if input.provider == .zai, let modelUsage = input.snapshot?.zaiUsage?.modelUsage @@ -269,7 +274,8 @@ extension UsageMenuCardView.Model { return Self.costHistoryInlineDashboard( provider: input.provider, snapshot: tokenSnapshot, - comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled) + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled, + preferredCurrencyCode: input.preferredCurrencyCode) } return nil } @@ -414,8 +420,26 @@ extension UsageMenuCardView.Model { private static func costHistoryInlineDashboard( provider: UsageProvider, snapshot: CostUsageTokenSnapshot, - comparisonPeriodsEnabled: Bool) -> InlineUsageDashboardModel + comparisonPeriodsEnabled: Bool, + preferredCurrencyCode: String) -> InlineUsageDashboardModel { + let displayCurrencyCode = UsageFormatter.convertedCost( + 0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode).currencyCode + func convertedValue(_ value: Double) -> Double { + UsageFormatter.convertedCost( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode).value + } + func convertedString(_ value: Double) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode) + } + let historyDays = max(1, min(365, snapshot.historyDays)) let defaultHistoryTitle = snapshot.historyLabel ?? (historyDays == 1 @@ -454,8 +478,8 @@ extension UsageMenuCardView.Model { return InlineUsageDashboardModel.Point( id: entry.date, label: Self.shortDayLabel(entry.date), - value: cost, - accessibilityValue: "\(entry.date): \(Self.costString(cost, currencyCode: snapshot.currencyCode))") + value: convertedValue(cost), + accessibilityValue: "\(entry.date): \(convertedString(cost))") } let latest = CostUsageTokenSnapshot.latestEntry(in: snapshot.daily) let usesLatestPrimary = provider == .bedrock || provider == .mistral @@ -463,7 +487,14 @@ extension UsageMenuCardView.Model { var details: [String] = [] if comparisonPeriodsEnabled { details.append(contentsOf: snapshot.comparisonSummaries().map { - Self.costWindowLine(summary: $0, currencyCode: snapshot.currencyCode) + let label = Self.costHistoryWindowLabel(days: $0.days) + let cost = $0.totalCostUSD.map(convertedString) ?? "—" + guard let totalTokens = $0.totalTokens else { return "\(label): \(cost)" } + return String( + format: L("%@: %@ · %@ tokens"), + label, + cost, + UsageFormatter.tokenCountString(totalTokens)) }) } if let topModel = Self.topCostModel(from: snapshot.daily) { @@ -494,12 +525,12 @@ extension UsageMenuCardView.Model { var kpis = [ InlineUsageDashboardModel.KPI( title: usesLatestPrimary ? L("Latest") : L("Today"), - value: primaryCostUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", + value: primaryCostUSD.map(convertedString) ?? "—", emphasis: true), .init( title: historyTitle, value: snapshot.last30DaysCostUSD - .map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", + .map(convertedString) ?? "—", emphasis: false), ] let tokenHistoryKPI = InlineUsageDashboardModel.KPI( @@ -518,17 +549,17 @@ extension UsageMenuCardView.Model { kpis.insert( .init( title: "Cursor-metered", - value: Self.costString(meteredCostUSD, currencyCode: snapshot.currencyCode), + value: convertedString(meteredCostUSD), emphasis: true), at: 0) } var model = InlineUsageDashboardModel( accessibilityLabel: accessibilityLabel, - valueStyle: Self.costValueStyle(currencyCode: snapshot.currencyCode), + valueStyle: Self.costValueStyle(currencyCode: displayCurrencyCode), kpis: kpis, points: points, detailLines: details) - model.currencyCode = snapshot.currencyCode + model.currencyCode = displayCurrencyCode return model } @@ -553,9 +584,27 @@ extension UsageMenuCardView.Model { ] } - fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot) + fileprivate static func claudeAdminAPIInlineDashboard( + _ usage: ClaudeAdminAPIUsageSnapshot, + preferredCurrencyCode: String = "auto") -> InlineUsageDashboardModel { + let displayCurrencyCode = UsageFormatter.convertedCost( + 0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD").currencyCode + func convertedValue(_ value: Double) -> Double { + UsageFormatter.convertedCost( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD").value + } + func convertedString(_ value: Double) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD") + } let today = usage.currentDay let last7 = usage.last7Days let last30 = usage.last30Days @@ -563,8 +612,8 @@ extension UsageMenuCardView.Model { InlineUsageDashboardModel.Point( id: $0.day, label: Self.shortDayLabel($0.day), - value: $0.costUSD, - accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))") + value: convertedValue($0.costUSD), + accessibilityValue: "\($0.day): \(convertedString($0.costUSD))") } var details = [ "30d: \(UsageFormatter.tokenCountString(last30.totalTokens)) \(L("tokens"))", @@ -575,13 +624,13 @@ extension UsageMenuCardView.Model { } var model = InlineUsageDashboardModel( accessibilityLabel: L("Claude Admin API 30 day spend trend"), - valueStyle: .currencyUSD, + valueStyle: Self.costValueStyle(currencyCode: displayCurrencyCode), kpis: [ - .init(title: L("Today"), value: UsageFormatter.usdString(today.costUSD), emphasis: true), - .init(title: L("7d spend"), value: UsageFormatter.usdString(last7.costUSD), emphasis: false), + .init(title: L("Today"), value: convertedString(today.costUSD), emphasis: true), + .init(title: L("7d spend"), value: convertedString(last7.costUSD), emphasis: false), .init( title: L("30d spend"), - value: UsageFormatter.usdString(last30.costUSD), + value: convertedString(last30.costUSD), emphasis: false), .init( title: L("Today tokens"), @@ -590,11 +639,30 @@ extension UsageMenuCardView.Model { ], points: points, detailLines: details) - model.currencyCode = "USD" + model.currencyCode = displayCurrencyCode return model } - private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? { + private static func openRouterInlineDashboard( + _ usage: OpenRouterUsageSnapshot, + preferredCurrencyCode: String) -> InlineUsageDashboardModel? + { + let displayCurrencyCode = UsageFormatter.convertedCost( + 0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD").currencyCode + func convertedValue(_ value: Double) -> Double { + UsageFormatter.convertedCost( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD").value + } + func convertedString(_ value: Double) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD") + } let periodValues: [(String, String, Double?)] = [ ("day", L("Today"), usage.keyUsageDaily), ("week", L("Week"), usage.keyUsageWeekly), @@ -602,11 +670,11 @@ extension UsageMenuCardView.Model { ] let points = periodValues.compactMap { id, label, value -> InlineUsageDashboardModel.Point? in guard let value else { return nil } - let formattedValue = Self.openRouterCurrencyString(value) + let formattedValue = convertedString(value) return InlineUsageDashboardModel.Point( id: id, label: label, - value: value, + value: convertedValue(value), accessibilityValue: String(format: L("%@: %@"), label, formattedValue)) } guard !points.isEmpty else { return nil } @@ -620,7 +688,7 @@ extension UsageMenuCardView.Model { details.append(String( format: L("%@: %@"), L("Key remaining"), - Self.openRouterCurrencyString(remaining))) + convertedString(remaining))) } case .noLimitConfigured: details.append(L("No limit set for the API key")) @@ -629,25 +697,25 @@ extension UsageMenuCardView.Model { } var model = InlineUsageDashboardModel( accessibilityLabel: L("OpenRouter API key spend trend"), - valueStyle: .currencyUSD, + valueStyle: Self.costValueStyle(currencyCode: displayCurrencyCode), kpis: [ - .init(title: L("Balance"), value: Self.openRouterCurrencyString(usage.balance), emphasis: true), + .init(title: L("Balance"), value: convertedString(usage.balance), emphasis: true), .init( title: L("Today"), - value: usage.keyUsageDaily.map(Self.openRouterCurrencyString) ?? "—", + value: usage.keyUsageDaily.map(convertedString) ?? "—", emphasis: false), .init( title: L("Week"), - value: usage.keyUsageWeekly.map(Self.openRouterCurrencyString) ?? "—", + value: usage.keyUsageWeekly.map(convertedString) ?? "—", emphasis: false), .init( title: L("Month"), - value: usage.keyUsageMonthly.map(Self.openRouterCurrencyString) ?? "—", + value: usage.keyUsageMonthly.map(convertedString) ?? "—", emphasis: false), ], points: points, detailLines: details) - model.currencyCode = "USD" + model.currencyCode = displayCurrencyCode return model } @@ -810,10 +878,6 @@ extension UsageMenuCardView.Model { }?.key } - private static func openRouterCurrencyString(_ value: Double) -> String { - String(format: "$%.2f", value) - } - private static func minimaxCashString(_ value: Double) -> String { String(format: "%.2f", max(0, value)) } diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index f626562fc8..ae6dc544a3 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -90,8 +90,10 @@ extension UsageMenuCardView.Model.ProviderCostSection { } extension UsageMenuCardView.Model { - static func sakanaPayAsYouGoSection(_ usage: SakanaPayAsYouGoSnapshot?, - preferredCurrencyCode: String = "auto") -> ProviderCostSection? { + static func sakanaPayAsYouGoSection( + _ usage: SakanaPayAsYouGoSnapshot?, + preferredCurrencyCode: String = "auto") -> ProviderCostSection? + { guard let usage else { return nil } return ProviderCostSection( title: L("Extra usage"), @@ -164,8 +166,10 @@ extension UsageMenuCardView.Model { return parts.joined(separator: " · ") } - private static func ampCreditsLine(_ usage: AmpUsageDetails, - preferredCurrencyCode: String = "auto") -> String? { + private static func ampCreditsLine( + _ usage: AmpUsageDetails, + preferredCurrencyCode: String = "auto") -> String? + { var lines: [String] = [] if let individualCredits = usage.individualCredits { let cost = UsageFormatter.convertedCostString( @@ -194,12 +198,11 @@ extension UsageMenuCardView.Model { guard enabled else { return nil } guard let snapshot else { return nil } - let effectiveCurrencyCode = preferredCurrencyCode != "auto" && !preferredCurrencyCode.isEmpty - ? preferredCurrencyCode - : snapshot.currencyCode - let sessionCost = snapshot.sessionCostUSD.map { - UsageFormatter.convertedCostString($0, targetCurrency: effectiveCurrencyCode) + UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode) } ?? "—" let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let sessionLabel = if provider == .bedrock || provider == .mistral { @@ -215,7 +218,10 @@ extension UsageMenuCardView.Model { }() let monthCost = snapshot.last30DaysCostUSD.map { - UsageFormatter.convertedCostString($0, targetCurrency: effectiveCurrencyCode) + UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode) } ?? "—" let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) @@ -239,7 +245,10 @@ extension UsageMenuCardView.Model { // 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.convertedCostString($0, targetCurrency: effectiveCurrencyCode) + let amount = UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode) return String(format: L("Cursor-metered: %@ (%@)"), amount, windowLabel.lowercased()) } let err = (error?.isEmpty ?? true) ? nil : error @@ -249,7 +258,12 @@ extension UsageMenuCardView.Model { meteredLine: meteredLine, comparisonLines: comparisonPeriodsEnabled ? snapshot.comparisonSummaries().map { - Self.costWindowLine(summary: $0, currencyCode: effectiveCurrencyCode) + Self.costWindowLine( + summary: $0, + currencyCode: UsageFormatter.effectiveCurrencyCode( + preferred: preferredCurrencyCode, + providerCurrency: snapshot.currencyCode), + sourceCurrencyCode: snapshot.currencyCode) } : [], hintLine: Self.tokenUsageHint(provider: provider), @@ -257,10 +271,17 @@ extension UsageMenuCardView.Model { errorCopyText: (error?.isEmpty ?? true) ? nil : error) } - static func costWindowLine(summary: CostUsageWindowSummary, currencyCode: String) -> String { + static func costWindowLine( + summary: CostUsageWindowSummary, + currencyCode: String, + sourceCurrencyCode: String? = nil) -> String + { let label = Self.costHistoryWindowLabel(days: summary.days) let cost = summary.totalCostUSD.map { - UsageFormatter.convertedCostString($0, targetCurrency: currencyCode) + UsageFormatter.convertedCostString( + $0, + preferredCurrency: currencyCode, + providerCurrency: sourceCurrencyCode ?? currencyCode) } ?? "—" guard let totalTokens = summary.totalTokens else { return "\(label): \(cost)" } return String( diff --git a/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift b/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift index 640ab2b599..413e7d5217 100644 --- a/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift +++ b/Sources/CodexBar/MenuDescriptor+ProviderUsage.swift @@ -91,18 +91,27 @@ extension MenuDescriptor { static func appendMistralUsageSummary( entries: inout [Entry], - usage: MistralUsageSnapshot) + usage: MistralUsageSnapshot, + preferredCurrencyCode: String = "auto") { let latest = usage.daily.last if let latest { + let cost = UsageFormatter.convertedCostString( + latest.cost, + preferredCurrency: preferredCurrencyCode, + providerCurrency: usage.currency) entries.append(.text( - "\(L("Latest")): \(usage.currencySymbol)\(String(format: "%.4f", max(0, latest.cost))) · " + + "\(L("Latest")): \(cost) · " + "\(UsageFormatter.tokenCountString(latest.totalTokens)) \(L("tokens"))", .secondary)) } let totalTokens = usage.totalInputTokens + usage.totalCachedTokens + usage.totalOutputTokens + let totalCost = UsageFormatter.convertedCostString( + usage.totalCost, + preferredCurrency: preferredCurrencyCode, + providerCurrency: usage.currency) entries.append(.text( - "\(L("Month")): \(usage.currencySymbol)\(String(format: "%.4f", max(0, usage.totalCost))) · " + + "\(L("Month")): \(totalCost) · " + "\(UsageFormatter.tokenCountString(totalTokens)) \(L("tokens"))", .secondary)) if let top = Self.topMistralModel(from: usage.daily) { diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index c1a0907e08..2c3f7a6f07 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -404,7 +404,10 @@ struct MenuDescriptor { preferredCurrencyCode: preferredCurrencyCode) } if let mistralUsage = snapshot.mistralUsage, !mistralUsage.daily.isEmpty { - Self.appendMistralUsageSummary(entries: &entries, usage: mistralUsage) + Self.appendMistralUsageSummary( + entries: &entries, + usage: mistralUsage, + preferredCurrencyCode: preferredCurrencyCode) } if let mimoUsage = snapshot.mimoUsage { entries.append(.text("\(L("Balance")): \(mimoUsage.balanceDetail)", .primary)) @@ -416,8 +419,12 @@ struct MenuDescriptor { if showOptionalUsage, let sakanaPayAsYouGo = snapshot.sakanaPayAsYouGo { entries.append(.text("\(L("Balance")): \(sakanaPayAsYouGo.balanceDetail)", .primary)) if let periodUsageTotal = sakanaPayAsYouGo.periodUsageTotal { + let cost = UsageFormatter.convertedCostString( + periodUsageTotal, + preferredCurrency: preferredCurrencyCode, + providerCurrency: "USD") entries.append(.text( - "\(L("Usage")): \(UsageFormatter.usdString(periodUsageTotal))", + "\(L("Usage")): \(cost)", .secondary)) } } diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 026849b596..7a924d0c07 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -76,7 +76,7 @@ enum AppLanguage: String, CaseIterable, Identifiable { } enum PreferredCurrencyOption: String, CaseIterable, Identifiable { - case auto = "auto" + case auto case usd = "USD" case gbp = "GBP" case eur = "EUR" @@ -89,7 +89,9 @@ enum PreferredCurrencyOption: String, CaseIterable, Identifiable { case sgd = "SGD" case inr = "INR" - var id: String { self.rawValue } + var id: String { + self.rawValue + } var label: String { switch self { diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 5b65eda7a2..94f75c5210 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -298,13 +298,19 @@ struct ClaudeProviderImplementation: ProviderImplementation { context.settings.showOptionalCreditsAndExtraUsage, cost.currencyCode != "Quota" { + func formatCost(_ value: Double) -> String { + UsageFormatter.convertedCostString( + value, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) + } if cost.limit > 0 { - let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + let used = formatCost(cost.used) + let limit = formatCost(cost.limit) entries.append(.text(String(format: L("extra_usage_format"), used, limit), .primary)) } if let balance = cost.balance { - let value = UsageFormatter.currencyString(balance, currencyCode: cost.currencyCode) + let value = formatCost(balance) let label = cost.limit > 0 ? L("Balance") : L("Credits") entries.append(.text("\(label): \(value)", .primary)) } diff --git a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift index 8286432b10..b0304510e1 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorProviderImplementation.swift @@ -86,9 +86,15 @@ struct CursorProviderImplementation: ProviderImplementation { @MainActor func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { guard let cost = context.snapshot?.providerCost, cost.currencyCode != "Quota" else { return } - let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let used = UsageFormatter.convertedCostString( + cost.used, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) if cost.limit > 0 { - let limitStr = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + let limitStr = UsageFormatter.convertedCostString( + cost.limit, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) entries.append(.text(String(format: L("cursor_on_demand_with_limit"), used, limitStr), .primary)) } else { entries.append(.text(String(format: L("cursor_on_demand"), used), .primary)) diff --git a/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift b/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift index 3d2ae23419..f69c529710 100644 --- a/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Devin/DevinProviderImplementation.swift @@ -126,7 +126,10 @@ struct DevinProviderImplementation: ProviderImplementation { cost.period == "Extra usage balance" else { return } - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let balance = UsageFormatter.convertedCostString( + cost.used, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) entries.append(.text(L("Extra usage balance: %@", balance), .primary)) } } diff --git a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift index e04c03aa12..751cb3d44f 100644 --- a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift @@ -157,7 +157,10 @@ struct FactoryProviderImplementation: ProviderImplementation { cost.period == "Extra usage balance" else { return } - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let balance = UsageFormatter.convertedCostString( + cost.used, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: cost.currencyCode) entries.append(.text(L("Extra usage balance: %@", balance), .primary)) } } diff --git a/Sources/CodexBar/Providers/Sub2API/Sub2APIProviderImplementation.swift b/Sources/CodexBar/Providers/Sub2API/Sub2APIProviderImplementation.swift index 9d8682f8e1..4fa483cc7d 100644 --- a/Sources/CodexBar/Providers/Sub2API/Sub2APIProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Sub2API/Sub2APIProviderImplementation.swift @@ -52,21 +52,38 @@ struct Sub2APIProviderImplementation: ProviderImplementation { func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { guard let usage = context.snapshot?.sub2APIUsage else { return } if let balance = usage.balance { - entries.append(.text( - "\(L("Balance")): \(UsageFormatter.currencyString(balance, currencyCode: usage.unit))", - .primary)) + let balanceText = UsageFormatter.convertedCostString( + balance, + preferredCurrency: context.settings.preferredCurrencyCode, + providerCurrency: usage.unit) + entries.append(.text("\(L("Balance")): \(balanceText)", .primary)) } if let today = usage.today { - entries.append(.text("\(L("Today")): \(self.totalsText(today, unit: usage.unit))", .secondary)) + let totals = self.totalsText( + today, + unit: usage.unit, + preferredCurrencyCode: context.settings.preferredCurrencyCode) + entries.append(.text("\(L("Today")): \(totals)", .secondary)) } if let total = usage.total { - entries.append(.text("\(L("Total")): \(self.totalsText(total, unit: usage.unit))", .secondary)) + let totals = self.totalsText( + total, + unit: usage.unit, + preferredCurrencyCode: context.settings.preferredCurrencyCode) + entries.append(.text("\(L("Total")): \(totals)", .secondary)) } } - private func totalsText(_ totals: Sub2APIUsageDetails.Totals, unit: String) -> String { + private func totalsText( + _ totals: Sub2APIUsageDetails.Totals, + unit: String, + preferredCurrencyCode: String) -> String + { "\(UsageFormatter.tokenCountString(totals.requests)) \(L("requests")) · " + "\(UsageFormatter.tokenCountString(totals.totalTokens)) \(L("tokens")) · " + - UsageFormatter.currencyString(totals.actualCostUSD, currencyCode: unit) + UsageFormatter.convertedCostString( + totals.actualCostUSD, + preferredCurrency: preferredCurrencyCode, + providerCurrency: unit) } } diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index e8d9da1b7d..656e84539a 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "جلسات الوكلاء"; "language_title" = "اللغة"; "language_subtitle" = "غير لغة العرض. يتطلب إعادة تشغيل التطبيق ليكون مفعوله بالكامل."; +"currency_title" = "العملة المفضلة"; +"currency_subtitle" = "عملة تقديرات التكلفة ومقاييس الإنفاق. تستخدم أسعار صرف تُحدّث يوميًا."; +"currency_auto" = "تلقائي (حسب المزوّد / USD)"; "language_system" = "النظام"; "language_english" = "الإنجليزية"; "language_spanish" = "الإسبانيول"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 3712280636..1921f5a94d 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -414,6 +414,9 @@ "section_agent_sessions" = "Sessions d'agents"; "language_title" = "Idioma"; "language_subtitle" = "Canvia l'idioma de la interfície. Cal reiniciar l'app perquè s'apliqui completament."; +"currency_title" = "Moneda preferida"; +"currency_subtitle" = "Moneda per a estimacions de cost i despeses. Utilitza tipus de canvi actualitzats diàriament."; +"currency_auto" = "Automàtic (segons el proveïdor / USD)"; "language_system" = "Sistema"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 7de8049dec..2986c311d4 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -426,6 +426,9 @@ "section_agent_sessions" = "Agenten-Sitzungen"; "language_title" = "Sprache"; "language_subtitle" = "Anzeigesprache wechseln. Ein App-Neustart wird empfohlen."; +"currency_title" = "Bevorzugte Währung"; +"currency_subtitle" = "Währung für Kostenschätzungen und Ausgaben. Verwendet täglich aktualisierte Wechselkurse."; +"currency_auto" = "Automatisch (Anbieter / USD)"; "language_system" = "System"; "language_english" = "Englisch"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 16e2b02585..656ac7975c 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -432,6 +432,9 @@ "section_agent_sessions" = "Sesiones de agentes"; "language_title" = "Idioma"; "language_subtitle" = "Cambia el idioma de la interfaz. Requiere reiniciar la app para aplicarse por completo."; +"currency_title" = "Moneda preferida"; +"currency_subtitle" = "Moneda para estimaciones de coste y gastos. Usa tipos de cambio actualizados diariamente."; +"currency_auto" = "Automático (según proveedor / USD)"; "language_system" = "Sistema"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 0bc50e5677..cdea6d030b 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "جلسات عامل‌ها"; "language_title" = "زبان"; "language_subtitle" = "زبان نمایش را تغییر دهید. برای اجرایی شدن کامل برنامه نیاز به ریستارت دارد."; +"currency_title" = "ارز ترجیحی"; +"currency_subtitle" = "ارز برآورد هزینه و مصرف. از نرخ‌های تبدیل روزانه استفاده می‌کند."; +"currency_auto" = "خودکار (بر اساس ارائه‌دهنده / USD)"; "language_system" = "سیستم"; "language_english" = "انگلیسی"; "language_spanish" = "اسپانیایی"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 090d0014f8..ff583450d9 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -428,6 +428,9 @@ "section_agent_sessions" = "Sessions d’agents"; "language_title" = "Langue"; "language_subtitle" = "Change la langue d'affichage. Nécessite de redémarrer l'app pour une prise en compte complète."; +"currency_title" = "Devise préférée"; +"currency_subtitle" = "Devise des estimations de coût et des dépenses. Utilise des taux de change actualisés chaque jour."; +"currency_auto" = "Automatique (selon le fournisseur / USD)"; "language_system" = "Système"; "language_english" = "Anglais"; "language_spanish" = "Espagnol"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 48c449014e..43bd695894 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -410,6 +410,9 @@ "section_agent_sessions" = "Sesións de axentes"; "language_title" = "Idioma"; "language_subtitle" = "Cambia o idioma da interface. Cómpre reiniciar a aplicación para que se aplique por completo."; +"currency_title" = "Moeda preferida"; +"currency_subtitle" = "Moeda para estimacións de custo e gasto. Usa tipos de cambio actualizados diariamente."; +"currency_auto" = "Automático (segundo o provedor / USD)"; "language_system" = "Sistema"; "language_english" = "Inglés"; "language_spanish" = "Castelán"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 02a0d2649a..275d7b769e 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "Sesi agen"; "language_title" = "Bahasa"; "language_subtitle" = "Ubah bahasa tampilan. Memerlukan restart aplikasi agar berlaku penuh."; +"currency_title" = "Mata uang pilihan"; +"currency_subtitle" = "Mata uang untuk estimasi biaya dan pengeluaran. Menggunakan kurs yang diperbarui setiap hari."; +"currency_auto" = "Otomatis (ikuti penyedia / USD)"; "language_system" = "Sistem"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 73afb88890..6ce92c2a94 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "Sessioni degli agenti"; "language_title" = "Lingua"; "language_subtitle" = "Cambia la lingua dell'interfaccia. Richiede il riavvio dell'app per applicare completamente la modifica."; +"currency_title" = "Valuta preferita"; +"currency_subtitle" = "Valuta per stime dei costi e spese. Usa tassi di cambio aggiornati ogni giorno."; +"currency_auto" = "Automatica (in base al provider / USD)"; "language_system" = "Sistema"; "language_english" = "Inglese"; "language_spanish" = "Spagnolo"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index e720ee88cb..7b6911423d 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -428,6 +428,9 @@ "section_agent_sessions" = "エージェントセッション"; "language_title" = "言語"; "language_subtitle" = "表示言語を変更します。完全に反映するにはアプリの再起動が必要です。"; +"currency_title" = "優先通貨"; +"currency_subtitle" = "費用見積もりと支出指標に使う通貨です。毎日更新される為替レートを使用します。"; +"currency_auto" = "自動(プロバイダー / USD に従う)"; "language_system" = "システム"; "language_english" = "英語"; "language_spanish" = "スペイン語"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index b8e1a3929e..7458d95078 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -426,6 +426,9 @@ "section_agent_sessions" = "에이전트 세션"; "language_title" = "언어"; "language_subtitle" = "표시 언어를 변경합니다. 적용하려면 앱을 다시 시작해야 합니다."; +"currency_title" = "기본 통화"; +"currency_subtitle" = "비용 추정 및 지출 지표에 사용할 통화입니다. 매일 갱신되는 환율을 사용합니다."; +"currency_auto" = "자동(제공업체 / USD 따름)"; "language_system" = "시스템"; "language_english" = "영어"; "language_spanish" = "스페인어"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 9a7cc674f7..cbe4af8954 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -428,6 +428,9 @@ "section_agent_sessions" = "Agentsessies"; "language_title" = "Taal"; "language_subtitle" = "Wijzig de weergavetaal. Vereist een herstart van de app om volledig effect te krijgen."; +"currency_title" = "Voorkeursvaluta"; +"currency_subtitle" = "Valuta voor kostenramingen en uitgaven. Gebruikt dagelijks bijgewerkte wisselkoersen."; +"currency_auto" = "Automatisch (provider / USD volgen)"; "language_system" = "Systeem"; "language_english" = "Engels"; "language_spanish" = "Spaans"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 0481d41a78..9958e32f9e 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "Sesje agentów"; "language_title" = "Język"; "language_subtitle" = "Zmień język interfejsu. Aby zmiana zaczęła w pełni obowiązywać, uruchom aplikację ponownie."; +"currency_title" = "Preferowana waluta"; +"currency_subtitle" = "Waluta szacowanych kosztów i wydatków. Używa kursów aktualizowanych codziennie."; +"currency_auto" = "Automatycznie (według dostawcy / USD)"; "language_system" = "System"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index fab4035f02..434ba9148c 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -428,6 +428,9 @@ "section_agent_sessions" = "Sessões de agentes"; "language_title" = "Idioma"; "language_subtitle" = "Altera o idioma de exibição. Requer reiniciar o app para ter efeito completo."; +"currency_title" = "Moeda preferida"; +"currency_subtitle" = "Moeda para estimativas de custo e gastos. Usa taxas de câmbio atualizadas diariamente."; +"currency_auto" = "Automático (seguir provedor / USD)"; "language_system" = "Sistema"; "language_english" = "Inglês"; "language_spanish" = "Espanhol"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 5da7117d9e..a1015d75f2 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -431,6 +431,9 @@ "section_agent_sessions" = "Сеансы агентов"; "language_title" = "Язык"; "language_subtitle" = "Изменяет язык интерфейса. Для полного применения нужен перезапуск приложения."; +"currency_title" = "Предпочитаемая валюта"; +"currency_subtitle" = "Валюта для оценки затрат и расходов. Используются курсы, обновляемые ежедневно."; +"currency_auto" = "Автоматически (валюта провайдера / USD)"; "language_system" = "Системный"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index a22447abf3..541451b974 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -429,6 +429,9 @@ "section_agent_sessions" = "Agentsessioner"; "language_title" = "Språk"; "language_subtitle" = "Byt visningsspråk. Appen behöver startas om för att ändringen ska slå igenom helt."; +"currency_title" = "Önskad valuta"; +"currency_subtitle" = "Valuta för kostnadsuppskattningar och utgifter. Använder växelkurser som uppdateras dagligen."; +"currency_auto" = "Automatiskt (följ leverantör / USD)"; "language_system" = "System"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 13159db68d..4e0f2ff782 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -415,6 +415,9 @@ "section_agent_sessions" = "เซสชันเอเจนต์"; "language_title" = "ภาษา"; "language_subtitle" = "เปลี่ยนภาษาที่แสดง ต้องรีสตาร์ทแอปเพื่อให้มีผลเต็มที่"; +"currency_title" = "สกุลเงินที่ต้องการ"; +"currency_subtitle" = "สกุลเงินสำหรับประมาณการต้นทุนและค่าใช้จ่าย ใช้อัตราแลกเปลี่ยนที่อัปเดตทุกวัน"; +"currency_auto" = "อัตโนมัติ (ตามผู้ให้บริการ / USD)"; "language_system" = "ระบบ"; "language_english" = "อังกฤษ"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index e7ced159dd..48a5096e45 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -413,6 +413,9 @@ "section_agent_sessions" = "Ajan oturumları"; "language_title" = "Dil"; "language_subtitle" = "Görüntüleme dilini değiştirin. Tam olarak geçerli olması için uygulamanın yeniden başlatılması gerekir."; +"currency_title" = "Tercih edilen para birimi"; +"currency_subtitle" = "Maliyet tahminleri ve harcamalar için para birimi. Günlük güncellenen kurları kullanır."; +"currency_auto" = "Otomatik (sağlayıcı / USD)"; "language_system" = "Sistem"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 28fa543599..1915ea7bf9 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -428,6 +428,9 @@ "section_agent_sessions" = "Сеанси агентів"; "language_title" = "Мова"; "language_subtitle" = "Змінює мову інтерфейсу. Для повного застосування потрібно перезапустити застосунок."; +"currency_title" = "Бажана валюта"; +"currency_subtitle" = "Валюта для оцінки вартості та витрат. Використовує курси, що оновлюються щодня."; +"currency_auto" = "Автоматично (валюта постачальника / USD)"; "language_system" = "Система"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index e6c6fa03b9..02c9b8fdf3 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -428,6 +428,9 @@ "section_agent_sessions" = "Phiên tác nhân"; "language_title" = "Ngôn ngữ"; "language_subtitle" = "Thay đổi ngôn ngữ hiển thị. Yêu cầu khởi động lại ứng dụng để có hiệu lực đầy đủ."; +"currency_title" = "Tiền tệ ưu tiên"; +"currency_subtitle" = "Tiền tệ dùng cho ước tính chi phí và chi tiêu. Sử dụng tỷ giá được cập nhật hằng ngày."; +"currency_auto" = "Tự động (theo nhà cung cấp / USD)"; "language_system" = "Hệ thống"; "language_english" = "Tiếng Anh"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 85f686be4a..ec01ed18f3 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -436,6 +436,9 @@ "section_agent_sessions" = "Agent 工作階段"; "language_title" = "語言"; "language_subtitle" = "更改顯示語言。需要重新啟動 App 才會完全生效。"; +"currency_title" = "偏好貨幣"; +"currency_subtitle" = "用於費用估算與支出指標的貨幣。使用每日更新的即時匯率。"; +"currency_auto" = "自動(依供應商 / USD)"; "language_system" = "依照系統"; "language_english" = "English"; "language_spanish" = "Español"; diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 6f9ec8c361..8b7de621e3 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -5,6 +5,7 @@ import Observation struct SpendDashboardConfiguration: Equatable, Sendable { let costUsageEnabled: Bool + let preferredCurrencyCode: String let providerIDs: [String] let codexAccountIdentities: [String] let codexAccountDisplayNames: [String: String] @@ -13,6 +14,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { init( costUsageEnabled: Bool, + preferredCurrencyCode: String = "auto", providerIDs: [String], codexAccountIdentities: [String], codexAccountDisplayNames: [String: String] = [:], @@ -20,6 +22,7 @@ struct SpendDashboardConfiguration: Equatable, Sendable { sourceRevisions: [String] = []) { self.costUsageEnabled = costUsageEnabled + self.preferredCurrencyCode = preferredCurrencyCode self.providerIDs = providerIDs self.codexAccountIdentities = codexAccountIdentities self.codexAccountDisplayNames = codexAccountDisplayNames @@ -142,6 +145,7 @@ enum SpendDashboardSource { { SpendDashboardConfiguration( costUsageEnabled: settings.costUsageEnabled, + preferredCurrencyCode: settings.preferredCurrencyCode, providerIDs: providers.map(\.rawValue), codexAccountIdentities: codexRequests.map { "\($0.id)|\($0.cacheIdentity)" }, codexAccountDisplayNames: self.codexDisplayNamesByID(codexRequests), @@ -964,7 +968,8 @@ final class SpendDashboardController { self.model = SpendDashboardModel.build( inputs: self.loadedInputs, requestedDays: self.selectedDays, - now: self.loadedAt) + now: self.loadedAt, + preferredCurrencyCode: self.configuration?.preferredCurrencyCode ?? "auto") } private func refreshRetainedCodexDisplayNames(_ displayNamesByID: [String: String]) { diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index c3ed207f96..96d3b5db9a 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -89,19 +89,30 @@ struct SpendDashboardModel: Equatable, Sendable { inputs: [ProviderInput], requestedDays: Int, now: Date, - calendar: Calendar = .current) -> Self + calendar: Calendar = .current, + preferredCurrencyCode: String = "auto") -> Self { let days = max(1, min(30, requestedDays)) let calculationCalendar = Self.gregorianCalendar(timeZone: calendar.timeZone) - let classifiedInputs = inputs.compactMap { input -> (currencyCode: String, input: ProviderInput)? in - guard let currencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } - return (currencyCode, input) + let classifiedInputs = inputs.compactMap { input -> ClassifiedInput? in + guard let sourceCurrencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } + let targetCurrencyCode = UsageFormatter.effectiveCurrencyCode( + preferred: preferredCurrencyCode, + providerCurrency: sourceCurrencyCode) + let conversion = CurrencyExchange.shared.convert( + amount: 1, + from: sourceCurrencyCode, + to: targetCurrencyCode) + return ClassifiedInput( + currencyCode: conversion == nil ? sourceCurrencyCode : targetCurrencyCode, + input: input, + costMultiplier: conversion ?? 1) } let groups = Dictionary(grouping: classifiedInputs, by: { $0.currencyCode }) .map { currencyCode, inputs in Self.buildCurrencyGroup( currencyCode: currencyCode, - inputs: inputs.map(\.input), + inputs: inputs, days: days, now: now, calendar: calculationCalendar) @@ -110,8 +121,15 @@ struct SpendDashboardModel: Equatable, Sendable { return Self(requestedDays: days, groups: groups) } + private struct ClassifiedInput { + let currencyCode: String + let input: ProviderInput + let costMultiplier: Double + } + private struct InputSummary { let input: ProviderInput + let costMultiplier: Double let entries: [WindowEntry] let totalTokens: Int? let totalCost: Double? @@ -162,14 +180,18 @@ struct SpendDashboardModel: Equatable, Sendable { private static func buildCurrencyGroup( currencyCode: String, - inputs: [ProviderInput], + inputs: [ClassifiedInput], days: Int, now: Date, calendar: Calendar) -> CurrencyGroup { let bounds = Self.bounds(days: days, now: now, calendar: calendar) - let summaries = inputs.map { input in - Self.inputSummary(input: input, bounds: bounds, calendar: calendar) + let summaries = inputs.map { classified in + Self.inputSummary( + input: classified.input, + costMultiplier: classified.costMultiplier, + bounds: bounds, + calendar: calendar) } let providers = Self.providerRows(summaries) let completeModelSummaries = summaries.filter { summary in @@ -195,6 +217,7 @@ struct SpendDashboardModel: Equatable, Sendable { private static func inputSummary( input: ProviderInput, + costMultiplier: Double, bounds: ClosedRange, calendar: Calendar) -> InputSummary { @@ -234,9 +257,12 @@ struct SpendDashboardModel: Equatable, Sendable { ? nil : entries.isEmpty ? (coveredDayCount > 0 && hasCompleteCostHistory ? 0 : nil) - : Self.completeCostSum(entries.map { Self.validCost($0.entry.costUSD) }) + : Self.completeCostSum(entries.map { + Self.validCost($0.entry.costUSD).map { $0 * costMultiplier } + }) return InputSummary( input: input, + costMultiplier: costMultiplier, entries: entries, totalTokens: totalTokens, totalCost: totalCost, @@ -301,7 +327,7 @@ struct SpendDashboardModel: Equatable, Sendable { } else { aggregate.invalidTokens = true } - if let cost = Self.validCost(breakdown.costUSD) { + if let cost = Self.validCost(breakdown.costUSD).map({ $0 * summary.costMultiplier }) { aggregate.sawCost = true aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowedCost) } else { @@ -482,7 +508,7 @@ struct SpendDashboardModel: Equatable, Sendable { provider: input.provider, providerName: input.displayName, cost: 0) - if let cost = Self.validCost(entry.costUSD) { + if let cost = Self.validCost(entry.costUSD).map({ $0 * summary.costMultiplier }) { aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowed) } else { aggregate.invalid = true diff --git a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift index 0fa18f2cf5..f39fbd21f1 100644 --- a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift +++ b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift @@ -90,17 +90,20 @@ extension StatusItemController { -> (today: String?, last30Days: String?) { let snapshot = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot - let providerCostCurrency = self.store.snapshot(for: provider)?.providerCost?.currencyCode - let preferred = self.settings.preferredCurrencyCode - let currencyCode = preferred != "auto" && !preferred.isEmpty - ? preferred - : (providerCostCurrency ?? snapshot?.currencyCode ?? "USD") + let sourceCurrencyCode = snapshot?.currencyCode ?? "USD" + let preferredCurrencyCode = self.settings.preferredCurrencyCode let today = MenuBarLayoutCostResolver.todayCostUSD(snapshot: snapshot, now: now).map { - UsageFormatter.convertedCostString($0, targetCurrency: currencyCode) + UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: sourceCurrencyCode) } let last30Days = snapshot?.last30DaysCostUSD.map { - UsageFormatter.convertedCostString($0, targetCurrency: currencyCode) + UsageFormatter.convertedCostString( + $0, + preferredCurrency: preferredCurrencyCode, + providerCurrency: sourceCurrencyCode) } return (today, last30Days) } diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 06e5cbcead..6e53de36b4 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -186,8 +186,8 @@ extension StatusItemController { let forecast: SessionEquivalentForecast? = if let codexProjection, let session = codexProjection .rateWindow(for: .session), - let weekly = codexProjection - .rateWindow(for: .weekly) + let weekly = codexProjection + .rateWindow(for: .weekly) { self.store.sessionEquivalentForecast( provider: target, @@ -197,7 +197,7 @@ extension StatusItemController { now: now) } else if let snapshot, let windows = self.store.sessionEquivalentWindows( - provider: target, snapshot: snapshot) + provider: target, snapshot: snapshot) { self.store.sessionEquivalentForecast( provider: target, diff --git a/Sources/CodexBarCore/CurrencyExchange.swift b/Sources/CodexBarCore/CurrencyExchange.swift index 181405fb8d..5ed4221375 100644 --- a/Sources/CodexBarCore/CurrencyExchange.swift +++ b/Sources/CodexBarCore/CurrencyExchange.swift @@ -16,8 +16,8 @@ public final class CurrencyExchange: @unchecked Sendable { ] private let lock = NSLock() - // Hardcoded fallback rates (approximate mid-market rates as of 2025-07). - // These are only used when no cached or live rates are available. + /// Hardcoded fallback rates (approximate mid-market rates as of 2025-07). + /// These are only used when no cached or live rates are available. private var rates: [String: Double] = [ "USD": 1.0, "GBP": 0.79, @@ -41,7 +41,9 @@ public final class CurrencyExchange: @unchecked Sendable { } /// Converts a USD amount to the specified target currency code. - public func convert(usdAmount: Double, to currencyCode: String) -> Double { + /// Returns `nil` when the requested rate is unavailable so callers cannot + /// accidentally relabel the unchanged amount as the target currency. + public func convert(usdAmount: Double, to currencyCode: String) -> Double? { let code = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() guard !code.isEmpty, code != "USD" else { return usdAmount } @@ -49,14 +51,14 @@ public final class CurrencyExchange: @unchecked Sendable { let rate = self.rates[code] self.lock.unlock() - guard let rate else { return usdAmount } + guard let rate else { return nil } return usdAmount * rate } /// Converts an amount from one currency to another via USD as the pivot. /// For example, `convert(amount: 10, from: "GBP", to: "CNY")` converts £10 to yuan. - /// Falls back to the original amount when either currency rate is unavailable. - public func convert(amount: Double, from sourceCurrency: String, to targetCurrency: String) -> Double { + /// Returns `nil` when either currency rate is unavailable. + public func convert(amount: Double, from sourceCurrency: String, to targetCurrency: String) -> Double? { let source = sourceCurrency.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() let target = targetCurrency.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() guard !source.isEmpty, !target.isEmpty, source != target else { return amount } @@ -66,7 +68,7 @@ public final class CurrencyExchange: @unchecked Sendable { let targetRate = target == "USD" ? 1.0 : self.rates[target] self.lock.unlock() - guard let sourceRate, let targetRate, sourceRate > 0 else { return amount } + guard let sourceRate, let targetRate, sourceRate > 0 else { return nil } let usdAmount = amount / sourceRate return usdAmount * targetRate } diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 77523d8fb9..f81fe62976 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -226,8 +226,11 @@ public enum UsageFormatter { /// Formats a USD value into a target currency code with exchange rate conversion applied. public static func convertedCostString(_ usdValue: Double, targetCurrency: String) -> String { - let converted = CurrencyExchange.shared.convert(usdAmount: usdValue, to: targetCurrency) - return self.currencyString(converted, currencyCode: targetCurrency) + let converted = Self.convertedCost( + usdValue, + preferredCurrency: targetCurrency, + providerCurrency: "USD") + return self.currencyString(converted.value, currencyCode: converted.currencyCode) } /// Formats a value from one currency into another via USD pivot conversion. @@ -238,7 +241,13 @@ public enum UsageFormatter { fromCurrency: String, targetCurrency: String) -> String { - let converted = CurrencyExchange.shared.convert(amount: value, from: fromCurrency, to: targetCurrency) + guard let converted = CurrencyExchange.shared.convert( + amount: value, + from: fromCurrency, + to: targetCurrency) + else { + return self.currencyString(value, currencyCode: fromCurrency) + } return self.currencyString(converted, currencyCode: targetCurrency) } @@ -263,12 +272,33 @@ public enum UsageFormatter { preferredCurrency: String, providerCurrency: String?) -> String { - let effective = Self.effectiveCurrencyCode(preferred: preferredCurrency, providerCurrency: providerCurrency) + let converted = Self.convertedCost( + value, + preferredCurrency: preferredCurrency, + providerCurrency: providerCurrency) + return Self.currencyString(converted.value, currencyCode: converted.currencyCode) + } + + /// Resolves and converts a numeric cost while preserving its source currency + /// when the requested exchange rate is unavailable. + public static func convertedCost( + _ value: Double, + preferredCurrency: String, + providerCurrency: String?) -> (value: Double, currencyCode: String) + { let sourceCurrency = providerCurrency ?? "USD" - guard effective == sourceCurrency else { - return Self.convertedCostString(value, fromCurrency: sourceCurrency, targetCurrency: effective) + let targetCurrency = Self.effectiveCurrencyCode( + preferred: preferredCurrency, + providerCurrency: providerCurrency) + guard targetCurrency != sourceCurrency, + let converted = CurrencyExchange.shared.convert( + amount: value, + from: sourceCurrency, + to: targetCurrency) + else { + return (value, sourceCurrency) } - return Self.currencyString(value, currencyCode: effective) + return (converted, targetCurrency) } /// Formats a USD value with proper negative handling and thousand separators. diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift index 1f4e853813..f023ad440d 100644 --- a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift +++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift @@ -53,6 +53,63 @@ struct InlineCostHistoryDashboardLabelTests { #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-15: $0.25") } + @Test + func `local cost history converts from snapshot currency into preferred currency`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 100, + sessionCostUSD: 10, + last30DaysTokens: 100, + last30DaysCostUSD: 10, + currencyCode: "EUR", + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 75, + outputTokens: 25, + totalTokens: 100, + costUSD: 10, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot(primary: nil, secondary: nil, updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + preferredCurrencyCode: "USD", + now: now)) + + let expected = UsageFormatter.convertedCostString( + 10, + preferredCurrency: "USD", + providerCurrency: "EUR") + let expectedValue = UsageFormatter.convertedCost( + 10, + preferredCurrency: "USD", + providerCurrency: "EUR").value + #expect(model.inlineUsageDashboard?.currencyCode == "USD") + #expect(model.inlineUsageDashboard?.kpis.first?.value == expected) + #expect(model.inlineUsageDashboard?.points.first?.value == expectedValue) + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-15: \(expected)") + } + @Test func `local cost history KPI titles preserve one day and dynamic windows`() throws { let now = Date(timeIntervalSince1970: 1_700_179_200) diff --git a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift index 496b297396..ea29bd0f58 100644 --- a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift +++ b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift @@ -203,6 +203,29 @@ struct SpendDashboardDateTruthTests { #expect(usd.totalCost == 2) } + @Test + func `preferred currency combines convertible dashboard groups and preserves unavailable sources`() throws { + let eurRate = try #require(CurrencyExchange.shared.rate(for: "EUR")) + let model = SpendDashboardModel.build( + inputs: [ + Self.input(id: "usd", provider: .claude, currency: "USD", cost: 2), + Self.input(id: "eur", provider: .codex, currency: "EUR", cost: 3), + Self.input(id: "chf", provider: .mistral, currency: "CHF", cost: 5), + ], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar, + preferredCurrencyCode: "USD") + + #expect(model.groups.map(\.currencyCode) == ["CHF", "USD"]) + let chf = try #require(model.groups.first(where: { $0.currencyCode == "CHF" })) + let usd = try #require(model.groups.first(where: { $0.currencyCode == "USD" })) + #expect(chf.totalCost == 5) + #expect(usd.providers.map(\.id).sorted() == ["eur", "usd"]) + #expect(abs((usd.totalCost ?? 0) - (2 + 3 / eurRate)) < 1e-9) + #expect(abs((usd.dailyPoints.map(\.cost).reduce(0, +)) - (2 + 3 / eurRate)) < 1e-9) + } + @Test func `date with a valid prefix and trailing junk fails closed`() throws { let snapshot = Self.snapshot(currency: "USD", entries: [ diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 53f7143a4b..7d7eb58c44 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -474,22 +474,22 @@ struct UsageFormatterTests { let exchange = CurrencyExchange.shared let epsilon = 1e-9 // USD → USD is identity - #expect(abs(exchange.convert(usdAmount: 10.0, to: "USD") - 10.0) < epsilon) + #expect(abs((exchange.convert(usdAmount: 10.0, to: "USD") ?? 0) - 10.0) < epsilon) // Cross-currency conversion via USD pivot let gbpRate = exchange.rate(for: "GBP") ?? 0.79 let eurRate = exchange.rate(for: "EUR") ?? 0.92 - #expect(abs(exchange.convert(usdAmount: 10.0, to: "GBP") - 10.0 * gbpRate) < epsilon) - #expect(abs(exchange.convert(usdAmount: 10.0, to: "EUR") - 10.0 * eurRate) < epsilon) + #expect(abs((exchange.convert(usdAmount: 10.0, to: "GBP") ?? 0) - 10.0 * gbpRate) < epsilon) + #expect(abs((exchange.convert(usdAmount: 10.0, to: "EUR") ?? 0) - 10.0 * eurRate) < epsilon) // Cross-currency: GBP → EUR let gbpToEur = exchange.convert(amount: 10.0, from: "GBP", to: "EUR") let expectedGbpToEur = 10.0 / gbpRate * eurRate - #expect(abs(gbpToEur - expectedGbpToEur) < epsilon) + #expect(abs((gbpToEur ?? 0) - expectedGbpToEur) < epsilon) // GBP → USD cross-currency let gbpToUsd = exchange.convert(amount: 10.0, from: "GBP", to: "USD") - #expect(abs(gbpToUsd - 10.0 / gbpRate) < epsilon) + #expect(abs((gbpToUsd ?? 0) - 10.0 / gbpRate) < epsilon) // Formatting let gbpFormatted = UsageFormatter.convertedCostString(10.0, targetCurrency: "GBP") @@ -504,6 +504,14 @@ struct UsageFormatterTests { let explicitCNY = UsageFormatter.convertedCostString(10.0, preferredCurrency: "CNY", providerCurrency: "USD") #expect(explicitCNY.contains("¥")) + + #expect(exchange.convert(amount: 10.0, from: "CHF", to: "USD") == nil) + let unavailable = UsageFormatter.convertedCostString( + 10.0, + preferredCurrency: "USD", + providerCurrency: "CHF") + #expect(unavailable.contains("CHF")) + #expect(!unavailable.contains("$")) } @Test From 75ef604f82c3f8d7472a6654a5fe3a235655b115 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:06:40 +0800 Subject: [PATCH 4/6] fix: support currency rate fetches on Linux --- Sources/CodexBarCore/CurrencyExchange.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/CodexBarCore/CurrencyExchange.swift b/Sources/CodexBarCore/CurrencyExchange.swift index 5ed4221375..2b434b7d50 100644 --- a/Sources/CodexBarCore/CurrencyExchange.swift +++ b/Sources/CodexBarCore/CurrencyExchange.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Manages currency exchange rates for converting USD-denominated AI model token estimates /// into user-preferred currencies (GBP, EUR, CNY, JPY, CAD, AUD, etc.). From 98272df48dec138b535b98e3d16afea4adb1039d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 08:56:26 -0700 Subject: [PATCH 5/6] fix: make currency rates opt in --- Sources/CodexBar/CodexbarApp.swift | 7 ++++--- Sources/CodexBar/MenuCardView+ModelInput.swift | 2 +- Sources/CodexBar/PreferencesGeneralPane.swift | 6 ++++-- Sources/CodexBar/SettingsStore.swift | 2 +- Sources/CodexBarCore/CurrencyExchange.swift | 11 +++++++++-- .../SettingsStoreCoverageTests.swift | 15 +++++++++++++++ Tests/CodexBarTests/UsageFormatterTests.swift | 10 ++++++++++ 7 files changed, 44 insertions(+), 9 deletions(-) diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index d761a2ac58..af17b6f99e 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -422,9 +422,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let settings = self?.settings else { return } AdaptiveActivityConsentPresenter.presentIfNeeded(settings: settings) AppNotifications.shared.requestAuthorizationOnStartup() - // Prefetch exchange rates at launch so conversion is instant when the user - // switches to a non-USD currency. Uses a 24 h cache so subsequent calls are no-ops. - await CurrencyExchange.shared.fetchLatestRatesIfNeeded() + // A persisted non-USD choice opts into the daily exchange-rate refresh. The service + // returns before networking for the default USD setting and Auto. + await CurrencyExchange.shared.fetchLatestRatesIfNeeded( + preferredCurrencyCode: settings.preferredCurrencyCode) } KeyboardShortcuts.onKeyUp(for: .openMenu) { [weak self] in // KeyboardShortcuts dispatches both normal and menu-tracking hotkeys on the main event loop. diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift index b3f75642ec..27b80a908a 100644 --- a/Sources/CodexBar/MenuCardView+ModelInput.swift +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -101,7 +101,7 @@ extension UsageMenuCardView.Model { self.tokenCostUsageEnabled = tokenCostUsageEnabled self.codexLocalSessionCostLedgerEnabled = codexLocalSessionCostLedgerEnabled self.tokenCostInlineDashboardEnabled = tokenCostInlineDashboardEnabled ?? tokenCostUsageEnabled - self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? (tokenCostUsageEnabled && snapshot != nil) + self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? tokenCostUsageEnabled self.costComparisonPeriodsEnabled = costComparisonPeriodsEnabled self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage self.claudeDailyRoutinesUsageVisible = claudeDailyRoutinesUsageVisible diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 7a924d0c07..eb9e848534 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -138,8 +138,10 @@ struct GeneralPane: View { Text(verbatim: PreferredCurrencyOption(rawValue: rawValue)?.label ?? rawValue) }) .onChange(of: self.settings.preferredCurrencyCode) { _, newValue in - guard newValue != "auto" else { return } - Task { await CurrencyExchange.shared.fetchLatestRatesIfNeeded() } + Task { + await CurrencyExchange.shared.fetchLatestRatesIfNeeded( + preferredCurrencyCode: newValue) + } } SettingsMenuPicker( diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 3deb4f6fe0..f453205888 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -530,7 +530,7 @@ extension SettingsStore { let agentSessionLabelStyleRaw = userDefaults.string(forKey: "agentSessionLabelStyle") ?? AgentSessionLabelStyle.project.rawValue let agentSessionsManualHosts = userDefaults.string(forKey: "agentSessionsManualHosts") ?? "" - let preferredCurrencyCode = userDefaults.string(forKey: "preferredCurrencyCode") ?? "auto" + let preferredCurrencyCode = userDefaults.string(forKey: "preferredCurrencyCode") ?? "USD" return SettingsDefaultsState( refreshFrequency: refreshFrequency, adaptiveActivityScanConsent: adaptiveActivityScanConsent, diff --git a/Sources/CodexBarCore/CurrencyExchange.swift b/Sources/CodexBarCore/CurrencyExchange.swift index 2b434b7d50..b6e47a5301 100644 --- a/Sources/CodexBarCore/CurrencyExchange.swift +++ b/Sources/CodexBarCore/CurrencyExchange.swift @@ -114,12 +114,19 @@ public final class CurrencyExchange: @unchecked Sendable { } } - /// Asynchronously fetches latest rates from open.er-api.com if cache is older than 24 hours. + public static func requiresLiveRates(preferredCurrencyCode: String) -> Bool { + let code = preferredCurrencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + return code != "AUTO" && code != "USD" && Self.supportedCurrencies.contains(code) + } + + /// Asynchronously fetches latest rates from open.er-api.com if the user selected a + /// non-USD currency and the cache is older than 24 hours. /// /// The ExchangeRate-API free tier provides daily-updated rates sourced from central banks /// and financial data providers. This is sufficient for cost estimation purposes. /// On failure, the previously cached (or hardcoded fallback) rates remain in use. - public func fetchLatestRatesIfNeeded() async { + public func fetchLatestRatesIfNeeded(preferredCurrencyCode: String) async { + guard Self.requiresLiveRates(preferredCurrencyCode: preferredCurrencyCode) else { return } if let lastFetch = self.getLastFetchTime(), Date().timeIntervalSince(lastFetch) < 86400 { return } diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index fa676ec483..3aa84a5b23 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -820,6 +820,21 @@ struct SettingsStoreCoverageTests { #expect(reloaded4.weeklyProgressWorkDays == nil) } + @Test + func `preferred currency defaults to USD and persists an explicit selection`() throws { + let suite = "SettingsStoreCoverageTests-preferred-currency" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let fresh = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(fresh.preferredCurrencyCode == "USD") + + fresh.preferredCurrencyCode = "GBP" + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.preferredCurrencyCode == "GBP") + } + private static func makeSettingsStore( suiteName: String = "SettingsStoreCoverageTests", antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore = AntigravityOAuthCredentialsStore()) diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 7d7eb58c44..c12d70e3de 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -514,6 +514,16 @@ struct UsageFormatterTests { #expect(!unavailable.contains("$")) } + @Test + func `live exchange rates require an explicit non USD currency`() { + #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "USD")) + #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: " usd ")) + #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "auto")) + #expect(!CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "CHF")) + #expect(CurrencyExchange.requiresLiveRates(preferredCurrencyCode: "GBP")) + #expect(CurrencyExchange.requiresLiveRates(preferredCurrencyCode: " eur ")) + } + @Test func `usage formatter localization keys exist in en and zh Hans with matching placeholders`() throws { let root = URL(fileURLWithPath: #filePath) From d6061833b1ef213ddafdfe953b145aab2b69fc28 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 08:59:31 -0700 Subject: [PATCH 6/6] fix: guard currency rate fetch call sites --- Sources/CodexBar/CodexbarApp.swift | 3 +++ Sources/CodexBar/PreferencesGeneralPane.swift | 1 + 2 files changed, 4 insertions(+) diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index af17b6f99e..e46362d36e 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -424,6 +424,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { AppNotifications.shared.requestAuthorizationOnStartup() // A persisted non-USD choice opts into the daily exchange-rate refresh. The service // returns before networking for the default USD setting and Auto. + guard CurrencyExchange.requiresLiveRates( + preferredCurrencyCode: settings.preferredCurrencyCode) + else { return } await CurrencyExchange.shared.fetchLatestRatesIfNeeded( preferredCurrencyCode: settings.preferredCurrencyCode) } diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index eb9e848534..4b2b0e750d 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -138,6 +138,7 @@ struct GeneralPane: View { Text(verbatim: PreferredCurrencyOption(rawValue: rawValue)?.label ?? rawValue) }) .onChange(of: self.settings.preferredCurrencyCode) { _, newValue in + guard CurrencyExchange.requiresLiveRates(preferredCurrencyCode: newValue) else { return } Task { await CurrencyExchange.shared.fetchLatestRatesIfNeeded( preferredCurrencyCode: newValue)