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
188 changes: 130 additions & 58 deletions mac/Sources/CodeBurnMenubar/AppStore.swift

Large diffs are not rendered by default.

68 changes: 7 additions & 61 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var manualRefreshTask: Task<Void, Never>?
private var manualRefreshGeneration: UInt64 = 0
private var claudeQuotaRefreshTask: Task<Bool, Never>?
private var claudeQuotaRefreshGeneration: UInt64 = 0
private var codexQuotaRefreshTask: Task<Bool, Never>?
private var codexQuotaRefreshGeneration: UInt64 = 0
private var refreshLoopHeartbeatAt: Date = .distantPast
private var lastLaunchAgentHeartbeatAt: Date = .distantPast
private var forceRefreshBackoff = RefreshBackoff()

func applicationWillFinishLaunching(_ notification: Notification) {
// Set accessory policy before the app's focus chain forms. On macOS Tahoe
Expand Down Expand Up @@ -171,7 +168,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
manualRefreshTask?.cancel()
manualRefreshTask = nil
manualRefreshGeneration &+= 1
cancelLiveQuotaRefreshTasks()
statusPayloadRefreshTask?.cancel()
statusPayloadRefreshTask = nil
statusPayloadRefreshStartedAt = nil
Expand All @@ -191,7 +187,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
manualRefreshTask?.cancel()
manualRefreshTask = nil
manualRefreshGeneration &+= 1
cancelLiveQuotaRefreshTasks()
statusPayloadRefreshTask?.cancel()
statusPayloadRefreshTask = nil
statusPayloadRefreshStartedAt = nil
Expand Down Expand Up @@ -298,39 +293,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {

private var lastRefreshTime: Date = .distantPast

private func cancelLiveQuotaRefreshTasks() {
claudeQuotaRefreshTask?.cancel()
claudeQuotaRefreshTask = nil
claudeQuotaRefreshGeneration &+= 1
codexQuotaRefreshTask?.cancel()
codexQuotaRefreshTask = nil
codexQuotaRefreshGeneration &+= 1
}

private func isForceRefreshPaused(now: Date = Date()) -> Bool {
if forceRefreshBackoff.isPaused(now: now) {
if let until = forceRefreshBackoff.pausedUntil {
store.pauseAutomaticRefresh(
until: until,
consecutiveStalls: forceRefreshBackoff.consecutiveStalls
)
}
return true
}
store.clearRefreshPause()
return false
}

@discardableResult
private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool {
if forceRefreshTask != nil {
guard let started = forceRefreshStartedAt else {
NSLog("CodeBurn: force refresh task had no start timestamp - clearing")
forceRefreshTask?.cancel()
forceRefreshTask = nil
forceRefreshStartedAt = nil
forceRefreshGeneration &+= 1
cancelLiveQuotaRefreshTasks()
store.resetLoadingState()
return true
}
Expand All @@ -341,16 +311,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
forceRefreshTask = nil
forceRefreshStartedAt = nil
forceRefreshGeneration &+= 1
cancelLiveQuotaRefreshTasks()
if let pausedUntil = forceRefreshBackoff.recordStall(now: now) {
store.pauseAutomaticRefresh(
until: pausedUntil,
consecutiveStalls: forceRefreshBackoff.consecutiveStalls
)
NSLog("CodeBurn: force refresh paused after %d consecutive stalls until %@",
forceRefreshBackoff.consecutiveStalls,
pausedUntil as NSDate)
}
store.resetLoadingState()
return true
}
Expand All @@ -361,15 +321,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool {
if statusPayloadRefreshTask != nil {
guard let started = statusPayloadRefreshStartedAt else {
NSLog("CodeBurn: status refresh task had no start timestamp - clearing")
NSLog("CodeBurn: today status refresh task had no start timestamp - clearing")
statusPayloadRefreshTask?.cancel()
statusPayloadRefreshTask = nil
statusPayloadRefreshGeneration &+= 1
return true
}
let elapsed = now.timeIntervalSince(started)
guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false }
NSLog("CodeBurn: status refresh stuck for %ds - cancelling", Int(elapsed))
NSLog("CodeBurn: today status refresh stuck for %ds - cancelling", Int(elapsed))
statusPayloadRefreshTask?.cancel()
statusPayloadRefreshTask = nil
statusPayloadRefreshStartedAt = nil
Expand Down Expand Up @@ -406,7 +366,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) {
let now = Date()
_ = clearStaleForceRefreshIfNeeded(now: now)
guard !isForceRefreshPaused(now: now) else { return }
if forceRefreshTask != nil {
refreshStatusPayloadIfNeeded(reason: "blocked force refresh")
}
Expand All @@ -427,8 +386,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
_ = await quotas
await MainActor.run { [weak self] in
guard let self, self.forceRefreshGeneration == generation else { return }
self.forceRefreshBackoff.recordSuccess()
self.store.clearRefreshPause()
self.forceRefreshTask = nil
self.forceRefreshStartedAt = nil
self.lastRefreshTime = Date()
Expand Down Expand Up @@ -514,11 +471,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
let task = Task { [store] in
await store.refreshSubscriptionReportingSuccess()
}
claudeQuotaRefreshGeneration &+= 1
let generation = claudeQuotaRefreshGeneration
claudeQuotaRefreshTask = task
let result = await task.value
if claudeQuotaRefreshGeneration == generation {
if claudeQuotaRefreshTask != nil {
claudeQuotaRefreshTask = nil
}
return result
Expand All @@ -531,11 +486,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
let task = Task { [store] in
await store.refreshCodexReportingSuccess()
}
codexQuotaRefreshGeneration &+= 1
let generation = codexQuotaRefreshGeneration
codexQuotaRefreshTask = task
let result = await task.value
if codexQuotaRefreshGeneration == generation {
if codexQuotaRefreshTask != nil {
codexQuotaRefreshTask = nil
}
return result
Expand Down Expand Up @@ -573,12 +526,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}

private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) {
let now = Date()
refreshLoopHeartbeatAt = now
refreshLoopHeartbeatAt = Date()
let hadForceRefreshInFlight = forceRefreshTask != nil
let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded(now: now)
let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded(now: now)
guard !isForceRefreshPaused(now: now) else { return }
let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded()
let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded()
let clearedStaleLoading = store.clearStaleLoadingIfNeeded()
let statusPayloadStale = store.needsStatusPayloadRefresh
let sinceLast = Date().timeIntervalSince(lastRefreshTime)
Expand Down Expand Up @@ -632,9 +583,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
statusPayloadRefreshGeneration &+= 1
pendingRefreshWork?.cancel()
pendingRefreshWork = nil
cancelLiveQuotaRefreshTasks()
forceRefreshBackoff.retryNow(resetStallCount: false)
store.clearRefreshPause()
stopRefreshTimer()
store.resetRefreshState(clearCache: true)
lastRefreshTime = .distantPast
Expand All @@ -653,8 +601,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
self.refreshStatusButton()
_ = await quotas
guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return }
self.forceRefreshBackoff.recordSuccess()
self.store.clearRefreshPause()
self.manualRefreshTask = nil
if self.refreshTimer == nil {
self.startRefreshLoop()
Expand Down
12 changes: 10 additions & 2 deletions mac/Sources/CodeBurnMenubar/Data/DataClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,21 @@ enum DataClientError: Error {
/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
/// commands through `/bin/zsh -c` anymore.
struct DataClient {
static func fetch(period: Period, provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload {
static func fetch(period: Period, day: String? = nil, days: Set<String> = [], provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload {
var subcommand = [
"status",
"--format", "menubar-json",
"--period", period.cliArg,
"--provider", provider.cliArg,
]
if days.count > 1 {
subcommand.append(contentsOf: ["--days", days.sorted().joined(separator: ",")])
} else if let day {
subcommand.append(contentsOf: ["--day", day])
} else if let d = days.first {
subcommand.append(contentsOf: ["--day", d])
} else {
subcommand.append(contentsOf: ["--period", period.cliArg])
}
if !includeOptimize {
subcommand.append("--no-optimize")
}
Expand Down
3 changes: 1 addition & 2 deletions mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ struct HeatmapSection: View {
} else {
PlanInsight(usage: store.subscription)
}
case .trend: TrendInsight(days: store.payload.history.daily, period: store.selectedPeriod)
case .trend: TrendInsight(days: store.payload.history.daily, period: store.trendPeriod)
case .forecast: ForecastInsight(days: store.payload.history.daily)
case .pulse: PulseInsight(payload: store.payload)
case .stats: StatsInsight(payload: store.payload)
Expand Down Expand Up @@ -1839,4 +1839,3 @@ private func relativeReset(_ date: Date) -> String {
let days = Int(ceil(hours / 24))
return "in \(days)d"
}

5 changes: 3 additions & 2 deletions mac/Sources/CodeBurnMenubar/Views/HeroSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ struct HeroSection: View {
}
}

if store.selectedPeriod == .today,
if !store.isDayMode,
store.selectedPeriod == .today,
store.dailyBudget > 0,
let todayCost = store.todayPayload?.current.cost,
todayCost >= store.dailyBudget {
Expand Down Expand Up @@ -92,7 +93,7 @@ struct HeroSection: View {

private var caption: String {
let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
if store.selectedPeriod == .today {
if !store.isDayMode && store.selectedPeriod == .today {
return "\(label) · \(todayDate)"
}
return label
Expand Down
82 changes: 11 additions & 71 deletions mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,6 @@ struct MenuBarContent: View {

Divider()

if let message = store.refreshPauseMessage {
RefreshPausedBanner(
message: message,
retry: { refreshNow() }
)
Divider()
}

if showAgentTabs {
AgentTabStrip()
Divider()
Expand All @@ -32,7 +24,7 @@ struct MenuBarContent: View {
PeriodSegmentedControl()
Divider().opacity(0.5)
if isFilteredEmpty {
EmptyProviderState(provider: store.selectedProvider, period: store.selectedPeriod)
EmptyProviderState(provider: store.selectedProvider, periodLabel: store.selectionLabel)
} else {
HeatmapSection()
.padding(.horizontal, 14)
Expand All @@ -57,22 +49,15 @@ struct MenuBarContent: View {
// error, etc.), surface a retry card instead of leaving the
// user stuck on a perpetual "Loading..." spinner.
if !store.hasCachedData {
if store.isCurrentKeyLoading || !store.hasAttemptedCurrentKeyLoad {
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
.transition(.opacity)
} else if let err = store.lastError {
if let err = store.lastError {
FetchErrorOverlay(
error: err,
periodLabel: store.selectedPeriod.rawValue,
periodLabel: store.selectionLabel,
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
)
.transition(.opacity)
} else {
FetchErrorOverlay(
error: "The last refresh stopped before returning data. CodeBurn will keep retrying, or you can retry now.",
periodLabel: store.selectedPeriod.rawValue,
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
)
BurnLoadingOverlay(periodLabel: store.selectionLabel)
.transition(.opacity)
}
}
Expand Down Expand Up @@ -120,42 +105,16 @@ struct MenuBarContent: View {

}

private struct RefreshPausedBanner: View {
let message: String
let retry: () -> Void

var body: some View {
HStack(spacing: 10) {
Image(systemName: "pause.circle.fill")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Theme.brandAccent)
Text(message)
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 6)
Button("Retry", action: retry)
.buttonStyle(.borderedProminent)
.tint(Theme.brandAccent)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.secondary.opacity(0.08))
}
}

private struct EmptyProviderState: View {
let provider: ProviderFilter
let period: Period
let periodLabel: String

var body: some View {
VStack(spacing: 10) {
Image(systemName: "tray")
.font(.system(size: 26))
.foregroundStyle(.tertiary)
Text("No \(provider.rawValue) data for \(periodPhrase)")
Text("No \(provider.rawValue) data for \(periodLabel)")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Expand All @@ -164,15 +123,6 @@ private struct EmptyProviderState: View {
.padding(.vertical, 60)
}

private var periodPhrase: String {
switch period {
case .today: "today"
case .sevenDays: "the last 7 days"
case .thirtyDays: "the last 30 days"
case .month: "this month"
case .all: "the last 6 months"
}
}
}

/// Shown when a fetch failed and the cache is still empty for this key. The
Expand Down Expand Up @@ -628,7 +578,11 @@ struct FooterBar: View {
}

private func refreshNow() {
MenuBarContent.refreshNow(store: store)
if let delegate = NSApp.delegate as? AppDelegate {
delegate.refreshSubscriptionNow()
} else {
Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
}
}

private enum ExportFormat {
Expand Down Expand Up @@ -694,17 +648,3 @@ struct FooterBar: View {
CLICurrencyConfig.persist(code: code)
}
}

private extension MenuBarContent {
static func refreshNow(store: AppStore) {
if let delegate = NSApp.delegate as? AppDelegate {
delegate.refreshSubscriptionNow()
} else {
Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
}
}

func refreshNow() {
Self.refreshNow(store: store)
}
}
Loading
Loading