diff --git a/.github/pr-proof/overview-spend-summary.png b/.github/pr-proof/overview-spend-summary.png new file mode 100644 index 0000000000..3f4fe69970 Binary files /dev/null and b/.github/pr-proof/overview-spend-summary.png differ diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index cacc1426e4..385aff2ba7 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -578,6 +578,28 @@ extension StatusItemController { let t0 = CACurrentMediaTime() defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) } + let spendModel = self.overviewSpendDashboardModel(providers: overviewProviders) + if !spendModel.groups.isEmpty { + let spendSummary = OverviewSpendSummary( + model: spendModel, + providerCount: overviewProviders.count) + let summaryItem = self.makeMenuCardItem( + OverviewSpendSummaryCardView( + summary: spendSummary, + days: spendModel.requestedDays, + width: menuWidth), + id: "overviewSpendSummary", + width: menuWidth, + heightCacheScope: "overviewSpendSummary", + heightCacheFingerprint: [ + spendSummary.primarySpendText, + spendSummary.coverageText, + spendSummary.tokenText ?? "", + ].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+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 47ed1a890b..51ceb3ad0d 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -33,6 +33,104 @@ extension ProviderSwitcherSelection { } } +struct OverviewSpendSummary: Equatable { + let primarySpendText: String + let coverageText: String + let tokenText: String? + let isPartial: Bool + + init(model: SpendDashboardModel, providerCount: Int) { + let providerCount = max(0, providerCount) + let knownCostCount = model.groups.reduce(0) { $0 + $1.pricedProviderCount } + let knownTokenRows = model.groups.flatMap(\.providers).compactMap(\.totalTokens) + let knownTokens = Self.safeTokenSum(knownTokenRows) + let tokenCoverageIsComplete = knownTokenRows.count == providerCount && + model.groups.allSatisfy { $0.totalTokens != nil } + self.isPartial = knownCostCount < providerCount || model.groups.contains { $0.totalCost == nil } + + let spendTexts = model.groups.compactMap { group -> String? in + guard let cost = group.totalCost ?? Self.safeCostSum(group.providers.compactMap(\.totalCost)) else { + return nil + } + let formatted = UsageFormatter.currencyString(cost, currencyCode: group.currencyCode) + let groupIsPartial = group.totalCost == nil || knownCostCount < providerCount + return groupIsPartial ? "~\(formatted)" : formatted + } + self.primarySpendText = spendTexts.isEmpty ? L("Spend unavailable") : spendTexts.joined(separator: " · ") + self.coverageText = "\(codexBarLocalizedInteger(knownCostCount)) / " + + "\(codexBarLocalizedInteger(providerCount)) \(L("Providers"))" + self.tokenText = knownTokens.map { + let formatted = ShareStatsFormatting.compactCount($0) + let value = tokenCoverageIsComplete ? formatted : "~\(formatted)" + return L("%@ tokens", value) + } + } + + private static func safeTokenSum(_ values: [Int]) -> Int? { + var total = 0 + for value in values { + let result = total.addingReportingOverflow(value) + guard !result.overflow else { return nil } + total = result.partialValue + } + return values.isEmpty ? nil : total + } + + private static func safeCostSum(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + var total = 0.0 + for value in values { + guard value.isFinite else { return nil } + let result = total + value + guard result.isFinite else { return nil } + total = result + } + 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.coverageText) + if let tokenText = self.summary.tokenText { + Text("·") + Text(tokenText) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .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) + } + } +} + struct OverviewMenuCardRowView: View { static let showsSectionDividers = false diff --git a/Sources/CodexBar/StatusItemController+OverviewSpend.swift b/Sources/CodexBar/StatusItemController+OverviewSpend.swift new file mode 100644 index 0000000000..aee63de202 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+OverviewSpend.swift @@ -0,0 +1,27 @@ +import CodexBarCore +import Foundation + +extension StatusItemController { + func overviewSpendDashboardModel( + providers: [UsageProvider], + now: Date = Date()) -> SpendDashboardModel + { + guard self.settings.costUsageEnabled else { + return SpendDashboardModel(requestedDays: self.settings.costUsageHistoryDays, groups: []) + } + 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..eaa94ac0d0 --- /dev/null +++ b/Tests/CodexBarTests/OverviewSpendSummaryTests.swift @@ -0,0 +1,86 @@ +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: nil, + totalCost: nil) + + let summary = OverviewSpendSummary( + model: SpendDashboardModel(requestedDays: 30, groups: [group]), + providerCount: 4) + + #expect(summary.primarySpendText == "~$759.56") + #expect(summary.coverageText == "3 / 4 Providers") + #expect(summary.tokenText == "~15.7M tokens") + #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) + let eur = self.group( + currencyCode: "EUR", + providers: [self.provider(.claude, tokens: 2000, cost: 8)], + totalTokens: 2000, + totalCost: 8) + + 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.coverageText == "2 / 2 Providers") + #expect(summary.tokenText == "3K tokens") + #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?) -> SpendDashboardModel.CurrencyGroup + { + SpendDashboardModel.CurrencyGroup( + currencyCode: currencyCode, + providers: providers, + models: [], + projects: [], + dailyPoints: [], + totalTokens: totalTokens, + totalCost: totalCost, + coveredDayCount: 30, + chartDomain: Date(timeIntervalSince1970: 0)...Date(timeIntervalSince1970: 86400), + modelHistoryCompleteness: totalCost == nil ? .incomplete : .complete) + } +} 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)")