diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b74542..da4204b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,13 @@ `Shell`, `ReadFile`, and `WriteFile`, and maps hidden managed Kimi Code model aliases to priced Kimi K2 entries. +### Added (macOS menubar) +- **Configurable status period.** The always-visible menubar status item can + track Today, Week, Month, or 6 Months independently from the popover's active + period/provider filters. The setting is persisted in `UserDefaults` under + `CodeBurnMenubarPeriod`, can be changed from Settings, and uses compact + suffixes for non-today periods. Closes #291. + ## 0.9.9 - 2026-05-15 ### Added (CLI) diff --git a/README.md b/README.md index fc847c0..9637099 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,15 @@ codeburn menubar One command: downloads the latest `.app`, installs into `~/Applications`, and launches it. Re-run with `--force` to reinstall. Native Swift and SwiftUI app lives in `mac/` (see `mac/README.md` for build details). -The menubar icon always shows today's spend (so $0 is normal if you have not used AI tools today). Click to open a popover with agent tabs, period switcher (Today, 7 Days, 30 Days, Month, All), Trend, Forecast, Pulse, Stats, and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Refreshes every 30 seconds. +The menubar icon shows the spend period selected in Settings (Today by default; Week, Month, and 6 Months are also available). Non-today periods add a short suffix such as `$42 / mo` so the menu bar value stays clear. Click to open a popover with agent tabs, period switcher (Today, 7 Days, 30 Days, Month, All), Trend, Forecast, Pulse, Stats, and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Refreshes every 30 seconds. + +You can also set the menubar status period from Terminal: + +```bash +defaults write org.agentseal.codeburn-menubar CodeBurnMenubarPeriod -string month +``` + +Allowed values are `today`, `week`, `month`, and `sixMonths`. Relaunch the app to apply external defaults changes. **Compact mode** shrinks the menubar item to fit the text, dropping decimals (e.g. `$110` instead of `$110.20`): diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index b1c89cb..9975903 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -3,6 +3,7 @@ import Observation private let cacheTTLSeconds: TimeInterval = 30 private let interactiveRefreshResetSeconds: TimeInterval = 120 +private let menubarPeriodDefaultsKey = "CodeBurnMenubarPeriod" struct CachedPayload { let payload: MenubarPayload @@ -20,6 +21,9 @@ struct PayloadCacheKey: Hashable { final class AppStore { var selectedProvider: ProviderFilter = .all var selectedPeriod: Period = .today + private(set) var menubarPeriod: Period = Period.savedMenubarPeriod() { + didSet { menubarPeriod.persistAsMenubarDefault() } + } var selectedInsight: InsightMode = .trend var accentPreset: AccentPreset = ThemeState.shared.preset { didSet { ThemeState.shared.preset = accentPreset } @@ -69,6 +73,10 @@ final class AppStore { PayloadCacheKey(period: .today, provider: .all) } + private var menubarStatusKey: PayloadCacheKey { + PayloadCacheKey(period: menubarPeriod, provider: .all) + } + private var currentKey: PayloadCacheKey { PayloadCacheKey(period: selectedPeriod, provider: selectedProvider) } @@ -77,8 +85,7 @@ final class AppStore { cache[currentKey]?.payload ?? .empty } - /// Today (across all providers) is pinned for the always-visible menubar icon, independent of - /// the popover's selected period or provider. + /// Today (across all providers) backs day-specific views in the popover. var todayPayload: MenubarPayload? { cache[todayAllKey]?.payload } @@ -88,8 +95,19 @@ final class AppStore { return Int(Date().timeIntervalSince(cached.fetchedAt)) } + var menubarPayloadAgeSeconds: Int? { + guard let cached = cache[menubarStatusKey] else { return nil } + return Int(Date().timeIntervalSince(cached.fetchedAt)) + } + var needsStatusPayloadRefresh: Bool { - cache[todayAllKey]?.isFresh != true + cache[menubarStatusKey]?.isFresh != true + } + + /// All-provider payload for the user-selected menubar status metric. The + /// popover's visible period/provider can differ from this setting. + var menubarPayload: MenubarPayload? { + cache[menubarStatusKey]?.payload } /// All-provider payload for the selected period. Used by the tab strip to show @@ -193,6 +211,15 @@ final class AppStore { } } + func setMenubarPeriod(_ period: Period) { + guard Period.menubarMetricCases.contains(period) else { return } + guard menubarPeriod != period else { return } + menubarPeriod = period + Task { [weak self] in + await self?.refreshQuietly(period: period) + } + } + private var inFlightKeys: Set = [] func resetLoadingState() { @@ -962,6 +989,59 @@ enum Period: String, CaseIterable, Identifiable { case .all: "all" } } + + /// Status item metrics intentionally stay to the coarse Settings choices. + /// The popover still offers 30 Days, but it is not a persisted status metric. + static let menubarMetricCases: [Period] = [.today, .sevenDays, .month, .all] + + var menubarMetricLabel: String { + switch self { + case .today: "Today" + case .sevenDays: "Week" + case .thirtyDays: "30 Days" + case .month: "Month" + case .all: "6 Months" + } + } + + var menubarDefaultsValue: String { + switch self { + case .today: "today" + case .sevenDays: "week" + case .thirtyDays: "30days" + case .month: "month" + case .all: "sixMonths" + } + } + + init(menubarDefaultsValue: String?) { + switch menubarDefaultsValue { + case "today": self = .today + case "week", "sevenDays": self = .sevenDays + case "month": self = .month + case "sixMonths", "all": self = .all + default: self = .today + } + } + + static func savedMenubarPeriod(defaults: UserDefaults = .standard) -> Period { + Period(menubarDefaultsValue: defaults.string(forKey: menubarPeriodDefaultsKey)) + } + + func persistAsMenubarDefault(defaults: UserDefaults = .standard) { + let period = Period.menubarMetricCases.contains(self) ? self : Period.today + defaults.set(period.menubarDefaultsValue, forKey: menubarPeriodDefaultsKey) + } + + func menubarSuffix(compact: Bool) -> String { + switch self { + case .today: "" + case .sevenDays: compact ? "/wk" : " / wk" + case .thirtyDays: compact ? "/30d" : " / 30d" + case .month: compact ? "/mo" : " / mo" + case .all: compact ? "/6mo" : " / 6mo" + } + } } /// NumberFormatter is expensive to instantiate (~microseconds each) and currency/token values diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 6191575..712d3fb 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -321,7 +321,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool { if statusPayloadRefreshTask != nil { guard let started = statusPayloadRefreshStartedAt else { - NSLog("CodeBurn: today status refresh task had no start timestamp - clearing") + NSLog("CodeBurn: status refresh task had no start timestamp - clearing") statusPayloadRefreshTask?.cancel() statusPayloadRefreshTask = nil statusPayloadRefreshGeneration &+= 1 @@ -329,7 +329,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } let elapsed = now.timeIntervalSince(started) guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false } - NSLog("CodeBurn: today status refresh stuck for %ds - cancelling", Int(elapsed)) + NSLog("CodeBurn: status refresh stuck for %ds - cancelling", Int(elapsed)) statusPayloadRefreshTask?.cancel() statusPayloadRefreshTask = nil statusPayloadRefreshStartedAt = nil @@ -339,14 +339,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { return false } - private func refreshTodayStatusPayloadIfNeeded(reason: String, force: Bool = false) { + private func refreshStatusPayloadIfNeeded(reason: String, force: Bool = false) { let now = Date() _ = clearStaleStatusPayloadRefreshIfNeeded(now: now) guard statusPayloadRefreshTask == nil else { return } guard force || store.needsStatusPayloadRefresh else { return } - if let age = store.todayPayloadAgeSeconds, age > 120 { - NSLog("CodeBurn: today status payload stale for %ds on %@ refresh", age, reason) + let menubarPeriod = store.menubarPeriod + if let age = store.menubarPayloadAgeSeconds, age > 120 { + NSLog("CodeBurn: status payload stale for %ds on %@ refresh", age, reason) } statusPayloadRefreshStartedAt = now @@ -354,7 +355,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let generation = statusPayloadRefreshGeneration statusPayloadRefreshTask = Task { [weak self] in guard let self else { return } - await self.store.refreshQuietly(period: .today, force: true) + await self.store.refreshQuietly(period: menubarPeriod, force: true) self.refreshStatusButton() guard self.statusPayloadRefreshGeneration == generation, !Task.isCancelled else { return } self.statusPayloadRefreshTask = nil @@ -366,7 +367,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let now = Date() _ = clearStaleForceRefreshIfNeeded(now: now) if forceRefreshTask != nil { - refreshTodayStatusPayloadIfNeeded(reason: "blocked force refresh") + refreshStatusPayloadIfNeeded(reason: "blocked force refresh") } guard forceRefreshTask == nil else { return } if !bypassRateLimit { @@ -378,11 +379,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let generation = forceRefreshGeneration forceRefreshTask = Task { - async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true) + async let main: Void = refreshUsagePayloads(force: true, showLoading: true) async let quotas: Bool = refreshLiveQuotaProgressIfDue(force: forceQuota) - if store.selectedPeriod != .today || store.selectedProvider != .all { - await store.refreshQuietly(period: .today) - } _ = await main refreshStatusButton() await MainActor.run { [weak self] in @@ -395,6 +393,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } } + private func refreshUsagePayloads(force: Bool, showLoading: Bool = false) async { + let menubarPeriod = store.menubarPeriod + let needsMenubarPayload = store.selectedPeriod != menubarPeriod || store.selectedProvider != .all + let needsTodayPayload = (store.selectedPeriod != .today || store.selectedProvider != .all) && menubarPeriod != .today + + async let visible: Void = store.refresh(includeOptimize: false, force: force, showLoading: showLoading) + async let menubar: Void = needsMenubarPayload + ? store.refreshQuietly(period: menubarPeriod, force: force) + : () + async let today: Void = needsTodayPayload + ? store.refreshQuietly(period: .today, force: force) + : () + _ = await (visible, menubar, today) + } + /// Loads the currency code persisted by `codeburn currency` so a relaunch picks up where /// the user left off. Rate is resolved from the on-disk FX cache if present, otherwise /// fetched live in the background. @@ -531,7 +544,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { let forceRefreshWasBlocked = hadForceRefreshInFlight && forceRefreshTask != nil if statusPayloadStale && (!shouldForceRefresh || forceRefreshWasBlocked || clearedStaleStatusRefresh) { - refreshTodayStatusPayloadIfNeeded(reason: reason, force: forcePayload) + refreshStatusPayloadIfNeeded(reason: reason, force: forcePayload) } } @@ -580,12 +593,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // "Refresh Now" should refresh the menubar payload AND every // connected provider's live quota. The user's intent is "make // this match reality right now." - let needsTodayTotal = self.store.selectedPeriod != .today || self.store.selectedProvider != .all - async let payload: Void = self.store.refresh(includeOptimize: false, force: true, showLoading: true) + async let payload: Void = self.refreshUsagePayloads(force: true, showLoading: true) async let quotas: Bool = self.refreshLiveQuotaProgressIfDue(force: true) - if needsTodayTotal { - await self.store.refreshQuietly(period: .today, force: true) - } _ = await payload guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return } self.lastRefreshTime = Date() @@ -620,7 +629,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { withObservationTracking { [weak self] in guard let self else { return } _ = self.store.payload - _ = self.store.todayPayload + _ = self.store.menubarPeriod + _ = self.store.menubarPayload // Track currency so the menubar title catches up immediately on // currency switch instead of waiting for the next 30s payload tick. _ = self.store.currency @@ -726,13 +736,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { attachment.bounds = CGRect(x: 0, y: -3, width: size.width, height: size.height) } - let hasPayload = store.todayPayload != nil + let menubarPeriod = store.menubarPeriod + let menubarPayload = store.menubarPayload + let hasPayload = menubarPayload != nil let compact = isCompact let fallback = compact ? "$-" : "$—" - let formatted = store.todayPayload?.current.cost + let formatted = menubarPayload?.current.cost + let suffix = menubarPeriod.menubarSuffix(compact: compact) let valueText = compact - ? (formatted?.asCompactCurrencyWhole() ?? fallback) - : " " + (formatted?.asCompactCurrency() ?? fallback) + ? (formatted?.asCompactCurrencyWhole() ?? fallback) + suffix + : " " + (formatted?.asCompactCurrency() ?? fallback) + suffix var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0] if !hasPayload { @@ -743,6 +756,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { composed.append(NSAttributedString(attachment: attachment)) composed.append(NSAttributedString(string: valueText, attributes: textAttrs)) button.attributedTitle = composed + button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)" } // MARK: - Popover diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index dae7406..9b2ad82 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -40,6 +40,15 @@ private struct GeneralSettingsTab: View { Text(code).tag(code) } } + Picker("Menubar metric", selection: Binding( + get: { store.menubarPeriod }, + set: { store.setMenubarPeriod($0) } + )) { + ForEach(Period.menubarMetricCases) { period in + Text(period.menubarMetricLabel).tag(period) + } + } + .pickerStyle(.menu) Picker("Accent", selection: Binding( get: { store.accentPreset }, set: { store.accentPreset = $0 } diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift new file mode 100644 index 0000000..7b9f087 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift @@ -0,0 +1,48 @@ +import Foundation +import XCTest +@testable import CodeBurnMenubar + +final class MenubarPeriodSettingsTests: XCTestCase { + func testSettingsPickerExposesRequestedPeriods() { + XCTAssertEqual(Period.menubarMetricCases, [.today, .sevenDays, .month, .all]) + } + + func testDefaultsValuesMapToPeriods() { + XCTAssertEqual(Period(menubarDefaultsValue: "today"), .today) + XCTAssertEqual(Period(menubarDefaultsValue: "week"), .sevenDays) + XCTAssertEqual(Period(menubarDefaultsValue: "month"), .month) + XCTAssertEqual(Period(menubarDefaultsValue: "sixMonths"), .all) + XCTAssertEqual(Period(menubarDefaultsValue: "all"), .all) + XCTAssertEqual(Period(menubarDefaultsValue: "30days"), .today) + XCTAssertEqual(Period(menubarDefaultsValue: "bogus"), .today) + XCTAssertEqual(Period(menubarDefaultsValue: nil), .today) + } + + func testPeriodsPersistCanonicalDefaultsValues() throws { + let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + Period.sevenDays.persistAsMenubarDefault(defaults: defaults) + XCTAssertEqual(defaults.string(forKey: "CodeBurnMenubarPeriod"), "week") + XCTAssertEqual(Period.savedMenubarPeriod(defaults: defaults), .sevenDays) + + Period.all.persistAsMenubarDefault(defaults: defaults) + XCTAssertEqual(defaults.string(forKey: "CodeBurnMenubarPeriod"), "sixMonths") + XCTAssertEqual(Period.savedMenubarPeriod(defaults: defaults), .all) + + Period.thirtyDays.persistAsMenubarDefault(defaults: defaults) + XCTAssertEqual(defaults.string(forKey: "CodeBurnMenubarPeriod"), "today") + XCTAssertEqual(Period.savedMenubarPeriod(defaults: defaults), .today) + } + + func testNonTodayPeriodsRenderCompactAndRegularSuffixes() { + XCTAssertEqual(Period.today.menubarSuffix(compact: false), "") + XCTAssertEqual(Period.sevenDays.menubarSuffix(compact: false), " / wk") + XCTAssertEqual(Period.month.menubarSuffix(compact: false), " / mo") + XCTAssertEqual(Period.all.menubarSuffix(compact: false), " / 6mo") + XCTAssertEqual(Period.sevenDays.menubarSuffix(compact: true), "/wk") + XCTAssertEqual(Period.month.menubarSuffix(compact: true), "/mo") + XCTAssertEqual(Period.all.menubarSuffix(compact: true), "/6mo") + } +}