diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 9c77cf4ef..b9a68bfcc 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -20,6 +20,18 @@ struct CostHistoryChartMenuView: View { } } + private struct DetailRow: Identifiable { + let id: String + let title: String + let subtitle: String? + let accentColor: Color + } + + private struct DetailContent { + let primary: String + let rows: [DetailRow] + } + private let provider: UsageProvider private let daily: [DailyEntry] private let totalCostUSD: Double? @@ -88,22 +100,48 @@ struct CostHistoryChartMenuView: View { } } - let detail = self.detailLines(model: model) - VStack(alignment: .leading, spacing: 0) { + let detail = self.detailContent(model: model) + VStack(alignment: .leading, spacing: Self.detailSpacing) { Text(detail.primary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - Text(detail.secondary ?? " ") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - .opacity(detail.secondary == nil ? 0 : 1) + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + ForEach(detail.rows) { row in + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(row.accentColor) + .frame(width: 2, height: row.subtitle == nil ? 14 : Self.detailRowHeight) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 1) { + Text(row.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + if let subtitle = row.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + .frame(height: Self.detailRowHeight, alignment: .leading) + } + ForEach(0.. Double { maxValue * 0.05 @@ -150,6 +193,7 @@ struct CostHistoryChartMenuView: View { var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 + var maxRenderedBreakdownRows = 0 for entry in sorted { guard let costUSD = entry.costUSD, costUSD > 0 else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } @@ -158,6 +202,7 @@ struct CostHistoryChartMenuView: View { pointsByKey[entry.date] = point entriesByKey[entry.date] = entry dateKeys.append((entry.date, date)) + maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, Self.renderedBreakdownRowCount(for: entry)) if let cur = peak { if costUSD > cur.costUSD { peak = (entry.date, costUSD) } } else { @@ -181,7 +226,8 @@ struct CostHistoryChartMenuView: View { axisDates: axisDates, barColor: barColor, peakKey: peak?.key, - maxCostUSD: maxCostUSD) + maxCostUSD: maxCostUSD, + maxRenderedBreakdownRows: maxRenderedBreakdownRows) } private static func barColor(for provider: UsageProvider) -> Color { @@ -211,6 +257,21 @@ struct CostHistoryChartMenuView: View { return model.pointsByDateKey[key] } + private static func renderedBreakdownRowCount(for entry: DailyEntry) -> Int { + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return 0 } + if breakdown.count > self.maxVisibleDetailLines { + return self.maxVisibleDetailLines + } + return breakdown.count + } + + private static func detailBlockHeight(maxBreakdownRows: Int) -> CGFloat { + guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight } + return self.detailPrimaryLineHeight + + (CGFloat(maxBreakdownRows) * self.detailRowHeight) + + (CGFloat(maxBreakdownRows) * self.detailSpacing) + } + private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { guard let key = self.selectedDateKey else { return nil } guard let plotAnchor = proxy.plotFrame else { return nil } @@ -286,41 +347,84 @@ struct CostHistoryChartMenuView: View { return best?.key } - private func detailLines(model: Model) -> (primary: String, secondary: String?) { + private func detailContent(model: Model) -> DetailContent { guard let key = self.selectedDateKey, let point = model.pointsByDateKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return DetailContent(primary: "Hover a bar for details", rows: []) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let cost = UsageFormatter.usdString(point.costUSD) - if let tokens = point.totalTokens { - let primary = "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" - let secondary = self.topModelsText(key: key, model: model) - return (primary, secondary) + let primary = if let tokens = point.totalTokens { + "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" + } else { + "\(dayLabel): \(cost)" } - let primary = "\(dayLabel): \(cost)" - let secondary = self.topModelsText(key: key, model: model) - return (primary, secondary) + return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model)) } - private func topModelsText(key: String, model: Model) -> String? { - guard let entry = model.entriesByDateKey[key] else { return nil } - guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return nil } - let parts = breakdown - .compactMap { item -> (name: String, costUSD: Double)? in - guard let costUSD = item.costUSD, costUSD > 0 else { return nil } - return (UsageFormatter.modelDisplayName(item.modelName), costUSD) - } + private func breakdownRows(key: String, model: Model) -> [DetailRow] { + guard let entry = model.entriesByDateKey[key] else { return [] } + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } + + let sorted = breakdown .sorted { lhs, rhs in - if lhs.costUSD == rhs.costUSD { return lhs.name < rhs.name } - return lhs.costUSD > rhs.costUSD + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + return lhs.modelName < rhs.modelName + } + + let visibleLimit = sorted.count > Self.maxVisibleDetailLines + ? (Self.maxVisibleDetailLines - 1) + : sorted.count + let visible = Array(sorted.prefix(visibleLimit)) + var rows = visible.enumerated().map { index, item in + DetailRow( + id: "\(item.modelName)-\(index)", + title: UsageFormatter.modelDisplayName(item.modelName), + subtitle: Self.breakdownValueText(costUSD: item.costUSD, totalTokens: item.totalTokens), + accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index))) + } + + let hidden = Array(sorted.dropFirst(visibleLimit)) + if !hidden.isEmpty { + let hiddenCost = hidden.reduce(0.0) { partial, item in + partial + (item.costUSD ?? 0) + } + let hiddenTokens = hidden.reduce(0) { partial, item in + partial + (item.totalTokens ?? 0) } - .prefix(3) - .map { "\($0.name) \(UsageFormatter.usdString($0.costUSD))" } - guard !parts.isEmpty else { return nil } - return "Top: \(parts.joined(separator: " · "))" + rows.append(DetailRow( + id: "overflow", + title: hidden.count == 1 ? "1 more model" : "\(hidden.count) more models", + subtitle: Self.breakdownValueText( + costUSD: hiddenCost > 0 ? hiddenCost : nil, + totalTokens: hiddenTokens > 0 ? hiddenTokens : nil), + accentColor: Color(nsColor: .tertiaryLabelColor).opacity(0.55))) + } + + return rows + } + + private static func breakdownAccentOpacity(for index: Int) -> Double { + let opacity = 0.75 - (Double(index) * 0.12) + return max(0.3, opacity) + } + + private static func breakdownValueText(costUSD: Double?, totalTokens: Int?) -> String? { + var parts: [String] = [] + if let costUSD, costUSD > 0 { + parts.append(UsageFormatter.usdString(costUSD)) + } + if let totalTokens, totalTokens > 0 { + parts.append("\(UsageFormatter.tokenCountString(totalTokens)) tokens") + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") } } diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 665f0977d..c3b82b0e2 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -117,7 +117,10 @@ extension CodexBarCLI { costUSD: entry.costUSD, modelsUsed: entry.modelsUsed, modelBreakdowns: entry.modelBreakdowns?.map { breakdown in - CostModelBreakdownPayload(modelName: breakdown.modelName, costUSD: breakdown.costUSD) + CostModelBreakdownPayload( + modelName: breakdown.modelName, + costUSD: breakdown.costUSD, + totalTokens: breakdown.totalTokens) }) } ?? [] @@ -272,10 +275,12 @@ struct CostDailyEntryPayload: Encodable { struct CostModelBreakdownPayload: Encodable { let modelName: String let costUSD: Double? + let totalTokens: Int? private enum CodingKeys: String, CodingKey { case modelName case costUSD = "cost" + case totalTokens } } diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 60f5e7598..2b2e4e0f0 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -29,11 +29,13 @@ public struct CostUsageDailyReport: Sendable, Decodable { public struct ModelBreakdown: Sendable, Decodable, Equatable { public let modelName: String public let costUSD: Double? + public let totalTokens: Int? private enum CodingKeys: String, CodingKey { case modelName case costUSD case cost + case totalTokens } public init(from decoder: Decoder) throws { @@ -42,11 +44,13 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.costUSD = try container.decodeIfPresent(Double.self, forKey: .costUSD) ?? container.decodeIfPresent(Double.self, forKey: .cost) + self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) } - public init(modelName: String, costUSD: Double?) { + public init(modelName: String, costUSD: Double?, totalTokens: Int? = nil) { self.modelName = modelName self.costUSD = costUSD + self.totalTokens = totalTokens } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 5e32060b0..97c86b8fd 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -513,6 +513,7 @@ extension CostUsageScanner { let cacheCreate = packed[safe: 2] ?? 0 let output = packed[safe: 3] ?? 0 let cachedCost = packed[safe: 4] ?? 0 + let modelTotal = input + cacheRead + cacheCreate + output // Cache tokens are tracked separately; totalTokens includes input + cache. dayInput += input @@ -528,15 +529,25 @@ extension CostUsageScanner { cacheReadInputTokens: cacheRead, cacheCreationInputTokens: cacheCreate, outputTokens: output) - breakdown.append(CostUsageDailyReport.ModelBreakdown(modelName: model, costUSD: cost)) + breakdown.append(CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: modelTotal)) if let cost { dayCost += cost dayCostSeen = true } } - breakdown.sort { lhs, rhs in (rhs.costUSD ?? -1) < (lhs.costUSD ?? -1) } - let top = Array(breakdown.prefix(3)) + breakdown.sort { lhs, rhs in + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + return lhs.modelName < rhs.modelName + } let dayTotal = dayInput + dayCacheRead + dayCacheCreate + dayOutput let entryCost = dayCostSeen ? dayCost : nil @@ -549,7 +560,7 @@ extension CostUsageScanner { totalTokens: dayTotal, costUSD: entryCost, modelsUsed: modelNames, - modelBreakdowns: top)) + modelBreakdowns: breakdown)) totalInput += dayInput totalOutput += dayOutput diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index a5ef942b5..3ca8e9514 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -557,6 +557,7 @@ enum CostUsageScanner { let input = packed[safe: 0] ?? 0 let cached = packed[safe: 1] ?? 0 let output = packed[safe: 2] ?? 0 + let modelTotal = input + output dayInput += input dayOutput += output @@ -566,15 +567,25 @@ enum CostUsageScanner { inputTokens: input, cachedInputTokens: cached, outputTokens: output) - breakdown.append(CostUsageDailyReport.ModelBreakdown(modelName: model, costUSD: cost)) + breakdown.append(CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: modelTotal)) if let cost { dayCost += cost dayCostSeen = true } } - breakdown.sort { lhs, rhs in (rhs.costUSD ?? -1) < (lhs.costUSD ?? -1) } - let top = Array(breakdown.prefix(3)) + breakdown.sort { lhs, rhs in + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + return lhs.modelName < rhs.modelName + } let dayTotal = dayInput + dayOutput let entryCost = dayCostSeen ? dayCost : nil @@ -585,7 +596,7 @@ enum CostUsageScanner { totalTokens: dayTotal, costUSD: entryCost, modelsUsed: modelNames, - modelBreakdowns: top)) + modelBreakdowns: breakdown)) totalInput += dayInput totalOutput += dayOutput diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index d376d383e..a3f82b287 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -57,7 +57,10 @@ struct CLICostTests { costUSD: 0.01, modelsUsed: ["claude-sonnet-4-20250514"], modelBreakdowns: [ - CostModelBreakdownPayload(modelName: "claude-sonnet-4-20250514", costUSD: 0.01), + CostModelBreakdownPayload( + modelName: "claude-sonnet-4-20250514", + costUSD: 0.01, + totalTokens: 15), ]), ], totals: CostTotalsPayload( @@ -83,6 +86,9 @@ struct CLICostTests { #expect(json.contains("\"totals\"")) #expect(json.contains("\"cacheReadTokens\":2")) #expect(json.contains("\"cacheCreationTokens\":3")) + #expect(json + .contains( + "\"modelBreakdowns\":[{\"modelName\":\"claude-sonnet-4-20250514\",\"cost\":0.01,\"totalTokens\":15}]")) #expect(json.contains("\"totalCost\"")) #expect(json.contains("1700000000")) } diff --git a/Tests/CodexBarTests/CostUsageDecodingTests.swift b/Tests/CodexBarTests/CostUsageDecodingTests.swift index 178b9e070..bd4d370b0 100644 --- a/Tests/CodexBarTests/CostUsageDecodingTests.swift +++ b/Tests/CodexBarTests/CostUsageDecodingTests.swift @@ -164,6 +164,30 @@ struct CostUsageDecodingTests { #expect(report.data[0].modelsUsed == ["a-model", "m-model", "z-model"]) } + @Test + func decodesModelBreakdownTokenTotals() throws { + let json = """ + { + "daily": [ + { + "date": "Dec 20, 2025", + "totalTokens": 30, + "costUSD": 0.12, + "modelBreakdowns": [ + { "modelName": "gpt-5.2", "cost": 0.10, "totalTokens": 24 }, + { "modelName": "gpt-5.2-mini", "cost": 0.02, "totalTokens": 6 } + ] + } + ] + } + """ + + let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) + let breakdown = try #require(report.data[0].modelBreakdowns) + #expect(breakdown.map(\.modelName) == ["gpt-5.2", "gpt-5.2-mini"]) + #expect(breakdown.map(\.totalTokens) == [24, 6]) + } + @Test func decodesDailyReportLegacyFormatWithEmptyModelMapAsNil() throws { let json = """ diff --git a/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift b/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift new file mode 100644 index 000000000..b56789eff --- /dev/null +++ b/Tests/CodexBarTests/CostUsageModelBreakdownTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CostUsageModelBreakdownTests { + @Test + func codexScannerKeepsAllModelBreakdownsWithTokenTotals() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2025, month: 12, day: 20) + let models: [(raw: String, input: Int, cached: Int, output: Int)] = [ + ("openai/gpt-5.2-codex", 80, 20, 10), + ("openai/gpt-5.2-mini", 50, 0, 5), + ("openai/o4-mini", 30, 0, 3), + ("openai/o3", 20, 0, 2), + ("openai/gpt-4.1", 10, 0, 1), + ] + + var events: [[String: Any]] = [] + for (index, model) in models.enumerated() { + let turnTimestamp = env.isoString(for: day.addingTimeInterval(TimeInterval(index * 2))) + let tokenTimestamp = env.isoString(for: day.addingTimeInterval(TimeInterval((index * 2) + 1))) + events.append([ + "type": "turn_context", + "timestamp": turnTimestamp, + "payload": [ + "model": model.raw, + ], + ]) + events.append([ + "type": "event_msg", + "timestamp": tokenTimestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": model.input, + "cached_input_tokens": model.cached, + "output_tokens": model.output, + ], + "model": model.raw, + ], + ], + ]) + } + + _ = try env.writeCodexSessionFile( + day: day, + filename: "breakdowns.jsonl", + contents: env.jsonl(events)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + let breakdown = try #require(report.data.first?.modelBreakdowns) + #expect(breakdown.count == models.count) + #expect(breakdown.allSatisfy { $0.totalTokens != nil }) + #expect(breakdown.map(\.totalTokens).compactMap(\.self).sorted() == [11, 22, 33, 55, 90]) + } +}