diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index a9a896ef..8471b87c 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -12,12 +12,14 @@ struct CachedPayload { } struct PayloadCacheKey: Hashable { + let scope: MenubarScope let period: Period let provider: ProviderFilter let day: String? let days: Set - init(period: Period, provider: ProviderFilter, day: String? = nil, days: Set = []) { + init(scope: MenubarScope = .local, period: Period, provider: ProviderFilter, day: String? = nil, days: Set = []) { + 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 = [] + 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? 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 = [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 = [], + fetchedAt: Date) { + cache[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)] = CachedPayload(payload: payload, fetchedAt: fetchedAt) + } + + func cachedPayloadForTesting(scope: MenubarScope = .local, + period: Period, + provider: ProviderFilter, + day: String? = nil, + days: Set = []) -> MenubarPayload? { + cache[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)]?.payload + } + + func setLastErrorForTesting(_ error: String, + scope: MenubarScope = .local, + period: Period, + provider: ProviderFilter, + day: String? = nil, + days: Set = []) { + 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 seedInFlightForTesting(period: Period, provider: ProviderFilter, day: String? = nil, insertedAt: Date) { - inFlightKeys[PayloadCacheKey(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 isInFlightForTesting(period: Period, provider: ProviderFilter, day: String? = nil) -> Bool { - inFlightKeys[PayloadCacheKey(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) + } } } diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift index 7eabd4c4..854fb2e5 100644 --- a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift +++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift @@ -40,12 +40,53 @@ 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 = [], provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload { + static func fetch(period: Period, + day: String? = nil, + days: Set = [], + 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) + } + do { + return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout) + } catch { + let snippet = String(decoding: result.stdout.prefix(2048), as: UTF8.self) + throw DataClientError.decode(CLIDecodeFailure( + underlying: error, + stdoutByteCount: result.stdout.count, + stdoutSnippet: snippet, + stderr: result.stderr + )) + } + } + + static func statusSubcommand(period: Period, + day: String? = nil, + days: Set = [], + 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", provider.cliArg, + "--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 { @@ -58,22 +99,7 @@ struct DataClient { if !includeOptimize { subcommand.append("--no-optimize") } - - let result = try await runCLI(subcommand: subcommand) - guard result.exitCode == 0 else { - throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr) - } - do { - return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout) - } catch { - let snippet = String(decoding: result.stdout.prefix(2048), as: UTF8.self) - throw DataClientError.decode(CLIDecodeFailure( - underlying: error, - stdoutByteCount: result.stdout.count, - stdoutSnippet: snippet, - stderr: result.stderr - )) - } + return subcommand } struct ProcessResult { diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 50507c43..998412ba 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -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 ) } diff --git a/mac/Sources/CodeBurnMenubar/MenubarScope.swift b/mac/Sources/CodeBurnMenubar/MenubarScope.swift new file mode 100644 index 00000000..0ffe6bb9 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/MenubarScope.swift @@ -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) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift index de4f24a4..e05adfec 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -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) + } + } + } + } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 389f909a..4e00381b 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -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 diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index b613510e..3a62587d 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -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 } diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift index 1314fbd9..02aae478 100644 --- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -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 = ["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() diff --git a/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift b/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift index b98449dd..ec95a908 100644 --- a/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift @@ -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("")) + #expect(text.contains("0 bytes")) + #expect(text.contains("")) } /// 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.. 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 { diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarPayloadCombinedTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarPayloadCombinedTests.swift new file mode 100644 index 00000000..3b616cc0 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/MenubarPayloadCombinedTests.swift @@ -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) +} diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift index 7b9f087c..e52a9875 100644 --- a/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift @@ -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") } } diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift index 1d347b18..8c769abf 100644 --- a/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift @@ -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) } diff --git a/src/main.ts b/src/main.ts index 9f0df2ea..734479af 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,10 +17,11 @@ import { renderOverview } from './overview.js' import { runWebDashboard } from './web-dashboard.js' import { hostname } from 'os' import { runShareServer } from './sharing/share-run.js' -import { addRemote, linkRemote, pullDevices, renderDevices } from './sharing/host.js' +import { addRemote, linkRemote, pullDevices, renderDevices, summarizeDeviceUsage } from './sharing/host.js' import { browse } from './sharing/discovery.js' import { promptChoice } from './sharing/prompt.js' import { loadRemotes, saveRemotes } from './sharing/store.js' +import type { UsageQuery } from './sharing/share-server.js' import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js' import { runOptimize } from './optimize.js' import { renderCompare } from './compare.js' @@ -148,6 +149,15 @@ function assertProvider(value: string, command: string): void { process.exit(1) } +function assertScope(value: string, allowed: readonly string[], command: string): void { + if (!allowed.includes(value)) { + process.stderr.write( + `codeburn ${command}: unknown scope "${value}". Valid values: ${allowed.join(', ')}.\n` + ) + process.exit(1) + } +} + async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise { await loadPricing() const { range, label } = getDateRange(period) @@ -605,6 +615,7 @@ program .command('status') .description('Compact status output (today + month)') .option('--format ', 'Output format: terminal, menubar-json, json', 'terminal') + .option('--scope ', 'Usage scope for menubar-json: local, combined', 'local') .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) @@ -616,6 +627,7 @@ program .option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)') .action(async (opts) => { assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status') + assertScope(opts.scope, ['local', 'combined'], 'status') assertProvider(opts.provider, 'status') if (opts.day && (opts.from || opts.to)) { process.stderr.write('error: --day cannot be combined with --from or --to\n') @@ -625,6 +637,14 @@ program process.stderr.write('error: --days cannot be combined with --day, --from, or --to\n') process.exit(1) } + if (opts.format === 'menubar-json' && opts.scope === 'combined' && opts.days) { + process.stderr.write('error: --scope combined cannot be combined with --days\n') + process.exit(1) + } + if (opts.scope === 'combined' && (opts.provider !== 'all' || opts.project.length > 0 || opts.exclude.length > 0)) { + process.stderr.write('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)\n') + process.exit(1) + } await loadPricing() const pf = opts.provider const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude) @@ -644,6 +664,19 @@ program daysSelection, optimize: opts.optimize !== false, }) + if (opts.scope === 'combined') { + const query: UsageQuery = customRange + ? { from: opts.from, to: opts.to } + : daySelection + ? { from: daySelection.day, to: daySelection.day } + : { period: opts.period } + const localGetUsage = async (): Promise => payload + const results = await pullDevices(localGetUsage, query, hostname(), {}) + payload.combined = summarizeDeviceUsage(results, { + start: toDateString(periodInfo.range.start), + end: toDateString(periodInfo.range.end), + }) + } console.log(JSON.stringify(payload)) return } diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 9fe3bec6..ef72538f 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -77,6 +77,37 @@ export type LocalModelSavings = { byProvider: Array<{ name: string; calls: number; savingsUSD: number }> } +export type DeviceSummary = { + id: string + name: string + local: boolean + error?: string + cost: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + cacheCreateTokens: number + cacheReadTokens: number + totalTokens: number +} + +export type CombinedUsage = { + perDevice: DeviceSummary[] + combined: { + cost: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + cacheCreateTokens: number + cacheReadTokens: number + totalTokens: number + deviceCount: number + reachableCount: number + } +} + export type MenubarPayload = { generated: string current: { @@ -180,6 +211,7 @@ export type MenubarPayload = { history: { daily: DailyHistoryEntry[] } + combined?: CombinedUsage } function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null { diff --git a/src/sharing/host.ts b/src/sharing/host.ts index 3600bcf5..a372161e 100644 --- a/src/sharing/host.ts +++ b/src/sharing/host.ts @@ -5,16 +5,23 @@ import { sanitizeForSharing } from './sanitize.js' import type { DiscoveredDevice } from './discovery.js' import type { UsageQuery } from './share-server.js' import { getSharingDir, loadRemotes, saveRemotes, type RemoteDevice } from './store.js' -import type { MenubarPayload } from '../menubar-json.js' +import type { CombinedUsage, DeviceSummary, MenubarPayload } from '../menubar-json.js' import { formatCost } from '../currency.js' import { renderTable } from '../text-table.js' import { Chalk } from 'chalk' +export type { CombinedUsage, DeviceSummary } from '../menubar-json.js' + // Minimal shape we read from a device's usage payload (the menubar payload). // Cache create/read are only in the daily history, so we sum those. type DevicePayload = { current?: { cost?: number; calls?: number; sessions?: number; inputTokens?: number; outputTokens?: number } - history?: { daily?: Array<{ cacheReadTokens?: number; cacheWriteTokens?: number }> } + history?: { daily?: Array<{ date?: string; cacheReadTokens?: number; cacheWriteTokens?: number }> } +} + +type SummaryWindow = { + start: string + end: string } export type DeviceUsage = { @@ -25,6 +32,80 @@ export type DeviceUsage = { error?: string } +const zeroUsage = { + cost: 0, + calls: 0, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, +} + +function num(n: number | undefined): number { + return n ?? 0 +} + +function summarizeOneDevice(d: DeviceUsage, window?: SummaryWindow): DeviceSummary { + const error = d.error !== undefined ? d.error : d.payload === undefined ? 'no usage payload' : undefined + if (error !== undefined || d.payload === undefined) { + return { + id: d.id, + name: d.name, + local: d.local, + error, + ...zeroUsage, + } + } + + const cur = d.payload.current + const daily = (d.payload.history?.daily ?? []).filter((e) => { + if (window === undefined) return true + return e.date !== undefined && window.start <= e.date && e.date <= window.end + }) + const inputTokens = num(cur?.inputTokens) + const outputTokens = num(cur?.outputTokens) + const cacheCreateTokens = daily.reduce((s, e) => s + num(e.cacheWriteTokens), 0) + const cacheReadTokens = daily.reduce((s, e) => s + num(e.cacheReadTokens), 0) + return { + id: d.id, + name: d.name, + local: d.local, + cost: num(cur?.cost), + calls: num(cur?.calls), + sessions: num(cur?.sessions), + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens: inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens, + } +} + +export function summarizeDeviceUsage(results: DeviceUsage[], window?: SummaryWindow): CombinedUsage { + const perDevice = results.map((d) => summarizeOneDevice(d, window)) + const combined = perDevice.reduce( + (a, d) => { + if (d.error !== undefined) return a + return { + cost: a.cost + d.cost, + calls: a.calls + d.calls, + sessions: a.sessions + d.sessions, + inputTokens: a.inputTokens + d.inputTokens, + outputTokens: a.outputTokens + d.outputTokens, + cacheCreateTokens: a.cacheCreateTokens + d.cacheCreateTokens, + cacheReadTokens: a.cacheReadTokens + d.cacheReadTokens, + totalTokens: a.totalTokens + d.totalTokens, + deviceCount: a.deviceCount, + reachableCount: a.reachableCount + 1, + } + }, + { ...zeroUsage, deviceCount: perDevice.length, reachableCount: 0 }, + ) + return { perDevice, combined } +} + function parseHostPort(input: string, defaultPort: number): { host: string; port: number } { const idx = input.lastIndexOf(':') if (idx > 0 && /^\d+$/.test(input.slice(idx + 1))) { @@ -118,38 +199,20 @@ export async function pullDevices( // Joined "Totals by machine" report: one row per device plus a bold Combined // row. Tokens are shown as full, comma-grouped numbers. export function renderDevices(results: DeviceUsage[]): string { - const num = (n: number | undefined): number => n ?? 0 const n = (x: number): string => Math.round(x).toLocaleString() const money = (x: number): string => formatCost(x).replace(/(\d)(?=(\d{3})+(\.|$))/g, '$1,') - const rows = results.map((d) => { - const cur = d.payload?.current - const daily = d.payload?.history?.daily ?? [] - const input = num(cur?.inputTokens) - const output = num(cur?.outputTokens) - const cacheCreate = daily.reduce((s, e) => s + num(e.cacheWriteTokens), 0) - const cacheRead = daily.reduce((s, e) => s + num(e.cacheReadTokens), 0) - return { - name: d.name + (d.local ? ' (this Mac)' : ''), - error: d.error, - cost: num(cur?.cost), - input, - output, - cacheCreate, - cacheRead, - total: input + output + cacheCreate + cacheRead, - } - }) - const combined = rows.reduce( - (a, r) => ({ - cost: a.cost + r.cost, - input: a.input + r.input, - output: a.output + r.output, - cacheCreate: a.cacheCreate + r.cacheCreate, - cacheRead: a.cacheRead + r.cacheRead, - total: a.total + r.total, - }), - { cost: 0, input: 0, output: 0, cacheCreate: 0, cacheRead: 0, total: 0 }, - ) + const summary = summarizeDeviceUsage(results) + const rows = summary.perDevice.map((d) => ({ + name: d.name + (d.local ? ' (this Mac)' : ''), + error: d.error, + cost: d.cost, + input: d.inputTokens, + output: d.outputTokens, + cacheCreate: d.cacheCreateTokens, + cacheRead: d.cacheReadTokens, + total: d.totalTokens, + })) + const combined = summary.combined const tableRows = [ ...rows.map((r) => @@ -157,7 +220,7 @@ export function renderDevices(results: DeviceUsage[]): string { ? [r.name, r.error, '-', '-', '-', '-', '-'] : [r.name, money(r.cost), n(r.total), n(r.input), n(r.output), n(r.cacheCreate), n(r.cacheRead)], ), - ['Combined', money(combined.cost), n(combined.total), n(combined.input), n(combined.output), n(combined.cacheCreate), n(combined.cacheRead)], + ['Combined', money(combined.cost), n(combined.totalTokens), n(combined.inputTokens), n(combined.outputTokens), n(combined.cacheCreateTokens), n(combined.cacheReadTokens)], ] const table = renderTable( [ diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts index 7252938f..10ec4cb8 100644 --- a/tests/cli-status-menubar.test.ts +++ b/tests/cli-status-menubar.test.ts @@ -154,4 +154,163 @@ describe('codeburn status --format menubar-json', () => { await rm(home, { recursive: true, force: true }) } }) + + it('attaches combined local-only usage for --scope combined and omits it for local scope', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'myapp') + await mkdir(projectDir, { recursive: true }) + + const now = new Date() + const h = now.getUTCHours() + const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000) + const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') + const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') + + await writeFile( + join(projectDir, 'session.jsonl'), + [ + userLine('s1', ts1), + assistantLine('s1', ts2, 'msg-1'), + ].join('\n'), + ) + + const combinedResult = runCli([ + 'status', + '--format', 'menubar-json', + '--scope', 'combined', + '--period', 'today', + '--provider', 'all', + '--no-optimize', + ], home) + + expect(combinedResult.status, `stderr: ${combinedResult.stderr}`).toBe(0) + + const payload = JSON.parse(combinedResult.stdout) as { + current: { + cost: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + } + history: { + daily: Array<{ cacheWriteTokens?: number; cacheReadTokens?: number }> + } + combined?: { + perDevice: Array<{ + id: string + local: boolean + cost: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + cacheCreateTokens: number + cacheReadTokens: number + totalTokens: number + }> + combined: { + cost: number + calls: number + sessions: number + inputTokens: number + outputTokens: number + cacheCreateTokens: number + cacheReadTokens: number + totalTokens: number + deviceCount: number + reachableCount: number + } + } + } + + expect(payload.combined).toBeDefined() + expect(payload.combined!.perDevice).toHaveLength(1) + const local = payload.combined!.perDevice[0]! + const cacheCreateTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheWriteTokens ?? 0), 0) + const cacheReadTokens = payload.history.daily.reduce((sum, d) => sum + (d.cacheReadTokens ?? 0), 0) + const totalTokens = payload.current.inputTokens + payload.current.outputTokens + cacheCreateTokens + cacheReadTokens + + expect(local).toMatchObject({ + id: 'local', + local: true, + cost: payload.current.cost, + calls: payload.current.calls, + sessions: payload.current.sessions, + inputTokens: payload.current.inputTokens, + outputTokens: payload.current.outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens, + }) + expect(payload.combined!.combined).toEqual({ + cost: payload.current.cost, + calls: payload.current.calls, + sessions: payload.current.sessions, + inputTokens: payload.current.inputTokens, + outputTokens: payload.current.outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens, + deviceCount: 1, + reachableCount: 1, + }) + + const localResult = runCli([ + 'status', + '--format', 'menubar-json', + '--scope', 'local', + '--period', 'today', + '--provider', 'all', + '--no-optimize', + ], home) + expect(localResult.status, `stderr: ${localResult.stderr}`).toBe(0) + expect(JSON.parse(localResult.stdout)).not.toHaveProperty('combined') + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it.each([ + ['--provider', ['--provider', 'claude']], + ['--project', ['--project', 'x']], + ['--exclude', ['--exclude', 'y']], + ])('rejects combined scope with filtered local payloads from %s', async (_name, filterArgs) => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-filter-')) + + try { + const result = runCli([ + 'status', + '--format', 'menubar-json', + '--scope', 'combined', + ...filterArgs, + '--no-optimize', + ], home) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('error: --scope combined cannot be combined with --provider, --project, or --exclude (paired devices report unfiltered usage)') + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('rejects invalid menubar-json scope values', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-scope-')) + + try { + const result = runCli([ + 'status', + '--format', 'menubar-json', + '--scope', 'remote', + '--no-optimize', + ], home) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('unknown scope "remote"') + } finally { + await rm(home, { recursive: true, force: true }) + } + }) }) diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index f7493d0b..ad2f5822 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { buildMenubarPayload, type PeriodData, type ProviderCost } from '../src/menubar-json.js' +import { buildMenubarPayload, type CombinedUsage, type PeriodData, type ProviderCost } from '../src/menubar-json.js' import type { OptimizeResult } from '../src/optimize.js' function emptyPeriod(label: string): PeriodData { @@ -231,4 +231,42 @@ describe('buildMenubarPayload', () => { const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null) expect(payload.current.providers).toEqual({ claude: 76.45 }) }) + + it('omits combined usage by default and accepts the documented combined shape when attached', () => { + const payload = buildMenubarPayload(emptyPeriod('Today'), [], null) + expect(payload).not.toHaveProperty('combined') + + const combined: CombinedUsage = { + perDevice: [ + { + id: 'local', + name: 'Mac Studio', + local: true, + cost: 1, + calls: 2, + sessions: 1, + inputTokens: 100, + outputTokens: 50, + cacheCreateTokens: 10, + cacheReadTokens: 20, + totalTokens: 180, + }, + ], + combined: { + cost: 1, + calls: 2, + sessions: 1, + inputTokens: 100, + outputTokens: 50, + cacheCreateTokens: 10, + cacheReadTokens: 20, + totalTokens: 180, + deviceCount: 1, + reachableCount: 1, + }, + } + payload.combined = combined + + expect(payload.combined).toEqual(combined) + }) }) diff --git a/tests/sharing/host.test.ts b/tests/sharing/host.test.ts index 3d1dd188..09a377d4 100644 --- a/tests/sharing/host.test.ts +++ b/tests/sharing/host.test.ts @@ -1,34 +1,38 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { mkdtemp, rm } from 'fs/promises' import { join } from 'path' import { tmpdir } from 'os' -import { generateIdentity } from '../../src/sharing/identity.js' -import { PeerStore } from '../../src/sharing/pairing.js' -import { ShareServer } from '../../src/sharing/share-server.js' -import { addRemote, pullDevices, renderDevices, type DeviceUsage } from '../../src/sharing/host.js' +import { addRemote, pullDevices, renderDevices, summarizeDeviceUsage, type DeviceUsage } from '../../src/sharing/host.js' -describe('host device flow (loopback)', () => { - let server: ShareServer - let port: number +const clientMock = vi.hoisted(() => ({ + hello: vi.fn(), + pair: vi.fn(), + pairRequest: vi.fn(), + fetchUsage: vi.fn(), +})) + +vi.mock('../../src/sharing/client.js', () => clientMock) + +describe('host device flow', () => { let dir: string - const remoteUsage = { current: { cost: 100, calls: 10, sessions: 2, inputTokens: 1000, outputTokens: 200 } } - beforeAll(async () => { + beforeEach(async () => { + vi.clearAllMocks() dir = await mkdtemp(join(tmpdir(), 'cb-host-')) - const serverId = await generateIdentity('MacBook') - server = new ShareServer({ identity: serverId, peers: new PeerStore(), getUsage: async () => remoteUsage }) - port = await server.listen(0, '127.0.0.1') }) - afterAll(async () => { - await server.close() + afterEach(async () => { await rm(dir, { recursive: true, force: true }) }) it('pairs, persists, pulls both devices, and combines', async () => { - const pin = server.openPairing() - const device = await addRemote(`127.0.0.1:${port}`, pin, { defaultPort: port, dir }) + const remoteUsage = { current: { cost: 100, calls: 10, sessions: 2, inputTokens: 1000, outputTokens: 200 } } + clientMock.hello.mockResolvedValue({ status: 200, json: { fingerprint: 'remote-fp', name: 'MacBook' } }) + clientMock.pair.mockResolvedValue({ status: 200, json: { token: 'remote-token' } }) + clientMock.fetchUsage.mockResolvedValue({ status: 200, json: remoteUsage }) + + const device = await addRemote('127.0.0.1:7777', '123456', { defaultPort: 7777, dir }) expect(device.name).toBe('MacBook') expect(device.token).toBeTruthy() @@ -41,6 +45,11 @@ describe('host device flow (loopback)', () => { const remote = results.find((r) => !r.local)! expect(remote.name).toBe('MacBook') expect(remote.payload!.current!.cost).toBe(100) + expect(clientMock.fetchUsage).toHaveBeenCalledWith( + expect.objectContaining({ host: '127.0.0.1', port: 7777, expectedFingerprint: 'remote-fp' }), + 'remote-token', + { period: 'month' }, + ) const text = renderDevices(results) expect(text).toContain('Mac Studio (this Mac)') @@ -51,11 +60,174 @@ describe('host device flow (loopback)', () => { it('renders an unreachable device as an error without dropping the combined row', () => { const results: DeviceUsage[] = [ - { name: 'Mac Studio', local: true, payload: { current: { cost: 10, calls: 1, sessions: 1, inputTokens: 1, outputTokens: 1 } } }, - { name: 'MacBook', local: false, error: 'connection refused' }, + { id: 'local', name: 'Mac Studio', local: true, payload: { current: { cost: 10, calls: 1, sessions: 1, inputTokens: 1, outputTokens: 1 } } }, + { id: 'remote-1', name: 'MacBook', local: false, error: 'connection refused' }, ] const text = renderDevices(results) expect(text).toContain('connection refused') expect(text).toContain('Combined') }) + + it('summarizes reachable devices and excludes error rows from combined totals', () => { + const results: DeviceUsage[] = [ + { + id: 'local', + name: 'Mac Studio', + local: true, + payload: { + current: { cost: 10, calls: 2, sessions: 1, inputTokens: 100, outputTokens: 40 }, + history: { + daily: [ + { cacheWriteTokens: 5, cacheReadTokens: 10 }, + { cacheWriteTokens: 7, cacheReadTokens: 3 }, + ], + }, + }, + }, + { + id: 'remote-1', + name: 'MacBook', + local: false, + payload: { + current: { cost: 3, calls: 4, sessions: 2, inputTokens: 20, outputTokens: 30 }, + history: { daily: [{ cacheWriteTokens: 2, cacheReadTokens: 8 }] }, + }, + }, + { id: 'remote-err', name: 'Offline', local: false, error: 'timeout' }, + ] + + const summary = summarizeDeviceUsage(results) + + expect(summary.perDevice).toEqual([ + { + id: 'local', + name: 'Mac Studio', + local: true, + cost: 10, + calls: 2, + sessions: 1, + inputTokens: 100, + outputTokens: 40, + cacheCreateTokens: 12, + cacheReadTokens: 13, + totalTokens: 165, + }, + { + id: 'remote-1', + name: 'MacBook', + local: false, + cost: 3, + calls: 4, + sessions: 2, + inputTokens: 20, + outputTokens: 30, + cacheCreateTokens: 2, + cacheReadTokens: 8, + totalTokens: 60, + }, + { + id: 'remote-err', + name: 'Offline', + local: false, + error: 'timeout', + cost: 0, + calls: 0, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + }, + ]) + expect(summary.combined).toEqual({ + cost: 13, + calls: 6, + sessions: 3, + inputTokens: 120, + outputTokens: 70, + cacheCreateTokens: 14, + cacheReadTokens: 21, + totalTokens: 225, + deviceCount: 3, + reachableCount: 2, + }) + }) + + it('scopes cache-token summaries to the optional window without changing no-window totals', () => { + const results: DeviceUsage[] = [ + { + id: 'local', + name: 'Mac Studio', + local: true, + payload: { + current: { cost: 1, calls: 1, sessions: 1, inputTokens: 100, outputTokens: 50 }, + history: { + daily: [ + { date: '2026-04-09', cacheWriteTokens: 100, cacheReadTokens: 1000 }, + { date: '2026-04-10', cacheWriteTokens: 5, cacheReadTokens: 10 }, + { date: '2026-04-11', cacheWriteTokens: 7, cacheReadTokens: 3 }, + ], + }, + }, + }, + { + id: 'remote-1', + name: 'MacBook', + local: false, + payload: { + current: { cost: 2, calls: 2, sessions: 1, inputTokens: 20, outputTokens: 30 }, + history: { + daily: [ + { date: '2026-04-08', cacheWriteTokens: 11, cacheReadTokens: 13 }, + { date: '2026-04-10', cacheWriteTokens: 2, cacheReadTokens: 8 }, + ], + }, + }, + }, + ] + + const all = summarizeDeviceUsage(results) + expect(all.perDevice[0]).toMatchObject({ + cacheCreateTokens: 112, + cacheReadTokens: 1013, + totalTokens: 1275, + }) + expect(all.perDevice[1]).toMatchObject({ + cacheCreateTokens: 13, + cacheReadTokens: 21, + totalTokens: 84, + }) + expect(all.combined).toMatchObject({ + inputTokens: 120, + outputTokens: 80, + cacheCreateTokens: 125, + cacheReadTokens: 1034, + totalTokens: 1359, + }) + + const scoped = summarizeDeviceUsage(results, { start: '2026-04-10', end: '2026-04-10' }) + expect(scoped.perDevice[0]).toMatchObject({ + cacheCreateTokens: 5, + cacheReadTokens: 10, + totalTokens: 165, + }) + expect(scoped.perDevice[1]).toMatchObject({ + cacheCreateTokens: 2, + cacheReadTokens: 8, + totalTokens: 60, + }) + expect(scoped.combined).toMatchObject({ + cost: 3, + calls: 3, + sessions: 2, + inputTokens: 120, + outputTokens: 80, + cacheCreateTokens: 7, + cacheReadTokens: 18, + totalTokens: 225, + deviceCount: 2, + reachableCount: 2, + }) + }) })