History

revert: accidental merge of ccusage work

Peter Steinberger committed 10 months ago

Commit 67bcbf91 parent 237662915 files · +39 −1747Browse files

Unified · Split

15 files
  1. Sources/CodexBar/CCUsageCostChartMenuView.swift +0 −310
  2. Sources/CodexBar/MenuCardView.swift +1 −74
  3. Sources/CodexBar/PreferencesAdvancedPane.swift +0 −94
  4. Sources/CodexBar/SettingsStore.swift +0 −106
  5. Sources/CodexBar/StatusItemController+Actions.swift +1 −1
  6. Sources/CodexBar/StatusItemController+Menu.swift +20 −129
  7. Sources/CodexBar/UsageBreakdownChartMenuView.swift +10 −77
  8. Sources/CodexBar/UsageStore.swift +1 −139
  9. Sources/CodexBarCore/CCUsageFetcher.swift +0 −157
  10. Sources/CodexBarCore/CCUsageModels.swift +0 −357
  11. Sources/CodexBarCore/SubprocessRunner.swift +0 −104
  12. Sources/CodexBarCore/TTYCommandRunner.swift +0 −8
  13. Sources/CodexBarCore/UsageFormatter.swift +0 −40
  14. Tests/CodexBarTests/CCUsageDecodingTests.swift +0 −127
  15. Tests/CodexBarTests/MenuCardModelTests.swift +6 −24
