Skip to content

Commit cb77d5b

Browse files
committed
Merge pull request #8 from iam-brain/bryan/cost-chart-detail
# Conflicts: # Sources/CodexBar/CostHistoryChartMenuView.swift
2 parents 2230253 + 3a17c4b commit cb77d5b

8 files changed

Lines changed: 282 additions & 47 deletions

File tree

Sources/CodexBar/CostHistoryChartMenuView.swift

Lines changed: 138 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ struct CostHistoryChartMenuView: View {
2020
}
2121
}
2222

23+
private struct DetailRow: Identifiable {
24+
let id: String
25+
let title: String
26+
let subtitle: String?
27+
let accentColor: Color
28+
}
29+
30+
private struct DetailContent {
31+
let primary: String
32+
let rows: [DetailRow]
33+
}
34+
2335
private let provider: UsageProvider
2436
private let daily: [DailyEntry]
2537
private let totalCostUSD: Double?
@@ -88,22 +100,48 @@ struct CostHistoryChartMenuView: View {
88100
}
89101
}
90102

91-
let detail = self.detailLines(model: model)
92-
VStack(alignment: .leading, spacing: 0) {
103+
let detail = self.detailContent(model: model)
104+
VStack(alignment: .leading, spacing: Self.detailSpacing) {
93105
Text(detail.primary)
94106
.font(.caption)
95107
.foregroundStyle(.secondary)
96108
.lineLimit(1)
97109
.truncationMode(.tail)
98-
.frame(height: 16, alignment: .leading)
99-
Text(detail.secondary ?? " ")
100-
.font(.caption)
101-
.foregroundStyle(.secondary)
102-
.lineLimit(1)
103-
.truncationMode(.tail)
104-
.frame(height: 16, alignment: .leading)
105-
.opacity(detail.secondary == nil ? 0 : 1)
110+
.frame(height: Self.detailPrimaryLineHeight, alignment: .leading)
111+
ForEach(detail.rows) { row in
112+
HStack(alignment: .top, spacing: 8) {
113+
Rectangle()
114+
.fill(row.accentColor)
115+
.frame(width: 2, height: row.subtitle == nil ? 14 : Self.detailRowHeight)
116+
.padding(.top, 1)
117+
118+
VStack(alignment: .leading, spacing: 1) {
119+
Text(row.title)
120+
.font(.caption)
121+
.foregroundStyle(.secondary)
122+
.lineLimit(1)
123+
.truncationMode(.tail)
124+
if let subtitle = row.subtitle {
125+
Text(subtitle)
126+
.font(.caption2)
127+
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
128+
.lineLimit(1)
129+
.truncationMode(.tail)
130+
}
131+
}
132+
}
133+
.frame(height: Self.detailRowHeight, alignment: .leading)
134+
}
135+
ForEach(0..<max(model.maxRenderedBreakdownRows - detail.rows.count, 0), id: \.self) { _ in
136+
Text(" ")
137+
.font(.caption)
138+
.frame(height: Self.detailRowHeight, alignment: .leading)
139+
.opacity(0)
140+
}
106141
}
142+
.frame(
143+
height: Self.detailBlockHeight(maxBreakdownRows: model.maxRenderedBreakdownRows),
144+
alignment: .topLeading)
107145
}
108146

109147
if let total = self.totalCostUSD {
@@ -126,9 +164,14 @@ struct CostHistoryChartMenuView: View {
126164
let barColor: Color
127165
let peakKey: String?
128166
let maxCostUSD: Double
167+
let maxRenderedBreakdownRows: Int
129168
}
130169

131170
private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1)
171+
private static let maxVisibleDetailLines = 4
172+
private static let detailPrimaryLineHeight: CGFloat = 16
173+
private static let detailRowHeight: CGFloat = 24
174+
private static let detailSpacing: CGFloat = 6
132175

133176
private static func capHeight(maxValue: Double) -> Double {
134177
maxValue * 0.05
@@ -150,6 +193,7 @@ struct CostHistoryChartMenuView: View {
150193

151194
var peak: (key: String, costUSD: Double)?
152195
var maxCostUSD: Double = 0
196+
var maxRenderedBreakdownRows = 0
153197
for entry in sorted {
154198
guard let costUSD = entry.costUSD, costUSD >= 0 else { continue }
155199
guard let date = self.dateFromDayKey(entry.date) else { continue }
@@ -158,6 +202,7 @@ struct CostHistoryChartMenuView: View {
158202
pointsByKey[entry.date] = point
159203
entriesByKey[entry.date] = entry
160204
dateKeys.append((entry.date, date))
205+
maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, Self.renderedBreakdownRowCount(for: entry))
161206
if let cur = peak {
162207
if costUSD > cur.costUSD { peak = (entry.date, costUSD) }
163208
} else {
@@ -181,7 +226,8 @@ struct CostHistoryChartMenuView: View {
181226
axisDates: axisDates,
182227
barColor: barColor,
183228
peakKey: peak?.key,
184-
maxCostUSD: maxCostUSD)
229+
maxCostUSD: maxCostUSD,
230+
maxRenderedBreakdownRows: maxRenderedBreakdownRows)
185231
}
186232

187233
private static func barColor(for provider: UsageProvider) -> Color {
@@ -211,6 +257,21 @@ struct CostHistoryChartMenuView: View {
211257
return model.pointsByDateKey[key]
212258
}
213259

260+
private static func renderedBreakdownRowCount(for entry: DailyEntry) -> Int {
261+
guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return 0 }
262+
if breakdown.count > self.maxVisibleDetailLines {
263+
return self.maxVisibleDetailLines
264+
}
265+
return breakdown.count
266+
}
267+
268+
private static func detailBlockHeight(maxBreakdownRows: Int) -> CGFloat {
269+
guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight }
270+
return self.detailPrimaryLineHeight +
271+
(CGFloat(maxBreakdownRows) * self.detailRowHeight) +
272+
(CGFloat(maxBreakdownRows) * self.detailSpacing)
273+
}
274+
214275
private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? {
215276
guard let key = self.selectedDateKey else { return nil }
216277
guard let plotAnchor = proxy.plotFrame else { return nil }
@@ -286,43 +347,84 @@ struct CostHistoryChartMenuView: View {
286347
return best?.key
287348
}
288349

289-
private func detailLines(model: Model) -> (primary: String, secondary: String?) {
350+
private func detailContent(model: Model) -> DetailContent {
290351
guard let key = self.selectedDateKey,
291352
let point = model.pointsByDateKey[key],
292353
let date = Self.dateFromDayKey(key)
293354
else {
294-
return ("Hover a bar for details", nil)
355+
return DetailContent(primary: "Hover a bar for details", rows: [])
295356
}
296357

297358
let dayLabel = date.formatted(.dateTime.month(.abbreviated).day())
298359
let cost = UsageFormatter.usdString(point.costUSD)
299-
if let tokens = point.totalTokens {
300-
let primary = "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens"
301-
let secondary = self.topModelsText(key: key, model: model)
302-
return (primary, secondary)
360+
let primary = if let tokens = point.totalTokens {
361+
"\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens"
362+
} else {
363+
"\(dayLabel): \(cost)"
303364
}
304-
let primary = "\(dayLabel): \(cost)"
305-
let secondary = self.topModelsText(key: key, model: model)
306-
return (primary, secondary)
365+
return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model))
307366
}
308367

309-
private func topModelsText(key: String, model: Model) -> String? {
310-
guard let entry = model.entriesByDateKey[key] else { return nil }
311-
guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return nil }
312-
let parts = breakdown
313-
.compactMap { item -> (name: String, detail: String, costUSD: Double)? in
314-
guard let costUSD = item.costUSD else { return nil }
315-
let name = UsageFormatter.modelDisplayName(item.modelName)
316-
guard let detail = UsageFormatter.modelCostDetail(item.modelName, costUSD: costUSD) else { return nil }
317-
return (name, detail, costUSD)
318-
}
368+
private func breakdownRows(key: String, model: Model) -> [DetailRow] {
369+
guard let entry = model.entriesByDateKey[key] else { return [] }
370+
guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] }
371+
372+
let sorted = breakdown
319373
.sorted { lhs, rhs in
320-
if lhs.costUSD == rhs.costUSD { return lhs.name < rhs.name }
321-
return lhs.costUSD > rhs.costUSD
374+
let lCost = lhs.costUSD ?? -1
375+
let rCost = rhs.costUSD ?? -1
376+
if lCost != rCost { return lCost > rCost }
377+
let lTokens = lhs.totalTokens ?? -1
378+
let rTokens = rhs.totalTokens ?? -1
379+
if lTokens != rTokens { return lTokens > rTokens }
380+
return lhs.modelName < rhs.modelName
381+
}
382+
383+
let visibleLimit = sorted.count > Self.maxVisibleDetailLines
384+
? (Self.maxVisibleDetailLines - 1)
385+
: sorted.count
386+
let visible = Array(sorted.prefix(visibleLimit))
387+
var rows = visible.enumerated().map { index, item in
388+
DetailRow(
389+
id: "\(item.modelName)-\(index)",
390+
title: UsageFormatter.modelDisplayName(item.modelName),
391+
subtitle: Self.breakdownValueText(costUSD: item.costUSD, totalTokens: item.totalTokens),
392+
accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index)))
393+
}
394+
395+
let hidden = Array(sorted.dropFirst(visibleLimit))
396+
if !hidden.isEmpty {
397+
let hiddenCost = hidden.reduce(0.0) { partial, item in
398+
partial + (item.costUSD ?? 0)
399+
}
400+
let hiddenTokens = hidden.reduce(0) { partial, item in
401+
partial + (item.totalTokens ?? 0)
322402
}
323-
.prefix(3)
324-
.map { "\($0.name) \($0.detail)" }
325-
guard !parts.isEmpty else { return nil }
326-
return "Top: \(parts.joined(separator: " · "))"
403+
rows.append(DetailRow(
404+
id: "overflow",
405+
title: hidden.count == 1 ? "1 more model" : "\(hidden.count) more models",
406+
subtitle: Self.breakdownValueText(
407+
costUSD: hiddenCost > 0 ? hiddenCost : nil,
408+
totalTokens: hiddenTokens > 0 ? hiddenTokens : nil),
409+
accentColor: Color(nsColor: .tertiaryLabelColor).opacity(0.55)))
410+
}
411+
412+
return rows
413+
}
414+
415+
private static func breakdownAccentOpacity(for index: Int) -> Double {
416+
let opacity = 0.75 - (Double(index) * 0.12)
417+
return max(0.3, opacity)
418+
}
419+
420+
private static func breakdownValueText(costUSD: Double?, totalTokens: Int?) -> String? {
421+
var parts: [String] = []
422+
if let costUSD, costUSD > 0 {
423+
parts.append(UsageFormatter.usdString(costUSD))
424+
}
425+
if let totalTokens, totalTokens > 0 {
426+
parts.append("\(UsageFormatter.tokenCountString(totalTokens)) tokens")
427+
}
428+
return parts.isEmpty ? nil : parts.joined(separator: " · ")
327429
}
328430
}

Sources/CodexBarCLI/CLICostCommand.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@ extension CodexBarCLI {
117117
costUSD: entry.costUSD,
118118
modelsUsed: entry.modelsUsed,
119119
modelBreakdowns: entry.modelBreakdowns?.map { breakdown in
120-
CostModelBreakdownPayload(modelName: breakdown.modelName, costUSD: breakdown.costUSD)
120+
CostModelBreakdownPayload(
121+
modelName: breakdown.modelName,
122+
costUSD: breakdown.costUSD,
123+
totalTokens: breakdown.totalTokens)
121124
})
122125
} ?? []
123126

@@ -272,10 +275,12 @@ struct CostDailyEntryPayload: Encodable {
272275
struct CostModelBreakdownPayload: Encodable {
273276
let modelName: String
274277
let costUSD: Double?
278+
let totalTokens: Int?
275279

276280
private enum CodingKeys: String, CodingKey {
277281
case modelName
278282
case costUSD = "cost"
283+
case totalTokens
279284
}
280285
}
281286

Sources/CodexBarCore/CostUsageModels.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ public struct CostUsageDailyReport: Sendable, Decodable {
2929
public struct ModelBreakdown: Sendable, Decodable, Equatable {
3030
public let modelName: String
3131
public let costUSD: Double?
32+
public let totalTokens: Int?
3233

3334
private enum CodingKeys: String, CodingKey {
3435
case modelName
3536
case costUSD
3637
case cost
38+
case totalTokens
3739
}
3840

3941
public init(from decoder: Decoder) throws {
@@ -42,11 +44,13 @@ public struct CostUsageDailyReport: Sendable, Decodable {
4244
self.costUSD =
4345
try container.decodeIfPresent(Double.self, forKey: .costUSD)
4446
?? container.decodeIfPresent(Double.self, forKey: .cost)
47+
self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens)
4548
}
4649

47-
public init(modelName: String, costUSD: Double?) {
50+
public init(modelName: String, costUSD: Double?, totalTokens: Int? = nil) {
4851
self.modelName = modelName
4952
self.costUSD = costUSD
53+
self.totalTokens = totalTokens
5054
}
5155
}
5256

Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,7 @@ extension CostUsageScanner {
513513
let cacheCreate = packed[safe: 2] ?? 0
514514
let output = packed[safe: 3] ?? 0
515515
let cachedCost = packed[safe: 4] ?? 0
516+
let modelTotal = input + cacheRead + cacheCreate + output
516517

517518
// Cache tokens are tracked separately; totalTokens includes input + cache.
518519
dayInput += input
@@ -528,15 +529,25 @@ extension CostUsageScanner {
528529
cacheReadInputTokens: cacheRead,
529530
cacheCreationInputTokens: cacheCreate,
530531
outputTokens: output)
531-
breakdown.append(CostUsageDailyReport.ModelBreakdown(modelName: model, costUSD: cost))
532+
breakdown.append(CostUsageDailyReport.ModelBreakdown(
533+
modelName: model,
534+
costUSD: cost,
535+
totalTokens: modelTotal))
532536
if let cost {
533537
dayCost += cost
534538
dayCostSeen = true
535539
}
536540
}
537541

538-
breakdown.sort { lhs, rhs in (rhs.costUSD ?? -1) < (lhs.costUSD ?? -1) }
539-
let top = Array(breakdown.prefix(3))
542+
breakdown.sort { lhs, rhs in
543+
let lCost = lhs.costUSD ?? -1
544+
let rCost = rhs.costUSD ?? -1
545+
if lCost != rCost { return lCost > rCost }
546+
let lTokens = lhs.totalTokens ?? -1
547+
let rTokens = rhs.totalTokens ?? -1
548+
if lTokens != rTokens { return lTokens > rTokens }
549+
return lhs.modelName < rhs.modelName
550+
}
540551

541552
let dayTotal = dayInput + dayCacheRead + dayCacheCreate + dayOutput
542553
let entryCost = dayCostSeen ? dayCost : nil
@@ -549,7 +560,7 @@ extension CostUsageScanner {
549560
totalTokens: dayTotal,
550561
costUSD: entryCost,
551562
modelsUsed: modelNames,
552-
modelBreakdowns: top))
563+
modelBreakdowns: breakdown))
553564

554565
totalInput += dayInput
555566
totalOutput += dayOutput

Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ enum CostUsageScanner {
422422
let input = packed[safe: 0] ?? 0
423423
let cached = packed[safe: 1] ?? 0
424424
let output = packed[safe: 2] ?? 0
425+
let modelTotal = input + output
425426

426427
dayInput += input
427428
dayOutput += output
@@ -431,15 +432,25 @@ enum CostUsageScanner {
431432
inputTokens: input,
432433
cachedInputTokens: cached,
433434
outputTokens: output)
434-
breakdown.append(CostUsageDailyReport.ModelBreakdown(modelName: model, costUSD: cost))
435+
breakdown.append(CostUsageDailyReport.ModelBreakdown(
436+
modelName: model,
437+
costUSD: cost,
438+
totalTokens: modelTotal))
435439
if let cost {
436440
dayCost += cost
437441
dayCostSeen = true
438442
}
439443
}
440444

441-
breakdown.sort { lhs, rhs in (rhs.costUSD ?? -1) < (lhs.costUSD ?? -1) }
442-
let top = Array(breakdown.prefix(3))
445+
breakdown.sort { lhs, rhs in
446+
let lCost = lhs.costUSD ?? -1
447+
let rCost = rhs.costUSD ?? -1
448+
if lCost != rCost { return lCost > rCost }
449+
let lTokens = lhs.totalTokens ?? -1
450+
let rTokens = rhs.totalTokens ?? -1
451+
if lTokens != rTokens { return lTokens > rTokens }
452+
return lhs.modelName < rhs.modelName
453+
}
443454

444455
let dayTotal = dayInput + dayOutput
445456
let entryCost = dayCostSeen ? dayCost : nil
@@ -450,7 +461,7 @@ enum CostUsageScanner {
450461
totalTokens: dayTotal,
451462
costUSD: entryCost,
452463
modelsUsed: modelNames,
453-
modelBreakdowns: top))
464+
modelBreakdowns: breakdown))
454465

455466
totalInput += dayInput
456467
totalOutput += dayOutput

0 commit comments

Comments
 (0)