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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased

### Added (macOS menubar)
- **Configurable menubar status period.** Settings now lets the status item
show Today, Week, Month, or 6 Months instead of always pinning the text to
today's spend. The choice persists in `CodeBurnMenubarPeriod` (`today`,
`week`, `month`, `sixMonths`) and non-today values carry a short suffix
such as `$42 / mo` so the menu bar remains unambiguous. Closes #291.

## 0.9.8 - 2026-05-10

### Added (CLI)
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,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 metric 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
75 changes: 73 additions & 2 deletions mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation
import Observation

private let cacheTTLSeconds: TimeInterval = 30
private let menubarPeriodDefaultsKey = "CodeBurnMenubarPeriod"

struct CachedPayload {
let payload: MenubarPayload
Expand All @@ -19,6 +20,9 @@ struct PayloadCacheKey: Hashable {
final class AppStore {
var selectedProvider: ProviderFilter = .all
var selectedPeriod: Period = .today
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 @@ -71,12 +75,17 @@ 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[PayloadCacheKey(period: .today, provider: .all)]?.payload
}

/// 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[PayloadCacheKey(period: menubarPeriod, provider: .all)]?.payload
}

/// All-provider payload for the selected period. Used by the tab strip to show
/// per-provider costs that match the active period, not just today.
var periodAllPayload: MenubarPayload? {
Expand Down Expand Up @@ -132,6 +141,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 @@ -812,6 +830,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
40 changes: 24 additions & 16 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,7 @@ 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 today: Void = store.refreshQuietly(period: .today)
_ = await (main, today)
await refreshUsagePayloads(force: true, showLoading: true)
refreshStatusButton()
await MainActor.run { [weak self] in
guard let self, self.forceRefreshGeneration == generation else { return }
Expand All @@ -252,6 +250,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
}

private func refreshUsagePayloads(force: Bool, showLoading: Bool = false) async {
let menubarPeriod = store.menubarPeriod
if store.selectedProvider == .all && store.selectedPeriod == menubarPeriod {
await store.refresh(includeOptimize: false, force: force, showLoading: showLoading)
} else {
async let visible: Void = store.refresh(includeOptimize: false, force: force, showLoading: showLoading)
async let menubar: Void = store.refreshQuietly(period: menubarPeriod)
_ = await (visible, menubar)
}
}

/// 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 @@ -297,13 +306,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// observer plus the loop's natural tick).
let sinceLast = Date().timeIntervalSince(self.lastRefreshTime)
if self.forceRefreshTask == nil && (clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= 5) {
if self.store.selectedPeriod != .today || self.store.selectedProvider != .all {
async let quiet: Void = self.store.refreshQuietly(period: .today)
async let main: Void = self.store.refresh(includeOptimize: false, force: true)
_ = await (quiet, main)
} else {
await self.store.refresh(includeOptimize: false, force: true)
}
await self.refreshUsagePayloads(force: true)
self.lastRefreshTime = Date()
self.refreshStatusButton()
}
Expand Down Expand Up @@ -338,7 +341,7 @@ 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."
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 claude: Bool = self.store.refreshSubscriptionReportingSuccess()
async let codex: Bool = self.store.refreshCodexReportingSuccess()
_ = await payload
Expand Down Expand Up @@ -368,7 +371,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 @@ -474,13 +478,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 @@ -491,6 +498,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
53 changes: 53 additions & 0 deletions mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import Foundation
import Testing
@testable import CodeBurnMenubar

@Suite("Menubar period settings")
struct MenubarPeriodSettingsTests {
@Test("settings picker exposes the requested status periods")
func exposesRequestedPeriods() {
#expect(Period.menubarMetricCases == [.today, .sevenDays, .month, .all])
}

@Test("defaults values map to periods")
func mapsDefaultsValues() {
#expect(Period(menubarDefaultsValue: "today") == .today)
#expect(Period(menubarDefaultsValue: "week") == .sevenDays)
#expect(Period(menubarDefaultsValue: "month") == .month)
#expect(Period(menubarDefaultsValue: "sixMonths") == .all)
#expect(Period(menubarDefaultsValue: "all") == .all)
#expect(Period(menubarDefaultsValue: "30days") == .today)
#expect(Period(menubarDefaultsValue: "bogus") == .today)
#expect(Period(menubarDefaultsValue: nil) == .today)
}

@Test("periods persist canonical defaults values")
func persistsCanonicalDefaultsValues() throws {
let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }

Period.sevenDays.persistAsMenubarDefault(defaults: defaults)
#expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "week")
#expect(Period.savedMenubarPeriod(defaults: defaults) == .sevenDays)

Period.all.persistAsMenubarDefault(defaults: defaults)
#expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "sixMonths")
#expect(Period.savedMenubarPeriod(defaults: defaults) == .all)

Period.thirtyDays.persistAsMenubarDefault(defaults: defaults)
#expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "today")
#expect(Period.savedMenubarPeriod(defaults: defaults) == .today)
}

@Test("non-today periods render compact and regular suffixes")
func rendersSuffixes() {
#expect(Period.today.menubarSuffix(compact: false) == "")
#expect(Period.sevenDays.menubarSuffix(compact: false) == " / wk")
#expect(Period.month.menubarSuffix(compact: false) == " / mo")
#expect(Period.all.menubarSuffix(compact: false) == " / 6mo")
#expect(Period.sevenDays.menubarSuffix(compact: true) == "/wk")
#expect(Period.month.menubarSuffix(compact: true) == "/mo")
#expect(Period.all.menubarSuffix(compact: true) == "/6mo")
}
}
Loading