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 @@
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−}
Sources/CodexBar/MenuCardView.swift+1−74
@@ -41,13 +41,6 @@ struct UsageMenuCardView: View { · 40 unmodified lines
4141 case error
4242 }
4343
44− struct TokenUsageSection: Sendable {
45− let sessionLine: String
46− let monthLine: String
47− let hintLine: String?
48− let errorLine: String?
49− }
50−
5144 let providerName: String
5245 let email: String
5346 let subtitleText: String
@@ -56,7 +49,6 @@ struct UsageMenuCardView: View { · 2 unmodified lines
5649 let metrics: [Metric]
5750 let creditsText: String?
5851 let creditsHintText: String?
59− let tokenUsage: TokenUsageSection?
6052 let placeholder: String?
6153 let progressColor: Color
6254 }
@@ -141,34 +133,6 @@ struct UsageMenuCardView: View { · 78 unmodified lines
141133 }
142134 }
143135 }
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− }
172136 }
173137 .padding(.bottom, self.model.creditsText == nil ? 8 : 2)
174138 }
@@ -180,7 +144,7 @@ struct UsageMenuCardView: View { · 5 unmodified lines
180144 }
181145
182146 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
184148 }
185149
186150 private var subtitleColor: Color {
@@ -203,13 +167,10 @@ extension UsageMenuCardView.Model { · 16 unmodified lines
203167 let creditsError: String?
204168 let dashboard: OpenAIDashboardSnapshot?
205169 let dashboardError: String?
206− let tokenSnapshot: CCUsageTokenSnapshot?
207− let tokenError: String?
208170 let account: AccountInfo
209171 let isRefreshing: Bool
210172 let lastError: String?
211173 let usageBarsShowUsed: Bool
212− let tokenCostUsageEnabled: Bool
213174 }
214175
215176 static func make(_ input: Input) -> UsageMenuCardView.Model {
@@ -226,11 +187,6 @@ extension UsageMenuCardView.Model { · 10 unmodified lines
226187 usageBarsShowUsed: input.usageBarsShowUsed)
227188 let creditsText = Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError)
228189 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)
234190 let subtitle = Self.subtitle(
235191 snapshot: input.snapshot,
236192 isRefreshing: input.isRefreshing,
@@ -246,7 +202,6 @@ extension UsageMenuCardView.Model { · 9 unmodified lines
246202 metrics: metrics,
247203 creditsText: creditsText,
248204 creditsHintText: creditsHintText,
249− tokenUsage: tokenUsage,
250205 placeholder: placeholder,
251206 progressColor: Self.progressColor(for: input.provider))
252207 }
@@ -368,34 +323,6 @@ extension UsageMenuCardView.Model { · 115 unmodified lines
368323 return UsageFormatter.truncatedSingleLine(error, max: 100)
369324 }
370325
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−
399326 private static func clamped(_ value: Double) -> Double {
400327 min(100, max(0, value))
401328 }
Sources/CodexBar/PreferencesAdvancedPane.swift+0−94
@@ -1,5 +1,4 @@
11 import AppKit
2−import CodexBarCore
32 import SwiftUI
43
54 @MainActor
@@ -10,11 +9,6 @@ struct AdvancedPane: View { · 4 unmodified lines
109 @State private var cliStatus: String?
1110
1211 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−
1812 ScrollView(.vertical, showsIndicators: true) {
1913 VStack(alignment: .leading, spacing: 16) {
2014 SettingsSection(contentSpacing: 6) {
@@ -58,42 +52,6 @@ struct AdvancedPane: View { · 37 unmodified lines
5852
5953 Divider()
6054
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−
9755 SettingsSection(contentSpacing: 12) {
9856 Text("Status")
9957 .font(.caption)
@@ -145,58 +103,6 @@ struct AdvancedPane: View { · 45 unmodified lines
145103 .padding(.horizontal, 20)
146104 .padding(.vertical, 12)
147105 }
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)
200106 }
201107
202108 // MARK: - CLI installer
Sources/CodexBar/SettingsStore.swift+0−106
@@ -63,13 +63,6 @@ final class SettingsStore: ObservableObject { · 62 unmodified lines
6363 didSet { self.objectWillChange.send() }
6464 }
6565
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−
7366 @AppStorage("randomBlinkEnabled") var randomBlinkEnabled: Bool = false {
7467 didSet { self.objectWillChange.send() }
7568 }
@@ -108,13 +101,6 @@ final class SettingsStore: ObservableObject { · 32 unmodified lines
108101 private let userDefaults: UserDefaults
109102 private let toggleStore: ProviderToggleStore
110103
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−
118104 init(userDefaults: UserDefaults = .standard) {
119105 self.userDefaults = userDefaults
120106 if userDefaults.object(forKey: "sessionQuotaNotificationsEnabled") == nil {
@@ -126,7 +112,6 @@ final class SettingsStore: ObservableObject { · 5 unmodified lines
126112 self.toggleStore.purgeLegacyKeys()
127113 LaunchAtLoginManager.setEnabled(self.launchAtLogin)
128114 self.runInitialProviderDetectionIfNeeded()
129− self.refreshCCUsageAvailability()
130115 }
131116
132117 func isProviderEnabled(provider: UsageProvider, metadata: ProviderMetadata) -> Bool {
@@ -144,74 +129,6 @@ final class SettingsStore: ObservableObject { · 11 unmodified lines
144129
145130 // MARK: - Private
146131
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−
215132 private func runInitialProviderDetectionIfNeeded(force: Bool = false) {
216133 guard force || !self.providerDetectionCompleted else { return }
217134 guard let codexMeta = ProviderRegistry.shared.metadata[.codex],
@@ -247,29 +164,6 @@ final class SettingsStore: ObservableObject { · 29 unmodified lines
247164 self.toggleStore.setEnabled(enableGemini, metadata: geminiMeta)
248165 self.providerDetectionCompleted = true
249166 }
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− }
273167 }
274168
275169 enum LaunchAtLoginManager {
Sources/CodexBar/StatusItemController+Actions.swift+1−1
@@ -6,7 +6,7 @@ extension StatusItemController { · 5 unmodified lines
66 // MARK: - Actions reachable from menus
77
88 @objc func refreshNow() {
9− Task { await self.store.refresh(forceTokenUsage: true) }
9+ Task { await self.store.refresh() }
1010 }
1111
1212 @objc func openDashboard() {
Sources/CodexBar/StatusItemController+Menu.swift+20−129
@@ -50,10 +50,7 @@ extension StatusItemController { · 49 unmodified lines
5050 dashboard != nil
5151 let hasCreditsHistory = openAIWebEligible && !(dashboard?.creditEvents ?? []).isEmpty
5252 let hasUsageBreakdown = openAIWebEligible && !(dashboard?.usageBreakdown ?? []).isEmpty
53− let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex
54− let hasCostHistory = self.settings.isCCUsageCostUsageEffectivelyEnabled(for: currentProvider) &&
55− (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false)
56− let hasOpenAIWebMenuItems = hasCreditsHistory || hasUsageBreakdown || hasCostHistory
53+ let hasOpenAIWebMenuItems = hasCreditsHistory || hasUsageBreakdown
5754
5855 if enabledProviders.count > 1 {
5956 let switcherItem = self.makeProviderSwitcherItem(
@@ -82,16 +79,13 @@ extension StatusItemController { · 22 unmodified lines
8279 }
8380
8481 if hasOpenAIWebMenuItems {
85− // Only show these when we actually have additional data.
82+ // Only show these when we actually have OpenAI web-only data.
8683 if hasCreditsHistory {
8784 _ = self.addCreditsHistorySubmenu(to: menu)
8885 }
8986 if hasUsageBreakdown {
9087 _ = self.addUsageBreakdownSubmenu(to: menu)
9188 }
92− if hasCostHistory {
93− _ = self.addCostHistorySubmenu(to: menu, provider: currentProvider)
94− }
9589 menu.addItem(.separator())
9690 }
9791
@@ -381,36 +375,6 @@ extension StatusItemController { · 283 unmodified lines
381375 return true
382376 }
383377
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−
414378 private func menuCardModel(for provider: UsageProvider?) -> UsageMenuCardView.Model? {
415379 let target = provider ?? self.store.enabledProviders().first ?? .codex
416380 let metadata = self.store.metadata(for: target)
@@ -420,29 +384,16 @@ extension StatusItemController { · 3 unmodified lines
420384 let creditsError: String?
421385 let dashboard: OpenAIDashboardSnapshot?
422386 let dashboardError: String?
423− let tokenSnapshot: CCUsageTokenSnapshot?
424− let tokenError: String?
425387 if target == .codex {
426388 credits = self.store.credits
427389 creditsError = self.store.lastCreditsError
428390 dashboard = self.store.openAIDashboardRequiresLogin ? nil : self.store.openAIDashboard
429391 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)
439392 } else {
440393 credits = nil
441394 creditsError = nil
442395 dashboard = nil
443396 dashboardError = nil
444− tokenSnapshot = nil
445− tokenError = nil
446397 }
447398
448399 let input = UsageMenuCardView.Model.Input(
@@ -453,13 +404,10 @@ extension StatusItemController { · 4 unmodified lines
453404 creditsError: creditsError,
454405 dashboard: dashboard,
455406 dashboardError: dashboardError,
456− tokenSnapshot: tokenSnapshot,
457− tokenError: tokenError,
458407 account: self.account,
459408 isRefreshing: self.store.isRefreshing,
460409 lastError: self.store.error(for: target),
461− usageBarsShowUsed: self.settings.usageBarsShowUsed,
462− tokenCostUsageEnabled: self.settings.isCCUsageCostUsageEffectivelyEnabled(for: target))
410+ usageBarsShowUsed: self.settings.usageBarsShowUsed)
463411 return UsageMenuCardView.Model.make(input)
464412 }
465413 }
@@ -495,7 +443,15 @@ private final class ProviderSwitcherView: NSView { · 29 unmodified lines
495443 self.onSelect = onSelect
496444 super.init(frame: NSRect(x: 0, y: 0, width: width, height: 30))
497445
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() {
499455 let button = PaddedToggleButton(
500456 title: segment.title,
501457 target: self,
@@ -513,67 +469,17 @@ private final class ProviderSwitcherView: NSView { · 11 unmodified lines
513469 button.wantsLayer = true
514470 button.layer?.cornerRadius = 6
515471 button.state = (selected == segment.provider) ? .on : .off
516− button.translatesAutoresizingMaskIntoConstraints = false
472+ stack.addArrangedSubview(button)
517473 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− }
532474 }
533475
534− let outerPadding: CGFloat = 14
535− let minimumGap: CGFloat = 8
536−
537− if self.buttons.count == 2 {
538− let left = self.buttons[0]
539− let right = self.buttons[1]
540− let gap = right.leadingAnchor.constraint(greaterThanOrEqualTo: left.trailingAnchor, constant: minimumGap)
541− gap.priority = .defaultHigh
542− NSLayoutConstraint.activate([
543− left.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: outerPadding),
544− left.centerYAnchor.constraint(equalTo: self.centerYAnchor),
545− right.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -outerPadding),
546− right.centerYAnchor.constraint(equalTo: self.centerYAnchor),
547− gap,
548− ])
549− } else if self.buttons.count == 3 {
550− let left = self.buttons[0]
551− let mid = self.buttons[1]
552− let right = self.buttons[2]
553−
554− let leftGap = mid.leadingAnchor.constraint(greaterThanOrEqualTo: left.trailingAnchor, constant: minimumGap)
555− leftGap.priority = .defaultHigh
556− let rightGap = right.leadingAnchor.constraint(
557− greaterThanOrEqualTo: mid.trailingAnchor,
558− constant: minimumGap)
559− rightGap.priority = .defaultHigh
560−
561− NSLayoutConstraint.activate([
562− left.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: outerPadding),
563− left.centerYAnchor.constraint(equalTo: self.centerYAnchor),
564− mid.centerXAnchor.constraint(equalTo: self.centerXAnchor),
565− mid.centerYAnchor.constraint(equalTo: self.centerYAnchor),
566− right.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -outerPadding),
567− right.centerYAnchor.constraint(equalTo: self.centerYAnchor),
568− leftGap,
569− rightGap,
570− ])
571− } else if let first = self.buttons.first {
572− NSLayoutConstraint.activate([
573− first.centerXAnchor.constraint(equalTo: self.centerXAnchor),
574− first.centerYAnchor.constraint(equalTo: self.centerYAnchor),
575− ])
576− }
476+ self.addSubview(stack)
477+ stack.translatesAutoresizingMaskIntoConstraints = false
478+ NSLayoutConstraint.activate([
479+ stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8),
480+ stack.centerYAnchor.constraint(equalTo: self.centerYAnchor),
481+ stack.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor, constant: -8),
482+ ])
577483
578484 self.updateButtonStyles()
579485 }
@@ -601,21 +507,6 @@ private final class ProviderSwitcherView: NSView { · 21 unmodified lines
601507 }
602508 }
603509
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−
619510 private static func paddedImage(_ image: NSImage, leading: CGFloat) -> NSImage {
620511 let size = NSSize(width: image.size.width + leading, height: image.size.height)
621512 let newImage = NSImage(size: size)
Sources/CodexBar/UsageBreakdownChartMenuView.swift+10−77
@@ -33,20 +33,11 @@ struct UsageBreakdownChartMenuView: View { · 32 unmodified lines
3333 .font(.footnote)
3434 .foregroundStyle(.secondary)
3535 } else {
36− Chart {
37− ForEach(model.points) { point in
38− BarMark(
39− x: .value("Day", point.date, unit: .day),
40− y: .value("Credits used", point.creditsUsed))
41− .foregroundStyle(by: .value("Service", point.service))
42− }
43− if let peak = model.peakPoint {
44− PointMark(
45− x: .value("Day", peak.date, unit: .day),
46− y: .value("Credits used", peak.creditsUsed))
47− .symbolSize(26)
48− .foregroundStyle(Color(nsColor: .systemYellow))
49− }
36+ Chart(model.points) { point in
37+ BarMark(
38+ x: .value("Day", point.date, unit: .day),
39+ y: .value("Credits used", point.creditsUsed))
40+ .foregroundStyle(by: .value("Service", point.service))
5041 }
5142 .chartForegroundStyleScale(domain: model.services, range: model.serviceColors)
5243 .chartYAxis(.hidden)
@@ -55,28 +46,18 @@ struct UsageBreakdownChartMenuView: View { · 2 unmodified lines
5546 AxisGridLine().foregroundStyle(Color.clear)
5647 AxisTick().foregroundStyle(Color.clear)
5748 AxisValueLabel(format: .dateTime.month(.abbreviated).day())
58− .font(.caption2)
59− .foregroundStyle(Color(nsColor: .tertiaryLabelColor))
49+ .foregroundStyle(.secondary)
6050 }
6151 }
6252 .chartLegend(.hidden)
6353 .frame(height: 130)
6454 .chartOverlay { proxy in
6555 GeometryReader { geo in
66− ZStack(alignment: .topLeading) {
67− if let rect = self.selectionBandRect(model: model, proxy: proxy, geo: geo) {
68− Rectangle()
69− .fill(Color.white.opacity(0.07))
70− .frame(width: rect.width, height: rect.height)
71− .position(x: rect.midX, y: rect.midY)
72− .allowsHitTesting(false)
73− }
74− MouseLocationReader { location in
75− self.updateSelection(location: location, model: model, proxy: proxy, geo: geo)
76− }
77− .frame(maxWidth: .infinity, maxHeight: .infinity)
78− .contentShape(Rectangle())
56+ MouseLocationReader { location in
57+ self.updateSelection(location: location, model: model, proxy: proxy, geo: geo)
7958 }
59+ .frame(maxWidth: .infinity, maxHeight: .infinity)
60+ .contentShape(Rectangle())
8061 }
8162 }
8263
@@ -115,7 +96,6 @@ struct UsageBreakdownChartMenuView: View { · 32 unmodified lines
11596 let points: [Point]
11697 let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown]
11798 let dayDates: [(dayKey: String, date: Date)]
118− let peakPoint: (date: Date, creditsUsed: Double)?
11999 let services: [String]
120100 let serviceColors: [Color]
121101 let axisDates: [Date]
@@ -141,19 +121,10 @@ struct UsageBreakdownChartMenuView: View { · 19 unmodified lines
141121 var dayDates: [(dayKey: String, date: Date)] = []
142122 dayDates.reserveCapacity(sorted.count)
143123
144− var peak: (date: Date, creditsUsed: Double)?
145−
146124 for day in sorted {
147125 guard let date = self.dateFromDayKey(day.day) else { continue }
148126 breakdownByDayKey[day.day] = day
149127 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− }
157128 for service in day.services where service.creditsUsed > 0 {
158129 points.append(Point(date: date, service: service.service, creditsUsed: service.creditsUsed))
159130 }
@@ -167,7 +138,6 @@ struct UsageBreakdownChartMenuView: View { · 7 unmodified lines
167138 points: points,
168139 breakdownByDayKey: breakdownByDayKey,
169140 dayDates: dayDates,
170− peakPoint: peak,
171141 services: services,
172142 serviceColors: colors,
173143 axisDates: axisDates)
@@ -241,43 +211,6 @@ struct UsageBreakdownChartMenuView: View { · 67 unmodified lines
241211 return comps.date
242212 }
243213
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−
281214 private func updateSelection(
282215 location: CGPoint?,
283216 model: Model,
Sources/CodexBar/UsageStore.swift+1−139
@@ -68,9 +68,6 @@ struct ConsecutiveFailureGate { · 67 unmodified lines
6868 final class UsageStore: ObservableObject {
6969 @Published private var snapshots: [UsageProvider: UsageSnapshot] = [:]
7070 @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> = []
7471 @Published var credits: CreditsSnapshot?
7572 @Published var lastCreditsError: String?
7673 @Published var openAIDashboard: OpenAIDashboardSnapshot?
@@ -98,7 +95,6 @@ final class UsageStore: ObservableObject { · 21 unmodified lines
9895
9996 private let codexFetcher: UsageFetcher
10097 private let claudeFetcher: any ClaudeUsageFetching
101− private let ccusageFetcher: CCUsageFetcher
10298 private let registry: ProviderRegistry
10399 private let settings: SettingsStore
104100 private let sessionQuotaNotifier: SessionQuotaNotifier
@@ -106,27 +102,21 @@ final class UsageStore: ObservableObject { · 1 unmodified line
106102 private let openAIWebLogger = Logger(subsystem: "com.steipete.codexbar", category: "openai-web")
107103 private var openAIWebDebugLines: [String] = []
108104 private var failureGates: [UsageProvider: ConsecutiveFailureGate] = [:]
109− private var tokenFailureGates: [UsageProvider: ConsecutiveFailureGate] = [:]
110105 private var providerSpecs: [UsageProvider: ProviderSpec] = [:]
111106 private let providerMetadata: [UsageProvider: ProviderMetadata]
112107 private var timerTask: Task<Void, Never>?
113− private var tokenTimerTask: Task<Void, Never>?
114108 private var cancellables = Set<AnyCancellable>()
115109 private var lastKnownSessionRemaining: [UsageProvider: Double] = [:]
116− private var lastTokenFetchAt: [UsageProvider: Date] = [:]
117− private let tokenFetchTTL: TimeInterval = 60 * 60
118110
119111 init(
120112 fetcher: UsageFetcher,
121113 claudeFetcher: any ClaudeUsageFetching = ClaudeUsageFetcher(),
122− ccusageFetcher: CCUsageFetcher = CCUsageFetcher(),
123114 settings: SettingsStore,
124115 registry: ProviderRegistry = .shared,
125116 sessionQuotaNotifier: SessionQuotaNotifier = SessionQuotaNotifier())
126117 {
127118 self.codexFetcher = fetcher
128119 self.claudeFetcher = claudeFetcher
129− self.ccusageFetcher = ccusageFetcher
130120 self.settings = settings
131121 self.registry = registry
132122 self.sessionQuotaNotifier = sessionQuotaNotifier
@@ -134,8 +124,6 @@ final class UsageStore: ObservableObject { · 1 unmodified line
134124 self
135125 .failureGates = Dictionary(uniqueKeysWithValues: UsageProvider.allCases
136126 .map { ($0, ConsecutiveFailureGate()) })
137− self.tokenFailureGates = Dictionary(uniqueKeysWithValues: UsageProvider.allCases
138− .map { ($0, ConsecutiveFailureGate()) })
139127 self.providerSpecs = registry.specs(
140128 settings: settings,
141129 metadata: self.providerMetadata,
@@ -149,7 +137,6 @@ final class UsageStore: ObservableObject { · 7 unmodified lines
149137 }
150138 Task { await self.refresh() }
151139 self.startTimer()
152− self.startTokenTimer()
153140 }
154141
155142 var codexSnapshot: UsageSnapshot? { self.snapshots[.codex] }
@@ -243,7 +230,7 @@ final class UsageStore: ObservableObject { · 87 unmodified lines
243230 self.settings.isProviderEnabled(provider: provider, metadata: self.metadata(for: provider))
244231 }
245232
246− func refresh(forceTokenUsage: Bool = false) async {
233+ func refresh() async {
247234 guard !self.isRefreshing else { return }
248235 self.isRefreshing = true
249236 defer { self.isRefreshing = false }
@@ -256,9 +243,6 @@ final class UsageStore: ObservableObject { · 6 unmodified lines
256243 group.addTask { await self.refreshCreditsIfNeeded() }
257244 }
258245
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−
262246 // OpenAI web scrape depends on the current Codex account email (which can change after login/account switch).
263247 // Run this after Codex usage refresh so we don't accidentally scrape with stale credentials.
264248 await self.refreshOpenAIDashboardIfNeeded()
@@ -311,28 +295,8 @@ final class UsageStore: ObservableObject { · 46 unmodified lines
311295 }
312296 }
313297
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−
333298 deinit {
334299 self.timerTask?.cancel()
335− self.tokenTimerTask?.cancel()
336300 }
337301
338302 private func refreshProvider(_ provider: UsageProvider) async {
@@ -342,13 +306,9 @@ final class UsageStore: ObservableObject { · 3 unmodified lines
342306 await MainActor.run {
343307 self.snapshots.removeValue(forKey: provider)
344308 self.errors[provider] = nil
345− self.tokenSnapshots.removeValue(forKey: provider)
346− self.tokenErrors[provider] = nil
347309 self.failureGates[provider]?.reset()
348− self.tokenFailureGates[provider]?.reset()
349310 self.statuses.removeValue(forKey: provider)
350311 self.lastKnownSessionRemaining.removeValue(forKey: provider)
351− self.lastTokenFetchAt.removeValue(forKey: provider)
352312 }
353313 return
354314 }
@@ -955,101 +915,3 @@ final class UsageStore: ObservableObject { · 600 unmodified lines
955915 self.pathDebugInfo = PathBuilder.debugSnapshot(purposes: [.rpc, .tty, .nodeTooling])
956916 }
957917 }
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−}
Sources/CodexBarCore/CCUsageFetcher.swift+0−157
@@ -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−}
Sources/CodexBarCore/CCUsageModels.swift+0−357
@@ -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−}
Sources/CodexBarCore/SubprocessRunner.swift+0−104
@@ -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−}
Sources/CodexBarCore/TTYCommandRunner.swift+0−8
@@ -396,14 +396,6 @@ public struct TTYCommandRunner { · 395 unmodified lines
396396 public static func which(_ tool: String) -> String? {
397397 if tool == "codex", let located = BinaryLocator.resolveCodexBinary() { return located }
398398 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− }
407399 return self.runWhich(tool)
408400 }
409401
Sources/CodexBarCore/UsageFormatter.swift+0−40
@@ -61,46 +61,6 @@ public enum UsageFormatter { · 60 unmodified lines
6161 return "\(formatted) left"
6262 }
6363
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−
10464 public static func creditEventSummary(_ event: CreditEvent) -> String {
10565 let formatter = DateFormatter()
10666 formatter.dateStyle = .medium
Tests/CodexBarTests/CCUsageDecodingTests.swift+0−127
@@ -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−}
Tests/CodexBarTests/MenuCardModelTests.swift+6−24
@@ -45,13 +45,10 @@ struct MenuCardModelTests { · 44 unmodified lines
4545 creditsError: nil,
4646 dashboard: nil,
4747 dashboardError: nil,
48− tokenSnapshot: nil,
49− tokenError: nil,
5048 account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"),
5149 isRefreshing: false,
5250 lastError: nil,
53− usageBarsShowUsed: false,
54− tokenCostUsageEnabled: false))
51+ usageBarsShowUsed: false))
5552
5653 #expect(model.providerName == "Codex")
5754 #expect(model.metrics.count == 2)
@@ -97,13 +94,10 @@ struct MenuCardModelTests { · 39 unmodified lines
9794 creditsError: nil,
9895 dashboard: dashboard,
9996 dashboardError: nil,
100− tokenSnapshot: nil,
101− tokenError: nil,
10297 account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"),
10398 isRefreshing: false,
10499 lastError: nil,
105− usageBarsShowUsed: true,
106− tokenCostUsageEnabled: false))
100+ usageBarsShowUsed: true))
107101
108102 #expect(model.metrics.first?.title == "Session")
109103 #expect(model.metrics.first?.percent == 22)
@@ -139,13 +133,10 @@ struct MenuCardModelTests { · 29 unmodified lines
139133 creditsError: nil,
140134 dashboard: dashboard,
141135 dashboardError: nil,
142− tokenSnapshot: nil,
143− tokenError: nil,
144136 account: AccountInfo(email: "codex@example.com", plan: nil),
145137 isRefreshing: false,
146138 lastError: nil,
147− usageBarsShowUsed: false,
148− tokenCostUsageEnabled: false))
139+ usageBarsShowUsed: false))
149140
150141 #expect(model.metrics.contains { $0.title == "Code review" && $0.percent == 73 })
151142 }
@@ -174,13 +165,10 @@ struct MenuCardModelTests { · 22 unmodified lines
174165 creditsError: nil,
175166 dashboard: nil,
176167 dashboardError: nil,
177− tokenSnapshot: nil,
178− tokenError: nil,
179168 account: AccountInfo(email: "codex@example.com", plan: "plus"),
180169 isRefreshing: false,
181170 lastError: nil,
182− usageBarsShowUsed: false,
183− tokenCostUsageEnabled: false))
171+ usageBarsShowUsed: false))
184172
185173 #expect(model.metrics.count == 1)
186174 #expect(model.metrics.first?.title == "Session")
@@ -198,13 +186,10 @@ struct MenuCardModelTests { · 11 unmodified lines
198186 creditsError: nil,
199187 dashboard: nil,
200188 dashboardError: nil,
201− tokenSnapshot: nil,
202− tokenError: nil,
203189 account: AccountInfo(email: nil, plan: nil),
204190 isRefreshing: false,
205191 lastError: "Probe failed for Codex",
206− usageBarsShowUsed: false,
207− tokenCostUsageEnabled: false))
192+ usageBarsShowUsed: false))
208193
209194 #expect(model.subtitleStyle == .error)
210195 #expect(model.subtitleText.contains("Probe failed"))
@@ -222,13 +207,10 @@ struct MenuCardModelTests { · 11 unmodified lines
222207 creditsError: nil,
223208 dashboard: nil,
224209 dashboardError: nil,
225− tokenSnapshot: nil,
226− tokenError: nil,
227210 account: AccountInfo(email: "codex@example.com", plan: "plus"),
228211 isRefreshing: false,
229212 lastError: nil,
230− usageBarsShowUsed: false,
231− tokenCostUsageEnabled: false))
213+ usageBarsShowUsed: false))
232214
233215 #expect(model.planText == nil)
234216 #expect(model.email.isEmpty)