mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
Add configurable menubar status period
This commit is contained in:
parent
d9acd8c4cd
commit
426a71c413
6 changed files with 177 additions and 19 deletions
|
|
@ -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)
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -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`):
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import Foundation
|
|||
import Observation
|
||||
|
||||
private let cacheTTLSeconds: TimeInterval = 30
|
||||
private let menubarPeriodDefaultsKey = "CodeBurnMenubarPeriod"
|
||||
|
||||
struct CachedPayload {
|
||||
let payload: MenubarPayload
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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? {
|
||||
|
|
@ -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() {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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.
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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…
Add table
Add a link
Reference in a new issue