Sources/CodexBar/CCUsageCostChartMenuView.swift+0−310
@@ -1,310 +0,0 @@
1import Charts
2import CodexBarCore
3import SwiftUI
4
5@MainActor
6struct 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}
Sources/CodexBar/MenuCardView.swift+1−74
@@ -41,13 +41,6 @@ struct UsageMenuCardView: View { · 40 unmodified lines
41 case error41 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: String44 let providerName: String
52 let email: String45 let email: String
53 let subtitleText: String46 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: Color53 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 != nil147 !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: AccountInfo170 let account: AccountInfo
209 let isRefreshing: Bool171 let isRefreshing: Bool
210 let lastError: String?172 let lastError: String?
211 let usageBarsShowUsed: Bool173 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 }
Sources/CodexBar/PreferencesAdvancedPane.swift+0−94
@@ -1,5 +1,4 @@
1import AppKit1import AppKit
2import CodexBarCore
3import SwiftUI2import SwiftUI
4 3
5@MainActor4@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 installer108 // MARK: - CLI installer
Sources/CodexBar/SettingsStore.swift+0−106
@@ -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: UserDefaults101 private let userDefaults: UserDefaults
109 private let toggleStore: ProviderToggleStore102 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 = userDefaults105 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: - Private130 // 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 = true165 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
275enum LaunchAtLoginManager {169enum LaunchAtLoginManager {
Sources/CodexBar/StatusItemController+Actions.swift+1−1
@@ -6,7 +6,7 @@ extension StatusItemController { · 5 unmodified lines
6 // MARK: - Actions reachable from menus6 // 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() {
Sources/CodexBar/StatusItemController+Menu.swift+20−129
@@ -50,10 +50,7 @@ extension StatusItemController { · 49 unmodified lines
50 dashboard != nil50 dashboard != nil
51 let hasCreditsHistory = openAIWebEligible && !(dashboard?.creditEvents ?? []).isEmpty51 let hasCreditsHistory = openAIWebEligible && !(dashboard?.creditEvents ?? []).isEmpty
52 let hasUsageBreakdown = openAIWebEligible && !(dashboard?.usageBreakdown ?? []).isEmpty52 let hasUsageBreakdown = openAIWebEligible && !(dashboard?.usageBreakdown ?? []).isEmpty
53 let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex53 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 true375 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 ?? .codex379 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.credits388 credits = self.store.credits
427 creditsError = self.store.lastCreditsError389 creditsError = self.store.lastCreditsError
428 dashboard = self.store.openAIDashboardRequiresLogin ? nil : self.store.openAIDashboard390 dashboard = self.store.openAIDashboardRequiresLogin ? nil : self.store.openAIDashboard
429 dashboardError = self.store.lastOpenAIDashboardError391 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 = nil393 credits = nil
441 creditsError = nil394 creditsError = nil
442 dashboard = nil395 dashboard = nil
443 dashboardError = nil396 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 = onSelect443 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 = true469 button.wantsLayer = true
514 button.layer?.cornerRadius = 6470 button.layer?.cornerRadius = 6
515 button.state = (selected == segment.provider) ? .on : .off471 button.state = (selected == segment.provider) ? .on : .off
516 button.translatesAutoresizingMaskIntoConstraints = false472 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 = 14476 self.addSubview(stack)
535 let minimumGap: CGFloat = 8477 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)
Sources/CodexBar/UsageBreakdownChartMenuView.swift+10−77
@@ -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 in37 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 in54 .chartOverlay { proxy in
65 GeometryReader { geo in55 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] = day126 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.date211 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,
Sources/CodexBar/UsageStore.swift+1−139
@@ -68,9 +68,6 @@ struct ConsecutiveFailureGate { · 67 unmodified lines
68final class UsageStore: ObservableObject {68final 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: UsageFetcher96 private let codexFetcher: UsageFetcher
100 private let claudeFetcher: any ClaudeUsageFetching97 private let claudeFetcher: any ClaudeUsageFetching
101 private let ccusageFetcher: CCUsageFetcher
102 private let registry: ProviderRegistry98 private let registry: ProviderRegistry
103 private let settings: SettingsStore99 private let settings: SettingsStore
104 private let sessionQuotaNotifier: SessionQuotaNotifier100 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 = fetcher118 self.codexFetcher = fetcher
128 self.claudeFetcher = claudeFetcher119 self.claudeFetcher = claudeFetcher
129 self.ccusageFetcher = ccusageFetcher
130 self.settings = settings120 self.settings = settings
131 self.registry = registry121 self.registry = registry
132 self.sessionQuotaNotifier = sessionQuotaNotifier122 self.sessionQuotaNotifier = sessionQuotaNotifier
@@ -134,8 +124,6 @@ final class UsageStore: ObservableObject { · 1 unmodified line
134 self124 self
135 .failureGates = Dictionary(uniqueKeysWithValues: UsageProvider.allCases125 .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 = true235 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] = nil308 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 return313 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
959extension 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}
Sources/CodexBarCore/CCUsageFetcher.swift+0−157
@@ -1,157 +0,0 @@
1import Foundation
2
3public 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
17public 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}
Sources/CodexBarCore/CCUsageModels.swift+0−357
@@ -1,357 +0,0 @@
1import Foundation
2
3public 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
25public 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
154public 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
230public 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
300private struct CCUsageLegacyTotals: Sendable, Decodable {
301 let totalInputTokens: Int?
302 let totalOutputTokens: Int?
303 let totalTokens: Int?
304 let totalCost: Double?
305}
306
307enum 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}
Sources/CodexBarCore/SubprocessRunner.swift+0−104
@@ -1,104 +0,0 @@
1import Foundation
2
3public 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
27public struct SubprocessResult: Sendable {
28 public let stdout: String
29 public let stderr: String
30}
31
32public 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}
Sources/CodexBarCore/TTYCommandRunner.swift+0−8
@@ -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
Sources/CodexBarCore/UsageFormatter.swift+0−40
@@ -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 = .medium66 formatter.dateStyle = .medium
Tests/CodexBarTests/CCUsageDecodingTests.swift+0−127
@@ -1,127 +0,0 @@
1import Foundation
2import Testing
3
4@testable import CodexBarCore
5
6@Suite
7struct 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}
Tests/CodexBarTests/MenuCardModelTests.swift+6−24
@@ -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)