Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):

Expand Down
86 changes: 83 additions & 3 deletions mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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<PayloadCacheKey> = []

func resetLoadingState() {
Expand Down Expand Up @@ -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
Expand Down
58 changes: 36 additions & 22 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,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: 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
return true
}
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
Expand All @@ -339,22 +339,23 @@ 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
statusPayloadRefreshGeneration &+= 1
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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
48 changes: 48 additions & 0 deletions mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading