From 05481806da294d56266f741aa2e1d0fc28378460 Mon Sep 17 00:00:00 2001 From: Clemens Peters Date: Wed, 1 Jul 2026 14:46:53 +0200 Subject: [PATCH] Add Codex project cost rollups --- .../CodexBar/CostHistoryChartMenuView.swift | 212 +++++++++++++++++- .../StatusItemController+HostedSubmenus.swift | 2 + ...ItemController+MenuRefreshScheduling.swift | 11 + Sources/CodexBarCLI/CLICostCommand.swift | 191 ++++++++++++++-- Sources/CodexBarCLI/CLIHelp.swift | 4 +- Sources/CodexBarCore/CostUsageFetcher.swift | 190 +++++++++++++++- Sources/CodexBarCore/CostUsageModels.swift | 64 ++++++ .../Vendored/CostUsage/CostUsageCache.swift | 4 +- .../CostUsageScanner+CacheHelpers.swift | 184 +++++++++++++++ .../Vendored/CostUsage/CostUsageScanner.swift | 121 +++++++++- Tests/CodexBarTests/CLICostTests.swift | 144 ++++++++++++ .../CostHistoryChartMenuViewTests.swift | 12 + Tests/CodexBarTests/CostUsageCacheTests.swift | 2 +- .../CostUsageScannerBreakdownTests.swift | 179 ++++++++++++++- docs/cli.md | 2 + 15 files changed, 1288 insertions(+), 34 deletions(-) diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 17fc33d3db..8d020292c9 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -47,6 +47,7 @@ struct CostHistoryChartMenuView: View { private let currencyCode: String private let historyDays: Int private let windowLabel: String? + private let projects: [CostUsageProjectBreakdown] private let width: CGFloat private let onHeightChange: ((CGFloat) -> Void)? @State private var selectedDateKey: String? @@ -58,6 +59,7 @@ struct CostHistoryChartMenuView: View { currencyCode: String = "USD", historyDays: Int = 30, windowLabel: String? = nil, + projects: [CostUsageProjectBreakdown] = [], onHeightChange: ((CGFloat) -> Void)? = nil, width: CGFloat) { @@ -67,6 +69,7 @@ struct CostHistoryChartMenuView: View { self.currencyCode = currencyCode self.historyDays = max(1, min(365, historyDays)) self.windowLabel = windowLabel + self.projects = projects self.onHeightChange = onHeightChange self.width = width } @@ -224,6 +227,38 @@ struct CostHistoryChartMenuView: View { .truncationMode(.head) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) } + + if !self.projects.isEmpty { + VStack(alignment: .leading, spacing: Self.projectRowSpacing) { + Text("Projects") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + ForEach(Array(self.projects.prefix(Self.maxVisibleProjectRows)), id: \.projectRowID) { project in + VStack(alignment: .leading, spacing: Self.projectSourceSpacing) { + self.projectParentRow(project) + if project.sources.count > 1 { + ForEach(Array(project.sources.prefix(Self.maxVisibleProjectSourceRows)), id: \.sourceRowID) { + source in + self.projectSourceRow(source) + } + let hiddenSourceCount = project.sources.count - Self.maxVisibleProjectSourceRows + if hiddenSourceCount > 0 { + Text("+ \(hiddenSourceCount) more") + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .padding(.leading, Self.projectSourceIndent) + .frame(height: Self.projectMoreRowHeight, alignment: .leading) + } + } + } + .frame(height: Self.projectEntryHeight(project), alignment: .topLeading) + } + } + .frame(height: Self.projectBlockHeight(projects: self.projects), alignment: .topLeading) + } } .padding(.horizontal, 16) .padding(.vertical, Self.verticalPadding) @@ -252,13 +287,25 @@ struct CostHistoryChartMenuView: View { private static let chartHeight: CGFloat = 114 private static let axisLabelAreaHeight: CGFloat = 16 private static let outerSpacing: CGFloat = 10 + private static let projectRowHeight: CGFloat = 31 + private static let projectRowSpacing: CGFloat = 5 + private static let maxVisibleProjectRows = 5 + private static let projectSourceRowHeight: CGFloat = 29 + private static let projectSourceSpacing: CGFloat = 3 + private static let projectSourceIndent: CGFloat = 10 + private static let projectMoreRowHeight: CGFloat = 16 + private static let maxVisibleProjectSourceRows = 2 static let verticalPadding: CGFloat = 10 /// Deterministic total height of the rendered card for a given selection. NSMenu's modal /// tracking run loop never delivers SwiftUI `onPreferenceChange`, so the live height can't be /// measured via a GeometryReader while the menu is open. Every component height is fixed, so /// we compute the total directly and resize from the hover handler instead. - private static func totalCardHeight(rows: [DetailRow], hasTotal: Bool) -> CGFloat { + private static func totalCardHeight( + rows: [DetailRow], + hasTotal: Bool, + projects: [CostUsageProjectBreakdown] = []) -> CGFloat + { var height = self.verticalPadding * 2 height += self.chartHeight height += self.axisLabelAreaHeight @@ -268,9 +315,26 @@ struct CostHistoryChartMenuView: View { height += self.outerSpacing height += self.detailPrimaryLineHeight } + if !projects.isEmpty { + height += self.outerSpacing + height += self.projectBlockHeight(projects: projects) + } return height } + private static func totalCardHeight(rows: [DetailRow], hasTotal: Bool, projectCount: Int) -> CGFloat { + let projects = (0.. String { if days == 1 { return L("Today") @@ -403,6 +467,24 @@ struct CostHistoryChartMenuView: View { return rowHeights + spacing } + private static func projectBlockHeight(projects: [CostUsageProjectBreakdown]) -> CGFloat { + let visibleProjects = Array(projects.prefix(self.maxVisibleProjectRows)) + guard !visibleProjects.isEmpty else { return 0 } + return self.detailPrimaryLineHeight + + self.projectRowSpacing + + visibleProjects.reduce(CGFloat(0)) { $0 + self.projectEntryHeight($1) } + + CGFloat(max(visibleProjects.count - 1, 0)) * self.projectRowSpacing + } + + private static func projectEntryHeight(_ project: CostUsageProjectBreakdown) -> CGFloat { + guard project.sources.count > 1 else { return self.projectRowHeight } + let visibleSources = min(project.sources.count, self.maxVisibleProjectSourceRows) + let moreRows = project.sources.count > self.maxVisibleProjectSourceRows ? 1 : 0 + return self.projectRowHeight + + CGFloat(visibleSources) * (self.projectSourceRowHeight + self.projectSourceSpacing) + + CGFloat(moreRows) * (self.projectMoreRowHeight + self.projectSourceSpacing) + } + private static func defaultSelectedDateKey(model: Model) -> String? { model.dateKeys.last?.key } @@ -469,7 +551,79 @@ struct CostHistoryChartMenuView: View { private func notifyHeightChange(selectedDateKey: String?, model: Model) { guard let onHeightChange = self.onHeightChange else { return } let rows = selectedDateKey.map { self.breakdownRows(key: $0, model: model) } ?? [] - onHeightChange(Self.totalCardHeight(rows: rows, hasTotal: self.totalCostUSD != nil)) + onHeightChange(Self.totalCardHeight( + rows: rows, + hasTotal: self.totalCostUSD != nil, + projects: self.projects)) + } + + private func projectSummary(_ project: CostUsageProjectBreakdown) -> String { + let cost = project.totalCostUSD + .map { self.costString($0) } ?? "—" + guard let totalTokens = project.totalTokens else { return cost } + return "\(cost) · \(L("%@ tokens", UsageFormatter.tokenCountString(totalTokens)))" + } + + @ViewBuilder + private func projectParentRow(_ project: CostUsageProjectBreakdown) -> some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 8) { + Text(project.name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 8) + Text(self.projectSummary(project)) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.head) + } + if let path = project.path { + Text(path) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.middle) + } + } + .frame(height: Self.projectRowHeight, alignment: .leading) + } + + @ViewBuilder + private func projectSourceRow(_ source: CostUsageProjectSourceBreakdown) -> some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 6) { + Text(source.name) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 6) + Text(self.projectSourceSummary(source)) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.head) + } + if let path = source.path { + Text(path) + .font(.caption2) + .foregroundStyle(Color(nsColor: .quaternaryLabelColor)) + .lineLimit(1) + .truncationMode(.middle) + } + } + .padding(.leading, Self.projectSourceIndent) + .frame(height: Self.projectSourceRowHeight, alignment: .leading) + } + + private func projectSourceSummary(_ source: CostUsageProjectSourceBreakdown) -> String { + let cost = source.totalCostUSD + .map { self.costString($0) } ?? "—" + guard let totalTokens = source.totalTokens else { return cost } + return "\(cost) · \(L("%@ tokens", UsageFormatter.tokenCountString(totalTokens)))" } private func nearestDateKey(to date: Date, model: Model) -> String? { @@ -625,7 +779,11 @@ extension CostHistoryChartMenuView { return self.detailBlockHeight(rows: rows) } - static func _totalCardHeightForTesting(modeSubtitlePresence: [Bool], hasTotal: Bool) -> CGFloat { + static func _totalCardHeightForTesting( + modeSubtitlePresence: [Bool], + hasTotal: Bool, + projectCount: Int = 0) -> CGFloat + { let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in DetailRow( id: "\(index)", @@ -634,6 +792,52 @@ extension CostHistoryChartMenuView { modeSubtitle: hasModeSubtitle ? "Mode" : nil, accentColor: .blue) } - return self.totalCardHeight(rows: rows, hasTotal: hasTotal) + return self.totalCardHeight(rows: rows, hasTotal: hasTotal, projectCount: projectCount) + } + + static func _totalCardHeightForTesting( + modeSubtitlePresence: [Bool], + hasTotal: Bool, + projectSourceCounts: [Int]) -> CGFloat + { + let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in + DetailRow( + id: "\(index)", + title: "Model \(index)", + subtitle: "Cost", + modeSubtitle: hasModeSubtitle ? "Mode" : nil, + accentColor: .blue) + } + let projects = projectSourceCounts.enumerated().map { index, sourceCount in + CostUsageProjectBreakdown( + name: "Project \(index)", + path: "/tmp/project-\(index)", + totalTokens: nil, + totalCostUSD: nil, + daily: [], + modelBreakdowns: nil, + sources: (0.. String { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName let header = Self.costHeaderLine("\(name) Cost (API-rate estimate)", useColor: useColor) + if groupBy == .project, provider == .codex { + return Self.renderProjectCostText(header: header, snapshot: snapshot) + } let todayCost = snapshot.sessionCostUSD .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" @@ -102,6 +126,39 @@ extension CodexBarCLI { return [header, todayLine, monthLine, hintLine].joined(separator: "\n") } + private static func renderProjectCostText(header: String, snapshot: CostUsageTokenSnapshot) -> String { + let historyLabel = snapshot.historyLabel + ?? (snapshot.historyDays == 1 ? "Today" : "Last \(snapshot.historyDays) days") + var lines = [header, "Projects (\(historyLabel)):"] + guard !snapshot.projects.isEmpty else { + lines.append("—") + lines.append(UsageFormatter.costEstimateHint(provider: .codex)) + return lines.joined(separator: "\n") + } + for project in snapshot.projects { + let cost = project.totalCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let tokens = project.totalTokens.map { UsageFormatter.tokenCountString($0) } + let summary = tokens.map { "\(cost) · \($0) tokens" } ?? cost + lines.append("\(project.name): \(summary)") + if let path = project.path { + lines.append(" \(path)") + } + for source in project.sources { + let sourceCost = source.totalCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let sourceTokens = source.totalTokens.map { UsageFormatter.tokenCountString($0) } + let sourceSummary = sourceTokens.map { "\(sourceCost) · \($0) tokens" } ?? sourceCost + lines.append(" - \(source.name): \(sourceSummary)") + if let path = source.path { + lines.append(" \(path)") + } + } + } + lines.append(UsageFormatter.costEstimateHint(provider: .codex)) + return lines.joined(separator: "\n") + } + private static func costHeaderLine(_ header: String, useColor: Bool) -> String { guard useColor else { return header } return "\u{001B}[1;36m\(header)\u{001B}[0m" @@ -116,23 +173,27 @@ extension CodexBarCLI { snapshot: CostUsageTokenSnapshot?, error: Error?) -> CostPayload { - let daily = snapshot?.daily.map { entry in - CostDailyEntryPayload( - date: entry.date, - inputTokens: entry.inputTokens, - outputTokens: entry.outputTokens, - cacheReadTokens: entry.cacheReadTokens, - cacheCreationTokens: entry.cacheCreationTokens, - totalTokens: entry.totalTokens, - costUSD: entry.costUSD, - modelsUsed: entry.modelsUsed, - modelBreakdowns: entry.modelBreakdowns?.map { breakdown in - CostModelBreakdownPayload( - modelName: breakdown.modelName, - costUSD: breakdown.costUSD, - totalTokens: breakdown.totalTokens) - }) - } ?? [] + let daily = snapshot?.daily.map(Self.costDailyPayload(from:)) ?? [] + let projects = provider == .codex + ? snapshot?.projects.map { project in + CostProjectPayload( + name: project.name, + path: project.path, + totalTokens: project.totalTokens, + totalCostUSD: project.totalCostUSD, + daily: project.daily.map(Self.costDailyPayload(from:)), + modelBreakdowns: project.modelBreakdowns?.map(Self.costModelBreakdownPayload(from:)), + sources: project.sources.map { source in + CostProjectSourcePayload( + name: source.name, + path: source.path, + totalTokens: source.totalTokens, + totalCostUSD: source.totalCostUSD, + daily: source.daily.map(Self.costDailyPayload(from:)), + modelBreakdowns: source.modelBreakdowns?.map(Self.costModelBreakdownPayload(from:))) + }) + } ?? [] + : [] return CostPayload( provider: provider.rawValue, @@ -145,10 +206,33 @@ extension CodexBarCLI { last30DaysTokens: snapshot?.last30DaysTokens, last30DaysCostUSD: snapshot?.last30DaysCostUSD, daily: daily, + projects: projects, totals: snapshot.flatMap(Self.costTotals(from:)), error: error.map { Self.makeErrorPayload($0) }) } + private static func costDailyPayload(from entry: CostUsageDailyReport.Entry) -> CostDailyEntryPayload { + CostDailyEntryPayload( + date: entry.date, + inputTokens: entry.inputTokens, + outputTokens: entry.outputTokens, + cacheReadTokens: entry.cacheReadTokens, + cacheCreationTokens: entry.cacheCreationTokens, + totalTokens: entry.totalTokens, + costUSD: entry.costUSD, + modelsUsed: entry.modelsUsed, + modelBreakdowns: entry.modelBreakdowns?.map(Self.costModelBreakdownPayload(from:))) + } + + private static func costModelBreakdownPayload( + from breakdown: CostUsageDailyReport.ModelBreakdown) -> CostModelBreakdownPayload + { + CostModelBreakdownPayload( + modelName: breakdown.modelName, + costUSD: breakdown.costUSD, + totalTokens: breakdown.totalTokens) + } + private static func costTotals(from snapshot: CostUsageTokenSnapshot) -> CostTotalsPayload? { let entries = snapshot.daily guard !entries.isEmpty else { @@ -218,6 +302,13 @@ extension CodexBarCLI { else { return 30 } return max(1, min(365, parsed)) } + + private static func decodeCostGroupBy(from values: ParsedValues) -> CostGroupBy { + guard let raw = values.options["groupBy"]?.last?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { return .none } + return CostGroupBy(rawValue: raw.lowercased()) ?? .none + } } struct CostOptions: CommanderParsable { @@ -255,6 +346,9 @@ struct CostOptions: CommanderParsable { @Option(name: .long("days"), help: "Cost history window in days (1...365)") var days: Int? + + @Option(name: .long("group-by"), help: "Group text output by: project") + var groupBy: String? } struct CostPayload: Encodable { @@ -268,6 +362,7 @@ struct CostPayload: Encodable { let last30DaysTokens: Int? let last30DaysCostUSD: Double? let daily: [CostDailyEntryPayload] + let projects: [CostProjectPayload] let totals: CostTotalsPayload? let error: ProviderErrorPayload? @@ -282,6 +377,7 @@ struct CostPayload: Encodable { last30DaysTokens: Int?, last30DaysCostUSD: Double?, daily: [CostDailyEntryPayload], + projects: [CostProjectPayload] = [], totals: CostTotalsPayload?, error: ProviderErrorPayload?) { @@ -295,6 +391,7 @@ struct CostPayload: Encodable { self.last30DaysTokens = last30DaysTokens self.last30DaysCostUSD = last30DaysCostUSD self.daily = daily + self.projects = projects self.totals = totals self.error = error } @@ -336,6 +433,62 @@ struct CostModelBreakdownPayload: Encodable { } } +struct CostProjectPayload: Encodable { + let name: String + let path: String? + let totalTokens: Int? + let totalCostUSD: Double? + let daily: [CostDailyEntryPayload] + let modelBreakdowns: [CostModelBreakdownPayload]? + let sources: [CostProjectSourcePayload] + + private enum CodingKeys: String, CodingKey { + case name + case path + case totalTokens + case totalCostUSD = "totalCost" + case daily + case modelBreakdowns + case sources + } + + init( + name: String, + path: String?, + totalTokens: Int?, + totalCostUSD: Double?, + daily: [CostDailyEntryPayload], + modelBreakdowns: [CostModelBreakdownPayload]?, + sources: [CostProjectSourcePayload] = []) + { + self.name = name + self.path = path + self.totalTokens = totalTokens + self.totalCostUSD = totalCostUSD + self.daily = daily + self.modelBreakdowns = modelBreakdowns + self.sources = sources + } +} + +struct CostProjectSourcePayload: Encodable { + let name: String + let path: String? + let totalTokens: Int? + let totalCostUSD: Double? + let daily: [CostDailyEntryPayload] + let modelBreakdowns: [CostModelBreakdownPayload]? + + private enum CodingKeys: String, CodingKey { + case name + case path + case totalTokens + case totalCostUSD = "totalCost" + case daily + case modelBreakdowns + } +} + struct CostTotalsPayload: Encodable { let totalInputTokens: Int? let totalOutputTokens: Int? diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 072677339b..ac8e503abf 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -60,7 +60,7 @@ extension CodexBarCLI { [--json-only] [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] - [--no-color] [--pretty] [--refresh] + [--no-color] [--pretty] [--refresh] [--days ] [--group-by project] Description: Print local token cost usage from Claude/Codex native logs plus supported pi sessions. @@ -68,6 +68,7 @@ extension CodexBarCLI { Examples: codexbar cost + codexbar cost --provider codex --group-by project codexbar cost --provider claude --format json --pretty """ } @@ -217,6 +218,7 @@ extension CodexBarCLI { [--json-only] [--json-output] [--log-level ] [-v|--verbose] [--provider \(ProviderHelp.list)] [--no-color] [--pretty] [--refresh] + [--days ] [--group-by project] codexbar serve [--port ] [--refresh-interval ] [--request-timeout ] [--json-output] [--log-level ] [-v|--verbose] diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index c4d59ca60e..3424f2035e 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -165,7 +165,7 @@ public struct CostUsageFetcher: Sendable { // pool thread; CostUsageScanExecutor bridges this task's cancellation into the // scanner-level checks. let scanOptions = options - let daily = try await CostUsageScanExecutor.run { checkCancellation in + let scanResult = try await CostUsageScanExecutor.run { checkCancellation in var daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, @@ -192,6 +192,15 @@ public struct CostUsageFetcher: Sendable { try checkCancellation() } + var projects: [CostUsageProjectBreakdown] = [] + var piDaily: CostUsageDailyReport? + if provider == .codex { + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: scanOptions.cacheRoot) + projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: since, until: until), + modelsDevCacheRoot: scanOptions.cacheRoot) + } if provider == .codex || provider == .claude { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, @@ -201,12 +210,23 @@ public struct CostUsageFetcher: Sendable { options: piOptions, checkCancellation: checkCancellation) try checkCancellation() + if provider == .codex { + piDaily = piReport + } daily = CostUsageDailyReport.merged([daily, piReport]) } - return daily + if provider == .codex { + projects = Self.mergedProjectBreakdowns( + projects + [piDaily.flatMap(Self.unknownProjectBreakdown(from:))].compactMap(\.self)) + } + return (daily: daily, projects: projects) } - return Self.tokenSnapshot(from: daily, now: now, historyDays: clampedHistoryDays) + return Self.tokenSnapshot( + from: scanResult.daily, + now: now, + historyDays: clampedHistoryDays, + projects: scanResult.projects) } static func loadCachedCodexTokenSnapshot( @@ -231,6 +251,7 @@ public struct CostUsageFetcher: Sendable { let options = overrideScannerOptions ?? CostUsageScanner.Options() let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) var reports: [CostUsageDailyReport] = [] + var projects: [CostUsageProjectBreakdown] = [] if !cache.days.isEmpty, cache.roots == CostUsageScanner.codexRootsFingerprint(options: options), @@ -242,6 +263,10 @@ public struct CostUsageFetcher: Sendable { modelsDevCacheRoot: options.cacheRoot) if !daily.data.isEmpty { reports.append(daily) + projects.append(contentsOf: CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: options.cacheRoot)) } } @@ -253,13 +278,17 @@ public struct CostUsageFetcher: Sendable { cacheRoot: options.cacheRoot) { reports.append(piDaily) + if let piProject = Self.unknownProjectBreakdown(from: piDaily) { + projects.append(piProject) + } } guard !reports.isEmpty else { return nil } return Self.tokenSnapshot( from: CostUsageDailyReport.merged(reports), now: now, - historyDays: clampedHistoryDays) + historyDays: clampedHistoryDays, + projects: Self.mergedProjectBreakdowns(projects)) } return cachedSnapshot.flatMap(\.self) } @@ -281,7 +310,8 @@ public struct CostUsageFetcher: Sendable { from daily: CostUsageDailyReport, now: Date, historyDays: Int = 30, - useCurrentLocalDayForSession: Bool = true) -> CostUsageTokenSnapshot + useCurrentLocalDayForSession: Bool = true, + projects: [CostUsageProjectBreakdown] = []) -> CostUsageTokenSnapshot { let sessionEntry = useCurrentLocalDayForSession ? CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now) @@ -316,9 +346,159 @@ public struct CostUsageFetcher: Sendable { last30DaysCostUSD: last30DaysCostUSD, historyDays: historyDays, daily: daily.data, + projects: projects, updatedAt: now) } + private static func unknownProjectBreakdown(from daily: CostUsageDailyReport) -> CostUsageProjectBreakdown? { + guard !daily.data.isEmpty else { return nil } + return CostUsageProjectBreakdown( + name: CostUsageProjectBreakdown.unknownProjectName, + path: nil, + totalTokens: daily.summary?.totalTokens, + totalCostUSD: daily.summary?.totalCostUSD, + daily: daily.data, + modelBreakdowns: Self.projectModelBreakdowns(from: daily.data), + sources: [ + CostUsageProjectSourceBreakdown( + name: CostUsageProjectBreakdown.unknownProjectName, + path: nil, + totalTokens: daily.summary?.totalTokens, + totalCostUSD: daily.summary?.totalCostUSD, + daily: daily.data, + modelBreakdowns: Self.projectModelBreakdowns(from: daily.data)), + ]) + } + + private static func mergedProjectBreakdowns( + _ projects: [CostUsageProjectBreakdown]) -> [CostUsageProjectBreakdown] + { + var dailyByPath: [String: [CostUsageDailyReport]] = [:] + var namesByPath: [String: String] = [:] + var sourceDailyByProjectPath: [String: [String: [CostUsageDailyReport]]] = [:] + var sourceNamesByProjectPath: [String: [String: String]] = [:] + for project in projects { + let key = project.path ?? "" + namesByPath[key] = project.name + dailyByPath[key, default: []].append(CostUsageDailyReport(data: project.daily, summary: nil)) + let sources = project.sources.isEmpty + ? [ + CostUsageProjectSourceBreakdown( + name: project.name, + path: project.path, + totalTokens: project.totalTokens, + totalCostUSD: project.totalCostUSD, + daily: project.daily, + modelBreakdowns: project.modelBreakdowns), + ] + : project.sources + for source in sources { + let sourceKey = source.path ?? "" + sourceNamesByProjectPath[key, default: [:]][sourceKey] = source.name + sourceDailyByProjectPath[key, default: [:]][sourceKey, default: []] + .append(CostUsageDailyReport(data: source.daily, summary: nil)) + } + } + return dailyByPath.map { key, reports in + let merged = CostUsageDailyReport.merged(reports) + return CostUsageProjectBreakdown( + name: namesByPath[key] ?? CostUsageProjectBreakdown.unknownProjectName, + path: key.isEmpty ? nil : key, + totalTokens: merged.summary?.totalTokens, + totalCostUSD: merged.summary?.totalCostUSD, + daily: merged.data, + modelBreakdowns: Self.projectModelBreakdowns(from: merged.data), + sources: Self.mergedProjectSources( + sourceDailyByPath: sourceDailyByProjectPath[key] ?? [:], + sourceNamesByPath: sourceNamesByProjectPath[key] ?? [:])) + } + .sorted { lhs, rhs in + let lhsCost = lhs.totalCostUSD ?? -1 + let rhsCost = rhs.totalCostUSD ?? -1 + if lhsCost != rhsCost { return lhsCost > rhsCost } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + private static func mergedProjectSources( + sourceDailyByPath: [String: [CostUsageDailyReport]], + sourceNamesByPath: [String: String]) -> [CostUsageProjectSourceBreakdown] + { + sourceDailyByPath.map { key, reports in + let merged = CostUsageDailyReport.merged(reports) + return CostUsageProjectSourceBreakdown( + name: sourceNamesByPath[key] ?? CostUsageProjectBreakdown.unknownProjectName, + path: key.isEmpty ? nil : key, + totalTokens: merged.summary?.totalTokens, + totalCostUSD: merged.summary?.totalCostUSD, + daily: merged.data, + modelBreakdowns: Self.projectModelBreakdowns(from: merged.data)) + } + .sorted { lhs, rhs in + let lhsCost = lhs.totalCostUSD ?? -1 + let rhsCost = rhs.totalCostUSD ?? -1 + if lhsCost != rhsCost { return lhsCost > rhsCost } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + private struct ProjectBreakdownAccumulator { + var totalTokens = 0 + var sawTotalTokens = false + var costUSD: Double = 0 + var sawCost = false + + mutating func add(_ breakdown: CostUsageDailyReport.ModelBreakdown) { + if let totalTokens = breakdown.totalTokens { + self.totalTokens += totalTokens + self.sawTotalTokens = true + } + if let costUSD = breakdown.costUSD { + self.costUSD += costUSD + self.sawCost = true + } + } + + func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown { + CostUsageDailyReport.ModelBreakdown( + modelName: modelName, + costUSD: self.sawCost ? self.costUSD : nil, + totalTokens: self.sawTotalTokens ? self.totalTokens : nil) + } + } + + private static func projectModelBreakdowns( + from entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.ModelBreakdown]? + { + var accumulators: [String: ProjectBreakdownAccumulator] = [:] + for entry in entries { + for breakdown in entry.modelBreakdowns ?? [] { + var accumulator = accumulators[breakdown.modelName] ?? ProjectBreakdownAccumulator() + accumulator.add(breakdown) + accumulators[breakdown.modelName] = accumulator + } + } + guard !accumulators.isEmpty else { return nil } + return accumulators.map { modelName, accumulator in + accumulator.build(modelName: modelName) + } + .sorted { lhs, rhs in + let lhsCost = lhs.costUSD ?? -1 + let rhsCost = rhs.costUSD ?? -1 + if lhsCost != rhsCost { return lhsCost > rhsCost } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + return lhs.modelName > rhs.modelName + } + } + static func selectCurrentSession(from sessions: [CostUsageSessionReport.Entry]) -> CostUsageSessionReport.Entry? { diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index d2a6a4d2c7..797faf4915 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -11,6 +11,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { public let historyDays: Int public let historyLabel: String? public let daily: [CostUsageDailyReport.Entry] + public let projects: [CostUsageProjectBreakdown] public let updatedAt: Date public init( @@ -24,6 +25,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { historyDays: Int = 30, historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry], + projects: [CostUsageProjectBreakdown] = [], updatedAt: Date) { self.sessionTokens = sessionTokens @@ -38,6 +40,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.historyDays = historyDays self.historyLabel = historyLabel self.daily = daily + self.projects = projects self.updatedAt = updatedAt } @@ -77,6 +80,67 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { } } +public struct CostUsageProjectBreakdown: Sendable, Equatable { + public static let unknownProjectName = "Unknown project" + + public let name: String + public let path: String? + public let totalTokens: Int? + public let totalCostUSD: Double? + public let daily: [CostUsageDailyReport.Entry] + public let modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]? + public let sources: [CostUsageProjectSourceBreakdown] + + public init( + name: String, + path: String?, + totalTokens: Int?, + totalCostUSD: Double?, + daily: [CostUsageDailyReport.Entry], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]?, + sources: [CostUsageProjectSourceBreakdown] = []) + { + self.name = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? Self.unknownProjectName + : name + let cleanPath = path?.trimmingCharacters(in: .whitespacesAndNewlines) + self.path = cleanPath?.isEmpty == true ? nil : cleanPath + self.totalTokens = totalTokens + self.totalCostUSD = totalCostUSD + self.daily = daily + self.modelBreakdowns = modelBreakdowns + self.sources = sources + } +} + +public struct CostUsageProjectSourceBreakdown: Sendable, Equatable { + public let name: String + public let path: String? + public let totalTokens: Int? + public let totalCostUSD: Double? + public let daily: [CostUsageDailyReport.Entry] + public let modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]? + + public init( + name: String, + path: String?, + totalTokens: Int?, + totalCostUSD: Double?, + daily: [CostUsageDailyReport.Entry], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]?) + { + self.name = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? CostUsageProjectBreakdown.unknownProjectName + : name + let cleanPath = path?.trimmingCharacters(in: .whitespacesAndNewlines) + self.path = cleanPath?.isEmpty == true ? nil : cleanPath + self.totalTokens = totalTokens + self.totalCostUSD = totalCostUSD + self.daily = daily + self.modelBreakdowns = modelBreakdowns + } +} + public struct CostUsageDailyReport: Sendable, Decodable { public struct ModelBreakdown: Sendable, Decodable, Equatable { public let modelName: String diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 40c2d8c6f4..477d836d36 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -8,7 +8,7 @@ enum CostUsageCacheIO { private static func artifactVersion(for provider: UsageProvider) -> Int { switch provider { case .codex: - 8 + 10 case .claude, .vertexai: 4 default: @@ -136,6 +136,8 @@ struct CostUsageFileUsage: Codable { var lastCodexTurnID: String? var sessionId: String? var forkedFromId: String? + var projectPath: String? + var canonicalProjectPath: String? var codexCostNanos: [String: [String: Int64]]? var codexPrioritySurchargeNanos: [String: [String: Int64]]? var codexStandardCostNanos: [String: [String: Int64]]? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 98e8779b4d..9c8d1cb44e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -274,6 +274,8 @@ extension CostUsageScanner { lastCodexTurnID: String? = nil, sessionId: String? = nil, forkedFromId: String? = nil, + projectPath: String? = nil, + canonicalProjectPath: String? = nil, codexCostNanos: [String: [String: Int64]]? = nil, codexPrioritySurchargeNanos: [String: [String: Int64]]? = nil, codexStandardCostNanos: [String: [String: Int64]]? = nil, @@ -297,6 +299,8 @@ extension CostUsageScanner { lastCodexTurnID: lastCodexTurnID, sessionId: sessionId, forkedFromId: forkedFromId, + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, codexCostNanos: codexCostNanos, codexPrioritySurchargeNanos: codexPrioritySurchargeNanos, codexStandardCostNanos: codexStandardCostNanos, @@ -663,6 +667,8 @@ extension CostUsageScanner { lastCodexTurnID: usage.lastCodexTurnID, sessionId: usage.sessionId, forkedFromId: usage.forkedFromId, + projectPath: usage.projectPath, + canonicalProjectPath: usage.canonicalProjectPath, codexCostNanos: Self.mergeCostMaps( Self.costMapOutsideScanWindow(usage.codexCostNanos, range: context.range), Self.codexCostNanos( @@ -942,6 +948,10 @@ extension CostUsageScanner { return false } let sessionId = delta.sessionId ?? cached.sessionId + let projectPath = delta.projectPath ?? cached.projectPath + let canonicalProjectPath = delta.projectPath.map { + context.resources.projectPathResolver.canonicalProjectPath(for: $0) + } ?? cached.canonicalProjectPath ?? context.resources.projectPathResolver.canonicalProjectPath(for: projectPath) let sessionAlreadyContributed = sessionId.map { state.contributingSessionIds.contains($0) } ?? false let cachedRows = cached.codexRows ?? [] let retainedCachedRows: [CodexUsageRow] @@ -1004,6 +1014,8 @@ extension CostUsageScanner { lastCodexTurnID: delta.lastCodexTurnID, sessionId: sessionId, forkedFromId: delta.forkedFromId ?? migratedCached.forkedFromId, + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, codexCostNanos: Self.codexMergedCostMap( migratedCached.codexCostNanos, deltaRows: uniqueRows, @@ -1056,6 +1068,10 @@ extension CostUsageScanner { inheritedTotalsResolver: context.resources.inheritedResolver.inheritedTotals(for:atOrBefore:), checkCancellation: context.checkCancellation) let sessionId = parsed.sessionId ?? input.cached?.sessionId + let projectPath = parsed.projectPath ?? input.cached?.projectPath + let canonicalProjectPath = parsed.projectPath.map { + context.resources.projectPathResolver.canonicalProjectPath(for: $0) + } ?? input.cached?.canonicalProjectPath ?? context.resources.projectPathResolver.canonicalProjectPath(for: projectPath) let uniqueRows = Self.uniqueCodexRows( rows: parsed.rows, sessionId: sessionId, @@ -1091,6 +1107,8 @@ extension CostUsageScanner { lastCodexTurnID: parsed.lastCodexTurnID, sessionId: sessionId, forkedFromId: parsed.forkedFromId, + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, codexCostNanos: Self.mergeCostMaps( context.dropDeferredCodexRows ? nil @@ -1391,6 +1409,172 @@ extension CostUsageScanner { return CostUsageDailyReport(data: entries, summary: summary) } + static func buildCodexProjectBreakdownsFromCache( + cache: CostUsageCache, + range: CostUsageDayRange, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + priorityTurns: [String: CodexPriorityTurnMetadata] = [:]) -> [CostUsageProjectBreakdown] + { + let projectPathResolver = CodexCanonicalProjectPathResolver() + var accumulatorsByProjectPath: [String: CodexProjectBreakdownAccumulator] = [:] + for (filePath, usage) in cache.files { + guard usage.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) else { + continue + } + var fileCache = CostUsageCache() + fileCache.files[filePath] = usage + fileCache.days = usage.days + let report = Self.buildCodexReportFromCache( + cache: fileCache, + range: range, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot, + priorityTurns: priorityTurns) + guard !report.data.isEmpty else { continue } + let projectKey = usage.canonicalProjectPath + ?? projectPathResolver.canonicalProjectPath(for: usage.projectPath) + ?? "" + let sourceKey = usage.projectPath ?? "" + var accumulator = accumulatorsByProjectPath[projectKey] ?? CodexProjectBreakdownAccumulator() + accumulator.add(report: report, sourcePath: sourceKey) + accumulatorsByProjectPath[projectKey] = accumulator + } + + return accumulatorsByProjectPath.map { projectPath, accumulator in + let merged = CostUsageDailyReport.merged(accumulator.reports) + let resolvedPath = projectPath.isEmpty ? nil : projectPath + return CostUsageProjectBreakdown( + name: Self.codexProjectName(path: resolvedPath), + path: resolvedPath, + totalTokens: merged.summary?.totalTokens, + totalCostUSD: merged.summary?.totalCostUSD, + daily: merged.data, + modelBreakdowns: Self.codexProjectModelBreakdowns(from: merged.data), + sources: Self.codexProjectSourceBreakdowns(from: accumulator.reportsBySourcePath)) + } + .sorted { lhs, rhs in + let lhsCost = lhs.totalCostUSD ?? -1 + let rhsCost = rhs.totalCostUSD ?? -1 + if lhsCost != rhsCost { return lhsCost > rhsCost } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + private static func codexProjectName(path: String?) -> String { + guard let path, !path.isEmpty else { return CostUsageProjectBreakdown.unknownProjectName } + let name = URL(fileURLWithPath: path, isDirectory: true).lastPathComponent + return name.isEmpty ? path : name + } + + private struct CodexProjectBreakdownAccumulator { + var reports: [CostUsageDailyReport] = [] + var reportsBySourcePath: [String: [CostUsageDailyReport]] = [:] + + mutating func add(report: CostUsageDailyReport, sourcePath: String) { + self.reports.append(report) + self.reportsBySourcePath[sourcePath, default: []].append(report) + } + } + + private static func codexProjectSourceBreakdowns( + from reportsBySourcePath: [String: [CostUsageDailyReport]]) -> [CostUsageProjectSourceBreakdown] + { + reportsBySourcePath.map { sourcePath, reports in + let merged = CostUsageDailyReport.merged(reports) + let resolvedPath = sourcePath.isEmpty ? nil : sourcePath + return CostUsageProjectSourceBreakdown( + name: Self.codexProjectName(path: resolvedPath), + path: resolvedPath, + totalTokens: merged.summary?.totalTokens, + totalCostUSD: merged.summary?.totalCostUSD, + daily: merged.data, + modelBreakdowns: Self.codexProjectModelBreakdowns(from: merged.data)) + } + .sorted { lhs, rhs in + let lhsCost = lhs.totalCostUSD ?? -1 + let rhsCost = rhs.totalCostUSD ?? -1 + if lhsCost != rhsCost { return lhsCost > rhsCost } + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + + private struct ProjectBreakdownAccumulator { + var totalTokens = 0 + var sawTotalTokens = false + var costUSD: Double = 0 + var sawCost = false + var standardCostUSD: Double = 0 + var sawStandardCost = false + var priorityCostUSD: Double = 0 + var sawPriorityCost = false + var standardTokens = 0 + var sawStandardTokens = false + var priorityTokens = 0 + var sawPriorityTokens = false + + mutating func add(_ breakdown: CostUsageDailyReport.ModelBreakdown) { + if let totalTokens = breakdown.totalTokens { + self.totalTokens += totalTokens + self.sawTotalTokens = true + } + if let costUSD = breakdown.costUSD { + self.costUSD += costUSD + self.sawCost = true + } + if let standardCostUSD = breakdown.standardCostUSD { + self.standardCostUSD += standardCostUSD + self.sawStandardCost = true + } + if let priorityCostUSD = breakdown.priorityCostUSD { + self.priorityCostUSD += priorityCostUSD + self.sawPriorityCost = true + } + if let standardTokens = breakdown.standardTokens { + self.standardTokens += standardTokens + self.sawStandardTokens = true + } + if let priorityTokens = breakdown.priorityTokens { + self.priorityTokens += priorityTokens + self.sawPriorityTokens = true + } + } + + func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown { + CostUsageDailyReport.ModelBreakdown( + modelName: modelName, + costUSD: self.sawCost ? self.costUSD : nil, + totalTokens: self.sawTotalTokens ? self.totalTokens : nil, + standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, + priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, + standardTokens: self.sawStandardTokens ? self.standardTokens : nil, + priorityTokens: self.sawPriorityTokens ? self.priorityTokens : nil) + } + } + + private static func codexProjectModelBreakdowns( + from entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.ModelBreakdown]? + { + var accumulators: [String: ProjectBreakdownAccumulator] = [:] + for entry in entries { + for breakdown in entry.modelBreakdowns ?? [] { + var accumulator = accumulators[breakdown.modelName] ?? ProjectBreakdownAccumulator() + accumulator.add(breakdown) + accumulators[breakdown.modelName] = accumulator + } + } + guard !accumulators.isEmpty else { return nil } + return Self.sortedModelBreakdowns(accumulators.map { modelName, accumulator in + accumulator.build(modelName: modelName) + }) + } + static func sortedModelBreakdowns(_ breakdowns: [CostUsageDailyReport.ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown] { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index e739dad4d7..2401cfafd7 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -3,6 +3,7 @@ import CryptoKit #else import Crypto #endif +import Dispatch import Foundation // swiftlint:disable type_body_length file_length @@ -57,6 +58,7 @@ enum CostUsageScanner { let lastCodexTurnID: String? let sessionId: String? let forkedFromId: String? + let projectPath: String? let rows: [CodexUsageRow] } @@ -176,6 +178,7 @@ enum CostUsageScanner { struct CodexScanResources { let fileIndex: CodexSessionFileIndex let inheritedResolver: CodexInheritedTotalsResolver + let projectPathResolver: CodexCanonicalProjectPathResolver let modelsDevCatalog: ModelsDevCatalog? let modelsDevCacheRoot: URL? let priorityTurns: [String: CodexPriorityTurnMetadata] @@ -191,6 +194,87 @@ enum CostUsageScanner { let checkCancellation: CancellationCheck? } + final class CodexCanonicalProjectPathResolver { + private var cache: [String: String] = [:] + private let homeCodexWorktreesPrefix: String + + init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) { + self.homeCodexWorktreesPrefix = homeDirectory + .appendingPathComponent(".codex/worktrees", isDirectory: true) + .standardizedFileURL + .path + } + + func canonicalProjectPath(for projectPath: String?) -> String? { + guard let projectPath else { return nil } + if let cached = self.cache[projectPath] { + return cached + } + let resolved = self.resolveCanonicalProjectPath(projectPath) ?? projectPath + self.cache[projectPath] = resolved + return resolved + } + + private func resolveCanonicalProjectPath(_ projectPath: String) -> String? { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: projectPath, isDirectory: &isDirectory), + isDirectory.boolValue + else { return nil } + guard let output = self.gitWorktreeList(projectPath: projectPath) else { return nil } + let worktrees = output + .split(separator: "\n") + .compactMap { line -> String? in + guard line.hasPrefix("worktree ") else { return nil } + let rawPath = line.dropFirst("worktree ".count) + return Self.standardizedAbsolutePath(String(rawPath)) + } + guard !worktrees.isEmpty else { return nil } + return worktrees.first { !self.isEphemeralWorktreePath($0) } + } + + private func gitWorktreeList(projectPath: String) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git", "-C", projectPath, "worktree", "list", "--porcelain"] + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + let semaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in semaphore.signal() } + do { + try process.run() + } catch { + return nil + } + + if semaphore.wait(timeout: .now() + .seconds(1)) == .timedOut { + process.terminate() + return nil + } + guard process.terminationStatus == 0 else { return nil } + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } + + private func isEphemeralWorktreePath(_ path: String) -> Bool { + path == self.homeCodexWorktreesPrefix + || path.hasPrefix(self.homeCodexWorktreesPrefix + "/") + || path.hasSuffix("/.codex/worktrees") + || path.contains("/.codex/worktrees/") + || path == "/private/tmp" + || path.hasPrefix("/private/tmp/") + } + + private static func standardizedAbsolutePath(_ path: String) -> String? { + let expanded = (path as NSString).expandingTildeInPath + guard expanded.hasPrefix("/") else { return nil } + return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path + } + } + struct CodexRefreshPlan { let refreshMs: Int64 let roots: [URL] @@ -955,6 +1039,7 @@ enum CostUsageScanner { let sessionId: String? let forkedFromId: String? let forkTimestamp: String? + let projectPath: String? } private struct CodexTokenCountRecord { @@ -993,6 +1078,7 @@ enum CostUsageScanner { private static let codexJSONFieldTurnId = Array("turn_id".utf8) private static let codexJSONFieldTurnIdCamel = Array("turnId".utf8) private static let codexJSONFieldType = Array("type".utf8) + private static let codexJSONFieldCwd = Array("cwd".utf8) private static func codexForkParentId(from payload: [String: Any]?) -> String? { guard let payload else { return nil } @@ -1065,6 +1151,24 @@ enum CostUsageScanner { return nil } + static func normalizedCodexProjectPath(_ rawPath: String?) -> String? { + guard let rawPath = rawPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawPath.isEmpty + else { return nil } + let expanded = (rawPath as NSString).expandingTildeInPath + guard expanded.hasPrefix("/") else { return nil } + return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL.path + } + + private static func codexProjectPath( + from bytes: UnsafeBufferPointer, + payloadRange: Range?) -> String? + { + guard let payloadRange else { return nil } + return Self.normalizedCodexProjectPath( + Self.extractJSONByteStringField(Self.codexJSONFieldCwd, from: bytes, in: payloadRange, atDepth: 1)) + } + private static func codexTotals( from bytes: UnsafeBufferPointer, in objectRange: Range?) -> CostUsageCodexTotals? @@ -1122,7 +1226,8 @@ enum CostUsageScanner { Self.codexJSONFieldTimestamp, from: rawBuffer, in: objectRange, - atDepth: 1))) + atDepth: 1), + projectPath: Self.codexProjectPath(from: rawBuffer, payloadRange: payloadRange))) case "turn_context": guard let payloadRange = Self.extractJSONByteObjectField( @@ -1280,7 +1385,8 @@ enum CostUsageScanner { ?? obj["id"] as? String, forkedFromId: Self.codexForkParentId(from: payload), forkTimestamp: payload?["timestamp"] as? String - ?? obj["timestamp"] as? String) + ?? obj["timestamp"] as? String, + projectPath: Self.normalizedCodexProjectPath(payload?["cwd"] as? String)) } } @@ -1507,6 +1613,7 @@ enum CostUsageScanner { lastCodexTurnID: initialCodexTurnID, sessionId: nil, forkedFromId: nil, + projectPath: nil, rows: []) } @@ -1528,6 +1635,7 @@ enum CostUsageScanner { var previousTotals = initialTotals var sessionId: String? var forkedFromId: String? + var projectPath: String? var inheritedTotals: CostUsageCodexTotals? var remainingInheritedTotals: CostUsageCodexTotals? var forkBaselineResolved = false @@ -1577,6 +1685,9 @@ enum CostUsageScanner { if forkedFromId == nil { forkedFromId = metadata.forkedFromId } + if projectPath == nil { + projectPath = metadata.projectPath + } if let forkedFromId { try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: metadata.forkTimestamp ?? "") } @@ -1798,6 +1909,7 @@ enum CostUsageScanner { { sessionId = metadata.sessionId forkedFromId = metadata.forkedFromId + projectPath = metadata.projectPath if let forkedFromId = metadata.forkedFromId, inheritedTotals == nil { @@ -1866,6 +1978,9 @@ enum CostUsageScanner { if forkedFromId == nil { forkedFromId = Self.codexForkParentId(from: payload) } + if projectPath == nil { + projectPath = Self.normalizedCodexProjectPath(payload?["cwd"] as? String) + } if let forkedFromId { let forkedAt = payload?["timestamp"] as? String ?? obj["timestamp"] as? String @@ -2137,6 +2252,7 @@ enum CostUsageScanner { lastCodexTurnID: currentTurnID, sessionId: sessionId, forkedFromId: forkedFromId, + projectPath: projectPath, rows: rows) } @@ -2342,6 +2458,7 @@ enum CostUsageScanner { let resources = CodexScanResources( fileIndex: fileIndex, inheritedResolver: inheritedResolver, + projectPathResolver: CodexCanonicalProjectPathResolver(), modelsDevCatalog: plan.modelsDevCatalog, modelsDevCacheRoot: options.cacheRoot, priorityTurns: plan.priorityTurns) diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index 1b223bce56..008bf2f6e5 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -38,6 +38,67 @@ struct CLICostTests { #expect(output.contains("Claude Code /status")) } + @Test + func `renders codex project grouped cost text`() { + let snap = CostUsageTokenSnapshot( + sessionTokens: 1200, + sessionCostUSD: 1.25, + last30DaysTokens: 9000, + last30DaysCostUSD: 9.99, + historyDays: 30, + daily: [], + projects: [ + CostUsageProjectBreakdown( + name: "client-a", + path: "/work/client-a", + totalTokens: 7000, + totalCostUSD: 7.5, + daily: [], + modelBreakdowns: nil, + sources: [ + CostUsageProjectSourceBreakdown( + name: "client-a", + path: "/work/client-a", + totalTokens: 5000, + totalCostUSD: 5.25, + daily: [], + modelBreakdowns: nil), + CostUsageProjectSourceBreakdown( + name: "client-a", + path: "/Users/test/.codex/worktrees/abcd/client-a", + totalTokens: 2000, + totalCostUSD: 2.25, + daily: [], + modelBreakdowns: nil), + ]), + CostUsageProjectBreakdown( + name: CostUsageProjectBreakdown.unknownProjectName, + path: nil, + totalTokens: 2000, + totalCostUSD: 2.49, + daily: [], + modelBreakdowns: nil), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let output = CodexBarCLI.renderCostText( + provider: .codex, + snapshot: snap, + groupBy: .project, + useColor: false) + .replacingOccurrences(of: "\u{00A0}", with: " ") + .replacingOccurrences(of: "$ ", with: "$") + + #expect(output.contains("Codex Cost (API-rate estimate)")) + #expect(output.contains("Projects (Last 30 days):")) + #expect(output.contains("client-a: $7.50 · 7K tokens")) + #expect(output.contains("/work/client-a")) + #expect(output.contains(" - client-a: $5.25 · 5K tokens")) + #expect(output.contains(" - client-a: $2.25 · 2K tokens")) + #expect(output.contains("/Users/test/.codex/worktrees/abcd/client-a")) + #expect(output.contains("Unknown project: $2.49 · 2K tokens")) + } + @Test func `encodes cost payload JSON`() throws { let payload = CostPayload( @@ -95,6 +156,89 @@ struct CLICostTests { #expect(json.contains("1700000000")) } + @Test + func `codex cost payload includes project rollups`() throws { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 0.01, + last30DaysTokens: 40, + last30DaysCostUSD: 0.04, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-04-02", + inputTokens: 30, + outputTokens: 10, + totalTokens: 40, + costUSD: 0.04, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.04, + totalTokens: 40), + ]), + ], + projects: [ + CostUsageProjectBreakdown( + name: "client-a", + path: "/work/client-a", + totalTokens: 40, + totalCostUSD: 0.04, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-04-02", + inputTokens: 30, + outputTokens: 10, + totalTokens: 40, + costUSD: 0.04, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: nil), + ], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 0.04, + totalTokens: 40), + ], + sources: [ + CostUsageProjectSourceBreakdown( + name: "client-a", + path: "/work/client-a", + totalTokens: 40, + totalCostUSD: 0.04, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-04-02", + inputTokens: 30, + outputTokens: 10, + totalTokens: 40, + costUSD: 0.04, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: nil), + ], + modelBreakdowns: nil), + ]), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + let payload = CodexBarCLI.makeCostPayload(provider: .codex, snapshot: snapshot, error: nil) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try encoder.encode(payload) + guard let json = String(data: data, encoding: .utf8) else { + Issue.record("Failed to decode cost payload JSON") + return + } + + #expect(json.contains("\"projects\"")) + #expect(json.contains("\"sources\"")) + #expect(json.contains("\"name\":\"client-a\"")) + #expect(json.contains("/work/client-a") || json.contains("\\/work\\/client-a")) + #expect(json.contains("\"totalCost\":0.04")) + #expect(json.contains("\"daily\"")) + #expect(json.contains("\"gpt-5.4\"")) + } + @Test func `encodes exact codex model I ds and zero cost breakdowns`() throws { let payload = CostPayload( diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index 188532812f..0e6aeb2973 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -181,5 +181,17 @@ struct CostHistoryChartMenuViewTests { modeSubtitlePresence: [false], hasTotal: true) #expect(withTotal > withoutTotal) + + let withProjects = CostHistoryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [false], + hasTotal: true, + projectCount: 3) + #expect(withProjects > withTotal) + + let withProjectSources = CostHistoryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [false], + hasTotal: true, + projectSourceCounts: [3]) + #expect(withProjectSources > withProjects) } } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index b7252a246d..c98b7cc7b5 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -11,7 +11,7 @@ struct CostUsageCacheTests { let claudeURL = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) let vertexURL = CostUsageCacheIO.cacheFileURL(provider: .vertexai, cacheRoot: root) - #expect(codexURL.lastPathComponent == "codex-v8.json") + #expect(codexURL.lastPathComponent == "codex-v10.json") #expect(claudeURL.lastPathComponent == "claude-v4.json") #expect(vertexURL.lastPathComponent == "vertexai-v4.json") } diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 681c6761a6..71b06d60b3 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -195,6 +195,183 @@ struct CostUsageScannerBreakdownTests { #expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0)) } + @Test + func `codex project breakdowns group by cwd and preserve daily totals`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let model = "gpt-5.4" + let projectA = env.root.appendingPathComponent("client-a", isDirectory: true).path + let projectB = env.root.appendingPathComponent("client-b", isDirectory: true).path + let projectAWorktree = env.root + .appendingPathComponent(".codex/worktrees/abcd/client-a", isDirectory: true) + .path + + try self.makeGitRepositoryWithWorktree(projectPath: projectA, worktreePath: projectAWorktree) + + func sessionMeta(id: String, cwd: String?) -> [String: Any] { + var payload: [String: Any] = ["id": id] + if let cwd { payload["cwd"] = cwd } + return [ + "type": "session_meta", + "timestamp": iso0, + "payload": payload, + ] + } + + let firstA = try env.writeCodexSessionFile( + day: day, + filename: "client-a-1.jsonl", + contents: env.jsonl([ + sessionMeta(id: "client-a-1", cwd: projectA), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 10, cached: 0, output: 1)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "client-a-2.jsonl", + contents: env.jsonl([ + sessionMeta(id: "client-a-2", cwd: projectA + "/."), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 20, cached: 0, output: 2)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "client-a-worktree.jsonl", + contents: env.jsonl([ + sessionMeta(id: "client-a-worktree", cwd: projectAWorktree), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 12, cached: 0, output: 1)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "client-b.jsonl", + contents: env.jsonl([ + sessionMeta(id: "client-b", cwd: projectB), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 5, cached: 0, output: 5)), + ])) + _ = try env.writeCodexSessionFile( + day: day, + filename: "unknown.jsonl", + contents: env.jsonl([ + sessionMeta(id: "unknown", cwd: nil), + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 7, cached: 0, output: 3)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(report.summary?.totalTokens == 66) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + var projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + modelsDevCacheRoot: env.cacheRoot) + let projectABreakdown = projects.first { $0.path == projectA } + #expect(projectABreakdown?.totalTokens == 46) + #expect(projectABreakdown?.sources.count == 2) + #expect(projectABreakdown?.sources.first(where: { $0.path == projectA })?.totalTokens == 33) + #expect(projectABreakdown?.sources.first(where: { $0.path == projectAWorktree })?.totalTokens == 13) + #expect(projects.first(where: { $0.path == projectB })?.totalTokens == 10) + #expect(projects.first(where: { $0.path == nil })?.name == CostUsageProjectBreakdown.unknownProjectName) + #expect(projects.first(where: { $0.path == nil })?.totalTokens == 10) + #expect(cache.files.values.first(where: { $0.projectPath == projectAWorktree })?.canonicalProjectPath == projectA) + + let appended = try "\n" + env.jsonl([ + self.codexTokenCount( + timestamp: iso2, + model: model, + total: (input: 15, cached: 0, output: 2)), + ]) + let handle = try FileHandle(forWritingTo: firstA) + try handle.seekToEnd() + try handle.write(contentsOf: Data(appended.utf8)) + try handle.close() + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( + cache: cache, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + modelsDevCacheRoot: env.cacheRoot) + #expect(projects.first(where: { $0.path == projectA })?.totalTokens == 52) + } + + private func makeGitRepositoryWithWorktree(projectPath: String, worktreePath: String) throws { + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: projectPath, isDirectory: true), + withIntermediateDirectories: true) + try self.runGit(["init", projectPath]) + try self.runGit(["-C", projectPath, "config", "user.email", "codexbar-test@example.com"]) + try self.runGit(["-C", projectPath, "config", "user.name", "CodexBar Test"]) + try "test\n".write( + to: URL(fileURLWithPath: projectPath).appendingPathComponent("README.md"), + atomically: false, + encoding: .utf8) + try self.runGit(["-C", projectPath, "add", "README.md"]) + try self.runGit(["-C", projectPath, "commit", "-m", "init"]) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: worktreePath).deletingLastPathComponent(), + withIntermediateDirectories: true) + try self.runGit(["-C", projectPath, "worktree", "add", "-b", "codex-test", worktreePath]) + } + + private func runGit(_ arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git"] + arguments + let output = Pipe() + process.standardOutput = output + process.standardError = output + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + let data = output.fileHandleForReading.readDataToEndOfFile() + let message = String(data: data, encoding: .utf8) ?? "" + throw NSError( + domain: "CodexBarTests.Git", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: message]) + } + } + @Test func `codex incremental append falls back to rescan when fork metadata appears late`() throws { let env = try CostUsageTestEnvironment() @@ -1133,7 +1310,7 @@ struct CostUsageScannerBreakdownTests { #expect(first.data[0].totalTokens == 132) let newCacheURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot) - #expect(newCacheURL.lastPathComponent == "codex-v8.json") + #expect(newCacheURL.lastPathComponent == "codex-v10.json") #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) #expect(FileManager.default.fileExists(atPath: oldCacheURL.path)) diff --git a/docs/cli.md b/docs/cli.md index 28809223d6..6e4e245884 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -115,6 +115,7 @@ payloads include the visible account label in `account`. - `sessionTokens`, `sessionCostUSD` - `last30DaysTokens`, `last30DaysCostUSD` - `daily[]`: `date`, `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost`, `modelsUsed`, `modelBreakdowns[]` (`modelName`, `cost`) +- Codex only: `projects[]`: `name`, `path`, `totalTokens`, `totalCost`, `daily[]`, `modelBreakdowns[]`, `sources[]` - `totals`: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheCreationTokens`, `totalTokens`, `totalCost` ## Example usage @@ -126,6 +127,7 @@ codexbar --format json --pretty # machine output codexbar --format json --provider both codexbar cost # local cost usage (default 30-day window + today) codexbar cost --days 90 # choose a 1...365 day cost window +codexbar cost --provider codex --group-by project codexbar cost --provider claude --format json --pretty codexbar serve --port 8080 # localhost HTTP JSON server codexbar serve --request-timeout 0 # disable serve request deadlines