feat(menubar): add Local/Combined usage toggle (#566) (#568)
Some checks are pending
CI / semgrep (push) Waiting to run

Mirror the combined multi-device dashboard in the macOS menubar via a
Local/Combined scope toggle (Settings -> Display). Builds on the menubar-json
`--scope combined` contract.

- MenubarScope enum (.local default) persisted in UserDefaults; PayloadCacheKey
  gains a scope dimension so local and combined payloads cache separately.
- DataClient.fetch(scope:): a pure statusSubcommand builder appends
  `--scope combined` only for combined, forces `--provider all` (the CLI rejects
  combined with a provider/project/exclude filter), and coerces multi-day
  selections to local (the CLI rejects combined + --days).
- MenubarPayload decodes the optional `combined` block (per-device + totals);
  nil when absent (back-compat).
- The badge always stays on the local payload; the combined network pull lives
  on a separate cache key and falls back to local with a "Combined unavailable"
  indicator, so a slow/offline peer never blocks the badge.
- Hero shows combined totals + a per-device breakdown in Combined scope; the
  token metric stays input+output (cache excluded) to match local; the
  per-device daily-budget warning is suppressed for a multi-device total;
  selecting Combined resets the provider tab to All.

Tests run via `swift test` (CommandLineTools Testing.framework on the rpath, no
Xcode): scope persistence, argv (local/combined/provider-forcing/multi-day),
cache-key partition, badge-survives-combined-failure, combined decode, token
consistency, provider reset, budget suppression.
This commit is contained in:
ozymandiashh 2026-06-28 23:01:01 +03:00 committed by GitHub
parent 88c1cee467
commit 805d2ffa49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 904 additions and 115 deletions

View file

@ -12,12 +12,14 @@ struct CachedPayload {
}
struct PayloadCacheKey: Hashable {
let scope: MenubarScope
let period: Period
let provider: ProviderFilter
let day: String?
let days: Set<String>
init(period: Period, provider: ProviderFilter, day: String? = nil, days: Set<String> = []) {
init(scope: MenubarScope = .local, period: Period, provider: ProviderFilter, day: String? = nil, days: Set<String> = []) {
self.scope = scope
self.period = period
self.provider = provider
self.day = days.count <= 1 ? (day ?? days.first) : nil
@ -37,7 +39,13 @@ struct PayloadCacheKey: Hashable {
final class AppStore {
var selectedProvider: ProviderFilter = .all
var selectedPeriod: Period = .today
var selectedScope: MenubarScope = MenubarScope.savedMenubarScope()
var selectedDays: Set<String> = []
var activeScope: MenubarScope { effectiveSelectedScope }
private var effectiveSelectedScope: MenubarScope {
selectedDays.count > 1 ? .local : selectedScope
}
var selectedDay: String? {
guard selectedDays.count == 1 else { return nil }
@ -46,6 +54,9 @@ final class AppStore {
private(set) var menubarPeriod: Period = Period.savedMenubarPeriod() {
didSet { menubarPeriod.persistAsMenubarDefault() }
}
private(set) var menubarScope: MenubarScope = MenubarScope.savedMenubarScope() {
didSet { menubarScope.persistAsMenubarDefault() }
}
var selectedInsight: InsightMode = .trend
var accentPreset: AccentPreset = ThemeState.shared.preset {
didSet { ThemeState.shared.preset = accentPreset }
@ -88,6 +99,10 @@ final class AppStore {
return total >= activeDailyBudget
}
var shouldShowDailyBudgetWarning: Bool {
isOverDailyBudget && activeScope == .local
}
/// The active daily-budget threshold formatted for display (tokens, or USD).
/// The cost budget is defined in USD (matching the "$" presets and field), so
/// it is not run through the display-currency conversion here.
@ -97,7 +112,10 @@ final class AppStore {
var isLoading: Bool { loadingCountsByKey.values.contains { $0 > 0 } }
var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 }
var hasAttemptedCurrentKeyLoad: Bool { attemptedKeys.contains(currentKey) }
var hasAttemptedCurrentKeyLoad: Bool {
attemptedKeys.contains(currentKey) ||
(effectiveSelectedScope == .combined && attemptedKeys.contains(localCurrentKey))
}
var lastError: String? { lastErrorByKey[currentKey] }
private var loadingCountsByKey: [PayloadCacheKey: Int] = [:]
private var loadingStartedAtByKey: [PayloadCacheKey: Date] = [:]
@ -122,6 +140,9 @@ final class AppStore {
private var cacheDate: String = ""
private var switchTask: Task<Void, Never>?
private var payloadRefreshGeneration: UInt64 = 0
#if DEBUG
private var refreshSuppressedForTesting = false
#endif
/// Tracks the last successful fetch timestamp per key for stuck-loading
/// diagnostics. NOT used for cache-freshness logic `CachedPayload.fetchedAt`
/// is authoritative there. This map persists across cache wipes (day
@ -146,19 +167,45 @@ final class AppStore {
}
private var todayAllKey: PayloadCacheKey {
PayloadCacheKey(period: .today, provider: .all, day: nil)
PayloadCacheKey(scope: .local, period: .today, provider: .all, day: nil)
}
private var menubarStatusKey: PayloadCacheKey {
PayloadCacheKey(period: menubarPeriod, provider: .all, day: nil)
PayloadCacheKey(scope: .local, period: menubarPeriod, provider: .all, day: nil)
}
private var currentKey: PayloadCacheKey {
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider, day: selectedDay, days: selectedDays)
PayloadCacheKey(scope: effectiveSelectedScope, period: selectedPeriod, provider: selectedProvider, day: selectedDay, days: selectedDays)
}
private var localCurrentKey: PayloadCacheKey {
PayloadCacheKey(scope: .local, period: selectedPeriod, provider: selectedProvider, day: selectedDay, days: selectedDays)
}
private var periodAllKey: PayloadCacheKey {
PayloadCacheKey(scope: .local, period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays)
}
var payload: MenubarPayload {
cache[currentKey]?.payload ?? .empty
if effectiveSelectedScope == .combined {
let combinedPayload = cache[currentKey]?.payload
if let localPayload = cache[localCurrentKey]?.payload {
if let combined = combinedPayload?.combined {
return MenubarPayload(
generated: combinedPayload?.generated ?? localPayload.generated,
current: localPayload.current,
optimize: localPayload.optimize,
history: localPayload.history,
combined: combined
)
}
return localPayload
}
if let combinedPayload {
return combinedPayload
}
}
return cache[currentKey]?.payload ?? .empty
}
/// Today (across all providers) backs day-specific views in the popover.
@ -187,7 +234,7 @@ final class AppStore {
/// 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? {
cache[PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays)]?.payload
cache[periodAllKey]?.payload
}
var isDayMode: Bool {
@ -206,7 +253,7 @@ final class AppStore {
}
var hasCachedData: Bool {
cache[currentKey] != nil
cache[currentKey] != nil || (effectiveSelectedScope == .combined && cache[localCurrentKey] != nil)
}
var hasStaleLoading: Bool {
@ -221,7 +268,7 @@ final class AppStore {
}
var hasMissingInteractivePayloadWithoutAttempt: Bool {
cache[currentKey] == nil && !isCurrentKeyLoading && !hasAttemptedCurrentKeyLoad
!hasCachedData && !isCurrentKeyLoading && !hasAttemptedCurrentKeyLoad
}
var shouldResetInteractiveRefreshPipeline: Bool {
@ -231,8 +278,9 @@ final class AppStore {
var staleInteractivePayloadAgeSeconds: Int? {
let keys = Set([
currentKey,
localCurrentKey,
todayAllKey,
PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays),
periodAllKey,
])
let staleAges = keys.compactMap { key -> TimeInterval? in
guard let cached = cache[key] else { return nil }
@ -243,11 +291,11 @@ final class AppStore {
}
var needsInteractivePayloadRefresh: Bool {
let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays)
return cache[currentKey]?.isFresh != true ||
cache[todayAllKey]?.isFresh != true ||
cache[periodAllKey]?.isFresh != true ||
hasStaleLoading
var requiredKeys: Set<PayloadCacheKey> = [currentKey, todayAllKey, periodAllKey]
if effectiveSelectedScope == .combined {
requiredKeys.insert(localCurrentKey)
}
return requiredKeys.contains { cache[$0]?.isFresh != true } || hasStaleLoading
}
/// True if any cached payload reports at least one provider. Used to keep the
@ -259,16 +307,47 @@ final class AppStore {
}
#if DEBUG
func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, day: String? = nil, fetchedAt: Date) {
cache[PayloadCacheKey(period: period, provider: provider, day: day)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
func setCachedPayloadForTesting(_ payload: MenubarPayload,
scope: MenubarScope = .local,
period: Period,
provider: ProviderFilter,
day: String? = nil,
days: Set<String> = [],
fetchedAt: Date) {
cache[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
}
func seedInFlightForTesting(period: Period, provider: ProviderFilter, day: String? = nil, insertedAt: Date) {
inFlightKeys[PayloadCacheKey(period: period, provider: provider, day: day)] = insertedAt
func cachedPayloadForTesting(scope: MenubarScope = .local,
period: Period,
provider: ProviderFilter,
day: String? = nil,
days: Set<String> = []) -> MenubarPayload? {
cache[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)]?.payload
}
func isInFlightForTesting(period: Period, provider: ProviderFilter, day: String? = nil) -> Bool {
inFlightKeys[PayloadCacheKey(period: period, provider: provider, day: day)] != nil
func setLastErrorForTesting(_ error: String,
scope: MenubarScope = .local,
period: Period,
provider: ProviderFilter,
day: String? = nil,
days: Set<String> = []) {
lastErrorByKey[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)] = error
}
func seedInFlightForTesting(scope: MenubarScope = .local,
period: Period,
provider: ProviderFilter,
day: String? = nil,
insertedAt: Date) {
inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] = insertedAt
}
func isInFlightForTesting(scope: MenubarScope = .local, period: Period, provider: ProviderFilter, day: String? = nil) -> Bool {
inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] != nil
}
func suppressRefreshesForTesting() {
refreshSuppressedForTesting = true
}
#endif
@ -325,6 +404,23 @@ final class AppStore {
}
}
func setMenubarScope(_ scope: MenubarScope) {
let shouldResetProvider = scope == .combined && selectedProvider != .all
guard menubarScope != scope || selectedScope != scope || shouldResetProvider else { return }
menubarScope = scope
selectedScope = scope
if shouldResetProvider {
selectedProvider = .all
}
#if DEBUG
if refreshSuppressedForTesting { return }
#endif
Task { [weak self] in
guard let self else { return }
await self.refreshSelectionQuietly(scope: self.effectiveSelectedScope, force: true)
}
}
/// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only
/// runs the CLI for the final selection. Fetches provider-specific and all-provider data
/// in parallel so the tab strip costs stay in sync with the hero.
@ -333,21 +429,46 @@ final class AppStore {
startInteractiveSelectionRefresh()
}
func switchTo(scope: MenubarScope) {
let shouldResetProvider = scope == .combined && selectedProvider != .all
guard selectedScope != scope || shouldResetProvider else { return }
selectedScope = scope
if shouldResetProvider {
selectedProvider = .all
}
startInteractiveSelectionRefresh()
}
private func startInteractiveSelectionRefresh() {
switchTask?.cancel()
resetLoadingState()
#if DEBUG
if refreshSuppressedForTesting { return }
#endif
let period = selectedPeriod
let provider = selectedProvider
let scope = effectiveSelectedScope
let day = selectedDay
let days = selectedDays
let key = PayloadCacheKey(period: period, provider: provider, day: day, days: days)
let key = PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)
let localKey = PayloadCacheKey(scope: .local, period: period, provider: provider, day: day, days: days)
let allKey = PayloadCacheKey(scope: .local, period: period, provider: .all, day: day, days: days)
lastErrorByKey[key] = nil
switchTask = Task {
if provider == .all {
if scope == .combined {
async let local: Void = refresh(key: localKey, includeOptimize: false, force: false, showLoading: false)
async let combined: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true)
if provider == .all {
_ = await (local, combined)
} else {
async let all: Void = refreshQuietly(key: allKey, includeOptimize: false, force: false)
_ = await (local, combined, all)
}
} else if provider == .all {
await refresh(key: key, includeOptimize: false, force: true, showLoading: true)
} else {
async let main: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true)
async let all: Void = refreshQuietly(period: period, day: day)
async let all: Void = refreshQuietly(key: allKey, includeOptimize: false, force: false)
_ = await (main, all)
}
}
@ -450,7 +571,7 @@ final class AppStore {
func recoverFromStuckLoading() async {
guard prepareStuckLoadingRecovery() else { return }
await refresh(key: currentKey, includeOptimize: false, force: true, showLoading: true)
await refresh(includeOptimize: false, force: true, showLoading: true)
}
/// Decides whether stuck-loading recovery should kick off a fresh fetch for
@ -476,7 +597,30 @@ final class AppStore {
}
func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
await refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading)
if effectiveSelectedScope == .combined {
async let local: Void = refreshQuietly(key: localCurrentKey, includeOptimize: includeOptimize, force: force)
async let combined: Void = refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading)
_ = await (local, combined)
} else {
await refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading)
}
}
private func refreshSelectionQuietly(scope: MenubarScope, force: Bool = false) async {
let scopedKey = PayloadCacheKey(
scope: scope,
period: selectedPeriod,
provider: selectedProvider,
day: selectedDay,
days: selectedDays
)
if scope == .combined {
async let local: Void = refreshQuietly(key: localCurrentKey, includeOptimize: false, force: false)
async let combined: Void = refreshQuietly(key: scopedKey, includeOptimize: false, force: force)
_ = await (local, combined)
} else {
await refreshQuietly(key: scopedKey, includeOptimize: false, force: force)
}
}
private func refresh(key: PayloadCacheKey, includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
@ -515,7 +659,7 @@ final class AppStore {
}
}
do {
let fresh = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: includeOptimize)
let fresh = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: includeOptimize, scope: key.scope)
if generationAtStart != payloadRefreshGeneration {
NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch")
return
@ -545,7 +689,7 @@ final class AppStore {
NSLog("CodeBurn: fetch failed for \(key.label)/\(key.provider.rawValue): \(error)")
if includeOptimize, cache[key] == nil {
do {
let fallback = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: false)
let fallback = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: false, scope: key.scope)
guard !Task.isCancelled else { return }
if generationAtStart != payloadRefreshGeneration { return }
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
@ -564,9 +708,9 @@ final class AppStore {
lastErrorByKey[key] = String(describing: error)
}
let allKey = PayloadCacheKey(period: key.period, provider: .all, day: key.day)
let allKey = PayloadCacheKey(scope: .local, period: key.period, provider: .all, day: key.day, days: key.days)
if key != allKey, cache[allKey]?.isFresh != true {
await refreshQuietly(period: key.period, day: key.day)
await refreshQuietly(key: allKey, includeOptimize: false, force: false)
}
}
@ -574,22 +718,32 @@ final class AppStore {
/// Does not toggle isLoading, so the popover's loading overlay is unaffected.
/// Always uses the .all provider since the menubar badge shows total spend.
func refreshQuietly(period: Period, day: String? = nil, force: Bool = false) async {
await refreshQuietly(key: PayloadCacheKey(scope: .local, period: period, provider: .all, day: day), includeOptimize: false, force: force)
}
private func refreshQuietly(key: PayloadCacheKey, includeOptimize: Bool, force: Bool = false) async {
invalidateStaleDayCache()
let key = PayloadCacheKey(period: period, provider: .all, day: day)
if !force, cache[key]?.isFresh == true { return }
if inFlightKeys[key] != nil { return }
inFlightKeys[key] = Date()
attemptedKeys.insert(key)
let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration
if day == nil && period == .today, let age = todayPayloadAgeSeconds, age > 120 {
if key.day == nil && key.period == .today, let age = todayPayloadAgeSeconds, age > 120 {
NSLog("CodeBurn: refreshing stale today status payload after %ds", age)
}
defer {
inFlightKeys[key] = nil
}
do {
let fresh = try await DataClient.fetch(period: period, day: day, provider: .all, includeOptimize: false)
let fresh = try await DataClient.fetch(
period: key.period,
day: key.day,
days: key.days,
provider: key.provider,
includeOptimize: includeOptimize,
scope: key.scope
)
if generationAtStart != payloadRefreshGeneration {
NSLog("CodeBurn: dropping quiet fetch result for \(key.label) — refresh pipeline reset mid-fetch")
return
@ -605,6 +759,9 @@ final class AppStore {
lastErrorByKey[key] = nil
} catch {
NSLog("CodeBurn: quiet refresh failed for \(key.label): \(error)")
if key.scope == .combined {
lastErrorByKey[key] = String(describing: error)
}
}
}

View file

@ -40,25 +40,20 @@ struct CLIDecodeFailure: Error, CustomStringConvertible {
/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
/// commands through `/bin/zsh -c` anymore.
struct DataClient {
static func fetch(period: Period, day: String? = nil, days: Set<String> = [], provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload {
var subcommand = [
"status",
"--format", "menubar-json",
"--provider", provider.cliArg,
]
if days.count > 1 {
subcommand.append(contentsOf: ["--days", days.sorted().joined(separator: ",")])
} else if let day {
subcommand.append(contentsOf: ["--day", day])
} else if let d = days.first {
subcommand.append(contentsOf: ["--day", d])
} else {
subcommand.append(contentsOf: ["--period", period.cliArg])
}
if !includeOptimize {
subcommand.append("--no-optimize")
}
static func fetch(period: Period,
day: String? = nil,
days: Set<String> = [],
provider: ProviderFilter,
includeOptimize: Bool,
scope: MenubarScope = .local) async throws -> MenubarPayload {
let subcommand = statusSubcommand(
period: period,
day: day,
days: days,
provider: provider,
includeOptimize: includeOptimize,
scope: scope
)
let result = try await runCLI(subcommand: subcommand)
guard result.exitCode == 0 else {
throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr)
@ -76,6 +71,37 @@ struct DataClient {
}
}
static func statusSubcommand(period: Period,
day: String? = nil,
days: Set<String> = [],
provider: ProviderFilter,
includeOptimize: Bool,
scope: MenubarScope = .local) -> [String] {
let effectiveScope: MenubarScope = days.count > 1 ? .local : scope
let effectiveProvider: ProviderFilter = effectiveScope == .combined ? .all : provider
var subcommand = [
"status",
"--format", "menubar-json",
"--provider", effectiveProvider.cliArg,
]
if effectiveScope == .combined {
subcommand.append(contentsOf: ["--scope", effectiveScope.cliArg])
}
if days.count > 1 {
subcommand.append(contentsOf: ["--days", days.sorted().joined(separator: ",")])
} else if let day {
subcommand.append(contentsOf: ["--day", day])
} else if let d = days.first {
subcommand.append(contentsOf: ["--day", d])
} else {
subcommand.append(contentsOf: ["--period", period.cliArg])
}
if !includeOptimize {
subcommand.append("--no-optimize")
}
return subcommand
}
struct ProcessResult {
let stdout: Data
let stderr: String

View file

@ -7,6 +7,40 @@ struct MenubarPayload: Codable, Sendable {
let current: CurrentBlock
let optimize: OptimizeBlock
let history: HistoryBlock
let combined: CombinedUsage?
}
struct CombinedUsage: Codable, Sendable {
let perDevice: [CombinedDeviceUsage]
let combined: CombinedUsageTotals
}
struct CombinedDeviceUsage: Codable, Sendable {
let id: String
let name: String
let local: Bool
let error: String?
let cost: Double
let calls: Int
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let cacheCreateTokens: Int
let cacheReadTokens: Int
let totalTokens: Int
}
struct CombinedUsageTotals: Codable, Sendable {
let cost: Double
let calls: Int
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let cacheCreateTokens: Int
let cacheReadTokens: Int
let totalTokens: Int
let deviceCount: Int
let reachableCount: Int
}
struct HistoryBlock: Codable, Sendable {
@ -387,6 +421,7 @@ extension MenubarPayload {
mcpServers: []
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: [])
history: HistoryBlock(daily: []),
combined: nil
)
}

View file

@ -0,0 +1,39 @@
import Foundation
private let menubarScopeDefaultsKey = "CodeBurnMenubarScope"
enum MenubarScope: String, CaseIterable, Identifiable, Sendable {
case local = "Local"
case combined = "Combined"
var id: String { rawValue }
var cliArg: String {
switch self {
case .local: "local"
case .combined: "combined"
}
}
var menubarDefaultsValue: String {
switch self {
case .local: "local"
case .combined: "combined"
}
}
init(menubarDefaultsValue: String?) {
switch menubarDefaultsValue {
case "combined": self = .combined
default: self = .local
}
}
static func savedMenubarScope(defaults: UserDefaults = .standard) -> MenubarScope {
MenubarScope(menubarDefaultsValue: defaults.string(forKey: menubarScopeDefaultsKey))
}
func persistAsMenubarDefault(defaults: UserDefaults = .standard) {
defaults.set(menubarDefaultsValue, forKey: menubarScopeDefaultsKey)
}
}

View file

@ -27,7 +27,7 @@ struct HeroSection: View {
HStack(spacing: 2) {
Image(systemName: "arrow.up")
.font(.system(size: 9, weight: .semibold))
Text(formatTokens(Double(store.payload.current.outputTokens)))
Text(formatTokens(Double(totals.outputTokens)))
}
.font(.system(size: 11))
.monospacedDigit()
@ -35,17 +35,17 @@ struct HeroSection: View {
HStack(spacing: 2) {
Image(systemName: "arrow.down")
.font(.system(size: 9, weight: .semibold))
Text(formatTokens(Double(store.payload.current.inputTokens)))
Text(formatTokens(Double(totals.inputTokens)))
}
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.tertiary)
} else {
Text("\(store.payload.current.calls.asThousandsSeparated()) calls")
Text("\(totals.calls.asThousandsSeparated()) calls")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
Text("\(store.payload.current.sessions) sessions")
Text("\(totals.sessions) sessions")
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.tertiary)
@ -55,7 +55,7 @@ struct HeroSection: View {
if !store.isDayMode,
store.selectedPeriod == .today,
store.isOverDailyBudget {
store.shouldShowDailyBudgetWarning {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10))
@ -66,6 +66,18 @@ struct HeroSection: View {
.padding(.top, 2)
}
if let usage = combinedUsage {
CombinedDeviceBreakdown(usage: usage, formatTokens: formatTokens)
} else if store.activeScope == .combined, store.lastError != nil {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10))
Text("Combined unavailable · showing local")
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.secondary)
}
if let savingsCaption {
HStack(spacing: 4) {
Image(systemName: "leaf.fill")
@ -83,13 +95,22 @@ struct HeroSection: View {
private var heroText: String {
if store.displayMetric == .tokens || store.displayMetric == .totalTokens {
let total = Double(store.payload.current.inputTokens + store.payload.current.outputTokens)
let total = Double(totals.totalTokens)
if total >= 1_000_000_000 { return String(format: "%.2fB tok", total / 1_000_000_000) }
if total >= 1_000_000 { return String(format: "%.1fM tok", total / 1_000_000) }
if total >= 1_000 { return String(format: "%.0fK tok", total / 1_000) }
return String(format: "%.0f tok", total)
}
return store.payload.current.cost.asCurrency()
return totals.cost.asCurrency()
}
private var combinedUsage: CombinedUsage? {
guard store.activeScope == .combined else { return nil }
return store.payload.combined
}
private var totals: HeroTotals {
HeroTotals(payload: store.payload, activeScope: store.activeScope)
}
private func formatTokens(_ n: Double) -> String {
@ -101,6 +122,9 @@ struct HeroSection: View {
private var caption: String {
let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
if combinedUsage != nil {
return "Combined · \(label)"
}
if !store.isDayMode && store.selectedPeriod == .today {
return "\(label) · \(todayDate)"
}
@ -113,6 +137,7 @@ struct HeroSection: View {
/// (above) and hypothetical avoided spend (below) never get summed
/// into a misleading "real cost" by the reader.
private var savingsCaption: String? {
guard combinedUsage == nil else { return nil }
let savings = store.payload.current.localModelSavings.totalUSD
guard savings > 0 else { return nil }
return "Saved \(savings.asCurrency()) with local models"
@ -124,3 +149,86 @@ struct HeroSection: View {
return formatter.string(from: Date())
}
}
struct HeroTotals: Equatable {
let cost: Double
let calls: Int
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let totalTokens: Int
init(cost: Double, calls: Int, sessions: Int, inputTokens: Int, outputTokens: Int, totalTokens: Int) {
self.cost = cost
self.calls = calls
self.sessions = sessions
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.totalTokens = totalTokens
}
init(payload: MenubarPayload, activeScope: MenubarScope) {
if activeScope == .combined, let combined = payload.combined?.combined {
self.init(
cost: combined.cost,
calls: combined.calls,
sessions: combined.sessions,
inputTokens: combined.inputTokens,
outputTokens: combined.outputTokens,
totalTokens: combined.inputTokens + combined.outputTokens
)
return
}
let current = payload.current
self.init(
cost: current.cost,
calls: current.calls,
sessions: current.sessions,
inputTokens: current.inputTokens,
outputTokens: current.outputTokens,
totalTokens: current.inputTokens + current.outputTokens
)
}
}
private struct CombinedDeviceBreakdown: View {
let usage: CombinedUsage
let formatTokens: (Double) -> String
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
Image(systemName: "desktopcomputer")
.font(.system(size: 10))
Text("\(usage.combined.reachableCount) of \(usage.combined.deviceCount) devices")
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.secondary)
VStack(spacing: 3) {
ForEach(usage.perDevice, id: \.id) { device in
HStack(spacing: 6) {
Image(systemName: device.error == nil ? "circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: device.error == nil ? 5 : 9, weight: .semibold))
.foregroundStyle(device.error == nil ? Color.secondary.opacity(0.75) : Theme.semanticWarning)
.frame(width: 10)
Text(device.local ? "\(device.name) · local" : device.name)
.font(.system(size: 10.5, weight: .medium))
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 6)
Text(device.error == nil ? device.cost.asCurrency() : "Unavailable")
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.secondary)
Text(formatTokens(Double(device.totalTokens)))
.font(.system(size: 10))
.monospacedDigit()
.foregroundStyle(.tertiary)
}
}
}
}
}
}

View file

@ -22,6 +22,7 @@ struct MenuBarContent: View {
HeroSection()
Divider().opacity(0.5)
PeriodSegmentedControl()
ScopeSegmentedControl()
Divider().opacity(0.5)
if isFilteredEmpty {
EmptyProviderState(provider: store.selectedProvider, periodLabel: store.selectionLabel)
@ -120,6 +121,41 @@ struct MenuBarContent: View {
}
private struct ScopeSegmentedControl: View {
@Environment(AppStore.self) private var store
var body: some View {
HStack(spacing: 1) {
ForEach(MenubarScope.allCases) { scope in
let isActive = store.activeScope == scope
Button {
store.switchTo(scope: scope)
} label: {
Text(scope.rawValue)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(isActive ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
.shadow(color: .black.opacity(isActive ? 0.06 : 0), radius: 1, y: 0.5)
)
}
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(Color.secondary.opacity(0.08))
)
.padding(.horizontal, 12)
.padding(.bottom, 10)
}
}
private struct EmptyProviderState: View {
let provider: ProviderFilter
let periodLabel: String

View file

@ -103,6 +103,15 @@ private struct GeneralSettingsTab: View {
}
}
.pickerStyle(.menu)
Picker("Scope", selection: Binding(
get: { store.menubarScope },
set: { store.setMenubarScope($0) }
)) {
ForEach(MenubarScope.allCases) { scope in
Text(scope.rawValue).tag(scope)
}
}
.pickerStyle(.menu)
Picker("Accent", selection: Binding(
get: { store.accentPreset },
set: { store.accentPreset = $0 }

View file

@ -2,7 +2,40 @@ import Foundation
import Testing
@testable import CodeBurnMenubar
private func menubarPayload(cost: Double) -> MenubarPayload {
private func combinedUsage(cost: Double = 12.5) -> CombinedUsage {
CombinedUsage(
perDevice: [
CombinedDeviceUsage(
id: "local",
name: "MacBook",
local: true,
error: nil,
cost: cost,
calls: 3,
sessions: 2,
inputTokens: 100,
outputTokens: 50,
cacheCreateTokens: 10,
cacheReadTokens: 20,
totalTokens: 180
)
],
combined: CombinedUsageTotals(
cost: cost,
calls: 3,
sessions: 2,
inputTokens: 100,
outputTokens: 50,
cacheCreateTokens: 10,
cacheReadTokens: 20,
totalTokens: 180,
deviceCount: 1,
reachableCount: 1
)
)
}
private func menubarPayload(cost: Double, combined: CombinedUsage? = nil) -> MenubarPayload {
MenubarPayload(
generated: "test",
current: CurrentBlock(
@ -30,7 +63,8 @@ private func menubarPayload(cost: Double) -> MenubarPayload {
mcpServers: []
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: [])
history: HistoryBlock(daily: []),
combined: combined
)
}
@ -74,6 +108,140 @@ struct AppStoreRefreshRecoveryTests {
#expect(!store.shouldResetInteractiveRefreshPipeline)
}
@Test("payload cache partitions local and combined scope")
func payloadCachePartitionsByScope() {
let store = AppStore()
store.setCachedPayloadForTesting(
menubarPayload(cost: 10),
scope: .local,
period: .today,
provider: .all,
fetchedAt: Date()
)
store.setCachedPayloadForTesting(
menubarPayload(cost: 99, combined: combinedUsage(cost: 42)),
scope: .combined,
period: .today,
provider: .all,
fetchedAt: Date()
)
#expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all)?.current.cost == 10)
#expect(store.cachedPayloadForTesting(scope: .combined, period: .today, provider: .all)?.current.cost == 99)
store.selectedScope = .combined
#expect(store.payload.current.cost == 10)
#expect(store.payload.combined?.combined.cost == 42)
}
@Test("multi-day combined selection uses local cache path")
func multiDayCombinedSelectionUsesLocalCachePath() {
let store = AppStore()
let days: Set<String> = ["2026-06-01", "2026-06-02"]
store.selectedScope = .combined
store.selectedDays = days
store.setCachedPayloadForTesting(
menubarPayload(cost: 18),
scope: .local,
period: .today,
provider: .all,
days: days,
fetchedAt: Date()
)
store.setCachedPayloadForTesting(
menubarPayload(cost: 99, combined: combinedUsage(cost: 44)),
scope: .combined,
period: .today,
provider: .all,
days: days,
fetchedAt: Date()
)
#expect(store.activeScope == .local)
#expect(store.payload.current.cost == 18)
#expect(store.payload.combined == nil)
}
@Test("combined failure state does not invalidate local badge payload")
func combinedFailureDoesNotInvalidateLocalBadgePayload() {
let store = AppStore()
store.setCachedPayloadForTesting(
menubarPayload(cost: 31),
scope: .local,
period: .today,
provider: .all,
fetchedAt: Date()
)
store.selectedScope = .combined
store.setLastErrorForTesting(
"timeout",
scope: .combined,
period: .today,
provider: .all
)
#expect(store.lastError == "timeout")
#expect(store.menubarPayload?.current.cost == 31)
#expect(!store.needsStatusPayloadRefresh)
#expect(store.payload.current.cost == 31)
#expect(store.payload.combined == nil)
}
@Test("switching to combined resets selected provider to all")
func switchingToCombinedResetsSelectedProviderToAll() {
let store = AppStore()
store.suppressRefreshesForTesting()
store.selectedScope = .local
store.selectedProvider = .claude
store.switchTo(scope: .combined)
#expect(store.selectedScope == .combined)
#expect(store.selectedProvider == .all)
}
@Test("daily budget warning is suppressed for combined scope")
func dailyBudgetWarningIsSuppressedForCombinedScope() {
let defaults = UserDefaults.standard
let previousDisplayMetric = defaults.object(forKey: "CodeBurnDisplayMetric")
let previousDailyBudget = defaults.object(forKey: "CodeBurnDailyBudget")
defer {
if let previousDisplayMetric {
defaults.set(previousDisplayMetric, forKey: "CodeBurnDisplayMetric")
} else {
defaults.removeObject(forKey: "CodeBurnDisplayMetric")
}
if let previousDailyBudget {
defaults.set(previousDailyBudget, forKey: "CodeBurnDailyBudget")
} else {
defaults.removeObject(forKey: "CodeBurnDailyBudget")
}
}
let store = AppStore()
store.selectedScope = .local
store.selectedDays = []
store.displayMetric = .cost
store.dailyBudget = 10
store.setCachedPayloadForTesting(
menubarPayload(cost: 12.5),
scope: .local,
period: .today,
provider: .all,
fetchedAt: Date()
)
#expect(store.isOverDailyBudget)
#expect(store.shouldShowDailyBudgetWarning)
store.selectedScope = .combined
#expect(store.isOverDailyBudget)
#expect(!store.shouldShowDailyBudgetWarning)
}
@Test("missing today status payload needs status refresh")
func missingTodayStatusPayloadNeedsStatusRefresh() {
let store = AppStore()

View file

@ -1,7 +1,52 @@
import XCTest
import Foundation
import Testing
@testable import CodeBurnMenubar
final class DataClientProcessTests: XCTestCase {
@Suite("DataClient process")
struct DataClientProcessTests {
@Test("status argv local omits scope")
func statusSubcommandLocalOmitsScope() {
let args = DataClient.statusSubcommand(
period: .today,
provider: .claude,
includeOptimize: false,
scope: .local
)
#expect(!args.contains("--scope"))
#expect(value(after: "--provider", in: args) == "claude")
#expect(args.contains("--no-optimize"))
}
@Test("status argv combined adds scope and forces provider all")
func statusSubcommandCombinedAddsScopeAndForcesAllProvider() {
let args = DataClient.statusSubcommand(
period: .today,
provider: .codex,
includeOptimize: true,
scope: .combined
)
#expect(value(after: "--scope", in: args) == "combined")
#expect(value(after: "--provider", in: args) == "all")
#expect(!args.contains("--no-optimize"))
}
@Test("status argv combined multi-day coerces to local")
func statusSubcommandCombinedMultiDayCoercesToLocal() {
let args = DataClient.statusSubcommand(
period: .today,
days: ["2026-06-01", "2026-06-02"],
provider: .codex,
includeOptimize: false,
scope: .combined
)
#expect(!args.contains("--scope"))
#expect(value(after: "--provider", in: args) == "codex")
#expect(value(after: "--days", in: args) == "2026-06-01,2026-06-02")
}
/// Concurrency + timeout smoke test: launch more hung subprocesses than
/// there are cooperative threads, all at once, with a short timeout, and
/// assert every call returns once the timeout kills its sleep.
@ -13,7 +58,8 @@ final class DataClientProcessTests: XCTestCase {
/// built up over ~2 days under the @MainActor refresh loop and is confirmed
/// by the live `sample`, not by this test. Kept as a guard that the
/// off-pool wait + timeout path stays correct under concurrency.
func testConcurrentTimedOutProcessesAllComplete() {
@Test("concurrent timed-out processes all complete")
func concurrentTimedOutProcessesAllComplete() {
let count = ProcessInfo.processInfo.activeProcessorCount * 2 + 4
let done = DispatchSemaphore(value: 0)
@ -31,16 +77,16 @@ final class DataClientProcessTests: XCTestCase {
done.signal()
}
// Wait on the XCTest thread (a real thread, not the cooperative pool) so
// Wait on the test thread (a real thread, not the cooperative pool) so
// the deadlock is detectable even when the pool is fully starved.
let outcome = done.wait(timeout: .now() + 15)
XCTAssertEqual(outcome, .success,
"runProcess deadlocked: \(count) concurrent CLIs starved the cooperative pool")
#expect(outcome == .success, "runProcess deadlocked: \(count) concurrent CLIs starved the cooperative pool")
}
/// A decode failure surfaces the CLI's actual stdout/stderr so a stray banner
/// on stdout (see #515) is self-diagnosing instead of an opaque "not valid JSON".
func testDecodeFailureSurfacesOutput() {
@Test("decode failure surfaces output")
func decodeFailureSurfacesOutput() {
struct Boom: Error {}
let failure = CLIDecodeFailure(
underlying: Boom(),
@ -49,36 +95,39 @@ final class DataClientProcessTests: XCTestCase {
stderr: "warn: x"
)
let text = String(describing: failure)
XCTAssertTrue(text.contains("(node) banner"), "should include the stdout snippet")
XCTAssertTrue(text.contains("13 bytes"), "should include the stdout byte count")
XCTAssertTrue(text.contains("warn: x"), "should include stderr")
#expect(text.contains("(node) banner"), "should include the stdout snippet")
#expect(text.contains("13 bytes"), "should include the stdout byte count")
#expect(text.contains("warn: x"), "should include stderr")
}
/// Empty stdout is reported distinctly (the JSONDecoder-on-empty-Data case).
func testDecodeFailureWithEmptyStdout() {
@Test("decode failure with empty stdout")
func decodeFailureWithEmptyStdout() {
struct Boom: Error {}
let failure = CLIDecodeFailure(underlying: Boom(), stdoutByteCount: 0, stdoutSnippet: "", stderr: "")
let text = String(describing: failure)
XCTAssertTrue(text.contains("0 bytes"))
XCTAssertTrue(text.contains("<empty>"))
#expect(text.contains("0 bytes"))
#expect(text.contains("<empty>"))
}
/// A normally-exiting process returns its real output and exit code through
/// the off-pool wait path.
func testProcessReturnsOutputAndExitCode() async throws {
@Test("process returns output and exit code")
func processReturnsOutputAndExitCode() async throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/echo")
process.arguments = ["hello"]
let result = try await DataClient.runProcess(process, timeoutSeconds: 5, label: "echo hello")
XCTAssertEqual(result.exitCode, 0)
XCTAssertEqual(String(data: result.stdout, encoding: .utf8), "hello\n")
#expect(result.exitCode == 0)
#expect(String(data: result.stdout, encoding: .utf8) == "hello\n")
}
/// Many NORMALLY-exiting processes, all at once, must every one complete
/// through the terminationHandler wait path. Guards against the wait path
/// leaking or wedging under concurrency (the production bug was the wait and
/// its timeout sharing one queue that saturated under sustained load).
func testManyNormalProcessesAllComplete() async {
@Test("many normal processes all complete")
func manyNormalProcessesAllComplete() async {
let count = 50
let codes = await withTaskGroup(of: Int32?.self) { group -> [Int32?] in
for _ in 0..<count {
@ -93,13 +142,14 @@ final class DataClientProcessTests: XCTestCase {
for await code in group { out.append(code) }
return out
}
XCTAssertEqual(codes.count, count)
XCTAssertTrue(codes.allSatisfy { $0 == 0 },
"every concurrent process should exit 0 via the terminationHandler wait path")
#expect(codes.count == count)
#expect(codes.allSatisfy { $0 == 0 },
"every concurrent process should exit 0 via the terminationHandler wait path")
}
/// The async semaphore never lets more than its count run concurrently.
func testAsyncSemaphoreCapsConcurrency() async {
@Test("async semaphore caps concurrency")
func asyncSemaphoreCapsConcurrency() async {
let sem = AsyncSemaphore(2)
let peak = PeakCounter()
await withTaskGroup(of: Void.self) { group in
@ -114,11 +164,18 @@ final class DataClientProcessTests: XCTestCase {
}
}
let observed = await peak.peak
XCTAssertLessThanOrEqual(observed, 2, "semaphore should cap concurrency at 2, saw \(observed)")
XCTAssertGreaterThan(observed, 0)
#expect(observed <= 2, "semaphore should cap concurrency at 2, saw \(observed)")
#expect(observed > 0)
}
}
private func value(after flag: String, in args: [String]) -> String? {
guard let index = args.firstIndex(of: flag), args.indices.contains(index + 1) else {
return nil
}
return args[index + 1]
}
private actor PeakCounter {
private var current = 0
private(set) var peak = 0

View file

@ -0,0 +1,130 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("MenubarPayload combined")
struct MenubarPayloadCombinedTests {
@Test("decodes combined block")
func decodesCombinedBlock() throws {
let json = combinedPayloadJSON()
let payload = try JSONDecoder().decode(MenubarPayload.self, from: json)
#expect(payload.combined?.perDevice.count == 2)
#expect(payload.combined?.perDevice.first?.id == "local-device")
#expect(payload.combined?.perDevice.first?.local == true)
#expect(payload.combined?.perDevice.last?.error == "offline")
#expect(payload.combined?.combined.cost == 4.5)
#expect(payload.combined?.combined.calls == 7)
#expect(payload.combined?.combined.deviceCount == 2)
#expect(payload.combined?.combined.reachableCount == 1)
}
@Test("combined hero token total excludes cache tokens")
func combinedHeroTokenTotalExcludesCacheTokens() throws {
let payload = try JSONDecoder().decode(MenubarPayload.self, from: combinedPayloadJSON())
let totals = HeroTotals(payload: payload, activeScope: .combined)
#expect(payload.combined?.combined.totalTokens == 2000)
#expect(totals.inputTokens == 1000)
#expect(totals.outputTokens == 500)
#expect(totals.totalTokens == 1500)
}
@Test("combined block is nil when absent")
func combinedBlockIsNilWhenAbsent() throws {
let json = Data("""
{
"generated": "2026-06-24T00:00:00Z",
"current": {
"label": "Today",
"cost": 1.25,
"calls": 2,
"sessions": 1,
"inputTokens": 100,
"outputTokens": 50
},
"optimize": {
"findingCount": 0,
"savingsUSD": 0,
"topFindings": []
},
"history": {
"daily": []
}
}
""".utf8)
let payload = try JSONDecoder().decode(MenubarPayload.self, from: json)
#expect(payload.combined == nil)
}
}
private func combinedPayloadJSON() -> Data {
Data("""
{
"generated": "2026-06-24T00:00:00Z",
"current": {
"label": "Today",
"cost": 1.25,
"calls": 2,
"sessions": 1,
"inputTokens": 100,
"outputTokens": 50
},
"optimize": {
"findingCount": 0,
"savingsUSD": 0,
"topFindings": []
},
"history": {
"daily": []
},
"combined": {
"perDevice": [
{
"id": "local-device",
"name": "MacBook Pro",
"local": true,
"error": null,
"cost": 4.5,
"calls": 7,
"sessions": 3,
"inputTokens": 1000,
"outputTokens": 500,
"cacheCreateTokens": 200,
"cacheReadTokens": 300,
"totalTokens": 2000
},
{
"id": "remote-device",
"name": "Studio",
"local": false,
"error": "offline",
"cost": 0,
"calls": 0,
"sessions": 0,
"inputTokens": 0,
"outputTokens": 0,
"cacheCreateTokens": 0,
"cacheReadTokens": 0,
"totalTokens": 0
}
],
"combined": {
"cost": 4.5,
"calls": 7,
"sessions": 3,
"inputTokens": 1000,
"outputTokens": 500,
"cacheCreateTokens": 200,
"cacheReadTokens": 300,
"totalTokens": 2000,
"deviceCount": 2,
"reachableCount": 1
}
}
}
""".utf8)
}

View file

@ -1,48 +1,71 @@
import Foundation
import XCTest
import Testing
@testable import CodeBurnMenubar
final class MenubarPeriodSettingsTests: XCTestCase {
func testSettingsPickerExposesRequestedPeriods() {
XCTAssertEqual(Period.menubarMetricCases, [.today, .sevenDays, .month, .all])
@Suite("Menubar period settings")
struct MenubarPeriodSettingsTests {
@Test("settings picker exposes requested periods")
func settingsPickerExposesRequestedPeriods() {
#expect(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)
@Test("defaults values map to periods")
func defaultsValuesMapToPeriods() {
#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)
}
func testPeriodsPersistCanonicalDefaultsValues() throws {
@Test("periods persist canonical defaults values")
func periodsPersistCanonicalDefaultsValues() {
let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
let defaults = 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)
#expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "week")
#expect(Period.savedMenubarPeriod(defaults: defaults) == .sevenDays)
Period.all.persistAsMenubarDefault(defaults: defaults)
XCTAssertEqual(defaults.string(forKey: "CodeBurnMenubarPeriod"), "sixMonths")
XCTAssertEqual(Period.savedMenubarPeriod(defaults: defaults), .all)
#expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "sixMonths")
#expect(Period.savedMenubarPeriod(defaults: defaults) == .all)
Period.thirtyDays.persistAsMenubarDefault(defaults: defaults)
XCTAssertEqual(defaults.string(forKey: "CodeBurnMenubarPeriod"), "today")
XCTAssertEqual(Period.savedMenubarPeriod(defaults: defaults), .today)
#expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "today")
#expect(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")
@Test("menubar scope persistence defaults to local and round-trips")
func menubarScopePersistenceDefaultsToLocalAndRoundTrips() {
let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
#expect(MenubarScope.savedMenubarScope(defaults: defaults) == .local)
MenubarScope.combined.persistAsMenubarDefault(defaults: defaults)
#expect(defaults.string(forKey: "CodeBurnMenubarScope") == "combined")
#expect(MenubarScope.savedMenubarScope(defaults: defaults) == .combined)
MenubarScope.local.persistAsMenubarDefault(defaults: defaults)
#expect(defaults.string(forKey: "CodeBurnMenubarScope") == "local")
#expect(MenubarScope.savedMenubarScope(defaults: defaults) == .local)
#expect(MenubarScope(menubarDefaultsValue: "bogus") == .local)
}
@Test("non-today periods render compact and regular suffixes")
func nonTodayPeriodsRenderCompactAndRegularSuffixes() {
#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")
}
}

View file

@ -24,7 +24,8 @@ struct MenubarStatusCacheTests {
tools: [], skills: [], subagents: [], mcpServers: []
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: [])
history: HistoryBlock(daily: []),
combined: nil
)
return try! JSONEncoder().encode(p)
}