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 | case error | 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 | let providerName: String | 44 | let providerName: String |
| 52 | let email: String | 45 | let email: String |
| 53 | let subtitleText: String | 46 | let subtitleText: String |
| @@ -56,7 +49,6 @@ struct UsageMenuCardView: View { · 2 unmodified lines | |||
| 56 | let metrics: [Metric] | 49 | let metrics: [Metric] |
| 57 | let creditsText: String? | 50 | let creditsText: String? |
| 58 | let creditsHintText: String? | 51 | let creditsHintText: String? |
| 59 | let tokenUsage: TokenUsageSection? | ||
| 60 | let placeholder: String? | 52 | let placeholder: String? |
| 61 | let progressColor: Color | 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 | .padding(.bottom, self.model.creditsText == nil ? 8 : 2) | 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 | private var hasDetails: Bool { | 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 | private var subtitleColor: Color { | 150 | private var subtitleColor: Color { |
| @@ -203,13 +167,10 @@ extension UsageMenuCardView.Model { · 16 unmodified lines | |||
| 203 | let creditsError: String? | 167 | let creditsError: String? |
| 204 | let dashboard: OpenAIDashboardSnapshot? | 168 | let dashboard: OpenAIDashboardSnapshot? |
| 205 | let dashboardError: String? | 169 | let dashboardError: String? |
| 206 | let tokenSnapshot: CCUsageTokenSnapshot? | ||
| 207 | let tokenError: String? | ||
| 208 | let account: AccountInfo | 170 | let account: AccountInfo |
| 209 | let isRefreshing: Bool | 171 | let isRefreshing: Bool |
| 210 | let lastError: String? | 172 | let lastError: String? |
| 211 | let usageBarsShowUsed: Bool | 173 | let usageBarsShowUsed: Bool |
| 212 | let tokenCostUsageEnabled: Bool | ||
| 213 | } | 174 | } |
| 214 | 175 | ||
| 215 | static func make(_ input: Input) -> UsageMenuCardView.Model { | 176 | static func make(_ input: Input) -> UsageMenuCardView.Model { |
| @@ -226,11 +187,6 @@ extension UsageMenuCardView.Model { · 10 unmodified lines | |||
| 226 | usageBarsShowUsed: input.usageBarsShowUsed) | 187 | usageBarsShowUsed: input.usageBarsShowUsed) |
| 227 | let creditsText = Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) | 188 | let creditsText = Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) |
| 228 | let creditsHintText = Self.dashboardHint(provider: input.provider, error: input.dashboardError) | 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 | let subtitle = Self.subtitle( | 190 | let subtitle = Self.subtitle( |
| 235 | snapshot: input.snapshot, | 191 | snapshot: input.snapshot, |
| 236 | isRefreshing: input.isRefreshing, | 192 | isRefreshing: input.isRefreshing, |
| @@ -246,7 +202,6 @@ extension UsageMenuCardView.Model { · 9 unmodified lines | |||
| 246 | metrics: metrics, | 202 | metrics: metrics, |
| 247 | creditsText: creditsText, | 203 | creditsText: creditsText, |
| 248 | creditsHintText: creditsHintText, | 204 | creditsHintText: creditsHintText, |
| 249 | tokenUsage: tokenUsage, | ||
| 250 | placeholder: placeholder, | 205 | placeholder: placeholder, |
| 251 | progressColor: Self.progressColor(for: input.provider)) | 206 | progressColor: Self.progressColor(for: input.provider)) |
| 252 | } | 207 | } |
| @@ -368,34 +323,6 @@ extension UsageMenuCardView.Model { · 115 unmodified lines | |||
| 368 | return UsageFormatter.truncatedSingleLine(error, max: 100) | 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 | private static func clamped(_ value: Double) -> Double { | 326 | private static func clamped(_ value: Double) -> Double { |
| 400 | min(100, max(0, value)) | 327 | min(100, max(0, value)) |
| 401 | } | 328 | } |
| @@ -1,5 +1,4 @@ | |||
| 1 | import AppKit | 1 | import AppKit |
| 2 | import CodexBarCore | ||
| 3 | import SwiftUI | 2 | import SwiftUI |
| 4 | 3 | ||
| 5 | @MainActor | 4 | @MainActor |
| @@ -10,11 +9,6 @@ struct AdvancedPane: View { · 4 unmodified lines | |||
| 10 | @State private var cliStatus: String? | 9 | @State private var cliStatus: String? |
| 11 | 10 | ||
| 12 | var body: some View { | 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 | ScrollView(.vertical, showsIndicators: true) { | 12 | ScrollView(.vertical, showsIndicators: true) { |
| 19 | VStack(alignment: .leading, spacing: 16) { | 13 | VStack(alignment: .leading, spacing: 16) { |
| 20 | SettingsSection(contentSpacing: 6) { | 14 | SettingsSection(contentSpacing: 6) { |
| @@ -58,42 +52,6 @@ struct AdvancedPane: View { · 37 unmodified lines | |||
| 58 | 52 | ||
| 59 | Divider() | 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 | SettingsSection(contentSpacing: 12) { | 55 | SettingsSection(contentSpacing: 12) { |
| 98 | Text("Status") | 56 | Text("Status") |
| 99 | .font(.caption) | 57 | .font(.caption) |
| @@ -145,58 +103,6 @@ struct AdvancedPane: View { · 45 unmodified lines | |||
| 145 | .padding(.horizontal, 20) | 103 | .padding(.horizontal, 20) |
| 146 | .padding(.vertical, 12) | 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 | // MARK: - CLI installer | 108 | // MARK: - CLI installer |
| @@ -63,13 +63,6 @@ final class SettingsStore: ObservableObject { · 62 unmodified lines | |||
| 63 | didSet { self.objectWillChange.send() } | 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 | @AppStorage("randomBlinkEnabled") var randomBlinkEnabled: Bool = false { | 66 | @AppStorage("randomBlinkEnabled") var randomBlinkEnabled: Bool = false { |
| 74 | didSet { self.objectWillChange.send() } | 67 | didSet { self.objectWillChange.send() } |
| 75 | } | 68 | } |
| @@ -108,13 +101,6 @@ final class SettingsStore: ObservableObject { · 32 unmodified lines | |||
| 108 | private let userDefaults: UserDefaults | 101 | private let userDefaults: UserDefaults |
| 109 | private let toggleStore: ProviderToggleStore | 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 | init(userDefaults: UserDefaults = .standard) { | 104 | init(userDefaults: UserDefaults = .standard) { |
| 119 | self.userDefaults = userDefaults | 105 | self.userDefaults = userDefaults |
| 120 | if userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") == nil { | 106 | if userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") == nil { |
| @@ -126,7 +112,6 @@ final class SettingsStore: ObservableObject { · 5 unmodified lines | |||
| 126 | self.toggleStore.purgeLegacyKeys() | 112 | self.toggleStore.purgeLegacyKeys() |
| 127 | LaunchAtLoginManager.setEnabled(self.launchAtLogin) | 113 | LaunchAtLoginManager.setEnabled(self.launchAtLogin) |
| 128 | self.runInitialProviderDetectionIfNeeded() | 114 | self.runInitialProviderDetectionIfNeeded() |
| 129 | self.refreshCCUsageAvailability() | ||
| 130 | } | 115 | } |
| 131 | 116 | ||
| 132 | func isProviderEnabled(provider: UsageProvider, metadata: ProviderMetadata) -> Bool { | 117 | func isProviderEnabled(provider: UsageProvider, metadata: ProviderMetadata) -> Bool { |
| @@ -144,74 +129,6 @@ final class SettingsStore: ObservableObject { · 11 unmodified lines | |||
| 144 | 129 | ||
| 145 | // MARK: - Private | 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 | private func runInitialProviderDetectionIfNeeded(force: Bool = false) { | 132 | private func runInitialProviderDetectionIfNeeded(force: Bool = false) { |
| 216 | guard force || !self.providerDetectionCompleted else { return } | 133 | guard force || !self.providerDetectionCompleted else { return } |
| 217 | guard let codexMeta = ProviderRegistry.shared.metadata[.codex], | 134 | guard let codexMeta = ProviderRegistry.shared.metadata[.codex], |
| @@ -247,29 +164,6 @@ final class SettingsStore: ObservableObject { · 29 unmodified lines | |||
| 247 | self.toggleStore.setEnabled(enableGemini, metadata: geminiMeta) | 164 | self.toggleStore.setEnabled(enableGemini, metadata: geminiMeta) |
| 248 | self.providerDetectionCompleted = true | 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 | enum LaunchAtLoginManager { | 169 | enum LaunchAtLoginManager { |
| @@ -6,7 +6,7 @@ extension StatusItemController { · 5 unmodified lines | |||
| 6 | // MARK: - Actions reachable from menus | 6 | // MARK: - Actions reachable from menus |
| 7 | 7 | ||
| 8 | @objc func refreshNow() { | 8 | @objc func refreshNow() { |
| 9 | Task { await self.store.refresh(forceTokenUsage: true) } | 9 | Task { await self.store.refresh() } |
| 10 | } | 10 | } |
| 11 | 11 | ||
| 12 | @objc func openDashboard() { | 12 | @objc func openDashboard() { |
| @@ -50,10 +50,7 @@ extension StatusItemController { · 49 unmodified lines | |||
| 50 | dashboard != nil | 50 | dashboard != nil |
| 51 | let hasCreditsHistory = openAIWebEligible && !(dashboard?.creditEvents ?? []).isEmpty | 51 | let hasCreditsHistory = openAIWebEligible && !(dashboard?.creditEvents ?? []).isEmpty |
| 52 | let hasUsageBreakdown = openAIWebEligible && !(dashboard?.usageBreakdown ?? []).isEmpty | 52 | let hasUsageBreakdown = openAIWebEligible && !(dashboard?.usageBreakdown ?? []).isEmpty |
| 53 | let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex | 53 | let hasOpenAIWebMenuItems = hasCreditsHistory || hasUsageBreakdown |
| 54 | let hasCostHistory = self.settings.isCCUsageCostUsageEffectivelyEnabled(for: currentProvider) && | ||
| 55 | (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) | ||
| 56 | let hasOpenAIWebMenuItems = hasCreditsHistory || hasUsageBreakdown || hasCostHistory | ||
| 57 | 54 | ||
| 58 | if enabledProviders.count > 1 { | 55 | if enabledProviders.count > 1 { |
| 59 | let switcherItem = self.makeProviderSwitcherItem( | 56 | let switcherItem = self.makeProviderSwitcherItem( |
| @@ -82,16 +79,13 @@ extension StatusItemController { · 22 unmodified lines | |||
| 82 | } | 79 | } |
| 83 | 80 | ||
| 84 | if hasOpenAIWebMenuItems { | 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 | if hasCreditsHistory { | 83 | if hasCreditsHistory { |
| 87 | _ = self.addCreditsHistorySubmenu(to: menu) | 84 | _ = self.addCreditsHistorySubmenu(to: menu) |
| 88 | } | 85 | } |
| 89 | if hasUsageBreakdown { | 86 | if hasUsageBreakdown { |
| 90 | _ = self.addUsageBreakdownSubmenu(to: menu) | 87 | _ = self.addUsageBreakdownSubmenu(to: menu) |
| 91 | } | 88 | } |
| 92 | if hasCostHistory { | ||
| 93 | _ = self.addCostHistorySubmenu(to: menu, provider: currentProvider) | ||
| 94 | } | ||
| 95 | menu.addItem(.separator()) | 89 | menu.addItem(.separator()) |
| 96 | } | 90 | } |
| 97 | 91 | ||
| @@ -381,36 +375,6 @@ extension StatusItemController { · 283 unmodified lines | |||
| 381 | return true | 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 | private func menuCardModel(for provider: UsageProvider?) -> UsageMenuCardView.Model? { | 378 | private func menuCardModel(for provider: UsageProvider?) -> UsageMenuCardView.Model? { |
| 415 | let target = provider ?? self.store.enabledProviders().first ?? .codex | 379 | let target = provider ?? self.store.enabledProviders().first ?? .codex |
| 416 | let metadata = self.store.metadata(for: target) | 380 | let metadata = self.store.metadata(for: target) |
| @@ -420,29 +384,16 @@ extension StatusItemController { · 3 unmodified lines | |||
| 420 | let creditsError: String? | 384 | let creditsError: String? |
| 421 | let dashboard: OpenAIDashboardSnapshot? | 385 | let dashboard: OpenAIDashboardSnapshot? |
| 422 | let dashboardError: String? | 386 | let dashboardError: String? |
| 423 | let tokenSnapshot: CCUsageTokenSnapshot? | ||
| 424 | let tokenError: String? | ||
| 425 | if target == .codex { | 387 | if target == .codex { |
| 426 | credits = self.store.credits | 388 | credits = self.store.credits |
| 427 | creditsError = self.store.lastCreditsError | 389 | creditsError = self.store.lastCreditsError |
| 428 | dashboard = self.store.openAIDashboardRequiresLogin ? nil : self.store.openAIDashboard | 390 | dashboard = self.store.openAIDashboardRequiresLogin ? nil : self.store.openAIDashboard |
| 429 | dashboardError = self.store.lastOpenAIDashboardError | 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 | } else { | 392 | } else { |
| 440 | credits = nil | 393 | credits = nil |
| 441 | creditsError = nil | 394 | creditsError = nil |
| 442 | dashboard = nil | 395 | dashboard = nil |
| 443 | dashboardError = nil | 396 | dashboardError = nil |
| 444 | tokenSnapshot = nil | ||
| 445 | tokenError = nil | ||
| 446 | } | 397 | } |
| 447 | 398 | ||
| 448 | let input = UsageMenuCardView.Model.Input( | 399 | let input = UsageMenuCardView.Model.Input( |
| @@ -453,13 +404,10 @@ extension StatusItemController { · 4 unmodified lines | |||
| 453 | creditsError: creditsError, | 404 | creditsError: creditsError, |
| 454 | dashboard: dashboard, | 405 | dashboard: dashboard, |
| 455 | dashboardError: dashboardError, | 406 | dashboardError: dashboardError, |
| 456 | tokenSnapshot: tokenSnapshot, | ||
| 457 | tokenError: tokenError, | ||
| 458 | account: self.account, | 407 | account: self.account, |
| 459 | isRefreshing: self.store.isRefreshing, | 408 | isRefreshing: self.store.isRefreshing, |
| 460 | lastError: self.store.error(for: target), | 409 | lastError: self.store.error(for: target), |
| 461 | usageBarsShowUsed: self.settings.usageBarsShowUsed, | 410 | usageBarsShowUsed: self.settings.usageBarsShowUsed) |
| 462 | tokenCostUsageEnabled: self.settings.isCCUsageCostUsageEffectivelyEnabled(for: target)) | ||
| 463 | return UsageMenuCardView.Model.make(input) | 411 | return UsageMenuCardView.Model.make(input) |
| 464 | } | 412 | } |
| 465 | } | 413 | } |
| @@ -495,7 +443,15 @@ private final class ProviderSwitcherView: NSView { · 29 unmodified lines | |||
| 495 | self.onSelect = onSelect | 443 | self.onSelect = onSelect |
| 496 | super.init(frame: NSRect(x: 0, y: 0, width: width, height: 30)) | 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 | let button = PaddedToggleButton( | 455 | let button = PaddedToggleButton( |
| 500 | title: segment.title, | 456 | title: segment.title, |
| 501 | target: self, | 457 | target: self, |
| @@ -513,67 +469,17 @@ private final class ProviderSwitcherView: NSView { · 11 unmodified lines | |||
| 513 | button.wantsLayer = true | 469 | button.wantsLayer = true |
| 514 | button.layer?.cornerRadius = 6 | 470 | button.layer?.cornerRadius = 6 |
| 515 | button.state = (selected == segment.provider) ? .on : .off | 471 | button.state = (selected == segment.provider) ? .on : .off |
| 516 | button.translatesAutoresizingMaskIntoConstraints = false | 472 | stack.addArrangedSubview(button) |
| 517 | self.buttons.append(button) | 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 | 476 | self.addSubview(stack) |
| 535 | let minimumGap: CGFloat = 8 | 477 | stack.translatesAutoresizingMaskIntoConstraints = false |
| 536 | 478 | NSLayoutConstraint.activate([ | |
| 537 | if self.buttons.count == 2 { | 479 | stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8), |
| 538 | let left = self.buttons[0] | 480 | stack.centerYAnchor.constraint(equalTo: self.centerYAnchor), |
| 539 | let right = self.buttons[1] | 481 | stack.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -8), |
| 540 | let gap = right.leadingAnchor.constraint(greaterThanOrEqualTo: left.trailingAnchor, constant: minimumGap) | 482 | ]) |
| 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 | } | ||
| 577 | 483 | ||
| 578 | self.updateButtonStyles() | 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 | private static func paddedImage(_ image: NSImage, leading: CGFloat) -> NSImage { | 510 | private static func paddedImage(_ image: NSImage, leading: CGFloat) -> NSImage { |
| 620 | let size = NSSize(width: image.size.width + leading, height: image.size.height) | 511 | let size = NSSize(width: image.size.width + leading, height: image.size.height) |
| 621 | let newImage = NSImage(size: size) | 512 | let newImage = NSImage(size: size) |
| @@ -33,20 +33,11 @@ struct UsageBreakdownChartMenuView: View { · 32 unmodified lines | |||
| 33 | .font(.footnote) | 33 | .font(.footnote) |
| 34 | .foregroundStyle(.secondary) | 34 | .foregroundStyle(.secondary) |
| 35 | } else { | 35 | } else { |
| 36 | Chart { | 36 | Chart(model.points) { point in |
| 37 | ForEach(model.points) { point in | 37 | BarMark( |
| 38 | BarMark( | 38 | x: .value("Day", point.date, unit: .day), |
| 39 | x: .value("Day", point.date, unit: .day), | 39 | y: .value("Credits used", point.creditsUsed)) |
| 40 | y: .value("Credits used", point.creditsUsed)) | 40 | .foregroundStyle(by: .value("Service", point.service)) |
| 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 | } | ||
| 50 | } | 41 | } |
| 51 | .chartForegroundStyleScale(domain: model.services, range: model.serviceColors) | 42 | .chartForegroundStyleScale(domain: model.services, range: model.serviceColors) |
| 52 | .chartYAxis(.hidden) | 43 | .chartYAxis(.hidden) |
| @@ -55,28 +46,18 @@ struct UsageBreakdownChartMenuView: View { · 2 unmodified lines | |||
| 55 | AxisGridLine().foregroundStyle(Color.clear) | 46 | AxisGridLine().foregroundStyle(Color.clear) |
| 56 | AxisTick().foregroundStyle(Color.clear) | 47 | AxisTick().foregroundStyle(Color.clear) |
| 57 | AxisValueLabel(format: .dateTime.month(.abbreviated).day()) | 48 | AxisValueLabel(format: .dateTime.month(.abbreviated).day()) |
| 58 | .font(.caption2) | 49 | .foregroundStyle(.secondary) |
| 59 | .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) | ||
| 60 | } | 50 | } |
| 61 | } | 51 | } |
| 62 | .chartLegend(.hidden) | 52 | .chartLegend(.hidden) |
| 63 | .frame(height: 130) | 53 | .frame(height: 130) |
| 64 | .chartOverlay { proxy in | 54 | .chartOverlay { proxy in |
| 65 | GeometryReader { geo in | 55 | GeometryReader { geo in |
| 66 | ZStack(alignment: .topLeading) { | 56 | MouseLocationReader { location in |
| 67 | if let rect = self.selectionBandRect(model: model, proxy: proxy, geo: geo) { | 57 | self.updateSelection(location: location, 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()) | ||
| 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 | let points: [Point] | 96 | let points: [Point] |
| 116 | let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] | 97 | let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] |
| 117 | let dayDates: [(dayKey: String, date: Date)] | 98 | let dayDates: [(dayKey: String, date: Date)] |
| 118 | let peakPoint: (date: Date, creditsUsed: Double)? | ||
| 119 | let services: [String] | 99 | let services: [String] |
| 120 | let serviceColors: [Color] | 100 | let serviceColors: [Color] |
| 121 | let axisDates: [Date] | 101 | let axisDates: [Date] |
| @@ -141,19 +121,10 @@ struct UsageBreakdownChartMenuView: View { · 19 unmodified lines | |||
| 141 | var dayDates: [(dayKey: String, date: Date)] = [] | 121 | var dayDates: [(dayKey: String, date: Date)] = [] |
| 142 | dayDates.reserveCapacity(sorted.count) | 122 | dayDates.reserveCapacity(sorted.count) |
| 143 | 123 | ||
| 144 | var peak: (date: Date, creditsUsed: Double)? | ||
| 145 | |||
| 146 | for day in sorted { | 124 | for day in sorted { |
| 147 | guard let date = self.dateFromDayKey(day.day) else { continue } | 125 | guard let date = self.dateFromDayKey(day.day) else { continue } |
| 148 | breakdownByDayKey[day.day] = day | 126 | breakdownByDayKey[day.day] = day |
| 149 | dayDates.append((dayKey: day.day, date: date)) | 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 | for service in day.services where service.creditsUsed > 0 { | 128 | for service in day.services where service.creditsUsed > 0 { |
| 158 | points.append(Point(date: date, service: service.service, creditsUsed: service.creditsUsed)) | 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 | points: points, | 138 | points: points, |
| 168 | breakdownByDayKey: breakdownByDayKey, | 139 | breakdownByDayKey: breakdownByDayKey, |
| 169 | dayDates: dayDates, | 140 | dayDates: dayDates, |
| 170 | peakPoint: peak, | ||
| 171 | services: services, | 141 | services: services, |
| 172 | serviceColors: colors, | 142 | serviceColors: colors, |
| 173 | axisDates: axisDates) | 143 | axisDates: axisDates) |
| @@ -241,43 +211,6 @@ struct UsageBreakdownChartMenuView: View { · 67 unmodified lines | |||
| 241 | return comps.date | 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 | private func updateSelection( | 214 | private func updateSelection( |
| 282 | location: CGPoint?, | 215 | location: CGPoint?, |
| 283 | model: Model, | 216 | model: Model, |
| @@ -68,9 +68,6 @@ struct ConsecutiveFailureGate { · 67 unmodified lines | |||
| 68 | final class UsageStore: ObservableObject { | 68 | final class UsageStore: ObservableObject { |
| 69 | @Published private var snapshots: [UsageProvider: UsageSnapshot] = [:] | 69 | @Published private var snapshots: [UsageProvider: UsageSnapshot] = [:] |
| 70 | @Published private var errors: [UsageProvider: String] = [:] | 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 | @Published var credits: CreditsSnapshot? | 71 | @Published var credits: CreditsSnapshot? |
| 75 | @Published var lastCreditsError: String? | 72 | @Published var lastCreditsError: String? |
| 76 | @Published var openAIDashboard: OpenAIDashboardSnapshot? | 73 | @Published var openAIDashboard: OpenAIDashboardSnapshot? |
| @@ -98,7 +95,6 @@ final class UsageStore: ObservableObject { · 21 unmodified lines | |||
| 98 | 95 | ||
| 99 | private let codexFetcher: UsageFetcher | 96 | private let codexFetcher: UsageFetcher |
| 100 | private let claudeFetcher: any ClaudeUsageFetching | 97 | private let claudeFetcher: any ClaudeUsageFetching |
| 101 | private let ccusageFetcher: CCUsageFetcher | ||
| 102 | private let registry: ProviderRegistry | 98 | private let registry: ProviderRegistry |
| 103 | private let settings: SettingsStore | 99 | private let settings: SettingsStore |
| 104 | private let sessionQuotaNotifier: SessionQuotaNotifier | 100 | private let sessionQuotaNotifier: SessionQuotaNotifier |
| @@ -106,27 +102,21 @@ final class UsageStore: ObservableObject { · 1 unmodified line | |||
| 106 | private let openAIWebLogger = Logger(subsystem: "com.steipete.codexbar", category: "openai-web") | 102 | private let openAIWebLogger = Logger(subsystem: "com.steipete.codexbar", category: "openai-web") |
| 107 | private var openAIWebDebugLines: [String] = [] | 103 | private var openAIWebDebugLines: [String] = [] |
| 108 | private var failureGates: [UsageProvider: ConsecutiveFailureGate] = [:] | 104 | private var failureGates: [UsageProvider: ConsecutiveFailureGate] = [:] |
| 109 | private var tokenFailureGates: [UsageProvider: ConsecutiveFailureGate] = [:] | ||
| 110 | private var providerSpecs: [UsageProvider: ProviderSpec] = [:] | 105 | private var providerSpecs: [UsageProvider: ProviderSpec] = [:] |
| 111 | private let providerMetadata: [UsageProvider: ProviderMetadata] | 106 | private let providerMetadata: [UsageProvider: ProviderMetadata] |
| 112 | private var timerTask: Task<Void, Never>? | 107 | private var timerTask: Task<Void, Never>? |
| 113 | private var tokenTimerTask: Task<Void, Never>? | ||
| 114 | private var cancellables = Set<AnyCancellable>() | 108 | private var cancellables = Set<AnyCancellable>() |
| 115 | private var lastKnownSessionRemaining: [UsageProvider: Double] = [:] | 109 | private var lastKnownSessionRemaining: [UsageProvider: Double] = [:] |
| 116 | private var lastTokenFetchAt: [UsageProvider: Date] = [:] | ||
| 117 | private let tokenFetchTTL: TimeInterval = 60 * 60 | ||
| 118 | 110 | ||
| 119 | init( | 111 | init( |
| 120 | fetcher: UsageFetcher, | 112 | fetcher: UsageFetcher, |
| 121 | claudeFetcher: any ClaudeUsageFetching = ClaudeUsageFetcher(), | 113 | claudeFetcher: any ClaudeUsageFetching = ClaudeUsageFetcher(), |
| 122 | ccusageFetcher: CCUsageFetcher = CCUsageFetcher(), | ||
| 123 | settings: SettingsStore, | 114 | settings: SettingsStore, |
| 124 | registry: ProviderRegistry = .shared, | 115 | registry: ProviderRegistry = .shared, |
| 125 | sessionQuotaNotifier: SessionQuotaNotifier = SessionQuotaNotifier()) | 116 | sessionQuotaNotifier: SessionQuotaNotifier = SessionQuotaNotifier()) |
| 126 | { | 117 | { |
| 127 | self.codexFetcher = fetcher | 118 | self.codexFetcher = fetcher |
| 128 | self.claudeFetcher = claudeFetcher | 119 | self.claudeFetcher = claudeFetcher |
| 129 | self.ccusageFetcher = ccusageFetcher | ||
| 130 | self.settings = settings | 120 | self.settings = settings |
| 131 | self.registry = registry | 121 | self.registry = registry |
| 132 | self.sessionQuotaNotifier = sessionQuotaNotifier | 122 | self.sessionQuotaNotifier = sessionQuotaNotifier |
| @@ -134,8 +124,6 @@ final class UsageStore: ObservableObject { · 1 unmodified line | |||
| 134 | self | 124 | self |
| 135 | .failureGates = Dictionary(uniqueKeysWithValues: UsageProvider.allCases | 125 | .failureGates = Dictionary(uniqueKeysWithValues: UsageProvider.allCases |
| 136 | .map { ($0, ConsecutiveFailureGate()) }) | 126 | .map { ($0, ConsecutiveFailureGate()) }) |
| 137 | self.tokenFailureGates = Dictionary(uniqueKeysWithValues: UsageProvider.allCases | ||
| 138 | .map { ($0, ConsecutiveFailureGate()) }) | ||
| 139 | self.providerSpecs = registry.specs( | 127 | self.providerSpecs = registry.specs( |
| 140 | settings: settings, | 128 | settings: settings, |
| 141 | metadata: self.providerMetadata, | 129 | metadata: self.providerMetadata, |
| @@ -149,7 +137,6 @@ final class UsageStore: ObservableObject { · 7 unmodified lines | |||
| 149 | } | 137 | } |
| 150 | Task { await self.refresh() } | 138 | Task { await self.refresh() } |
| 151 | self.startTimer() | 139 | self.startTimer() |
| 152 | self.startTokenTimer() | ||
| 153 | } | 140 | } |
| 154 | 141 | ||
| 155 | var codexSnapshot: UsageSnapshot? { self.snapshots[.codex] } | 142 | var codexSnapshot: UsageSnapshot? { self.snapshots[.codex] } |
| @@ -243,7 +230,7 @@ final class UsageStore: ObservableObject { · 87 unmodified lines | |||
| 243 | self.settings.isProviderEnabled(provider: provider, metadata: self.metadata(for: provider)) | 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 | guard !self.isRefreshing else { return } | 234 | guard !self.isRefreshing else { return } |
| 248 | self.isRefreshing = true | 235 | self.isRefreshing = true |
| 249 | defer { self.isRefreshing = false } | 236 | defer { self.isRefreshing = false } |
| @@ -256,9 +243,6 @@ final class UsageStore: ObservableObject { · 6 unmodified lines | |||
| 256 | group.addTask { await self.refreshCreditsIfNeeded() } | 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 | // OpenAI web scrape depends on the current Codex account email (which can change after login/account switch). | 246 | // OpenAI web scrape depends on the current Codex account email (which can change after login/account switch). |
| 263 | // Run this after Codex usage refresh so we don't accidentally scrape with stale credentials. | 247 | // Run this after Codex usage refresh so we don't accidentally scrape with stale credentials. |
| 264 | await self.refreshOpenAIDashboardIfNeeded() | 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 | deinit { | 298 | deinit { |
| 334 | self.timerTask?.cancel() | 299 | self.timerTask?.cancel() |
| 335 | self.tokenTimerTask?.cancel() | ||
| 336 | } | 300 | } |
| 337 | 301 | ||
| 338 | private func refreshProvider(_ provider: UsageProvider) async { | 302 | private func refreshProvider(_ provider: UsageProvider) async { |
| @@ -342,13 +306,9 @@ final class UsageStore: ObservableObject { · 3 unmodified lines | |||
| 342 | await MainActor.run { | 306 | await MainActor.run { |
| 343 | self.snapshots.removeValue(forKey: provider) | 307 | self.snapshots.removeValue(forKey: provider) |
| 344 | self.errors[provider] = nil | 308 | self.errors[provider] = nil |
| 345 | self.tokenSnapshots.removeValue(forKey: provider) | ||
| 346 | self.tokenErrors[provider] = nil | ||
| 347 | self.failureGates[provider]?.reset() | 309 | self.failureGates[provider]?.reset() |
| 348 | self.tokenFailureGates[provider]?.reset() | ||
| 349 | self.statuses.removeValue(forKey: provider) | 310 | self.statuses.removeValue(forKey: provider) |
| 350 | self.lastKnownSessionRemaining.removeValue(forKey: provider) | 311 | self.lastKnownSessionRemaining.removeValue(forKey: provider) |
| 351 | self.lastTokenFetchAt.removeValue(forKey: provider) | ||
| 352 | } | 312 | } |
| 353 | return | 313 | return |
| 354 | } | 314 | } |
| @@ -955,101 +915,3 @@ final class UsageStore: ObservableObject { · 600 unmodified lines | |||
| 955 | self.pathDebugInfo = PathBuilder.debugSnapshot(purposes: [.rpc, .tty, .nodeTooling]) | 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 | public static func which(_ tool: String) -> String? { | 396 | public static func which(_ tool: String) -> String? { |
| 397 | if tool == "codex", let located = BinaryLocator.resolveCodexBinary() { return located } | 397 | if tool == "codex", let located = BinaryLocator.resolveCodexBinary() { return located } |
| 398 | if tool == "claude", let located = BinaryLocator.resolveClaudeBinary() { return located } | 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 | return self.runWhich(tool) | 399 | return self.runWhich(tool) |
| 408 | } | 400 | } |
| 409 | 401 | ||
| @@ -61,46 +61,6 @@ public enum UsageFormatter { · 60 unmodified lines | |||
| 61 | return "\(formatted) left" | 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 | public static func creditEventSummary(_ event: CreditEvent) -> String { | 64 | public static func creditEventSummary(_ event: CreditEvent) -> String { |
| 105 | let formatter = DateFormatter() | 65 | let formatter = DateFormatter() |
| 106 | formatter.dateStyle = .medium | 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 | creditsError: nil, | 45 | creditsError: nil, |
| 46 | dashboard: nil, | 46 | dashboard: nil, |
| 47 | dashboardError: nil, | 47 | dashboardError: nil, |
| 48 | tokenSnapshot: nil, | ||
| 49 | tokenError: nil, | ||
| 50 | account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), | 48 | account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), |
| 51 | isRefreshing: false, | 49 | isRefreshing: false, |
| 52 | lastError: nil, | 50 | lastError: nil, |
| 53 | usageBarsShowUsed: false, | 51 | usageBarsShowUsed: false)) |
| 54 | tokenCostUsageEnabled: false)) | ||
| 55 | 52 | ||
| 56 | #expect(model.providerName == "Codex") | 53 | #expect(model.providerName == "Codex") |
| 57 | #expect(model.metrics.count == 2) | 54 | #expect(model.metrics.count == 2) |
| @@ -97,13 +94,10 @@ struct MenuCardModelTests { · 39 unmodified lines | |||
| 97 | creditsError: nil, | 94 | creditsError: nil, |
| 98 | dashboard: dashboard, | 95 | dashboard: dashboard, |
| 99 | dashboardError: nil, | 96 | dashboardError: nil, |
| 100 | tokenSnapshot: nil, | ||
| 101 | tokenError: nil, | ||
| 102 | account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), | 97 | account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), |
| 103 | isRefreshing: false, | 98 | isRefreshing: false, |
| 104 | lastError: nil, | 99 | lastError: nil, |
| 105 | usageBarsShowUsed: true, | 100 | usageBarsShowUsed: true)) |
| 106 | tokenCostUsageEnabled: false)) | ||
| 107 | 101 | ||
| 108 | #expect(model.metrics.first?.title == "Session") | 102 | #expect(model.metrics.first?.title == "Session") |
| 109 | #expect(model.metrics.first?.percent == 22) | 103 | #expect(model.metrics.first?.percent == 22) |
| @@ -139,13 +133,10 @@ struct MenuCardModelTests { · 29 unmodified lines | |||
| 139 | creditsError: nil, | 133 | creditsError: nil, |
| 140 | dashboard: dashboard, | 134 | dashboard: dashboard, |
| 141 | dashboardError: nil, | 135 | dashboardError: nil, |
| 142 | tokenSnapshot: nil, | ||
| 143 | tokenError: nil, | ||
| 144 | account: AccountInfo(email: "codex@example.com", plan: nil), | 136 | account: AccountInfo(email: "codex@example.com", plan: nil), |
| 145 | isRefreshing: false, | 137 | isRefreshing: false, |
| 146 | lastError: nil, | 138 | lastError: nil, |
| 147 | usageBarsShowUsed: false, | 139 | usageBarsShowUsed: false)) |
| 148 | tokenCostUsageEnabled: false)) | ||
| 149 | 140 | ||
| 150 | #expect(model.metrics.contains { $0.title == "Code review" && $0.percent == 73 }) | 141 | #expect(model.metrics.contains { $0.title == "Code review" && $0.percent == 73 }) |
| 151 | } | 142 | } |
| @@ -174,13 +165,10 @@ struct MenuCardModelTests { · 22 unmodified lines | |||
| 174 | creditsError: nil, | 165 | creditsError: nil, |
| 175 | dashboard: nil, | 166 | dashboard: nil, |
| 176 | dashboardError: nil, | 167 | dashboardError: nil, |
| 177 | tokenSnapshot: nil, | ||
| 178 | tokenError: nil, | ||
| 179 | account: AccountInfo(email: "codex@example.com", plan: "plus"), | 168 | account: AccountInfo(email: "codex@example.com", plan: "plus"), |
| 180 | isRefreshing: false, | 169 | isRefreshing: false, |
| 181 | lastError: nil, | 170 | lastError: nil, |
| 182 | usageBarsShowUsed: false, | 171 | usageBarsShowUsed: false)) |
| 183 | tokenCostUsageEnabled: false)) | ||
| 184 | 172 | ||
| 185 | #expect(model.metrics.count == 1) | 173 | #expect(model.metrics.count == 1) |
| 186 | #expect(model.metrics.first?.title == "Session") | 174 | #expect(model.metrics.first?.title == "Session") |
| @@ -198,13 +186,10 @@ struct MenuCardModelTests { · 11 unmodified lines | |||
| 198 | creditsError: nil, | 186 | creditsError: nil, |
| 199 | dashboard: nil, | 187 | dashboard: nil, |
| 200 | dashboardError: nil, | 188 | dashboardError: nil, |
| 201 | tokenSnapshot: nil, | ||
| 202 | tokenError: nil, | ||
| 203 | account: AccountInfo(email: nil, plan: nil), | 189 | account: AccountInfo(email: nil, plan: nil), |
| 204 | isRefreshing: false, | 190 | isRefreshing: false, |
| 205 | lastError: "Probe failed for Codex", | 191 | lastError: "Probe failed for Codex", |
| 206 | usageBarsShowUsed: false, | 192 | usageBarsShowUsed: false)) |
| 207 | tokenCostUsageEnabled: false)) | ||
| 208 | 193 | ||
| 209 | #expect(model.subtitleStyle == .error) | 194 | #expect(model.subtitleStyle == .error) |
| 210 | #expect(model.subtitleText.contains("Probe failed")) | 195 | #expect(model.subtitleText.contains("Probe failed")) |
| @@ -222,13 +207,10 @@ struct MenuCardModelTests { · 11 unmodified lines | |||
| 222 | creditsError: nil, | 207 | creditsError: nil, |
| 223 | dashboard: nil, | 208 | dashboard: nil, |
| 224 | dashboardError: nil, | 209 | dashboardError: nil, |
| 225 | tokenSnapshot: nil, | ||
| 226 | tokenError: nil, | ||
| 227 | account: AccountInfo(email: "codex@example.com", plan: "plus"), | 210 | account: AccountInfo(email: "codex@example.com", plan: "plus"), |
| 228 | isRefreshing: false, | 211 | isRefreshing: false, |
| 229 | lastError: nil, | 212 | lastError: nil, |
| 230 | usageBarsShowUsed: false, | 213 | usageBarsShowUsed: false)) |
| 231 | tokenCostUsageEnabled: false)) | ||
| 232 | 214 | ||
| 233 | #expect(model.planText == nil) | 215 | #expect(model.planText == nil) |
| 234 | #expect(model.email.isEmpty) | 216 | #expect(model.email.isEmpty) |