diff --git a/.github/pr-proof/overview-spend-summary.png b/.github/pr-proof/overview-spend-summary.png new file mode 100644 index 0000000000..4a4ea55118 Binary files /dev/null and b/.github/pr-proof/overview-spend-summary.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b65ed4e9a..1853404b7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.52.1 — Unreleased ### Added +- Overview: show selected-provider usage and spend with partial-price, coverage, and provenance context (#3023). Thanks @Chipagosfinest! - CLI: add `codexbar usage --format toon` emitting the JSON payload as TOON v4.1 for agents that prefer denser structured output (#2996, #3021). Thanks @elijahfriedman and @teseo for the spec! - Usage & Spend: add an All time range alongside 7d/30d, backed by 365 days of local history with dedicated Claude and Cursor spend snapshot slots (#3009). Thanks @Yuxin-Qiao! - Usage & Spend: add explicit cost provenance, coverage counters, and a pinned IANA day-bucketing timezone while making uncertain Codex fork accounting fail closed (#3015). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index cacc1426e4..b8e037ac07 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -578,6 +578,32 @@ extension StatusItemController { let t0 = CACurrentMediaTime() defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) } + let spendProviders = overviewProviders.filter { self.settings.costSummaryShowsInline(for: $0) } + let spendModel = self.overviewSpendDashboardModel(providers: spendProviders) + if !spendModel.groups.isEmpty { + let spendSummary = OverviewSpendSummary( + model: spendModel, + providerCount: spendProviders.count) + let summaryItem = self.makeMenuCardItem( + OverviewSpendSummaryCardView( + summary: spendSummary, + days: spendModel.requestedDays, + width: menuWidth), + id: "overviewSpendSummary", + width: menuWidth, + heightCacheScope: "overviewSpendSummary", + heightCacheFingerprint: [ + spendSummary.primarySpendText, + spendSummary.providerCoverageText, + spendSummary.tokenText ?? "", + spendSummary.historyCoverageText, + spendSummary.pricingCoverageText, + spendSummary.provenanceText, + ].joined(separator: "|")) + menu.addItem(summaryItem) + menu.addItem(.separator()) + } + for (index, row) in rows.enumerated() { let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" let storageText = self.store.storageFootprintText(for: row.provider) diff --git a/Sources/CodexBar/StatusItemController+OverviewSpend.swift b/Sources/CodexBar/StatusItemController+OverviewSpend.swift new file mode 100644 index 0000000000..9819fff1b0 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+OverviewSpend.swift @@ -0,0 +1,145 @@ +import CodexBarCore +import Foundation +import SwiftUI + +struct OverviewSpendSummary: Equatable { + let primarySpendText: String + let providerCoverageText: String + let tokenText: String? + let historyCoverageText: String + let pricingCoverageText: String + let provenanceText: String + let isPartial: Bool + + init(model: SpendDashboardModel, providerCount: Int) { + let includedProviders = model.groups.flatMap(\.providers) + let providerCount = max(max(0, providerCount), includedProviders.count) + let pricedProviderCount = includedProviders.count { $0.totalCost != nil } + let tokenProviderCount = includedProviders.count { $0.totalTokens != nil } + let isPartial = pricedProviderCount > 0 && pricedProviderCount < providerCount + self.isPartial = isPartial + + self.primarySpendText = model.groups.isEmpty + ? L("Spend unavailable") + : model.groups.map { group in + let text = spendDashboardGroupCostText(group) + guard isPartial, group.totalCost != nil, !text.hasPrefix("~") else { return text } + return "~\(text)" + }.joined(separator: " · ") + self.providerCoverageText = L( + "%d of %d subscriptions have spend", + pricedProviderCount, + providerCount) + + let tokens = Self.safeTokenSum(model.groups.compactMap(\.totalTokens)) + self.tokenText = tokens.map { + let value = ShareStatsFormatting.compactCount($0) + let isPartial = tokenProviderCount < providerCount + return L("%@ tokens", isPartial ? "~\(value)" : value) + } + + let coveredDays = includedProviders.count < providerCount + ? 0 + : model.groups.map(\.coveredDayCount).min() ?? 0 + self.historyCoverageText = spendDashboardCoverageText( + covered: coveredDays, + requested: model.requestedDays) + + let coverage = model.groups.reduce(into: CostUsageCoverageCounts()) { result, group in + result.merge(group.coverage) + } + self.pricingCoverageText = spendDashboardCoverageChipText(coverage) + self.provenanceText = model.groups + .map(\.provenance) + .reduce(into: [CostProvenance]()) { values, provenance in + guard !values.contains(provenance) else { return } + values.append(provenance) + } + .map(spendDashboardProvenanceText) + .joined(separator: " · ") + } + + private static func safeTokenSum(_ values: [Int]) -> Int? { + guard !values.isEmpty else { return nil } + var total = 0 + for value in values { + let result = total.addingReportingOverflow(value) + guard !result.overflow else { return nil } + total = result.partialValue + } + return total + } +} + +struct OverviewSpendSummaryCardView: View { + let summary: OverviewSpendSummary + let days: Int + let width: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 6) { + Text(L("Usage & Spend")) + .font(.headline.weight(.semibold)) + Text("·") + Text(spendDashboardDayRangeText(self.days)) + } + .foregroundStyle(.secondary) + + Text(self.summary.primarySpendText) + .font(.system(.title2, design: .rounded, weight: .bold)) + .monospacedDigit() + .lineLimit(2) + + HStack(spacing: 8) { + Text(self.summary.providerCoverageText) + if let tokenText = self.summary.tokenText { + Text("·") + Text(tokenText) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + Text("\(self.summary.historyCoverageText) · \(self.summary.provenanceText)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + Text(self.summary.pricingCoverageText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding(.vertical, 10) + .frame(width: self.width, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.accentColor.opacity(0.08)) + .padding(.horizontal, 6) + } + } +} + +extension StatusItemController { + func overviewSpendDashboardModel( + providers: [UsageProvider], + now: Date = Date()) -> SpendDashboardModel + { + let inputs = providers.compactMap { provider -> SpendDashboardModel.ProviderInput? in + guard let snapshot = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot else { + return nil + } + return SpendDashboardModel.ProviderInput( + provider: provider, + displayName: self.store.metadata(for: provider).displayName, + snapshot: snapshot) + } + return SpendDashboardModel.build( + inputs: inputs, + requestedDays: self.settings.costUsageHistoryDays, + now: now, + preferredCurrencyCode: self.settings.preferredCurrencyCode) + } +} diff --git a/Tests/CodexBarTests/OverviewSpendSummaryTests.swift b/Tests/CodexBarTests/OverviewSpendSummaryTests.swift new file mode 100644 index 0000000000..bc14360371 --- /dev/null +++ b/Tests/CodexBarTests/OverviewSpendSummaryTests.swift @@ -0,0 +1,138 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct OverviewSpendSummaryTests { + @Test + func `summary marks incomplete provider coverage as partial`() { + let group = self.group( + providers: [ + self.provider(.codex, tokens: 4_800_000, cost: 412.64), + self.provider(.claude, tokens: nil, cost: nil), + self.provider(.openrouter, tokens: 9_640_000, cost: 282.74), + self.provider(.cursor, tokens: 1_250_000, cost: 64.18), + ], + totalTokens: 15_690_000, + totalCost: 759.56, + coverage: CostUsageCoverageCounts(priced: 3, unpriced: 1)) + + let summary = OverviewSpendSummary( + model: SpendDashboardModel(requestedDays: 30, groups: [group]), + providerCount: 4) + + #expect(summary.primarySpendText == "~$759.56") + #expect(summary.providerCoverageText == "3 of 4 subscriptions have spend") + #expect(summary.tokenText == "~15.7M tokens") + #expect(summary.historyCoverageText == "Coverage: 30 / 30") + #expect(summary.pricingCoverageText == "Priced 3 · Unpriced 1 · Unmetered 0 · Estimated 0") + #expect(summary.provenanceText == "List-price equivalent") + #expect(summary.isPartial) + } + + @Test + func `summary keeps distinct currencies separate`() { + let usd = self.group( + currencyCode: "USD", + providers: [self.provider(.codex, tokens: 1000, cost: 12)], + totalTokens: 1000, + totalCost: 12, + coveredDayCount: 7) + let eur = self.group( + currencyCode: "EUR", + providers: [self.provider(.claude, tokens: 2000, cost: 8)], + totalTokens: 2000, + totalCost: 8, + coveredDayCount: 7, + provenance: .vendorMetered) + + let summary = OverviewSpendSummary( + model: SpendDashboardModel(requestedDays: 7, groups: [eur, usd]), + providerCount: 2) + + #expect(summary.primarySpendText.contains("$12.00")) + #expect(summary.primarySpendText.contains("€8.00")) + #expect(summary.providerCoverageText == "2 of 2 subscriptions have spend") + #expect(summary.tokenText == "3K tokens") + #expect(summary.historyCoverageText == "Coverage: 7 / 7") + #expect(summary.provenanceText == "Plan metered · List-price equivalent") + #expect(!summary.isPartial) + } + + @Test + func `summary keeps wholly unpriced spend unavailable`() { + let group = self.group( + providers: [self.provider(.claude, tokens: 2000, cost: nil)], + totalTokens: 2000, + totalCost: nil, + coverage: CostUsageCoverageCounts(unpriced: 1), + provenance: .unknown) + + let summary = OverviewSpendSummary( + model: SpendDashboardModel(requestedDays: 30, groups: [group]), + providerCount: 1) + + #expect(summary.primarySpendText == "Spend unavailable") + #expect(summary.providerCoverageText == "0 of 1 subscriptions have spend") + #expect(summary.pricingCoverageText == "Priced 0 · Unpriced 1 · Unmetered 0 · Estimated 0") + #expect(summary.provenanceText == "Spend unavailable") + #expect(!summary.isPartial) + } + + @Test + func `summary marks a missing selected provider as partial`() { + let group = self.group( + providers: [self.provider(.codex, tokens: 1000, cost: 12)], + totalTokens: 1000, + totalCost: 12) + + let summary = OverviewSpendSummary( + model: SpendDashboardModel(requestedDays: 30, groups: [group]), + providerCount: 2) + + #expect(summary.primarySpendText == "~$12.00") + #expect(summary.providerCoverageText == "1 of 2 subscriptions have spend") + #expect(summary.tokenText == "~1K tokens") + #expect(summary.historyCoverageText == "Coverage: 0 / 30") + #expect(summary.isPartial) + } + + private func provider( + _ provider: UsageProvider, + tokens: Int?, + cost: Double?) -> SpendDashboardModel.ProviderRow + { + SpendDashboardModel.ProviderRow( + id: provider.rawValue, + rank: 1, + provider: provider, + displayName: provider.rawValue, + totalTokens: tokens, + totalCost: cost, + coveredDayCount: 30) + } + + private func group( + currencyCode: String = "USD", + providers: [SpendDashboardModel.ProviderRow], + totalTokens: Int?, + totalCost: Double?, + coveredDayCount: Int = 30, + coverage: CostUsageCoverageCounts? = nil, + provenance: CostProvenance = .listPriceEstimate) -> SpendDashboardModel.CurrencyGroup + { + SpendDashboardModel.CurrencyGroup( + currencyCode: currencyCode, + providers: providers, + models: [], + projects: [], + dailyPoints: [], + totalTokens: totalTokens, + totalCost: totalCost, + coveredDayCount: coveredDayCount, + chartDomain: Date(timeIntervalSince1970: 0)...Date(timeIntervalSince1970: 86400), + modelHistoryCompleteness: totalCost == nil ? .incomplete : .complete, + coverage: coverage ?? CostUsageCoverageCounts(priced: providers.count), + provenance: provenance) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index d70f7bb89f..8e5c710179 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -962,7 +962,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "The memory-pressure debug fixture installs its synthetic entry in the Codex cache slot."), SuppressedProviderReference( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1087, + line: 1113, anchor: "controller.refreshOpenMenuIfStillVisible(menu, provider: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -2482,7 +2482,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1120, + line: 1146, anchor: "return .provider((self.resolvedMenuProvider(enabledProviders: enabledProviders) ?? .codex).instanceID)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2490,7 +2490,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Menu.swift", - line: 1133, + line: 1159, anchor: "return self.store.enabledFirstPartyProvidersForDisplay().first ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/SpendDashboardScreenshotRenderTests.swift b/Tests/CodexBarTests/SpendDashboardScreenshotRenderTests.swift index 918f0d72bb..f3815b797d 100644 --- a/Tests/CodexBarTests/SpendDashboardScreenshotRenderTests.swift +++ b/Tests/CodexBarTests/SpendDashboardScreenshotRenderTests.swift @@ -61,6 +61,15 @@ final class SpendDashboardScreenshotRenderTests: XCTestCase { let renders: [(String, AnyView)] = [ ("usage-spend-30d", AnyView(Self.chrome(selectedDays: 30, group: thirtyGroup))), ("usage-spend-all", AnyView(Self.chrome(selectedDays: SpendDashboardSource.scanDays, group: allGroup))), + ( + "overview-spend-summary", + AnyView( + OverviewSpendSummaryCardView( + summary: OverviewSpendSummary(model: thirty, providerCount: 2), + days: 30, + width: 320) + .padding(.vertical, 8) + .background(Color(nsColor: .windowBackgroundColor)))), ] for (name, view) in renders { let data = try XCTUnwrap(Self.pngData(for: view), "render failed for \(name)") diff --git a/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift new file mode 100644 index 0000000000..9a6a3eb1ef --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift @@ -0,0 +1,81 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `overview spend follows the inline display preference`() throws { + for (style, enabled) in [ + (CostSummaryDisplayStyle.inlineSummary, true), + (.both, true), + (.costSubmenu, false), + ] { + let result = try self.overviewSpendSummaryIsPresent(style: style, costUsageEnabled: true) + #expect(result == enabled, "Unexpected Overview spend visibility for \(style.rawValue)") + } + + #expect(try !self.overviewSpendSummaryIsPresent(style: .both, costUsageEnabled: false)) + } + + private func overviewSpendSummaryIsPresent( + style: CostSummaryDisplayStyle, + costUsageEnabled: Bool) throws -> Bool + { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = true + settings.costSummaryDisplayStyle = style + settings.costUsageEnabled = costUsageEnabled + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let now = Date() + let components = Calendar.current.dateComponents([.year, .month, .day], from: now) + let year = try #require(components.year) + let month = try #require(components.month) + let dayOfMonth = try #require(components.day) + let day = String(format: "%04d-%02d-%02d", year, month, dayOfMonth) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 100, + sessionCostUSD: 1, + last30DaysTokens: 100, + last30DaysCostUSD: 1, + costProvenance: .listPriceEstimate, + daily: [ + CostUsageDailyReport.Entry( + date: day, + inputTokens: 60, + outputTokens: 40, + totalTokens: 100, + requestCount: 1, + costUSD: 1, + modelsUsed: ["test-model"], + modelBreakdowns: nil), + ], + updatedAt: now), provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + return menu.items.contains { ($0.representedObject as? String) == "overviewSpendSummary" } + } +}