Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/pr-proof/overview-spend-summary.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment on lines +581 to +582

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the submenu-only cost preference

When cost tracking is enabled but the user selects Cost summaries → Submenu only, this path still unconditionally inserts OverviewSpendSummaryCardView directly into the main Overview menu. The existing preference explicitly suppresses main-menu summaries through costSummaryShowsInline(for:), so the new overview summary should also be gated by the inline display preference.

Useful? React with 👍 / 👎.

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)
Expand Down
98 changes: 98 additions & 0 deletions Sources/CodexBar/StatusItemController+MenuTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 27 additions & 0 deletions Sources/CodexBar/StatusItemController+OverviewSpend.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
86 changes: 86 additions & 0 deletions Tests/CodexBarTests/OverviewSpendSummaryTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 9 additions & 0 deletions Tests/CodexBarTests/SpendDashboardScreenshotRenderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
Loading