Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions mac/Sources/CodeBurnMenubar/Views/HeroSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 28 additions & 11 deletions mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down