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
48 changes: 37 additions & 11 deletions mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@ final class AppStore {
}
var showingAccentPicker: Bool = false
var currency: String = "USD"
var isLoading: Bool = false
var isLoading: Bool { loadingCount > 0 }
private var loadingCount: Int = 0
var lastError: String?
var subscription: SubscriptionUsage?
var subscriptionError: String?
var subscriptionLoadState: SubscriptionLoadState = .idle
var capacityEstimates: [String: CapacityEstimate] = [:]

private var cache: [PayloadCacheKey: CachedPayload] = [:]
private var switchTask: Task<Void, Never>?

private var currentKey: PayloadCacheKey {
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider)
Expand Down Expand Up @@ -62,16 +64,37 @@ final class AppStore {
payload.optimize.findingCount
}

/// Switch to a period. Always fetches fresh data so the user never sees stale numbers.
func switchTo(period: Period) async {
/// Switch to a period. Cancels any in-flight switch and fetches provider-specific +
/// all-provider data in parallel so tab strip costs stay in sync with the hero.
func switchTo(period: Period) {
selectedPeriod = period
await refresh(includeOptimize: true, force: true)
switchTask?.cancel()
switchTask = Task {
if selectedProvider == .all {
await refresh(includeOptimize: true, force: true)
} else {
async let main: Void = refresh(includeOptimize: true, force: true)
async let all: Void = refreshQuietly(period: period)
_ = await (main, all)
}
}
}

/// Switch to a provider filter. Always fetches fresh data so the user never sees stale numbers.
func switchTo(provider: ProviderFilter) async {
/// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only
/// runs the CLI for the final selection. Fetches provider-specific and all-provider data
/// in parallel so the tab strip costs stay in sync with the hero.
func switchTo(provider: ProviderFilter) {
selectedProvider = provider
await refresh(includeOptimize: true, force: true)
switchTask?.cancel()
switchTask = Task {
if provider == .all {
await refresh(includeOptimize: true, force: true)
} else {
async let main: Void = refresh(includeOptimize: true, force: true)
async let all: Void = refreshQuietly(period: selectedPeriod)
_ = await (main, all)
}
}
}

private var inFlightKeys: Set<PayloadCacheKey> = []
Expand All @@ -85,18 +108,21 @@ final class AppStore {
if !force, cache[key]?.isFresh == true { return }
guard !inFlightKeys.contains(key) else { return }
inFlightKeys.insert(key)
if cache[key] == nil {
isLoading = true
let showedLoading = cache[key] == nil
if showedLoading {
loadingCount += 1
}
defer {
inFlightKeys.remove(key)
isLoading = false
if showedLoading { loadingCount = max(loadingCount - 1, 0) }
}
do {
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
guard !Task.isCancelled else { return }
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
lastError = nil
} catch {
if Task.isCancelled { return }
lastError = String(describing: error)
NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)")
}
Expand Down Expand Up @@ -336,7 +362,7 @@ private let thousandsFormatter: NumberFormatter = {
return f
}()

extension Double {
@MainActor extension Double {
func asCurrency() -> String {
let state = CurrencyState.shared
let converted = self * state.rate
Expand Down
19 changes: 14 additions & 5 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
ProcessInfo.processInfo.disableSuddenTermination()
backgroundActivity = ProcessInfo.processInfo.beginActivity(
options: [.userInitiated, .automaticTerminationDisabled, .suddenTerminationDisabled],
reason: "CodeBurn menubar polls AI coding cost every 15 seconds while idle in the background."
reason: "CodeBurn menubar polls AI coding cost every 30 seconds while idle in the background."
)

restorePersistedCurrency()
Expand Down Expand Up @@ -174,7 +174,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
lastRefreshTime = now

Task {
await store.refresh(includeOptimize: true, force: true)
async let main: Void = store.refresh(includeOptimize: true, force: true)
async let today: Void = store.refreshQuietly(period: .today)
_ = await (main, today)
refreshStatusButton()
}
}
Expand Down Expand Up @@ -202,9 +204,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}

private func startRefreshLoop() {
Task {
await store.refresh(includeOptimize: true)
refreshStatusButton()
Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
if self.store.selectedPeriod != .today || self.store.selectedProvider != .all {
await self.store.refreshQuietly(period: .today)
}
await self.store.refresh(includeOptimize: true, force: true)
self.refreshStatusButton()
try? await Task.sleep(nanoseconds: refreshIntervalNanos)
}
}
}

Expand Down
8 changes: 4 additions & 4 deletions mac/Sources/CodeBurnMenubar/CurrencyState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ private let minValidFXRate: Double = 0.0001
private let maxValidFXRate: Double = 1_000_000
private let fxFetchTimeoutSeconds: TimeInterval = 10

@Observable
final class CurrencyState: @unchecked Sendable {
@MainActor @Observable
final class CurrencyState: Sendable {
static let shared = CurrencyState()

var code: String = "USD"
Expand All @@ -31,7 +31,7 @@ final class CurrencyState: @unchecked Sendable {
}
}

static func symbolForCode(_ code: String) -> String {
nonisolated static func symbolForCode(_ code: String) -> String {
// Some locales return "US$" for USD or "CA$" for CAD via NumberFormatter. Prefer the
// plain glyph form everyone recognises.
if let override = symbolOverrides[code] { return override }
Expand All @@ -42,7 +42,7 @@ final class CurrencyState: @unchecked Sendable {
return formatter.currencySymbol ?? code
}

private static let symbolOverrides: [String: String] = [
nonisolated private static let symbolOverrides: [String: String] = [
"USD": "$",
"CAD": "$",
"AUD": "$",
Expand Down
2 changes: 2 additions & 0 deletions mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import Observation

enum AccentPreset: String, CaseIterable, Identifiable {
case ember = "Ember"
Expand Down Expand Up @@ -72,6 +73,7 @@ enum AccentPreset: String, CaseIterable, Identifiable {
}

@MainActor
@Observable
final class ThemeState {
static let shared = ThemeState()

Expand Down
2 changes: 1 addition & 1 deletion mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ struct AgentTabStrip: View {
HStack(spacing: 5) {
ForEach(visibleFilters) { filter in
Button {
Task { await store.switchTo(provider: filter) }
store.switchTo(provider: filter)
} label: {
AgentTab(
filter: filter,
Expand Down
26 changes: 17 additions & 9 deletions mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ private struct MiniStat: View {
}

private struct TrendBar: Identifiable {
let id = UUID()
var id: String { date }
let date: String
let cost: Double
let inputTokens: Double
Expand Down Expand Up @@ -793,7 +793,7 @@ private struct AllStats {
let historyDayCount: Int
}

private func computeAllStats(payload: MenubarPayload) -> AllStats {
@MainActor private func computeAllStats(payload: MenubarPayload) -> AllStats {
let history = payload.history.daily
let favoriteModel = payload.current.topModels.first?.name ?? "—"

Expand Down Expand Up @@ -848,13 +848,21 @@ private func computeAllStats(payload: MenubarPayload) -> AllStats {

var longestStreak = 0
var running = 0
let sortedDates = history.map(\.date).sorted()
for date in sortedDates {
if (costByDate[date] ?? 0) > 0 {
running += 1
longestStreak = max(longestStreak, running)
} else {
running = 0
if let firstDate = history.map(\.date).min(),
let lastDate = history.map(\.date).max(),
let start = formatter.date(from: firstDate),
let end = formatter.date(from: lastDate) {
var cursor = start
while cursor <= end {
let key = formatter.string(from: cursor)
if (costByDate[key] ?? 0) > 0 {
running += 1
longestStreak = max(longestStreak, running)
} else {
running = 0
}
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
cursor = next
}
}

Expand Down
30 changes: 15 additions & 15 deletions mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ struct MenuBarContent: View {

StarBanner()
}
.id(store.accentPreset)
}

/// True when a specific provider tab is selected and that provider has no spend in the
Expand Down Expand Up @@ -457,7 +456,7 @@ struct FooterBar: View {
Task {
let downloads = (NSHomeDirectory() as NSString).appendingPathComponent("Downloads")
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
let base = "codeburn-\(formatter.string(from: Date()))"
let outputPath = (downloads as NSString).appendingPathComponent(base + format.suffix)

Expand All @@ -466,13 +465,17 @@ struct FooterBar: View {
])

do {
try process.run()
process.waitUntilExit()
if process.terminationStatus == 0 {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)])
} else {
NSLog("CodeBurn: \(format.cliName.uppercased()) export exited with status \(process.terminationStatus)")
let fmt = format
process.terminationHandler = { proc in
Task { @MainActor in
if proc.terminationStatus == 0 {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)])
} else {
NSLog("CodeBurn: \(fmt.cliName.uppercased()) export exited with status \(proc.terminationStatus)")
}
}
}
try process.run()
} catch {
NSLog("CodeBurn: \(format.cliName.uppercased()) export failed: \(error)")
}
Expand All @@ -483,21 +486,18 @@ struct FooterBar: View {
/// thread right away so the UI redraws the next frame, then fetches a fresh rate in the
/// background. CLI config is persisted so other codeburn commands stay in sync.
private func applyCurrency(code: String) {
store.currency = code
let symbol = CurrencyState.symbolForCode(code)

Task {
let cached = await FXRateCache.shared.cachedRate(for: code)
await MainActor.run {
if let cached {
store.currency = code
CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
}

let fresh = await FXRateCache.shared.rate(for: code)
if let fresh, fresh != cached {
await MainActor.run {
CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol)
}
}
store.currency = code
CurrencyState.shared.apply(code: code, rate: fresh ?? cached, symbol: symbol)
}

CLICurrencyConfig.persist(code: code)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ struct PeriodSegmentedControl: View {
HStack(spacing: 1) {
ForEach(Period.allCases) { period in
Button {
Task { await store.switchTo(period: period) }
store.switchTo(period: period)
} label: {
Text(period.rawValue)
.font(.system(size: 11, weight: .medium))
Expand Down
2 changes: 1 addition & 1 deletion src/data/litellm-snapshot.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ export function getShortModelName(model: string): string {
'gpt-5.4-mini': 'GPT-5.4 Mini',
'gpt-5.4': 'GPT-5.4',
'gpt-5.3-codex': 'GPT-5.3 Codex',
'gpt-5.3': 'GPT-5.3',
'gpt-5.2-pro': 'GPT-5.2 Pro',
'gpt-5.2-low': 'GPT-5.2 Low',
'gpt-5.2': 'GPT-5.2',
Expand All @@ -263,6 +264,9 @@ export function getShortModelName(model: string): string {
'gemini-3-flash-preview': 'Gemini 3 Flash',
'gemini-2.5-pro': 'Gemini 2.5 Pro',
'gemini-2.5-flash': 'Gemini 2.5 Flash',
'deepseek-coder-max': 'DeepSeek Coder Max',
'deepseek-coder': 'DeepSeek Coder',
'deepseek-r1': 'DeepSeek R1',
'o4-mini': 'o4-mini',
'o3': 'o3',
'MiniMax-M2.7-highspeed': 'MiniMax M2.7 Highspeed',
Expand Down
Loading