Unified · Split
| @@ -1,310 +0,0 @@ | ||
| 1 | −import Charts | |
| 2 | −import CodexBarCore | |
| 3 | −import SwiftUI | |
| 4 | − | |
| 5 | −@MainActor | |
| 6 | −struct CCUsageCostChartMenuView: View { | |
| 7 | − private struct Point: Identifiable { | |
| 8 | − let id: String | |
| 9 | − let date: Date | |
| 10 | − let costUSD: Double | |
| 11 | − let totalTokens: Int? | |
| 12 | − | |
| 13 | − init(date: Date, costUSD: Double, totalTokens: Int?) { | |
| 14 | − self.date = date | |
| 15 | − self.costUSD = costUSD | |
| 16 | − self.totalTokens = totalTokens | |
| 17 | − self.id = "\(Int(date.timeIntervalSince1970))-\(costUSD)" | |
| 18 | − } | |
| 19 | − } | |
| 20 | − | |
| 21 | − private let provider: UsageProvider | |
| 22 | − private let daily: [CCUsageDailyReport.Entry] | |
| 23 | − private let totalCostUSD: Double? | |
| 24 | − @State private var selectedDateKey: String? | |
| 25 | − | |
| 26 | − init(provider: UsageProvider, daily: [CCUsageDailyReport.Entry], totalCostUSD: Double?) { | |
| 27 | − self.provider = provider | |
| 28 | − self.daily = daily | |
| 29 | − self.totalCostUSD = totalCostUSD | |
| 30 | − } | |
| 31 | − | |
| 32 | − var body: some View { | |
| 33 | − let model = Self.makeModel(provider: self.provider, daily: self.daily) | |
| 34 | − VStack(alignment: .leading, spacing: 10) { | |
| 35 | − if model.points.isEmpty { | |
| 36 | − Text("No cost history data.") | |
| 37 | − .font(.footnote) | |
| 38 | − .foregroundStyle(.secondary) | |
| 39 | − } else { | |
| 40 | − Chart { | |
| 41 | − ForEach(model.points) { point in | |
| 42 | − BarMark( | |
| 43 | − x: .value("Day", point.date, unit: .day), | |
| 44 | − y: .value("Cost", point.costUSD)) | |
| 45 | − .foregroundStyle(model.barColor) | |
| 46 | − } | |
| 47 | − if let peak = Self.peakPoint(model: model) { | |
| 48 | − PointMark( | |
| 49 | − x: .value("Day", peak.date, unit: .day), | |
| 50 | − y: .value("Cost", peak.costUSD)) | |
| 51 | − .symbolSize(26) | |
| 52 | − .foregroundStyle(Color(nsColor: .systemYellow)) | |
| 53 | − } | |
| 54 | − } | |
| 55 | − .chartYAxis(.hidden) | |
| 56 | − .chartXAxis { | |
| 57 | − AxisMarks(values: model.axisDates) { _ in | |
| 58 | − AxisGridLine().foregroundStyle(Color.clear) | |
| 59 | − AxisTick().foregroundStyle(Color.clear) | |
| 60 | − AxisValueLabel(format: .dateTime.month(.abbreviated).day()) | |
| 61 | − .font(.caption2) | |
| 62 | − .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) | |
| 63 | − } | |
| 64 | − } | |
| 65 | − .chartLegend(.hidden) | |
| 66 | − .frame(height: 130) | |
| 67 | − .chartOverlay { proxy in | |
| 68 | − GeometryReader { geo in | |
| 69 | − ZStack(alignment: .topLeading) { | |
| 70 | − if let rect = self.selectionBandRect(model: model, proxy: proxy, geo: geo) { | |
| 71 | − Rectangle() | |
| 72 | − .fill(Color.white.opacity(0.07)) | |
| 73 | − .frame(width: rect.width, height: rect.height) | |
| 74 | − .position(x: rect.midX, y: rect.midY) | |
| 75 | − .allowsHitTesting(false) | |
| 76 | − } | |
| 77 | − MouseLocationReader { location in | |
| 78 | − self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) | |
| 79 | − } | |
| 80 | − .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 81 | − .contentShape(Rectangle()) | |
| 82 | − } | |
| 83 | − } | |
| 84 | − } | |
| 85 | − | |
| 86 | − Text(self.detailText(model: model)) | |
| 87 | − .font(.caption) | |
| 88 | − .foregroundStyle(.secondary) | |
| 89 | − .lineLimit(1) | |
| 90 | − .fixedSize(horizontal: false, vertical: true) | |
| 91 | − .frame(height: 16, alignment: .leading) | |
| 92 | − } | |
| 93 | − | |
| 94 | − if let total = self.totalCostUSD { | |
| 95 | − Text("Total (30d): \(UsageFormatter.usdString(total))") | |
| 96 | − .font(.caption) | |
| 97 | − .foregroundStyle(.secondary) | |
| 98 | − } | |
| 99 | − } | |
| 100 | − .padding(.horizontal, 16) | |
| 101 | − .padding(.vertical, 10) | |
| 102 | − .frame(minWidth: 300, maxWidth: 300, alignment: .leading) | |
| 103 | − } | |
| 104 | − | |
| 105 | − private struct Model { | |
| 106 | − let points: [Point] | |
| 107 | − let pointsByDateKey: [String: Point] | |
| 108 | − let entriesByDateKey: [String: CCUsageDailyReport.Entry] | |
| 109 | − let dateKeys: [(key: String, date: Date)] | |
| 110 | − let axisDates: [Date] | |
| 111 | − let barColor: Color | |
| 112 | − let peakKey: String? | |
| 113 | − } | |
| 114 | − | |
| 115 | − private static func makeModel(provider: UsageProvider, daily: [CCUsageDailyReport.Entry]) -> Model { | |
| 116 | − let sorted = daily.sorted { lhs, rhs in lhs.date < rhs.date } | |
| 117 | − var points: [Point] = [] | |
| 118 | − points.reserveCapacity(sorted.count) | |
| 119 | − | |
| 120 | − var pointsByKey: [String: Point] = [:] | |
| 121 | − pointsByKey.reserveCapacity(sorted.count) | |
| 122 | − | |
| 123 | − var entriesByKey: [String: CCUsageDailyReport.Entry] = [:] | |
| 124 | − entriesByKey.reserveCapacity(sorted.count) | |
| 125 | − | |
| 126 | − var dateKeys: [(key: String, date: Date)] = [] | |
| 127 | − dateKeys.reserveCapacity(sorted.count) | |
| 128 | − | |
| 129 | − var peak: (key: String, costUSD: Double)? | |
| 130 | − for entry in sorted { | |
| 131 | − guard let costUSD = entry.costUSD, costUSD > 0 else { continue } | |
| 132 | − guard let date = self.dateFromDayKey(entry.date) else { continue } | |
| 133 | − let point = Point(date: date, costUSD: costUSD, totalTokens: entry.totalTokens) | |
| 134 | − points.append(point) | |
| 135 | − pointsByKey[entry.date] = point | |
| 136 | − entriesByKey[entry.date] = entry | |
| 137 | − dateKeys.append((entry.date, date)) | |
| 138 | − if let cur = peak { | |
| 139 | − if costUSD > cur.costUSD { peak = (entry.date, costUSD) } | |
| 140 | − } else { | |
| 141 | − peak = (entry.date, costUSD) | |
| 142 | − } | |
| 143 | − } | |
| 144 | − | |
| 145 | − let axisDates: [Date] = { | |
| 146 | − guard let first = dateKeys.first?.date, let last = dateKeys.last?.date else { return [] } | |
| 147 | − if Calendar.current.isDate(first, inSameDayAs: last) { return [first] } | |
| 148 | − return [first, last] | |
| 149 | − }() | |
| 150 | − | |
| 151 | − let barColor = Self.barColor(for: provider) | |
| 152 | − return Model( | |
| 153 | − points: points, | |
| 154 | − pointsByDateKey: pointsByKey, | |
| 155 | − entriesByDateKey: entriesByKey, | |
| 156 | − dateKeys: dateKeys, | |
| 157 | − axisDates: axisDates, | |
| 158 | − barColor: barColor, | |
| 159 | − peakKey: peak?.key) | |
| 160 | − } | |
| 161 | − | |
| 162 | − private static func barColor(for provider: UsageProvider) -> Color { | |
| 163 | − switch provider { | |
| 164 | − case .codex: | |
| 165 | − Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) | |
| 166 | − case .claude: | |
| 167 | − Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255) | |
| 168 | − case .gemini: | |
| 169 | − Color(red: 171 / 255, green: 135 / 255, blue: 234 / 255) | |
| 170 | − } | |
| 171 | − } | |
| 172 | − | |
| 173 | − private static func dateFromDayKey(_ key: String) -> Date? { | |
| 174 | − let parts = key.split(separator: "-") | |
| 175 | − guard parts.count == 3, | |
| 176 | − let year = Int(parts[0]), | |
| 177 | − let month = Int(parts[1]), | |
| 178 | − let day = Int(parts[2]) else { return nil } | |
| 179 | − | |
| 180 | − var comps = DateComponents() | |
| 181 | − comps.calendar = Calendar.current | |
| 182 | − comps.timeZone = TimeZone.current | |
| 183 | − comps.year = year | |
| 184 | − comps.month = month | |
| 185 | − comps.day = day | |
| 186 | − comps.hour = 12 | |
| 187 | − return comps.date | |
| 188 | − } | |
| 189 | − | |
| 190 | − private static func peakPoint(model: Model) -> Point? { | |
| 191 | − guard let key = model.peakKey else { return nil } | |
| 192 | − return model.pointsByDateKey[key] | |
| 193 | − } | |
| 194 | − | |
| 195 | − private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { | |
| 196 | − guard let key = self.selectedDateKey else { return nil } | |
| 197 | − guard let plotAnchor = proxy.plotFrame else { return nil } | |
| 198 | − let plotFrame = geo[plotAnchor] | |
| 199 | − guard let index = model.dateKeys.firstIndex(where: { $0.key == key }) else { return nil } | |
| 200 | − let date = model.dateKeys[index].date | |
| 201 | − guard let x = proxy.position(forX: date) else { return nil } | |
| 202 | − | |
| 203 | − func xForIndex(_ idx: Int) -> CGFloat? { | |
| 204 | − guard idx >= 0, idx < model.dateKeys.count else { return nil } | |
| 205 | − return proxy.position(forX: model.dateKeys[idx].date) | |
| 206 | − } | |
| 207 | − | |
| 208 | − let xPrev = xForIndex(index - 1) | |
| 209 | − let xNext = xForIndex(index + 1) | |
| 210 | − | |
| 211 | − let leftInPlot: CGFloat = if let xPrev { | |
| 212 | − (xPrev + x) / 2 | |
| 213 | − } else if let xNext { | |
| 214 | − x - (xNext - x) / 2 | |
| 215 | − } else { | |
| 216 | − x - 8 | |
| 217 | − } | |
| 218 | − | |
| 219 | − let rightInPlot: CGFloat = if let xNext { | |
| 220 | − (xNext + x) / 2 | |
| 221 | − } else if let xPrev { | |
| 222 | − x + (x - xPrev) / 2 | |
| 223 | − } else { | |
| 224 | − x + 8 | |
| 225 | − } | |
| 226 | − | |
| 227 | − let left = plotFrame.origin.x + min(leftInPlot, rightInPlot) | |
| 228 | − let right = plotFrame.origin.x + max(leftInPlot, rightInPlot) | |
| 229 | − return CGRect(x: left, y: plotFrame.origin.y, width: right - left, height: plotFrame.height) | |
| 230 | − } | |
| 231 | − | |
| 232 | − private func updateSelection( | |
| 233 | − location: CGPoint?, | |
| 234 | − model: Model, | |
| 235 | − proxy: ChartProxy, | |
| 236 | − geo: GeometryProxy) | |
| 237 | − { | |
| 238 | − guard let location else { | |
| 239 | − if self.selectedDateKey != nil { self.selectedDateKey = nil } | |
| 240 | − return | |
| 241 | − } | |
| 242 | − | |
| 243 | − guard let plotAnchor = proxy.plotFrame else { return } | |
| 244 | − let plotFrame = geo[plotAnchor] | |
| 245 | − guard plotFrame.contains(location) else { return } | |
| 246 | − | |
| 247 | − let xInPlot = location.x - plotFrame.origin.x | |
| 248 | − guard let date: Date = proxy.value(atX: xInPlot) else { return } | |
| 249 | − guard let nearest = self.nearestDateKey(to: date, model: model) else { return } | |
| 250 | − | |
| 251 | − if self.selectedDateKey != nearest { | |
| 252 | − self.selectedDateKey = nearest | |
| 253 | − } | |
| 254 | − } | |
| 255 | − | |
| 256 | − private func nearestDateKey(to date: Date, model: Model) -> String? { | |
| 257 | − guard !model.dateKeys.isEmpty else { return nil } | |
| 258 | − var best: (key: String, distance: TimeInterval)? | |
| 259 | − for entry in model.dateKeys { | |
| 260 | − let dist = abs(entry.date.timeIntervalSince(date)) | |
| 261 | − if let cur = best { | |
| 262 | − if dist < cur.distance { best = (entry.key, dist) } | |
| 263 | − } else { | |
| 264 | − best = (entry.key, dist) | |
| 265 | − } | |
| 266 | − } | |
| 267 | − return best?.key | |
| 268 | − } | |
| 269 | − | |
| 270 | − private func detailText(model: Model) -> String { | |
| 271 | − guard let key = self.selectedDateKey, | |
| 272 | − let point = model.pointsByDateKey[key], | |
| 273 | − let date = Self.dateFromDayKey(key) | |
| 274 | − else { | |
| 275 | − return "Hover a bar for details" | |
| 276 | − } | |
| 277 | − | |
| 278 | − let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) | |
| 279 | − let cost = UsageFormatter.usdString(point.costUSD) | |
| 280 | − if let tokens = point.totalTokens { | |
| 281 | − let topModels = self.topModelsText(key: key, model: model) | |
| 282 | − if let topModels { | |
| 283 | − return "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens · \(topModels)" | |
| 284 | − } | |
| 285 | − return "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" | |
| 286 | − } | |
| 287 | − if let topModels = self.topModelsText(key: key, model: model) { | |
| 288 | − return "\(dayLabel): \(cost) · \(topModels)" | |
| 289 | − } | |
| 290 | − return "\(dayLabel): \(cost)" | |
| 291 | − } | |
| 292 | − | |
| 293 | − private func topModelsText(key: String, model: Model) -> String? { | |
| 294 | − guard let entry = model.entriesByDateKey[key] else { return nil } | |
| 295 | − guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return nil } | |
| 296 | − let parts = breakdown | |
| 297 | − .compactMap { item -> (name: String, costUSD: Double)? in | |
| 298 | − guard let costUSD = item.costUSD, costUSD > 0 else { return nil } | |
| 299 | − return (item.modelName, costUSD) | |
| 300 | − } | |
| 301 | − .sorted { lhs, rhs in | |
| 302 | − if lhs.costUSD == rhs.costUSD { return lhs.name < rhs.name } | |
| 303 | − return lhs.costUSD > rhs.costUSD | |
| 304 | − } | |
| 305 | − .prefix(3) | |
| 306 | − .map { "\($0.name) \(UsageFormatter.usdString($0.costUSD))" } | |
| 307 | − guard !parts.isEmpty else { return nil } | |
| 308 | − return "Top: \(parts.joined(separator: " · "))" | |
| 309 | − } | |
| 310 | −} | |
| @@ -41,13 +41,6 @@ struct UsageMenuCardView: View { · 40 unmodified lines | ||
| 41 | 41 | case error |
| 42 | 42 | } |
| 43 | 43 | |
| 44 | − struct TokenUsageSection: Sendable { | |
| 45 | − let sessionLine: String | |
| 46 | − let monthLine: String | |
| 47 | − let hintLine: String? | |
| 48 | − let errorLine: String? | |
| 49 | − } | |
| 50 | − | |
| 51 | 44 | let providerName: String |
| 52 | 45 | let email: String |
| 53 | 46 | let subtitleText: String |
| @@ -56,7 +49,6 @@ struct UsageMenuCardView: View { · 2 unmodified lines | ||
| 56 | 49 | let metrics: [Metric] |
| 57 | 50 | let creditsText: String? |
| 58 | 51 | let creditsHintText: String? |
| 59 | − let tokenUsage: TokenUsageSection? | |
| 60 | 52 | let placeholder: String? |
| 61 | 53 | let progressColor: Color |
| 62 | 54 | } |
| @@ -141,34 +133,6 @@ struct UsageMenuCardView: View { · 78 unmodified lines | ||
| 141 | 133 | } |
| 142 | 134 | } |
| 143 | 135 | } |
| 144 | − if self.model.creditsText != nil, self.model.tokenUsage != nil { | |
| 145 | − Divider() | |
| 146 | − } | |
| 147 | − if let tokenUsage = self.model.tokenUsage { | |
| 148 | − VStack(alignment: .leading, spacing: 4) { | |
| 149 | − Text("Cost") | |
| 150 | − .font(.subheadline) | |
| 151 | − .fontWeight(.medium) | |
| 152 | − Text(tokenUsage.sessionLine) | |
| 153 | − .font(.footnote) | |
| 154 | − Text(tokenUsage.monthLine) | |
| 155 | − .font(.footnote) | |
| 156 | − if let hint = tokenUsage.hintLine, !hint.isEmpty { | |
| 157 | − Text(hint) | |
| 158 | − .font(.footnote) | |
| 159 | − .foregroundStyle(.secondary) | |
| 160 | − .lineLimit(2) | |
| 161 | − .fixedSize(horizontal: false, vertical: true) | |
| 162 | − } | |
| 163 | − if let error = tokenUsage.errorLine, !error.isEmpty { | |
| 164 | − Text(error) | |
| 165 | − .font(.footnote) | |
| 166 | − .foregroundStyle(Color(nsColor: .systemRed)) | |
| 167 | − .lineLimit(2) | |
| 168 | − .fixedSize(horizontal: false, vertical: true) | |
| 169 | − } | |
| 170 | − } | |
| 171 | − } | |
| 172 | 136 | } |
| 173 | 137 | .padding(.bottom, self.model.creditsText == nil ? 8 : 2) |
| 174 | 138 | } |
| @@ -180,7 +144,7 @@ struct UsageMenuCardView: View { · 5 unmodified lines | ||
| 180 | 144 | } |
| 181 | 145 | |
| 182 | 146 | private var hasDetails: Bool { |
| 183 | − !self.model.metrics.isEmpty || self.model.placeholder != nil || self.model.tokenUsage != nil | |
| 147 | + !self.model.metrics.isEmpty || self.model.placeholder != nil | |
| 184 | 148 | } |
| 185 | 149 | |
| 186 | 150 | private var subtitleColor: Color { |
| @@ -203,13 +167,10 @@ extension UsageMenuCardView.Model { · 16 unmodified lines | ||
| 203 | 167 | let creditsError: String? |
| 204 | 168 | let dashboard: OpenAIDashboardSnapshot? |
| 205 | 169 | let dashboardError: String? |
| 206 | − let tokenSnapshot: CCUsageTokenSnapshot? | |
| 207 | − let tokenError: String? | |
| 208 | 170 | let account: AccountInfo |
| 209 | 171 | let isRefreshing: Bool |
| 210 | 172 | let lastError: String? |
| 211 | 173 | let usageBarsShowUsed: Bool |
| 212 | − let tokenCostUsageEnabled: Bool | |
| 213 | 174 | } |
| 214 | 175 | |
| 215 | 176 | static func make(_ input: Input) -> UsageMenuCardView.Model { |
| @@ -226,11 +187,6 @@ extension UsageMenuCardView.Model { · 10 unmodified lines | ||
| 226 | 187 | usageBarsShowUsed: input.usageBarsShowUsed) |
| 227 | 188 | let creditsText = Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) |
| 228 | 189 | let creditsHintText = Self.dashboardHint(provider: input.provider, error: input.dashboardError) |
| 229 | − let tokenUsage = Self.tokenUsageSection( | |
| 230 | − provider: input.provider, | |
| 231 | − enabled: input.tokenCostUsageEnabled, | |
| 232 | − snapshot: input.tokenSnapshot, | |
| 233 | − error: input.tokenError) | |
| 234 | 190 | let subtitle = Self.subtitle( |
| 235 | 191 | snapshot: input.snapshot, |
| 236 | 192 | isRefreshing: input.isRefreshing, |
| @@ -246,7 +202,6 @@ extension UsageMenuCardView.Model { · 9 unmodified lines | ||
| 246 | 202 | metrics: metrics, |
| 247 | 203 | creditsText: creditsText, |
| 248 | 204 | creditsHintText: creditsHintText, |
| 249 | − tokenUsage: tokenUsage, | |
| 250 | 205 | placeholder: placeholder, |
| 251 | 206 | progressColor: Self.progressColor(for: input.provider)) |
| 252 | 207 | } |
| @@ -368,34 +323,6 @@ extension UsageMenuCardView.Model { · 115 unmodified lines | ||
| 368 | 323 | return UsageFormatter.truncatedSingleLine(error, max: 100) |
| 369 | 324 | } |
| 370 | 325 | |
| 371 | − private static func tokenUsageSection( | |
| 372 | − provider: UsageProvider, | |
| 373 | − enabled: Bool, | |
| 374 | − snapshot: CCUsageTokenSnapshot?, | |
| 375 | − error: String?) -> TokenUsageSection? | |
| 376 | − { | |
| 377 | − guard provider == .codex || provider == .claude else { return nil } | |
| 378 | − guard enabled else { return nil } | |
| 379 | − guard let snapshot else { return nil } | |
| 380 | − | |
| 381 | − let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" | |
| 382 | − let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } | |
| 383 | − let sessionLine: String = { | |
| 384 | − if let sessionTokens { | |
| 385 | − return "Session: \(sessionCost) · \(sessionTokens) tokens" | |
| 386 | − } | |
| 387 | − return "Session: \(sessionCost)" | |
| 388 | − }() | |
| 389 | − | |
| 390 | − let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" | |
| 391 | − let err = (error?.isEmpty ?? true) ? nil : UsageFormatter.truncatedSingleLine(error!, max: 120) | |
| 392 | − return TokenUsageSection( | |
| 393 | − sessionLine: sessionLine, | |
| 394 | − monthLine: "Last 30 days: \(monthCost)", | |
| 395 | − hintLine: nil, | |
| 396 | − errorLine: err) | |
| 397 | − } | |
| 398 | − | |
| 399 | 326 | private static func clamped(_ value: Double) -> Double { |
| 400 | 327 | min(100, max(0, value)) |
| 401 | 328 | } |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import AppKit |
| 2 | −import CodexBarCore | |
| 3 | 2 | import SwiftUI |
| 4 | 3 | |
| 5 | 4 | @MainActor |
| @@ -10,11 +9,6 @@ struct AdvancedPane: View { · 4 unmodified lines | ||
| 10 | 9 | @State private var cliStatus: String? |
| 11 | 10 | |
| 12 | 11 | var body: some View { |
| 13 | − let ccusageAvailability = self.settings.ccusageAvailability | |
| 14 | − let ccusageBinding = Binding( | |
| 15 | − get: { ccusageAvailability.isAnyInstalled ? self.settings.ccusageCostUsageEnabled : false }, | |
| 16 | − set: { self.settings.ccusageCostUsageEnabled = $0 }) | |
| 17 | − | |
| 18 | 12 | ScrollView(.vertical, showsIndicators: true) { |
| 19 | 13 | VStack(alignment: .leading, spacing: 16) { |
| 20 | 14 | SettingsSection(contentSpacing: 6) { |
| @@ -58,42 +52,6 @@ struct AdvancedPane: View { · 37 unmodified lines | ||
| 58 | 52 | |
| 59 | 53 | Divider() |
| 60 | 54 | |
| 61 | − SettingsSection(contentSpacing: 12) { | |
| 62 | − Text("Usage") | |
| 63 | − .font(.caption) | |
| 64 | − .foregroundStyle(.secondary) | |
| 65 | − .textCase(.uppercase) | |
| 66 | − PreferenceToggleRow( | |
| 67 | − title: "Show ccusage cost summary", | |
| 68 | − subtitle: "Requires ccusage. Shows session + last 30 days cost in the menu.", | |
| 69 | − binding: ccusageBinding) | |
| 70 | − .disabled(!ccusageAvailability.isAnyInstalled) | |
| 71 | − if ccusageAvailability.isAnyInstalled { | |
| 72 | − if self.settings.ccusageCostUsageEnabled { | |
| 73 | − Text("Auto-refresh: hourly · Timeout: 10m") | |
| 74 | − .font(.footnote) | |
| 75 | − .foregroundStyle(.tertiary) | |
| 76 | − | |
| 77 | − self.ccusageStatusLine( | |
| 78 | − provider: .claude, | |
| 79 | − isInstalled: ccusageAvailability.claudePath != nil) | |
| 80 | − self.ccusageStatusLine(provider: .codex, isInstalled: ccusageAvailability.codexPath != nil) | |
| 81 | − } | |
| 82 | − } else { | |
| 83 | − Text("ccusage not detected.") | |
| 84 | − .font(.footnote) | |
| 85 | − .foregroundStyle(.tertiary) | |
| 86 | − Text("Install Claude: npm i -g ccusage") | |
| 87 | − .font(.footnote) | |
| 88 | − .foregroundStyle(.tertiary) | |
| 89 | − Text("Install Codex: npm i -g @ccusage/codex") | |
| 90 | − .font(.footnote) | |
| 91 | − .foregroundStyle(.tertiary) | |
| 92 | − } | |
| 93 | − } | |
| 94 | − | |
| 95 | − Divider() | |
| 96 | − | |
| 97 | 55 | SettingsSection(contentSpacing: 12) { |
| 98 | 56 | Text("Status") |
| 99 | 57 | .font(.caption) |
| @@ -145,58 +103,6 @@ struct AdvancedPane: View { · 45 unmodified lines | ||
| 145 | 103 | .padding(.horizontal, 20) |
| 146 | 104 | .padding(.vertical, 12) |
| 147 | 105 | } |
| 148 | − .onAppear { self.settings.refreshCCUsageAvailability() } | |
| 149 | − } | |
| 150 | − | |
| 151 | − private func ccusageStatusLine(provider: UsageProvider, isInstalled: Bool) -> some View { | |
| 152 | − let (name, binary) = switch provider { | |
| 153 | − case .claude: | |
| 154 | − ("Claude", "ccusage") | |
| 155 | − case .codex: | |
| 156 | − ("Codex", "ccusage-codex") | |
| 157 | − case .gemini: | |
| 158 | − ("Gemini", "ccusage") | |
| 159 | − } | |
| 160 | − guard provider == .claude || provider == .codex else { | |
| 161 | − return Text("\(name): unsupported") | |
| 162 | − .font(.footnote) | |
| 163 | − .foregroundStyle(.tertiary) | |
| 164 | − } | |
| 165 | − | |
| 166 | − if !isInstalled { | |
| 167 | − return Text("\(name): not detected (\(binary))") | |
| 168 | − .font(.footnote) | |
| 169 | − .foregroundStyle(.tertiary) | |
| 170 | − } | |
| 171 | − if self.store.isTokenRefreshInFlight(for: provider) { | |
| 172 | − return Text("\(name): detected · fetching…") | |
| 173 | − .font(.footnote) | |
| 174 | − .foregroundStyle(.tertiary) | |
| 175 | − } | |
| 176 | − if let snapshot = self.store.tokenSnapshot(for: provider) { | |
| 177 | − let updated = UsageFormatter.updatedString(from: snapshot.updatedAt) | |
| 178 | − let cost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" | |
| 179 | − return Text("\(name): detected · \(updated) · 30d \(cost)") | |
| 180 | − .font(.footnote) | |
| 181 | − .foregroundStyle(.tertiary) | |
| 182 | − } | |
| 183 | − if let error = self.store.tokenError(for: provider), !error.isEmpty { | |
| 184 | − let truncated = UsageFormatter.truncatedSingleLine(error, max: 120) | |
| 185 | − return Text("\(name): detected · \(truncated)") | |
| 186 | − .font(.footnote) | |
| 187 | − .foregroundStyle(.tertiary) | |
| 188 | − } | |
| 189 | − if let lastAttempt = self.store.tokenLastAttemptAt(for: provider) { | |
| 190 | − let rel = RelativeDateTimeFormatter() | |
| 191 | − rel.unitsStyle = .abbreviated | |
| 192 | − let when = rel.localizedString(for: lastAttempt, relativeTo: Date()) | |
| 193 | − return Text("\(name): detected · last attempt \(when)") | |
| 194 | − .font(.footnote) | |
| 195 | − .foregroundStyle(.tertiary) | |
| 196 | − } | |
| 197 | − return Text("\(name): detected · no data yet") | |
| 198 | − .font(.footnote) | |
| 199 | − .foregroundStyle(.tertiary) | |
| 200 | 106 | } |
| 201 | 107 | |
| 202 | 108 | // MARK: - CLI installer |
| @@ -63,13 +63,6 @@ final class SettingsStore: ObservableObject { · 62 unmodified lines | ||
| 63 | 63 | didSet { self.objectWillChange.send() } |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | − /// Optional: show provider cost summary from ccusage CLIs (offline). | |
| 67 | − @AppStorage("tokenCostUsageEnabled") var ccusageCostUsageEnabled: Bool = false { | |
| 68 | − didSet { self.objectWillChange.send() } | |
| 69 | − } | |
| 70 | − | |
| 71 | − @Published private(set) var ccusageAvailability: CCUsageAvailability = .init(claudePath: nil, codexPath: nil) | |
| 72 | − | |
| 73 | 66 | @AppStorage("randomBlinkEnabled") var randomBlinkEnabled: Bool = false { |
| 74 | 67 | didSet { self.objectWillChange.send() } |
| 75 | 68 | } |
| @@ -108,13 +101,6 @@ final class SettingsStore: ObservableObject { · 32 unmodified lines | ||
| 108 | 101 | private let userDefaults: UserDefaults |
| 109 | 102 | private let toggleStore: ProviderToggleStore |
| 110 | 103 | |
| 111 | − struct CCUsageAvailability: Sendable, Equatable { | |
| 112 | − let claudePath: String? | |
| 113 | − let codexPath: String? | |
| 114 | − | |
| 115 | − var isAnyInstalled: Bool { self.claudePath != nil || self.codexPath != nil } | |
| 116 | − } | |
| 117 | − | |
| 118 | 104 | init(userDefaults: UserDefaults = .standard) { |
| 119 | 105 | self.userDefaults = userDefaults |
| 120 | 106 | if userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") == nil { |
| @@ -126,7 +112,6 @@ final class SettingsStore: ObservableObject { · 5 unmodified lines | ||
| 126 | 112 | self.toggleStore.purgeLegacyKeys() |
| 127 | 113 | LaunchAtLoginManager.setEnabled(self.launchAtLogin) |
| 128 | 114 | self.runInitialProviderDetectionIfNeeded() |
| 129 | − self.refreshCCUsageAvailability() | |
| 130 | 115 | } |
| 131 | 116 | |
| 132 | 117 | func isProviderEnabled(provider: UsageProvider, metadata: ProviderMetadata) -> Bool { |
| @@ -144,74 +129,6 @@ final class SettingsStore: ObservableObject { · 11 unmodified lines | ||
| 144 | 129 | |
| 145 | 130 | // MARK: - Private |
| 146 | 131 | |
| 147 | − nonisolated static func ccusageAvailability( | |
| 148 | − env: [String: String] = ProcessInfo.processInfo.environment, | |
| 149 | − loginPATH: [String]? = LoginShellPathCache.shared.current, | |
| 150 | − commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, | |
| 151 | − fileManager: FileManager = .default) -> CCUsageAvailability | |
| 152 | − { | |
| 153 | − func find(_ binary: String, in paths: [String]) -> String? { | |
| 154 | − for path in paths where !path.isEmpty { | |
| 155 | − let candidate = "\(path.hasSuffix("/") ? String(path.dropLast()) : path)/\(binary)" | |
| 156 | − if fileManager.isExecutableFile(atPath: candidate) { return candidate } | |
| 157 | − } | |
| 158 | − return nil | |
| 159 | − } | |
| 160 | − | |
| 161 | − func resolve(_ binary: String, hardcoded: [String]) -> String? { | |
| 162 | − // 1) Login-shell PATH (captured once per launch) | |
| 163 | − if let loginPATH, | |
| 164 | − let pathHit = find(binary, in: loginPATH) | |
| 165 | − { | |
| 166 | − return pathHit | |
| 167 | − } | |
| 168 | − | |
| 169 | − // 2) Existing PATH | |
| 170 | − if let existingPATH = env["PATH"]?.split(separator: ":").map(String.init), | |
| 171 | − let pathHit = find(binary, in: existingPATH) | |
| 172 | − { | |
| 173 | − return pathHit | |
| 174 | − } | |
| 175 | − | |
| 176 | − // 3) Interactive login shell lookup (captures nvm/fnm/mise paths from .zshrc/.bashrc) | |
| 177 | − if let shellHit = commandV(binary, env["SHELL"], 2.0, fileManager), | |
| 178 | − fileManager.isExecutableFile(atPath: shellHit) | |
| 179 | − { | |
| 180 | − return shellHit | |
| 181 | − } | |
| 182 | − | |
| 183 | − // 4) Hardcoded locations | |
| 184 | − for candidate in hardcoded where fileManager.isExecutableFile(atPath: candidate) { | |
| 185 | − return candidate | |
| 186 | − } | |
| 187 | − | |
| 188 | − return nil | |
| 189 | − } | |
| 190 | − | |
| 191 | − let claudePath = resolve( | |
| 192 | − "ccusage", | |
| 193 | − hardcoded: ["/opt/homebrew/bin/ccusage", "/usr/local/bin/ccusage"]) | |
| 194 | − let codexPath = resolve( | |
| 195 | − "ccusage-codex", | |
| 196 | − hardcoded: ["/opt/homebrew/bin/ccusage-codex", "/usr/local/bin/ccusage-codex"]) | |
| 197 | − return CCUsageAvailability(claudePath: claudePath, codexPath: codexPath) | |
| 198 | − } | |
| 199 | − | |
| 200 | − func isCCUsageInstalled(for provider: UsageProvider) -> Bool { | |
| 201 | − switch provider { | |
| 202 | − case .claude: | |
| 203 | − self.ccusageAvailability.claudePath != nil | |
| 204 | − case .codex: | |
| 205 | − self.ccusageAvailability.codexPath != nil | |
| 206 | − case .gemini: | |
| 207 | − false | |
| 208 | − } | |
| 209 | − } | |
| 210 | − | |
| 211 | − func isCCUsageCostUsageEffectivelyEnabled(for provider: UsageProvider) -> Bool { | |
| 212 | − self.ccusageCostUsageEnabled && self.isCCUsageInstalled(for: provider) | |
| 213 | − } | |
| 214 | − | |
| 215 | 132 | private func runInitialProviderDetectionIfNeeded(force: Bool = false) { |
| 216 | 133 | guard force || !self.providerDetectionCompleted else { return } |
| 217 | 134 | guard let codexMeta = ProviderRegistry.shared.metadata[.codex], |
| @@ -247,29 +164,6 @@ final class SettingsStore: ObservableObject { · 29 unmodified lines | ||
| 247 | 164 | self.toggleStore.setEnabled(enableGemini, metadata: geminiMeta) |
| 248 | 165 | self.providerDetectionCompleted = true |
| 249 | 166 | } |
| 250 | − | |
| 251 | − func refreshCCUsageAvailability() { | |
| 252 | − LoginShellPathCache.shared.captureOnce { [weak self] _ in | |
| 253 | − Task { @MainActor [weak self] in | |
| 254 | − guard let self else { return } | |
| 255 | − let availability = await Task.detached(priority: .utility) { | |
| 256 | − Self.ccusageAvailability() | |
| 257 | − }.value | |
| 258 | − self.ccusageAvailability = availability | |
| 259 | − self.applyCCUsageDefaultIfNeeded(availability: availability) | |
| 260 | − } | |
| 261 | − } | |
| 262 | − } | |
| 263 | − | |
| 264 | − private func applyCCUsageDefaultIfNeeded(availability: CCUsageAvailability) { | |
| 265 | − // @AppStorage always reads/writes UserDefaults.standard. | |
| 266 | − let defaults = UserDefaults.standard | |
| 267 | − guard defaults.object(forKey: "tokenCostUsageEnabled") == nil else { return } | |
| 268 | − guard availability.isAnyInstalled else { return } | |
| 269 | − let enabledByDefault = availability.isAnyInstalled | |
| 270 | − defaults.set(enabledByDefault, forKey: "tokenCostUsageEnabled") | |
| 271 | − self.ccusageCostUsageEnabled = enabledByDefault | |
| 272 | − } | |
| 273 | 167 | } |
| 274 | 168 | |
| 275 | 169 | enum LaunchAtLoginManager { |
| @@ -6,7 +6,7 @@ extension StatusItemController { · 5 unmodified lines | ||
| 6 | 6 | // MARK: - Actions reachable from menus |
| 7 | 7 | |
| 8 | 8 | @objc func refreshNow() { |
| 9 | − Task { await self.store.refresh(forceTokenUsage: true) } | |
| 9 | + Task { await self.store.refresh() } | |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | 12 | @objc func openDashboard() { |
| @@ -50,10 +50,7 @@ extension StatusItemController { · 49 unmodified lines | ||
| 50 | 50 | dashboard != nil |
| 51 | 51 | let hasCreditsHistory = openAIWebEligible && !(dashboard?.creditEvents ?? []).isEmpty |
| 52 | 52 | let hasUsageBreakdown = openAIWebEligible && !(dashboard?.usageBreakdown ?? []).isEmpty |
| 53 | − let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex | |
| 54 | − let hasCostHistory = self.settings.isCCUsageCostUsageEffectivelyEnabled(for: currentProvider) && | |
| 55 | − (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) | |
| 56 | − let hasOpenAIWebMenuItems = hasCreditsHistory || hasUsageBreakdown || hasCostHistory | |
| 53 | + let hasOpenAIWebMenuItems = hasCreditsHistory || hasUsageBreakdown | |
| 57 | 54 | |
| 58 | 55 | if enabledProviders.count > 1 { |
| 59 | 56 | let switcherItem = self.makeProviderSwitcherItem( |
| @@ -82,16 +79,13 @@ extension StatusItemController { · 22 unmodified lines | ||
| 82 | 79 | } |
| 83 | 80 | |
| 84 | 81 | if hasOpenAIWebMenuItems { |
| 85 | − // Only show these when we actually have additional data. | |
| 82 | + // Only show these when we actually have OpenAI web-only data. | |
| 86 | 83 | if hasCreditsHistory { |
| 87 | 84 | _ = self.addCreditsHistorySubmenu(to: menu) |
| 88 | 85 | } |
| 89 | 86 | if hasUsageBreakdown { |
| 90 | 87 | _ = self.addUsageBreakdownSubmenu(to: menu) |
| 91 | 88 | } |
| 92 | − if hasCostHistory { | |
| 93 | − _ = self.addCostHistorySubmenu(to: menu, provider: currentProvider) | |
| 94 | − } | |
| 95 | 89 | menu.addItem(.separator()) |
| 96 | 90 | } |
| 97 | 91 | |
| @@ -381,36 +375,6 @@ extension StatusItemController { · 283 unmodified lines | ||
| 381 | 375 | return true |
| 382 | 376 | } |
| 383 | 377 | |
| 384 | − @discardableResult | |
| 385 | − private func addCostHistorySubmenu(to menu: NSMenu, provider: UsageProvider) -> Bool { | |
| 386 | − guard provider == .codex || provider == .claude else { return false } | |
| 387 | − guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return false } | |
| 388 | − guard !tokenSnapshot.daily.isEmpty else { return false } | |
| 389 | − | |
| 390 | − let item = NSMenuItem(title: "Cost history (30 days)", action: nil, keyEquivalent: "") | |
| 391 | − item.isEnabled = true | |
| 392 | − let submenu = NSMenu() | |
| 393 | − let chartView = CCUsageCostChartMenuView( | |
| 394 | − provider: provider, | |
| 395 | − daily: tokenSnapshot.daily, | |
| 396 | − totalCostUSD: tokenSnapshot.last30DaysCostUSD) | |
| 397 | − let hosting = NSHostingView(rootView: chartView) | |
| 398 | − hosting.frame = NSRect(origin: .zero, size: NSSize(width: Self.menuCardWidth, height: 1)) | |
| 399 | − hosting.layoutSubtreeIfNeeded() | |
| 400 | − let size = hosting.fittingSize | |
| 401 | − hosting.frame = NSRect(origin: .zero, size: NSSize(width: Self.menuCardWidth, height: size.height)) | |
| 402 | − | |
| 403 | − let chartItem = NSMenuItem() | |
| 404 | − chartItem.view = hosting | |
| 405 | − chartItem.isEnabled = false | |
| 406 | − chartItem.representedObject = "ccusageCostHistoryChart" | |
| 407 | − submenu.addItem(chartItem) | |
| 408 | − | |
| 409 | − item.submenu = submenu | |
| 410 | − menu.addItem(item) | |
| 411 | − return true | |
| 412 | − } | |
| 413 | − | |
| 414 | 378 | private func menuCardModel(for provider: UsageProvider?) -> UsageMenuCardView.Model? { |
| 415 | 379 | let target = provider ?? self.store.enabledProviders().first ?? .codex |
| 416 | 380 | let metadata = self.store.metadata(for: target) |
| @@ -420,29 +384,16 @@ extension StatusItemController { · 3 unmodified lines | ||
| 420 | 384 | let creditsError: String? |
| 421 | 385 | let dashboard: OpenAIDashboardSnapshot? |
| 422 | 386 | let dashboardError: String? |
| 423 | − let tokenSnapshot: CCUsageTokenSnapshot? | |
| 424 | − let tokenError: String? | |
| 425 | 387 | if target == .codex { |
| 426 | 388 | credits = self.store.credits |
| 427 | 389 | creditsError = self.store.lastCreditsError |
| 428 | 390 | dashboard = self.store.openAIDashboardRequiresLogin ? nil : self.store.openAIDashboard |
| 429 | 391 | dashboardError = self.store.lastOpenAIDashboardError |
| 430 | − tokenSnapshot = self.store.tokenSnapshot(for: target) | |
| 431 | − tokenError = self.store.tokenError(for: target) | |
| 432 | − } else if target == .claude { | |
| 433 | − credits = nil | |
| 434 | − creditsError = nil | |
| 435 | − dashboard = nil | |
| 436 | − dashboardError = nil | |
| 437 | − tokenSnapshot = self.store.tokenSnapshot(for: target) | |
| 438 | − tokenError = self.store.tokenError(for: target) | |
| 439 | 392 | } else { |
| 440 | 393 | credits = nil |
| 441 | 394 | creditsError = nil |
| 442 | 395 | dashboard = nil |
| 443 | 396 | dashboardError = nil |
| 444 | − tokenSnapshot = nil | |
| 445 | − tokenError = nil | |
| 446 | 397 | } |
| 447 | 398 | |
| 448 | 399 | let input = UsageMenuCardView.Model.Input( |
| @@ -453,13 +404,10 @@ extension StatusItemController { · 4 unmodified lines | ||
| 453 | 404 | creditsError: creditsError, |
| 454 | 405 | dashboard: dashboard, |
| 455 | 406 | dashboardError: dashboardError, |
| 456 | − tokenSnapshot: tokenSnapshot, | |
| 457 | − tokenError: tokenError, | |
| 458 | 407 | account: self.account, |
| 459 | 408 | isRefreshing: self.store.isRefreshing, |
| 460 | 409 | lastError: self.store.error(for: target), |
| 461 | − usageBarsShowUsed: self.settings.usageBarsShowUsed, | |
| 462 | − tokenCostUsageEnabled: self.settings.isCCUsageCostUsageEffectivelyEnabled(for: target)) | |
| 410 | + usageBarsShowUsed: self.settings.usageBarsShowUsed) | |
| 463 | 411 | return UsageMenuCardView.Model.make(input) |
| 464 | 412 | } |
| 465 | 413 | } |
| @@ -495,7 +443,15 @@ private final class ProviderSwitcherView: NSView { · 29 unmodified lines | ||
| 495 | 443 | self.onSelect = onSelect |
| 496 | 444 | super.init(frame: NSRect(x: 0, y: 0, width: width, height: 30)) |
| 497 | 445 | |
| 498 | − func makeButton(index: Int, segment: Segment) -> NSButton { | |
| 446 | + let stack = NSStackView() | |
| 447 | + stack.orientation = .horizontal | |
| 448 | + stack.spacing = 6 | |
| 449 | + stack.alignment = .centerY | |
| 450 | + if #available(macOS 11, *) { | |
| 451 | + stack.edgeInsets = NSEdgeInsets(top: 2, left: 6, bottom: 2, right: 6) | |
| 452 | + } | |
| 453 | + | |
| 454 | + for (index, segment) in self.segments.enumerated() { | |
| 499 | 455 | let button = PaddedToggleButton( |
| 500 | 456 | title: segment.title, |
| 501 | 457 | target: self, |
| @@ -513,67 +469,17 @@ private final class ProviderSwitcherView: NSView { · 11 unmodified lines | ||
| 513 | 469 | button.wantsLayer = true |
| 514 | 470 | button.layer?.cornerRadius = 6 |
| 515 | 471 | button.state = (selected == segment.provider) ? .on : .off |
| 516 | − button.translatesAutoresizingMaskIntoConstraints = false | |
| 472 | + stack.addArrangedSubview(button) | |
| 517 | 473 | self.buttons.append(button) |
| 518 | − return button | |
| 519 | − } | |
| 520 | − | |
| 521 | − for (index, segment) in self.segments.enumerated() { | |
| 522 | − let button = makeButton(index: index, segment: segment) | |
| 523 | − self.addSubview(button) | |
| 524 | − } | |
| 525 | − | |
| 526 | − // Keep segment widths stable across selected/unselected to avoid shifting. | |
| 527 | − for button in self.buttons { | |
| 528 | − let width = ceil(Self.maxToggleWidth(for: button)) | |
| 529 | − if width > 0 { | |
| 530 | − button.widthAnchor.constraint(equalToConstant: width).isActive = true | |
| 531 | − } | |
| 532 | 474 | } |
| 533 | 475 | |
| 534 | − let outerPadding: CGFloat = 14 | |
| 535 | − let minimumGap: CGFloat = 8 | |
| 536 | − | |
| 537 | − if self.buttons.count == 2 { | |
| 538 | − let left = self.buttons[0] | |
| 539 | − let right = self.buttons[1] | |
| 540 | − let gap = right.leadingAnchor.constraint(greaterThanOrEqualTo: left.trailingAnchor, constant: minimumGap) | |
| 541 | − gap.priority = .defaultHigh | |
| 542 | − NSLayoutConstraint.activate([ | |
| 543 | − left.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: outerPadding), | |
| 544 | − left.centerYAnchor.constraint(equalTo: self.centerYAnchor), | |
| 545 | − right.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -outerPadding), | |
| 546 | − right.centerYAnchor.constraint(equalTo: self.centerYAnchor), | |
| 547 | − gap, | |
| 548 | − ]) | |
| 549 | − } else if self.buttons.count == 3 { | |
| 550 | − let left = self.buttons[0] | |
| 551 | − let mid = self.buttons[1] | |
| 552 | − let right = self.buttons[2] | |
| 553 | − | |
| 554 | − let leftGap = mid.leadingAnchor.constraint(greaterThanOrEqualTo: left.trailingAnchor, constant: minimumGap) | |
| 555 | − leftGap.priority = .defaultHigh | |
| 556 | − let rightGap = right.leadingAnchor.constraint( | |
| 557 | − greaterThanOrEqualTo: mid.trailingAnchor, | |
| 558 | − constant: minimumGap) | |
| 559 | − rightGap.priority = .defaultHigh | |
| 560 | − | |
| 561 | − NSLayoutConstraint.activate([ | |
| 562 | − left.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: outerPadding), | |
| 563 | − left.centerYAnchor.constraint(equalTo: self.centerYAnchor), | |
| 564 | − mid.centerXAnchor.constraint(equalTo: self.centerXAnchor), | |
| 565 | − mid.centerYAnchor.constraint(equalTo: self.centerYAnchor), | |
| 566 | − right.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -outerPadding), | |
| 567 | − right.centerYAnchor.constraint(equalTo: self.centerYAnchor), | |
| 568 | − leftGap, | |
| 569 | − rightGap, | |
| 570 | − ]) | |
| 571 | − } else if let first = self.buttons.first { | |
| 572 | − NSLayoutConstraint.activate([ | |
| 573 | − first.centerXAnchor.constraint(equalTo: self.centerXAnchor), | |
| 574 | − first.centerYAnchor.constraint(equalTo: self.centerYAnchor), | |
| 575 | − ]) | |
| 576 | − } | |
| 476 | + self.addSubview(stack) | |
| 477 | + stack.translatesAutoresizingMaskIntoConstraints = false | |
| 478 | + NSLayoutConstraint.activate([ | |
| 479 | + stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8), | |
| 480 | + stack.centerYAnchor.constraint(equalTo: self.centerYAnchor), | |
| 481 | + stack.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -8), | |
| 482 | + ]) | |
| 577 | 483 | |
| 578 | 484 | self.updateButtonStyles() |
| 579 | 485 | } |
| @@ -601,21 +507,6 @@ private final class ProviderSwitcherView: NSView { · 21 unmodified lines | ||
| 601 | 507 | } |
| 602 | 508 | } |
| 603 | 509 | |
| 604 | − private static func maxToggleWidth(for button: NSButton) -> CGFloat { | |
| 605 | − let originalState = button.state | |
| 606 | − defer { button.state = originalState } | |
| 607 | − | |
| 608 | − button.state = .off | |
| 609 | − button.layoutSubtreeIfNeeded() | |
| 610 | − let offWidth = button.fittingSize.width | |
| 611 | − | |
| 612 | − button.state = .on | |
| 613 | − button.layoutSubtreeIfNeeded() | |
| 614 | − let onWidth = button.fittingSize.width | |
| 615 | − | |
| 616 | − return max(offWidth, onWidth) | |
| 617 | − } | |
| 618 | − | |
| 619 | 510 | private static func paddedImage(_ image: NSImage, leading: CGFloat) -> NSImage { |
| 620 | 511 | let size = NSSize(width: image.size.width + leading, height: image.size.height) |
| 621 | 512 | let newImage = NSImage(size: size) |
| @@ -33,20 +33,11 @@ struct UsageBreakdownChartMenuView: View { · 32 unmodified lines | ||
| 33 | 33 | .font(.footnote) |
| 34 | 34 | .foregroundStyle(.secondary) |
| 35 | 35 | } else { |
| 36 | − Chart { | |
| 37 | − ForEach(model.points) { point in | |
| 38 | − BarMark( | |
| 39 | − x: .value("Day", point.date, unit: .day), | |
| 40 | − y: .value("Credits used", point.creditsUsed)) | |
| 41 | − .foregroundStyle(by: .value("Service", point.service)) | |
| 42 | − } | |
| 43 | − if let peak = model.peakPoint { | |
| 44 | − PointMark( | |
| 45 | − x: .value("Day", peak.date, unit: .day), | |
| 46 | − y: .value("Credits used", peak.creditsUsed)) | |
| 47 | − .symbolSize(26) | |
| 48 | − .foregroundStyle(Color(nsColor: .systemYellow)) | |
| 49 | − } | |
| 36 | + Chart(model.points) { point in | |
| 37 | + BarMark( | |
| 38 | + x: .value("Day", point.date, unit: .day), | |
| 39 | + y: .value("Credits used", point.creditsUsed)) | |
| 40 | + .foregroundStyle(by: .value("Service", point.service)) | |
| 50 | 41 | } |
| 51 | 42 | .chartForegroundStyleScale(domain: model.services, range: model.serviceColors) |
| 52 | 43 | .chartYAxis(.hidden) |
| @@ -55,28 +46,18 @@ struct UsageBreakdownChartMenuView: View { · 2 unmodified lines | ||
| 55 | 46 | AxisGridLine().foregroundStyle(Color.clear) |
| 56 | 47 | AxisTick().foregroundStyle(Color.clear) |
| 57 | 48 | AxisValueLabel(format: .dateTime.month(.abbreviated).day()) |
| 58 | − .font(.caption2) | |
| 59 | − .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) | |
| 49 | + .foregroundStyle(.secondary) | |
| 60 | 50 | } |
| 61 | 51 | } |
| 62 | 52 | .chartLegend(.hidden) |
| 63 | 53 | .frame(height: 130) |
| 64 | 54 | .chartOverlay { proxy in |
| 65 | 55 | GeometryReader { geo in |
| 66 | − ZStack(alignment: .topLeading) { | |
| 67 | − if let rect = self.selectionBandRect(model: model, proxy: proxy, geo: geo) { | |
| 68 | − Rectangle() | |
| 69 | − .fill(Color.white.opacity(0.07)) | |
| 70 | − .frame(width: rect.width, height: rect.height) | |
| 71 | − .position(x: rect.midX, y: rect.midY) | |
| 72 | − .allowsHitTesting(false) | |
| 73 | − } | |
| 74 | − MouseLocationReader { location in | |
| 75 | − self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) | |
| 76 | − } | |
| 77 | − .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 78 | − .contentShape(Rectangle()) | |
| 56 | + MouseLocationReader { location in | |
| 57 | + self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) | |
| 79 | 58 | } |
| 59 | + .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 60 | + .contentShape(Rectangle()) | |
| 80 | 61 | } |
| 81 | 62 | } |
| 82 | 63 | |
| @@ -115,7 +96,6 @@ struct UsageBreakdownChartMenuView: View { · 32 unmodified lines | ||
| 115 | 96 | let points: [Point] |
| 116 | 97 | let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] |
| 117 | 98 | let dayDates: [(dayKey: String, date: Date)] |
| 118 | − let peakPoint: (date: Date, creditsUsed: Double)? | |
| 119 | 99 | let services: [String] |
| 120 | 100 | let serviceColors: [Color] |
| 121 | 101 | let axisDates: [Date] |
| @@ -141,19 +121,10 @@ struct UsageBreakdownChartMenuView: View { · 19 unmodified lines | ||
| 141 | 121 | var dayDates: [(dayKey: String, date: Date)] = [] |
| 142 | 122 | dayDates.reserveCapacity(sorted.count) |
| 143 | 123 | |
| 144 | − var peak: (date: Date, creditsUsed: Double)? | |
| 145 | − | |
| 146 | 124 | for day in sorted { |
| 147 | 125 | guard let date = self.dateFromDayKey(day.day) else { continue } |
| 148 | 126 | breakdownByDayKey[day.day] = day |
| 149 | 127 | dayDates.append((dayKey: day.day, date: date)) |
| 150 | − if day.totalCreditsUsed > 0 { | |
| 151 | − if let cur = peak { | |
| 152 | − if day.totalCreditsUsed > cur.creditsUsed { peak = (date, day.totalCreditsUsed) } | |
| 153 | − } else { | |
| 154 | − peak = (date, day.totalCreditsUsed) | |
| 155 | − } | |
| 156 | − } | |
| 157 | 128 | for service in day.services where service.creditsUsed > 0 { |
| 158 | 129 | points.append(Point(date: date, service: service.service, creditsUsed: service.creditsUsed)) |
| 159 | 130 | } |
| @@ -167,7 +138,6 @@ struct UsageBreakdownChartMenuView: View { · 7 unmodified lines | ||
| 167 | 138 | points: points, |
| 168 | 139 | breakdownByDayKey: breakdownByDayKey, |
| 169 | 140 | dayDates: dayDates, |
| 170 | − peakPoint: peak, | |
| 171 | 141 | services: services, |
| 172 | 142 | serviceColors: colors, |
| 173 | 143 | axisDates: axisDates) |
| @@ -241,43 +211,6 @@ struct UsageBreakdownChartMenuView: View { · 67 unmodified lines | ||
| 241 | 211 | return comps.date |
| 242 | 212 | } |
| 243 | 213 | |
| 244 | − private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { | |
| 245 | − guard let key = self.selectedDayKey else { return nil } | |
| 246 | − guard let plotAnchor = proxy.plotFrame else { return nil } | |
| 247 | − let plotFrame = geo[plotAnchor] | |
| 248 | − guard let index = model.dayDates.firstIndex(where: { $0.dayKey == key }) else { return nil } | |
| 249 | − let date = model.dayDates[index].date | |
| 250 | − guard let x = proxy.position(forX: date) else { return nil } | |
| 251 | − | |
| 252 | − func xForIndex(_ idx: Int) -> CGFloat? { | |
| 253 | − guard idx >= 0, idx < model.dayDates.count else { return nil } | |
| 254 | − return proxy.position(forX: model.dayDates[idx].date) | |
| 255 | − } | |
| 256 | − | |
| 257 | − let xPrev = xForIndex(index - 1) | |
| 258 | − let xNext = xForIndex(index + 1) | |
| 259 | − | |
| 260 | − let leftInPlot: CGFloat = if let xPrev { | |
| 261 | − (xPrev + x) / 2 | |
| 262 | − } else if let xNext { | |
| 263 | − x - (xNext - x) / 2 | |
| 264 | − } else { | |
| 265 | − x - 8 | |
| 266 | − } | |
| 267 | − | |
| 268 | − let rightInPlot: CGFloat = if let xNext { | |
| 269 | − (xNext + x) / 2 | |
| 270 | − } else if let xPrev { | |
| 271 | − x + (x - xPrev) / 2 | |
| 272 | − } else { | |
| 273 | − x + 8 | |
| 274 | − } | |
| 275 | − | |
| 276 | − let left = plotFrame.origin.x + min(leftInPlot, rightInPlot) | |
| 277 | − let right = plotFrame.origin.x + max(leftInPlot, rightInPlot) | |
| 278 | − return CGRect(x: left, y: plotFrame.origin.y, width: right - left, height: plotFrame.height) | |
| 279 | − } | |
| 280 | − | |
| 281 | 214 | private func updateSelection( |
| 282 | 215 | location: CGPoint?, |
| 283 | 216 | model: Model, |
| @@ -68,9 +68,6 @@ struct ConsecutiveFailureGate { · 67 unmodified lines | ||
| 68 | 68 | final class UsageStore: ObservableObject { |
| 69 | 69 | @Published private var snapshots: [UsageProvider: UsageSnapshot] = [:] |
| 70 | 70 | @Published private var errors: [UsageProvider: String] = [:] |
| 71 | − @Published private var tokenSnapshots: [UsageProvider: CCUsageTokenSnapshot] = [:] | |
| 72 | − @Published private var tokenErrors: [UsageProvider: String] = [:] | |
| 73 | − @Published private var tokenRefreshInFlight: Set<UsageProvider> = [] | |
| 74 | 71 | @Published var credits: CreditsSnapshot? |
| 75 | 72 | @Published var lastCreditsError: String? |
| 76 | 73 | @Published var openAIDashboard: OpenAIDashboardSnapshot? |
| @@ -98,7 +95,6 @@ final class UsageStore: ObservableObject { · 21 unmodified lines | ||
| 98 | 95 | |
| 99 | 96 | private let codexFetcher: UsageFetcher |
| 100 | 97 | private let claudeFetcher: any ClaudeUsageFetching |
| 101 | − private let ccusageFetcher: CCUsageFetcher | |
| 102 | 98 | private let registry: ProviderRegistry |
| 103 | 99 | private let settings: SettingsStore |
| 104 | 100 | private let sessionQuotaNotifier: SessionQuotaNotifier |
| @@ -106,27 +102,21 @@ final class UsageStore: ObservableObject { · 1 unmodified line | ||
| 106 | 102 | private let openAIWebLogger = Logger(subsystem: "com.steipete.codexbar", category: "openai-web") |
| 107 | 103 | private var openAIWebDebugLines: [String] = [] |
| 108 | 104 | private var failureGates: [UsageProvider: ConsecutiveFailureGate] = [:] |
| 109 | − private var tokenFailureGates: [UsageProvider: ConsecutiveFailureGate] = [:] | |
| 110 | 105 | private var providerSpecs: [UsageProvider: ProviderSpec] = [:] |
| 111 | 106 | private let providerMetadata: [UsageProvider: ProviderMetadata] |
| 112 | 107 | private var timerTask: Task<Void, Never>? |
| 113 | − private var tokenTimerTask: Task<Void, Never>? | |
| 114 | 108 | private var cancellables = Set<AnyCancellable>() |
| 115 | 109 | private var lastKnownSessionRemaining: [UsageProvider: Double] = [:] |
| 116 | − private var lastTokenFetchAt: [UsageProvider: Date] = [:] | |
| 117 | − private let tokenFetchTTL: TimeInterval = 60 * 60 | |
| 118 | 110 | |
| 119 | 111 | init( |
| 120 | 112 | fetcher: UsageFetcher, |
| 121 | 113 | claudeFetcher: any ClaudeUsageFetching = ClaudeUsageFetcher(), |
| 122 | − ccusageFetcher: CCUsageFetcher = CCUsageFetcher(), | |
| 123 | 114 | settings: SettingsStore, |
| 124 | 115 | registry: ProviderRegistry = .shared, |
| 125 | 116 | sessionQuotaNotifier: SessionQuotaNotifier = SessionQuotaNotifier()) |
| 126 | 117 | { |
| 127 | 118 | self.codexFetcher = fetcher |
| 128 | 119 | self.claudeFetcher = claudeFetcher |
| 129 | − self.ccusageFetcher = ccusageFetcher | |
| 130 | 120 | self.settings = settings |
| 131 | 121 | self.registry = registry |
| 132 | 122 | self.sessionQuotaNotifier = sessionQuotaNotifier |
| @@ -134,8 +124,6 @@ final class UsageStore: ObservableObject { · 1 unmodified line | ||
| 134 | 124 | self |
| 135 | 125 | .failureGates = Dictionary(uniqueKeysWithValues: UsageProvider.allCases |
| 136 | 126 | .map { ($0, ConsecutiveFailureGate()) }) |
| 137 | − self.tokenFailureGates = Dictionary(uniqueKeysWithValues: UsageProvider.allCases | |
| 138 | − .map { ($0, ConsecutiveFailureGate()) }) | |
| 139 | 127 | self.providerSpecs = registry.specs( |
| 140 | 128 | settings: settings, |
| 141 | 129 | metadata: self.providerMetadata, |
| @@ -149,7 +137,6 @@ final class UsageStore: ObservableObject { · 7 unmodified lines | ||
| 149 | 137 | } |
| 150 | 138 | Task { await self.refresh() } |
| 151 | 139 | self.startTimer() |
| 152 | − self.startTokenTimer() | |
| 153 | 140 | } |
| 154 | 141 | |
| 155 | 142 | var codexSnapshot: UsageSnapshot? { self.snapshots[.codex] } |
| @@ -243,7 +230,7 @@ final class UsageStore: ObservableObject { · 87 unmodified lines | ||
| 243 | 230 | self.settings.isProviderEnabled(provider: provider, metadata: self.metadata(for: provider)) |
| 244 | 231 | } |
| 245 | 232 | |
| 246 | − func refresh(forceTokenUsage: Bool = false) async { | |
| 233 | + func refresh() async { | |
| 247 | 234 | guard !self.isRefreshing else { return } |
| 248 | 235 | self.isRefreshing = true |
| 249 | 236 | defer { self.isRefreshing = false } |
| @@ -256,9 +243,6 @@ final class UsageStore: ObservableObject { · 6 unmodified lines | ||
| 256 | 243 | group.addTask { await self.refreshCreditsIfNeeded() } |
| 257 | 244 | } |
| 258 | 245 | |
| 259 | − // Token-cost usage can be slow; run it outside the refresh group so we don't block menu updates. | |
| 260 | − self.scheduleTokenRefresh(force: forceTokenUsage) | |
| 261 | − | |
| 262 | 246 | // OpenAI web scrape depends on the current Codex account email (which can change after login/account switch). |
| 263 | 247 | // Run this after Codex usage refresh so we don't accidentally scrape with stale credentials. |
| 264 | 248 | await self.refreshOpenAIDashboardIfNeeded() |
| @@ -311,28 +295,8 @@ final class UsageStore: ObservableObject { · 46 unmodified lines | ||
| 311 | 295 | } |
| 312 | 296 | } |
| 313 | 297 | |
| 314 | − private func startTokenTimer() { | |
| 315 | − self.tokenTimerTask?.cancel() | |
| 316 | − let wait = self.tokenFetchTTL | |
| 317 | − self.tokenTimerTask = Task.detached(priority: .utility) { [weak self] in | |
| 318 | − while !Task.isCancelled { | |
| 319 | − try? await Task.sleep(for: .seconds(wait)) | |
| 320 | − await self?.scheduleTokenRefresh(force: false) | |
| 321 | − } | |
| 322 | − } | |
| 323 | − } | |
| 324 | − | |
| 325 | − private func scheduleTokenRefresh(force: Bool) { | |
| 326 | − for provider in UsageProvider.allCases { | |
| 327 | − Task { [weak self] in | |
| 328 | − await self?.refreshTokenUsage(provider, force: force) | |
| 329 | − } | |
| 330 | − } | |
| 331 | − } | |
| 332 | − | |
| 333 | 298 | deinit { |
| 334 | 299 | self.timerTask?.cancel() |
| 335 | − self.tokenTimerTask?.cancel() | |
| 336 | 300 | } |
| 337 | 301 | |
| 338 | 302 | private func refreshProvider(_ provider: UsageProvider) async { |
| @@ -342,13 +306,9 @@ final class UsageStore: ObservableObject { · 3 unmodified lines | ||
| 342 | 306 | await MainActor.run { |
| 343 | 307 | self.snapshots.removeValue(forKey: provider) |
| 344 | 308 | self.errors[provider] = nil |
| 345 | − self.tokenSnapshots.removeValue(forKey: provider) | |
| 346 | − self.tokenErrors[provider] = nil | |
| 347 | 309 | self.failureGates[provider]?.reset() |
| 348 | − self.tokenFailureGates[provider]?.reset() | |
| 349 | 310 | self.statuses.removeValue(forKey: provider) |
| 350 | 311 | self.lastKnownSessionRemaining.removeValue(forKey: provider) |
| 351 | − self.lastTokenFetchAt.removeValue(forKey: provider) | |
| 352 | 312 | } |
| 353 | 313 | return |
| 354 | 314 | } |
| @@ -955,101 +915,3 @@ final class UsageStore: ObservableObject { · 600 unmodified lines | ||
| 955 | 915 | self.pathDebugInfo = PathBuilder.debugSnapshot(purposes: [.rpc, .tty, .nodeTooling]) |
| 956 | 916 | } |
| 957 | 917 | } |
| 958 | − | |
| 959 | −extension UsageStore { | |
| 960 | − func tokenSnapshot(for provider: UsageProvider) -> CCUsageTokenSnapshot? { | |
| 961 | − self.tokenSnapshots[provider] | |
| 962 | − } | |
| 963 | − | |
| 964 | − func tokenError(for provider: UsageProvider) -> String? { | |
| 965 | − self.tokenErrors[provider] | |
| 966 | − } | |
| 967 | − | |
| 968 | − func tokenLastAttemptAt(for provider: UsageProvider) -> Date? { | |
| 969 | − self.lastTokenFetchAt[provider] | |
| 970 | − } | |
| 971 | − | |
| 972 | − func isTokenRefreshInFlight(for provider: UsageProvider) -> Bool { | |
| 973 | − self.tokenRefreshInFlight.contains(provider) | |
| 974 | − } | |
| 975 | − | |
| 976 | − private func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { | |
| 977 | − guard provider == .codex || provider == .claude else { | |
| 978 | − self.tokenSnapshots.removeValue(forKey: provider) | |
| 979 | − self.tokenErrors[provider] = nil | |
| 980 | − self.tokenFailureGates[provider]?.reset() | |
| 981 | − self.lastTokenFetchAt.removeValue(forKey: provider) | |
| 982 | − return | |
| 983 | − } | |
| 984 | − | |
| 985 | − guard self.settings.ccusageCostUsageEnabled else { | |
| 986 | − self.tokenSnapshots.removeValue(forKey: provider) | |
| 987 | − self.tokenErrors[provider] = nil | |
| 988 | − self.tokenFailureGates[provider]?.reset() | |
| 989 | − self.lastTokenFetchAt.removeValue(forKey: provider) | |
| 990 | − return | |
| 991 | − } | |
| 992 | − | |
| 993 | − guard self.settings.isCCUsageInstalled(for: provider) else { | |
| 994 | − self.tokenSnapshots.removeValue(forKey: provider) | |
| 995 | − self.tokenErrors[provider] = nil | |
| 996 | − self.tokenFailureGates[provider]?.reset() | |
| 997 | − self.lastTokenFetchAt.removeValue(forKey: provider) | |
| 998 | − return | |
| 999 | − } | |
| 1000 | − | |
| 1001 | − guard self.isEnabled(provider) else { | |
| 1002 | − self.tokenSnapshots.removeValue(forKey: provider) | |
| 1003 | − self.tokenErrors[provider] = nil | |
| 1004 | − self.tokenFailureGates[provider]?.reset() | |
| 1005 | − self.lastTokenFetchAt.removeValue(forKey: provider) | |
| 1006 | − return | |
| 1007 | − } | |
| 1008 | − | |
| 1009 | − guard !self.tokenRefreshInFlight.contains(provider) else { return } | |
| 1010 | − | |
| 1011 | − let now = Date() | |
| 1012 | − if !force, | |
| 1013 | − let last = self.lastTokenFetchAt[provider], | |
| 1014 | − now.timeIntervalSince(last) < self.tokenFetchTTL, | |
| 1015 | − self.tokenSnapshots[provider] != nil | |
| 1016 | − { | |
| 1017 | − return | |
| 1018 | − } | |
| 1019 | − self.lastTokenFetchAt[provider] = now | |
| 1020 | − self.tokenRefreshInFlight.insert(provider) | |
| 1021 | − defer { self.tokenRefreshInFlight.remove(provider) } | |
| 1022 | − | |
| 1023 | − do { | |
| 1024 | − let fetcher = self.ccusageFetcher | |
| 1025 | − let cli: CCUsageFetcher.CLI = switch provider { | |
| 1026 | − case .codex: .codex | |
| 1027 | − case .claude: .ccusage | |
| 1028 | − case .gemini: .ccusage | |
| 1029 | − } | |
| 1030 | − let snapshot = try await Task.detached(priority: .utility) { | |
| 1031 | − try await fetcher.loadTokenSnapshot(cli: cli, now: now) | |
| 1032 | − }.value | |
| 1033 | − self.tokenSnapshots[provider] = snapshot | |
| 1034 | − self.tokenErrors[provider] = nil | |
| 1035 | − self.tokenFailureGates[provider]?.recordSuccess() | |
| 1036 | − } catch { | |
| 1037 | − if let ccusageError = error as? CCUsageError, | |
| 1038 | − case .cliNotInstalled = ccusageError | |
| 1039 | − { | |
| 1040 | − self.tokenSnapshots.removeValue(forKey: provider) | |
| 1041 | − self.tokenErrors[provider] = nil | |
| 1042 | − return | |
| 1043 | − } | |
| 1044 | − let hadPriorData = self.tokenSnapshots[provider] != nil | |
| 1045 | − let shouldSurface = self.tokenFailureGates[provider]? | |
| 1046 | − .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true | |
| 1047 | − if shouldSurface { | |
| 1048 | − self.tokenErrors[provider] = error.localizedDescription | |
| 1049 | − self.tokenSnapshots.removeValue(forKey: provider) | |
| 1050 | − } else { | |
| 1051 | − self.tokenErrors[provider] = nil | |
| 1052 | − } | |
| 1053 | − } | |
| 1054 | − } | |
| 1055 | −} | |
| @@ -1,157 +0,0 @@ | ||
| 1 | −import Foundation | |
| 2 | − | |
| 3 | −public enum CCUsageError: LocalizedError, Sendable { | |
| 4 | − case cliNotInstalled(binary: String, installCommand: String) | |
| 5 | − case decodeFailed(String) | |
| 6 | − | |
| 7 | − public var errorDescription: String? { | |
| 8 | − switch self { | |
| 9 | − case let .cliNotInstalled(binary, installCommand): | |
| 10 | − "\(binary) is not installed. Install with `\(installCommand)` and restart CodexBar." | |
| 11 | − case let .decodeFailed(details): | |
| 12 | − "Could not parse ccusage output: \(details)" | |
| 13 | − } | |
| 14 | − } | |
| 15 | −} | |
| 16 | − | |
| 17 | −public struct CCUsageFetcher: Sendable { | |
| 18 | − public init() {} | |
| 19 | − | |
| 20 | − public enum CLI: String, Sendable { | |
| 21 | − case ccusage | |
| 22 | − case codex = "ccusage-codex" | |
| 23 | − | |
| 24 | − var installCommand: String { | |
| 25 | − switch self { | |
| 26 | − case .ccusage: | |
| 27 | − "npm i -g ccusage" | |
| 28 | − case .codex: | |
| 29 | − "npm i -g @ccusage/codex" | |
| 30 | − } | |
| 31 | − } | |
| 32 | − } | |
| 33 | − | |
| 34 | − public func loadTokenSnapshot(cli: CLI, now: Date = Date()) async throws -> CCUsageTokenSnapshot { | |
| 35 | − guard let ccusagePath = TTYCommandRunner.which(cli.rawValue) else { | |
| 36 | − throw CCUsageError.cliNotInstalled(binary: cli.rawValue, installCommand: cli.installCommand) | |
| 37 | − } | |
| 38 | − | |
| 39 | − let env = TTYCommandRunner.enrichedEnvironment() | |
| 40 | − let untilKey = Self.dayKey(from: now) | |
| 41 | − // Rollback/rolling window: last 30 days (inclusive). | |
| 42 | − let sinceKey = Self.dayKey( | |
| 43 | − from: Calendar.current.date(byAdding: .day, value: -30, to: now) ?? now) | |
| 44 | − | |
| 45 | − // Run sequentially to keep CPU/RAM spikes down on large histories. | |
| 46 | − let session = try await Self.runSession( | |
| 47 | − ccusagePath: ccusagePath, | |
| 48 | − env: env, | |
| 49 | − sinceKey: sinceKey, | |
| 50 | − untilKey: untilKey) | |
| 51 | − let daily = try await Self.runDaily(ccusagePath: ccusagePath, env: env, sinceKey: sinceKey, untilKey: untilKey) | |
| 52 | − | |
| 53 | − let current = Self.selectCurrentSession(from: session.data) | |
| 54 | − let totalFromSummary = daily.summary?.totalCostUSD | |
| 55 | − let totalFromEntries = daily.data.compactMap(\.costUSD).reduce(0, +) | |
| 56 | − let last30DaysCostUSD = totalFromSummary ?? (totalFromEntries > 0 ? totalFromEntries : nil) | |
| 57 | − | |
| 58 | − return CCUsageTokenSnapshot( | |
| 59 | − sessionTokens: current?.totalTokens, | |
| 60 | − sessionCostUSD: current?.costUSD, | |
| 61 | − last30DaysCostUSD: last30DaysCostUSD, | |
| 62 | − daily: daily.data, | |
| 63 | − updatedAt: now) | |
| 64 | − } | |
| 65 | − | |
| 66 | − private static func runSession( | |
| 67 | − ccusagePath: String, | |
| 68 | − env: [String: String], | |
| 69 | − sinceKey: String, | |
| 70 | − untilKey: String) async throws | |
| 71 | − -> CCUsageSessionReport | |
| 72 | − { | |
| 73 | − let result = try await SubprocessRunner.run( | |
| 74 | − binary: ccusagePath, | |
| 75 | − arguments: ["session", "--json", "--offline", "--since", sinceKey, "--until", untilKey], | |
| 76 | − environment: env, | |
| 77 | − timeout: 10 * 60, | |
| 78 | − label: "ccusage session") | |
| 79 | − | |
| 80 | − guard let data = result.stdout.data(using: .utf8) else { | |
| 81 | − throw CCUsageError.decodeFailed("empty stdout") | |
| 82 | − } | |
| 83 | − do { | |
| 84 | − return try JSONDecoder().decode(CCUsageSessionReport.self, from: data) | |
| 85 | − } catch { | |
| 86 | − throw CCUsageError.decodeFailed(error.localizedDescription) | |
| 87 | − } | |
| 88 | − } | |
| 89 | − | |
| 90 | − private static func runDaily( | |
| 91 | − ccusagePath: String, | |
| 92 | − env: [String: String], | |
| 93 | − sinceKey: String, | |
| 94 | − untilKey: String) async throws -> CCUsageDailyReport | |
| 95 | − { | |
| 96 | − let result = try await SubprocessRunner.run( | |
| 97 | − binary: ccusagePath, | |
| 98 | − arguments: ["daily", "--json", "--offline", "--since", sinceKey, "--until", untilKey], | |
| 99 | − environment: env, | |
| 100 | − timeout: 10 * 60, | |
| 101 | − label: "ccusage daily") | |
| 102 | − | |
| 103 | − guard let data = result.stdout.data(using: .utf8) else { | |
| 104 | − throw CCUsageError.decodeFailed("empty stdout") | |
| 105 | − } | |
| 106 | − do { | |
| 107 | − return try JSONDecoder().decode(CCUsageDailyReport.self, from: data) | |
| 108 | − } catch { | |
| 109 | − throw CCUsageError.decodeFailed(error.localizedDescription) | |
| 110 | − } | |
| 111 | − } | |
| 112 | − | |
| 113 | − static func selectCurrentSession(from sessions: [CCUsageSessionReport.Entry]) | |
| 114 | − -> CCUsageSessionReport.Entry? | |
| 115 | − { | |
| 116 | − if sessions.isEmpty { return nil } | |
| 117 | − return sessions.max { lhs, rhs in | |
| 118 | − let lDate = CCUsageDateParser.parse(lhs.lastActivity) ?? .distantPast | |
| 119 | − let rDate = CCUsageDateParser.parse(rhs.lastActivity) ?? .distantPast | |
| 120 | − if lDate != rDate { return lDate < rDate } | |
| 121 | − let lCost = lhs.costUSD ?? -1 | |
| 122 | − let rCost = rhs.costUSD ?? -1 | |
| 123 | − if lCost != rCost { return lCost < rCost } | |
| 124 | − let lTokens = lhs.totalTokens ?? -1 | |
| 125 | − let rTokens = rhs.totalTokens ?? -1 | |
| 126 | − if lTokens != rTokens { return lTokens < rTokens } | |
| 127 | − return lhs.session < rhs.session | |
| 128 | − } | |
| 129 | − } | |
| 130 | − | |
| 131 | − static func selectMostRecentMonth(from months: [CCUsageMonthlyReport.Entry]) | |
| 132 | − -> CCUsageMonthlyReport.Entry? | |
| 133 | − { | |
| 134 | − if months.isEmpty { return nil } | |
| 135 | − return months.max { lhs, rhs in | |
| 136 | − let lDate = CCUsageDateParser.parseMonth(lhs.month) ?? .distantPast | |
| 137 | − let rDate = CCUsageDateParser.parseMonth(rhs.month) ?? .distantPast | |
| 138 | − if lDate != rDate { return lDate < rDate } | |
| 139 | − let lCost = lhs.costUSD ?? -1 | |
| 140 | − let rCost = rhs.costUSD ?? -1 | |
| 141 | − if lCost != rCost { return lCost < rCost } | |
| 142 | − let lTokens = lhs.totalTokens ?? -1 | |
| 143 | − let rTokens = rhs.totalTokens ?? -1 | |
| 144 | − if lTokens != rTokens { return lTokens < rTokens } | |
| 145 | − return lhs.month < rhs.month | |
| 146 | − } | |
| 147 | − } | |
| 148 | − | |
| 149 | − private static func dayKey(from date: Date) -> String { | |
| 150 | − let df = DateFormatter() | |
| 151 | − df.locale = Locale(identifier: "en_US_POSIX") | |
| 152 | − df.timeZone = TimeZone.current | |
| 153 | − // ccusage expects YYYYMMDD (Claude); @ccusage/codex accepts both. | |
| 154 | − df.dateFormat = "yyyyMMdd" | |
| 155 | − return df.string(from: date) | |
| 156 | − } | |
| 157 | −} | |
| @@ -1,357 +0,0 @@ | ||
| 1 | −import Foundation | |
| 2 | − | |
| 3 | −public struct CCUsageTokenSnapshot: Sendable, Equatable { | |
| 4 | − public let sessionTokens: Int? | |
| 5 | − public let sessionCostUSD: Double? | |
| 6 | − public let last30DaysCostUSD: Double? | |
| 7 | − public let daily: [CCUsageDailyReport.Entry] | |
| 8 | − public let updatedAt: Date | |
| 9 | − | |
| 10 | − public init( | |
| 11 | − sessionTokens: Int?, | |
| 12 | − sessionCostUSD: Double?, | |
| 13 | − last30DaysCostUSD: Double?, | |
| 14 | − daily: [CCUsageDailyReport.Entry], | |
| 15 | − updatedAt: Date) | |
| 16 | − { | |
| 17 | − self.sessionTokens = sessionTokens | |
| 18 | − self.sessionCostUSD = sessionCostUSD | |
| 19 | − self.last30DaysCostUSD = last30DaysCostUSD | |
| 20 | − self.daily = daily | |
| 21 | − self.updatedAt = updatedAt | |
| 22 | − } | |
| 23 | −} | |
| 24 | − | |
| 25 | −public struct CCUsageDailyReport: Sendable, Decodable { | |
| 26 | − public struct ModelBreakdown: Sendable, Decodable, Equatable { | |
| 27 | − public let modelName: String | |
| 28 | − public let costUSD: Double? | |
| 29 | − | |
| 30 | − private enum CodingKeys: String, CodingKey { | |
| 31 | − case modelName | |
| 32 | − case costUSD | |
| 33 | − case cost | |
| 34 | − } | |
| 35 | − | |
| 36 | − public init(from decoder: Decoder) throws { | |
| 37 | − let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 38 | − self.modelName = try container.decode(String.self, forKey: .modelName) | |
| 39 | − self.costUSD = | |
| 40 | − try container.decodeIfPresent(Double.self, forKey: .costUSD) | |
| 41 | − ?? container.decodeIfPresent(Double.self, forKey: .cost) | |
| 42 | − } | |
| 43 | − } | |
| 44 | − | |
| 45 | − public struct Entry: Sendable, Decodable, Equatable { | |
| 46 | − public let date: String | |
| 47 | − public let inputTokens: Int? | |
| 48 | − public let outputTokens: Int? | |
| 49 | − public let totalTokens: Int? | |
| 50 | − public let costUSD: Double? | |
| 51 | − public let modelsUsed: [String]? | |
| 52 | − public let modelBreakdowns: [ModelBreakdown]? | |
| 53 | − | |
| 54 | − private enum CodingKeys: String, CodingKey { | |
| 55 | − case date | |
| 56 | − case inputTokens | |
| 57 | − case outputTokens | |
| 58 | − case totalTokens | |
| 59 | − case costUSD | |
| 60 | − case totalCost | |
| 61 | − case modelsUsed | |
| 62 | − case models | |
| 63 | − case modelBreakdowns | |
| 64 | − } | |
| 65 | − | |
| 66 | − public init(from decoder: Decoder) throws { | |
| 67 | − let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 68 | − self.date = try container.decode(String.self, forKey: .date) | |
| 69 | − self.inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens) | |
| 70 | − self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) | |
| 71 | − self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) | |
| 72 | − self.costUSD = | |
| 73 | − try container.decodeIfPresent(Double.self, forKey: .costUSD) | |
| 74 | − ?? container.decodeIfPresent(Double.self, forKey: .totalCost) | |
| 75 | − self.modelsUsed = | |
| 76 | − try container.decodeIfPresent([String].self, forKey: .modelsUsed) | |
| 77 | − ?? container.decodeIfPresent([String].self, forKey: .models) | |
| 78 | − self.modelBreakdowns = try container.decodeIfPresent([ModelBreakdown].self, forKey: .modelBreakdowns) | |
| 79 | − } | |
| 80 | − } | |
| 81 | − | |
| 82 | − public struct Summary: Sendable, Decodable, Equatable { | |
| 83 | − public let totalInputTokens: Int? | |
| 84 | − public let totalOutputTokens: Int? | |
| 85 | − public let totalTokens: Int? | |
| 86 | − public let totalCostUSD: Double? | |
| 87 | − | |
| 88 | − private enum CodingKeys: String, CodingKey { | |
| 89 | − case totalInputTokens | |
| 90 | − case totalOutputTokens | |
| 91 | − case totalTokens | |
| 92 | − case totalCostUSD | |
| 93 | − case totalCost | |
| 94 | − } | |
| 95 | − | |
| 96 | − public init( | |
| 97 | − totalInputTokens: Int?, | |
| 98 | − totalOutputTokens: Int?, | |
| 99 | − totalTokens: Int?, | |
| 100 | − totalCostUSD: Double?) | |
| 101 | − { | |
| 102 | − self.totalInputTokens = totalInputTokens | |
| 103 | − self.totalOutputTokens = totalOutputTokens | |
| 104 | − self.totalTokens = totalTokens | |
| 105 | − self.totalCostUSD = totalCostUSD | |
| 106 | − } | |
| 107 | − | |
| 108 | − public init(from decoder: Decoder) throws { | |
| 109 | − let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 110 | − self.totalInputTokens = try container.decodeIfPresent(Int.self, forKey: .totalInputTokens) | |
| 111 | − self.totalOutputTokens = try container.decodeIfPresent(Int.self, forKey: .totalOutputTokens) | |
| 112 | − self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) | |
| 113 | − self.totalCostUSD = | |
| 114 | − try container.decodeIfPresent(Double.self, forKey: .totalCostUSD) | |
| 115 | − ?? container.decodeIfPresent(Double.self, forKey: .totalCost) | |
| 116 | − } | |
| 117 | − } | |
| 118 | − | |
| 119 | − public let data: [Entry] | |
| 120 | − public let summary: Summary? | |
| 121 | − | |
| 122 | − private enum CodingKeys: String, CodingKey { | |
| 123 | − case type | |
| 124 | − case data | |
| 125 | − case summary | |
| 126 | − case daily | |
| 127 | − case totals | |
| 128 | − } | |
| 129 | − | |
| 130 | − public init(from decoder: Decoder) throws { | |
| 131 | − let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 132 | − | |
| 133 | − if container.contains(.type) { | |
| 134 | − _ = try container.decode(String.self, forKey: .type) | |
| 135 | − self.data = try container.decode([Entry].self, forKey: .data) | |
| 136 | − self.summary = try container.decodeIfPresent(Summary.self, forKey: .summary) | |
| 137 | − return | |
| 138 | − } | |
| 139 | − | |
| 140 | − self.data = try container.decode([Entry].self, forKey: .daily) | |
| 141 | − if container.contains(.totals) { | |
| 142 | − let totals = try container.decode(CCUsageLegacyTotals.self, forKey: .totals) | |
| 143 | − self.summary = Summary( | |
| 144 | − totalInputTokens: totals.totalInputTokens, | |
| 145 | − totalOutputTokens: totals.totalOutputTokens, | |
| 146 | − totalTokens: totals.totalTokens, | |
| 147 | − totalCostUSD: totals.totalCost) | |
| 148 | − } else { | |
| 149 | − self.summary = nil | |
| 150 | − } | |
| 151 | − } | |
| 152 | −} | |
| 153 | − | |
| 154 | −public struct CCUsageSessionReport: Sendable, Decodable { | |
| 155 | − public struct Entry: Sendable, Decodable, Equatable { | |
| 156 | − public let session: String | |
| 157 | − public let inputTokens: Int? | |
| 158 | − public let outputTokens: Int? | |
| 159 | − public let totalTokens: Int? | |
| 160 | − public let costUSD: Double? | |
| 161 | − public let lastActivity: String? | |
| 162 | − | |
| 163 | − private enum CodingKeys: String, CodingKey { | |
| 164 | − case session | |
| 165 | − case sessionId | |
| 166 | − case inputTokens | |
| 167 | − case outputTokens | |
| 168 | − case totalTokens | |
| 169 | − case costUSD | |
| 170 | − case totalCost | |
| 171 | − case lastActivity | |
| 172 | − } | |
| 173 | − | |
| 174 | − public init(from decoder: Decoder) throws { | |
| 175 | − let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 176 | − self.session = | |
| 177 | − try container.decodeIfPresent(String.self, forKey: .session) | |
| 178 | − ?? container.decode(String.self, forKey: .sessionId) | |
| 179 | − self.inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens) | |
| 180 | − self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) | |
| 181 | − self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) | |
| 182 | − self.costUSD = | |
| 183 | − try container.decodeIfPresent(Double.self, forKey: .costUSD) | |
| 184 | − ?? container.decodeIfPresent(Double.self, forKey: .totalCost) | |
| 185 | − self.lastActivity = try container.decodeIfPresent(String.self, forKey: .lastActivity) | |
| 186 | − } | |
| 187 | − } | |
| 188 | − | |
| 189 | − public struct Summary: Sendable, Decodable, Equatable { | |
| 190 | − public let totalCostUSD: Double? | |
| 191 | − | |
| 192 | − private enum CodingKeys: String, CodingKey { | |
| 193 | − case totalCostUSD | |
| 194 | − case totalCost | |
| 195 | − } | |
| 196 | − | |
| 197 | − public init(from decoder: Decoder) throws { | |
| 198 | − let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 199 | − self.totalCostUSD = | |
| 200 | − try container.decodeIfPresent(Double.self, forKey: .totalCostUSD) | |
| 201 | − ?? container.decodeIfPresent(Double.self, forKey: .totalCost) | |
| 202 | − } | |
| 203 | − } | |
| 204 | − | |
| 205 | − public let data: [Entry] | |
| 206 | − public let summary: Summary? | |
| 207 | − | |
| 208 | − private enum CodingKeys: String, CodingKey { | |
| 209 | − case type | |
| 210 | − case data | |
| 211 | − case summary | |
| 212 | − case sessions | |
| 213 | − case totals | |
| 214 | − } | |
| 215 | − | |
| 216 | − public init(from decoder: Decoder) throws { | |
| 217 | − let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 218 | − if container.contains(.type) { | |
| 219 | − _ = try container.decode(String.self, forKey: .type) | |
| 220 | − self.data = try container.decode([Entry].self, forKey: .data) | |
| 221 | − self.summary = try container.decodeIfPresent(Summary.self, forKey: .summary) | |
| 222 | − return | |
| 223 | − } | |
| 224 | − | |
| 225 | − self.data = try container.decode([Entry].self, forKey: .sessions) | |
| 226 | − self.summary = try container.decodeIfPresent(Summary.self, forKey: .totals) | |
| 227 | − } | |
| 228 | −} | |
| 229 | − | |
| 230 | −public struct CCUsageMonthlyReport: Sendable, Decodable { | |
| 231 | − public struct Entry: Sendable, Decodable, Equatable { | |
| 232 | − public let month: String | |
| 233 | − public let totalTokens: Int? | |
| 234 | − public let costUSD: Double? | |
| 235 | − | |
| 236 | − private enum CodingKeys: String, CodingKey { | |
| 237 | − case month | |
| 238 | − case totalTokens | |
| 239 | − case costUSD | |
| 240 | − case totalCost | |
| 241 | − } | |
| 242 | − | |
| 243 | − public init(from decoder: Decoder) throws { | |
| 244 | − let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 245 | − self.month = try container.decode(String.self, forKey: .month) | |
| 246 | − self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) | |
| 247 | − self.costUSD = | |
| 248 | − try container.decodeIfPresent(Double.self, forKey: .costUSD) | |
| 249 | − ?? container.decodeIfPresent(Double.self, forKey: .totalCost) | |
| 250 | − } | |
| 251 | − } | |
| 252 | − | |
| 253 | − public struct Summary: Sendable, Decodable, Equatable { | |
| 254 | − public let totalTokens: Int? | |
| 255 | − public let totalCostUSD: Double? | |
| 256 | − | |
| 257 | − private enum CodingKeys: String, CodingKey { | |
| 258 | − case totalTokens | |
| 259 | − case costUSD | |
| 260 | − case totalCostUSD | |
| 261 | − case totalCost | |
| 262 | − } | |
| 263 | − | |
| 264 | − public init(from decoder: Decoder) throws { | |
| 265 | − let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 266 | − self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) | |
| 267 | − self.totalCostUSD = | |
| 268 | − try container.decodeIfPresent(Double.self, forKey: .totalCostUSD) | |
| 269 | − ?? container.decodeIfPresent(Double.self, forKey: .costUSD) | |
| 270 | − ?? container.decodeIfPresent(Double.self, forKey: .totalCost) | |
| 271 | − } | |
| 272 | − } | |
| 273 | − | |
| 274 | − public let data: [Entry] | |
| 275 | − public let summary: Summary? | |
| 276 | − | |
| 277 | − private enum CodingKeys: String, CodingKey { | |
| 278 | − case type | |
| 279 | − case data | |
| 280 | − case summary | |
| 281 | − case monthly | |
| 282 | − case totals | |
| 283 | − } | |
| 284 | − | |
| 285 | − public init(from decoder: Decoder) throws { | |
| 286 | − let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 287 | − | |
| 288 | − if container.contains(.type) { | |
| 289 | − _ = try container.decode(String.self, forKey: .type) | |
| 290 | − self.data = try container.decode([Entry].self, forKey: .data) | |
| 291 | − self.summary = try container.decodeIfPresent(Summary.self, forKey: .summary) | |
| 292 | − return | |
| 293 | − } | |
| 294 | − | |
| 295 | − self.data = try container.decode([Entry].self, forKey: .monthly) | |
| 296 | − self.summary = try container.decodeIfPresent(Summary.self, forKey: .totals) | |
| 297 | − } | |
| 298 | −} | |
| 299 | − | |
| 300 | −private struct CCUsageLegacyTotals: Sendable, Decodable { | |
| 301 | − let totalInputTokens: Int? | |
| 302 | − let totalOutputTokens: Int? | |
| 303 | − let totalTokens: Int? | |
| 304 | − let totalCost: Double? | |
| 305 | −} | |
| 306 | − | |
| 307 | −enum CCUsageDateParser { | |
| 308 | − static func parse(_ text: String?) -> Date? { | |
| 309 | − guard let text, !text.isEmpty else { return nil } | |
| 310 | − let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 311 | − | |
| 312 | − let iso = ISO8601DateFormatter() | |
| 313 | − iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] | |
| 314 | − if let d = iso.date(from: trimmed) { return d } | |
| 315 | − iso.formatOptions = [.withInternetDateTime] | |
| 316 | − if let d = iso.date(from: trimmed) { return d } | |
| 317 | − | |
| 318 | − let day = DateFormatter() | |
| 319 | − day.locale = Locale(identifier: "en_US_POSIX") | |
| 320 | − day.timeZone = TimeZone.current | |
| 321 | − day.dateFormat = "yyyy-MM-dd" | |
| 322 | − if let d = day.date(from: trimmed) { return d } | |
| 323 | − | |
| 324 | − let monthDayYear = DateFormatter() | |
| 325 | − monthDayYear.locale = Locale(identifier: "en_US_POSIX") | |
| 326 | − monthDayYear.timeZone = TimeZone.current | |
| 327 | − monthDayYear.dateFormat = "MMM d, yyyy" | |
| 328 | − if let d = monthDayYear.date(from: trimmed) { return d } | |
| 329 | − | |
| 330 | − return nil | |
| 331 | − } | |
| 332 | − | |
| 333 | − static func parseMonth(_ text: String?) -> Date? { | |
| 334 | − guard let text, !text.isEmpty else { return nil } | |
| 335 | − let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 336 | − | |
| 337 | − let monthYear = DateFormatter() | |
| 338 | − monthYear.locale = Locale(identifier: "en_US_POSIX") | |
| 339 | − monthYear.timeZone = TimeZone.current | |
| 340 | − monthYear.dateFormat = "MMM yyyy" | |
| 341 | − if let d = monthYear.date(from: trimmed) { return d } | |
| 342 | − | |
| 343 | − let fullMonthYear = DateFormatter() | |
| 344 | − fullMonthYear.locale = Locale(identifier: "en_US_POSIX") | |
| 345 | − fullMonthYear.timeZone = TimeZone.current | |
| 346 | − fullMonthYear.dateFormat = "MMMM yyyy" | |
| 347 | − if let d = fullMonthYear.date(from: trimmed) { return d } | |
| 348 | − | |
| 349 | − let ym = DateFormatter() | |
| 350 | − ym.locale = Locale(identifier: "en_US_POSIX") | |
| 351 | − ym.timeZone = TimeZone.current | |
| 352 | − ym.dateFormat = "yyyy-MM" | |
| 353 | − if let d = ym.date(from: trimmed) { return d } | |
| 354 | − | |
| 355 | − return nil | |
| 356 | − } | |
| 357 | −} | |
| @@ -1,104 +0,0 @@ | ||
| 1 | −import Foundation | |
| 2 | − | |
| 3 | −public enum SubprocessRunnerError: LocalizedError, Sendable { | |
| 4 | − case binaryNotFound(String) | |
| 5 | − case launchFailed(String) | |
| 6 | − case timedOut(String) | |
| 7 | − case nonZeroExit(code: Int32, stderr: String) | |
| 8 | − | |
| 9 | − public var errorDescription: String? { | |
| 10 | − switch self { | |
| 11 | − case let .binaryNotFound(binary): | |
| 12 | − return "Missing CLI '\(binary)'. Install it and restart CodexBar." | |
| 13 | − case let .launchFailed(details): | |
| 14 | − return "Failed to launch process: \(details)" | |
| 15 | − case let .timedOut(label): | |
| 16 | − return "Command timed out: \(label)" | |
| 17 | − case let .nonZeroExit(code, stderr): | |
| 18 | − let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 19 | − if trimmed.isEmpty { | |
| 20 | − return "Command failed with exit code \(code)." | |
| 21 | − } | |
| 22 | − return "Command failed (\(code)): \(trimmed)" | |
| 23 | − } | |
| 24 | − } | |
| 25 | −} | |
| 26 | − | |
| 27 | −public struct SubprocessResult: Sendable { | |
| 28 | − public let stdout: String | |
| 29 | − public let stderr: String | |
| 30 | −} | |
| 31 | − | |
| 32 | −public enum SubprocessRunner { | |
| 33 | − public static func run( | |
| 34 | − binary: String, | |
| 35 | − arguments: [String], | |
| 36 | − environment: [String: String], | |
| 37 | − timeout: TimeInterval, | |
| 38 | − label: String) async throws -> SubprocessResult | |
| 39 | − { | |
| 40 | − guard FileManager.default.isExecutableFile(atPath: binary) else { | |
| 41 | − throw SubprocessRunnerError.binaryNotFound(binary) | |
| 42 | − } | |
| 43 | − | |
| 44 | − let process = Process() | |
| 45 | − process.executableURL = URL(fileURLWithPath: binary) | |
| 46 | − process.arguments = arguments | |
| 47 | − process.environment = environment | |
| 48 | − | |
| 49 | − let stdoutPipe = Pipe() | |
| 50 | − let stderrPipe = Pipe() | |
| 51 | − process.standardOutput = stdoutPipe | |
| 52 | − process.standardError = stderrPipe | |
| 53 | − process.standardInput = nil | |
| 54 | − | |
| 55 | − let exitCodeTask = Task<Int32, Error> { | |
| 56 | − try await withCheckedThrowingContinuation { cont in | |
| 57 | − process.terminationHandler = { proc in | |
| 58 | − cont.resume(returning: proc.terminationStatus) | |
| 59 | − } | |
| 60 | − do { | |
| 61 | − try process.run() | |
| 62 | − } catch { | |
| 63 | − cont.resume(throwing: SubprocessRunnerError.launchFailed(error.localizedDescription)) | |
| 64 | − } | |
| 65 | − } | |
| 66 | − } | |
| 67 | − | |
| 68 | − do { | |
| 69 | − let exitCode = try await withThrowingTaskGroup(of: Int32.self) { group in | |
| 70 | − group.addTask { try await exitCodeTask.value } | |
| 71 | − group.addTask { | |
| 72 | − try await Task.sleep(for: .seconds(timeout)) | |
| 73 | − throw SubprocessRunnerError.timedOut(label) | |
| 74 | − } | |
| 75 | − let code = try await group.next()! | |
| 76 | − group.cancelAll() | |
| 77 | − return code | |
| 78 | − } | |
| 79 | − | |
| 80 | − let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() | |
| 81 | − let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() | |
| 82 | − let stdout = String(data: stdoutData, encoding: .utf8) ?? "" | |
| 83 | − let stderr = String(data: stderrData, encoding: .utf8) ?? "" | |
| 84 | − | |
| 85 | − if exitCode != 0 { | |
| 86 | − throw SubprocessRunnerError.nonZeroExit(code: exitCode, stderr: stderr) | |
| 87 | − } | |
| 88 | − | |
| 89 | − return SubprocessResult(stdout: stdout, stderr: stderr) | |
| 90 | − } catch { | |
| 91 | − if process.isRunning { | |
| 92 | − process.terminate() | |
| 93 | − let killDeadline = Date().addingTimeInterval(0.4) | |
| 94 | − while process.isRunning, Date() < killDeadline { | |
| 95 | − usleep(50000) | |
| 96 | − } | |
| 97 | − if process.isRunning { | |
| 98 | − kill(process.processIdentifier, SIGKILL) | |
| 99 | − } | |
| 100 | − } | |
| 101 | − throw error | |
| 102 | − } | |
| 103 | − } | |
| 104 | −} | |
| @@ -396,14 +396,6 @@ public struct TTYCommandRunner { · 395 unmodified lines | ||
| 396 | 396 | public static func which(_ tool: String) -> String? { |
| 397 | 397 | if tool == "codex", let located = BinaryLocator.resolveCodexBinary() { return located } |
| 398 | 398 | if tool == "claude", let located = BinaryLocator.resolveClaudeBinary() { return located } |
| 399 | − if tool == "ccusage" || tool == "ccusage-codex" { | |
| 400 | − let env = ProcessInfo.processInfo.environment | |
| 401 | − if let shellHit = ShellCommandLocator.commandV(tool, env["SHELL"], 2.0, .default), | |
| 402 | − FileManager.default.isExecutableFile(atPath: shellHit) | |
| 403 | − { | |
| 404 | − return shellHit | |
| 405 | − } | |
| 406 | − } | |
| 407 | 399 | return self.runWhich(tool) |
| 408 | 400 | } |
| 409 | 401 | |
| @@ -61,46 +61,6 @@ public enum UsageFormatter { · 60 unmodified lines | ||
| 61 | 61 | return "\(formatted) left" |
| 62 | 62 | } |
| 63 | 63 | |
| 64 | − public static func usdString(_ value: Double) -> String { | |
| 65 | − let formatter = NumberFormatter() | |
| 66 | − formatter.numberStyle = .currency | |
| 67 | − formatter.currencyCode = "USD" | |
| 68 | − formatter.maximumFractionDigits = 2 | |
| 69 | − formatter.minimumFractionDigits = 2 | |
| 70 | − formatter.locale = Locale(identifier: "en_US_POSIX") | |
| 71 | − return formatter.string(from: NSNumber(value: value)) ?? String(format: "$%.2f", value) | |
| 72 | − } | |
| 73 | − | |
| 74 | − public static func tokenCountString(_ value: Int) -> String { | |
| 75 | − let absValue = abs(value) | |
| 76 | − let sign = value < 0 ? "-" : "" | |
| 77 | − | |
| 78 | − let units: [(threshold: Int, divisor: Double, suffix: String)] = [ | |
| 79 | − (1_000_000_000, 1_000_000_000, "B"), | |
| 80 | − (1_000_000, 1_000_000, "M"), | |
| 81 | − (1000, 1000, "K"), | |
| 82 | − ] | |
| 83 | − | |
| 84 | − for unit in units where absValue >= unit.threshold { | |
| 85 | − let scaled = Double(absValue) / unit.divisor | |
| 86 | − let formatted: String | |
| 87 | − if scaled >= 10 { | |
| 88 | − formatted = String(format: "%.0f", scaled) | |
| 89 | − } else { | |
| 90 | − var s = String(format: "%.1f", scaled) | |
| 91 | − if s.hasSuffix(".0") { s.removeLast(2) } | |
| 92 | − formatted = s | |
| 93 | − } | |
| 94 | − return "\(sign)\(formatted)\(unit.suffix)" | |
| 95 | − } | |
| 96 | − | |
| 97 | − let formatter = NumberFormatter() | |
| 98 | − formatter.numberStyle = .decimal | |
| 99 | − formatter.usesGroupingSeparator = true | |
| 100 | − formatter.locale = Locale(identifier: "en_US_POSIX") | |
| 101 | − return formatter.string(from: NSNumber(value: value)) ?? "\(value)" | |
| 102 | − } | |
| 103 | − | |
| 104 | 64 | public static func creditEventSummary(_ event: CreditEvent) -> String { |
| 105 | 65 | let formatter = DateFormatter() |
| 106 | 66 | formatter.dateStyle = .medium |
| @@ -1,127 +0,0 @@ | ||
| 1 | −import Foundation | |
| 2 | −import Testing | |
| 3 | − | |
| 4 | −@testable import CodexBarCore | |
| 5 | − | |
| 6 | −@Suite | |
| 7 | −struct CCUsageDecodingTests { | |
| 8 | − @Test | |
| 9 | − func decodesDailyReportTypeFormat() throws { | |
| 10 | − let json = """ | |
| 11 | − { | |
| 12 | − "type": "daily", | |
| 13 | − "data": [ | |
| 14 | − { | |
| 15 | − "date": "2025-12-20", | |
| 16 | − "inputTokens": 10, | |
| 17 | − "outputTokens": 20, | |
| 18 | − "totalTokens": 30, | |
| 19 | − "costUSD": 0.12 | |
| 20 | − } | |
| 21 | − ], | |
| 22 | − "summary": { | |
| 23 | − "totalInputTokens": 10, | |
| 24 | − "totalOutputTokens": 20, | |
| 25 | − "totalTokens": 30, | |
| 26 | − "totalCostUSD": 0.12 | |
| 27 | − } | |
| 28 | − } | |
| 29 | − """ | |
| 30 | − | |
| 31 | − let report = try JSONDecoder().decode(CCUsageDailyReport.self, from: Data(json.utf8)) | |
| 32 | − #expect(report.data.count == 1) | |
| 33 | − #expect(report.data[0].date == "2025-12-20") | |
| 34 | − #expect(report.data[0].totalTokens == 30) | |
| 35 | − #expect(report.data[0].costUSD == 0.12) | |
| 36 | − #expect(report.summary?.totalCostUSD == 0.12) | |
| 37 | − } | |
| 38 | − | |
| 39 | − @Test | |
| 40 | − func decodesDailyReportLegacyFormat() throws { | |
| 41 | − let json = """ | |
| 42 | − { | |
| 43 | − "daily": [ | |
| 44 | − { | |
| 45 | − "date": "2025-12-20", | |
| 46 | − "inputTokens": 1, | |
| 47 | − "outputTokens": 2, | |
| 48 | − "totalTokens": 3, | |
| 49 | − "totalCost": 0.01 | |
| 50 | − } | |
| 51 | − ], | |
| 52 | − "totals": { | |
| 53 | − "totalInputTokens": 1, | |
| 54 | − "totalOutputTokens": 2, | |
| 55 | − "totalTokens": 3, | |
| 56 | − "totalCost": 0.01 | |
| 57 | − } | |
| 58 | − } | |
| 59 | − """ | |
| 60 | − | |
| 61 | − let report = try JSONDecoder().decode(CCUsageDailyReport.self, from: Data(json.utf8)) | |
| 62 | − #expect(report.data.count == 1) | |
| 63 | − #expect(report.summary?.totalTokens == 3) | |
| 64 | − #expect(report.summary?.totalCostUSD == 0.01) | |
| 65 | − } | |
| 66 | − | |
| 67 | − @Test | |
| 68 | − func decodesMonthlyReportLegacyFormat() throws { | |
| 69 | − let json = """ | |
| 70 | − { | |
| 71 | − "monthly": [ | |
| 72 | − { | |
| 73 | − "month": "Dec 2025", | |
| 74 | − "totalTokens": 123, | |
| 75 | − "costUSD": 4.56 | |
| 76 | − } | |
| 77 | − ], | |
| 78 | − "totals": { | |
| 79 | − "totalTokens": 123, | |
| 80 | − "costUSD": 4.56 | |
| 81 | − } | |
| 82 | − } | |
| 83 | − """ | |
| 84 | − | |
| 85 | − let report = try JSONDecoder().decode(CCUsageMonthlyReport.self, from: Data(json.utf8)) | |
| 86 | − #expect(report.data.count == 1) | |
| 87 | − #expect(report.data[0].month == "Dec 2025") | |
| 88 | − #expect(report.data[0].costUSD == 4.56) | |
| 89 | − #expect(report.summary?.totalCostUSD == 4.56) | |
| 90 | − } | |
| 91 | − | |
| 92 | − @Test | |
| 93 | − func selectsMostRecentSession() throws { | |
| 94 | − let json = """ | |
| 95 | − { | |
| 96 | − "type": "session", | |
| 97 | − "data": [ | |
| 98 | − { | |
| 99 | − "session": "A", | |
| 100 | − "totalTokens": 100, | |
| 101 | − "costUSD": 0.50, | |
| 102 | − "lastActivity": "2025-12-19" | |
| 103 | − }, | |
| 104 | − { | |
| 105 | − "session": "B", | |
| 106 | − "totalTokens": 50, | |
| 107 | − "costUSD": 0.20, | |
| 108 | − "lastActivity": "2025-12-20T12:00:00Z" | |
| 109 | − }, | |
| 110 | − { | |
| 111 | − "session": "C", | |
| 112 | − "totalTokens": 200, | |
| 113 | − "costUSD": 0.10, | |
| 114 | − "lastActivity": "2025-12-20T11:00:00Z" | |
| 115 | − } | |
| 116 | − ], | |
| 117 | − "summary": { | |
| 118 | − "totalCostUSD": 0.80 | |
| 119 | − } | |
| 120 | − } | |
| 121 | − """ | |
| 122 | − | |
| 123 | − let report = try JSONDecoder().decode(CCUsageSessionReport.self, from: Data(json.utf8)) | |
| 124 | − let selected = CCUsageFetcher.selectCurrentSession(from: report.data) | |
| 125 | − #expect(selected?.session == "B") | |
| 126 | − } | |
| 127 | −} | |
| @@ -45,13 +45,10 @@ struct MenuCardModelTests { · 44 unmodified lines | ||
| 45 | 45 | creditsError: nil, |
| 46 | 46 | dashboard: nil, |
| 47 | 47 | dashboardError: nil, |
| 48 | − tokenSnapshot: nil, | |
| 49 | − tokenError: nil, | |
| 50 | 48 | account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), |
| 51 | 49 | isRefreshing: false, |
| 52 | 50 | lastError: nil, |
| 53 | − usageBarsShowUsed: false, | |
| 54 | − tokenCostUsageEnabled: false)) | |
| 51 | + usageBarsShowUsed: false)) | |
| 55 | 52 | |
| 56 | 53 | #expect(model.providerName == "Codex") |
| 57 | 54 | #expect(model.metrics.count == 2) |
| @@ -97,13 +94,10 @@ struct MenuCardModelTests { · 39 unmodified lines | ||
| 97 | 94 | creditsError: nil, |
| 98 | 95 | dashboard: dashboard, |
| 99 | 96 | dashboardError: nil, |
| 100 | − tokenSnapshot: nil, | |
| 101 | − tokenError: nil, | |
| 102 | 97 | account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), |
| 103 | 98 | isRefreshing: false, |
| 104 | 99 | lastError: nil, |
| 105 | − usageBarsShowUsed: true, | |
| 106 | − tokenCostUsageEnabled: false)) | |
| 100 | + usageBarsShowUsed: true)) | |
| 107 | 101 | |
| 108 | 102 | #expect(model.metrics.first?.title == "Session") |
| 109 | 103 | #expect(model.metrics.first?.percent == 22) |
| @@ -139,13 +133,10 @@ struct MenuCardModelTests { · 29 unmodified lines | ||
| 139 | 133 | creditsError: nil, |
| 140 | 134 | dashboard: dashboard, |
| 141 | 135 | dashboardError: nil, |
| 142 | − tokenSnapshot: nil, | |
| 143 | − tokenError: nil, | |
| 144 | 136 | account: AccountInfo(email: "codex@example.com", plan: nil), |
| 145 | 137 | isRefreshing: false, |
| 146 | 138 | lastError: nil, |
| 147 | − usageBarsShowUsed: false, | |
| 148 | − tokenCostUsageEnabled: false)) | |
| 139 | + usageBarsShowUsed: false)) | |
| 149 | 140 | |
| 150 | 141 | #expect(model.metrics.contains { $0.title == "Code review" && $0.percent == 73 }) |
| 151 | 142 | } |
| @@ -174,13 +165,10 @@ struct MenuCardModelTests { · 22 unmodified lines | ||
| 174 | 165 | creditsError: nil, |
| 175 | 166 | dashboard: nil, |
| 176 | 167 | dashboardError: nil, |
| 177 | − tokenSnapshot: nil, | |
| 178 | − tokenError: nil, | |
| 179 | 168 | account: AccountInfo(email: "codex@example.com", plan: "plus"), |
| 180 | 169 | isRefreshing: false, |
| 181 | 170 | lastError: nil, |
| 182 | − usageBarsShowUsed: false, | |
| 183 | − tokenCostUsageEnabled: false)) | |
| 171 | + usageBarsShowUsed: false)) | |
| 184 | 172 | |
| 185 | 173 | #expect(model.metrics.count == 1) |
| 186 | 174 | #expect(model.metrics.first?.title == "Session") |
| @@ -198,13 +186,10 @@ struct MenuCardModelTests { · 11 unmodified lines | ||
| 198 | 186 | creditsError: nil, |
| 199 | 187 | dashboard: nil, |
| 200 | 188 | dashboardError: nil, |
| 201 | − tokenSnapshot: nil, | |
| 202 | − tokenError: nil, | |
| 203 | 189 | account: AccountInfo(email: nil, plan: nil), |
| 204 | 190 | isRefreshing: false, |
| 205 | 191 | lastError: "Probe failed for Codex", |
| 206 | − usageBarsShowUsed: false, | |
| 207 | − tokenCostUsageEnabled: false)) | |
| 192 | + usageBarsShowUsed: false)) | |
| 208 | 193 | |
| 209 | 194 | #expect(model.subtitleStyle == .error) |
| 210 | 195 | #expect(model.subtitleText.contains("Probe failed")) |
| @@ -222,13 +207,10 @@ struct MenuCardModelTests { · 11 unmodified lines | ||
| 222 | 207 | creditsError: nil, |
| 223 | 208 | dashboard: nil, |
| 224 | 209 | dashboardError: nil, |
| 225 | − tokenSnapshot: nil, | |
| 226 | − tokenError: nil, | |
| 227 | 210 | account: AccountInfo(email: "codex@example.com", plan: "plus"), |
| 228 | 211 | isRefreshing: false, |
| 229 | 212 | lastError: nil, |
| 230 | − usageBarsShowUsed: false, | |
| 231 | − tokenCostUsageEnabled: false)) | |
| 213 | + usageBarsShowUsed: false)) | |
| 232 | 214 | |
| 233 | 215 | #expect(model.planText == nil) |
| 234 | 216 | #expect(model.email.isEmpty) |