mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-26 17:35:10 +00:00
Add configurable menubar status period (#302)
Some checks are pending
CI / semgrep (push) Waiting to run
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:
parent
1cf36aed2d
commit
aa69857e30
6 changed files with 194 additions and 30 deletions
|
|
@ -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)
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -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`):
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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…
Add table
Add a link
Reference in a new issue