diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index aab72636..c2c64124 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -58,6 +58,38 @@ final class AppStore { var dailyBudget: Double = UserDefaults.standard.double(forKey: "CodeBurnDailyBudget") { didSet { UserDefaults.standard.set(dailyBudget, forKey: "CodeBurnDailyBudget") } } + // Token-denominated daily budget, used when the display metric is token-based. + // Stored separately from the cost budget so switching metric never reinterprets + // a dollar threshold as a token count (or vice versa). + var dailyTokenBudget: Double = UserDefaults.standard.double(forKey: "CodeBurnDailyTokenBudget") { + didSet { UserDefaults.standard.set(dailyTokenBudget, forKey: "CodeBurnDailyTokenBudget") } + } + + /// True when the menubar metric counts tokens rather than cost. + var isTokenMetric: Bool { displayMetric == .tokens || displayMetric == .totalTokens } + + /// Active daily-budget threshold for the current metric: a token count when + /// tracking tokens, otherwise USD cost. 0 means the alert is off. + var activeDailyBudget: Double { isTokenMetric ? dailyTokenBudget : dailyBudget } + + /// Today's total in the active metric (USD cost, or input+output tokens), + /// or nil when today's payload has not loaded yet. + var todayMetricTotal: Double? { + guard let current = todayPayload?.current else { return nil } + return isTokenMetric ? Double(current.inputTokens + current.outputTokens) : current.cost + } + + /// True when today's usage has reached or passed the active daily budget. + var isOverDailyBudget: Bool { + guard activeDailyBudget > 0, let total = todayMetricTotal else { return false } + return total >= activeDailyBudget + } + + /// The active daily-budget threshold formatted for display (currency or tokens). + var dailyBudgetLabel: String { + isTokenMetric ? "\(activeDailyBudget.asCompactTokens()) tokens" : activeDailyBudget.asCurrency() + } + var isLoading: Bool { loadingCountsByKey.values.contains { $0 > 0 } } var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 } var hasAttemptedCurrentKeyLoad: Bool { attemptedKeys.contains(currentKey) } @@ -1228,6 +1260,14 @@ private let thousandsFormatter: NumberFormatter = { let state = CurrencyState.shared return "\(state.symbol)\(Int((self * state.rate).rounded()))" } + + func asCompactTokens() -> String { + let n = self + if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) } + if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) } + if n >= 1_000 { return String(format: "%.0fK", n / 1_000) } + return String(format: "%.0f", n) + } } extension Int { diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index d4303871..e3fbab43 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -589,6 +589,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { _ = self.store.currency _ = self.store.displayMetric _ = self.store.dailyBudget + _ = self.store.dailyTokenBudget // Track the live-quota state too so the flame icon re-tints on // every subscription / codex usage update, not just every 30s. _ = self.store.subscription @@ -692,8 +693,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // user gets a glanceable signal even when the menu bar is busy. let aggregate = store.aggregateQuotaStatus var tint = Self.flameTint(for: aggregate.severity) - if tint == nil, store.dailyBudget > 0, - let todayCost = store.todayPayload?.current.cost, todayCost >= store.dailyBudget { + if tint == nil, store.isOverDailyBudget { tint = NSColor.systemYellow } let flameConfig: NSImage.SymbolConfiguration diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift index 431bde94..de4f24a4 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -55,13 +55,11 @@ struct HeroSection: View { if !store.isDayMode, store.selectedPeriod == .today, - store.dailyBudget > 0, - let todayCost = store.todayPayload?.current.cost, - todayCost >= store.dailyBudget { + store.isOverDailyBudget { HStack(spacing: 4) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 10)) - Text("Daily budget of \(store.dailyBudget.asCurrency()) exceeded") + Text("Daily budget of \(store.dailyBudgetLabel) exceeded") .font(.system(size: 11, weight: .medium)) } .foregroundStyle(.orange) diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 53c1755e..cb664bfe 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -73,18 +73,35 @@ private struct GeneralSettingsTab: View { } Section("Alerts") { - Picker("Daily budget", selection: Binding( - get: { store.dailyBudget }, - set: { store.dailyBudget = $0 } - )) { - Text("Off").tag(0.0) - Text("$25").tag(25.0) - Text("$50").tag(50.0) - Text("$100").tag(100.0) - Text("$200").tag(200.0) - Text("$500").tag(500.0) + // The budget tracks whatever the menubar metric shows: dollars for + // the Cost metric, tokens for the Tokens / Total Tokens metrics. + if store.isTokenMetric { + Picker("Daily budget", selection: Binding( + get: { store.dailyTokenBudget }, + set: { store.dailyTokenBudget = $0 } + )) { + Text("Off").tag(0.0) + Text("1M").tag(1_000_000.0) + Text("5M").tag(5_000_000.0) + Text("10M").tag(10_000_000.0) + Text("25M").tag(25_000_000.0) + Text("50M").tag(50_000_000.0) + Text("100M").tag(100_000_000.0) + } + } else { + Picker("Daily budget", selection: Binding( + get: { store.dailyBudget }, + set: { store.dailyBudget = $0 } + )) { + Text("Off").tag(0.0) + Text("$25").tag(25.0) + Text("$50").tag(50.0) + Text("$100").tag(100.0) + Text("$200").tag(200.0) + Text("$500").tag(500.0) + } } - Text("Flame icon turns yellow when you pass the daily budget.") + Text("Flame icon turns yellow when today's \(store.isTokenMetric ? "tokens" : "cost") pass the daily budget.") .font(.system(size: 11)) .foregroundStyle(.secondary) }