mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-09 17:19:15 +00:00
feat(menubar): add Local/Combined usage toggle (#566) (#568)
Some checks are pending
CI / semgrep (push) Waiting to run
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:
parent
88c1cee467
commit
805d2ffa49
12 changed files with 904 additions and 115 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
39
mac/Sources/CodeBurnMenubar/MenubarScope.swift
Normal file
39
mac/Sources/CodeBurnMenubar/MenubarScope.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
130
mac/Tests/CodeBurnMenubarTests/MenubarPayloadCombinedTests.swift
Normal file
130
mac/Tests/CodeBurnMenubarTests/MenubarPayloadCombinedTests.swift
Normal 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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue