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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.28.1 — Unreleased

### Added
- Cost history: show Codex standard and fast spend/token splits in model breakdowns (#1070). Thanks @iam-brain!
- Alibaba Token Plan: add Bailian token-plan quota tracking via browser or manual cookies (#1098). Thanks @YanxinXue!
- OpenCode: show workspace renewal dates for OpenCode and OpenCode Go usage windows (#1099). Thanks @Yuxin-Qiao!

Expand Down
136 changes: 108 additions & 28 deletions Sources/CodexBar/CostHistoryChartMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct CostHistoryChartMenuView: View {
let id: String
let title: String
let subtitle: String?
let modeSubtitle: String?
let accentColor: Color
}

Expand Down Expand Up @@ -123,7 +124,9 @@ struct CostHistoryChartMenuView: View {
HStack(alignment: .top, spacing: 8) {
Rectangle()
.fill(row.accentColor)
.frame(width: 2, height: row.subtitle == nil ? 14 : Self.detailRowHeight)
.frame(
width: 2,
height: Self.accentHeight(for: row))
.padding(.top, 1)

VStack(alignment: .leading, spacing: 1) {
Expand All @@ -132,26 +135,38 @@ struct CostHistoryChartMenuView: View {
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(height: Self.detailTitleLineHeight, alignment: .leading)
if let subtitle = row.subtitle {
Text(subtitle)
.font(.caption2)
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
.lineLimit(1)
.truncationMode(.tail)
.frame(height: Self.detailSubtitleLineHeight, alignment: .leading)
}
if let modeSubtitle = row.modeSubtitle {
Text(modeSubtitle)
.font(.caption2)
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
.lineLimit(1)
.truncationMode(.tail)
.frame(height: Self.detailSubtitleLineHeight, alignment: .leading)
}
}
}
.frame(height: Self.detailRowHeight, alignment: .leading)
.frame(height: Self.detailRowHeight(for: row), alignment: .leading)
}
ForEach(0..<max(model.maxRenderedBreakdownRows - detail.rows.count, 0), id: \.self) { _ in
Text(" ")
.font(.caption)
.frame(height: Self.detailRowHeight, alignment: .leading)
.frame(height: Self.compactDetailRowHeight, alignment: .leading)
.opacity(0)
Comment thread
iam-brain marked this conversation as resolved.
}
}
.frame(
height: Self.detailBlockHeight(maxBreakdownRows: model.maxRenderedBreakdownRows),
height: Self.detailBlockHeight(
maxBreakdownRows: model.maxRenderedBreakdownRows,
maxRowsHeight: model.maxDetailRowsHeight),
alignment: .topLeading)
}

Expand All @@ -178,18 +193,34 @@ struct CostHistoryChartMenuView: View {
let peakKey: String?
let maxCostUSD: Double
let maxRenderedBreakdownRows: Int
let maxDetailRowsHeight: CGFloat
}

private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1)
private static let maxVisibleDetailLines = 4
private static let detailPrimaryLineHeight: CGFloat = 16
private static let detailRowHeight: CGFloat = 24
private static let detailTitleLineHeight: CGFloat = 16
private static let detailSubtitleLineHeight: CGFloat = 13
private static let compactDetailRowHeight: CGFloat = 36
private static let expandedDetailRowHeight: CGFloat = 44
private static let detailSpacing: CGFloat = 6

private static func windowLabel(days: Int) -> String {
days == 1 ? "today" : "\(days)d"
}

private static func detailRowHeight(for row: DetailRow) -> CGFloat {
self.detailRowHeight(hasModeSubtitle: row.modeSubtitle != nil)
}

private static func detailRowHeight(hasModeSubtitle: Bool) -> CGFloat {
hasModeSubtitle ? self.expandedDetailRowHeight : self.compactDetailRowHeight
}

private static func accentHeight(for row: DetailRow) -> CGFloat {
row.subtitle == nil && row.modeSubtitle == nil ? 14 : self.detailRowHeight(for: row)
}

private static func capHeight(maxValue: Double) -> Double {
maxValue * 0.05
}
Expand All @@ -211,6 +242,7 @@ struct CostHistoryChartMenuView: View {
var peak: (key: String, costUSD: Double)?
var maxCostUSD: Double = 0
var maxRenderedBreakdownRows = 0
var detailRowMetrics: [(count: Int, height: CGFloat)] = []
for entry in sorted {
guard let costUSD = entry.costUSD, costUSD >= 0 else { continue }
guard let date = self.dateFromDayKey(entry.date) else { continue }
Expand All @@ -219,7 +251,9 @@ struct CostHistoryChartMenuView: View {
pointsByKey[entry.date] = point
entriesByKey[entry.date] = entry
dateKeys.append((entry.date, date))
maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, Self.renderedBreakdownRowCount(for: entry))
let rowMetric = Self.renderedBreakdownRowsMetric(for: entry)
detailRowMetrics.append(rowMetric)
maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, rowMetric.count)
if let cur = peak {
if costUSD > cur.costUSD { peak = (entry.date, costUSD) }
} else {
Expand All @@ -235,6 +269,11 @@ struct CostHistoryChartMenuView: View {
}()

let barColor = Self.barColor(for: provider)
let maxDetailRowsHeight = detailRowMetrics.reduce(CGFloat(0)) { currentMax, metric in
let fillerRows = max(maxRenderedBreakdownRows - metric.count, 0)
let filledHeight = metric.height + (CGFloat(fillerRows) * Self.compactDetailRowHeight)
return max(currentMax, filledHeight)
}
return Model(
points: points,
pointsByDateKey: pointsByKey,
Expand All @@ -244,7 +283,8 @@ struct CostHistoryChartMenuView: View {
barColor: barColor,
peakKey: maxCostUSD > 0 ? peak?.key : nil,
maxCostUSD: maxCostUSD,
maxRenderedBreakdownRows: maxRenderedBreakdownRows)
maxRenderedBreakdownRows: maxRenderedBreakdownRows,
maxDetailRowsHeight: maxDetailRowsHeight)
}

private static func barColor(for provider: UsageProvider) -> Color {
Expand Down Expand Up @@ -274,15 +314,25 @@ 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 }
return min(breakdown.count, self.maxVisibleDetailLines)
private static func renderedBreakdownRowsMetric(for entry: DailyEntry) -> (count: Int, height: CGFloat) {
guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return (0, 0) }
let renderedRows = Array(
self.sortedBreakdown(breakdown)
.prefix(self.maxVisibleDetailLines))
let height = renderedRows.reduce(CGFloat(0)) { total, item in
total + self.detailRowHeight(hasModeSubtitle: Self.hasModeSubtitle(item))
}
return (renderedRows.count, height)
}

private static func hasModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> Bool {
item.standardCostUSD != nil || item.priorityCostUSD != nil
}

private static func detailBlockHeight(maxBreakdownRows: Int) -> CGFloat {
private static func detailBlockHeight(maxBreakdownRows: Int, maxRowsHeight: CGFloat) -> CGFloat {
guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight }
return self.detailPrimaryLineHeight +
(CGFloat(maxBreakdownRows) * self.detailRowHeight) +
maxRowsHeight +
(CGFloat(maxBreakdownRows) * self.detailSpacing)
}

Expand Down Expand Up @@ -383,32 +433,62 @@ struct CostHistoryChartMenuView: View {
guard let entry = model.entriesByDateKey[key] else { return [] }
guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] }

return breakdown
.sorted { 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
}
return Self.sortedBreakdown(breakdown)
.prefix(Self.maxVisibleDetailLines)
.enumerated()
.map { index, item in
DetailRow(
id: "\(item.modelName)-\(index)",
title: UsageFormatter.modelDisplayName(item.modelName),
subtitle: UsageFormatter.modelCostDetail(
item.modelName,
costUSD: item.costUSD,
totalTokens: item.totalTokens),
subtitle: self.modelBreakdownTotalSubtitle(item),
modeSubtitle: self.modelBreakdownModeSubtitle(item),
accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index)))
}
}

private static func sortedBreakdown(
_ breakdown: [CostUsageDailyReport.ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown]
{
breakdown.sorted { 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
}
}

private func modelBreakdownTotalSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? {
UsageFormatter.modelCostDetail(
item.modelName,
costUSD: item.costUSD,
totalTokens: item.totalTokens)
}

private func modelBreakdownModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? {
var parts: [String] = []
if let standardCost = item.standardCostUSD {
var standardPart = "Std \(UsageFormatter.usdString(standardCost))"
if let standardTokens = item.standardTokens {
standardPart += " · \(UsageFormatter.tokenCountString(standardTokens))"
}
parts.append(standardPart)
}
if let priorityCost = item.priorityCostUSD {
var priorityPart = "Fast \(UsageFormatter.usdString(priorityCost))"
if let priorityTokens = item.priorityTokens {
priorityPart += " · \(UsageFormatter.tokenCountString(priorityTokens))"
}
parts.append(priorityPart)
}
guard !parts.isEmpty else { return nil }
return parts.joined(separator: " / ")
}

private static func breakdownAccentOpacity(for index: Int) -> Double {
let opacity = 0.75 - (Double(index) * 0.12)
return max(0.3, opacity)
Expand Down
110 changes: 55 additions & 55 deletions Sources/CodexBar/InlineUsageDashboardContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -160,45 +160,6 @@ extension UsageMenuCardView.Model {
detailLines: details)
}

fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot)
-> InlineUsageDashboardModel
{
let today = usage.latestDay
let last7 = usage.last7Days
let last30 = usage.last30Days
let points = usage.daily.suffix(30).map {
InlineUsageDashboardModel.Point(
id: $0.day,
label: Self.shortDayLabel($0.day),
value: $0.costUSD,
accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))")
}
var details = [
"30d: \(UsageFormatter.tokenCountString(last30.totalTokens)) tokens",
"Cache read: \(UsageFormatter.tokenCountString(last30.cacheReadInputTokens)) tokens",
]
if let topModel = usage.topModels.first {
details.append("Top model: \(Self.shortModelName(topModel.name))")
}
return InlineUsageDashboardModel(
accessibilityLabel: "Claude Admin API 30 day spend trend",
valueStyle: .currencyUSD,
kpis: [
.init(title: "Today", value: UsageFormatter.usdString(today.costUSD), emphasis: true),
.init(title: "7d spend", value: UsageFormatter.usdString(last7.costUSD), emphasis: false),
.init(
title: "30d spend",
value: UsageFormatter.usdString(last30.costUSD),
emphasis: false),
.init(
title: "Today tokens",
value: UsageFormatter.tokenCountString(today.totalTokens),
emphasis: false),
],
points: points,
detailLines: details)
}

private static func costHistoryInlineDashboard(
provider: UsageProvider,
snapshot: CostUsageTokenSnapshot) -> InlineUsageDashboardModel
Expand Down Expand Up @@ -250,6 +211,45 @@ extension UsageMenuCardView.Model {
detailLines: details)
}

fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot)
-> InlineUsageDashboardModel
{
let today = usage.latestDay
let last7 = usage.last7Days
let last30 = usage.last30Days
let points = usage.daily.suffix(30).map {
InlineUsageDashboardModel.Point(
id: $0.day,
label: Self.shortDayLabel($0.day),
value: $0.costUSD,
accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))")
}
var details = [
"30d: \(UsageFormatter.tokenCountString(last30.totalTokens)) tokens",
"Cache read: \(UsageFormatter.tokenCountString(last30.cacheReadInputTokens)) tokens",
]
if let topModel = usage.topModels.first {
details.append("Top model: \(Self.shortModelName(topModel.name))")
}
return InlineUsageDashboardModel(
accessibilityLabel: "Claude Admin API 30 day spend trend",
valueStyle: .currencyUSD,
kpis: [
.init(title: "Today", value: UsageFormatter.usdString(today.costUSD), emphasis: true),
.init(title: "7d spend", value: UsageFormatter.usdString(last7.costUSD), emphasis: false),
.init(
title: "30d spend",
value: UsageFormatter.usdString(last30.costUSD),
emphasis: false),
.init(
title: "Today tokens",
value: UsageFormatter.tokenCountString(today.totalTokens),
emphasis: false),
],
points: points,
detailLines: details)
}

private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? {
let periodValues: [(String, String, Double?)] = [
("day", "Today", usage.keyUsageDaily),
Expand Down Expand Up @@ -413,22 +413,6 @@ extension UsageMenuCardView.Model {
detailLines: details)
}

private static func topCostModel(from entries: [CostUsageDailyReport.Entry]) -> String? {
var scores: [String: (cost: Double, tokens: Int)] = [:]
for entry in entries {
for model in entry.modelBreakdowns ?? [] {
var score = scores[model.modelName] ?? (0, 0)
score.cost += model.costUSD ?? 0
score.tokens += model.totalTokens ?? 0
scores[model.modelName] = score
}
}
return scores.max {
if $0.value.cost == $1.value.cost { return $0.value.tokens < $1.value.tokens }
return $0.value.cost < $1.value.cost
}?.key
}

private static func topMistralModel(from entries: [MistralDailyUsageBucket]) -> String? {
var tokens: [String: Int] = [:]
for entry in entries {
Expand Down Expand Up @@ -478,6 +462,22 @@ extension UsageMenuCardView.Model {
guard trimmed.count > 26 else { return trimmed }
return String(trimmed.prefix(25)) + "…"
}

private static func topCostModel(from entries: [CostUsageDailyReport.Entry]) -> String? {
var scores: [String: (cost: Double, tokens: Int)] = [:]
for entry in entries {
for model in entry.modelBreakdowns ?? [] {
var score = scores[model.modelName] ?? (0, 0)
score.cost += model.costUSD ?? 0
score.tokens += model.totalTokens ?? 0
scores[model.modelName] = score
}
}
return scores.max {
if $0.value.cost == $1.value.cost { return $0.value.tokens < $1.value.tokens }
return $0.value.cost < $1.value.cost
}?.key
}
}

struct InlineUsageDashboardContent: View {
Expand Down
Loading