Add configurable menubar status period (#302)
Some checks are pending
CI / semgrep (push) Waiting to run

The menubar status item can now track Today, Week, Month, or 6 Months
independently from the popover's active period. Setting is persisted
in UserDefaults and changeable from Settings or via `defaults write`.

Thanks @ozymandiashh. Closes #291.
This commit is contained in:
iamtoruk 2026-05-24 02:46:19 -07:00
parent 1cf36aed2d
commit aa69857e30
6 changed files with 194 additions and 30 deletions

View file

@ -32,6 +32,11 @@
not forwarding the `toolSequence` field, so all cached Claude sessions lost
their tool ordering data.
### Added (macOS menubar)
- **Configurable menubar status period.** The menubar dropdown now lets you
choose which period (Today, 7 Days, Month, All Time) is shown in the status
bar. Persisted via UserDefaults. Thanks @ozymandiashh. (#302)
## 0.9.10 - 2026-05-20
### Added (CLI)

View file

@ -346,7 +346,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`):

View file

@ -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 }
@ -76,6 +80,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)
}
@ -84,8 +92,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
}
@ -95,8 +102,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
@ -200,6 +218,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() {
@ -989,6 +1016,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

View file

@ -361,7 +361,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
@ -369,7 +369,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
@ -379,14 +379,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
@ -394,7 +395,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
@ -407,7 +408,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
_ = clearStaleForceRefreshIfNeeded(now: now)
guard !isForceRefreshPaused(now: now) else { return }
if forceRefreshTask != nil {
refreshTodayStatusPayloadIfNeeded(reason: "blocked force refresh")
refreshStatusPayloadIfNeeded(reason: "blocked force refresh")
}
guard forceRefreshTask == nil else { return }
if !bypassRateLimit {
@ -419,11 +420,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 quotas
@ -438,6 +436,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.
@ -580,7 +593,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)
}
}
@ -632,12 +645,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()
@ -674,7 +683,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
@ -786,22 +796,25 @@ 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 suffix = menubarPeriod.menubarSuffix(compact: compact)
let valueText: String
if store.displayMetric == .tokens, let p = store.todayPayload?.current {
if store.displayMetric == .tokens, let p = menubarPayload?.current {
let out = formatTokensMenubar(Double(p.outputTokens))
let inp = formatTokensMenubar(Double(p.inputTokens))
valueText = compact ? "\(out)\(inp)" : "\(out)\(inp)"
} else if store.displayMetric == .totalTokens, let p = store.todayPayload?.current {
valueText = compact ? "\(out)\(inp)\(suffix)" : "\(out)\(inp)\(suffix)"
} else if store.displayMetric == .totalTokens, let p = menubarPayload?.current {
let total = formatTokensMenubar(Double(p.inputTokens + p.outputTokens))
valueText = compact ? total : " \(total) tok"
valueText = compact ? "\(total)\(suffix)" : " \(total) tok\(suffix)"
} else {
let fallback = compact ? "$-" : "$—"
let formatted = store.todayPayload?.current.cost
let formatted = menubarPayload?.current.cost
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]
@ -813,6 +826,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)"
}
private func formatTokensMenubar(_ n: Double) -> String {

View file

@ -48,6 +48,15 @@ private struct GeneralSettingsTab: View {
Text("Tokens (↑↓)").tag(DisplayMetric.tokens)
Text("Total Tokens").tag(DisplayMetric.totalTokens)
}
Picker("Period", 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 }

View file

@ -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")
}
}