Skip to content
Closed
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
27 changes: 20 additions & 7 deletions Sources/CodexBar/PreferencesSpendDashboardPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,11 @@ private struct SpendDashboardTrendPanel: View {
HStack(alignment: .center, spacing: 12) {
Text(self.activeSection.title)
.font(.headline)
if self.activeSection == .hourly, let hourlyDay = self.group.hourlyDay {
Text(SpendActivityDateFormatting.mediumDateString(hourlyDay, calendar: self.group.calendar))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if self.availableSections.count > 1 {
Picker(L("Usage & Spend"), selection: self.normalizedSelection) {
Expand Down Expand Up @@ -1259,17 +1264,23 @@ private struct SpendDailyLedgerRow: View {
}

private var tokensText: String {
self.countText(self.summary.totalTokens, format: UsageFormatter.tokenCountString)
self.countText(
self.summary.totalTokens,
isPartial: self.summary.hasPartialTokens,
format: UsageFormatter.tokenCountString)
}

private var requestsText: String {
self.countText(self.summary.requestCount, format: codexBarLocalizedInteger)
self.countText(
self.summary.requestCount,
isPartial: self.summary.hasPartialRequests,
format: codexBarLocalizedInteger)
}

private func countText(_ count: Int?, format: (Int) -> String) -> String {
private func countText(_ count: Int?, isPartial: Bool, format: (Int) -> String) -> String {
guard let count else { return "—" }
let text = format(count)
return self.summary.hasPartialCounts ? "≥\(text)" : text
return isPartial ? "≥\(text)" : text
}

private var accessibilityLabel: String {
Expand All @@ -1289,16 +1300,18 @@ private struct SpendSessionRows: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(self.group.sessions.enumerated()), id: \.element.id) { index, row in
let subtitle = row.modelName ?? SpendActivityDateFormatting.mediumDateString(row.lastActivity)
let subtitle = (row.contextLabels + [
SpendActivityDateFormatting.mediumDateTimeString(row.lastActivity),
]).joined(separator: " · ")
if index > 0 {
Divider()
}
HStack(spacing: 10) {
SpendProviderIcon(provider: row.provider, sourceKind: .native)
VStack(alignment: .leading, spacing: 2) {
Text(row.displayName)
Text(row.headline)
.lineLimit(1)
.help(row.displayName)
.help(row.headline)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
Expand Down
12 changes: 12 additions & 0 deletions Sources/CodexBar/SpendActivityHeatmap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,18 @@ enum SpendActivityDateFormatting {
formatter.timeStyle = .none
return formatter.string(from: date)
}

static func mediumDateTimeString(_ date: Date, calendar: Calendar? = nil, locale: Locale? = nil) -> String {
let formatter = DateFormatter()
formatter.locale = locale ?? codexBarLocalizedResourceLocale()
formatter.calendar = calendar ?? Calendar.current
if let calendar {
formatter.timeZone = calendar.timeZone
}
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: date)
}
}

enum SpendActivityAccessibility {
Expand Down
79 changes: 74 additions & 5 deletions Sources/CodexBar/SpendDashboardModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,19 @@ struct SpendDashboardModel: Equatable, Sendable {
self.incompleteRequestCount > 0 || self.providers.contains(where: \.countsAreLowerBound)
}

/// Totals sum only the providers that report the metric, so a mixed day is a floor.
var hasPartialTokens: Bool {
self.hasPartialCounts || Self.mixesKnownAndUnknown(self.providers.map(\.totalTokens))
}

var hasPartialRequests: Bool {
self.hasPartialCounts || Self.mixesKnownAndUnknown(self.providers.map(\.requestCount))
}

private static func mixesKnownAndUnknown(_ values: [Int?]) -> Bool {
values.contains { $0 != nil } && values.contains { $0 == nil }
}

var id: Date {
self.day
}
Expand Down Expand Up @@ -257,6 +270,8 @@ struct SpendDashboardModel: Equatable, Sendable {
let overflowModelCount: Int
let displayedModels: [ModelRow]
let selectedDay: Date?
/// Day shown by the hourly chart: the selected day, else the newest day with hourly spend.
let hourlyDay: Date?
let hourlyPoints: [HourlyPoint]
let hourlyChartDomain: ClosedRange<Date>?
let timeZone: TimeZone
Expand Down Expand Up @@ -289,6 +304,7 @@ struct SpendDashboardModel: Equatable, Sendable {
sessions: [SessionRow] = [],
overflowModelCount: Int = 0,
selectedDay: Date? = nil,
hourlyDay: Date? = nil,
hourlyPoints: [HourlyPoint] = [],
hourlyChartDomain: ClosedRange<Date>? = nil,
timeZone: TimeZone = .current)
Expand All @@ -313,6 +329,7 @@ struct SpendDashboardModel: Equatable, Sendable {
self.overflowModelCount = overflowModelCount
self.displayedModels = Array(models.prefix(Self.modelRowDisplayLimit))
self.selectedDay = selectedDay
self.hourlyDay = hourlyDay ?? selectedDay
self.hourlyPoints = hourlyPoints
self.hourlyChartDomain = hourlyChartDomain
self.timeZone = timeZone
Expand All @@ -330,6 +347,27 @@ struct SpendDashboardModel: Equatable, Sendable {
let totalTokens: Int?
let totalCost: Double?
let modelName: String?
var title: String?
var projectName: String?

/// Most specific identity available: session title, then project folder, then source name.
var headline: String {
self.title ?? self.projectName ?? self.displayName
}

var contextLabels: [String] {
var labels: [String] = []
if self.title != nil, let projectName = self.projectName {
labels.append(projectName)
}
if self.headline != self.displayName {
labels.append(self.displayName)
}
if let modelName = self.modelName {
labels.append(modelName)
}
return labels
}
}

struct HourlyPoint: Identifiable, Equatable, Sendable {
Expand Down Expand Up @@ -627,9 +665,10 @@ struct SpendDashboardModel: Equatable, Sendable {
case (false, false): .unknown
}
let overflowCount = max(0, modelSummary.rows.count - CurrencyGroup.modelRowDisplayLimit)
let hourlyDay = selectedDay ?? Self.latestHourlyDay(summaries: summaries, bounds: bounds, calendar: calendar)
let hourlyPoints = Self.hourlyPoints(
summaries: summaries,
selectedDay: selectedDay,
selectedDay: hourlyDay,
bounds: bounds,
calendar: calendar)
return CurrencyGroup(
Expand All @@ -652,10 +691,11 @@ struct SpendDashboardModel: Equatable, Sendable {
sessions: Self.sessionRows(summaries: summaries, bounds: bounds, calendar: calendar),
overflowModelCount: overflowCount,
selectedDay: selectedDay,
hourlyDay: hourlyPoints.isEmpty ? nil : hourlyDay,
hourlyPoints: hourlyPoints,
hourlyChartDomain: Self.hourlyChartDomain(
points: hourlyPoints,
selectedDay: selectedDay,
selectedDay: hourlyDay,
calendar: calendar),
timeZone: calendar.timeZone)
}
Expand Down Expand Up @@ -1028,8 +1068,8 @@ struct SpendDashboardModel: Equatable, Sendable {
result.append(DailySummary(
day: day,
providers: sortedRows,
totalTokens: Self.completeIntSum(providerRows.map(\.totalTokens)),
requestCount: Self.completeIntSum(providerRows.map(\.requestCount)),
totalTokens: Self.knownIntSum(providerRows.map(\.totalTokens)),
requestCount: Self.knownIntSum(providerRows.map(\.requestCount)),
totalCost: totalCost))
guard let nextDay = calendar.date(byAdding: .day, value: 1, to: day) else { return [] }
day = calendar.startOfDay(for: nextDay)
Expand Down Expand Up @@ -1452,7 +1492,9 @@ struct SpendDashboardModel: Equatable, Sendable {
lastActivity: session.lastActivity,
totalTokens: session.totalTokens,
totalCost: session.costUSD.map { $0 * summary.costMultiplier },
modelName: modelName)
modelName: modelName,
title: Self.nonEmptyTrimmed(session.title),
projectName: Self.sessionProjectName(path: session.projectPath))
}
}
.sorted { lhs, rhs in
Expand All @@ -1464,6 +1506,18 @@ struct SpendDashboardModel: Equatable, Sendable {
return Array(rows.prefix(12))
}

private static func sessionProjectName(path: String?) -> String? {
guard let path = self.nonEmptyTrimmed(path) else { return nil }
return self.nonEmptyTrimmed(URL(fileURLWithPath: path, isDirectory: true).lastPathComponent)
}

private static func nonEmptyTrimmed(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
return nil
}
return trimmed
}

private static func hourlyPoints(
summaries: [InputSummary],
selectedDay: Date?,
Expand Down Expand Up @@ -1518,6 +1572,21 @@ struct SpendDashboardModel: Equatable, Sendable {
}
}

/// Hourly bars stay readable only within one day, so an unselected range focuses the newest day with spend.
private static func latestHourlyDay(
summaries: [InputSummary],
bounds: ClosedRange<Date>,
calendar: Calendar) -> Date?
{
summaries
.filter { !$0.hasInvalidCostHistory }
.flatMap(\.input.snapshot.hourly)
.filter { (Self.validCost($0.costUSD) ?? 0) > 0 }
.map { calendar.startOfDay(for: $0.hour) }
.filter { bounds.contains($0) }
.max()
}

private static func hourlyChartDomain(
points: [HourlyPoint],
selectedDay: Date?,
Expand Down
8 changes: 7 additions & 1 deletion Sources/CodexBarCore/CostUsageModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ public struct CostUsageSessionBreakdown: Sendable, Equatable, Identifiable {
public let requestCount: Int?
public let costUSD: Double?
public let modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]
public let title: String?
public let projectPath: String?

public var id: String {
self.sessionID
Expand All @@ -140,9 +142,13 @@ public struct CostUsageSessionBreakdown: Sendable, Equatable, Identifiable {
totalTokens: Int?,
requestCount: Int?,
costUSD: Double?,
modelBreakdowns: [CostUsageDailyReport.ModelBreakdown])
modelBreakdowns: [CostUsageDailyReport.ModelBreakdown],
title: String? = nil,
projectPath: String? = nil)
{
self.sessionID = sessionID
self.title = title
self.projectPath = projectPath
self.lastActivity = lastActivity
self.inputTokens = inputTokens
self.cachedInputTokens = cachedInputTokens
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand.

enum CodexParserHash {
static let value = "86c0a89614a11da9"
static let value = "e571f6bf944e28c2"
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ extension CostUsageScanner {
totalTokens: summary?.totalTokens,
requestCount: requestCounts.isEmpty ? nil : requestCounts.reduce(0, +),
costUSD: summary?.totalCostUSD,
modelBreakdowns: Self.codexProjectModelBreakdowns(from: report.data) ?? [])
modelBreakdowns: Self.codexProjectModelBreakdowns(from: report.data) ?? [],
title: file.usage.codexSession?.title,
projectPath: file.usage.projectPath ?? file.usage.codexSession?.cwd)
}
.sorted { lhs, rhs in
if lhs.lastActivity != rhs.lastActivity {
Expand Down
5 changes: 4 additions & 1 deletion Tests/CodexBarTests/CostUsageFetcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1207,7 +1207,7 @@ extension CostUsageFetcherTests {
[
"type": "session_meta",
"timestamp": env.isoString(for: day),
"payload": ["session_id": "first-session"],
"payload": ["session_id": "first-session", "cwd": "/Users/example/Projects/alpha-app"],
],
[
"type": "event_msg",
Expand Down Expand Up @@ -1284,6 +1284,9 @@ extension CostUsageFetcherTests {
#expect(first.requestCount == nil)
#expect(first.modelBreakdowns.map(\.modelName) == ["gpt-5.4"])
#expect(first.costUSD != nil)
#expect(first.projectPath == "/Users/example/Projects/alpha-app")
let second = try #require(snapshot.sessions.first(where: { $0.sessionID == "second-session" }))
#expect(second.projectPath == nil)

let cache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot)
let range = CostUsageScanner.CostUsageDayRange(since: day, until: day)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2491,7 +2491,7 @@ struct ProviderArchitectureGatekeeperTests {
reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."),
AllowedProviderConstruct(
path: "Sources/CodexBar/SpendDashboardModel.swift",
line: 1361,
line: 1401,
anchor: "guard provider == .mistral || provider == .openrouter || provider == .xai else { return displayCalendar }",
expectedProviderIDs: ["mistral", "openrouter", "xai"],
expectedReferenceCount: 3,
Expand Down
41 changes: 41 additions & 0 deletions Tests/CodexBarTests/SpendDashboardDailyLedgerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,47 @@ struct SpendDashboardDailyLedgerTests {
#expect(rows.allSatisfy { $0.isKnownIdle == complete })
}

@Test
func `cost only providers keep known token and request counts as a floor`() throws {
let claude = Self.input(
id: "claude",
provider: .claude,
displayName: "Claude",
entries: [Self.entry(day: "2026-07-16", cost: 2, tokens: 20, requests: 2)],
totalTokens: 20)
let cursor = SpendDashboardModel.ProviderInput(
provider: .cursor,
displayName: "Cursor",
snapshot: CostUsageTokenSnapshot(
sessionTokens: nil,
sessionCostUSD: nil,
last30DaysTokens: nil,
last30DaysCostUSD: 3,
currencyCode: "USD",
historyDays: 3,
daily: [CostUsageDailyReport.Entry(
date: "2026-07-16",
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
costUSD: 3,
modelsUsed: nil,
modelBreakdowns: nil)],
updatedAt: Self.now))

let mixed = try #require(Self.group(inputs: [claude, cursor])?.dailySummaries.last)
#expect(mixed.totalCost == 5)
#expect(mixed.totalTokens == 20)
#expect(mixed.requestCount == 2)
#expect(mixed.hasPartialTokens)
#expect(mixed.hasPartialRequests)

let alone = try #require(Self.group(inputs: [claude])?.dailySummaries.last)
#expect(alone.totalTokens == 20)
#expect(!alone.hasPartialTokens)
#expect(!alone.hasPartialRequests)
}

@Test
func `unpriced requests with zero tokens remain unknown spend`() throws {
let input = Self.input(
Expand Down
Loading
Loading