From 9854b21fe1411dec6b26a250c8ec72d24eb68d32 Mon Sep 17 00:00:00 2001 From: solnikhil Date: Wed, 13 May 2026 12:47:13 +0530 Subject: [PATCH 01/27] Improve Kiro credits UX --- Sources/CodexBar/MenuCardView+Costs.swift | 106 +++++ Sources/CodexBar/MenuCardView+Kiro.swift | 36 ++ Sources/CodexBar/MenuCardView.swift | 128 ++---- Sources/CodexBar/MenuDescriptor.swift | 16 +- .../PreferencesProviderDetailView.swift | 12 + .../Kiro/KiroProviderImplementation.swift | 21 + Sources/CodexBar/SettingsStore+Defaults.swift | 17 + .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 27 ++ Sources/CodexBar/SettingsStoreState.swift | 1 + .../StatusItemController+Animation.swift | 50 ++ .../Providers/Kiro/KiroStatusProbe.swift | 429 +++++++++++++++--- Sources/CodexBarCore/UsageFetcher.swift | 7 + .../CodexBarTests/KiroStatusProbeTests.swift | 77 +++- Tests/CodexBarTests/MenuCardModelTests.swift | 57 +++ .../StatusItemBalanceDisplayTests.swift | 118 +++++ 16 files changed, 937 insertions(+), 166 deletions(-) create mode 100644 Sources/CodexBar/MenuCardView+Costs.swift create mode 100644 Sources/CodexBar/MenuCardView+Kiro.swift diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift new file mode 100644 index 0000000000..89ad8b06fe --- /dev/null +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -0,0 +1,106 @@ +import CodexBarCore +import Foundation + +extension UsageMenuCardView.Model { + static func creditsLine( + metadata: ProviderMetadata, + credits: CreditsSnapshot?, + error: String?) -> String? + { + guard metadata.supportsCredits else { return nil } + if let credits { + return UsageFormatter.creditsString(from: credits.remaining) + } + if let error, !error.isEmpty { + return error.trimmingCharacters(in: .whitespacesAndNewlines) + } + return metadata.creditsHint + } + + static func tokenUsageSection( + provider: UsageProvider, + enabled: Bool, + snapshot: CostUsageTokenSnapshot?, + error: String?) -> TokenUsageSection? + { + guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } + guard enabled else { return nil } + guard let snapshot else { return nil } + + let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } + let sessionLine: String = { + if let sessionTokens { + return "Today: \(sessionCost) · \(sessionTokens) tokens" + } + return "Today: \(sessionCost)" + }() + + let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) + let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) + let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } + let monthLine: String = { + if let monthTokens { + return "Last 30 days: \(monthCost) · \(monthTokens) tokens" + } + return "Last 30 days: \(monthCost)" + }() + let err = (error?.isEmpty ?? true) ? nil : error + return TokenUsageSection( + sessionLine: sessionLine, + monthLine: monthLine, + hintLine: nil, + errorLine: err, + errorCopyText: (error?.isEmpty ?? true) ? nil : error) + } + + static func providerCostSection( + provider: UsageProvider, + cost: ProviderCostSnapshot?) -> ProviderCostSection? + { + if provider == .manus { + return nil + } + guard let cost else { return nil } + guard provider != .synthetic else { return nil } + + if provider == .factory, cost.period == "Extra usage balance" { + let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + return ProviderCostSection( + title: "Extra usage", + percentUsed: nil, + spendLine: "Balance: \(balance)", + percentLine: nil) + } + + guard cost.limit > 0 else { return nil } + + let used: String + let limit: String + let title: String + + if cost.currencyCode == "Quota" { + title = "Quota usage" + used = String(format: "%.0f", cost.used) + limit = String(format: "%.0f", cost.limit) + } else { + title = "Extra usage" + used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + } + + let percentUsed = Self.clamped((cost.used / cost.limit) * 100) + let periodLabel = cost.period ?? "This month" + + return ProviderCostSection( + title: title, + percentUsed: percentUsed, + spendLine: "\(periodLabel): \(used) / \(limit)", + percentLine: String(format: "%.0f%% used", min(100, max(0, percentUsed)))) + } + + static func clamped(_ value: Double) -> Double { + min(100, max(0, value)) + } +} diff --git a/Sources/CodexBar/MenuCardView+Kiro.swift b/Sources/CodexBar/MenuCardView+Kiro.swift new file mode 100644 index 0000000000..7e800ae5b0 --- /dev/null +++ b/Sources/CodexBar/MenuCardView+Kiro.swift @@ -0,0 +1,36 @@ +import CodexBarCore +import Foundation + +extension UsageMenuCardView.Model { + static func kiroUsageNotes(input: Input) -> [String] { + var notes: [String] = [] + if let authMethod = input.snapshot?.loginMethod(for: .kiro)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !authMethod.isEmpty + { + notes.append("Auth: \(authMethod)") + } + if let overages = input.snapshot?.kiroUsage?.overagesStatus? + .trimmingCharacters(in: .whitespacesAndNewlines), + !overages.isEmpty + { + notes.append("Overages: \(overages)") + } + return notes + } + + static func kiroPlan(snapshot: UsageSnapshot?) -> String? { + guard let plan = snapshot?.kiroUsage?.displayPlanName, + !plan.isEmpty + else { return nil } + return plan + } + + static func kiroCreditNumber(_ value: Double) -> String { + let rounded = value.rounded() + if abs(value - rounded) < 0.005 { + return String(format: "%.0f", rounded) + } + return String(format: "%.2f", value) + } +} diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 86787596f4..2320266805 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -792,6 +792,10 @@ extension UsageMenuCardView.Model { } private static func usageNotes(input: Input) -> [String] { + if input.provider == .kiro { + return kiroUsageNotes(input: input) + } + if input.provider == .kilo { var notes = Self.kiloLoginDetails(snapshot: input.snapshot) let resolvedSource = input.sourceLabel? @@ -852,6 +856,11 @@ extension UsageMenuCardView.Model { account: AccountInfo, metadata: ProviderMetadata) -> String? { + if provider == .kiro, + let plan = kiroPlan(snapshot: snapshot) + { + return plan + } if provider == .kilo { guard let pass = self.kiloLoginPass(snapshot: snapshot) else { return nil @@ -1140,6 +1149,14 @@ extension UsageMenuCardView.Model { { primaryDetailText = detail } + if input.provider == .kiro, + let kiroUsage = input.snapshot?.kiroUsage, + kiroUsage.creditsTotal > 0 + { + let remaining = Self.kiroCreditNumber(kiroUsage.creditsRemaining) + let total = Self.kiroCreditNumber(kiroUsage.creditsTotal) + primaryDetailText = "\(remaining) of \(total) credits left" + } if input.provider == .alibaba || input.provider == .mistral || input.provider == .manus, let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -1251,6 +1268,15 @@ extension UsageMenuCardView.Model { weeklyResetText = nil } } + if input.provider == .kiro, + let kiroUsage = input.snapshot?.kiroUsage, + let remaining = kiroUsage.bonusCreditsRemaining, + let total = kiroUsage.bonusCreditsTotal + { + let remainingText = Self.kiroCreditNumber(remaining) + let totalText = Self.kiroCreditNumber(total) + weeklyDetailText = "\(remainingText) of \(totalText) bonus credits left" + } if input.provider == .alibaba, let detail = weekly.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -1503,110 +1529,8 @@ extension UsageMenuCardView.Model { return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true)) } - private static func creditsLine( - metadata: ProviderMetadata, - credits: CreditsSnapshot?, - error: String?) -> String? - { - guard metadata.supportsCredits else { return nil } - if let credits { - return UsageFormatter.creditsString(from: credits.remaining) - } - if let error, !error.isEmpty { - return error.trimmingCharacters(in: .whitespacesAndNewlines) - } - return metadata.creditsHint - } - private static func dashboardHint(error: String?) -> String? { guard let error, !error.isEmpty else { return nil } return error } - - private static func tokenUsageSection( - provider: UsageProvider, - enabled: Bool, - snapshot: CostUsageTokenSnapshot?, - error: String?) -> TokenUsageSection? - { - guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } - guard enabled else { return nil } - guard let snapshot else { return nil } - - let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" - let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } - let sessionLine: String = { - if let sessionTokens { - return "Today: \(sessionCost) · \(sessionTokens) tokens" - } - return "Today: \(sessionCost)" - }() - - let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" - let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) - let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) - let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } - let monthLine: String = { - if let monthTokens { - return "Last 30 days: \(monthCost) · \(monthTokens) tokens" - } - return "Last 30 days: \(monthCost)" - }() - let err = (error?.isEmpty ?? true) ? nil : error - return TokenUsageSection( - sessionLine: sessionLine, - monthLine: monthLine, - hintLine: nil, - errorLine: err, - errorCopyText: (error?.isEmpty ?? true) ? nil : error) - } - - private static func providerCostSection( - provider: UsageProvider, - cost: ProviderCostSnapshot?) -> ProviderCostSection? - { - if provider == .manus { - return nil - } - guard let cost else { return nil } - guard provider != .synthetic else { return nil } - - if provider == .factory, cost.period == "Extra usage balance" { - let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - return ProviderCostSection( - title: "Extra usage", - percentUsed: nil, - spendLine: "Balance: \(balance)", - percentLine: nil) - } - - guard cost.limit > 0 else { return nil } - - let used: String - let limit: String - let title: String - - if cost.currencyCode == "Quota" { - title = "Quota usage" - used = String(format: "%.0f", cost.used) - limit = String(format: "%.0f", cost.limit) - } else { - title = "Extra usage" - used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) - } - - let percentUsed = Self.clamped((cost.used / cost.limit) * 100) - let periodLabel = cost.period ?? "This month" - - return ProviderCostSection( - title: title, - percentUsed: percentUsed, - spendLine: "\(periodLabel): \(used) / \(limit)", - percentLine: String(format: "%.0f%% used", min(100, max(0, percentUsed)))) - } - - private static func clamped(_ value: Double) -> Double { - min(100, max(0, value)) - } } diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 475e39b644..5862744d9f 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -294,7 +294,21 @@ struct MenuDescriptor { if let emailText, !emailText.isEmpty { entries.append(.text("Account: \(redactedEmail)", .secondary)) } - if provider == .kilo { + if provider == .kiro { + if let plan = snapshot?.kiroUsage?.displayPlanName, + !plan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + entries.append(.text("Plan: \(plan)", .secondary)) + } + if let loginMethodText, !loginMethodText.isEmpty { + entries.append(.text("Auth: \(loginMethodText)", .secondary)) + } + if let overages = snapshot?.kiroUsage?.overagesStatus, + !overages.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + entries.append(.text("Overages: \(overages)", .secondary)) + } + } else if provider == .kilo { let kiloLogin = self.kiloLoginParts(loginMethod: loginMethodText) if let pass = kiloLogin.pass { entries.append(.text("Plan: \(AccountFormatter.plan(pass, provider: provider))", .secondary)) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index c0dd769d5a..09d19b92e4 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -165,6 +165,11 @@ struct ProviderDetailView: View { if !self.model.email.isEmpty { infoLabels.append("Account") } + if self.provider == .kiro, + self.model.metrics.isEmpty == false + { + infoLabels.append("Auth") + } if let planRow = Self.planRow(provider: self.provider, planText: self.model.planText) { infoLabels.append(planRow.label) } @@ -307,6 +312,13 @@ private struct ProviderDetailInfoGrid: View { ProviderDetailInfoRow(label: "Account", value: email, labelWidth: self.labelWidth) } + if self.provider == .kiro, + let authMethod = self.store.snapshot(for: self.provider)?.loginMethod(for: .kiro), + !authMethod.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + ProviderDetailInfoRow(label: "Auth", value: authMethod, labelWidth: self.labelWidth) + } + if let planRow = ProviderDetailView.planRow( provider: self.provider, planText: self.model.planText) diff --git a/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift b/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift index e54bc4290e..170adeeebe 100644 --- a/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift @@ -1,8 +1,29 @@ import CodexBarCore import CodexBarMacroSupport import Foundation +import SwiftUI @ProviderImplementationRegistration struct KiroProviderImplementation: ProviderImplementation { let id: UsageProvider = .kiro + + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + [ + ProviderSettingsPickerDescriptor( + id: "kiroMenuBarDisplay", + title: "Kiro menu bar value", + subtitle: "Show or hide Kiro credits, percent, or both next to the menu bar icon.", + binding: Binding( + get: { context.settings.kiroMenuBarDisplayMode.rawValue }, + set: { rawValue in + guard let mode = KiroMenuBarDisplayMode(rawValue: rawValue) else { return } + context.settings.kiroMenuBarDisplayMode = mode + }), + options: KiroMenuBarDisplayMode.allCases.map { + ProviderSettingsPickerOption(id: $0.rawValue, title: $0.label) + }, + isVisible: { true }, + onChange: nil), + ] + } } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 0ecabf6a44..28bc3bb579 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -197,6 +197,23 @@ extension SettingsStore { set { self.menuBarDisplayModeRaw = newValue.rawValue } } + private var kiroMenuBarDisplayModeRaw: String? { + get { self.defaultsState.kiroMenuBarDisplayModeRaw } + set { + self.defaultsState.kiroMenuBarDisplayModeRaw = newValue + if let raw = newValue { + self.userDefaults.set(raw, forKey: "kiroMenuBarDisplayMode") + } else { + self.userDefaults.removeObject(forKey: "kiroMenuBarDisplayMode") + } + } + } + + var kiroMenuBarDisplayMode: KiroMenuBarDisplayMode { + get { KiroMenuBarDisplayMode(rawValue: self.kiroMenuBarDisplayModeRaw ?? "") ?? .automatic } + set { self.kiroMenuBarDisplayModeRaw = newValue.rawValue } + } + var multiAccountMenuLayout: MultiAccountMenuLayout { get { MultiAccountMenuLayout(rawValue: self.defaultsState.multiAccountMenuLayoutRaw) ?? .segmented } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 5e21f16452..f2515605b0 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -22,6 +22,7 @@ extension SettingsStore { _ = self.menuBarShowsBrandIconWithPercent _ = self.menuBarShowsHighestUsage _ = self.menuBarDisplayMode + _ = self.kiroMenuBarDisplayMode _ = self.historicalTrackingEnabled _ = self.multiAccountMenuLayout _ = self.menuBarMetricPreferencesRaw diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index d6fddc1485..068af6f336 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -62,6 +62,30 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable { } } +enum KiroMenuBarDisplayMode: String, CaseIterable, Identifiable { + case automatic + case hidden + case creditsLeft + case percentLeft + case creditsAndPercent + case usedAndTotal + + var id: String { + self.rawValue + } + + var label: String { + switch self { + case .automatic: "Automatic" + case .hidden: "Hidden" + case .creditsLeft: "Credits left" + case .percentLeft: "Percent left" + case .creditsAndPercent: "Credits + percent" + case .usedAndTotal: "Used / total" + } + } +} + enum MultiAccountMenuLayout: String, CaseIterable, Identifiable { case segmented case stacked @@ -299,6 +323,8 @@ extension SettingsStore { forKey: "menuBarShowsBrandIconWithPercent") as? Bool ?? false let menuBarDisplayModeRaw = userDefaults.string(forKey: "menuBarDisplayMode") ?? MenuBarDisplayMode.percent.rawValue + let kiroMenuBarDisplayModeRaw = userDefaults.string(forKey: "kiroMenuBarDisplayMode") + ?? KiroMenuBarDisplayMode.automatic.rawValue let historicalTrackingEnabled = userDefaults.object(forKey: "historicalTrackingEnabled") as? Bool ?? false let multiAccountMenuLayoutRaw = userDefaults.string(forKey: "multiAccountMenuLayout") ?? { let legacyShowAll = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false @@ -375,6 +401,7 @@ extension SettingsStore { resetTimesShowAbsolute: resetTimesShowAbsolute, menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent, menuBarDisplayModeRaw: menuBarDisplayModeRaw, + kiroMenuBarDisplayModeRaw: kiroMenuBarDisplayModeRaw, historicalTrackingEnabled: historicalTrackingEnabled, multiAccountMenuLayoutRaw: multiAccountMenuLayoutRaw, menuBarMetricPreferencesRaw: resolvedPreferences, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 30224e5e71..32701401c1 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -21,6 +21,7 @@ struct SettingsDefaultsState { var resetTimesShowAbsolute: Bool var menuBarShowsBrandIconWithPercent: Bool var menuBarDisplayModeRaw: String? + var kiroMenuBarDisplayModeRaw: String? var historicalTrackingEnabled: Bool var multiAccountMenuLayoutRaw: String var menuBarMetricPreferencesRaw: [String: String] diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 05401c1127..9ea1aee51e 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -577,6 +577,12 @@ extension StatusItemController { { return credits } + if provider == .kiro { + return Self.kiroDisplayText( + snapshot: snapshot, + mode: self.settings.kiroMenuBarDisplayMode, + showUsed: self.settings.usageBarsShowUsed) + } let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot) let mode = self.settings.menuBarDisplayMode @@ -659,6 +665,50 @@ extension StatusItemController { removingSuffix: " left") } + nonisolated static func kiroDisplayText( + snapshot: UsageSnapshot?, + mode: KiroMenuBarDisplayMode, + showUsed: Bool) + -> String? + { + guard mode != .hidden else { return nil } + guard let usage = snapshot?.kiroUsage else { + return MenuBarDisplayText.percentText(window: snapshot?.primary, showUsed: showUsed) + } + let percentText = MenuBarDisplayText.percentText( + window: snapshot?.primary, + showUsed: showUsed) + let creditsLeft = Self.kiroCreditNumber(usage.creditsRemaining) + let usedTotal = "\(Self.kiroCreditNumber(usage.creditsUsed)) / \(Self.kiroCreditNumber(usage.creditsTotal))" + + switch mode { + case .automatic, .creditsLeft: + if usage.creditsTotal > 0 { + return creditsLeft + } + return percentText + case .hidden: + return nil + case .percentLeft: + return MenuBarDisplayText.percentText(window: snapshot?.primary, showUsed: false) + case .creditsAndPercent: + guard usage.creditsTotal > 0 else { return percentText } + guard let percentText else { return creditsLeft } + return "\(creditsLeft) · \(percentText)" + case .usedAndTotal: + guard usage.creditsTotal > 0 else { return percentText } + return usedTotal + } + } + + nonisolated static func kiroCreditNumber(_ value: Double) -> String { + let rounded = value.rounded() + if abs(value - rounded) < 0.005 { + return String(format: "%.0f", rounded) + } + return String(format: "%.2f", value) + } + private nonisolated static func displayValue( from text: String?, prefix: String, diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index 2855284850..239aa35dac 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -2,15 +2,55 @@ import Foundation public struct KiroUsageSnapshot: Sendable { public let planName: String + public let displayPlanName: String + public let accountEmail: String? + public let authMethod: String? public let creditsUsed: Double public let creditsTotal: Double public let creditsPercent: Double public let bonusCreditsUsed: Double? public let bonusCreditsTotal: Double? public let bonusExpiryDays: Int? + public let overagesStatus: String? + public let manageURL: String? + public let contextUsage: KiroContextUsageSnapshot? public let resetsAt: Date? public let updatedAt: Date + public init( + planName: String, + displayPlanName: String? = nil, + accountEmail: String? = nil, + authMethod: String? = nil, + creditsUsed: Double, + creditsTotal: Double, + creditsPercent: Double, + bonusCreditsUsed: Double?, + bonusCreditsTotal: Double?, + bonusExpiryDays: Int?, + overagesStatus: String? = nil, + manageURL: String? = nil, + contextUsage: KiroContextUsageSnapshot? = nil, + resetsAt: Date?, + updatedAt: Date) + { + self.planName = planName + self.displayPlanName = displayPlanName ?? KiroStatusProbe.displayPlanName(planName) + self.accountEmail = accountEmail + self.authMethod = authMethod + self.creditsUsed = creditsUsed + self.creditsTotal = creditsTotal + self.creditsPercent = creditsPercent + self.bonusCreditsUsed = bonusCreditsUsed + self.bonusCreditsTotal = bonusCreditsTotal + self.bonusExpiryDays = bonusExpiryDays + self.overagesStatus = overagesStatus + self.manageURL = manageURL + self.contextUsage = contextUsage + self.resetsAt = resetsAt + self.updatedAt = updatedAt + } + public func toUsageSnapshot() -> UsageSnapshot { let primary = RateWindow( usedPercent: self.creditsPercent, @@ -37,19 +77,108 @@ public struct KiroUsageSnapshot: Sendable { let identity = ProviderIdentitySnapshot( providerID: .kiro, - accountEmail: nil, - accountOrganization: self.planName, - loginMethod: self.planName) + accountEmail: self.accountEmail, + accountOrganization: nil, + loginMethod: self.authMethod) + + let kiroUsage = KiroUsageDetails( + planName: self.planName, + displayPlanName: self.displayPlanName, + creditsUsed: self.creditsUsed, + creditsTotal: self.creditsTotal, + creditsRemaining: self.creditsRemaining, + bonusCreditsUsed: self.bonusCreditsUsed, + bonusCreditsTotal: self.bonusCreditsTotal, + bonusCreditsRemaining: self.bonusCreditsRemaining, + bonusExpiryDays: self.bonusExpiryDays, + overagesStatus: self.overagesStatus, + manageURL: self.manageURL, + contextUsage: self.contextUsage) return UsageSnapshot( primary: primary, secondary: secondary, tertiary: nil, + kiroUsage: kiroUsage, providerCost: nil, zaiUsage: nil, updatedAt: self.updatedAt, identity: identity) } + + public var creditsRemaining: Double { + max(0, self.creditsTotal - self.creditsUsed) + } + + public var bonusCreditsRemaining: Double? { + guard let bonusCreditsUsed, let bonusCreditsTotal else { return nil } + return max(0, bonusCreditsTotal - bonusCreditsUsed) + } +} + +public struct KiroContextUsageSnapshot: Codable, Equatable, Sendable { + public let totalPercentUsed: Double + public let contextFilesPercent: Double? + public let toolsPercent: Double? + public let kiroResponsesPercent: Double? + public let promptsPercent: Double? + + public init( + totalPercentUsed: Double, + contextFilesPercent: Double?, + toolsPercent: Double?, + kiroResponsesPercent: Double?, + promptsPercent: Double?) + { + self.totalPercentUsed = totalPercentUsed + self.contextFilesPercent = contextFilesPercent + self.toolsPercent = toolsPercent + self.kiroResponsesPercent = kiroResponsesPercent + self.promptsPercent = promptsPercent + } +} + +public struct KiroUsageDetails: Codable, Equatable, Sendable { + public let planName: String + public let displayPlanName: String + public let creditsUsed: Double + public let creditsTotal: Double + public let creditsRemaining: Double + public let bonusCreditsUsed: Double? + public let bonusCreditsTotal: Double? + public let bonusCreditsRemaining: Double? + public let bonusExpiryDays: Int? + public let overagesStatus: String? + public let manageURL: String? + public let contextUsage: KiroContextUsageSnapshot? + + public init( + planName: String, + displayPlanName: String, + creditsUsed: Double, + creditsTotal: Double, + creditsRemaining: Double, + bonusCreditsUsed: Double?, + bonusCreditsTotal: Double?, + bonusCreditsRemaining: Double?, + bonusExpiryDays: Int?, + overagesStatus: String?, + manageURL: String?, + contextUsage: KiroContextUsageSnapshot?) + { + self.planName = planName + self.displayPlanName = displayPlanName + self.creditsUsed = creditsUsed + self.creditsTotal = creditsTotal + self.creditsRemaining = creditsRemaining + self.bonusCreditsUsed = bonusCreditsUsed + self.bonusCreditsTotal = bonusCreditsTotal + self.bonusCreditsRemaining = bonusCreditsRemaining + self.bonusExpiryDays = bonusExpiryDays + self.overagesStatus = overagesStatus + self.manageURL = manageURL + self.contextUsage = contextUsage + } } public enum KiroStatusProbeError: LocalizedError, Sendable { @@ -105,9 +234,19 @@ public struct KiroStatusProbe: Sendable { } public func fetch() async throws -> KiroUsageSnapshot { - try await self.ensureLoggedIn() + let account = try await self.ensureLoggedIn() let output = try await self.runUsageCommand() - return try self.parse(output: output) + var contextUsage: KiroContextUsageSnapshot? + do { + contextUsage = try await self.fetchContextUsage() + } catch { + Self.logger.debug("Kiro context usage probe failed: \(error.localizedDescription)") + } + return try self.parse( + output: output, + accountEmail: account.email, + authMethod: account.authMethod, + contextUsage: contextUsage) } private struct KiroCLIResult { @@ -117,15 +256,20 @@ public struct KiroStatusProbe: Sendable { let terminatedForIdle: Bool } - private func ensureLoggedIn() async throws { + struct KiroAccountInfo: Equatable { + let authMethod: String? + let email: String? + } + + private func ensureLoggedIn() async throws -> KiroAccountInfo { let result = try await self.runCommand(arguments: ["whoami"], timeout: 5.0) - try self.validateWhoAmIOutput( + return try self.validateWhoAmIOutput( stdout: result.stdout, stderr: result.stderr, terminationStatus: result.terminationStatus) } - func validateWhoAmIOutput(stdout: String, stderr: String, terminationStatus: Int32) throws { + func validateWhoAmIOutput(stdout: String, stderr: String, terminationStatus: Int32) throws -> KiroAccountInfo { let trimmedStdout = stdout.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedStderr = stderr.trimmingCharacters(in: .whitespacesAndNewlines) let combined = trimmedStderr.isEmpty ? trimmedStdout : trimmedStderr @@ -145,6 +289,33 @@ public struct KiroStatusProbe: Sendable { if combined.isEmpty { throw KiroStatusProbeError.cliFailed("Kiro CLI whoami returned no output.") } + + return self.parseWhoAmIOutput(combined) + } + + func parseWhoAmIOutput(_ output: String) -> KiroAccountInfo { + let stripped = Self.stripANSI(output) + var authMethod: String? + var email: String? + for rawLine in stripped.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + if line.localizedCaseInsensitiveContains("logged in with") { + authMethod = line.replacingOccurrences( + of: #"(?i)^\s*logged in with\s+"#, + with: "", + options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } else if line.localizedCaseInsensitiveContains("email:") { + email = line.replacingOccurrences( + of: #"(?i)^\s*email:\s*"#, + with: "", + options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return KiroAccountInfo( + authMethod: authMethod?.nilIfEmpty, + email: email?.nilIfEmpty) } private func runUsageCommand() async throws -> String { @@ -188,6 +359,17 @@ public struct KiroStatusProbe: Sendable { return result.stdout } + private func fetchContextUsage() async throws -> KiroContextUsageSnapshot? { + let result = try await self.runCommand( + arguments: ["chat", "--no-interactive", "/context"], + timeout: 8.0, + idleTimeout: 3.0) + let output = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? result.stderr + : result.stdout + return self.parseContextUsage(output: output) + } + private func runCommand( arguments: [String], timeout: TimeInterval, @@ -333,7 +515,12 @@ public struct KiroStatusProbe: Sendable { } } - func parse(output: String) throws -> KiroUsageSnapshot { + func parse( + output: String, + accountEmail: String? = nil, + authMethod: String? = nil, + contextUsage: KiroContextUsageSnapshot? = nil) throws -> KiroUsageSnapshot + { let stripped = Self.stripANSI(output) let trimmed = stripped.trimmingCharacters(in: .whitespacesAndNewlines) @@ -361,37 +548,15 @@ public struct KiroStatusProbe: Sendable { var matchedCredits = false var matchedNewFormat = false - // Parse plan name from "| KIRO FREE" or similar (legacy format) - var planName = "Kiro" - if let planMatch = stripped.range(of: #"\|\s*(KIRO\s+\w+)"#, options: .regularExpression) { - let raw = String(stripped[planMatch]).replacingOccurrences(of: "|", with: "") - planName = raw.trimmingCharacters(in: .whitespaces) - } - - // Parse plan name from "Plan: Q Developer Pro" (new format, kiro-cli 1.24+) - if let newPlanMatch = stripped.range(of: #"Plan:\s*(.+)"#, options: .regularExpression) { - let line = String(stripped[newPlanMatch]) - // Extract just the plan name, stopping at newline - let planLine = line.replacingOccurrences(of: "Plan:", with: "").trimmingCharacters(in: .whitespaces) - if let firstLine = planLine.split(separator: "\n").first { - planName = String(firstLine).trimmingCharacters(in: .whitespaces) - matchedNewFormat = true - } - } + let parsedPlan = Self.parsePlanName(from: stripped) + let planName = parsedPlan.name + matchedNewFormat = parsedPlan.matchedNewFormat // Check if this is a managed plan with no usage data let isManagedPlan = lowered.contains("managed by admin") || lowered.contains("managed by organization") - // Parse reset date from "resets on 01/01" - var resetsAt: Date? - if let resetMatch = stripped.range(of: #"resets on (\d{2}/\d{2})"#, options: .regularExpression) { - let resetStr = String(stripped[resetMatch]) - if let dateRange = resetStr.range(of: #"\d{2}/\d{2}"#, options: .regularExpression) { - let dateStr = String(resetStr[dateRange]) - resetsAt = Self.parseResetDate(dateStr) - } - } + let resetsAt = Self.parseResetDate(in: stripped) // Parse credits percentage from "████...█ X%" var creditsPercent: Double = 0 @@ -420,24 +585,16 @@ public struct KiroStatusProbe: Sendable { creditsPercent = (creditsUsed / creditsTotal) * 100.0 } - // Parse bonus credits from "Bonus credits: X.XX/Y credits used, expires in Z days" - var bonusUsed: Double? - var bonusTotal: Double? - var bonusExpiryDays: Int? - if let bonusMatch = stripped.range(of: #"Bonus credits:\s*(\d+\.?\d*)/(\d+)"#, options: .regularExpression) { - let bonusStr = String(stripped[bonusMatch]) - let numbers = bonusStr.matches(of: /(\d+\.?\d*)/) - if numbers.count >= 2 { - bonusUsed = Double(String(numbers[0].output.1)) - bonusTotal = Double(String(numbers[1].output.1)) - } - } - if let expiryMatch = stripped.range(of: #"expires in (\d+) days?"#, options: .regularExpression) { - let expiryStr = String(stripped[expiryMatch]) - if let numMatch = expiryStr.range(of: #"\d+"#, options: .regularExpression) { - bonusExpiryDays = Int(String(expiryStr[numMatch])) - } - } + let bonusCredits = Self.parseBonusCredits(in: stripped) + + let overagesStatus = Self.firstCapture( + in: stripped, + pattern: #"(?i)Overages:\s*([^\n]+)"#) + .map(Self.cleanInlineValue) + .flatMap(\.nilIfEmpty) + let manageURL = Self.firstCapture( + in: stripped, + pattern: #"https://app\.kiro\.dev/account/usage"#) // Managed plans in new format may omit usage metrics. Only fall back to zeros when // we did not parse any usage values, so we do not mask real metrics. @@ -445,12 +602,18 @@ public struct KiroStatusProbe: Sendable { // Managed plans don't expose credits; return snapshot with plan name only return KiroUsageSnapshot( planName: planName, + displayPlanName: Self.displayPlanName(planName), + accountEmail: accountEmail?.nilIfEmpty, + authMethod: authMethod?.nilIfEmpty, creditsUsed: 0, creditsTotal: 0, creditsPercent: 0, - bonusCreditsUsed: nil, - bonusCreditsTotal: nil, - bonusExpiryDays: nil, + bonusCreditsUsed: bonusCredits.used, + bonusCreditsTotal: bonusCredits.total, + bonusExpiryDays: bonusCredits.expiryDays, + overagesStatus: overagesStatus, + manageURL: manageURL, + contextUsage: contextUsage, resetsAt: nil, updatedAt: Date()) } @@ -464,16 +627,39 @@ public struct KiroStatusProbe: Sendable { return KiroUsageSnapshot( planName: planName, + displayPlanName: Self.displayPlanName(planName), + accountEmail: accountEmail?.nilIfEmpty, + authMethod: authMethod?.nilIfEmpty, creditsUsed: creditsUsed, creditsTotal: creditsTotal, creditsPercent: creditsPercent, - bonusCreditsUsed: bonusUsed, - bonusCreditsTotal: bonusTotal, - bonusExpiryDays: bonusExpiryDays, + bonusCreditsUsed: bonusCredits.used, + bonusCreditsTotal: bonusCredits.total, + bonusExpiryDays: bonusCredits.expiryDays, + overagesStatus: overagesStatus, + manageURL: manageURL, + contextUsage: contextUsage, resetsAt: resetsAt, updatedAt: Date()) } + func parseContextUsage(output: String) -> KiroContextUsageSnapshot? { + let stripped = Self.stripANSI(output) + guard let total = Self.firstCapture( + in: stripped, + pattern: #"(?i)Context window:\s*(\d+\.?\d*)%\s+used"#) + .flatMap(Double.init) + else { + return nil + } + return KiroContextUsageSnapshot( + totalPercentUsed: total, + contextFilesPercent: Self.percent(after: "Context files", in: stripped), + toolsPercent: Self.percent(after: "Tools", in: stripped), + kiroResponsesPercent: Self.percent(after: "Kiro responses", in: stripped), + promptsPercent: Self.percent(after: "Your prompts", in: stripped)) + } + private static func stripANSI(_ text: String) -> String { // Remove ANSI escape sequences let pattern = #"\x1B\[[0-9;?]*[A-Za-z]|\x1B\].*?\x07"# @@ -484,7 +670,87 @@ public struct KiroStatusProbe: Sendable { return regex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: "") } + private static func parsePlanName(from text: String) -> (name: String, matchedNewFormat: Bool) { + var planName = "Kiro" + var matchedNewFormat = false + + // Parse plan name from "| KIRO FREE" or similar (legacy format) + if let planMatch = text.range(of: #"\|\s*(KIRO\s+\w+)"#, options: .regularExpression) { + let raw = String(text[planMatch]).replacingOccurrences(of: "|", with: "") + planName = raw.trimmingCharacters(in: .whitespaces) + } + + // Parse plan name from "Estimated Usage | resets on 2026-06-01 | KIRO FREE" (kiro-cli 2.x) + if let estimatedMatch = text.range( + of: #"Estimated Usage\s*\|[^\n|]*\|\s*([A-Z][A-Z0-9 ]+)"#, + options: .regularExpression) + { + let line = String(text[estimatedMatch]) + if let plan = line.split(separator: "|").last?.trimmingCharacters(in: .whitespacesAndNewlines), + !plan.isEmpty + { + planName = plan + } + } + + // Parse plan name from "Plan: Q Developer Pro" (new format, kiro-cli 1.24+) + if let newPlanMatch = text.range(of: #"Plan:\s*(.+)"#, options: .regularExpression) { + let line = String(text[newPlanMatch]) + let planLine = line.replacingOccurrences(of: "Plan:", with: "").trimmingCharacters(in: .whitespaces) + if let firstLine = planLine.split(separator: "\n").first { + planName = String(firstLine).trimmingCharacters(in: .whitespaces) + matchedNewFormat = true + } + } + + return (planName, matchedNewFormat) + } + + private static func parseResetDate(in text: String) -> Date? { + guard let resetMatch = text.range( + of: #"resets on (\d{4}-\d{2}-\d{2}|\d{2}/\d{2})"#, + options: .regularExpression) + else { return nil } + + let resetStr = String(text[resetMatch]) + guard let dateRange = resetStr.range( + of: #"\d{4}-\d{2}-\d{2}|\d{2}/\d{2}"#, + options: .regularExpression) + else { return nil } + + return Self.parseResetDate(String(resetStr[dateRange])) + } + + private static func parseBonusCredits(in text: String) -> (used: Double?, total: Double?, expiryDays: Int?) { + var used: Double? + var total: Double? + var expiryDays: Int? + if let bonusMatch = text.range(of: #"Bonus credits:\s*(\d+\.?\d*)/(\d+)"#, options: .regularExpression) { + let bonusStr = String(text[bonusMatch]) + let numbers = bonusStr.matches(of: /(\d+\.?\d*)/) + if numbers.count >= 2 { + used = Double(String(numbers[0].output.1)) + total = Double(String(numbers[1].output.1)) + } + } + if let expiryMatch = text.range(of: #"expires in (\d+) days?"#, options: .regularExpression) { + let expiryStr = String(text[expiryMatch]) + if let numMatch = expiryStr.range(of: #"\d+"#, options: .regularExpression) { + expiryDays = Int(String(expiryStr[numMatch])) + } + } + return (used, total, expiryDays) + } + private static func parseResetDate(_ dateStr: String) -> Date? { + if dateStr.contains("-") { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = Calendar.current.timeZone + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: dateStr) + } + // Format: MM/DD - assume current or next year let parts = dateStr.split(separator: "/") guard parts.count == 2, @@ -510,6 +776,45 @@ public struct KiroStatusProbe: Sendable { return calendar.date(from: components) } + public static func displayPlanName(_ planName: String) -> String { + let cleaned = Self.cleanInlineValue(planName) + .replacingOccurrences(of: #"\s+"#, with: " ", options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard cleaned.localizedCaseInsensitiveContains("KIRO") else { + return cleaned.isEmpty ? planName : cleaned + } + return cleaned + .split(separator: " ") + .map { word in + if word.caseInsensitiveCompare("KIRO") == .orderedSame { return "Kiro" } + return word.prefix(1).uppercased() + word.dropFirst().lowercased() + } + .joined(separator: " ") + } + + private static func percent(after label: String, in text: String) -> Double? { + let escaped = NSRegularExpression.escapedPattern(for: label) + return self.firstCapture( + in: text, + pattern: #"(?i)"# + escaped + #"\s+(\d+\.?\d*)%"#) + .flatMap(Double.init) + } + + private static func firstCapture(in text: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let nsRange = NSRange(text.startIndex.. 1 ? 1 : 0 + guard let range = Range(match.range(at: captureIndex), in: text) else { return nil } + return String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func cleanInlineValue(_ text: String) -> String { + self.stripANSI(text) + .replacingOccurrences(of: #"\x1B|\[[0-9;?]*[A-Za-z]"#, with: "", options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + private static func isUsageOutputComplete(_ output: String) -> Bool { let stripped = self.stripANSI(output).lowercased() return stripped.contains("covered in plan") @@ -519,3 +824,9 @@ public struct KiroStatusProbe: Sendable { || stripped.contains("managed by admin") } } + +extension String { + fileprivate var nilIfEmpty: String? { + self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 64a980b991..79cd45515c 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -85,6 +85,7 @@ public struct UsageSnapshot: Codable, Sendable { public let tertiary: RateWindow? public let extraRateWindows: [NamedRateWindow]? public let providerCost: ProviderCostSnapshot? + public let kiroUsage: KiroUsageDetails? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? public let openRouterUsage: OpenRouterUsageSnapshot? @@ -98,6 +99,7 @@ public struct UsageSnapshot: Codable, Sendable { case tertiary case extraRateWindows case providerCost + case kiroUsage case openRouterUsage case updatedAt case identity @@ -111,6 +113,7 @@ public struct UsageSnapshot: Codable, Sendable { secondary: RateWindow?, tertiary: RateWindow? = nil, extraRateWindows: [NamedRateWindow]? = nil, + kiroUsage: KiroUsageDetails? = nil, providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, @@ -123,6 +126,7 @@ public struct UsageSnapshot: Codable, Sendable { self.secondary = secondary self.tertiary = tertiary self.extraRateWindows = extraRateWindows + self.kiroUsage = kiroUsage self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage @@ -139,6 +143,7 @@ public struct UsageSnapshot: Codable, Sendable { self.tertiary = try container.decodeIfPresent(RateWindow.self, forKey: .tertiary) self.extraRateWindows = try container.decodeIfPresent([NamedRateWindow].self, forKey: .extraRateWindows) self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) + self.kiroUsage = try container.decodeIfPresent(KiroUsageDetails.self, forKey: .kiroUsage) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) @@ -170,6 +175,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encode(self.tertiary, forKey: .tertiary) try container.encodeIfPresent(self.extraRateWindows, forKey: .extraRateWindows) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) + try container.encodeIfPresent(self.kiroUsage, forKey: .kiroUsage) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) try container.encode(self.updatedAt, forKey: .updatedAt) try container.encodeIfPresent(self.identity, forKey: .identity) @@ -254,6 +260,7 @@ public struct UsageSnapshot: Codable, Sendable { secondary: self.secondary, tertiary: self.tertiary, extraRateWindows: self.extraRateWindows, + kiroUsage: self.kiroUsage, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index 0ef6e01d4d..2fe8601ef2 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -17,6 +17,7 @@ struct KiroStatusProbeTests { let snapshot = try probe.parse(output: output) #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.displayPlanName == "Kiro Free") #expect(snapshot.creditsPercent == 25) #expect(snapshot.creditsUsed == 12.50) #expect(snapshot.creditsTotal == 50) @@ -39,6 +40,7 @@ struct KiroStatusProbeTests { let snapshot = try probe.parse(output: output) #expect(snapshot.planName == "KIRO PRO") + #expect(snapshot.displayPlanName == "Kiro Pro") #expect(snapshot.creditsPercent == 80) #expect(snapshot.creditsUsed == 40.00) #expect(snapshot.creditsTotal == 50) @@ -202,6 +204,65 @@ struct KiroStatusProbeTests { #expect(snapshot.resetsAt != nil) } + @Test + func `parses kiro cli two usage format`() throws { + let output = """ + \u{001B}[1mEstimated Usage\u{001B}[0m | resets on 2026-06-01 | \u{001B}[mKIRO FREE\u{001B}[0m + + 🎁 Bonus credits: 45.53/2000 credits used, expires in 19 days + + \u{001B}[1mCredits\u{001B}[0m (0.17 of 50 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 0% + + Overages: \u{001B}[1mDisabled\u{001B}[0m + + To manage your plan or configure overages navigate to https://app.kiro.dev/account/usage + """ + + let probe = KiroStatusProbe() + let snapshot = try probe.parse( + output: output, + accountEmail: "person@example.com", + authMethod: "Google") + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.displayPlanName == "Kiro Free") + #expect(snapshot.accountEmail == "person@example.com") + #expect(snapshot.authMethod == "Google") + #expect(snapshot.creditsUsed == 0.17) + #expect(snapshot.creditsTotal == 50) + #expect(snapshot.creditsRemaining == 49.83) + #expect(snapshot.bonusCreditsUsed == 45.53) + #expect(snapshot.bonusCreditsTotal == 2000) + #expect(snapshot.bonusCreditsRemaining == 1954.47) + #expect(snapshot.bonusExpiryDays == 19) + #expect(snapshot.overagesStatus == "Disabled") + #expect(snapshot.manageURL == "https://app.kiro.dev/account/usage") + #expect(snapshot.resetsAt != nil) + } + + @Test + func `parses context usage`() throws { + let output = """ + Context window: 1.3% used (estimated) + ██████████████████████████████████████████████████████████████████████████████ 1.3% + + █ Context files 0.5% (estimated) + █ Tools 0.8% (estimated) + █ Kiro responses 0.0% (estimated) + █ Your prompts 0.0% (estimated) + """ + + let probe = KiroStatusProbe() + let context = try #require(probe.parseContextUsage(output: output)) + + #expect(context.totalPercentUsed == 1.3) + #expect(context.contextFilesPercent == 0.5) + #expect(context.toolsPercent == 0.8) + #expect(context.kiroResponsesPercent == 0) + #expect(context.promptsPercent == 0) + } + // MARK: - Snapshot Conversion @Test @@ -225,8 +286,10 @@ struct KiroStatusProbeTests { #expect(usage.primary?.usedPercent == 25.0) #expect(usage.primary?.resetsAt == resetDate) #expect(usage.secondary?.usedPercent == 25.0) // 5/20 * 100 - #expect(usage.loginMethod(for: .kiro) == "KIRO PRO") - #expect(usage.accountOrganization(for: .kiro) == "KIRO PRO") + #expect(usage.loginMethod(for: .kiro) == nil) + #expect(usage.accountOrganization(for: .kiro) == nil) + #expect(usage.kiroUsage?.displayPlanName == "Kiro Pro") + #expect(usage.kiroUsage?.creditsRemaining == 75) } @Test @@ -364,9 +427,15 @@ struct KiroStatusProbeTests { func `whoami success does not throw`() throws { let probe = KiroStatusProbe() - try probe.validateWhoAmIOutput( - stdout: "user@example.com", + let account = try probe.validateWhoAmIOutput( + stdout: """ + Logged in with Google + Email: user@example.com + """, stderr: "", terminationStatus: 0) + + #expect(account.authMethod == "Google") + #expect(account.email == "user@example.com") } } diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 60d25bc7d9..236ae5c4f6 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -136,6 +136,63 @@ struct FactoryMenuCardModelTests { } } +struct KiroMenuCardModelTests { + @Test + func `kiro model shows account plan credits bonus and overages`() throws { + let now = Date() + let snapshot = KiroUsageSnapshot( + planName: "KIRO FREE", + accountEmail: "person@example.com", + authMethod: "Google", + creditsUsed: 0.17, + creditsTotal: 50, + creditsPercent: 0, + bonusCreditsUsed: 45.53, + bonusCreditsTotal: 2000, + bonusExpiryDays: 19, + overagesStatus: "Disabled", + manageURL: "https://app.kiro.dev/account/usage", + contextUsage: KiroContextUsageSnapshot( + totalPercentUsed: 1.3, + contextFilesPercent: 0.5, + toolsPercent: 0.8, + kiroResponsesPercent: 0, + promptsPercent: 0), + resetsAt: now.addingTimeInterval(3600), + updatedAt: now).toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.kiro]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .kiro, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.email == "person@example.com") + #expect(model.planText == "Kiro Free") + #expect(model.metrics.map(\.title) == ["Credits", "Bonus"]) + #expect(model.metrics.first?.detailText == "49.83 of 50 credits left") + #expect(model.metrics.dropFirst().first?.detailText == "1954.47 of 2000 bonus credits left") + #expect(model.usageNotes.contains("Auth: Google")) + #expect(model.usageNotes.contains("Overages: Disabled")) + #expect(model.usageNotes.contains { $0.localizedCaseInsensitiveContains("Context window") } == false) + } +} + struct MiniMaxMenuCardModelTests { @Test func `minimax service metrics respect used and remaining display modes`() throws { diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift index db41356441..a9b899b231 100644 --- a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -146,6 +146,101 @@ struct StatusItemBalanceDisplayTests { #expect(displayText == "1234.5") } + @Test + func `kiro menu bar automatic uses credits left`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-automatic", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .automatic + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "49.83") + } + + @Test + func `kiro menu bar credits and percent combines values`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-both", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .creditsAndPercent + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "49.83 · 0%") + } + + @Test + func `kiro menu bar hidden suppresses text value`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-hidden", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .hidden + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == nil) + } + + @Test + func `kiro menu bar used and total formats credits`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-used-total", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .usedAndTotal + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "0.17 / 50") + } + + @Test + func `kiro managed plan display falls back to percent`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-managed", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .automatic + settings.usageBarsShowUsed = false + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = KiroUsageSnapshot( + planName: "Q Developer Pro", + creditsUsed: 0, + creditsTotal: 0, + creditsPercent: 0, + bonusCreditsUsed: nil, + bonusCreditsTotal: nil, + bonusExpiryDays: nil, + resetsAt: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "100%") + } + @Test func `mistral primary window is nil even when billing end date is set`() { let endDate = Date(timeIntervalSinceNow: 3600) @@ -216,4 +311,27 @@ struct StatusItemBalanceDisplayTests { rateLimit: nil, updatedAt: Date()).toUsageSnapshot() } + + private static func kiroSnapshot() -> UsageSnapshot { + KiroUsageSnapshot( + planName: "KIRO FREE", + accountEmail: "person@example.com", + authMethod: "Google", + creditsUsed: 0.17, + creditsTotal: 50, + creditsPercent: 0, + bonusCreditsUsed: 45.53, + bonusCreditsTotal: 2000, + bonusExpiryDays: 19, + overagesStatus: "Disabled", + manageURL: "https://app.kiro.dev/account/usage", + contextUsage: KiroContextUsageSnapshot( + totalPercentUsed: 1.3, + contextFilesPercent: 0.5, + toolsPercent: 0.8, + kiroResponsesPercent: 0, + promptsPercent: 0), + resetsAt: Date(), + updatedAt: Date()).toUsageSnapshot() + } } From cd2c2c41148139200cd32cf73ef5273b9e12b18b Mon Sep 17 00:00:00 2001 From: thiagocaltoe Date: Wed, 13 May 2026 00:13:07 -0300 Subject: [PATCH 02/27] Add provider changelog links --- Sources/CodexBar/MenuContent.swift | 3 + Sources/CodexBar/MenuDescriptor.swift | 6 ++ .../StatusItemController+Actions.swift | 10 ++++ ...atusItemController+MenuActionMapping.swift | 1 + .../Claude/ClaudeProviderDescriptor.swift | 1 + .../Codex/CodexProviderDescriptor.swift | 1 + .../Gemini/GeminiProviderDescriptor.swift | 1 + .../CodexBarCore/Providers/Providers.swift | 4 ++ .../ProviderChangelogLinkTests.swift | 60 +++++++++++++++++++ 9 files changed, 87 insertions(+) create mode 100644 Tests/CodexBarTests/ProviderChangelogLinkTests.swift diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index ff326288fe..b9204b1fb1 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -118,6 +118,8 @@ struct MenuContent: View { self.actions.openDashboard() case .statusPage: self.actions.openStatusPage() + case .changelog: + self.actions.openChangelog() case .addCodexAccount: self.actions.addCodexAccount() case .requestCodexSystemPromotion: @@ -150,6 +152,7 @@ struct MenuActions { let refreshAugmentSession: () -> Void let openDashboard: () -> Void let openStatusPage: () -> Void + let openChangelog: () -> Void let addCodexAccount: () -> Void let switchAccount: (UsageProvider) -> Void let openTerminal: (String) -> Void diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 475e39b644..70a88b73fe 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -32,6 +32,7 @@ struct MenuDescriptor { case refresh = "arrow.clockwise" case dashboard = "chart.bar" case statusPage = "waveform.path.ecg" + case changelog = "list.bullet.rectangle" case addAccount = "plus" case systemAccount = "person.crop.circle" case switchAccount = "key" @@ -55,6 +56,7 @@ struct MenuDescriptor { case refreshAugmentSession case dashboard case statusPage + case changelog case addCodexAccount case requestCodexSystemPromotion(UUID) case addProviderAccount(UsageProvider) @@ -422,6 +424,9 @@ struct MenuDescriptor { if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { entries.append(.action("Status Page", .statusPage)) } + if metadata?.changelogURL != nil { + entries.append(.action("Changelog", .changelog)) + } if let statusLine = self.statusLine(for: provider, store: store) { entries.append(.text(statusLine, .secondary)) @@ -550,6 +555,7 @@ extension MenuDescriptor.MenuAction { case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue case .statusPage: MenuDescriptor.MenuActionSystemImage.statusPage.rawValue + case .changelog: MenuDescriptor.MenuActionSystemImage.changelog.rawValue case .addCodexAccount, .addProviderAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue case .requestCodexSystemPromotion: nil diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 76b69991f6..18136b89da 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -119,6 +119,16 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { NSWorkspace.shared.open(url) } + @objc func openChangelog() { + let preferred = self.lastMenuProvider + ?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first) + + let provider = preferred ?? .codex + let meta = self.store.metadata(for: provider) + guard let urlString = meta.changelogURL, let url = URL(string: urlString) else { return } + NSWorkspace.shared.open(url) + } + @objc func openTerminalCommand(_ sender: NSMenuItem) { let command = sender.representedObject as? String ?? "claude" Self.openTerminal(command: command) diff --git a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift index eb68b018ee..c4becbc97d 100644 --- a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift +++ b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift @@ -8,6 +8,7 @@ extension StatusItemController { case .refreshAugmentSession: (#selector(self.refreshAugmentSession), nil) case .dashboard: (#selector(self.openDashboard), nil) case .statusPage: (#selector(self.openStatusPage), nil) + case .changelog: (#selector(self.openChangelog), nil) case .addCodexAccount: (#selector(self.addManagedCodexAccountFromMenu(_:)), nil) case let .addProviderAccount(provider): (#selector(self.runSwitchAccount(_:)), provider.rawValue) case let .requestCodexSystemPromotion(managedAccountID): diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 6f45cb981a..fee83a4648 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -24,6 +24,7 @@ public enum ClaudeProviderDescriptor { browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, dashboardURL: "https://console.anthropic.com/settings/billing", subscriptionDashboardURL: "https://claude.ai/settings/usage", + changelogURL: "https://github.com/anthropics/claude-code/releases", statusPageURL: "https://status.claude.com/"), branding: ProviderBranding( iconStyle: .claude, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 13c770183a..cb39b9c958 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -24,6 +24,7 @@ public enum CodexProviderDescriptor { browserCookieOrder: ProviderBrowserCookieDefaults.codexCookieImportOrder ?? ProviderBrowserCookieDefaults.defaultImportOrder, dashboardURL: "https://chatgpt.com/codex/settings/usage", + changelogURL: "https://github.com/openai/codex/releases", statusPageURL: "https://status.openai.com/"), branding: ProviderBranding( iconStyle: .codex, diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift index 1311efc098..ff5d0ca225 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift @@ -22,6 +22,7 @@ public enum GeminiProviderDescriptor { isPrimaryProvider: false, usesAccountFallback: false, dashboardURL: "https://gemini.google.com", + changelogURL: "https://github.com/google-gemini/gemini-cli/releases", statusPageURL: nil, statusLinkURL: "https://www.google.com/appsstatus/dashboard/products/npdyhgECDJ6tB66MxXyo/history", statusWorkspaceProductID: "npdyhgECDJ6tB66MxXyo"), diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 0d2482a16e..994ba03699 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -106,6 +106,8 @@ public struct ProviderMetadata: Sendable { public let browserCookieOrder: BrowserCookieImportOrder? public let dashboardURL: String? public let subscriptionDashboardURL: String? + /// Provider-specific release notes or changelog URL for CLI/provider updates. + public let changelogURL: String? /// Statuspage.io base URL for incident polling (append /api/v2/status.json). public let statusPageURL: String? /// Browser-only status link (no API polling); used when statusPageURL is nil. @@ -130,6 +132,7 @@ public struct ProviderMetadata: Sendable { browserCookieOrder: BrowserCookieImportOrder? = nil, dashboardURL: String?, subscriptionDashboardURL: String? = nil, + changelogURL: String? = nil, statusPageURL: String?, statusLinkURL: String? = nil, statusWorkspaceProductID: String? = nil) @@ -150,6 +153,7 @@ public struct ProviderMetadata: Sendable { self.browserCookieOrder = browserCookieOrder self.dashboardURL = dashboardURL self.subscriptionDashboardURL = subscriptionDashboardURL + self.changelogURL = changelogURL self.statusPageURL = statusPageURL self.statusLinkURL = statusLinkURL self.statusWorkspaceProductID = statusWorkspaceProductID diff --git a/Tests/CodexBarTests/ProviderChangelogLinkTests.swift b/Tests/CodexBarTests/ProviderChangelogLinkTests.swift new file mode 100644 index 0000000000..a996e0e0dd --- /dev/null +++ b/Tests/CodexBarTests/ProviderChangelogLinkTests.swift @@ -0,0 +1,60 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct ProviderChangelogLinkTests { + @Test + func `known CLI providers declare changelog URLs`() throws { + let metadata = ProviderDefaults.metadata + + #expect(metadata[.codex]?.changelogURL == "https://github.com/openai/codex/releases") + #expect(metadata[.claude]?.changelogURL == "https://github.com/anthropics/claude-code/releases") + #expect(metadata[.gemini]?.changelogURL == "https://github.com/google-gemini/gemini-cli/releases") + } + + @Test + func `provider menu shows changelog action only when URL is known`() throws { + let codexDescriptor = self.makeDescriptor(provider: .codex, suite: "ProviderChangelogLinkTests-codex") + #expect(self.actionTitles(from: codexDescriptor).contains("Changelog")) + + let openRouterDescriptor = self.makeDescriptor(provider: .openrouter, suite: "ProviderChangelogLinkTests-openrouter") + #expect(!self.actionTitles(from: openRouterDescriptor).contains("Changelog")) + } + + private func makeDescriptor(provider: UsageProvider, suite: String) -> MenuDescriptor { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let fetcher = UsageFetcher(environment: [:]) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + + return MenuDescriptor.build( + provider: provider, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false, + includeContextualActions: false) + } + + private func actionTitles(from descriptor: MenuDescriptor) -> [String] { + descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .action(title, _) = entry else { return nil } + return title + } + } +} From 0919805de8d730bbb6c1313dcfbd0e6f21106244 Mon Sep 17 00:00:00 2001 From: thiagocaltoe Date: Wed, 13 May 2026 00:17:47 -0300 Subject: [PATCH 03/27] Fix changelog menu test --- Tests/CodexBarTests/ProviderChangelogLinkTests.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Tests/CodexBarTests/ProviderChangelogLinkTests.swift b/Tests/CodexBarTests/ProviderChangelogLinkTests.swift index a996e0e0dd..79d1310e25 100644 --- a/Tests/CodexBarTests/ProviderChangelogLinkTests.swift +++ b/Tests/CodexBarTests/ProviderChangelogLinkTests.swift @@ -6,7 +6,7 @@ import Testing @MainActor struct ProviderChangelogLinkTests { @Test - func `known CLI providers declare changelog URLs`() throws { + func `known CLI providers declare changelog URLs`() { let metadata = ProviderDefaults.metadata #expect(metadata[.codex]?.changelogURL == "https://github.com/openai/codex/releases") @@ -15,11 +15,13 @@ struct ProviderChangelogLinkTests { } @Test - func `provider menu shows changelog action only when URL is known`() throws { + func `provider menu shows changelog action only when URL is known`() { let codexDescriptor = self.makeDescriptor(provider: .codex, suite: "ProviderChangelogLinkTests-codex") #expect(self.actionTitles(from: codexDescriptor).contains("Changelog")) - let openRouterDescriptor = self.makeDescriptor(provider: .openrouter, suite: "ProviderChangelogLinkTests-openrouter") + let openRouterDescriptor = self.makeDescriptor( + provider: .openrouter, + suite: "ProviderChangelogLinkTests-openrouter") #expect(!self.actionTitles(from: openRouterDescriptor).contains("Changelog")) } @@ -46,7 +48,7 @@ struct ProviderChangelogLinkTests { settings: settings, account: fetcher.loadAccountInfo(), updateReady: false, - includeContextualActions: false) + includeContextualActions: true) } private func actionTitles(from descriptor: MenuDescriptor) -> [String] { From 81a420a6af2d884d0b4f199f8855c95feb641fab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 13 May 2026 08:24:25 +0100 Subject: [PATCH 04/27] feat: gate provider changelog links behind setting --- CHANGELOG.md | 1 + Sources/CodexBar/MenuDescriptor.swift | 2 +- Sources/CodexBar/PreferencesDisplayPane.swift | 4 +++ .../Resources/en.lproj/Localizable.strings | 2 ++ .../Resources/pt-BR.lproj/Localizable.strings | 2 ++ .../zh-Hans.lproj/Localizable.strings | 2 ++ Sources/CodexBar/SettingsStore+Defaults.swift | 8 ++++++ .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/SettingsStore.swift | 3 +++ Sources/CodexBar/SettingsStoreState.swift | 1 + .../ProviderChangelogLinkTests.swift | 25 +++++++++++++++--- Tests/CodexBarTests/SettingsStoreTests.swift | 26 +++++++++++++++++++ 12 files changed, 72 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 139678d661..84f3dc10c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Display: add a setting to hide quota-warning tick marks on usage bars while keeping quota warning notifications active (#918, fixes #916). Thanks @ThiagoCAltoe! +- Menu: add an opt-in setting for provider changelog links, starting with Codex, Claude Code, and Gemini CLI (#929, fixes #660). Thanks @ThiagoCAltoe! - Moonshot / Kimi API: add API-key balance tracking, CLI support, docs, and menu bar balance copy (#899). Thanks @giuseppebisemi! ### Fixed diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 70a88b73fe..368b30cbde 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -424,7 +424,7 @@ struct MenuDescriptor { if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { entries.append(.action("Status Page", .statusPage)) } - if metadata?.changelogURL != nil { + if store.settings.providerChangelogLinksEnabled, metadata?.changelogURL != nil { entries.append(.action("Changelog", .changelog)) } diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 9c3aea051c..4628a649a1 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -82,6 +82,10 @@ struct DisplayPane: View { title: L("show_reset_time_as_clock_title"), subtitle: L("show_reset_time_as_clock_subtitle"), binding: self.$settings.resetTimesShowAbsolute) + PreferenceToggleRow( + title: L("show_provider_changelog_links_title"), + subtitle: L("show_provider_changelog_links_subtitle"), + binding: self.$settings.providerChangelogLinksEnabled) PreferenceToggleRow( title: L("show_credits_extra_usage_title"), subtitle: L("show_credits_extra_usage_subtitle"), diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 635c04617e..50a54388bf 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -486,6 +486,8 @@ "show_quota_warning_markers_subtitle" = "Draw threshold tick marks on usage bars when quota warnings are configured."; "show_reset_time_as_clock_title" = "Show reset time as clock"; "show_reset_time_as_clock_subtitle" = "Display reset times as absolute clock values instead of countdowns."; +"show_provider_changelog_links_title" = "Show provider changelog links"; +"show_provider_changelog_links_subtitle" = "Adds release-notes links for supported CLI-backed providers to the menu."; "show_credits_extra_usage_title" = "Show credits + extra usage"; "show_credits_extra_usage_subtitle" = "Show Codex Credits and Claude Extra usage sections in the menu."; "show_all_token_accounts_title" = "Show all token accounts"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index c9f0101650..06d52976ed 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -466,6 +466,8 @@ "show_quota_warning_markers_subtitle" = "Desenha marcas de limite nas barras de uso quando os alertas de cota estão configurados."; "show_reset_time_as_clock_title" = "Mostrar renovação como horário"; "show_reset_time_as_clock_subtitle" = "Mostra horários de renovação como horas absolutas, em vez de contagens regressivas."; +"show_provider_changelog_links_title" = "Mostrar links de changelog dos provedores"; +"show_provider_changelog_links_subtitle" = "Adiciona links de notas de versão para provedores baseados em CLI compatíveis no menu."; "show_credits_extra_usage_title" = "Mostrar créditos + uso extra"; "show_credits_extra_usage_subtitle" = "Mostra as seções de créditos do Codex e uso extra do Claude no menu."; "show_all_token_accounts_title" = "Mostrar todas as contas de token"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index af37234f5e..329892d4d9 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -493,6 +493,8 @@ "show_quota_warning_markers_subtitle" = "配置配额警告后,在用量条上绘制阈值刻度标记。"; "show_reset_time_as_clock_title" = "将重置时间显示为时钟"; "show_reset_time_as_clock_subtitle" = "将重置时间显示为绝对时钟值而非倒计时。"; +"show_provider_changelog_links_title" = "显示提供商变更日志链接"; +"show_provider_changelog_links_subtitle" = "在菜单中为支持的 CLI 提供商添加发布说明链接。"; "show_credits_extra_usage_title" = "显示积分 + 额外用量"; "show_credits_extra_usage_subtitle" = "在菜单中显示 Codex 积分和 Claude 额外用量部分。"; "show_all_token_accounts_title" = "显示所有令牌账户"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 0ecabf6a44..bf5bb75f63 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -172,6 +172,14 @@ extension SettingsStore { } } + var providerChangelogLinksEnabled: Bool { + get { self.defaultsState.providerChangelogLinksEnabled } + set { + self.defaultsState.providerChangelogLinksEnabled = newValue + self.userDefaults.set(newValue, forKey: "providerChangelogLinksEnabled") + } + } + var menuBarShowsBrandIconWithPercent: Bool { get { self.defaultsState.menuBarShowsBrandIconWithPercent } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 5e21f16452..e29f4e1d2b 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -19,6 +19,7 @@ extension SettingsStore { _ = self.quotaWarningMarkersVisible _ = self.usageBarsShowUsed _ = self.resetTimesShowAbsolute + _ = self.providerChangelogLinksEnabled _ = self.menuBarShowsBrandIconWithPercent _ = self.menuBarShowsHighestUsage _ = self.menuBarDisplayMode diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index d6fddc1485..046549ca3e 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -295,6 +295,8 @@ extension SettingsStore { } let usageBarsShowUsed = userDefaults.object(forKey: "usageBarsShowUsed") as? Bool ?? false let resetTimesShowAbsolute = userDefaults.object(forKey: "resetTimesShowAbsolute") as? Bool ?? false + let providerChangelogLinksEnabled = userDefaults.object( + forKey: "providerChangelogLinksEnabled") as? Bool ?? false let menuBarShowsBrandIconWithPercent = userDefaults.object( forKey: "menuBarShowsBrandIconWithPercent") as? Bool ?? false let menuBarDisplayModeRaw = userDefaults.string(forKey: "menuBarDisplayMode") @@ -373,6 +375,7 @@ extension SettingsStore { quotaWarningMarkersVisible: quotaWarningMarkersVisible, usageBarsShowUsed: usageBarsShowUsed, resetTimesShowAbsolute: resetTimesShowAbsolute, + providerChangelogLinksEnabled: providerChangelogLinksEnabled, menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent, menuBarDisplayModeRaw: menuBarDisplayModeRaw, historicalTrackingEnabled: historicalTrackingEnabled, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 30224e5e71..653230cfea 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -19,6 +19,7 @@ struct SettingsDefaultsState { var quotaWarningMarkersVisible: Bool var usageBarsShowUsed: Bool var resetTimesShowAbsolute: Bool + var providerChangelogLinksEnabled: Bool var menuBarShowsBrandIconWithPercent: Bool var menuBarDisplayModeRaw: String? var historicalTrackingEnabled: Bool diff --git a/Tests/CodexBarTests/ProviderChangelogLinkTests.swift b/Tests/CodexBarTests/ProviderChangelogLinkTests.swift index 79d1310e25..a1923a4abf 100644 --- a/Tests/CodexBarTests/ProviderChangelogLinkTests.swift +++ b/Tests/CodexBarTests/ProviderChangelogLinkTests.swift @@ -15,17 +15,33 @@ struct ProviderChangelogLinkTests { } @Test - func `provider menu shows changelog action only when URL is known`() { - let codexDescriptor = self.makeDescriptor(provider: .codex, suite: "ProviderChangelogLinkTests-codex") + func `provider menu hides changelog action until enabled`() { + let codexDescriptor = self.makeDescriptor( + provider: .codex, + suite: "ProviderChangelogLinkTests-codex-default") + #expect(!self.actionTitles(from: codexDescriptor).contains("Changelog")) + } + + @Test + func `provider menu shows changelog action only when setting and URL are present`() { + let codexDescriptor = self.makeDescriptor( + provider: .codex, + suite: "ProviderChangelogLinkTests-codex", + changelogLinksEnabled: true) #expect(self.actionTitles(from: codexDescriptor).contains("Changelog")) let openRouterDescriptor = self.makeDescriptor( provider: .openrouter, - suite: "ProviderChangelogLinkTests-openrouter") + suite: "ProviderChangelogLinkTests-openrouter", + changelogLinksEnabled: true) #expect(!self.actionTitles(from: openRouterDescriptor).contains("Changelog")) } - private func makeDescriptor(provider: UsageProvider, suite: String) -> MenuDescriptor { + private func makeDescriptor( + provider: UsageProvider, + suite: String, + changelogLinksEnabled: Bool = false) -> MenuDescriptor + { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) let settings = SettingsStore( @@ -34,6 +50,7 @@ struct ProviderChangelogLinkTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) settings.statusChecksEnabled = false + settings.providerChangelogLinksEnabled = changelogLinksEnabled let fetcher = UsageFetcher(environment: [:]) let store = UsageStore( diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 0f8408f22c..ad8f5f27b4 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -137,6 +137,32 @@ struct SettingsStoreTests { #expect(storeB.providerStorageFootprintsEnabled == true) } + @Test + func `provider changelog links setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-provider-changelog-links" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.providerChangelogLinksEnabled == false) + #expect(defaultsA.bool(forKey: "providerChangelogLinksEnabled") == false) + storeA.providerChangelogLinksEnabled = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.providerChangelogLinksEnabled == true) + } + @Test func `persists selected menu provider across instances`() throws { let suite = "SettingsStoreTests-selectedMenuProvider" From f7c83fe2c157b0b38c5cb8fad5540309a05c9a44 Mon Sep 17 00:00:00 2001 From: solnikhil Date: Wed, 13 May 2026 13:11:29 +0530 Subject: [PATCH 05/27] Address Kiro review feedback --- Sources/CodexBar/MenuCardView+Kiro.swift | 8 -------- Sources/CodexBar/MenuCardView.swift | 8 ++++---- .../CodexBar/StatusItemController+Animation.swift | 15 +++++---------- .../Providers/Kiro/KiroStatusProbe.swift | 6 ++++++ Sources/CodexBarCore/UsageFormatter.swift | 8 ++++++++ Tests/CodexBarTests/KiroStatusProbeTests.swift | 13 +++++++++++++ 6 files changed, 36 insertions(+), 22 deletions(-) diff --git a/Sources/CodexBar/MenuCardView+Kiro.swift b/Sources/CodexBar/MenuCardView+Kiro.swift index 7e800ae5b0..83f96cfa62 100644 --- a/Sources/CodexBar/MenuCardView+Kiro.swift +++ b/Sources/CodexBar/MenuCardView+Kiro.swift @@ -25,12 +25,4 @@ extension UsageMenuCardView.Model { else { return nil } return plan } - - static func kiroCreditNumber(_ value: Double) -> String { - let rounded = value.rounded() - if abs(value - rounded) < 0.005 { - return String(format: "%.0f", rounded) - } - return String(format: "%.2f", value) - } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 2320266805..b4152b68da 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1153,8 +1153,8 @@ extension UsageMenuCardView.Model { let kiroUsage = input.snapshot?.kiroUsage, kiroUsage.creditsTotal > 0 { - let remaining = Self.kiroCreditNumber(kiroUsage.creditsRemaining) - let total = Self.kiroCreditNumber(kiroUsage.creditsTotal) + let remaining = UsageFormatter.kiroCreditNumber(kiroUsage.creditsRemaining) + let total = UsageFormatter.kiroCreditNumber(kiroUsage.creditsTotal) primaryDetailText = "\(remaining) of \(total) credits left" } if input.provider == .alibaba || input.provider == .mistral || input.provider == .manus, @@ -1273,8 +1273,8 @@ extension UsageMenuCardView.Model { let remaining = kiroUsage.bonusCreditsRemaining, let total = kiroUsage.bonusCreditsTotal { - let remainingText = Self.kiroCreditNumber(remaining) - let totalText = Self.kiroCreditNumber(total) + let remainingText = UsageFormatter.kiroCreditNumber(remaining) + let totalText = UsageFormatter.kiroCreditNumber(total) weeklyDetailText = "\(remainingText) of \(totalText) bonus credits left" } if input.provider == .alibaba, diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 9ea1aee51e..4a94c8dd40 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -678,8 +678,11 @@ extension StatusItemController { let percentText = MenuBarDisplayText.percentText( window: snapshot?.primary, showUsed: showUsed) - let creditsLeft = Self.kiroCreditNumber(usage.creditsRemaining) - let usedTotal = "\(Self.kiroCreditNumber(usage.creditsUsed)) / \(Self.kiroCreditNumber(usage.creditsTotal))" + let creditsLeft = UsageFormatter.kiroCreditNumber(usage.creditsRemaining) + let usedTotal = [ + UsageFormatter.kiroCreditNumber(usage.creditsUsed), + UsageFormatter.kiroCreditNumber(usage.creditsTotal), + ].joined(separator: " / ") switch mode { case .automatic, .creditsLeft: @@ -701,14 +704,6 @@ extension StatusItemController { } } - nonisolated static func kiroCreditNumber(_ value: Double) -> String { - let rounded = value.rounded() - if abs(value - rounded) < 0.005 { - return String(format: "%.0f", rounded) - } - return String(format: "%.2f", value) - } - private nonisolated static func displayValue( from text: String?, prefix: String, diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index 239aa35dac..3a44c829ec 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -299,6 +299,7 @@ public struct KiroStatusProbe: Sendable { var email: String? for rawLine in stripped.components(separatedBy: .newlines) { let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { continue } if line.localizedCaseInsensitiveContains("logged in with") { authMethod = line.replacingOccurrences( of: #"(?i)^\s*logged in with\s+"#, @@ -311,6 +312,11 @@ public struct KiroStatusProbe: Sendable { with: "", options: [.regularExpression]) .trimmingCharacters(in: .whitespacesAndNewlines) + } else if email == nil, + !line.contains(" "), + line.contains("@") + { + email = line } } return KiroAccountInfo( diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 4b0ba1a8ad..12756c9ea9 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -103,6 +103,14 @@ public enum UsageFormatter { return "\(formatted) left" } + public static func kiroCreditNumber(_ value: Double) -> String { + let rounded = value.rounded() + if abs(value - rounded) < 0.005 { + return String(format: "%.0f", rounded) + } + return String(format: "%.2f", value) + } + /// Formats a USD value with proper negative handling and thousand separators. /// Uses Swift's modern FormatStyle API (iOS 15+/macOS 12+) for robust, locale-aware formatting. public static func usdString(_ value: Double) -> String { diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index 2fe8601ef2..728b318c85 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -438,4 +438,17 @@ struct KiroStatusProbeTests { #expect(account.authMethod == "Google") #expect(account.email == "user@example.com") } + + @Test + func `whoami legacy bare email parses account`() throws { + let probe = KiroStatusProbe() + + let account = try probe.validateWhoAmIOutput( + stdout: "user@example.com", + stderr: "", + terminationStatus: 0) + + #expect(account.authMethod == nil) + #expect(account.email == "user@example.com") + } } From 766cb36e560d3c110d787eadd328e8e6c6582e19 Mon Sep 17 00:00:00 2001 From: solnikhil Date: Wed, 13 May 2026 13:14:11 +0530 Subject: [PATCH 06/27] Polish Kiro credit detail styling --- Sources/CodexBar/MenuCardView.swift | 8 ++++++-- Tests/CodexBarTests/MenuCardModelTests.swift | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index b4152b68da..0059c65061 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1155,7 +1155,7 @@ extension UsageMenuCardView.Model { { let remaining = UsageFormatter.kiroCreditNumber(kiroUsage.creditsRemaining) let total = UsageFormatter.kiroCreditNumber(kiroUsage.creditsTotal) - primaryDetailText = "\(remaining) of \(total) credits left" + primaryDetailLeft = "\(remaining) of \(total) credits left" } if input.provider == .alibaba || input.provider == .mistral || input.provider == .manus, let detail = primary.resetDescription, @@ -1275,7 +1275,11 @@ extension UsageMenuCardView.Model { { let remainingText = UsageFormatter.kiroCreditNumber(remaining) let totalText = UsageFormatter.kiroCreditNumber(total) - weeklyDetailText = "\(remainingText) of \(totalText) bonus credits left" + paceDetail = PaceDetail( + leftLabel: "\(remainingText) of \(totalText) bonus credits left", + rightLabel: nil, + pacePercent: nil, + paceOnTop: true) } if input.provider == .alibaba, let detail = weekly.resetDescription, diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 236ae5c4f6..e1e6353f5f 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -185,8 +185,8 @@ struct KiroMenuCardModelTests { #expect(model.email == "person@example.com") #expect(model.planText == "Kiro Free") #expect(model.metrics.map(\.title) == ["Credits", "Bonus"]) - #expect(model.metrics.first?.detailText == "49.83 of 50 credits left") - #expect(model.metrics.dropFirst().first?.detailText == "1954.47 of 2000 bonus credits left") + #expect(model.metrics.first?.detailLeftText == "49.83 of 50 credits left") + #expect(model.metrics.dropFirst().first?.detailLeftText == "1954.47 of 2000 bonus credits left") #expect(model.usageNotes.contains("Auth: Google")) #expect(model.usageNotes.contains("Overages: Disabled")) #expect(model.usageNotes.contains { $0.localizedCaseInsensitiveContains("Context window") } == false) From cd2abfd1028d74d80b669ff17c89b14ec384073e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 13 May 2026 15:08:20 +0100 Subject: [PATCH 07/27] fix: repair stale managed Codex account removal --- CHANGELOG.md | 1 + .../CodexBar/ManagedCodexAccountService.swift | 4 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 28 +++++++-- .../ManagedCodexAccountServiceTests.swift | 8 +-- ...kenAccountEnvironmentPrecedenceTests.swift | 57 ++++++++----------- 5 files changed, 55 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f3dc10c6..10a9d2f95a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Moonshot / Kimi API: add API-key balance tracking, CLI support, docs, and menu bar balance copy (#899). Thanks @giuseppebisemi! ### Fixed +- Settings: let stale managed Codex account records be removed even when their stored home path is outside CodexBar's managed-home directory, and keep CLI known-owner tests from writing fixtures into the live app store. - Settings: apply the selected app language from packaged SwiftPM resources instead of falling back to English when the `.lproj` directory casing differs (#908). - Codex: route menu account refreshes through the resolved live-vs-managed account source so matched accounts keep using the stable `CODEX_HOME` (#932, fixes #931). Thanks @ThiagoCAltoe! - ChatGPT credits: restrict purchase links to real HTTPS `chatgpt.com` settings/usage/billing/credits paths and drop query/fragment data (#903). Thanks @ThiagoCAltoe! diff --git a/Sources/CodexBar/ManagedCodexAccountService.swift b/Sources/CodexBar/ManagedCodexAccountService.swift index a3cdd9aea3..6af27abbe8 100644 --- a/Sources/CodexBar/ManagedCodexAccountService.swift +++ b/Sources/CodexBar/ManagedCodexAccountService.swift @@ -310,14 +310,14 @@ final class ManagedCodexAccountService { guard let account = snapshot.account(id: id) else { return } let homeURL = URL(fileURLWithPath: account.managedHomePath, isDirectory: true) - try self.homeFactory.validateManagedHomeForDeletion(homeURL) + let canDeleteHome = (try? self.homeFactory.validateManagedHomeForDeletion(homeURL)) != nil let remaining = snapshot.accounts.filter { $0.id != id } try self.store.storeAccounts(ManagedCodexAccountSet( version: snapshot.version, accounts: remaining)) - if self.fileManager.fileExists(atPath: homeURL.path) { + if canDeleteHome, self.fileManager.fileExists(atPath: homeURL.path) { try? self.fileManager.removeItem(at: homeURL) } } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index ae4800ac2a..89b1dadc99 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -33,10 +33,20 @@ struct TokenAccountCLIContext { let selection: TokenAccountCLISelection let config: CodexBarConfig let accountsByProvider: [UsageProvider: ProviderTokenAccountData] - - init(selection: TokenAccountCLISelection, config: CodexBarConfig, verbose _: Bool) throws { + private let baseEnvironment: [String: String] + private let managedCodexAccountStoreURL: URL? + + init( + selection: TokenAccountCLISelection, + config: CodexBarConfig, + verbose _: Bool, + baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, + managedCodexAccountStoreURL: URL? = nil) throws + { self.selection = selection self.config = config + self.baseEnvironment = baseEnvironment + self.managedCodexAccountStoreURL = managedCodexAccountStoreURL self.accountsByProvider = Dictionary(uniqueKeysWithValues: config.providers.compactMap { provider in guard let accounts = provider.tokenAccounts else { return nil } return (provider.id, accounts) @@ -346,9 +356,19 @@ struct TokenAccountCLIContext { } private func codexAccountReconciler() -> DefaultCodexAccountReconciler { - DefaultCodexAccountReconciler( + let storeLoader: @Sendable () throws -> ManagedCodexAccountSet = if let managedCodexAccountStoreURL { + { + try FileManagedCodexAccountStore(fileURL: managedCodexAccountStoreURL).loadAccounts() + } + } else { + { + try FileManagedCodexAccountStore().loadAccounts() + } + } + return DefaultCodexAccountReconciler( + storeLoader: storeLoader, activeSource: self.providerConfig(for: .codex)?.codexActiveSource ?? .liveSystem, - baseEnvironment: ProcessInfo.processInfo.environment, + baseEnvironment: self.baseEnvironment, managedEnvironmentBuilder: { environment, account in CodexHomeScope.scopedEnvironment(base: environment, codexHome: account.managedHomePath) }) diff --git a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift index 834c76693d..58c27b2f6a 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift @@ -718,7 +718,7 @@ struct ManagedCodexAccountServiceTests { } @Test - func `remove fails closed for home outside managed root`() async throws { + func `remove drops account but leaves unsafe home untouched`() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) let outsideRoot = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, @@ -746,11 +746,9 @@ struct ManagedCodexAccountServiceTests { identityReader: StubManagedCodexIdentityReader.emails([]), workspaceResolver: StubManagedCodexWorkspaceResolver()) - await #expect(throws: ManagedCodexAccountServiceError.unsafeManagedHome(account.managedHomePath)) { - try await service.removeManagedAccount(id: account.id) - } + try await service.removeManagedAccount(id: account.id) - #expect(store.snapshot.accounts.count == 1) + #expect(store.snapshot.accounts.isEmpty) #expect(FileManager.default.fileExists(atPath: outsideRoot.path)) } } diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 9a2162e20d..bd89634722 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -397,8 +397,10 @@ struct TokenAccountEnvironmentPrecedenceTests { try Self.withCLIKnownOwnerFixtures( ambientHome: ambientHome, managedAccounts: []) - { - let rawCLIOwners = try Self.codexCLIKnownOwners() + { managedStoreURL in + let rawCLIOwners = try Self.codexCLIKnownOwners( + ambientHome: ambientHome, + managedStoreURL: managedStoreURL) let cliOwners = try #require(rawCLIOwners) let appOwners = appStore.codexDashboardKnownOwnerCandidates() @@ -444,8 +446,10 @@ struct TokenAccountEnvironmentPrecedenceTests { try Self.withCLIKnownOwnerFixtures( ambientHome: ambientHome, managedAccounts: [managedAccount]) - { - let rawCLIOwners = try Self.codexCLIKnownOwners() + { managedStoreURL in + let rawCLIOwners = try Self.codexCLIKnownOwners( + ambientHome: ambientHome, + managedStoreURL: managedStoreURL) let cliOwners = try #require(rawCLIOwners) let appOwners = appStore.codexDashboardKnownOwnerCandidates() @@ -492,8 +496,10 @@ struct TokenAccountEnvironmentPrecedenceTests { try Self.withCLIKnownOwnerFixtures( ambientHome: ambientHome, managedAccounts: [managedAccount]) - { - let rawCLIOwners = try Self.codexCLIKnownOwners() + { managedStoreURL in + let rawCLIOwners = try Self.codexCLIKnownOwners( + ambientHome: ambientHome, + managedStoreURL: managedStoreURL) let cliOwners = try #require(rawCLIOwners) let appOwners = appStore.codexDashboardKnownOwnerCandidates() @@ -533,11 +539,16 @@ struct TokenAccountEnvironmentPrecedenceTests { settings: settings) } - private static func codexCLIKnownOwners() throws -> [CodexDashboardKnownOwnerCandidate]? { + private static func codexCLIKnownOwners( + ambientHome: URL, + managedStoreURL: URL) throws -> [CodexDashboardKnownOwnerCandidate]? + { let context = try TokenAccountCLIContext( selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), config: CodexBarConfig(providers: [ProviderConfig(id: .codex)]), - verbose: false) + verbose: false, + baseEnvironment: ["CODEX_HOME": ambientHome.path], + managedCodexAccountStoreURL: managedStoreURL) return context.settingsSnapshot(for: .codex, account: nil)?.codex?.dashboardAuthorityKnownOwners } @@ -587,38 +598,20 @@ struct TokenAccountEnvironmentPrecedenceTests { private static func withCLIKnownOwnerFixtures( ambientHome: URL, managedAccounts: [ManagedCodexAccount], - operation: () throws -> T) throws -> T + operation: (URL) throws -> T) throws -> T { - let managedStoreURL = FileManagedCodexAccountStore.defaultURL() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-known-owner-store-\(UUID().uuidString)", isDirectory: true) + let managedStoreURL = root.appendingPathComponent("managed-codex-accounts.json", isDirectory: false) let fileManager = FileManager.default - let originalManagedStoreData = try? Data(contentsOf: managedStoreURL) - let hadOriginalManagedStore = fileManager.fileExists(atPath: managedStoreURL.path) - let originalCodexHome = getenv("CODEX_HOME").map { String(cString: $0) } + defer { try? fileManager.removeItem(at: root) } let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL) try managedStore.storeAccounts(ManagedCodexAccountSet( version: FileManagedCodexAccountStore.currentVersion, accounts: managedAccounts)) - setenv("CODEX_HOME", ambientHome.path, 1) - - defer { - if let originalCodexHome { - setenv("CODEX_HOME", originalCodexHome, 1) - } else { - unsetenv("CODEX_HOME") - } - - if hadOriginalManagedStore, let originalManagedStoreData { - try? fileManager.createDirectory( - at: managedStoreURL.deletingLastPathComponent(), - withIntermediateDirectories: true) - try? originalManagedStoreData.write(to: managedStoreURL, options: [.atomic]) - } else { - try? fileManager.removeItem(at: managedStoreURL) - } - } - return try operation() + return try operation(managedStoreURL) } private static func makeSnapshotWithAllFields(provider: UsageProvider) -> UsageSnapshot { From 34d9b187a5084693c302295f6c8bfd2d7f6e577d Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Thu, 14 May 2026 00:47:40 -0500 Subject: [PATCH 08/27] fix: handle Claude enterprise web accounts (#941) Fixes Claude Enterprise/organization web accounts by tolerating null session-window usage and exposing an optional organization ID field for Claude token accounts. Local verification: - swift test --filter 'ClaudeWebEnterpriseUsageTests|ProvidersPaneCoverageTests|ClaudeUsageTests' - make check Co-authored-by: Claude --- .../PreferencesProviderSettingsRows.swift | 49 ++++++++++++------- .../PreferencesProvidersPane+Testing.swift | 3 +- .../CodexBar/PreferencesProvidersPane.swift | 9 +++- .../Shared/ProviderSettingsDescriptors.swift | 3 +- .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 8 ++- .../ClaudeWebEnterpriseUsageTests.swift | 19 +++++++ .../ProvidersPaneCoverageTests.swift | 13 +++++ 7 files changed, 77 insertions(+), 27 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeWebEnterpriseUsageTests.swift diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index e063773dfb..1623384540 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -246,6 +246,7 @@ struct ProviderSettingsTokenAccountsRowView: View { let descriptor: ProviderSettingsTokenAccountsDescriptor @State private var newLabel: String = "" @State private var newToken: String = "" + @State private var newOrgID: String = "" var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -317,25 +318,37 @@ struct ProviderSettingsTokenAccountsRowView: View { } if self.descriptor.primaryAddAction == nil { - HStack(spacing: 8) { - TextField("Label", text: self.$newLabel) - .textFieldStyle(.roundedBorder) - .font(.footnote) - SecureField(self.descriptor.placeholder, text: self.$newToken) - .textFieldStyle(.roundedBorder) - .font(.footnote) - Button("Add") { - let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) - let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) - guard !label.isEmpty, !token.isEmpty else { return } - self.descriptor.addAccount(label, token) - self.newLabel = "" - self.newToken = "" + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + TextField("Label", text: self.$newLabel) + .textFieldStyle(.roundedBorder) + .font(.footnote) + SecureField(self.descriptor.placeholder, text: self.$newToken) + .textFieldStyle(.roundedBorder) + .font(.footnote) + Button("Add") { + let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) + let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !label.isEmpty, !token.isEmpty else { return } + let orgID = self.descriptor.showsOrganizationField + ? self.newOrgID.trimmingCharacters(in: .whitespacesAndNewlines) + : "" + self.descriptor.addAccount(label, token, orgID.isEmpty ? nil : orgID) + self.newLabel = "" + self.newToken = "" + self.newOrgID = "" + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + if self.descriptor.showsOrganizationField { + TextField("Org ID (optional)", text: self.$newOrgID) + .textFieldStyle(.roundedBorder) + .font(.footnote) + .help("Optional organization ID for accounts linked to multiple Anthropic organizations.") } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index cf106b036d..585690c089 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -247,7 +247,8 @@ enum ProvidersPaneTestHarness { accounts: { [] }, activeIndex: { 0 }, setActiveIndex: { _ in }, - addAccount: { _, _ in }, + showsOrganizationField: false, + addAccount: { _, _, _ in }, removeAccount: { _ in }, primaryAddActionTitle: nil, primaryAddAction: nil, diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 2908cd94f0..f4e383001f 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -384,8 +384,13 @@ struct ProvidersPane: View { } } }, - addAccount: { label, token in - self.settings.addTokenAccount(provider: provider, label: label, token: token) + showsOrganizationField: provider == .claude, + addAccount: { label, token, organizationID in + self.settings.addTokenAccount( + provider: provider, + label: label, + token: token, + organizationID: organizationID) Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refreshProvider(provider, allowDisabled: true) diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index 9b419ce431..449ff4b923 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -134,7 +134,8 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let accounts: () -> [ProviderTokenAccount] let activeIndex: () -> Int let setActiveIndex: (Int) -> Void - let addAccount: (_ label: String, _ token: String) -> Void + let showsOrganizationField: Bool + let addAccount: (_ label: String, _ token: String, _ organizationID: String?) -> Void let removeAccount: (_ accountID: UUID) -> Void let primaryAddActionTitle: String? let primaryAddAction: (() async -> Void)? diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index 41d65d8542..d6a74c7e9a 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -488,10 +488,8 @@ public enum ClaudeWebAPIFetcher { sessionResets = self.parseISO8601Date(resetsAt) } } - guard let sessionPercent else { - // If we can't parse session utilization, treat this as a failure so callers can fall back to the CLI. - throw FetchError.invalidResponse - } + // Enterprise/credit-based accounts return null for five_hour; treat as 0% rather than an error. + let resolvedSessionPercent = sessionPercent ?? 0.0 // Parse seven_day (weekly) usage var weeklyPercent: Double? @@ -521,7 +519,7 @@ public enum ClaudeWebAPIFetcher { } return WebUsageData( - sessionPercentUsed: sessionPercent, + sessionPercentUsed: resolvedSessionPercent, sessionResetsAt: sessionResets, weeklyPercentUsed: weeklyPercent, weeklyResetsAt: weeklyResets, diff --git a/Tests/CodexBarTests/ClaudeWebEnterpriseUsageTests.swift b/Tests/CodexBarTests/ClaudeWebEnterpriseUsageTests.swift new file mode 100644 index 0000000000..ee81a881b7 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebEnterpriseUsageTests.swift @@ -0,0 +1,19 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeWebEnterpriseUsageTests { + @Test + func `parses usage response when session window is null`() throws { + let json = """ + { + "five_hour": null, + "seven_day": { "utilization": 42, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + #expect(parsed.sessionPercentUsed == 0) + #expect(parsed.weeklyPercentUsed == 42) + } +} diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index d4ef4ddb1f..87874a882a 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -14,6 +14,19 @@ struct ProvidersPaneCoverageTests { ProvidersPaneTestHarness.exercise(settings: settings, store: store) } + @Test + func `claude token account descriptor shows organization field`() throws { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-claude-org-field") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let claudeDescriptor = try #require(pane._test_tokenAccountDescriptor(for: .claude)) + #expect(claudeDescriptor.showsOrganizationField) + + let copilotDescriptor = try #require(pane._test_tokenAccountDescriptor(for: .copilot)) + #expect(!copilotDescriptor.showsOrganizationField) + } + @Test func `open router menu bar metric picker shows only automatic and primary`() { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-openrouter-picker") From 100e32fc65687d4bb4155399c4c2bbe140e82c6a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 06:48:06 +0100 Subject: [PATCH 09/27] docs: update changelog for Claude enterprise fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a9d2f95a..1abefc9a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Settings: let stale managed Codex account records be removed even when their stored home path is outside CodexBar's managed-home directory, and keep CLI known-owner tests from writing fixtures into the live app store. - Settings: apply the selected app language from packaged SwiftPM resources instead of falling back to English when the `.lproj` directory casing differs (#908). +- Claude: handle Enterprise/organization web accounts whose session quota window is null and expose an optional Org ID field for Claude token accounts (#941, fixes #940). Thanks @clintandrewhall! - Codex: route menu account refreshes through the resolved live-vs-managed account source so matched accounts keep using the stable `CODEX_HOME` (#932, fixes #931). Thanks @ThiagoCAltoe! - ChatGPT credits: restrict purchase links to real HTTPS `chatgpt.com` settings/usage/billing/credits paths and drop query/fragment data (#903). Thanks @ThiagoCAltoe! - z.ai: show the MCP quota bucket as monthly instead of a misleading 1-minute window (#904). Thanks @ThiagoCAltoe! From 36db0c621d7214470d56a2e2c9c4fddc16a82d85 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 07:33:49 +0100 Subject: [PATCH 10/27] fix: handle quota and usage edge cases --- CHANGELOG.md | 4 + .../CodexBar/QuotaWarningSettingsViews.swift | 25 ++--- Sources/CodexBar/SettingsStore+Config.swift | 4 +- Sources/CodexBar/SettingsStore+Defaults.swift | 25 +++++ .../SettingsStore+MenuObservation.swift | 2 + Sources/CodexBar/SettingsStore.swift | 37 +++++-- Sources/CodexBar/SettingsStoreState.swift | 2 + .../StatusItemController+Actions.swift | 11 ++- .../CodexBar/StatusItemController+Menu.swift | 6 +- Sources/CodexBar/StatusItemController.swift | 2 +- Sources/CodexBar/UsageStore.swift | 2 + Sources/CodexBarCLI/TokenAccountCLI.swift | 26 ++++- .../Claude/ClaudeProviderDescriptor.swift | 7 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 78 ++++++++++++--- Tests/CodexBarTests/ClaudeOAuthTests.swift | 79 +++++++++++++++ Tests/CodexBarTests/SettingsStoreTests.swift | 55 +++++++++++ .../StatusMenuOpenRefreshTests.swift | 97 +++++++++++++++++++ Tests/CodexBarTests/StatusMenuTests.swift | 36 ++++--- ...kenAccountEnvironmentPrecedenceTests.swift | 60 ++++++++++++ ...sageStoreSessionQuotaTransitionTests.swift | 42 +++++++- 20 files changed, 540 insertions(+), 60 deletions(-) create mode 100644 Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abefc9a0a..290186cb35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - Moonshot / Kimi API: add API-key balance tracking, CLI support, docs, and menu bar balance copy (#899). Thanks @giuseppebisemi! ### Fixed +- Claude: show Enterprise OAuth spend-limit usage when the API returns only `extra_usage` data instead of session windows (#925). +- CLI: route Claude token-account `--source cli` reads through the selected OAuth/session credential so `--all-accounts` no longer relabels ambient CLI usage (#403). +- Codex: keep session and weekly quota-warning marker thresholds independent so usage bars do not duplicate marker lines (#938, fixes #927). Thanks @iam-brain! +- Menu: avoid rebuilding visible menus during background open-menu refreshes so hover submenus stay responsive (#923, fixes #909). Thanks @AmrMohamad! - Settings: let stale managed Codex account records be removed even when their stored home path is outside CodexBar's managed-home directory, and keep CLI known-owner tests from writing fixtures into the live app store. - Settings: apply the selected app language from packaged SwiftPM resources instead of falling back to English when the `.lproj` directory casing differs (#908). - Claude: handle Enterprise/organization web accounts whose session quota window is null and expose an optional Org ID field for Claude token accounts (#941, fixes #940). Thanks @clintandrewhall! diff --git a/Sources/CodexBar/QuotaWarningSettingsViews.swift b/Sources/CodexBar/QuotaWarningSettingsViews.swift index 1d4f53361a..ce37b40f1b 100644 --- a/Sources/CodexBar/QuotaWarningSettingsViews.swift +++ b/Sources/CodexBar/QuotaWarningSettingsViews.swift @@ -27,15 +27,8 @@ struct GlobalQuotaWarningSettingsView: View { .toggleStyle(.checkbox) } - QuotaWarningThresholdField( - title: L("quota_warning_warn_at"), - subtitle: L("quota_warning_global_threshold_subtitle"), - thresholds: { self.settings.quotaWarningThresholds }, - setThresholds: { self.settings.quotaWarningThresholds = $0 }) - .disabled(!self.settings.quotaWarningWindowEnabled(.session) && !self.settings - .quotaWarningWindowEnabled(.weekly)) - .opacity(!self.settings.quotaWarningWindowEnabled(.session) && !self.settings - .quotaWarningWindowEnabled(.weekly) ? 0.55 : 1) + self.windowThresholdField(.session) + self.windowThresholdField(.weekly) Toggle(isOn: self.$settings.quotaWarningSoundEnabled) { Text(L("quota_warning_sound")) @@ -45,6 +38,16 @@ struct GlobalQuotaWarningSettingsView: View { } .padding(.leading, 20) } + + private func windowThresholdField(_ window: QuotaWarningWindow) -> some View { + QuotaWarningThresholdField( + title: String(format: L("quota_warning_window_warn_at"), window.localizedCapitalizedDisplayName), + subtitle: L("quota_warning_global_threshold_subtitle"), + thresholds: { self.settings.quotaWarningThresholds(window) }, + setThresholds: { self.settings.setQuotaWarningThresholds(window, thresholds: $0) }) + .disabled(!self.settings.quotaWarningWindowEnabled(window)) + .opacity(!self.settings.quotaWarningWindowEnabled(window) ? 0.55 : 1) + } } @MainActor @@ -73,7 +76,7 @@ struct ProviderQuotaWarningSettingsView: View { self.settings.setQuotaWarningOverride( provider: self.provider, window: window, - thresholds: self.settings.quotaWarningThresholds, + thresholds: self.settings.quotaWarningThresholds(window), enabled: self.settings.quotaWarningWindowEnabled(window)) } else { self.settings.setQuotaWarningOverride( @@ -127,7 +130,7 @@ struct ProviderQuotaWarningSettingsView: View { } } else { Text(String(format: L("quota_warning_inherited"), Self.thresholdText( - self.settings.quotaWarningThresholds, + self.settings.quotaWarningThresholds(window), enabled: self.settings.quotaWarningWindowEnabled(window)))) .font(.footnote) .foregroundStyle(.secondary) diff --git a/Sources/CodexBar/SettingsStore+Config.swift b/Sources/CodexBar/SettingsStore+Config.swift index 578945ceba..e4ef5d2aa3 100644 --- a/Sources/CodexBar/SettingsStore+Config.swift +++ b/Sources/CodexBar/SettingsStore+Config.swift @@ -11,7 +11,9 @@ extension SettingsStore { } func resolvedQuotaWarningThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int] { - self.quotaWarningConfig(for: provider).thresholds(for: window, global: self.quotaWarningThresholds) + self.quotaWarningConfig(for: provider).thresholds( + for: window, + global: self.quotaWarningThresholds(window)) } func quotaWarningEnabled(provider: UsageProvider, window: QuotaWarningWindow) -> Bool { diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index bf5bb75f63..936934bd77 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -116,7 +116,32 @@ extension SettingsStore { set { let sanitized = QuotaWarningThresholds.sanitized(newValue) self.defaultsState.quotaWarningThresholdsRaw = sanitized + self.defaultsState.quotaWarningSessionThresholdsRaw = sanitized + self.defaultsState.quotaWarningWeeklyThresholdsRaw = sanitized self.userDefaults.set(sanitized, forKey: "quotaWarningThresholds") + self.userDefaults.set(sanitized, forKey: "quotaWarningSessionThresholds") + self.userDefaults.set(sanitized, forKey: "quotaWarningWeeklyThresholds") + } + } + + func quotaWarningThresholds(_ window: QuotaWarningWindow) -> [Int] { + switch window { + case .session: + QuotaWarningThresholds.sanitized(self.defaultsState.quotaWarningSessionThresholdsRaw) + case .weekly: + QuotaWarningThresholds.sanitized(self.defaultsState.quotaWarningWeeklyThresholdsRaw) + } + } + + func setQuotaWarningThresholds(_ window: QuotaWarningWindow, thresholds: [Int]) { + let sanitized = QuotaWarningThresholds.sanitized(thresholds) + switch window { + case .session: + self.defaultsState.quotaWarningSessionThresholdsRaw = sanitized + self.userDefaults.set(sanitized, forKey: "quotaWarningSessionThresholds") + case .weekly: + self.defaultsState.quotaWarningWeeklyThresholdsRaw = sanitized + self.userDefaults.set(sanitized, forKey: "quotaWarningWeeklyThresholds") } } diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index e29f4e1d2b..05d51e73f5 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -13,6 +13,8 @@ extension SettingsStore { _ = self.sessionQuotaNotificationsEnabled _ = self.quotaWarningNotificationsEnabled _ = self.quotaWarningThresholds + _ = self.quotaWarningThresholds(.session) + _ = self.quotaWarningThresholds(.weekly) _ = self.quotaWarningWindowEnabled(.session) _ = self.quotaWarningWindowEnabled(.weekly) _ = self.quotaWarningSoundEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 046549ca3e..25804441b5 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -306,15 +306,7 @@ extension SettingsStore { let legacyShowAll = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false return legacyShowAll ? MultiAccountMenuLayout.stacked.rawValue : MultiAccountMenuLayout.segmented.rawValue }() - let storedPreferences = userDefaults.dictionary(forKey: "menuBarMetricPreferences") as? [String: String] ?? [:] - var resolvedPreferences = storedPreferences - if resolvedPreferences.isEmpty, - let menuBarMetricRaw = userDefaults.string(forKey: "menuBarMetricPreference"), - let legacyPreference = MenuBarMetricPreference(rawValue: menuBarMetricRaw) - { - resolvedPreferences = Dictionary( - uniqueKeysWithValues: UsageProvider.allCases.map { ($0.rawValue, legacyPreference.rawValue) }) - } + let resolvedPreferences = Self.loadMenuBarMetricPreferences(userDefaults: userDefaults) let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false let hidePersonalInfo = userDefaults.object(forKey: "hidePersonalInfo") as? Bool ?? false let randomBlinkEnabled = userDefaults.object(forKey: "randomBlinkEnabled") as? Bool ?? false @@ -369,6 +361,8 @@ extension SettingsStore { sessionQuotaNotificationsEnabled: sessionQuotaNotificationsEnabled, quotaWarningNotificationsEnabled: quotaWarnings.notificationsEnabled, quotaWarningThresholdsRaw: quotaWarnings.thresholdsRaw, + quotaWarningSessionThresholdsRaw: quotaWarnings.sessionThresholdsRaw, + quotaWarningWeeklyThresholdsRaw: quotaWarnings.weeklyThresholdsRaw, quotaWarningSessionEnabled: quotaWarnings.sessionEnabled, quotaWarningWeeklyEnabled: quotaWarnings.weeklyEnabled, quotaWarningSoundEnabled: quotaWarnings.soundEnabled, @@ -404,9 +398,22 @@ extension SettingsStore { appLanguageRaw: appLanguageRaw) } + private static func loadMenuBarMetricPreferences(userDefaults: UserDefaults) -> [String: String] { + let storedPreferences = userDefaults.dictionary(forKey: "menuBarMetricPreferences") as? [String: String] ?? [:] + if !storedPreferences.isEmpty { + return storedPreferences + } + guard let menuBarMetricRaw = userDefaults.string(forKey: "menuBarMetricPreference"), + let legacyPreference = MenuBarMetricPreference(rawValue: menuBarMetricRaw) + else { return [:] } + return Dictionary(uniqueKeysWithValues: UsageProvider.allCases.map { ($0.rawValue, legacyPreference.rawValue) }) + } + private struct LoadedQuotaWarningDefaults { var notificationsEnabled: Bool var thresholdsRaw: [Int] + var sessionThresholdsRaw: [Int] + var weeklyThresholdsRaw: [Int] var sessionEnabled: Bool var weeklyEnabled: Bool var soundEnabled: Bool @@ -419,6 +426,16 @@ extension SettingsStore { if Self.isRunningTests, rawThresholds != thresholdsRaw { userDefaults.set(thresholdsRaw, forKey: "quotaWarningThresholds") } + let rawSessionThresholds = userDefaults.array(forKey: "quotaWarningSessionThresholds") as? [Int] + let sessionThresholdsRaw = QuotaWarningThresholds.sanitized(rawSessionThresholds ?? thresholdsRaw) + if Self.isRunningTests, rawSessionThresholds != sessionThresholdsRaw { + userDefaults.set(sessionThresholdsRaw, forKey: "quotaWarningSessionThresholds") + } + let rawWeeklyThresholds = userDefaults.array(forKey: "quotaWarningWeeklyThresholds") as? [Int] + let weeklyThresholdsRaw = QuotaWarningThresholds.sanitized(rawWeeklyThresholds ?? thresholdsRaw) + if Self.isRunningTests, rawWeeklyThresholds != weeklyThresholdsRaw { + userDefaults.set(weeklyThresholdsRaw, forKey: "quotaWarningWeeklyThresholds") + } let sessionDefault = userDefaults.object(forKey: "quotaWarningSessionEnabled") as? Bool let sessionEnabled = sessionDefault ?? true @@ -441,6 +458,8 @@ extension SettingsStore { return LoadedQuotaWarningDefaults( notificationsEnabled: notificationsEnabled, thresholdsRaw: thresholdsRaw, + sessionThresholdsRaw: sessionThresholdsRaw, + weeklyThresholdsRaw: weeklyThresholdsRaw, sessionEnabled: sessionEnabled, weeklyEnabled: weeklyEnabled, soundEnabled: soundEnabled) diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 653230cfea..0d4e73b7e5 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -13,6 +13,8 @@ struct SettingsDefaultsState { var sessionQuotaNotificationsEnabled: Bool var quotaWarningNotificationsEnabled: Bool var quotaWarningThresholdsRaw: [Int] + var quotaWarningSessionThresholdsRaw: [Int] + var quotaWarningWeeklyThresholdsRaw: [Int] var quotaWarningSessionEnabled: Bool var quotaWarningWeeklyEnabled: Bool var quotaWarningSoundEnabled: Bool diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 18136b89da..ca3a1659c9 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -4,17 +4,23 @@ import CodexBarCore extension StatusItemController: StatusItemMenuPersistentActionDelegate { // MARK: - Actions reachable from menus - func refreshStore(forceTokenUsage: Bool) { + func refreshStore(forceTokenUsage: Bool, refreshOpenMenusWhenComplete: Bool = true) { Task { await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh(forceTokenUsage: forceTokenUsage) self.store.scheduleStorageFootprintRefreshForOverview(force: true) self.invalidateMenus() - self.refreshOpenMenusIfNeeded() + if refreshOpenMenusWhenComplete { + self.refreshOpenMenusIfNeeded() + } } } } + func refreshOpenMenusAfterExplicitStoreAction() { + self.invalidateMenus(refreshOpenMenus: true) + } + @objc func refreshNow() { self.refreshStore(forceTokenUsage: true) } @@ -32,6 +38,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh(forceTokenUsage: false) } + self.refreshOpenMenusAfterExplicitStoreAction() } } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index c26e4fbb6d..3ec68f1a39 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1129,10 +1129,10 @@ extension StatusItemController { } private func scheduleOpenMenuRefresh(for menu: NSMenu) { - // Kick off a user-initiated refresh on open (non-forced) and re-check after a delay. + // Kick off a refresh on open (non-forced) and re-check after a delay. // NEVER block menu opening with network requests. if !self.store.isRefreshing { - self.refreshStore(forceTokenUsage: false) + self.refreshStore(forceTokenUsage: false, refreshOpenMenusWhenComplete: false) } let key = ObjectIdentifier(menu) self.menuRefreshTasks[key]?.cancel() @@ -1151,7 +1151,7 @@ extension StatusItemController { let retryMissingSnapshotCount = retryProviders.count { self.store.snapshot(for: $0) == nil } let willRetryRefresh = retryStaleProviderCount > 0 || retryMissingSnapshotCount > 0 guard willRetryRefresh else { return } - self.refreshStore(forceTokenUsage: false) + self.refreshStore(forceTokenUsage: false, refreshOpenMenusWhenComplete: false) } } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 22d7e4fc11..4bdb708842 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -329,7 +329,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin Task { @MainActor [weak self] in guard let self else { return } self.observeStoreChanges() - self.invalidateMenus(refreshOpenMenus: !self.store.isRefreshing) + self.invalidateMenus() } } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 711b21e9b2..86f980a4ac 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -56,6 +56,8 @@ extension UsageStore { _ = self.settings.sessionQuotaNotificationsEnabled _ = self.settings.quotaWarningNotificationsEnabled _ = self.settings.quotaWarningThresholds + _ = self.settings.quotaWarningThresholds(.session) + _ = self.settings.quotaWarningThresholds(.weekly) _ = self.settings.quotaWarningSoundEnabled _ = self.settings.usageBarsShowUsed _ = self.settings.costUsageEnabled diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 89b1dadc99..dcc58e0d19 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -337,13 +337,31 @@ struct TokenAccountCLIContext { provider: UsageProvider, account: ProviderTokenAccount?) -> ProviderSourceMode { - guard base == .auto, - provider == .claude - else { + guard provider == .claude else { return base } let config = self.providerConfig(for: provider) - return self.claudeCredentialRouting(account: account, config: config).isOAuth ? .oauth : base + let routing = self.claudeCredentialRouting(account: account, config: config) + + if base == .auto { + return routing.isOAuth ? .oauth : base + } + + guard base == .cli, account != nil else { + return base + } + + // Claude CLI usage is ambient to the active local CLI profile, so per-token-account + // CLI reads can be mislabeled as separate accounts. Use the selected account's + // routable credential instead. + switch routing { + case .oauth: + return .oauth + case .webCookie: + return .web + case .none: + return base + } } func preferredSourceMode(for provider: UsageProvider) -> ProviderSourceMode { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index fee83a4648..2775e50274 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -302,8 +302,9 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { accountEmail: usage.accountEmail, accountOrganization: usage.accountOrganization, loginMethod: usage.loginMethod) + let primary = usage.primaryWindowKind == .spendLimit ? nil : usage.primary return UsageSnapshot( - primary: usage.primary, + primary: primary, secondary: usage.secondary, tertiary: usage.opus, extraRateWindows: usage.extraRateWindows.isEmpty ? nil : usage.extraRateWindows, @@ -311,6 +312,10 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { updatedAt: usage.updatedAt, identity: identity) } + + static func _snapshotForTesting(from usage: ClaudeUsageSnapshot) -> UsageSnapshot { + self.snapshot(from: usage) + } } struct ClaudeWebFetchStrategy: ProviderFetchStrategy { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 99def2eebd..a1b8408ecc 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -7,7 +7,13 @@ public protocol ClaudeUsageFetching: Sendable { } public struct ClaudeUsageSnapshot: Sendable { + public enum PrimaryWindowKind: Equatable, Sendable { + case usage + case spendLimit + } + public let primary: RateWindow + public let primaryWindowKind: PrimaryWindowKind public let secondary: RateWindow? public let opus: RateWindow? public let extraRateWindows: [NamedRateWindow] @@ -20,6 +26,7 @@ public struct ClaudeUsageSnapshot: Sendable { public init( primary: RateWindow, + primaryWindowKind: PrimaryWindowKind = .usage, secondary: RateWindow?, opus: RateWindow?, extraRateWindows: [NamedRateWindow] = [], @@ -31,6 +38,7 @@ public struct ClaudeUsageSnapshot: Sendable { rawText: String?) { self.primary = primary + self.primaryWindowKind = primaryWindowKind self.secondary = secondary self.opus = opus self.extraRateWindows = extraRateWindows @@ -848,12 +856,35 @@ extension ClaudeUsageFetcher { resetDescription: resetDescription) } - guard let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60) + let loginMethod = ClaudePlan.oauthLoginMethod( + subscriptionType: credentials.subscriptionType, + rateLimitTier: credentials.rateLimitTier) + let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60) ?? makeWindow(usage.sevenDay, windowMinutes: 7 * 24 * 60) ?? makeWindow(usage.sevenDayOAuthApps, windowMinutes: 7 * 24 * 60) ?? makeWindow(usage.sevenDaySonnet, windowMinutes: 7 * 24 * 60) ?? makeWindow(usage.sevenDayOpus, windowMinutes: 7 * 24 * 60) - else { + let treatAsSpendLimit = primary == nil && usage.extraUsage?.isEnabled == true + let providerCost = Self.oauthExtraUsageCost( + usage.extraUsage, + loginMethod: loginMethod, + treatAsSpendLimit: treatAsSpendLimit) + + guard let primary else { + if let spendLimit = Self.oauthSpendLimitWindow(from: providerCost, extraUsage: usage.extraUsage) { + return ClaudeUsageSnapshot( + primary: spendLimit, + primaryWindowKind: .spendLimit, + secondary: nil, + opus: nil, + extraRateWindows: Self.oauthExtraRateWindows(from: usage), + providerCost: providerCost, + updatedAt: Date(), + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod, + rawText: nil) + } throw ClaudeUsageError.parseFailed("missing session data") } @@ -863,11 +894,6 @@ extension ClaudeUsageFetcher { windowMinutes: 7 * 24 * 60) let extraRateWindows = Self.oauthExtraRateWindows(from: usage) - let loginMethod = ClaudePlan.oauthLoginMethod( - subscriptionType: credentials.subscriptionType, - rateLimitTier: credentials.rateLimitTier) - let providerCost = Self.oauthExtraUsageCost(usage.extraUsage, loginMethod: loginMethod) - return ClaudeUsageSnapshot( primary: primary, secondary: weekly, @@ -883,7 +909,8 @@ extension ClaudeUsageFetcher { private static func oauthExtraUsageCost( _ extra: OAuthExtraUsage?, - loginMethod: String?) -> ProviderCostSnapshot? + loginMethod: String?, + treatAsSpendLimit: Bool = false) -> ProviderCostSnapshot? { guard let extra, extra.isEnabled == true else { return nil } guard let used = extra.usedCredits, @@ -891,24 +918,50 @@ extension ClaudeUsageFetcher { else { return nil } let currency = extra.currency?.trimmingCharacters(in: .whitespacesAndNewlines) let code = (currency?.isEmpty ?? true) ? "USD" : currency! - let normalized = Self.normalizeClaudeExtraUsageAmounts(used: used, limit: limit) + let isSpendLimit = treatAsSpendLimit || ClaudePlan.fromCompatibilityLoginMethod(loginMethod) == .enterprise + let normalized = Self.normalizeClaudeExtraUsageAmounts( + used: used, + limit: limit, + treatAsMajorUnits: isSpendLimit) return ProviderCostSnapshot( used: normalized.used, limit: normalized.limit, currencyCode: code, - period: "Monthly", + period: isSpendLimit ? "Spend limit" : "Monthly", resetsAt: nil, updatedAt: Date()) } + private static func oauthSpendLimitWindow( + from providerCost: ProviderCostSnapshot?, + extraUsage: OAuthExtraUsage?) -> RateWindow? + { + guard let providerCost, + providerCost.limit > 0 + else { return nil } + let usedPercent = extraUsage?.utilization ?? (providerCost.used / providerCost.limit) * 100 + let used = UsageFormatter.currencyString(providerCost.used, currencyCode: providerCost.currencyCode) + let limit = UsageFormatter.currencyString(providerCost.limit, currencyCode: providerCost.currencyCode) + return RateWindow( + usedPercent: min(100, max(0, usedPercent)), + windowMinutes: nil, + resetsAt: providerCost.resetsAt, + resetDescription: "\(providerCost.period ?? "Spend limit"): \(used) / \(limit)") + } + private static func normalizeClaudeExtraUsageAmounts( used: Double, - limit: Double) -> (used: Double, limit: Double) + limit: Double, + treatAsMajorUnits: Bool) -> (used: Double, limit: Double) { + if treatAsMajorUnits { + return (used: used, limit: limit) + } + // Claude's OAuth API returns values in cents (minor units), same as the Web API. // Always convert to dollars (major units) for display consistency. // See: ClaudeWebAPIFetcher.swift which always divides by 100. - (used: used / 100.0, limit: limit / 100.0) + return (used: used / 100.0, limit: limit / 100.0) } private static func oauthExtraRateWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] { @@ -1096,6 +1149,7 @@ extension ClaudeUsageFetcher { if mergedProviderCost != snapshot.providerCost || mergedExtraRateWindows != snapshot.extraRateWindows { return ClaudeUsageSnapshot( primary: snapshot.primary, + primaryWindowKind: snapshot.primaryWindowKind, secondary: snapshot.secondary, opus: snapshot.opus, extraRateWindows: mergedExtraRateWindows, diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index ee872ceee6..654318531b 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -197,6 +197,85 @@ struct ClaudeOAuthTests { #expect(snap.providerCost?.used == 5.2) } + @Test + func `maps enterprise O auth spend limit without session windows`() throws { + let json = """ + { + "extra_usage": { + "is_enabled": true, + "monthly_limit": 600, + "used_credits": 434.43, + "utilization": 72, + "currency": "USD" + } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting( + Data(json.utf8), + subscriptionType: "enterprise") + #expect(snap.loginMethod == "Claude Enterprise") + #expect(snap.primary.usedPercent == 72) + #expect(snap.primaryWindowKind == .spendLimit) + #expect(snap.primary.windowMinutes == nil) + #expect(snap.primary.resetDescription == "Spend limit: $434.43 / $600.00") + #expect(snap.secondary == nil) + #expect(snap.providerCost?.period == "Spend limit") + #expect(snap.providerCost?.limit == 600) + #expect(snap.providerCost?.used == 434.43) + + let usage = ClaudeOAuthFetchStrategy._snapshotForTesting(from: snap) + #expect(usage.primary == nil) + #expect(usage.providerCost?.period == "Spend limit") + #expect(usage.providerCost?.used == 434.43) + } + + @Test + func `maps O auth spend limit without plan metadata as major units`() throws { + let json = """ + { + "extra_usage": { + "is_enabled": true, + "monthly_limit": 600, + "used_credits": 434.43, + "utilization": 72, + "currency": "USD" + } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snap.loginMethod == nil) + #expect(snap.primaryWindowKind == .spendLimit) + #expect(snap.primary.usedPercent == 72) + #expect(snap.primary.resetDescription == "Spend limit: $434.43 / $600.00") + #expect(snap.providerCost?.period == "Spend limit") + #expect(snap.providerCost?.limit == 600) + #expect(snap.providerCost?.used == 434.43) + } + + @Test + func `maps large enterprise O auth spend limit as major units`() throws { + let json = """ + { + "extra_usage": { + "is_enabled": true, + "monthly_limit": 10000, + "used_credits": 1234.56, + "utilization": 12.3456, + "currency": "USD" + } + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting( + Data(json.utf8), + subscriptionType: "enterprise") + #expect(snap.primaryWindowKind == .spendLimit) + #expect(snap.primary.usedPercent == 12.3456) + #expect(snap.primary.resetDescription == "Spend limit: $1,234.56 / $10,000.00") + #expect(snap.providerCost?.period == "Spend limit") + #expect(snap.providerCost?.limit == 10000) + #expect(snap.providerCost?.used == 1234.56) + } + @Test func `normalizes high limit O auth extra usage`() throws { let json = """ diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index ad8f5f27b4..438b39139f 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -634,6 +634,27 @@ struct SettingsStoreTests { #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) } + @Test + func `global quota warning thresholds resolve independently by window`() throws { + let suite = "SettingsStoreTests-quota-warning-window-thresholds" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.setQuotaWarningThresholds(.session, thresholds: [25]) + store.setQuotaWarningThresholds(.weekly, thresholds: [75, 10]) + + #expect(store.quotaWarningThresholds(.session) == [25]) + #expect(store.quotaWarningThresholds(.weekly) == [75, 10]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [25]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .weekly) == [75, 10]) + } + @Test func `provider quota warning windows override global enablement independently`() throws { let suite = "SettingsStoreTests-quota-warning-provider-window-override" @@ -1013,6 +1034,40 @@ struct SettingsStoreTests { #expect(didChange.get() == true) } + @Test + func `menu observation token updates on per-window quota threshold changes`() async throws { + let suite = "SettingsStoreTests-observation-quota-threshold-windows" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + func expectObservation( + for window: QuotaWarningWindow, + thresholds: [Int]) async + { + let didChange = ObservationFlag() + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.setQuotaWarningThresholds(window, thresholds: thresholds) + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == true) + } + + await expectObservation(for: .session, thresholds: [70, 30]) + await expectObservation(for: .weekly, thresholds: [80, 40]) + } + @Test func `config backed settings trigger observation`() async throws { let suite = "SettingsStoreTests-observation-config" diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift new file mode 100644 index 0000000000..c1871ebf7b --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -0,0 +1,97 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `store observation marks open menu stale without rebuilding during tracking`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + + let openedVersion = controller.menuVersions[key] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 33, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")), + provider: .codex) + + for _ in 0..<20 where controller.menuContentVersion == openedVersion { + await Task.yield() + } + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + #expect(rebuildCount == 0) + } + + @Test + func `explicit store actions refresh a visible open menu`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + + let openedVersion = controller.menuVersions[key] + + controller.refreshOpenMenusAfterExplicitStoreAction() + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } +} diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index cff5412bda..35eb4bc312 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -6,18 +6,18 @@ import Testing @MainActor @Suite(.serialized) struct StatusMenuTests { - private func disableMenuCardsForTesting() { + func disableMenuCardsForTesting() { StatusItemController.menuCardRenderingEnabled = false StatusItemController.setMenuRefreshEnabledForTesting(false) } - private func makeStatusBarForTesting() -> NSStatusBar { + func makeStatusBarForTesting() -> NSStatusBar { // Use the real system status bar in tests. Creating standalone NSStatusBar instances // has caused AppKit teardown crashes under swiftpm-testing-helper. .system } - private func makeSettings() -> SettingsStore { + func makeSettings() -> SettingsStore { let suite = "StatusMenuTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) @@ -29,7 +29,7 @@ struct StatusMenuTests { syntheticTokenStore: NoopSyntheticTokenStore()) } - private func makeCodexStore(settings: SettingsStore, dashboardAuthorized: Bool) -> UsageStore { + func makeCodexStore(settings: SettingsStore, dashboardAuthorized: Bool) -> UsageStore { let now = Date() let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -85,6 +85,7 @@ struct StatusMenuTests { settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false + settings.providerDetectionCompleted = true settings.alibabaCodingPlanAPIRegion = .chinaMainland let fetcher = UsageFetcher() @@ -314,17 +315,15 @@ struct StatusMenuTests { loginMethod: "Plus Plan")), provider: .codex) - for _ in 0..<50 - where controller.menuContentVersion == openedVersion || - controller.menuVersions[key] != controller.menuContentVersion - { - StatusItemController.setMenuRefreshEnabledForTesting(true) - controller.refreshOpenMenusIfNeeded() - try? await Task.sleep(for: .milliseconds(20)) + for _ in 0..<50 where controller.menuContentVersion == openedVersion { + await Task.yield() } + let staleVersion = controller.menuContentVersion + controller.refreshOpenMenusIfNeeded() + #expect(controller.menuContentVersion != openedVersion) - #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] == staleVersion) } @Test @@ -609,9 +608,16 @@ struct StatusMenuTests { let nextProviderButton = self.switcherButtons(in: menu).first(where: { $0.state == .off }) #expect(nextProviderButton != nil) - nextProviderButton?.performClick(nil) - await Task.yield() - await Task.yield() + let clickedNextProvider = nextProviderButton.map { + initialSwitcher?._test_simulateRuntimeClick(buttonTag: $0.tag) + } ?? false + #expect(clickedNextProvider == true) + for _ in 0..<50 + where initialSwitcherID == (menu.items.first?.view as? ProviderSwitcherView) + .map(ObjectIdentifier.init) + { + await Task.yield() + } let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView #expect(updatedSwitcher != nil) diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index bd89634722..f1d0c51002 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -218,6 +218,66 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(effectiveSourceMode == .oauth) } + @Test + func `claude OAuth token selection reroutes explicit CLI source to OAuth in CLI`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil) + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .cli, + provider: .claude, + account: account) + + #expect(effectiveSourceMode == .oauth) + } + + @Test + func `claude session key selection reroutes explicit CLI source to Web in CLI`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil) + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .cli, + provider: .claude, + account: account) + + #expect(effectiveSourceMode == .web) + } + + @Test + func `claude ambient explicit CLI source remains CLI in CLI`() throws { + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .cli, + provider: .claude, + account: nil) + + #expect(effectiveSourceMode == .cli) + } + @Test func `claude session key selection stays in manual cookie mode in CLI settings snapshot`() throws { let accounts = ProviderTokenAccountData( diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 150bb43747..a427096988 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -1,7 +1,7 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore @MainActor struct UsageStoreSessionQuotaTransitionTests { @@ -119,6 +119,46 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.posts.isEmpty) } + @Test + func `claude spend limit fallback does not emit session or quota warning notifications`() throws { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-spend-limit") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + let json = """ + { + "extra_usage": { + "is_enabled": true, + "monthly_limit": 600, + "used_credits": 434.43, + "utilization": 72, + "currency": "USD" + } + } + """ + let claude = try ClaudeUsageFetcher._mapOAuthUsageForTesting( + Data(json.utf8), + subscriptionType: "enterprise") + let snapshot = ClaudeOAuthFetchStrategy._snapshotForTesting(from: claude) + + store.handleSessionQuotaTransition(provider: .claude, snapshot: snapshot) + store.handleQuotaWarningTransitions(provider: .claude, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(snapshot.providerCost?.period == "Spend limit") + #expect(notifier.posts.isEmpty) + #expect(notifier.quotaWarningPosts.isEmpty) + } + @Test func `claude five hour primary still emits session quota notifications`() { let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-session") From ac5de8466281b24fc83133223ad7bcadc0048a49 Mon Sep 17 00:00:00 2001 From: Giuseppe Bisemi Date: Thu, 14 May 2026 08:39:50 +0200 Subject: [PATCH 11/27] Rebalance Kimi provider icon (#912) --- Sources/CodexBar/Resources/ProviderIcon-kimi.svg | 2 +- Tests/CodexBarTests/ProviderIconResourcesTests.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/Resources/ProviderIcon-kimi.svg b/Sources/CodexBar/Resources/ProviderIcon-kimi.svg index 03dee9305c..77cba2eacd 100644 --- a/Sources/CodexBar/Resources/ProviderIcon-kimi.svg +++ b/Sources/CodexBar/Resources/ProviderIcon-kimi.svg @@ -1 +1 @@ -Kimi +Kimi diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index b3e563ec7e..351e90be12 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -24,6 +24,7 @@ struct ProviderIconResourcesTests { "copilot", "crof", "commandcode", + "kimi", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") From 6423275467b2e27b268abd75ce76c38383a36784 Mon Sep 17 00:00:00 2001 From: HaiTao Wu Date: Thu, 14 May 2026 14:41:02 +0800 Subject: [PATCH 12/27] Add zh-Hans translations for Claude peak hour labels (#921) --- Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 329892d4d9..d11dfc4e46 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -385,8 +385,11 @@ "metric_secondary" = "次要(%@)"; "metric_tertiary" = "第三(%@)"; "multiple_workspaces_found" = "CodexBar 发现了 %1$@ 的多个工作区。请选择要添加的工作区。"; +"off_peak" = "非高峰"; +"off_peak_peak_in" = "非高峰 · %@ 后进入高峰"; "ory_session_…=…; csrftoken=…" = ""; "overview_choose_providers" = "选择最多 %1$@ 个提供商"; +"peak_ends_in" = "高峰 · %@ 后结束"; "remove_account_message" = "从 CodexBar 中移除 %@?其托管的 Codex 主目录将被删除。"; "version_format" = "版本 %@"; "vertex_ai_login_instructions" = "要使用 Vertex AI 跟踪,您需要通过 Google Cloud 进行认证。 From e48a4bc7a69dd2feb34846fb8de6e971580691c7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 07:44:08 +0100 Subject: [PATCH 13/27] docs: update changelog for recent contributor fixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 290186cb35..ae801d77f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,13 @@ ## 0.26 — Unreleased ### Added +- Localization: add Simplified Chinese translations for Claude peak-hour labels (#921). Thanks @whtis! - Display: add a setting to hide quota-warning tick marks on usage bars while keeping quota warning notifications active (#918, fixes #916). Thanks @ThiagoCAltoe! - Menu: add an opt-in setting for provider changelog links, starting with Codex, Claude Code, and Gemini CLI (#929, fixes #660). Thanks @ThiagoCAltoe! - Moonshot / Kimi API: add API-key balance tracking, CLI support, docs, and menu bar balance copy (#899). Thanks @giuseppebisemi! ### Fixed +- Kimi: rebalance provider icon alignment within its viewBox (#912). Thanks @giuseppebisemi! - Claude: show Enterprise OAuth spend-limit usage when the API returns only `extra_usage` data instead of session windows (#925). - CLI: route Claude token-account `--source cli` reads through the selected OAuth/session credential so `--all-accounts` no longer relabels ambient CLI usage (#403). - Codex: keep session and weekly quota-warning marker thresholds independent so usage bars do not duplicate marker lines (#938, fixes #927). Thanks @iam-brain! From e6ea0aec58c2043f6936c66e36c25ef1af5e4543 Mon Sep 17 00:00:00 2001 From: Piotr Durlej Date: Thu, 14 May 2026 09:39:51 +0200 Subject: [PATCH 14/27] Add Tahoe menu bar visibility guidance Co-authored-by: Piotr Durlej --- CHANGELOG.md | 1 + .../CodexBar/MenuBarVisibilityWatcher.swift | 110 ++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 4 + Sources/CodexBar/StatusItemController.swift | 1 + .../MenuBarVisibilityWatcherTests.swift | 83 +++++++++++++ 5 files changed, 199 insertions(+) create mode 100644 Sources/CodexBar/MenuBarVisibilityWatcher.swift create mode 100644 Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ae801d77f4..de2bafda1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Kimi: rebalance provider icon alignment within its viewBox (#912). Thanks @giuseppebisemi! +- Menu bar: detect when macOS Tahoe hides CodexBar behind the new Allow in Menu Bar setting and show recovery guidance (#945, fixes #890). Thanks @pdurlej! - Claude: show Enterprise OAuth spend-limit usage when the API returns only `extra_usage` data instead of session windows (#925). - CLI: route Claude token-account `--source cli` reads through the selected OAuth/session credential so `--all-accounts` no longer relabels ambient CLI usage (#403). - Codex: keep session and weekly quota-warning marker thresholds independent so usage bars do not duplicate marker lines (#938, fixes #927). Thanks @iam-brain! diff --git a/Sources/CodexBar/MenuBarVisibilityWatcher.swift b/Sources/CodexBar/MenuBarVisibilityWatcher.swift new file mode 100644 index 0000000000..2b8f0f46b6 --- /dev/null +++ b/Sources/CodexBar/MenuBarVisibilityWatcher.swift @@ -0,0 +1,110 @@ +import AppKit +import Foundation + +struct StatusItemVisibilitySnapshot: Equatable { + let isVisible: Bool + let hasButton: Bool + let hasWindow: Bool + let hasScreen: Bool + let buttonWidth: CGFloat +} + +@MainActor +func isStatusItemBlocked(_ item: NSStatusItem) -> Bool { + MenuBarVisibilityWatcher.isBlockedSnapshot( + snapshot: StatusItemVisibilitySnapshot( + isVisible: item.isVisible, + hasButton: item.button != nil, + hasWindow: item.button?.window != nil, + hasScreen: item.button?.window?.screen != nil, + buttonWidth: item.button?.frame.size.width ?? 0)) +} + +enum MenuBarVisibilityWatcher { + static let guidanceShownKey = "hasShownTahoeAllowListGuidance" + static let guidanceLastShownAtKey = "tahoeAllowListGuidanceLastShownAt" + static let guidanceRepeatInterval: TimeInterval = 24 * 60 * 60 + static let startupFreshnessInterval: TimeInterval = 10 + static let settingsURL = URL(string: "x-apple.systempreferences:com.apple.MenuBarSettings")! + + static func isBlockedSnapshot(snapshot: StatusItemVisibilitySnapshot) -> Bool { + guard snapshot.isVisible else { return false } + guard snapshot.hasButton else { return true } + return !snapshot.hasWindow || !snapshot.hasScreen || snapshot.buttonWidth <= 0 + } + + @MainActor + static func hasBlockedVisibleStatusItems(_ items: [NSStatusItem]) -> Bool { + let visibleItems = items.filter(\.isVisible) + guard !visibleItems.isEmpty else { return false } + return visibleItems.allSatisfy { item in + isStatusItemBlocked(item) + } + } + + static func shouldShowGuidance(defaults: UserDefaults, now: Date = Date()) -> Bool { + guard defaults.bool(forKey: self.guidanceShownKey) else { return true } + let lastShownAt = defaults.double(forKey: self.guidanceLastShownAtKey) + guard lastShownAt > 0 else { return false } + return now.timeIntervalSince1970 - lastShownAt >= self.guidanceRepeatInterval + } + + static func markGuidanceShown(defaults: UserDefaults, now: Date = Date()) { + defaults.set(true, forKey: self.guidanceShownKey) + defaults.set(now.timeIntervalSince1970, forKey: self.guidanceLastShownAtKey) + } + + @MainActor + static func presentGuidance( + defaults: UserDefaults, + now: Date = Date(), + openURL: (URL) -> Void = { NSWorkspace.shared.open($0) }) + { + self.markGuidanceShown(defaults: defaults, now: now) + + let alert = NSAlert() + alert.messageText = L("CodexBar can't show its menu bar icon") + alert.informativeText = L( + "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. " + + "CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on.") + alert.alertStyle = .warning + alert.addButton(withTitle: L("Open Menu Bar Settings")) + alert.addButton(withTitle: L("Dismiss")) + + if alert.runModal() == .alertFirstButtonReturn { + openURL(self.settingsURL) + } + } +} + +extension StatusItemController { + func scheduleTahoeAllowListVisibilityCheck(appLaunchedAt: Date = Date()) { + guard !SettingsStore.isRunningTests else { return } + if #available(macOS 26.0, *) { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + Task { @MainActor [weak self] in + self?.checkTahoeAllowListVisibility(appLaunchedAt: appLaunchedAt) + } + } + } + } + + private func checkTahoeAllowListVisibility(appLaunchedAt: Date, now: Date = Date()) { + guard now.timeIntervalSince(appLaunchedAt) <= MenuBarVisibilityWatcher.startupFreshnessInterval else { + return + } + guard MenuBarVisibilityWatcher.hasBlockedVisibleStatusItems(self.tahoeAllowListStatusItems) else { + return + } + + self.menuLogger.error("Status item failed to materialize — likely blocked by Tahoe Allow in Menu Bar panel") + guard MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults, now: now) else { + return + } + MenuBarVisibilityWatcher.presentGuidance(defaults: self.settings.userDefaults, now: now) + } + + private var tahoeAllowListStatusItems: [NSStatusItem] { + [self.statusItem] + Array(self.statusItems.values) + } +} diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 50a54388bf..c021054554 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -79,6 +79,7 @@ "Codex login failed" = "Codex login failed"; "Codex login timed out" = "Codex login timed out"; "CodexBar Lifecycle Keepalive" = "CodexBar Lifecycle Keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar can't show its menu bar icon"; "CodexBar could not read managed account storage. " = "CodexBar could not read managed account storage. "; "Configure…" = "Configure…"; "Connected" = "Connected"; @@ -108,6 +109,7 @@ "Designs" = "Designs"; "Disable Keychain access" = "Disable Keychain access"; "Disabled" = "Disabled"; +"Dismiss" = "Dismiss"; "Disconnected" = "Disconnected"; "Display" = "Display"; "Display mode" = "Display mode"; @@ -206,6 +208,7 @@ "Open Console" = "Open Console"; "Open Dashboard" = "Open Dashboard"; "Open Mistral Admin" = "Open Mistral Admin"; +"Open Menu Bar Settings" = "Open Menu Bar Settings"; "Open Ollama Settings" = "Open Ollama Settings"; "Open Terminal" = "Open Terminal"; "Open Usage Page" = "Open Usage Page"; @@ -592,6 +595,7 @@ "login_shell_path" = "Login shell PATH (startup capture)"; "cleared" = "Cleared."; "no_fetch_attempts" = "No fetch attempts yet."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on."; /* Metric preferences */ "metric_pref_automatic" = "Automatic"; diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 4bdb708842..eeceb69112 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -268,6 +268,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.wireBindings() self.updateVisibility() self.updateIcons() + self.scheduleTahoeAllowListVisibilityCheck() NotificationCenter.default.addObserver( self, selector: #selector(self.handleDebugReplayNotification(_:)), diff --git a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift new file mode 100644 index 0000000000..a829a2ddfd --- /dev/null +++ b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import CodexBar + +struct MenuBarVisibilityWatcherTests { + @Test + func `does not flag intentionally hidden status item`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 0) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `flags visible item without attached window`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `flags visible item without button`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: false, + hasWindow: false, + hasScreen: false, + buttonWidth: 0) + + #expect(MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `flags visible item with zero width`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 0) + + #expect(MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `allows visible item attached to a screen with width`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `guidance shows once then repeats after a day`() throws { + let defaults = try #require(UserDefaults(suiteName: "MenuBarVisibilityWatcherTests")) + defaults.removePersistentDomain(forName: "MenuBarVisibilityWatcherTests") + let now = Date(timeIntervalSince1970: 1000) + + #expect(MenuBarVisibilityWatcher.shouldShowGuidance(defaults: defaults, now: now)) + + MenuBarVisibilityWatcher.markGuidanceShown(defaults: defaults, now: now) + + #expect(!MenuBarVisibilityWatcher.shouldShowGuidance( + defaults: defaults, + now: now.addingTimeInterval(MenuBarVisibilityWatcher.guidanceRepeatInterval - 1))) + #expect(MenuBarVisibilityWatcher.shouldShowGuidance( + defaults: defaults, + now: now.addingTimeInterval(MenuBarVisibilityWatcher.guidanceRepeatInterval))) + } +} From 4abfbb6c5b4bf8e2d7fdcbc3f01c2a939ad45855 Mon Sep 17 00:00:00 2001 From: zhulijin1991 <167979819+zhulijin1991@users.noreply.github.com> Date: Thu, 14 May 2026 15:41:48 +0800 Subject: [PATCH 15/27] Coalesce historical pace reset buckets Co-authored-by: zhulijin1991 --- CHANGELOG.md | 1 + Sources/CodexBar/HistoricalUsagePace.swift | 4 +- .../HistoricalUsagePaceOwnershipTests.swift | 2 +- .../HistoricalUsagePaceTestSupport.swift | 2 +- .../HistoricalUsagePaceTests.swift | 44 +++++++++++++++++-- 5 files changed, 46 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de2bafda1e..2bc6ad0dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - CLI: route Claude token-account `--source cli` reads through the selected OAuth/session credential so `--all-accounts` no longer relabels ambient CLI usage (#403). - Codex: keep session and weekly quota-warning marker thresholds independent so usage bars do not duplicate marker lines (#938, fixes #927). Thanks @iam-brain! - Menu: avoid rebuilding visible menus during background open-menu refreshes so hover submenus stay responsive (#923, fixes #909). Thanks @AmrMohamad! +- Codex: coalesce historical pace reset timestamps into 5-minute buckets so dashboard and live reset jitter do not duplicate weekly history windows (#901). Thanks @zhulijin1991! - Settings: let stale managed Codex account records be removed even when their stored home path is outside CodexBar's managed-home directory, and keep CLI known-owner tests from writing fixtures into the live app store. - Settings: apply the selected app language from packaged SwiftPM resources instead of falling back to English when the `.lproj` directory casing differs (#908). - Claude: handle Enterprise/organization web accounts whose session quota window is null and expose an optional Org ID field for Claude token accounts (#941, fixes #940). Thanks @clintandrewhall! diff --git a/Sources/CodexBar/HistoricalUsagePace.swift b/Sources/CodexBar/HistoricalUsagePace.swift index b9eeaad8b4..dc8a0ac5cd 100644 --- a/Sources/CodexBar/HistoricalUsagePace.swift +++ b/Sources/CodexBar/HistoricalUsagePace.swift @@ -80,7 +80,7 @@ actor HistoricalUsageHistoryStore { private static let backfillCalibrationMinimumCredits = 0.001 private static let backfillSampleFractions: [Double] = (0...14).map { Double($0) / 14.0 } private static let coverageTolerance: TimeInterval = 16 * 60 * 60 - private static let resetBucketSeconds: TimeInterval = 60 + private static let resetBucketSeconds: TimeInterval = 5 * 60 private let fileURL: URL private var records: [HistoricalUsageRecord] = [] @@ -762,7 +762,7 @@ enum CodexHistoricalPaceEvaluator { static let minimumWeeksForRisk = 5 private static let recencyTauWeeks: Double = 3 private static let epsilon: Double = 1e-9 - private static let resetBucketSeconds: TimeInterval = 60 + private static let resetBucketSeconds: TimeInterval = 5 * 60 static func evaluate(window: RateWindow, now: Date, dataset: CodexHistoricalDataset?) -> UsagePace? { guard let dataset else { return nil } diff --git a/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift index 7c32b1974a..9cfa9ede16 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift @@ -283,7 +283,7 @@ extension HistoricalUsagePaceTests { windowMinutes: record.windowMinutes) }, to: fileURL) - let expectedResetAt = fixtureResetAt.addingTimeInterval(dateShift) + let expectedResetAt = Self.normalizeReset(fixtureResetAt.addingTimeInterval(dateShift)) let dataset = await store.loadCodexDataset( canonicalAccountKey: providerAccountKey, diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift b/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift index d086a95824..32ff81233f 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift @@ -94,7 +94,7 @@ extension HistoricalUsagePaceTests { } static func normalizeReset(_ value: Date) -> Date { - let bucket = 60.0 + let bucket = 5 * 60.0 let rounded = (value.timeIntervalSinceReferenceDate / bucket).rounded() * bucket return Date(timeIntervalSinceReferenceDate: rounded) } diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift index a8a2200b63..63cecbfe45 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift @@ -285,6 +285,44 @@ struct HistoricalUsagePaceTests { #expect(Self.datasetCurveSignature(first) == Self.datasetCurveSignature(second)) } + @Test + func `history store backfill is idempotent across minute scale reset jitter`() async { + let fileURL = Self.makeTempURL() + let store = HistoricalUsageHistoryStore(fileURL: fileURL) + let now = Date(timeIntervalSince1970: 1_770_000_000) + let windowMinutes = 10080 + let canonicalReset = Self.normalizeReset(now.addingTimeInterval(2 * 24 * 60 * 60)) + let firstWindow = RateWindow( + usedPercent: 50, + windowMinutes: windowMinutes, + resetsAt: canonicalReset.addingTimeInterval(-90), + resetDescription: nil) + let secondWindow = RateWindow( + usedPercent: 50, + windowMinutes: windowMinutes, + resetsAt: canonicalReset.addingTimeInterval(90), + resetDescription: nil) + + let breakdown = Self.syntheticBreakdown(endingAt: now, days: 35, dailyCredits: 10) + let first = await store.backfillCodexWeeklyFromUsageBreakdown( + breakdown, + referenceWindow: firstWindow, + now: now, + accountKey: nil) + let recordsAfterFirst = (try? Self.readHistoricalRecords(from: fileURL)) ?? [] + let second = await store.backfillCodexWeeklyFromUsageBreakdown( + breakdown, + referenceWindow: secondWindow, + now: now, + accountKey: nil) + let recordsAfterSecond = (try? Self.readHistoricalRecords(from: fileURL)) ?? [] + + #expect((first?.weeks.count ?? 0) >= 3) + #expect(first?.weeks.count == second?.weeks.count) + #expect(recordsAfterSecond.count == recordsAfterFirst.count) + #expect(Self.datasetCurveSignature(first) == Self.datasetCurveSignature(second)) + } + @Test func `history store backfill fills incomplete existing week`() async { let fileURL = Self.makeTempURL() @@ -358,7 +396,7 @@ struct HistoricalUsagePaceTests { let store = HistoricalUsageHistoryStore(fileURL: fileURL) let windowMinutes = 10080 let duration = TimeInterval(windowMinutes) * 60 - let canonicalReset = Date(timeIntervalSince1970: 1_770_000_000) + let canonicalReset = Self.normalizeReset(Date(timeIntervalSince1970: 1_770_000_000)) let windowStart = canonicalReset.addingTimeInterval(-duration) let samples: [(u: Double, used: Double)] = [ @@ -370,7 +408,7 @@ struct HistoricalUsagePaceTests { (0.98, 95), ] for (index, sample) in samples.enumerated() { - let jitteredReset = canonicalReset.addingTimeInterval(index.isMultiple(of: 2) ? -20 : 20) + let jitteredReset = canonicalReset.addingTimeInterval(index.isMultiple(of: 2) ? -100 : 100) _ = await store.recordCodexWeekly( window: RateWindow( usedPercent: sample.used, @@ -400,7 +438,7 @@ struct HistoricalUsagePaceTests { window: RateWindow( usedPercent: 35, windowMinutes: windowMinutes, - resetsAt: targetReset.addingTimeInterval(30), + resetsAt: targetReset.addingTimeInterval(120), resetDescription: nil), sampledAt: targetStart.addingTimeInterval(duration * 0.5), accountKey: nil) From 2f6a31c23e2c190723a88a6cd5bed3381e8b2db1 Mon Sep 17 00:00:00 2001 From: iam-brain <94809115+iam-brain@users.noreply.github.com> Date: Thu, 14 May 2026 04:08:24 -0400 Subject: [PATCH 16/27] Add Codex priority cost pricing Co-authored-by: Bryan Font --- CHANGELOG.md | 1 + .../Vendored/CostUsage/CostUsageCache.swift | 4 +- .../Vendored/CostUsage/CostUsagePricing.swift | 112 +++++++- .../CostUsageScanner+CodexPriority.swift | 171 ++++++++++++ .../Vendored/CostUsage/CostUsageScanner.swift | 171 +++++++++++- Tests/CodexBarTests/CostUsageCacheTests.swift | 2 +- .../CodexBarTests/CostUsagePricingTests.swift | 143 ++++++++++ .../CostUsageScannerCodexPriorityTests.swift | 179 +++++++++++++ .../CostUsageScannerPriorityTests.swift | 252 ++++++++++++++++++ .../CodexBarTests/CostUsageScannerTests.swift | 85 ++++++ 10 files changed, 1102 insertions(+), 18 deletions(-) create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift create mode 100644 Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift create mode 100644 Tests/CodexBarTests/CostUsageScannerPriorityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bc6ad0dcb..592e518962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.26 — Unreleased ### Added +- Codex: add tiered long-context and Fast/Priority pricing to local cost history using local app-server priority traces (#917). Thanks @iam-brain! - Localization: add Simplified Chinese translations for Claude peak-hour labels (#921). Thanks @whtis! - Display: add a setting to hide quota-warning tick marks on usage bars while keeping quota warning notifications active (#918, fixes #916). Thanks @ThiagoCAltoe! - Menu: add an opt-in setting for provider changelog links, starting with Codex, Claude Code, and Gemini CLI (#929, fixes #660). Thanks @ThiagoCAltoe! diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 73ccb4c594..f4885ed7a0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -4,7 +4,7 @@ enum CostUsageCacheIO { private static func artifactVersion(for provider: UsageProvider) -> Int { switch provider { case .codex: - 4 + 6 case .claude, .vertexai: 2 default: @@ -76,8 +76,10 @@ struct CostUsageFileUsage: Codable { var parsedBytes: Int64? var lastModel: String? var lastTotals: CostUsageCodexTotals? + var lastCodexTurnID: String? var sessionId: String? var forkedFromId: String? + var codexRows: [CostUsageScanner.CodexUsageRow]? var claudeRows: [CostUsageScanner.ClaudeUsageRow]? } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index e40ba08cb4..f8dbc147fa 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -1,11 +1,47 @@ import Foundation enum CostUsagePricing { + private static let codexPriorityInputTokenLimit = 272_000 + struct CodexPricing { let inputCostPerToken: Double let outputCostPerToken: Double let cacheReadInputCostPerToken: Double? let displayLabel: String? + + let thresholdTokens: Int? + let inputCostPerTokenAboveThreshold: Double? + let outputCostPerTokenAboveThreshold: Double? + let cacheReadInputCostPerTokenAboveThreshold: Double? + let priorityInputCostPerToken: Double? + let priorityOutputCostPerToken: Double? + let priorityCacheReadInputCostPerToken: Double? + + init( + inputCostPerToken: Double, + outputCostPerToken: Double, + cacheReadInputCostPerToken: Double?, + displayLabel: String?, + thresholdTokens: Int? = nil, + inputCostPerTokenAboveThreshold: Double? = nil, + outputCostPerTokenAboveThreshold: Double? = nil, + cacheReadInputCostPerTokenAboveThreshold: Double? = nil, + priorityInputCostPerToken: Double? = nil, + priorityOutputCostPerToken: Double? = nil, + priorityCacheReadInputCostPerToken: Double? = nil) + { + self.inputCostPerToken = inputCostPerToken + self.outputCostPerToken = outputCostPerToken + self.cacheReadInputCostPerToken = cacheReadInputCostPerToken + self.displayLabel = displayLabel + self.thresholdTokens = thresholdTokens + self.inputCostPerTokenAboveThreshold = inputCostPerTokenAboveThreshold + self.outputCostPerTokenAboveThreshold = outputCostPerTokenAboveThreshold + self.cacheReadInputCostPerTokenAboveThreshold = cacheReadInputCostPerTokenAboveThreshold + self.priorityInputCostPerToken = priorityInputCostPerToken + self.priorityOutputCostPerToken = priorityOutputCostPerToken + self.priorityCacheReadInputCostPerToken = priorityCacheReadInputCostPerToken + } } struct ClaudePricing { @@ -96,12 +132,22 @@ enum CostUsagePricing { inputCostPerToken: 2.5e-6, outputCostPerToken: 1.5e-5, cacheReadInputCostPerToken: 2.5e-7, - displayLabel: nil), + displayLabel: nil, + thresholdTokens: 272_000, + inputCostPerTokenAboveThreshold: 5e-6, + outputCostPerTokenAboveThreshold: 2.25e-5, + cacheReadInputCostPerTokenAboveThreshold: 5e-7, + priorityInputCostPerToken: 5e-6, + priorityOutputCostPerToken: 3e-5, + priorityCacheReadInputCostPerToken: 5e-7), "gpt-5.4-mini": CodexPricing( inputCostPerToken: 7.5e-7, outputCostPerToken: 4.5e-6, cacheReadInputCostPerToken: 7.5e-8, - displayLabel: nil), + displayLabel: nil, + priorityInputCostPerToken: 1.5e-6, + priorityOutputCostPerToken: 9e-6, + priorityCacheReadInputCostPerToken: 1.5e-7), "gpt-5.4-nano": CodexPricing( inputCostPerToken: 2e-7, outputCostPerToken: 1.25e-6, @@ -116,7 +162,14 @@ enum CostUsagePricing { inputCostPerToken: 5e-6, outputCostPerToken: 3e-5, cacheReadInputCostPerToken: 5e-7, - displayLabel: nil), + displayLabel: nil, + thresholdTokens: 272_000, + inputCostPerTokenAboveThreshold: 1e-5, + outputCostPerTokenAboveThreshold: 4.5e-5, + cacheReadInputCostPerTokenAboveThreshold: 1e-6, + priorityInputCostPerToken: 1.25e-5, + priorityOutputCostPerToken: 7.5e-5, + priorityCacheReadInputCostPerToken: 1.25e-6), "gpt-5.5-pro": CodexPricing( inputCostPerToken: 3e-5, outputCostPerToken: 1.8e-4, @@ -330,6 +383,7 @@ enum CostUsagePricing { { return self.codexCostUSD( pricing: lookup.pricing, + thresholdTokens: self.codex[key]?.thresholdTokens, inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, outputTokens: outputTokens) @@ -343,6 +397,33 @@ enum CostUsagePricing { outputTokens: outputTokens) } + static func codexPriorityCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int = 0, + outputTokens: Int) -> Double? + { + let key = self.normalizeCodexModel(model) + guard let pricing = self.codex[key], + let priorityInputCostPerToken = pricing.priorityInputCostPerToken, + let priorityOutputCostPerToken = pricing.priorityOutputCostPerToken + else { return nil } + if max(0, inputTokens) > self.codexPriorityInputTokenLimit { + return nil + } + + let priorityPricing = CodexPricing( + inputCostPerToken: priorityInputCostPerToken, + outputCostPerToken: priorityOutputCostPerToken, + cacheReadInputCostPerToken: pricing.priorityCacheReadInputCostPerToken, + displayLabel: nil) + return self.codexCostUSD( + pricing: priorityPricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) + } + private static func codexCostUSD( pricing: CodexPricing, inputTokens: Int, @@ -352,13 +433,26 @@ enum CostUsagePricing { let cached = min(max(0, cachedInputTokens), max(0, inputTokens)) let nonCached = max(0, inputTokens - cached) let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken - return Double(nonCached) * pricing.inputCostPerToken - + Double(cached) * cachedRate - + Double(max(0, outputTokens)) * pricing.outputCostPerToken + + let usesLongContextRates = pricing.thresholdTokens.map { max(0, inputTokens) > $0 } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cachedInputRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? cachedRate + : cachedRate + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + + return (Double(nonCached) * inputRate) + + (Double(cached) * cachedInputRate) + + (Double(max(0, outputTokens)) * outputRate) } private static func codexCostUSD( pricing: ModelsDevPricingInfo, + thresholdTokens: Int? = nil, inputTokens: Int, cachedInputTokens: Int, outputTokens: Int) -> Double @@ -368,7 +462,11 @@ enum CostUsagePricing { inputCostPerToken: pricing.inputCostPerToken, outputCostPerToken: pricing.outputCostPerToken, cacheReadInputCostPerToken: pricing.cacheReadInputCostPerToken, - displayLabel: nil), + displayLabel: nil, + thresholdTokens: thresholdTokens ?? pricing.thresholdTokens, + inputCostPerTokenAboveThreshold: pricing.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: pricing.outputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: pricing.cacheReadInputCostPerTokenAboveThreshold), inputTokens: inputTokens, cachedInputTokens: cachedInputTokens, outputTokens: outputTokens) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift new file mode 100644 index 0000000000..d1eaceac24 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift @@ -0,0 +1,171 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#endif + +extension CostUsageScanner { + struct CodexPriorityTurnMetadata: Codable, Equatable, Sendable { + var threadID: String? + var turnID: String + var model: String? + var timestamp: String? + } + + private static let requestMarker = "websocket request:" + + static func defaultCodexPriorityDatabaseURL() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex", isDirectory: true) + .appendingPathComponent("logs_2.sqlite", isDirectory: false) + } + + static func codexPriorityTurns( + databaseURL: URL? = nil, + sinceDayKey: String? = nil, + untilDayKey: String? = nil) -> [String: CodexPriorityTurnMetadata] + { + let url = databaseURL ?? self.defaultCodexPriorityDatabaseURL() + guard FileManager.default.fileExists(atPath: url.path) else { return [:] } + + #if canImport(SQLite3) + var db: OpaquePointer? + guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(db) + return [:] + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let query = if sinceDayKey != nil || untilDayKey != nil { + """ + select ts, feedback_log_body + from logs + where ts >= ? and ts < ? and feedback_log_body like '%websocket request:%' + """ + } else { + """ + select ts, feedback_log_body + from logs + where feedback_log_body like '%websocket request:%' + """ + } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return [:] } + defer { sqlite3_finalize(stmt) } + + if sinceDayKey != nil || untilDayKey != nil { + let start = self.epochSeconds(forDayKey: sinceDayKey ?? "0000-01-01") ?? 0 + let end = self.epochSeconds(forDayKey: self.nextDayKey(after: untilDayKey ?? "9999-12-30")) + ?? Int64.max + sqlite3_bind_int64(stmt, 1, start) + sqlite3_bind_int64(stmt, 2, end) + } + + var turns: [String: CodexPriorityTurnMetadata] = [:] + while sqlite3_step(stmt) == SQLITE_ROW { + let timestamp = self.timestamp(stmt: stmt, index: 0) + guard self.timestamp(timestamp, isInRangeSince: sinceDayKey, until: untilDayKey), + let body = self.text(stmt: stmt, index: 1), + let parsed = self.parseCodexPriorityTraceRow(timestamp: timestamp, body: body) + else { continue } + turns[parsed.turnID] = parsed + } + return turns + #else + return [:] + #endif + } + + static func parseCodexPriorityTraceRow(timestamp: String?, body: String) -> CodexPriorityTurnMetadata? { + guard let markerRange = body.range(of: self.requestMarker) else { return nil } + let prefix = String(body[.. String? { + guard let range = text.range(of: "\(name)=") else { return nil } + let tail = text[range.upperBound...] + let value = tail.prefix { char in + !char.isWhitespace && char != "," && char != "]" && char != ")" + } + return value.isEmpty ? nil : String(value) + } + + #if canImport(SQLite3) + private static func text(stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let cString = sqlite3_column_text(stmt, index) + else { return nil } + return String(cString: cString) + } + + private static func timestamp(stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil } + if sqlite3_column_type(stmt, index) == SQLITE_INTEGER { + return String(sqlite3_column_int64(stmt, index)) + } + return self.text(stmt: stmt, index: index) + } + #endif + + private static func timestamp(_ timestamp: String?, isInRangeSince since: String?, until: String?) -> Bool { + guard since != nil || until != nil else { return true } + guard let dayKey = self.dayKey(fromTimestamp: timestamp) else { return false } + if let since, dayKey < since { return false } + if let until, dayKey > until { return false } + return true + } + + private static func dayKey(fromTimestamp timestamp: String?) -> String? { + guard let timestamp else { return nil } + if let seconds = Int64(timestamp) { + return CostUsageScanner.CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(seconds))) + } + let dayKey = timestamp.prefix(10) + return dayKey.count == 10 ? String(dayKey) : nil + } + + private static func nextDayKey(after dayKey: String) -> String { + guard let date = self.localDate(forDayKey: dayKey), + let next = Calendar.current.date(byAdding: .day, value: 1, to: date) + else { return dayKey } + return CostUsageScanner.CostUsageDayRange.dayKey(from: next) + } + + private static func epochSeconds(forDayKey dayKey: String) -> Int64? { + guard let date = self.localDate(forDayKey: dayKey) else { return nil } + return Int64(date.timeIntervalSince1970) + } + + private static func localDate(forDayKey dayKey: String) -> Date? { + let parts = dayKey.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) + else { return nil } + var components = DateComponents() + components.calendar = Calendar.current + components.year = year + components.month = month + components.day = day + return components.date + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 9789165d88..f18183920b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -14,6 +14,7 @@ enum CostUsageScanner { var codexSessionsRoot: URL? var claudeProjectsRoots: [URL]? var cacheRoot: URL? + var codexTraceDatabaseURL: URL? var refreshMinIntervalSeconds: TimeInterval = 60 var claudeLogProviderFilter: ClaudeLogProviderFilter = .all /// Force a full rescan, ignoring per-file cache and incremental offsets. @@ -23,12 +24,14 @@ enum CostUsageScanner { codexSessionsRoot: URL? = nil, claudeProjectsRoots: [URL]? = nil, cacheRoot: URL? = nil, + codexTraceDatabaseURL: URL? = nil, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, forceRescan: Bool = false) { self.codexSessionsRoot = codexSessionsRoot self.claudeProjectsRoots = claudeProjectsRoots self.cacheRoot = cacheRoot + self.codexTraceDatabaseURL = codexTraceDatabaseURL self.claudeLogProviderFilter = claudeLogProviderFilter self.forceRescan = forceRescan } @@ -39,8 +42,19 @@ enum CostUsageScanner { let parsedBytes: Int64 let lastModel: String? let lastTotals: CostUsageCodexTotals? + let lastCodexTurnID: String? let sessionId: String? let forkedFromId: String? + let rows: [CodexUsageRow] + } + + struct CodexUsageRow: Codable, Equatable { + let day: String + let model: String + let turnID: String? + let input: Int + let cached: Int + let output: Int } private struct CodexScanState { @@ -648,6 +662,7 @@ enum CostUsageScanner { startOffset: Int64 = 0, initialModel: String? = nil, initialTotals: CostUsageCodexTotals? = nil, + initialCodexTurnID: String? = nil, inheritedTotalsResolver: ((String, String) -> CostUsageCodexTotals?)? = nil) -> CodexParseResult { var currentModel = initialModel @@ -656,8 +671,10 @@ enum CostUsageScanner { var forkedFromId: String? var inheritedTotals: CostUsageCodexTotals? var remainingInheritedTotals: CostUsageCodexTotals? + var currentTurnID = initialCodexTurnID var days: [String: [String: [Int]]] = [:] + var rows: [CodexUsageRow] = [] func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) @@ -707,7 +724,10 @@ enum CostUsageScanner { || line.bytes.containsAscii(#""type":"session_meta""#) else { return } - if line.bytes.containsAscii(#""type":"event_msg""#), !line.bytes.containsAscii(#""token_count""#) { + if line.bytes.containsAscii(#""type":"event_msg""#), + !line.bytes.containsAscii(#""token_count""#), + !line.bytes.containsAscii(#""task_started""#) + { return } @@ -762,6 +782,10 @@ enum CostUsageScanner { guard type == "event_msg" else { return } guard let payload = obj["payload"] as? [String: Any] else { return } + if (payload["type"] as? String) == "task_started" { + currentTurnID = Self.codexTurnID(from: payload) + return + } guard (payload["type"] as? String) == "token_count" else { return } let info = payload["info"] as? [String: Any] @@ -846,7 +870,26 @@ enum CostUsageScanner { if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } let cachedClamp = min(deltaCached, deltaInput) - add(dayKey: dayKey, model: model, input: deltaInput, cached: cachedClamp, output: deltaOutput) + let normModel = CostUsagePricing.normalizeCodexModel(model) + add( + dayKey: dayKey, + model: normModel, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput) + if CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) + { + rows.append(CodexUsageRow( + day: dayKey, + model: normModel, + turnID: Self.codexTurnID(from: payload) ?? currentTurnID, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput)) + } } }) } catch { @@ -861,8 +904,20 @@ enum CostUsageScanner { parsedBytes: parsedBytes, lastModel: currentModel, lastTotals: previousTotals, + lastCodexTurnID: currentTurnID, sessionId: sessionId, - forkedFromId: forkedFromId) + forkedFromId: forkedFromId, + rows: rows) + } + + private static func codexTurnID(from payload: [String: Any]) -> String? { + if let turnID = payload["turn_id"] as? String ?? payload["turnId"] as? String ?? payload["id"] as? String { + return turnID + } + if let info = payload["info"] as? [String: Any] { + return info["turn_id"] as? String ?? info["turnId"] as? String ?? info["id"] as? String + } + return nil } private static func scanCodexFile( @@ -923,7 +978,8 @@ enum CostUsageScanner { range: range, startOffset: startOffset, initialModel: cached.lastModel, - initialTotals: cached.lastTotals) + initialTotals: cached.lastTotals, + initialCodexTurnID: cached.lastCodexTurnID) let sessionId = delta.sessionId ?? cached.sessionId if let sessionId, state.seenSessionIds.contains(sessionId) { dropCachedFile(cached) @@ -943,8 +999,10 @@ enum CostUsageScanner { parsedBytes: delta.parsedBytes, lastModel: delta.lastModel, lastTotals: delta.lastTotals, + lastCodexTurnID: delta.lastCodexTurnID, sessionId: sessionId, - forkedFromId: delta.forkedFromId ?? cached.forkedFromId) + forkedFromId: delta.forkedFromId ?? cached.forkedFromId, + codexRows: (cached.codexRows ?? []) + delta.rows) if let sessionId { state.seenSessionIds.insert(sessionId) resources.fileIndex.remember(fileURL: fileURL, sessionId: sessionId) @@ -977,8 +1035,10 @@ enum CostUsageScanner { parsedBytes: parsed.parsedBytes, lastModel: parsed.lastModel, lastTotals: parsed.lastTotals, + lastCodexTurnID: parsed.lastCodexTurnID, sessionId: sessionId, - forkedFromId: parsed.forkedFromId) + forkedFromId: parsed.forkedFromId, + codexRows: parsed.rows) cache.files[path] = usage Self.applyFileDays(cache: &cache, fileDays: usage.days, sign: 1) if let sessionId { @@ -1080,18 +1140,24 @@ enum CostUsageScanner { } let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) + let priorityTurns = Self.codexPriorityTurns( + databaseURL: options.codexTraceDatabaseURL, + sinceDayKey: range.sinceKey, + untilDayKey: range.untilKey) return Self.buildCodexReportFromCache( cache: cache, range: range, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: options.cacheRoot) + modelsDevCacheRoot: options.cacheRoot, + priorityTurns: priorityTurns) } private static func buildCodexReportFromCache( cache: CostUsageCache, range: CostUsageDayRange, modelsDevCatalog: ModelsDevCatalog? = nil, - modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport + modelsDevCacheRoot: URL? = nil, + priorityTurns: [String: CodexPriorityTurnMetadata] = [:]) -> CostUsageDailyReport { var entries: [CostUsageDailyReport.Entry] = [] var totalInput = 0 @@ -1103,6 +1169,7 @@ enum CostUsageScanner { let dayKeys = cache.days.keys.sorted().filter { CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) } + let rowsByDayModel = self.codexRowsByDayModel(cache: cache, range: range) for day in dayKeys { guard let models = cache.days[day] else { continue } @@ -1125,13 +1192,29 @@ enum CostUsageScanner { dayInput += input dayOutput += output - let cost = CostUsagePricing.codexCostUSD( + let rows = rowsByDayModel[day]?[model] + var cost = rows.flatMap { + self.codexRowsCostUSD( + rows: $0, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } ?? CostUsagePricing.codexCostUSD( model: model, inputTokens: input, cachedInputTokens: cached, outputTokens: output, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) + if !priorityTurns.isEmpty, + let rows, + let surcharge = self.codexPrioritySurchargeUSD( + rows: rows, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + cost = (cost ?? 0) + surcharge + } breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, @@ -1176,6 +1259,72 @@ enum CostUsageScanner { return CostUsageDailyReport(data: entries, summary: summary) } + private static func codexRowsByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: [CodexUsageRow]]] + { + var rowsByDayModel: [String: [String: [CodexUsageRow]]] = [:] + for usage in cache.files.values { + for row in usage.codexRows ?? [] { + guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey) + else { continue } + rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) + } + } + return rowsByDayModel + } + + private static func codexRowsCostUSD( + rows: [CodexUsageRow], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + var total: Double = 0 + var seen = false + for row in rows { + guard let cost = CostUsagePricing.codexCostUSD( + model: row.model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { continue } + total += cost + seen = true + } + return seen ? total : nil + } + + private static func codexPrioritySurchargeUSD( + rows: [CodexUsageRow], + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + var total: Double = 0 + var seen = false + for row in rows { + guard let turnID = row.turnID, priorityTurns[turnID] != nil else { continue } + guard let baseCost = CostUsagePricing.codexCostUSD( + model: row.model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot), + let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: row.model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + else { continue } + total += max(priorityCost - baseCost, 0) + seen = true + } + return seen ? total : nil + } + // MARK: - Shared cache mutations static func makeFileUsage( @@ -1185,8 +1334,10 @@ enum CostUsageScanner { parsedBytes: Int64?, lastModel: String? = nil, lastTotals: CostUsageCodexTotals? = nil, + lastCodexTurnID: String? = nil, sessionId: String? = nil, forkedFromId: String? = nil, + codexRows: [CodexUsageRow]? = nil, claudeRows: [ClaudeUsageRow]? = nil) -> CostUsageFileUsage { CostUsageFileUsage( @@ -1196,8 +1347,10 @@ enum CostUsageScanner { parsedBytes: parsedBytes, lastModel: lastModel, lastTotals: lastTotals, + lastCodexTurnID: lastCodexTurnID, sessionId: sessionId, forkedFromId: forkedFromId, + codexRows: codexRows, claudeRows: claudeRows) } diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index fddb9bcf16..961c24ab7f 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -10,7 +10,7 @@ struct CostUsageCacheTests { let codexURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: root) let claudeURL = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) - #expect(codexURL.lastPathComponent == "codex-v4.json") + #expect(codexURL.lastPathComponent == "codex-v6.json") #expect(claudeURL.lastPathComponent == "claude-v2.json") } } diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index c62edd09fe..4d4ea170b1 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -68,6 +68,149 @@ struct CostUsagePricingTests { #expect(cost == expected) } + @Test + func `codex cost applies gpt54 and gpt55 long context rates to full session`() throws { + let root = try Self.cacheRoot() + let gpt54 = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + let gpt55 = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + + #expect(gpt54 == (272_001.0 * 5e-6) + (10.0 * 2.25e-5)) + #expect(gpt55 == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) + } + + @Test + func `codex cost keeps normal rates at long context input boundary`() throws { + let root = try Self.cacheRoot() + let gpt55 = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 272_000, + cachedInputTokens: 0, + outputTokens: 128_000, + modelsDevCacheRoot: root) + + #expect(gpt55 == (272_000.0 * 5e-6) + (128_000.0 * 3e-5)) + } + + @Test + func `codex cost applies long context rates to all cached and non cached input`() throws { + let root = try Self.cacheRoot() + let gpt55 = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 300_000, + cachedInputTokens: 200_000, + outputTokens: 10, + modelsDevCacheRoot: root) + + let cached = 200_000.0 * 1e-6 + let nonCached = 100_000.0 * 1e-5 + let output = 10.0 * 4.5e-5 + + #expect(gpt55 == cached + nonCached + output) + } + + @Test + func `codex priority cost applies model specific fast rates`() { + let gpt54 = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.4", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) + let gpt55 = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) + let gpt54Mini = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.4-mini", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) + + #expect(gpt54 == (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5)) + #expect(gpt55 == (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5)) + #expect(gpt54Mini == (80.0 * 1.5e-6) + (20.0 * 1.5e-7) + (10.0 * 9e-6)) + } + + @Test + func `codex priority cost is unavailable for long context requests`() { + let gpt55 = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10) + let gpt54Mini = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.4-mini", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10) + + #expect(gpt55 == nil) + #expect(gpt54Mini == nil) + } + + @Test + func `codex priority cost remains available at priority input boundary`() { + let gpt55 = CostUsagePricing.codexPriorityCostUSD( + model: "gpt-5.5", + inputTokens: 272_000, + cachedInputTokens: 0, + outputTokens: 10) + + #expect(gpt55 == (272_000.0 * 1.25e-5) + (10.0 * 7.5e-5)) + } + + @Test + func `codex models dev pricing uses codex long context threshold`() throws { + let root = try Self.seedModelsDevCache(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-5.5": { + "id": "gpt-5.5", + "cost": { + "input": 5, + "output": 30, + "cache_read": 0.5, + "context_over_200k": { + "input": 10, + "output": 45, + "cache_read": 1 + } + } + } + } + } + } + """) + + let atBoundary = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 272_000, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + let aboveBoundary = CostUsagePricing.codexCostUSD( + model: "gpt-5.5", + inputTokens: 272_001, + cachedInputTokens: 0, + outputTokens: 10, + modelsDevCacheRoot: root) + + #expect(atBoundary == (272_000.0 * 5e-6) + (10.0 * 3e-5)) + #expect(aboveBoundary == (272_001.0 * 1e-5) + (10.0 * 4.5e-5)) + } + @Test func `codex cost supports gpt55 pro bundled fallback`() throws { let root = try Self.cacheRoot() diff --git a/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift new file mode 100644 index 0000000000..ba4fd6a333 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerCodexPriorityTests.swift @@ -0,0 +1,179 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +import Testing +@testable import CodexBarCore + +struct CostUsageScannerCodexPriorityTests { + @Test + func `parses priority turn metadata without exposing request body`() { + let body = "INFO thread_id=11111111-1111-1111-1111-111111111111 " + + "turn.id=22222222-2222-2222-2222-222222222222 websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority","instructions":"secret prompt"}"# + + let parsed = CostUsageScanner.parseCodexPriorityTraceRow(timestamp: "2026-05-10T12:00:00Z", body: body) + + #expect(parsed?.threadID == "11111111-1111-1111-1111-111111111111") + #expect(parsed?.turnID == "22222222-2222-2222-2222-222222222222") + #expect(parsed?.model == "gpt-5.5") + #expect(parsed?.timestamp == "2026-05-10T12:00:00Z") + } + + @Test + func `ignores non priority malformed and non response request rows`() { + let prefix = "thread_id=thread turn.id=turn websocket request: " + + #expect(CostUsageScanner.parseCodexPriorityTraceRow( + timestamp: nil, + body: prefix + #"{"type":"session.update","service_tier":"priority"}"#) == nil) + #expect(CostUsageScanner.parseCodexPriorityTraceRow( + timestamp: nil, + body: prefix + #"{"type":"response.create"}"#) == nil) + #expect(CostUsageScanner.parseCodexPriorityTraceRow( + timestamp: nil, + body: prefix + #"{"type":"response.create","service_tier":"default"}"#) == nil) + #expect(CostUsageScanner.parseCodexPriorityTraceRow( + timestamp: nil, + body: prefix + #"{"#) == nil) + } + + @Test + func `reads priority turns from sqlite logs table`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:00:00Z", + body: "thread_id=thread-a turn.id=turn-a websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority","input":"private"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: "2026-05-10T12:01:00Z", + body: """ + thread_id=thread-b turn.id=turn-b websocket request: {"type":"response.create","model":"gpt-5.5"} + """) + + let turns = CostUsageScanner.codexPriorityTurns(databaseURL: dbURL) + + #expect(turns.keys.sorted() == ["turn-a"]) + #expect(turns["turn-a"]?.threadID == "thread-a") + #expect(turns["turn-a"]?.model == "gpt-5.5") + } + + @Test + func `sqlite scan only returns priority turns in requested day range`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let previousDay = try #require(Calendar.current.date(byAdding: .day, value: -1, to: day)) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: env.isoString(for: previousDay), + body: "thread_id=thread-old turn.id=turn-old websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + timestamp: env.isoString(for: day), + body: "thread_id=thread-new turn.id=turn-new websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + + let turns = CostUsageScanner.codexPriorityTurns( + databaseURL: dbURL, + sinceDayKey: dayKey, + untilDayKey: dayKey) + + #expect(turns.keys.sorted() == ["turn-new"]) + } + + @Test + func `sqlite scan uses local day boundaries for integer timestamps`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try Self.createTestLogsDatabase(at: dbURL) + + var components = DateComponents() + components.calendar = Calendar.current + components.year = 2026 + components.month = 5 + components.day = 10 + let dayStart = try #require(components.date) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: dayStart) + let previousSecond = try #require(Calendar.current.date(byAdding: .second, value: -1, to: dayStart)) + let nextSecond = try #require(Calendar.current.date(byAdding: .second, value: 1, to: dayStart)) + + try Self.insertTestLog( + dbURL: dbURL, + epochSeconds: Int64(previousSecond.timeIntervalSince1970), + body: "thread_id=thread-before turn.id=turn-before websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + try Self.insertTestLog( + dbURL: dbURL, + epochSeconds: Int64(nextSecond.timeIntervalSince1970), + body: "thread_id=thread-after turn.id=turn-after websocket request: " + + #"{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}"#) + + let turns = CostUsageScanner.codexPriorityTurns( + databaseURL: dbURL, + sinceDayKey: dayKey, + untilDayKey: dayKey) + + #expect(turns.keys.sorted() == ["turn-after"]) + } + + static func createTestLogsDatabase(at dbURL: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try self.exec(db, "create table logs (ts integer not null, feedback_log_body text)") + } + + static func insertTestLog(dbURL: URL, timestamp: String, body: String) throws { + try self.insertTestLog(dbURL: dbURL, epochSeconds: self.epochSeconds(timestamp), body: body) + } + + static func insertTestLog(dbURL: URL, epochSeconds: Int64, body: String) throws { + var db: OpaquePointer? + guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "insert into logs (ts, feedback_log_body) values (?, ?)", -1, &stmt, nil) + == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_int64(stmt, 1, epochSeconds) + sqlite3_bind_text(stmt, 2, body, -1, transient) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func epochSeconds(_ timestamp: String) -> Int64 { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: timestamp) else { return 0 } + return Int64(date.timeIntervalSince1970) + } + + private static func exec(_ db: OpaquePointer?, _ sql: String) throws { + var message: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &message) == SQLITE_OK else { + sqlite3_free(message) + throw SQLiteTestError.exec + } + } + + private enum SQLiteTestError: Error { + case open + case prepare + case step + case exec + } +} +#endif diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift new file mode 100644 index 0000000000..e52a3dd478 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -0,0 +1,252 @@ +import Foundation +#if canImport(SQLite3) +import Testing +@testable import CodexBarCore + +struct CostUsageScannerPriorityTests { + @Test + func `codex daily report applies gpt55 priority rates`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let iso3 = env.isoString(for: day.addingTimeInterval(3)) + + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], + self.tokenCount(timestamp: iso2, input: 100, cached: 20, output: 10), + ["type": "event_msg", "timestamp": iso3, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso3, input: 100, cached: 20, output: 10), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso3) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let standardCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + let priorityCost = (80.0 * 1.25e-5) + (20.0 * 1.25e-6) + (10.0 * 7.5e-5) + + #expect(report.summary?.totalCostUSD == standardCost + priorityCost) + } + + @Test + func `codex daily report applies gpt54 priority rates`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let iso3 = env.isoString(for: day.addingTimeInterval(3)) + + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.4"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], + self.tokenCount(timestamp: iso2, input: 100, cached: 20, output: 10), + ["type": "event_msg", "timestamp": iso3, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso3, input: 100, cached: 20, output: 10), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso3, model: "gpt-5.4") + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let standardCost = (80.0 * 2.5e-6) + (20.0 * 2.5e-7) + (10.0 * 1.5e-5) + let priorityCost = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + + #expect(report.summary?.totalCostUSD == standardCost + priorityCost) + } + + @Test + func `codex daily report keeps base cost when sqlite metadata is missing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso1, input: 100, cached: 20, output: 10), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let expected = (80.0 * 5e-6) + (20.0 * 5e-7) + (10.0 * 3e-5) + + #expect(report.summary?.totalCostUSD == expected) + } + + @Test + func `codex pricing skips priority surcharge for long context rows`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let iso3 = env.isoString(for: day.addingTimeInterval(3)) + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], + self.tokenCount(timestamp: iso1, input: 272_001, cached: 0, output: 10), + ["type": "event_msg", "timestamp": iso2, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.tokenCount(timestamp: iso2, input: 300_000, cached: 0, output: 5), + self.tokenCount(timestamp: iso3, input: 100_001, cached: 0, output: 5), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso2) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let standardTurnBase = (272_001.0 * 1e-5) + (10.0 * 4.5e-5) + let standardFirstRow = (300_000.0 * 1e-5) + (5.0 * 4.5e-5) + let prioritySecondRow = (100_001.0 * 1.25e-5) + (5.0 * 7.5e-5) + + let expected = standardTurnBase + standardFirstRow + prioritySecondRow + #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) + } + + @Test + func `codex cumulative totals do not trigger long context pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let iso3 = env.isoString(for: day.addingTimeInterval(3)) + let entries: [[String: Any]] = [ + ["type": "turn_context", "timestamp": iso0, "payload": ["model": "gpt-5.5"]], + ["type": "event_msg", "timestamp": iso1, "payload": ["type": "task_started", "turn_id": "standard-turn"]], + self.totalTokenCount(timestamp: iso1, input: 120_000, cached: 60000, output: 100), + self.totalTokenCount(timestamp: iso2, input: 240_000, cached: 120_000, output: 200), + ["type": "event_msg", "timestamp": iso3, "payload": ["type": "task_started", "turn_id": "priority-turn"]], + self.totalTokenCount(timestamp: iso3, input: 360_000, cached: 180_000, output: 300), + ] + _ = try env.writeCodexSessionFile(day: day, filename: "session.jsonl", contents: env.jsonl(entries)) + + let dbURL = env.root.appendingPathComponent("logs_2.sqlite") + try CostUsageScannerCodexPriorityTests.createTestLogsDatabase(at: dbURL) + try self.insertPriorityTrace(dbURL: dbURL, timestamp: iso3) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: dbURL) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let standardRow = (Double(60000) * 5e-6) + (Double(60000) * 5e-7) + (Double(100) * 3e-5) + let priorityRow = (Double(60000) * 1.25e-5) + (Double(60000) * 1.25e-6) + (Double(100) * 7.5e-5) + let expected = standardRow + standardRow + priorityRow + + #expect(abs((report.summary?.totalCostUSD ?? 0) - expected) < 0.000_000_001) + } + + private func tokenCount(timestamp: String, input: Int, cached: Int, output: Int) -> [String: Any] { + [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": input, + "cached_input_tokens": cached, + "output_tokens": output, + ], + ], + ], + ] + } + + private func totalTokenCount(timestamp: String, input: Int, cached: Int, output: Int) -> [String: Any] { + [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": input, + "cached_input_tokens": cached, + "output_tokens": output, + ], + ], + ], + ] + } + + private func insertPriorityTrace(dbURL: URL, timestamp: String, model: String = "gpt-5.5") throws { + try CostUsageScannerCodexPriorityTests.insertTestLog( + dbURL: dbURL, + timestamp: timestamp, + body: "thread_id=thread turn.id=priority-turn websocket request: " + + #"{"type":"response.create","model":""# + model + #"","service_tier":"priority"}"#) + } +} +#endif diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index c2eba4fe42..2b9dd7abe2 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -415,6 +415,91 @@ struct CostUsageScannerTests { #expect(packed[2] == 6) } + @Test + func `codex incremental parsing keeps current turn id`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let iso3 = env.isoString(for: day.addingTimeInterval(3)) + + let model = "openai/gpt-5.5" + let turnID = "22222222-2222-2222-2222-222222222222" + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": [ + "model": model, + ], + ] + let taskStarted: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "task_started", + "id": turnID, + ], + ] + let firstTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso2, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "priority-session.jsonl", + contents: env.jsonl([turnContext, taskStarted, firstTokenCount])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + + let first = CostUsageScanner.parseCodexFile(fileURL: fileURL, range: range) + #expect(first.lastCodexTurnID == turnID) + #expect(first.rows.map(\.turnID) == [turnID]) + + let secondTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso3, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 160, + "cached_input_tokens": 40, + "output_tokens": 16, + ], + ], + ], + ] + try env.jsonl([turnContext, taskStarted, firstTokenCount, secondTokenCount]) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let delta = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: range, + startOffset: first.parsedBytes, + initialModel: first.lastModel, + initialTotals: first.lastTotals, + initialCodexTurnID: first.lastCodexTurnID) + + #expect(delta.lastCodexTurnID == turnID) + #expect(delta.rows.map(\.turnID) == [turnID]) + #expect(delta.rows.first?.input == 60) + #expect(delta.rows.first?.cached == 20) + #expect(delta.rows.first?.output == 6) + } + @Test func `claude incremental parsing reads appended lines only`() throws { let env = try CostUsageTestEnvironment() From 9d7a24e562ba752afff710430522e51e7ac48add Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 08:36:39 +0100 Subject: [PATCH 17/27] fix: parse Claude web spend limits --- CHANGELOG.md | 2 +- .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 46 +++++++++++++++++-- .../ClaudeWebEnterpriseUsageTests.swift | 24 ++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 592e518962..6fdf9782c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ - Codex: coalesce historical pace reset timestamps into 5-minute buckets so dashboard and live reset jitter do not duplicate weekly history windows (#901). Thanks @zhulijin1991! - Settings: let stale managed Codex account records be removed even when their stored home path is outside CodexBar's managed-home directory, and keep CLI known-owner tests from writing fixtures into the live app store. - Settings: apply the selected app language from packaged SwiftPM resources instead of falling back to English when the `.lproj` directory casing differs (#908). -- Claude: handle Enterprise/organization web accounts whose session quota window is null and expose an optional Org ID field for Claude token accounts (#941, fixes #940). Thanks @clintandrewhall! +- Claude: handle Enterprise/organization web accounts whose session quota window is null, parse inline spend-limit usage, and expose an optional Org ID field for Claude token accounts (#941, fixes #940). Thanks @clintandrewhall! - Codex: route menu account refreshes through the resolved live-vs-managed account source so matched accounts keep using the stable `CODEX_HOME` (#932, fixes #931). Thanks @ThiagoCAltoe! - ChatGPT credits: restrict purchase links to real HTTPS `chatgpt.com` settings/usage/billing/credits paths and drop query/fragment data (#903). Thanks @ThiagoCAltoe! - z.ai: show the MCP quota bucket as monthly instead of a misleading 1-minute window (#904). Thanks @ThiagoCAltoe! diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index d6a74c7e9a..84396e11bf 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -517,6 +517,7 @@ public enum ClaudeWebAPIFetcher { if let sourceKey = extraRateParse.sourceKeys["claude-routines"] { logger?("Usage API extra window key matched: routines=\(sourceKey)") } + let extraUsageCost = Self.parseExtraUsageCost(from: json["extra_usage"]) return WebUsageData( sessionPercentUsed: resolvedSessionPercent, @@ -525,7 +526,7 @@ public enum ClaudeWebAPIFetcher { weeklyResetsAt: weeklyResets, opusPercentUsed: opusPercent, extraRateWindows: extraRateParse.windows, - extraUsageCost: nil, + extraUsageCost: extraUsageCost, accountOrganization: nil, accountEmail: nil, loginMethod: nil) @@ -543,6 +544,19 @@ public enum ClaudeWebAPIFetcher { // MARK: - Extra usage cost (Claude "Extra") + private static func parseExtraUsageCost(from value: Any?) -> ProviderCostSnapshot? { + guard let extraUsage = value as? [String: Any] else { return nil } + guard let used = Self.doubleValue(extraUsage["used_credits"]), + let limit = Self.doubleValue(extraUsage["monthly_limit"] ?? extraUsage["monthly_credit_limit"]), + limit > 0 else { return nil } + let currency = (extraUsage["currency"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let currencyCode = currency?.isEmpty == false ? currency ?? "USD" : "USD" + return Self.makeExtraUsageCost( + usedCredits: used, + monthlyCreditLimit: limit, + currencyCode: currencyCode) + } + private struct OverageSpendLimitResponse: Decodable { let monthlyCreditLimit: Double? let currency: String? @@ -589,18 +603,42 @@ public enum ClaudeWebAPIFetcher { let currency = decoded.currency, !currency.isEmpty else { return nil } - let usedAmount = used / 100.0 - let limitAmount = limit / 100.0 + return Self.makeExtraUsageCost( + usedCredits: used, + monthlyCreditLimit: limit, + currencyCode: currency) + } + + private static func makeExtraUsageCost( + usedCredits: Double, + monthlyCreditLimit: Double, + currencyCode: String) -> ProviderCostSnapshot + { + let usedAmount = usedCredits / 100.0 + let limitAmount = monthlyCreditLimit / 100.0 return ProviderCostSnapshot( used: usedAmount, limit: limitAmount, - currencyCode: currency, + currencyCode: currencyCode, period: "Monthly", resetsAt: nil, updatedAt: Date()) } + private static func doubleValue(_ value: Any?) -> Double? { + switch value { + case let int as Int: + Double(int) + case let double as Double: + double + case let string as String: + Double(string) + default: + nil + } + } + #if DEBUG // MARK: - Test hooks (DEBUG-only) diff --git a/Tests/CodexBarTests/ClaudeWebEnterpriseUsageTests.swift b/Tests/CodexBarTests/ClaudeWebEnterpriseUsageTests.swift index ee81a881b7..b11ffa6018 100644 --- a/Tests/CodexBarTests/ClaudeWebEnterpriseUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeWebEnterpriseUsageTests.swift @@ -16,4 +16,28 @@ struct ClaudeWebEnterpriseUsageTests { #expect(parsed.sessionPercentUsed == 0) #expect(parsed.weeklyPercentUsed == 42) } + + @Test + func `parses enterprise credit spend from usage response`() throws { + let json = """ + { + "five_hour": null, + "seven_day": null, + "extra_usage": { + "monthly_limit": 100000, + "used_credits": 4132 + } + } + """ + let data = Data(json.utf8) + let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + + #expect(parsed.sessionPercentUsed == 0) + #expect(parsed.sessionResetsAt == nil) + #expect(parsed.weeklyPercentUsed == nil) + #expect(parsed.extraUsageCost?.used == 41.32) + #expect(parsed.extraUsageCost?.limit == 1000) + #expect(parsed.extraUsageCost?.currencyCode == "USD") + #expect(parsed.extraUsageCost?.period == "Monthly") + } } From 24d67e5f2e1f974e3beb44d57270e6535e425d26 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 08:46:21 +0100 Subject: [PATCH 18/27] fix: refresh Gemini credentials without access token --- CHANGELOG.md | 1 + .../Providers/Gemini/GeminiStatusProbe.swift | 31 ++++---- .../GeminiStatusProbeAPITests.swift | 71 +++++++++++++++++++ .../CodexBarTests/GeminiTestEnvironment.swift | 9 ++- 4 files changed, 97 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fdf9782c3..c3643d0039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Codex: keep session and weekly quota-warning marker thresholds independent so usage bars do not duplicate marker lines (#938, fixes #927). Thanks @iam-brain! - Menu: avoid rebuilding visible menus during background open-menu refreshes so hover submenus stay responsive (#923, fixes #909). Thanks @AmrMohamad! - Codex: coalesce historical pace reset timestamps into 5-minute buckets so dashboard and live reset jitter do not duplicate weekly history windows (#901). Thanks @zhulijin1991! +- Gemini: refresh OAuth credentials when the CLI has a refresh token but no cached access token instead of reporting "not logged in" after authentication (#915). - Settings: let stale managed Codex account records be removed even when their stored home path is outside CodexBar's managed-home directory, and keep CLI known-owner tests from writing fixtures into the live app store. - Settings: apply the selected app language from packaged SwiftPM resources instead of falling back to English when the `.lproj` directory casing differs (#908). - Claude: handle Enterprise/organization web accounts whose session quota window is null, parse inline spend-limit usage, and expose an optional Org ID field for Claude token accounts (#941, fixes #940). Thanks @clintandrewhall! diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index 7b24789940..2451970728 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -210,31 +210,36 @@ public struct GeminiStatusProbe: Sendable { "now": "\(Date())", ]) - guard let storedAccessToken = creds.accessToken, !storedAccessToken.isEmpty else { - Self.log.error("No access token found") - throw GeminiStatusProbeError.notLoggedIn - } - - var accessToken = storedAccessToken - if let expiry = creds.expiryDate, expiry < Date() { - Self.log.info("Token expired; attempting refresh", metadata: [ - "expiry": "\(expiry)", - ]) + var accessToken = creds.accessToken?.isEmpty == false ? creds.accessToken : nil + var idToken = creds.idToken + let needsRefresh = accessToken == nil || creds.expiryDate.map { $0 < Date() } == true + if needsRefresh { + if accessToken == nil { + Self.log.info("No access token found; attempting refresh from stored Gemini credentials") + } else if let expiry = creds.expiryDate { + Self.log.info("Token expired; attempting refresh", metadata: [ + "expiry": "\(expiry)", + ]) + } - guard let refreshToken = creds.refreshToken else { + guard let refreshToken = creds.refreshToken, !refreshToken.isEmpty else { Self.log.error("No refresh token available") throw GeminiStatusProbeError.notLoggedIn } - accessToken = try await Self.refreshAccessToken( refreshToken: refreshToken, timeout: timeout, homeDirectory: homeDirectory, dataLoader: dataLoader) + idToken = (try? Self.loadCredentials(homeDirectory: homeDirectory).idToken) ?? idToken + } + guard let accessToken else { + Self.log.error("No access token found") + throw GeminiStatusProbeError.notLoggedIn } // Extract account info from JWT - let claims = Self.extractClaimsFromToken(creds.idToken) + let claims = Self.extractClaimsFromToken(idToken) // Load Code Assist status to get project ID and tier (aligned with CLI setupUser logic) let caStatus = await Self.loadCodeAssistStatus( diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift index daf0f997bf..976ac72b9f 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift @@ -118,6 +118,77 @@ struct GeminiStatusProbeAPITests { #expect(updated["access_token"] as? String == "new-token") } + @Test + func `refreshes when stored Gemini credentials only have refresh token`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: nil, + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let binURL = try env.writeFakeGeminiCLI() + let previousValue = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + setenv("GEMINI_CLI_PATH", binURL.path, 1) + defer { + if let previousValue { + setenv("GEMINI_CLI_PATH", previousValue, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + } + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + let auth = request.value(forHTTPHeaderField: "Authorization") + guard auth == "Bearer new-token" else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountEmail == "user@example.com") + + let updated = try env.readCredentials() + #expect(updated["access_token"] as? String == "new-token") + } + @Test func `refreshes expired token with nix share layout`() async throws { let env = try GeminiTestEnvironment() diff --git a/Tests/CodexBarTests/GeminiTestEnvironment.swift b/Tests/CodexBarTests/GeminiTestEnvironment.swift index d44a423b88..6e7ce8d8a7 100644 --- a/Tests/CodexBarTests/GeminiTestEnvironment.swift +++ b/Tests/CodexBarTests/GeminiTestEnvironment.swift @@ -42,11 +42,16 @@ struct GeminiTestEnvironment { try data.write(to: self.geminiDir.appendingPathComponent("settings.json"), options: .atomic) } - func writeCredentials(accessToken: String, refreshToken: String?, expiry: Date, idToken: String?) throws { + func writeCredentials( + accessToken: String?, + refreshToken: String?, + expiry: Date, + idToken: String?) throws + { var payload: [String: Any] = [ - "access_token": accessToken, "expiry_date": expiry.timeIntervalSince1970 * 1000, ] + if let accessToken { payload["access_token"] = accessToken } if let refreshToken { payload["refresh_token"] = refreshToken } if let idToken { payload["id_token"] = idToken } let data = try JSONSerialization.data(withJSONObject: payload) From 8c3e2547f9b7f6eb7ec95d2c583bcfdd1cc5ff68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 09:15:15 +0100 Subject: [PATCH 19/27] refactor: split Claude web extra usage helpers --- .../ClaudeWeb/ClaudeWebAPIFetcher.swift | 207 +++++++++--------- 1 file changed, 107 insertions(+), 100 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index 84396e11bf..6bd41e8068 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -208,7 +208,11 @@ public enum ClaudeWebAPIFetcher { var usage = try await fetchUsageData(orgId: organization.id, sessionKey: sessionKey, logger: log) if usage.extraUsageCost == nil, - let extra = await fetchExtraUsageCost(orgId: organization.id, sessionKey: sessionKey, logger: log) + let extra = await ClaudeWebExtraUsageCost.fetch( + baseURL: Self.baseURL, + orgId: organization.id, + sessionKey: sessionKey, + logger: log) { usage = WebUsageData( sessionPercentUsed: usage.sessionPercentUsed, @@ -517,7 +521,7 @@ public enum ClaudeWebAPIFetcher { if let sourceKey = extraRateParse.sourceKeys["claude-routines"] { logger?("Usage API extra window key matched: routines=\(sourceKey)") } - let extraUsageCost = Self.parseExtraUsageCost(from: json["extra_usage"]) + let extraUsageCost = ClaudeWebExtraUsageCost.parse(from: json["extra_usage"]) return WebUsageData( sessionPercentUsed: resolvedSessionPercent, @@ -542,103 +546,6 @@ public enum ClaudeWebAPIFetcher { return nil } - // MARK: - Extra usage cost (Claude "Extra") - - private static func parseExtraUsageCost(from value: Any?) -> ProviderCostSnapshot? { - guard let extraUsage = value as? [String: Any] else { return nil } - guard let used = Self.doubleValue(extraUsage["used_credits"]), - let limit = Self.doubleValue(extraUsage["monthly_limit"] ?? extraUsage["monthly_credit_limit"]), - limit > 0 else { return nil } - let currency = (extraUsage["currency"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let currencyCode = currency?.isEmpty == false ? currency ?? "USD" : "USD" - return Self.makeExtraUsageCost( - usedCredits: used, - monthlyCreditLimit: limit, - currencyCode: currencyCode) - } - - private struct OverageSpendLimitResponse: Decodable { - let monthlyCreditLimit: Double? - let currency: String? - let usedCredits: Double? - let isEnabled: Bool? - - enum CodingKeys: String, CodingKey { - case monthlyCreditLimit = "monthly_credit_limit" - case currency - case usedCredits = "used_credits" - case isEnabled = "is_enabled" - } - } - - /// Best-effort fetch of Claude Extra spend/limit (does not fail the main usage fetch). - private static func fetchExtraUsageCost( - orgId: String, - sessionKey: String, - logger: ((String) -> Void)? = nil) async -> ProviderCostSnapshot? - { - let url = URL(string: "\(baseURL)/organizations/\(orgId)/overage_spend_limit")! - var request = URLRequest(url: url) - request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.httpMethod = "GET" - request.timeoutInterval = 15 - - do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { return nil } - logger?("Overage API status: \(httpResponse.statusCode)") - guard httpResponse.statusCode == 200 else { return nil } - return Self.parseOverageSpendLimit(data) - } catch { - return nil - } - } - - private static func parseOverageSpendLimit(_ data: Data) -> ProviderCostSnapshot? { - guard let decoded = try? JSONDecoder().decode(OverageSpendLimitResponse.self, from: data) else { return nil } - guard decoded.isEnabled == true else { return nil } - guard let used = decoded.usedCredits, - let limit = decoded.monthlyCreditLimit, - let currency = decoded.currency, - !currency.isEmpty else { return nil } - - return Self.makeExtraUsageCost( - usedCredits: used, - monthlyCreditLimit: limit, - currencyCode: currency) - } - - private static func makeExtraUsageCost( - usedCredits: Double, - monthlyCreditLimit: Double, - currencyCode: String) -> ProviderCostSnapshot - { - let usedAmount = usedCredits / 100.0 - let limitAmount = monthlyCreditLimit / 100.0 - - return ProviderCostSnapshot( - used: usedAmount, - limit: limitAmount, - currencyCode: currencyCode, - period: "Monthly", - resetsAt: nil, - updatedAt: Date()) - } - - private static func doubleValue(_ value: Any?) -> Double? { - switch value { - case let int as Int: - Double(int) - case let double as Double: - double - case let string as String: - Double(string) - default: - nil - } - } - #if DEBUG // MARK: - Test hooks (DEBUG-only) @@ -655,7 +562,7 @@ public enum ClaudeWebAPIFetcher { } public static func _parseOverageSpendLimitForTesting(_ data: Data) -> ProviderCostSnapshot? { - self.parseOverageSpendLimit(data) + ClaudeWebExtraUsageCost.parseOverageSpendLimit(data) } public static func _parseAccountInfoForTesting(_ data: Data, orgId: String?) -> WebAccountInfo? { @@ -969,6 +876,106 @@ public enum ClaudeWebAPIFetcher { #endif } +private enum ClaudeWebExtraUsageCost { + // MARK: - Extra usage cost (Claude "Extra") + + static func parse(from value: Any?) -> ProviderCostSnapshot? { + guard let extraUsage = value as? [String: Any] else { return nil } + guard let used = Self.doubleValue(extraUsage["used_credits"]), + let limit = Self.doubleValue(extraUsage["monthly_limit"] ?? extraUsage["monthly_credit_limit"]), + limit > 0 else { return nil } + let currency = (extraUsage["currency"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let currencyCode = currency?.isEmpty == false ? currency ?? "USD" : "USD" + return Self.makeExtraUsageCost( + usedCredits: used, + monthlyCreditLimit: limit, + currencyCode: currencyCode) + } + + struct OverageSpendLimitResponse: Decodable { + let monthlyCreditLimit: Double? + let currency: String? + let usedCredits: Double? + let isEnabled: Bool? + + enum CodingKeys: String, CodingKey { + case monthlyCreditLimit = "monthly_credit_limit" + case currency + case usedCredits = "used_credits" + case isEnabled = "is_enabled" + } + } + + /// Best-effort fetch of Claude Extra spend/limit (does not fail the main usage fetch). + static func fetch( + baseURL: String, + orgId: String, + sessionKey: String, + logger: ((String) -> Void)? = nil) async -> ProviderCostSnapshot? + { + let url = URL(string: "\(baseURL)/organizations/\(orgId)/overage_spend_limit")! + var request = URLRequest(url: url) + request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpMethod = "GET" + request.timeoutInterval = 15 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { return nil } + logger?("Overage API status: \(httpResponse.statusCode)") + guard httpResponse.statusCode == 200 else { return nil } + return Self.parseOverageSpendLimit(data) + } catch { + return nil + } + } + + static func parseOverageSpendLimit(_ data: Data) -> ProviderCostSnapshot? { + guard let decoded = try? JSONDecoder().decode(OverageSpendLimitResponse.self, from: data) else { return nil } + guard decoded.isEnabled == true else { return nil } + guard let used = decoded.usedCredits, + let limit = decoded.monthlyCreditLimit, + let currency = decoded.currency, + !currency.isEmpty else { return nil } + + return Self.makeExtraUsageCost( + usedCredits: used, + monthlyCreditLimit: limit, + currencyCode: currency) + } + + static func makeExtraUsageCost( + usedCredits: Double, + monthlyCreditLimit: Double, + currencyCode: String) -> ProviderCostSnapshot + { + let usedAmount = usedCredits / 100.0 + let limitAmount = monthlyCreditLimit / 100.0 + + return ProviderCostSnapshot( + used: usedAmount, + limit: limitAmount, + currencyCode: currencyCode, + period: "Monthly", + resetsAt: nil, + updatedAt: Date()) + } + + static func doubleValue(_ value: Any?) -> Double? { + switch value { + case let int as Int: + Double(int) + case let double as Double: + double + case let string as String: + Double(string) + default: + nil + } + } +} + private struct ClaudeWebOrganizationResponse: Decodable { let uuid: String let name: String? From aebe1c91cd58ecac96cb1b3e72d3366049ea564d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 10:25:44 +0100 Subject: [PATCH 20/27] fix: defer account switcher menu rebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #944. Refs #946. Co-authored-by: Jakub Hašek --- CHANGELOG.md | 1 + .../CodexBar/StatusItemController+Menu.swift | 190 ++++++++++-------- .../StatusItemController+MenuContexts.swift | 34 ++++ ...ItemController+MenuRefreshScheduling.swift | 23 +++ .../StatusItemController+MenuTypes.swift | 44 +++- Sources/CodexBar/StatusItemController.swift | 2 + .../LocalizationBundleTests.swift | 34 +++- .../StatusMenuCodexSwitcherTests.swift | 84 ++++++++ .../StatusMenuTokenAccountSwitcherTests.swift | 59 ++++++ 9 files changed, 376 insertions(+), 95 deletions(-) create mode 100644 Sources/CodexBar/StatusItemController+MenuContexts.swift create mode 100644 Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c3643d0039..d4492212a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed - Kimi: rebalance provider icon alignment within its viewBox (#912). Thanks @giuseppebisemi! +- Menu: defer account-switcher menu rebuilds so switching Codex or token accounts does not send the open menu into a flicker loop (#946, fixes #944). Thanks @kubahasek! - Menu bar: detect when macOS Tahoe hides CodexBar behind the new Allow in Menu Bar setting and show recovery guidance (#945, fixes #890). Thanks @pdurlej! - Claude: show Enterprise OAuth spend-limit usage when the API returns only `extra_usage` data instead of session windows (#925). - CLI: route Claude token-account `--source cli` reads through the selected OAuth/session credential so `--all-accounts` no longer relabels ambient CLI usage (#403). diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 3ec68f1a39..fa8df2a239 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -206,7 +206,9 @@ extension StatusItemController { let switcherUsageBarsShowUsedMatch = self.settings.usageBarsShowUsed == self.lastSwitcherUsageBarsShowUsed let switcherSelectionMatches = switcherSelection == self.lastMergedSwitcherSelection let switcherOverviewAvailabilityMatches = includesOverview == self.lastSwitcherIncludesOverview - let tokenSwitcherCompatible = tokenAccountDisplay == nil && !hasTokenSwitcher + let tokenSwitcherCompatible = tokenAccountDisplay == self.lastTokenAccountMenuDisplay && + ((tokenAccountDisplay?.showSwitcher == true && hasTokenSwitcher) || + (tokenAccountDisplay?.showSwitcher != true && !hasTokenSwitcher)) let codexSwitcherCompatible = codexAccountDisplay == self.lastCodexAccountMenuDisplay && ((codexAccountDisplay?.showSwitcher == true && hasCodexSwitcher) || (codexAccountDisplay?.showSwitcher != true && !hasCodexSwitcher)) @@ -292,35 +294,19 @@ extension StatusItemController { self.menuLogger.debug("populateMenu(open): rebuilding whole menu and replacing provider switcher") } #endif - menu.removeAllItems() - self.addProviderSwitcherIfNeeded( - to: menu, - enabledProviders: enabledProviders, - includesOverview: includesOverview, - selection: switcherSelection ?? .provider(currentProvider), - width: menuWidth) - // Track which providers the switcher was built with for smart update detection - if self.shouldMergeIcons, enabledProviders.count > 1 { - self.lastSwitcherProviders = enabledProviders - self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed - self.lastMergedSwitcherSelection = switcherSelection - self.lastSwitcherIncludesOverview = includesOverview - } - self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay, width: menuWidth) - self.lastCodexAccountMenuDisplay = codexAccountDisplay - self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay, width: menuWidth) - let menuContext = MenuCardContext( - currentProvider: currentProvider, - selectedProvider: selectedProvider, - menuWidth: menuWidth, - codexAccountDisplay: codexAccountDisplay, - tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext) - self.addPrimaryMenuContent( - to: menu, - context: menuContext, - switcherSelection: switcherSelection ?? .provider(currentProvider)) - self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) + self.rebuildMenuContent( + menu, + context: MenuRebuildContext( + enabledProviders: enabledProviders, + includesOverview: includesOverview, + switcherSelection: switcherSelection, + currentProvider: currentProvider, + selectedProvider: selectedProvider, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + openAIContext: openAIContext, + descriptor: descriptor)) } private func reusableFixedWidthRows(in menu: NSMenu) -> [NSMenuItem] { @@ -362,62 +348,93 @@ extension StatusItemController { _ menu: NSMenu, context: MenuUpdateContext) { - // Batch menu updates to prevent visual flickering during provider switch. - CATransaction.begin() - CATransaction.setDisableActions(true) - defer { CATransaction.commit() } + self.performMenuMutationWithoutAnimation { + let contentStartIndex = menu.items.first?.view is ProviderSwitcherView ? 2 : 0 + (menu.items.first?.view as? ProviderSwitcherView)?.updateSelection(context.switcherSelection) + while menu.items.count > contentStartIndex { + menu.removeItem(at: contentStartIndex) + } - let contentStartIndex = menu.items.first?.view is ProviderSwitcherView ? 2 : 0 - (menu.items.first?.view as? ProviderSwitcherView)?.updateSelection(context.switcherSelection) - while menu.items.count > contentStartIndex { - menu.removeItem(at: contentStartIndex) + self.lastMergedSwitcherSelection = context.switcherSelection + let enabledProviders = self.store.enabledProvidersForDisplay() + self.lastSwitcherProviders = enabledProviders + self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed + self.lastSwitcherIncludesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) + self.addCodexAccountSwitcherIfNeeded( + to: menu, + display: context.codexAccountDisplay, + width: context.menuWidth) + self.lastCodexAccountMenuDisplay = context.codexAccountDisplay + self.addTokenAccountSwitcherIfNeeded( + to: menu, + display: context.tokenAccountDisplay, + width: context.menuWidth) + self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay + + let descriptor = MenuDescriptor.build( + provider: context.provider, + store: self.store, + settings: self.settings, + account: self.account, + managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, + codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, + updateReady: self.updater.updateStatus.isUpdateReady, + includeContextualActions: context.switcherSelection != .overview) + + let menuContext = MenuCardContext( + currentProvider: context.currentProvider, + selectedProvider: context.provider, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay, + openAIContext: context.openAIContext) + self.addPrimaryMenuContent(to: menu, context: menuContext, switcherSelection: context.switcherSelection) + self.addActionableSections(descriptor.sections, to: menu, width: context.menuWidth) } - - self.lastMergedSwitcherSelection = context.switcherSelection - let enabledProviders = self.store.enabledProvidersForDisplay() - self.lastSwitcherProviders = enabledProviders - self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed - self.lastSwitcherIncludesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) - self.addCodexAccountSwitcherIfNeeded(to: menu, display: context.codexAccountDisplay, width: context.menuWidth) - self.lastCodexAccountMenuDisplay = context.codexAccountDisplay - self.addTokenAccountSwitcherIfNeeded(to: menu, display: context.tokenAccountDisplay, width: context.menuWidth) - - let descriptor = MenuDescriptor.build( - provider: context.provider, - store: self.store, - settings: self.settings, - account: self.account, - managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator, - updateReady: self.updater.updateStatus.isUpdateReady, - includeContextualActions: context.switcherSelection != .overview) - - let menuContext = MenuCardContext( - currentProvider: context.currentProvider, - selectedProvider: context.provider, - menuWidth: context.menuWidth, - codexAccountDisplay: context.codexAccountDisplay, - tokenAccountDisplay: context.tokenAccountDisplay, - openAIContext: context.openAIContext) - self.addPrimaryMenuContent(to: menu, context: menuContext, switcherSelection: context.switcherSelection) - self.addActionableSections(descriptor.sections, to: menu, width: context.menuWidth) } - private struct OpenAIWebContext { - let hasUsageBreakdown: Bool - let hasCreditsHistory: Bool - let hasCostHistory: Bool - let canShowBuyCredits: Bool - let hasOpenAIWebMenuItems: Bool - } - - private struct MenuCardContext { - let currentProvider: UsageProvider - let selectedProvider: UsageProvider? - let menuWidth: CGFloat - let codexAccountDisplay: CodexAccountMenuDisplay? - let tokenAccountDisplay: TokenAccountMenuDisplay? - let openAIContext: OpenAIWebContext + private func rebuildMenuContent( + _ menu: NSMenu, + context: MenuRebuildContext) + { + self.performMenuMutationWithoutAnimation { + menu.removeAllItems() + self.addProviderSwitcherIfNeeded( + to: menu, + enabledProviders: context.enabledProviders, + includesOverview: context.includesOverview, + selection: context.switcherSelection ?? .provider(context.currentProvider), + width: context.menuWidth) + // Track which providers the switcher was built with for smart update detection + if self.shouldMergeIcons, context.enabledProviders.count > 1 { + self.lastSwitcherProviders = context.enabledProviders + self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed + self.lastMergedSwitcherSelection = context.switcherSelection + self.lastSwitcherIncludesOverview = context.includesOverview + } + self.addCodexAccountSwitcherIfNeeded( + to: menu, + display: context.codexAccountDisplay, + width: context.menuWidth) + self.lastCodexAccountMenuDisplay = context.codexAccountDisplay + self.addTokenAccountSwitcherIfNeeded( + to: menu, + display: context.tokenAccountDisplay, + width: context.menuWidth) + self.lastTokenAccountMenuDisplay = context.tokenAccountDisplay + let menuContext = MenuCardContext( + currentProvider: context.currentProvider, + selectedProvider: context.selectedProvider, + menuWidth: context.menuWidth, + codexAccountDisplay: context.codexAccountDisplay, + tokenAccountDisplay: context.tokenAccountDisplay, + openAIContext: context.openAIContext) + self.addPrimaryMenuContent( + to: menu, + context: menuContext, + switcherSelection: context.switcherSelection ?? .provider(context.currentProvider)) + self.addActionableSections(context.descriptor.sections, to: menu, width: context.menuWidth) + } } private func openAIWebContext( @@ -946,11 +963,8 @@ extension StatusItemController { onSelect: { [weak self, weak menu] index -> Task? in guard let self, let menu else { return nil } self.settings.setActiveTokenAccountIndex(index, for: display.provider) - // Immediately rebuild to show the new selection, then refresh data - // and rebuild again once fresh data arrives. - self.populateMenu(menu, provider: display.provider) - self.markMenuFresh(menu) self.applyIcon(phase: nil) + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: display.provider) return Task { @MainActor [weak self, weak menu] in guard let self else { return } await ProviderInteractionContext.$current.withValue(.userInitiated) { @@ -990,7 +1004,7 @@ extension StatusItemController { let visibleAccountID = account.id self.settings.selectDisplayedCodexVisibleAccount(account) if self.store.prepareCodexAccountScopedRefreshIfNeeded(), let menu { - self.refreshOpenMenuIfStillVisible(menu, provider: .codex) + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) } Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { @@ -1117,7 +1131,7 @@ extension StatusItemController { } } - private func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return } self.populateMenu(menu, provider: provider) diff --git a/Sources/CodexBar/StatusItemController+MenuContexts.swift b/Sources/CodexBar/StatusItemController+MenuContexts.swift new file mode 100644 index 0000000000..1091374281 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuContexts.swift @@ -0,0 +1,34 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + struct OpenAIWebContext { + let hasUsageBreakdown: Bool + let hasCreditsHistory: Bool + let hasCostHistory: Bool + let canShowBuyCredits: Bool + let hasOpenAIWebMenuItems: Bool + } + + struct MenuCardContext { + let currentProvider: UsageProvider + let selectedProvider: UsageProvider? + let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let openAIContext: OpenAIWebContext + } + + struct MenuRebuildContext { + let enabledProviders: [UsageProvider] + let includesOverview: Bool + let switcherSelection: ProviderSwitcherSelection? + let currentProvider: UsageProvider + let selectedProvider: UsageProvider? + let menuWidth: CGFloat + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let openAIContext: OpenAIWebContext + let descriptor: MenuDescriptor + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift new file mode 100644 index 0000000000..64c37b553c --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -0,0 +1,23 @@ +import AppKit +import CodexBarCore +import QuartzCore + +extension StatusItemController { + func performMenuMutationWithoutAnimation(_ updates: () -> Void) { + CATransaction.begin() + CATransaction.setDisableActions(true) + defer { CATransaction.commit() } + updates() + } + + func deferSwitcherMenuRebuildIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + self.providerSwitcherUpdateToken &+= 1 + let updateToken = self.providerSwitcherUpdateToken + Task { @MainActor [weak self, weak menu] in + await Task.yield() + guard let self, let menu else { return } + guard self.providerSwitcherUpdateToken == updateToken else { return } + self.rebuildOpenMenuIfStillVisible(menu, provider: provider) + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index b3f648d62c..88e5fa3057 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -64,7 +64,7 @@ struct OpenAIWebMenuItems { let canShowBuyCredits: Bool } -struct TokenAccountMenuDisplay { +struct TokenAccountMenuDisplay: Equatable { let provider: UsageProvider let accounts: [ProviderTokenAccount] let snapshots: [TokenAccountUsageSnapshot] @@ -78,6 +78,48 @@ struct TokenAccountMenuDisplay { var showSwitcher: Bool { self.layout == .segmented } + + static func == (lhs: TokenAccountMenuDisplay, rhs: TokenAccountMenuDisplay) -> Bool { + lhs.provider == rhs.provider && + lhs.accountIdentity == rhs.accountIdentity && + lhs.activeIndex == rhs.activeIndex && + lhs.layout == rhs.layout && + lhs.snapshotIdentity == rhs.snapshotIdentity + } + + private var accountIdentity: [AccountIdentity] { + self.accounts.map { account in + AccountIdentity( + id: account.id, + label: account.label, + externalIdentifier: account.externalIdentifier, + organizationID: account.organizationID) + } + } + + private var snapshotIdentity: [SnapshotIdentity] { + self.snapshots.map { snapshot in + SnapshotIdentity( + id: snapshot.id, + hasSnapshot: snapshot.snapshot != nil, + error: snapshot.error, + sourceLabel: snapshot.sourceLabel) + } + } + + private struct AccountIdentity: Equatable { + let id: UUID + let label: String + let externalIdentifier: String? + let organizationID: String? + } + + private struct SnapshotIdentity: Equatable { + let id: UUID + let hasSnapshot: Bool + let error: String? + let sourceLabel: String? + } } struct CodexAccountMenuDisplay: Equatable { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index eeceb69112..ca836ec3de 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -145,6 +145,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastMergedSwitcherSelection: ProviderSwitcherSelection? /// Tracks the visible Codex account switcher contents for merged-menu smart updates. var lastCodexAccountMenuDisplay: CodexAccountMenuDisplay? + /// Tracks the visible token account switcher contents for merged-menu smart updates. + var lastTokenAccountMenuDisplay: TokenAccountMenuDisplay? /// Monotonic token used to ignore stale deferred provider-switcher menu rebuilds. var providerSwitcherUpdateToken = 0 var lastAppliedMergedIconRenderSignature: String? diff --git a/Tests/CodexBarTests/LocalizationBundleTests.swift b/Tests/CodexBarTests/LocalizationBundleTests.swift index 643116ebad..ed15b66ab1 100644 --- a/Tests/CodexBarTests/LocalizationBundleTests.swift +++ b/Tests/CodexBarTests/LocalizationBundleTests.swift @@ -24,8 +24,22 @@ struct LocalizationBundleTests { #expect(bundle.bundleURL == fixture.appBundle.bundleURL) } + @Test + func `packaged app resolves raw copied localization resources from main bundle`() throws { + let fixture = try Self.makeAppBundleFixture( + includeLocalizationBundle: false, + includeMainLocalization: true) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let bundle = codexBarLocalizationResourceBundle(mainBundle: fixture.appBundle) + + #expect(bundle.bundleURL == fixture.appBundle.bundleURL) + #expect(bundle.path(forResource: "en", ofType: "lproj") != nil) + } + private static func makeAppBundleFixture( - includeLocalizationBundle: Bool) throws -> (root: URL, appBundle: Bundle) + includeLocalizationBundle: Bool, + includeMainLocalization: Bool = false) throws -> (root: URL, appBundle: Bundle) { let root = FileManager.default.temporaryDirectory.appendingPathComponent( "codexbar-localization-\(UUID().uuidString)", @@ -52,18 +66,26 @@ struct LocalizationBundleTests { atomically: true, encoding: .utf8) + if includeMainLocalization { + try Self.writeEnglishLocalization(to: resourcesURL.appendingPathComponent("en.lproj", isDirectory: true)) + } + if includeLocalizationBundle { let lprojURL = resourcesURL .appendingPathComponent("CodexBar_CodexBar.bundle", isDirectory: true) .appendingPathComponent("en.lproj", isDirectory: true) - try FileManager.default.createDirectory(at: lprojURL, withIntermediateDirectories: true) - try "\"Settings\" = \"Settings\";\n".write( - to: lprojURL.appendingPathComponent("Localizable.strings"), - atomically: true, - encoding: .utf8) + try Self.writeEnglishLocalization(to: lprojURL) } let appBundle = try #require(Bundle(url: appURL)) return (root, appBundle) } + + private static func writeEnglishLocalization(to lprojURL: URL) throws { + try FileManager.default.createDirectory(at: lprojURL, withIntermediateDirectories: true) + try "\"Settings\" = \"Settings\";\n".write( + to: lprojURL.appendingPathComponent("Localizable.strings"), + atomically: true, + encoding: .utf8) + } } diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift index 77ff180485..5b1b599371 100644 --- a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -870,6 +870,90 @@ struct StatusMenuCodexSwitcherTests { @MainActor extension StatusMenuCodexSwitcherTests { + @Test + func `codex account switch defers open menu rebuild until after switcher action`() async throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.multiAccountMenuLayout = .segmented + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(self.snapshot(email: "live@example.com", percent: 11), provider: .codex) + store.lastCodexAccountScopedRefreshGuard = store.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: false) + let blocker = BlockingStatusMenuCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try #require(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first) + let managedVisibleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.storedAccountID == managedAccountID }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + switcher._test_selectAccount(id: managedVisibleAccount.id) + + #expect(rebuildCount == 0) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + #expect(rebuildCount == 1) + + await blocker.waitUntilStarted() + await blocker.resume(with: .success(self.snapshot(email: "managed@example.com", percent: 17))) + } + @Test func `codex stacked refresh discards selected outcome when visible selection changes mid flight`() throws { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift index 43e7f60f71..22702c871b 100644 --- a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift @@ -206,6 +206,65 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { XCTAssertNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) XCTAssertEqual(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, ["menuCard-0", "menuCard-1"]) } + + func test_tokenAccountSwitchDefersOpenMenuRebuildUntilAfterSwitcherAction() async throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.multiAccountMenuLayout = .segmented + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .claude || provider == .codex) + } + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + settings.setActiveTokenAccountIndex(0, for: .claude) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) + + XCTAssertEqual(rebuildCount, 0) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + XCTAssertEqual(rebuildCount, 1) + + await blocker.waitUntilStarted(count: 1) + await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) + await selectionTask.value + } } private struct StatusMenuTokenAccountFetchStrategy: ProviderFetchStrategy { From 6e68613d854193f1ebdcd82980ee592ce4ad057a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 10:42:55 +0100 Subject: [PATCH 21/27] docs: fix changelog spacing --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4492212a2..7c6c525e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - ChatGPT credits: restrict purchase links to real HTTPS `chatgpt.com` settings/usage/billing/credits paths and drop query/fragment data (#903). Thanks @ThiagoCAltoe! - z.ai: show the MCP quota bucket as monthly instead of a misleading 1-minute window (#904). Thanks @ThiagoCAltoe! - Menu: middle-truncate long account emails in Codex account controls and keep the Codex account switcher visible during merged-menu refreshes with transient account snapshots. + ## 0.25.1 — 2026-05-11 ### Fixed @@ -33,6 +34,7 @@ - CLI: include a VERSION file in standalone release archives so `--version` reports the release tag outside the app bundle (#898). Thanks @ThiagoCAltoe! - Pi: rebuild stale session cost caches after cache-version migrations so refreshed cost history reflects current scanner data. - Keychain cache: reduce repeated development prompt churn by trusting the bundled helper when writing CodexBar-owned cache items (#888). + ## 0.25 — 2026-05-10 ### Highlights From 32fd0ece80b9eb8003a8415bee4620efd731daf7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 15:24:18 +0100 Subject: [PATCH 22/27] fix: handle unavailable Codex limits gracefully --- .../CodexBar/MenuCardView+ModelHelpers.swift | 52 +++++++ Sources/CodexBar/MenuCardView.swift | 4 +- .../Codex/CodexProviderImplementation.swift | 3 + .../CodexBar/StatusItemController+Menu.swift | 1 + Sources/CodexBarCLI/CLIRenderer.swift | 15 ++ Sources/CodexBarCore/UsageFetcher.swift | 79 +++++++++- Tests/CodexBarTests/CLISnapshotTests.swift | 31 ++++ .../CodexCLIWindowNormalizationTests.swift | 59 +++++++ .../CodexSystemPromotionUITests.swift | 31 ++++ .../CodexUsageFetcherFallbackTests.swift | 95 +++++++++++ .../MenuCardModelCodexProjectionTests.swift | 147 ++++++++++++++++++ Tests/CodexBarTests/MenuCardModelTests.swift | 55 +++++++ 12 files changed, 566 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index c59a4977c7..24eb598ca8 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -9,6 +9,16 @@ extension UsageMenuCardView.Model { let paceOnTop: Bool } + var isOverviewErrorOnly: Bool { + self.subtitleStyle == .error && + self.metrics.isEmpty && + self.usageNotes.isEmpty && + self.creditsRemaining == nil && + self.providerCost == nil && + self.tokenUsage == nil && + self.placeholder == nil + } + static func progressColor(for provider: UsageProvider) -> Color { let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color return Color(red: color.red, green: color.green, blue: color.blue) @@ -22,6 +32,48 @@ extension UsageMenuCardView.Model { UsageFormatter.resetLine(for: window, style: style, now: now) } + static func placeholder(input: Input) -> String? { + if self.shouldShowRateLimitsUnavailablePlaceholder(input: input) { + return "Limits not available" + } + if input.snapshot == nil, !input.isRefreshing, input.lastError == nil { + return "No usage yet" + } + return nil + } + + static func lastError(input: Input) -> String? { + guard let lastError = input.lastError?.trimmingCharacters(in: .whitespacesAndNewlines), + !lastError.isEmpty + else { + return nil + } + if self.shouldShowRateLimitsUnavailablePlaceholder(input: input, lastError: lastError) { + return nil + } + return lastError + } + + private static func shouldShowRateLimitsUnavailablePlaceholder(input: Input, lastError: String? = nil) -> Bool { + let currentError = lastError ?? input.lastError + if let currentError = currentError?.trimmingCharacters(in: .whitespacesAndNewlines), + !currentError.isEmpty, + !UsageError.isNoRateLimitsFoundDescription(currentError) + { + return false + } + return self.rateLimitsUnavailable(input: input, lastError: currentError) + } + + private static func rateLimitsUnavailable(input: Input, lastError: String? = nil) -> Bool { + UsageLimitsAvailability.resolve( + provider: input.provider, + snapshot: input.snapshot, + account: input.account, + lastErrorDescription: lastError ?? input.lastError) + .isUnavailable + } + static func sessionPaceDetail( provider: UsageProvider, window: RateWindow, diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 86787596f4..5020e733c6 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -767,10 +767,10 @@ extension UsageMenuCardView.Model { let subtitle = Self.subtitle( snapshot: input.snapshot, isRefreshing: input.isRefreshing, - lastError: input.lastError, + lastError: Self.lastError(input: input), now: input.now) let redacted = Self.redactedText(input: input, subtitle: subtitle) - let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil ? "No usage yet" : nil + let placeholder = Self.placeholder(input: input) return UsageMenuCardView.Model( provider: input.provider, diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 57cffd4448..79e3548efa 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -241,6 +241,9 @@ struct CodexProviderImplementation: ProviderImplementation { isEnabled: isEnabled, isChecked: isChecked) } + guard submenuItems.count > 1 || submenuItems.contains(where: { $0.isEnabled && $0.action != nil }) else { + return + } entries.append(.submenu( "System Account", diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index fa8df2a239..90a91656a4 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -503,6 +503,7 @@ extension StatusItemController { let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders .compactMap { provider in guard let model = self.menuCardModel(for: provider) else { return nil } + guard !model.isOverviewErrorOnly else { return nil } return (provider: provider, model: model) } guard !rows.isEmpty else { return false } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 0d93a62514..128bcc328c 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -34,6 +34,11 @@ enum CLIRenderer { now: now, lines: &lines) self.appendTertiaryLines(snapshot: snapshot, labels: labels, context: context, now: now, lines: &lines) + self.appendLimitsUnavailableLine( + provider: provider, + snapshot: snapshot, + useColor: context.useColor, + lines: &lines) self.appendCreditsLine(provider: provider, credits: credits, useColor: context.useColor, lines: &lines) self.appendIdentityAndNotes( provider: provider, @@ -161,6 +166,16 @@ enum CLIRenderer { useColor: useColor)) } + private static func appendLimitsUnavailableLine( + provider: UsageProvider, + snapshot: UsageSnapshot, + useColor: Bool, + lines: inout [String]) + { + guard snapshot.rateLimitsUnavailable(for: provider) else { return } + lines.append(self.labelValueLine("Limits", value: "not available", useColor: useColor)) + } + private static func appendIdentityAndNotes( provider: UsageProvider, snapshot: UsageSnapshot, diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 64a980b991..ec839f0110 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -247,6 +247,15 @@ public struct UsageSnapshot: Codable, Sendable { self.identity(for: provider)?.loginMethod } + public var hasRateLimitWindows: Bool { + self.primary != nil || self.secondary != nil || self.tertiary != nil || + !(self.extraRateWindows?.isEmpty ?? true) + } + + public func rateLimitsUnavailable(for provider: UsageProvider) -> Bool { + UsageLimitsAvailability.resolve(provider: provider, snapshot: self).isUnavailable + } + /// Keep this initializer-style copy in sync with UsageSnapshot fields so relabeling/scoping never drops data. public func withIdentity(_ identity: ProviderIdentitySnapshot?) -> UsageSnapshot { UsageSnapshot( @@ -316,6 +325,11 @@ public struct AccountInfo: Equatable, Sendable { public let email: String? public let plan: String? + public var hasIdentity: Bool { + self.email?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false || + self.plan?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + public init(email: String?, plan: String?) { self.email = email self.plan = plan @@ -347,6 +361,40 @@ public enum UsageError: LocalizedError, Sendable { "Could not parse Codex session log." } } + + public static func isNoRateLimitsFoundDescription(_ text: String?) -> Bool { + text?.trimmingCharacters(in: .whitespacesAndNewlines) == UsageError.noRateLimitsFound.errorDescription + } +} + +public enum UsageLimitsAvailability: Equatable, Sendable { + case available + case unavailable + + public var isUnavailable: Bool { + self == .unavailable + } + + public static func resolve( + provider: UsageProvider, + snapshot: UsageSnapshot?, + account: AccountInfo? = nil, + lastErrorDescription: String? = nil) -> Self + { + guard provider == .codex else { return .available } + + if let snapshot { + guard snapshot.identity(for: provider) != nil else { return .available } + return snapshot.hasRateLimitWindows ? .available : .unavailable + } + + guard UsageError.isNoRateLimitsFoundDescription(lastErrorDescription), + account?.hasIdentity == true + else { + return .available + } + return .unavailable + } } // MARK: - Codex RPC client (local process) @@ -393,6 +441,7 @@ private struct RPCRateLimitSnapshot: Decodable, Encodable { let primary: RPCRateLimitWindow? let secondary: RPCRateLimitWindow? let credits: RPCCreditsSnapshot? + let planType: String? } private struct RPCRateLimitWindow: Decodable, Encodable { @@ -748,6 +797,7 @@ public struct UsageFetcher: Sendable { // for the same pipe. let limits = try await rpc.fetchRateLimits().rateLimits let account = try? await rpc.fetchAccount() + let rateLimitsPlan = Self.normalizedCodexAccountField(limits.planType) let identity = ProviderIdentitySnapshot( providerID: .codex, accountEmail: account?.account.flatMap { details in @@ -756,13 +806,15 @@ public struct UsageFetcher: Sendable { accountOrganization: nil, loginMethod: account?.account.flatMap { details in if case let .chatgpt(_, plan) = details { plan } else { nil } - }) + } ?? rateLimitsPlan) + let credits = Self.makeCredits(from: limits.credits) + let shouldReturnUnavailableUsage = credits == nil || rateLimitsPlan != nil let usage = CodexReconciledState.fromCLI( primary: Self.makeWindow(from: limits.primary), secondary: Self.makeWindow(from: limits.secondary), identity: identity)? .toUsageSnapshot() - let credits = Self.makeCredits(from: limits.credits) + ?? (shouldReturnUnavailableUsage ? Self.emptyCodexUsageSnapshotIfIdentified(identity: identity) : nil) guard usage != nil || credits != nil else { throw UsageError.noRateLimitsFound } @@ -879,6 +931,16 @@ public struct UsageFetcher: Sendable { return CreditsSnapshot(remaining: self.parseCredits(rpc.balance), events: [], updatedAt: Date()) } + private static func emptyCodexUsageSnapshotIfIdentified(identity: ProviderIdentitySnapshot) -> UsageSnapshot? { + guard identity.accountEmail != nil || identity.loginMethod != nil else { return nil } + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: identity) + } + private static func recoverUsageFromRPCError(_ error: Error) -> UsageSnapshot? { guard let body = self.decodeRateLimitsErrorBody(from: error) else { return nil } let identity = ProviderIdentitySnapshot( @@ -983,13 +1045,22 @@ public struct UsageFetcher: Sendable { extension UsageFetcher { static func _mapCodexRPCLimitsForTesting( primary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, - secondary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?) throws -> UsageSnapshot + secondary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, + planType: String? = nil) throws -> UsageSnapshot { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.normalizedCodexAccountField(planType)) guard let state = CodexReconciledState.fromCLI( primary: primary.map(self.makeTestingWindow), secondary: secondary.map(self.makeTestingWindow), - identity: nil) + identity: identity) else { + if let usage = self.emptyCodexUsageSnapshotIfIdentified(identity: identity) { + return usage + } throw UsageError.noRateLimitsFound } return state.toUsageSnapshot() diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index 49527cf4d5..7df099b3f6 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -120,6 +120,37 @@ struct CLISnapshotTests { #expect(!output.contains("Plan: Prolite")) } + @Test + func `renders Codex plan only limits as unavailable`() { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snap = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(timeIntervalSince1970: 0), + identity: identity) + + let output = CLIRenderer.renderText( + provider: .codex, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Codex 1.2.3 (codex-cli)", + status: nil, + useColor: false, + resetStyle: .absolute)) + + #expect(output.contains("Limits: not available")) + #expect(output.contains("Account: user@example.com")) + #expect(output.contains("Plan: Pro 20x")) + #expect(!output.contains("Session:")) + #expect(!output.contains("Weekly:")) + } + @Test func `renders text snapshot for claude without weekly`() { let snap = UsageSnapshot( diff --git a/Tests/CodexBarTests/CodexCLIWindowNormalizationTests.swift b/Tests/CodexBarTests/CodexCLIWindowNormalizationTests.swift index a507704890..417517870d 100644 --- a/Tests/CodexBarTests/CodexCLIWindowNormalizationTests.swift +++ b/Tests/CodexBarTests/CodexCLIWindowNormalizationTests.swift @@ -112,6 +112,65 @@ struct CodexCLIWindowNormalizationTests { } } + @Test + func `maps plan only RPC limits into empty identified snapshot`() throws { + let snapshot = try UsageFetcher._mapCodexRPCLimitsForTesting( + primary: nil, + secondary: nil, + planType: "pro") + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.loginMethod(for: .codex) == "pro") + #expect(snapshot.rateLimitsUnavailable(for: .codex)) + } + + @Test + func `codex no rate limit error means limits unavailable without snapshot`() { + let availability = UsageLimitsAvailability.resolve( + provider: .codex, + snapshot: nil, + account: AccountInfo(email: "user@example.com", plan: nil), + lastErrorDescription: UsageError.noRateLimitsFound.errorDescription) + + #expect(availability == .unavailable) + } + + @Test + func `codex no rate limit error stays available without account context`() { + let availability = UsageLimitsAvailability.resolve( + provider: .codex, + snapshot: nil, + account: AccountInfo(email: nil, plan: nil), + lastErrorDescription: UsageError.noRateLimitsFound.errorDescription) + + #expect(availability == .available) + } + + @Test + func `codex windowed snapshot wins over stale no rate limit error`() { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: identity) + let availability = UsageLimitsAvailability.resolve( + provider: .codex, + snapshot: snapshot, + lastErrorDescription: UsageError.noRateLimitsFound.errorDescription) + + #expect(availability == .available) + } + @Test func `maps weekly only status snapshot into secondary`() throws { let status = CodexStatusSnapshot( diff --git a/Tests/CodexBarTests/CodexSystemPromotionUITests.swift b/Tests/CodexBarTests/CodexSystemPromotionUITests.swift index 3f10eb26eb..0f780686d8 100644 --- a/Tests/CodexBarTests/CodexSystemPromotionUITests.swift +++ b/Tests/CodexBarTests/CodexSystemPromotionUITests.swift @@ -119,4 +119,35 @@ struct CodexSystemPromotionUITests { #expect(submenu.2[1].isEnabled) #expect(submenu.2[1].action == .requestCodexSystemPromotion(managedAccountID)) } + + @Test + func `codex menu descriptor hides single live system account submenu`() throws { + let container = try CodexAccountPromotionTestContainer( + suiteName: "CodexSystemPromotionUITests-menu-single-live") + defer { container.tearDown() } + + container.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: container.liveHomeURL.path, + observedAt: Date()) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: container.usageStore, + settings: container.settings, + account: UsageFetcher().loadAccountInfo(), + managedCodexAccountCoordinator: ManagedCodexAccountCoordinator(), + codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator( + service: container.makeService()), + updateReady: false) + + let hasSystemAccountSubmenu = descriptor.sections + .flatMap(\.entries) + .contains { entry in + guard case let .submenu(title, _, _) = entry else { return false } + return title == "System Account" + } + + #expect(!hasSystemAccountSubmenu) + } } diff --git a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift index 006e7b5627..4e33617d8d 100644 --- a/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift +++ b/Tests/CodexBarTests/CodexUsageFetcherFallbackTests.swift @@ -85,6 +85,35 @@ struct CodexUsageFetcherFallbackTests { } } + @Test + func `CLI usage loads plan only RPC response as unavailable limits`() async throws { + let stubCLIPath = try self.makePlanOnlyStubCodexCLI() + defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + + let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + let snapshot = try await fetcher.loadLatestUsage() + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.accountEmail(for: .codex) == "stub@example.com") + #expect(snapshot.loginMethod(for: .codex) == "pro") + #expect(snapshot.rateLimitsUnavailable(for: .codex)) + } + + @Test + func `CLI plan and credits response without usage windows keeps unavailable limits`() async throws { + let stubCLIPath = try self.makePlanOnlyStubCodexCLI(includeCredits: true) + defer { try? FileManager.default.removeItem(atPath: stubCLIPath) } + + let fetcher = UsageFetcher(environment: ["CODEX_CLI_PATH": stubCLIPath]) + let snapshot = try await fetcher.loadLatestCLIAccountSnapshot() + + #expect(snapshot.usage?.primary == nil) + #expect(snapshot.usage?.secondary == nil) + #expect(snapshot.usage?.rateLimitsUnavailable(for: .codex) == true) + #expect(snapshot.credits?.remaining == 21) + } + @Test func `CLI usage fails when RPC body recovery misses session lane`() async throws { let stubCLIPath = try self.makeDecodeMismatchStubCodexCLI(message: Self.partialDecodeBodyMessage) @@ -294,6 +323,72 @@ struct CodexUsageFetcherFallbackTests { return url.path } + private func makePlanOnlyStubCodexCLI(includeCredits: Bool = false) throws -> String { + let creditsPayload = includeCredits + ? [ + ",", + " \"credits\": {", + " \"hasCredits\": True,", + " \"unlimited\": False,", + " \"balance\": \"21\"", + " }", + ].joined(separator: "\n") + : "" + let script = """ + #!/usr/bin/python3 + import json + import sys + + args = sys.argv[1:] + if "app-server" in args: + for line in sys.stdin: + if not line.strip(): + continue + message = json.loads(line) + method = message.get("method") + if method == "initialized": + continue + + identifier = message.get("id") + if method == "initialize": + payload = {"id": identifier, "result": {}} + elif method == "account/rateLimits/read": + payload = { + "id": identifier, + "result": { + "rateLimits": { + "planType": "pro" + \(creditsPayload) + } + } + } + elif method == "account/read": + payload = { + "id": identifier, + "result": { + "account": { + "type": "chatgpt", + "email": "stub@example.com", + "planType": "pro" + }, + "requiresOpenaiAuth": False + } + } + else: + payload = {"id": identifier, "result": {}} + + print(json.dumps(payload), flush=True) + else: + sys.stderr.write("unexpected non app-server Codex invocation\\n") + sys.exit(92) + """ + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-plan-only-stub-\(UUID().uuidString)", isDirectory: false) + try Data(script.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + private func makeCreditsOnlyStubCodexCLI() throws -> String { let script = """ #!/usr/bin/python3 diff --git a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift index f7760a665e..c941370ec5 100644 --- a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift +++ b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift @@ -5,6 +5,153 @@ import Testing @testable import CodexBar struct MenuCardModelCodexProjectionTests { + @Test + func `codex plan only snapshot shows limits unavailable placeholder`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == "Limits not available") + #expect(model.metrics.isEmpty) + #expect(model.subtitleStyle == .info) + #expect(!model.subtitleText.contains("Found sessions")) + #expect(model.planText == "Pro 20x") + } + + @Test + func `codex plan only snapshot keeps actionable refresh errors visible`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: "Codex connection failed: timed out.", + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == "Codex connection failed: timed out.") + #expect(model.planText == "Pro 20x") + } + + @Test + func `codex account fallback shows limits unavailable instead of no limits error`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "pro"), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == "Limits not available") + #expect(model.subtitleStyle == .info) + #expect(!model.subtitleText.contains("Found sessions")) + #expect(model.email == "user@example.com") + #expect(model.planText == "Pro 20x") + } + + @Test + func `codex no account fallback keeps no limits error visible`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == UsageError.noRateLimitsFound.errorDescription) + } + @Test func `builds metrics using used percent when enabled`() throws { let now = Date() diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 60d25bc7d9..c0fc230a0a 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -4,6 +4,61 @@ import SwiftUI import Testing @testable import CodexBar +struct OverviewMenuCardVisibilityTests { + @Test + func `overview hides cards that only contain an error`() throws { + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: "No Cursor session found.", + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: Date())) + + #expect(model.isOverviewErrorOnly) + } + + @Test + func `overview keeps cards with graceful unavailable placeholders`() throws { + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "pro"), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: Date())) + + #expect(model.placeholder == "Limits not available") + #expect(!model.isOverviewErrorOnly) + } +} + struct FactoryMenuCardModelTests { @Test func `factory token rate billing uses time window labels`() throws { From 0776680b23d7fe113856980df829197ff0f4ba0d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 15:24:26 +0100 Subject: [PATCH 23/27] docs: update unreleased changelog --- CHANGELOG.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c6c525e9f..af1d722966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,29 +3,32 @@ ## 0.26 — Unreleased ### Added +- Moonshot / Kimi API: add API-key balance tracking, CLI support, docs, and menu bar balance copy (#899). Thanks @giuseppebisemi! +- Menu: add an opt-in setting for provider changelog links, starting with Codex, Claude Code, and Gemini CLI (#929, fixes #660). Thanks @ThiagoCAltoe! +- Display: add a setting to hide quota-warning tick marks on usage bars while keeping quota warning notifications active (#918, fixes #916). Thanks @ThiagoCAltoe! - Codex: add tiered long-context and Fast/Priority pricing to local cost history using local app-server priority traces (#917). Thanks @iam-brain! +- Localization: add Brazilian Portuguese translations (#902). Thanks @ThiagoCAltoe! - Localization: add Simplified Chinese translations for Claude peak-hour labels (#921). Thanks @whtis! -- Display: add a setting to hide quota-warning tick marks on usage bars while keeping quota warning notifications active (#918, fixes #916). Thanks @ThiagoCAltoe! -- Menu: add an opt-in setting for provider changelog links, starting with Codex, Claude Code, and Gemini CLI (#929, fixes #660). Thanks @ThiagoCAltoe! -- Moonshot / Kimi API: add API-key balance tracking, CLI support, docs, and menu bar balance copy (#899). Thanks @giuseppebisemi! ### Fixed -- Kimi: rebalance provider icon alignment within its viewBox (#912). Thanks @giuseppebisemi! +- Codex: show authenticated plan/account rows as "Limits not available" instead of a red no-rate-limit error when Codex reports profile data but no rate-limit windows yet. +- Overview: hide provider rows that only contain an error, and avoid showing a one-item Codex System Account submenu. +- Claude: handle Enterprise and organization spend-limit usage across OAuth/web accounts, including null session quota windows, inline spend-limit usage, `extra_usage`-only responses, and token-account Org ID support (#925, #941, fixes #940). Thanks @clintandrewhall! - Menu: defer account-switcher menu rebuilds so switching Codex or token accounts does not send the open menu into a flicker loop (#946, fixes #944). Thanks @kubahasek! +- Menu: avoid rebuilding visible menus during background open-menu refreshes so hover submenus stay responsive (#923, fixes #909). Thanks @AmrMohamad! - Menu bar: detect when macOS Tahoe hides CodexBar behind the new Allow in Menu Bar setting and show recovery guidance (#945, fixes #890). Thanks @pdurlej! -- Claude: show Enterprise OAuth spend-limit usage when the API returns only `extra_usage` data instead of session windows (#925). - CLI: route Claude token-account `--source cli` reads through the selected OAuth/session credential so `--all-accounts` no longer relabels ambient CLI usage (#403). +- Codex: route menu account refreshes through the resolved live-vs-managed account source so matched accounts keep using the stable `CODEX_HOME` (#932, fixes #931). Thanks @ThiagoCAltoe! +- Gemini: refresh OAuth credentials when the CLI has a refresh token but no cached access token instead of reporting "not logged in" after authentication (#915). +- Gemini: label OAuth-backed API fetches as `oauth-api` instead of plain `api` (#930). Thanks @ThiagoCAltoe! - Codex: keep session and weekly quota-warning marker thresholds independent so usage bars do not duplicate marker lines (#938, fixes #927). Thanks @iam-brain! -- Menu: avoid rebuilding visible menus during background open-menu refreshes so hover submenus stay responsive (#923, fixes #909). Thanks @AmrMohamad! - Codex: coalesce historical pace reset timestamps into 5-minute buckets so dashboard and live reset jitter do not duplicate weekly history windows (#901). Thanks @zhulijin1991! -- Gemini: refresh OAuth credentials when the CLI has a refresh token but no cached access token instead of reporting "not logged in" after authentication (#915). -- Settings: let stale managed Codex account records be removed even when their stored home path is outside CodexBar's managed-home directory, and keep CLI known-owner tests from writing fixtures into the live app store. +- Menu: middle-truncate long account emails in Codex account controls and keep the Codex account switcher visible during merged-menu refreshes with transient account snapshots. - Settings: apply the selected app language from packaged SwiftPM resources instead of falling back to English when the `.lproj` directory casing differs (#908). -- Claude: handle Enterprise/organization web accounts whose session quota window is null, parse inline spend-limit usage, and expose an optional Org ID field for Claude token accounts (#941, fixes #940). Thanks @clintandrewhall! -- Codex: route menu account refreshes through the resolved live-vs-managed account source so matched accounts keep using the stable `CODEX_HOME` (#932, fixes #931). Thanks @ThiagoCAltoe! +- Settings: let stale managed Codex account records be removed even when their stored home path is outside CodexBar's managed-home directory, and keep CLI known-owner tests from writing fixtures into the live app store. - ChatGPT credits: restrict purchase links to real HTTPS `chatgpt.com` settings/usage/billing/credits paths and drop query/fragment data (#903). Thanks @ThiagoCAltoe! - z.ai: show the MCP quota bucket as monthly instead of a misleading 1-minute window (#904). Thanks @ThiagoCAltoe! -- Menu: middle-truncate long account emails in Codex account controls and keep the Codex account switcher visible during merged-menu refreshes with transient account snapshots. +- Kimi: rebalance provider icon alignment within its viewBox (#912). Thanks @giuseppebisemi! ## 0.25.1 — 2026-05-11 From 837cdb971ab8e36107c2756b75d6cca55ac333d8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 16:19:54 +0100 Subject: [PATCH 24/27] fix: repair update and account menu paths Co-authored-by: Radek Sienkiewicz --- CHANGELOG.md | 3 + Sources/CodexBar/CodexbarApp.swift | 62 +++++++- .../FactoryProviderImplementation.swift | 2 +- ...tusItemController+AccountMenuDisplay.swift | 20 ++- .../StatusItemController+Actions.swift | 2 +- .../Factory/FactoryManualCredentials.swift | 58 ++++++++ .../Factory/FactoryProviderDescriptor.swift | 2 +- .../Factory/FactoryStatusProbe.swift | 49 +++---- .../TokenAccountSupportCatalog+Data.swift | 4 +- .../FactoryManualCredentialTests.swift | 135 ++++++++++++++++++ .../StatusItemControllerMenuTests.swift | 51 +++++++ .../StatusMenuTokenAccountSwitcherTests.swift | 57 ++++++++ 12 files changed, 411 insertions(+), 34 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Factory/FactoryManualCredentials.swift create mode 100644 Tests/CodexBarTests/FactoryManualCredentialTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index af1d722966..af6cb88109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - Localization: add Simplified Chinese translations for Claude peak-hour labels (#921). Thanks @whtis! ### Fixed +- Updates: make the restart/apply-update menu action use Sparkle's prepared install callback on the first click (#947). Thanks @velvet-shark! +- Multi-account menus: keep stacked token-account cards capped to current accounts and ignore stale snapshots from removed accounts (#949). +- Droid: accept pasted Factory `Authorization: Bearer` headers and bearer tokens for manual sessions when cookies alone are insufficient (#914). - Codex: show authenticated plan/account rows as "Limits not available" instead of a red no-rate-limit error when Codex reports profile data but no rate-limit windows yet. - Overview: hide provider rows that only contain an error, and avoid showing a one-item Codex System Account submenu. - Claude: handle Enterprise and organization spend-limit usage across OAuth/web accounts, including null session quota windows, inline spend-limit usage, `extra_usage`-only responses, and token-account Org ID support (#925, #941, fixes #940). Thanks @clintandrewhall! diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 0d5ca0a155..5048744b59 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -125,6 +125,7 @@ protocol UpdaterProviding: AnyObject { var unavailableReason: String? { get } var updateStatus: UpdateStatus { get } func checkForUpdates(_ sender: Any?) + func installUpdate() } /// No-op updater used for debug builds and non-bundled runs to suppress Sparkle dialogs. @@ -140,6 +141,7 @@ final class DisabledUpdaterController: UpdaterProviding { } func checkForUpdates(_ sender: Any?) {} + func installUpdate() {} } @MainActor @@ -158,12 +160,25 @@ import Sparkle @MainActor final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDelegate { + private final class ImmediateInstallHandler: @unchecked Sendable { + private let handler: () -> Void + + init(_ handler: @escaping () -> Void) { + self.handler = handler + } + + func install() { + self.handler() + } + } + private lazy var controller = SPUStandardUpdaterController( startingUpdater: false, updaterDelegate: self, userDriverDelegate: nil) let updateStatus = UpdateStatus() let unavailableReason: String? = nil + private var immediateInstallHandler: ImmediateInstallHandler? init(savedAutoUpdate: Bool) { super.init() @@ -191,20 +206,59 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDele self.controller.checkForUpdates(sender) } - nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { - Task { @MainActor in - self.updateStatus.isUpdateReady = true + func installUpdate() { + guard let immediateInstallHandler else { + self.controller.checkForUpdates(nil) + return } + + immediateInstallHandler.install() + } + + nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { + _ = updater + _ = item } nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { + _ = updater + _ = item + _ = error Task { @MainActor in + self.immediateInstallHandler = nil self.updateStatus.isUpdateReady = false } } nonisolated func userDidCancelDownload(_ updater: SPUUpdater) { + _ = updater + Task { @MainActor in + self.immediateInstallHandler = nil + self.updateStatus.isUpdateReady = false + } + } + + nonisolated func updater( + _ updater: SPUUpdater, + willInstallUpdateOnQuit item: SUAppcastItem, + immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) + -> Bool + { + _ = updater + _ = item + let installHandler = ImmediateInstallHandler(immediateInstallHandler) + Task { @MainActor in + self.immediateInstallHandler = installHandler + self.updateStatus.isUpdateReady = true + } + return true + } + + nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { + _ = updater + _ = error Task { @MainActor in + self.immediateInstallHandler = nil self.updateStatus.isUpdateReady = false } } @@ -219,10 +273,12 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding, SPUUpdaterDele Task { @MainActor in switch choice { case .install, .skip: + self.immediateInstallHandler = nil self.updateStatus.isUpdateReady = false case .dismiss: self.updateStatus.isUpdateReady = downloaded @unknown default: + self.immediateInstallHandler = nil self.updateStatus.isUpdateReady = false } } diff --git a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift index 237ec0db82..c9a759cbac 100644 --- a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift @@ -49,7 +49,7 @@ struct FactoryProviderImplementation: ProviderImplementation { source: context.settings.factoryCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies and WorkOS tokens.", - manual: "Paste a Cookie header from app.factory.ai.", + manual: "Paste a Cookie or Authorization header from app.factory.ai.", off: "Factory cookies are disabled.") } diff --git a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift index b38481d87a..c3564ae48c 100644 --- a/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift +++ b/Sources/CodexBar/StatusItemController+AccountMenuDisplay.swift @@ -8,15 +8,31 @@ extension StatusItemController { guard accounts.count > 1 else { return nil } let activeIndex = self.settings.tokenAccountsData(for: provider)?.clampedActiveIndex() ?? 0 let showAll = self.settings.multiAccountMenuLayout == .stacked - let snapshots = showAll ? (self.store.accountSnapshots[provider] ?? []) : [] + let displayAccounts = showAll + ? self.store.limitedTokenAccounts(accounts, selected: self.settings.selectedTokenAccount(for: provider)) + : accounts + let snapshots = showAll + ? self.tokenAccountSnapshots(for: provider, matching: displayAccounts) + : [] return TokenAccountMenuDisplay( provider: provider, - accounts: accounts, + accounts: displayAccounts, snapshots: snapshots, activeIndex: activeIndex, layout: showAll ? .stacked : .segmented) } + private func tokenAccountSnapshots( + for provider: UsageProvider, + matching accounts: [ProviderTokenAccount]) -> [TokenAccountUsageSnapshot] + { + var snapshotsByID: [UUID: TokenAccountUsageSnapshot] = [:] + for snapshot in self.store.accountSnapshots[provider] ?? [] { + snapshotsByID[snapshot.account.id] = snapshot + } + return accounts.compactMap { snapshotsByID[$0.id] } + } + func codexAccountMenuDisplay(for provider: UsageProvider) -> CodexAccountMenuDisplay? { guard provider == .codex else { return nil } let projection = self.settings.codexVisibleAccountProjection diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index ca3a1659c9..9b376c10d9 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -43,7 +43,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } @objc func installUpdate() { - self.updater.checkForUpdates(nil) + self.updater.installUpdate() } @objc func openDashboard() { diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryManualCredentials.swift b/Sources/CodexBarCore/Providers/Factory/FactoryManualCredentials.swift new file mode 100644 index 0000000000..ae4698a534 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Factory/FactoryManualCredentials.swift @@ -0,0 +1,58 @@ +import Foundation + +extension FactoryStatusProbe { + struct ManualCredentials { + let cookieHeader: String? + let bearerToken: String? + } + + static func manualCredentials(from raw: String?) -> ManualCredentials? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + let normalizedCookieHeader = CookieHeaderNormalizer.normalize(raw) + let cookieHeader = normalizedCookieHeader.flatMap { + CookieHeaderNormalizer.pairs(from: $0).isEmpty ? nil : $0 + } + let bearerToken = self.authorizationBearerToken(from: raw) + ?? normalizedCookieHeader.flatMap(self.bearerToken(fromHeader:)) + ?? self.bareBearerToken(from: raw) + guard cookieHeader != nil || bearerToken != nil else { return nil } + return ManualCredentials(cookieHeader: cookieHeader, bearerToken: bearerToken) + } + + static func bearerToken(fromHeader cookieHeader: String) -> String? { + for pair in CookieHeaderNormalizer.pairs(from: cookieHeader) where pair.name == "access-token" { + let token = pair.value.trimmingCharacters(in: .whitespacesAndNewlines) + if !token.isEmpty { return token } + } + return nil + } + + private static func authorizationBearerToken(from raw: String) -> String? { + let pattern = #"(?i)(?:authorization\s*:\s*)?bearer\s+([A-Za-z0-9._~+/=-]+)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let range = NSRange(raw.startIndex..= 2, + let tokenRange = Range(match.range(at: 1), in: raw) + else { + return nil + } + let token = raw[tokenRange].trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty ? nil : String(token) + } + + private static func bareBearerToken(from raw: String) -> String? { + let token = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.contains("="), + !token.contains(";"), + !token.contains(" "), + !token.contains("\n"), + token.count >= 40 || token.split(separator: ".").count >= 3 + else { + return nil + } + return token + } +} diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift index dbbe1509df..815cd537af 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryProviderDescriptor.swift @@ -65,6 +65,6 @@ struct FactoryStatusFetchStrategy: ProviderFetchStrategy { private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { guard context.settings?.factory?.cookieSource == .manual else { return nil } - return CookieHeaderNormalizer.normalize(context.settings?.factory?.manualCookieHeader) + return context.settings?.factory?.manualCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines) } } diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift index cbe8c244e2..c4b3fe338d 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift @@ -835,24 +835,33 @@ public struct FactoryStatusProbe: Sendable { let log: (String) -> Void = { msg in logger?("[factory] \(msg)") } var lastError: Error? - if let override = CookieHeaderNormalizer.normalize(cookieHeaderOverride) { - log("Using manual cookie header") - let bearer = Self.bearerToken(fromHeader: override) - let candidates = [ - self.baseURL, - Self.authBaseURL, - Self.apiBaseURL, - ] - for baseURL in candidates { - do { - return try await self.fetchWithCookieHeader( - override, - bearerToken: bearer, - baseURL: baseURL) - } catch { - lastError = error + let manualOverride = cookieHeaderOverride?.trimmingCharacters(in: .whitespacesAndNewlines) + if manualOverride?.isEmpty == false { + guard let override = Self.manualCredentials(from: manualOverride) else { + throw FactoryStatusProbeError.noSessionCookie + } + if let cookieHeader = override.cookieHeader { + log("Using manual cookie header") + let candidates = [ + self.baseURL, + Self.authBaseURL, + Self.apiBaseURL, + ] + for baseURL in candidates { + do { + return try await self.fetchWithCookieHeader( + cookieHeader, + bearerToken: override.bearerToken, + baseURL: baseURL) + } catch { + lastError = error + } } } + if let bearerToken = override.bearerToken { + log("Using manual Factory bearer token") + return try await self.fetchWithBearerToken(bearerToken, logger: log) + } if let lastError { throw lastError } throw FactoryStatusProbeError.noSessionCookie } @@ -1197,14 +1206,6 @@ public struct FactoryStatusProbe: Sendable { cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") } - private static func bearerToken(fromHeader cookieHeader: String) -> String? { - for pair in CookieHeaderNormalizer.pairs(from: cookieHeader) where pair.name == "access-token" { - let token = pair.value.trimmingCharacters(in: .whitespacesAndNewlines) - if !token.isEmpty { return token } - } - return nil - } - private func fetchWithCookieHeader( _ cookieHeader: String, bearerToken: String?, diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 626989067d..36e053995d 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -53,8 +53,8 @@ extension TokenAccountSupportCatalog { cookieName: nil), .factory: TokenAccountSupport( title: "Session tokens", - subtitle: "Store multiple Factory Cookie headers.", - placeholder: "Cookie: …", + subtitle: "Store multiple Factory Cookie or Authorization headers.", + placeholder: "Cookie: … or Authorization: Bearer …", injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), diff --git a/Tests/CodexBarTests/FactoryManualCredentialTests.swift b/Tests/CodexBarTests/FactoryManualCredentialTests.swift new file mode 100644 index 0000000000..33edb0f9b9 --- /dev/null +++ b/Tests/CodexBarTests/FactoryManualCredentialTests.swift @@ -0,0 +1,135 @@ +import CodexBarCore +import Foundation +import Testing + +extension FactoryStatusProbeFetchTests { + @Test + func `rejects malformed manual override before cached cookies`() async throws { + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(FactoryStubURLProtocol.self) + } + FactoryStubURLProtocol.handler = nil + FactoryStubURLProtocol.requests = [] + CookieHeaderCache.clear(provider: .factory) + } + FactoryStubURLProtocol.requests = [] + CookieHeaderCache.store(provider: .factory, cookieHeader: "session=cached", sourceLabel: "Chrome") + FactoryStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse(url: url, body: "{}", statusCode: 200) + } + + let probe = FactoryStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + await #expect { + _ = try await probe.fetch(cookieHeaderOverride: "definitely not a cookie or bearer") + } throws: { error in + guard case FactoryStatusProbeError.noSessionCookie = error else { return false } + return true + } + #expect(FactoryStubURLProtocol.requests.isEmpty) + } + + @Test + func `falls back to bearer authorization when pasted cookie is stale`() async throws { + let registered = URLProtocol.registerClass(FactoryStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(FactoryStubURLProtocol.self) + } + FactoryStubURLProtocol.handler = nil + FactoryStubURLProtocol.requests = [] + } + FactoryStubURLProtocol.requests = [] + + FactoryStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if request.value(forHTTPHeaderField: "Cookie")?.contains("stale-session") == true { + return Self.makeResponse(url: url, body: "{}", statusCode: 401) + } + guard request.value(forHTTPHeaderField: "Authorization") == "Bearer factory-access-token" else { + return Self.makeResponse(url: url, body: "{}", statusCode: 401) + } + #expect(request.value(forHTTPHeaderField: "Cookie") == nil) + if url.host == "api.factory.ai", url.path == "/api/app/auth/me" { + let body = """ + { + "organization": { + "id": "org_1", + "name": "Acme", + "subscription": { + "factoryTier": "team", + "orbSubscription": { + "plan": { "name": "Team", "id": "plan_1" }, + "status": "active" + } + } + }, + "userProfile": { + "id": "user-1", + "email": "user@example.com" + } + } + """ + return Self.makeResponse(url: url, body: body) + } + if url.host == "api.factory.ai", url.path == "/api/billing/limits" { + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + if url.host == "api.factory.ai", url.path == "/api/organization/subscription/usage" { + let body = """ + { + "usage": { + "standard": { + "userTokens": 100, + "totalAllowance": 1000, + "usedRatio": 0.10 + } + }, + "userId": "user-1" + } + """ + return Self.makeResponse(url: url, body: body) + } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + let probe = FactoryStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + let snapshot = try await probe.fetch( + cookieHeaderOverride: "Cookie: session=stale-session\nAuthorization: Bearer factory-access-token") + + #expect(snapshot.userId == "user-1") + #expect(snapshot.standardUserTokens == 100) + #expect(snapshot.standardAllowance == 1000) + #expect(Self.requestTrace() == [ + "GET app.factory.ai/api/app/auth/me", + "GET auth.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/app/auth/me", + "GET api.factory.ai/api/billing/limits", + "GET api.factory.ai/api/organization/subscription/usage?useCache=true&userId=user-1", + ]) + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private static func requestTrace() -> [String] { + FactoryStubURLProtocol.requests.compactMap { request in + guard let url = request.url else { return nil } + let query = url.query.map { "?\($0)" } ?? "" + return "\(request.httpMethod ?? "?") \(url.host ?? "unknown")\(url.path)\(query)" + } + } +} diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index 753ab87801..9e3f2d3de8 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -5,6 +5,26 @@ import Testing @testable import CodexBar struct StatusItemControllerMenuTests { + @MainActor + private final class RecordingUpdater: UpdaterProviding { + var automaticallyChecksForUpdates = false + var automaticallyDownloadsUpdates = false + let isAvailable = true + let unavailableReason: String? = nil + let updateStatus = UpdateStatus(isUpdateReady: true) + var checkForUpdatesCount = 0 + var installUpdateCount = 0 + + func checkForUpdates(_ sender: Any?) { + _ = sender + self.checkForUpdatesCount += 1 + } + + func installUpdate() { + self.installUpdateCount += 1 + } + } + private func makeSnapshot( primary: RateWindow?, secondary: RateWindow?, @@ -110,4 +130,35 @@ struct StatusItemControllerMenuTests { submenuMenu.addItem(parentItem) #expect(ceil(submenuMenu.size.width) < 310) } + + @Test + @MainActor + func `update menu action installs prepared update instead of checking again`() throws { + let suite = "StatusItemControllerMenuTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let updater = RecordingUpdater() + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: updater, + preferencesSelection: PreferencesSelection(), + statusBar: .system) + + controller.installUpdate() + + #expect(updater.installUpdateCount == 1) + #expect(updater.checkForUpdatesCount == 0) + } } diff --git a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift index 22702c871b..0ae73e8c79 100644 --- a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift @@ -207,6 +207,63 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { XCTAssertEqual(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, ["menuCard-0", "menuCard-1"]) } + func test_multiAccountStackedLayoutIgnoresStaleSnapshotsAndKeepsMenuCapped() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnly(.copilot, settings) + for index in 0..<8 { + settings.addTokenAccount(provider: .copilot, label: "Account \(index)", token: "gh_\(index)") + } + settings.setActiveTokenAccountIndex(7, for: .copilot) + let accounts = settings.tokenAccounts(for: .copilot) + let staleAccounts = (0..<2).map { index in + ProviderTokenAccount( + id: UUID(), + label: "Removed \(index)", + token: "stale_\(index)", + addedAt: TimeInterval(index), + lastUsed: nil) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let staleSnapshots = staleAccounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(percent: Double(70 + index)), + error: nil, + sourceLabel: "stale") + } + let currentSnapshots = accounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(percent: Double(10 + index)), + error: nil, + sourceLabel: "current") + } + store.accountSnapshots[.copilot] = staleSnapshots + currentSnapshots + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + XCTAssertNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + XCTAssertEqual( + self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, + ["menuCard-0", "menuCard-1", "menuCard-2", "menuCard-3", "menuCard-4", "menuCard-5"]) + } + func test_tokenAccountSwitchDefersOpenMenuRebuildUntilAfterSwitcherAction() async throws { self.disableMenuCardsForTesting() StatusItemController.setMenuRefreshEnabledForTesting(true) From eba4b6fc45f1ec9ab5bd73b14526a42218ce377a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 18:58:36 +0100 Subject: [PATCH 25/27] fix: handle account-scoped usage and menu login bugs --- CHANGELOG.md | 3 + Sources/CodexBar/CursorLoginRunner.swift | 238 +++++------------- Sources/CodexBar/MenuCardView.swift | 13 +- .../Providers/Cursor/CursorLoginFlow.swift | 14 +- .../CodexBar/StatusItemController+Menu.swift | 12 +- .../StatusItemController+SwitcherViews.swift | 16 ++ Sources/CodexBar/UsageStore+TokenCost.swift | 12 + Sources/CodexBar/UsageStore.swift | 16 +- Sources/CodexBarCore/CostUsageFetcher.swift | 14 +- .../CostUsageScanner+CodexPriority.swift | 2 +- .../Vendored/CostUsage/CostUsageScanner.swift | 7 +- .../CodexBarTests/CostUsageFetcherTests.swift | 65 +++++ .../CursorLoginRunnerTests.swift | 71 ++++++ Tests/CodexBarTests/MenuCardModelTests.swift | 1 + .../StatusMenuSwitcherClickTests.swift | 23 ++ 15 files changed, 297 insertions(+), 210 deletions(-) create mode 100644 Tests/CodexBarTests/CursorLoginRunnerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index af6cb88109..4efb6ba550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ - Localization: add Simplified Chinese translations for Claude peak-hour labels (#921). Thanks @whtis! ### Fixed +- Codex: scope local cost history to the selected managed account's `CODEX_HOME` and label cost cards as local-log estimates (#910). +- Cursor: open Add Account in the user's browser and import the resulting browser session instead of trapping login in an embedded web view (#922). +- Menu: disable implicit provider-switcher layer animations and reuse the deferred rebuild path so open menus stay stable under pointer movement (#950). - Updates: make the restart/apply-update menu action use Sparkle's prepared install callback on the first click (#947). Thanks @velvet-shark! - Multi-account menus: keep stacked token-account cards capped to current accounts and ignore stale snapshots from removed accounts (#949). - Droid: accept pasted Factory `Authorization: Bearer` headers and bearer tokens for manual sessions when cookies alone are insufficient (#914). diff --git a/Sources/CodexBar/CursorLoginRunner.swift b/Sources/CodexBar/CursorLoginRunner.swift index f2b48f215d..637beaec68 100644 --- a/Sources/CodexBar/CursorLoginRunner.swift +++ b/Sources/CodexBar/CursorLoginRunner.swift @@ -1,12 +1,10 @@ import AppKit import CodexBarCore import Foundation -import WebKit -/// Handles Cursor login flow using a WebKit-based browser window. -/// Captures session cookies after successful authentication. +/// Opens Cursor in the user's browser and waits until the normal browser-cookie importer can read a session. @MainActor -final class CursorLoginRunner: NSObject { +final class CursorLoginRunner { enum Phase { case loading case waitingLogin @@ -25,198 +23,82 @@ final class CursorLoginRunner: NSObject { let email: String? } - private let browserDetection: BrowserDetection - private var webView: WKWebView? - private var window: NSWindow? - private var continuation: CheckedContinuation? - private var phaseCallback: ((Phase) -> Void)? - private var hasCompletedLogin = false + typealias SnapshotLoader = @Sendable () async throws -> CursorStatusSnapshot + typealias Sleeper = @Sendable (UInt64) async throws -> Void + + private let loadSnapshot: SnapshotLoader + private let openURL: @MainActor (URL) -> Bool + private let sleeper: Sleeper + private let timeout: TimeInterval + private let pollInterval: TimeInterval private let logger = CodexBarLog.logger(LogCategories.cursorLogin) - private static let dashboardURL = URL(string: "https://cursor.com/dashboard")! - private static let loginURLPattern = "authenticator.cursor.sh" + static let dashboardURL = URL(string: "https://cursor.com/dashboard")! - init(browserDetection: BrowserDetection) { - self.browserDetection = browserDetection - super.init() + init( + browserDetection: BrowserDetection, + timeout: TimeInterval = 120, + pollInterval: TimeInterval = 2, + openURL: @escaping @MainActor (URL) -> Bool = { NSWorkspace.shared.open($0) }, + loadSnapshot: SnapshotLoader? = nil, + sleeper: @escaping Sleeper = { try await Task.sleep(nanoseconds: $0) }) + { + self.timeout = timeout + self.pollInterval = pollInterval + self.openURL = openURL + self.sleeper = sleeper + self.loadSnapshot = loadSnapshot ?? { + let probe = CursorStatusProbe(browserDetection: browserDetection) + return try await probe.fetch() + } } - /// Runs the Cursor login flow in a browser window. - /// Returns the result after the user completes login or cancels. - func run(onPhaseChange: @escaping @Sendable (Phase) -> Void) async -> Result { - // Keep this instance alive during the flow. - WebKitTeardown.retain(self) - self.phaseCallback = onPhaseChange + func run(onPhaseChange: @escaping @MainActor (Phase) -> Void) async -> Result { onPhaseChange(.loading) self.logger.info("Cursor login started") - return await withCheckedContinuation { continuation in - self.continuation = continuation - self.setupWindow() - } - } - - private func setupWindow() { - // Use a non-persistent store for the login flow; cookies are persisted explicitly. - let config = WKWebViewConfiguration() - config.websiteDataStore = .nonPersistent() - - let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 480, height: 640), configuration: config) - webView.navigationDelegate = self - self.webView = webView - - // Create window - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 640), - styleMask: [.titled, .closable, .resizable], - backing: .buffered, - defer: false) - window.isReleasedWhenClosed = false - window.title = "Cursor Login" - window.contentView = webView - window.center() - window.delegate = self - window.makeKeyAndOrderFront(nil) - self.window = window - self.logger.info("Cursor login window opened") - - // Navigate to dashboard (will redirect to login if not authenticated) - let request = URLRequest(url: Self.dashboardURL) - webView.load(request) - } - - private func complete(with result: Result) { - guard let continuation = self.continuation else { return } - self.continuation = nil - self.logger.info("Cursor login completed", metadata: ["outcome": "\(result.outcome)"]) - self.scheduleCleanup() - continuation.resume(returning: result) - } - - private func scheduleCleanup() { - self.logger.info("Cursor login window closing") - WebKitTeardown.scheduleCleanup(owner: self, window: self.window, webView: self.webView) - } - - private func captureSessionCookies() async { - guard let webView = self.webView else { return } - - let dataStore = webView.configuration.websiteDataStore - let cookies = await dataStore.httpCookieStore.allCookies() - - // Filter for cursor.com cookies - let cursorCookies = cookies.filter { cookie in - cookie.domain.contains("cursor.com") || cookie.domain.contains("cursor.sh") - } - - guard !cursorCookies.isEmpty else { - self.phaseCallback?(.failed("No session cookies found")) - self.logger.warning("Cursor login failed: no session cookies found") - self.complete(with: Result(outcome: .failed("No session cookies found"), email: nil)) - return - } - - // Save cookies to the session store - await CursorSessionStore.shared.setCookies(cursorCookies) - self.logger.info("Cursor session cookies captured", metadata: ["count": "\(cursorCookies.count)"]) - - // Try to get user email - let email = await self.fetchUserEmail() - - self.hasCompletedLogin = true - self.phaseCallback?(.success) - self.complete(with: Result(outcome: .success, email: email)) - } - - private func fetchUserEmail() async -> String? { - do { - let probe = CursorStatusProbe(browserDetection: self.browserDetection) - let snapshot = try await probe.fetch() - return snapshot.accountEmail - } catch { - return nil + guard self.openURL(Self.dashboardURL) else { + let message = "Could not open Cursor login in your browser." + onPhaseChange(.failed(message)) + self.logger.error("Cursor login browser launch failed") + return Result(outcome: .failed(message), email: nil) } - } -} - -// MARK: - WKNavigationDelegate - -extension CursorLoginRunner: WKNavigationDelegate { - nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - Task { @MainActor in - guard let url = webView.url else { return } - - let urlString = url.absoluteString - // Check if on login page - if urlString.contains(Self.loginURLPattern) { - self.phaseCallback?(.waitingLogin) - return - } + onPhaseChange(.waitingLogin) + let deadline = Date().addingTimeInterval(self.timeout) + var lastError: Error? - // Check if on dashboard (login successful) - if urlString.contains("cursor.com/dashboard"), !self.hasCompletedLogin { - await self.captureSessionCookies() + repeat { + if Task.isCancelled { + self.logger.info("Cursor login cancelled") + return Result(outcome: .cancelled, email: nil) } - } - } - - nonisolated func webView( - _ webView: WKWebView, - didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) - { - Task { @MainActor in - guard let url = webView.url else { return } - let urlString = url.absoluteString - // Detect redirect to dashboard after login - if urlString.contains("cursor.com/dashboard"), !self.hasCompletedLogin { - // Wait a moment for cookies to be set, then capture - try? await Task.sleep(nanoseconds: 500_000_000) - await self.captureSessionCookies() + do { + let snapshot = try await self.loadSnapshot() + onPhaseChange(.success) + self.logger.info("Cursor login completed", metadata: ["outcome": "success"]) + return Result(outcome: .success, email: snapshot.accountEmail) + } catch { + lastError = error } - } - } - nonisolated func webView( - _ webView: WKWebView, - didFail navigation: WKNavigation!, - withError error: Error) - { - Task { @MainActor in - self.phaseCallback?(.failed(error.localizedDescription)) - self.logger.error("Cursor login navigation failed", metadata: ["error": error.localizedDescription]) - self.complete(with: Result(outcome: .failed(error.localizedDescription), email: nil)) - } - } + guard Date() < deadline else { break } + let delay = UInt64(max(0.1, self.pollInterval) * 1_000_000_000) + try? await self.sleeper(delay) + } while true - nonisolated func webView( - _ webView: WKWebView, - didFailProvisionalNavigation navigation: WKNavigation!, - withError error: Error) - { - Task { @MainActor in - // Ignore cancelled navigations (common during redirects) - let nsError = error as NSError - if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { - return - } - self.phaseCallback?(.failed(error.localizedDescription)) - self.logger.error("Cursor login navigation failed", metadata: ["error": error.localizedDescription]) - self.complete(with: Result(outcome: .failed(error.localizedDescription), email: nil)) - } + let message = Self.timeoutMessage(lastError: lastError) + onPhaseChange(.failed(message)) + self.logger.warning("Cursor login timed out", metadata: ["error": message]) + return Result(outcome: .failed(message), email: nil) } -} -// MARK: - NSWindowDelegate - -extension CursorLoginRunner: NSWindowDelegate { - nonisolated func windowWillClose(_ notification: Notification) { - Task { @MainActor in - if !self.hasCompletedLogin { - self.logger.info("Cursor login cancelled") - self.complete(with: Result(outcome: .cancelled, email: nil)) - } + private static func timeoutMessage(lastError: Error?) -> String { + let hint = "Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." + guard let lastError else { + return "Timed out waiting for Cursor login. \(hint)" } + return "Timed out waiting for Cursor login. \(hint) Last error: \(lastError.localizedDescription)" } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5020e733c6..aad095fc48 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1556,11 +1556,22 @@ extension UsageMenuCardView.Model { return TokenUsageSection( sessionLine: sessionLine, monthLine: monthLine, - hintLine: nil, + hintLine: Self.tokenUsageHint(provider: provider), errorLine: err, errorCopyText: (error?.isEmpty ?? true) ? nil : error) } + private static func tokenUsageHint(provider: UsageProvider) -> String? { + switch provider { + case .codex: + "Estimated from local Codex logs for the selected account." + case .claude, .vertexai: + "Estimated from local logs; may differ from your bill." + default: + nil + } + } + private static func providerCostSection( provider: UsageProvider, cost: ProviderCostSnapshot?) -> ProviderCostSection? diff --git a/Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift b/Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift index ff65fb5393..04f75022d2 100644 --- a/Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift +++ b/Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift @@ -4,14 +4,12 @@ import CodexBarCore extension StatusItemController { func runCursorLoginFlow() async { let cursorRunner = CursorLoginRunner(browserDetection: self.store.browserDetection) - let phaseHandler: @Sendable (CursorLoginRunner.Phase) -> Void = { [weak self] phase in - Task { @MainActor in - switch phase { - case .loading, .waitingLogin: - self?.loginPhase = .waitingBrowser - case .success, .failed: - self?.loginPhase = .idle - } + let phaseHandler: @MainActor (CursorLoginRunner.Phase) -> Void = { [weak self] phase in + switch phase { + case .loading, .waitingLogin: + self?.loginPhase = .waitingBrowser + case .success, .failed: + self?.loginPhase = .idle } } let result = await cursorRunner.run(onPhaseChange: phaseHandler) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 90a91656a4..92a99960f3 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -934,17 +934,7 @@ extension StatusItemController { self.lastMenuProvider = provider } self.lastMergedSwitcherSelection = selection - self.providerSwitcherUpdateToken &+= 1 - let updateToken = self.providerSwitcherUpdateToken - Task { @MainActor [weak self, weak menu] in - await Task.yield() - guard let self, let menu else { return } - guard self.providerSwitcherUpdateToken == updateToken else { return } - self.applyIcon(phase: nil) - guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - } + self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider) }) let item = NSMenuItem() item.view = view diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 047e25b358..b24a932d45 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -1,5 +1,6 @@ import AppKit import CodexBarCore +import QuartzCore enum ProviderSwitcherSelection: Equatable { case overview @@ -585,6 +586,10 @@ final class ProviderSwitcherView: NSView { } private func updateButtonStyles() { + CATransaction.begin() + CATransaction.setDisableActions(true) + defer { CATransaction.commit() } + for button in self.buttons { let isSelected = button.state == .on let isHovered = self.hoveredButtonTag == button.tag @@ -602,6 +607,17 @@ final class ProviderSwitcherView: NSView { } } + #if DEBUG + func _test_buttonFrames() -> [NSRect] { + self.buttons.map(\.frame) + } + + func _test_setHoveredButtonTag(_ tag: Int?) { + self.hoveredButtonTag = tag + self.updateButtonStyles() + } + #endif + private func isLightMode() -> Bool { self.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index f00f14504d..d9317b0dfc 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -18,6 +18,18 @@ extension UsageStore { self.tokenRefreshInFlight.contains(provider) } + func tokenCostScope(for provider: UsageProvider) -> (codexHomePath: String?, signature: String) { + guard provider == .codex else { + return (nil, provider.rawValue) + } + let homePath = self.settings.activeManagedCodexRemoteHomePath? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let homePath, !homePath.isEmpty else { + return (nil, "codex:ambient") + } + return (homePath, "codex:managed:\(homePath)") + } + nonisolated static func costUsageCacheDirectory( fileManager: FileManager = .default) -> URL { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 86f980a4ac..40f52b9a8e 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -222,6 +222,7 @@ final class UsageStore { @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] @ObservationIgnored var quotaWarningState: [QuotaWarningStateKey: QuotaWarningState] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] + @ObservationIgnored var lastTokenFetchScope: [UsageProvider: String] = [:] @ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] @ObservationIgnored var weeklyLimitResetDetectorStates: [String: WeeklyLimitResetDetectorState] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @@ -1331,6 +1332,7 @@ extension UsageStore { self.tokenSnapshots.removeAll() self.tokenErrors.removeAll() self.lastTokenFetchAt.removeAll() + self.lastTokenFetchScope.removeAll() self.tokenFailureGates[.codex]?.reset() self.tokenFailureGates[.claude]?.reset() return nil @@ -1342,6 +1344,7 @@ extension UsageStore { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) return } @@ -1350,6 +1353,7 @@ extension UsageStore { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) return } @@ -1358,19 +1362,23 @@ extension UsageStore { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenFetchScope.removeValue(forKey: provider) return } guard !self.tokenRefreshInFlight.contains(provider) else { return } let now = Date() + let costScope = self.tokenCostScope(for: provider) if !force, let last = self.lastTokenFetchAt[provider], + self.lastTokenFetchScope[provider] == costScope.signature, now.timeIntervalSince(last) < self.tokenFetchTTL { return } self.lastTokenFetchAt[provider] = now + self.lastTokenFetchScope[provider] = costScope.signature self.tokenRefreshInFlight.insert(provider) defer { self.tokenRefreshInFlight.remove(provider) } @@ -1382,18 +1390,14 @@ extension UsageStore { do { let fetcher = self.costUsageFetcher let timeoutSeconds = self.tokenFetchTimeout - // CostUsageFetcher scans local Codex session logs from this machine. That data is - // intentionally presented as provider-level local telemetry rather than managed-account - // remote state, so managed Codex account selection does not retarget this fetch. - // If the UI later needs account-scoped token history, it should label and source that - // separately instead of silently changing the meaning of this section. let snapshot = try await withThrowingTaskGroup(of: CostUsageTokenSnapshot.self) { group in group.addTask(priority: .utility) { try await fetcher.loadTokenSnapshot( provider: provider, now: now, forceRefresh: force, - allowVertexClaudeFallback: !self.isEnabled(.claude)) + allowVertexClaudeFallback: !self.isEnabled(.claude), + codexHomePath: costScope.codexHomePath) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 190b8ee95d..db94e62244 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -24,13 +24,15 @@ public struct CostUsageFetcher: Sendable { provider: UsageProvider, now: Date = Date(), forceRefresh: Bool = false, - allowVertexClaudeFallback: Bool = false) async throws -> CostUsageTokenSnapshot + allowVertexClaudeFallback: Bool = false, + codexHomePath: String? = nil) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( provider: provider, now: now, forceRefresh: forceRefresh, - allowVertexClaudeFallback: allowVertexClaudeFallback) + allowVertexClaudeFallback: allowVertexClaudeFallback, + codexHomePath: codexHomePath) } static func loadTokenSnapshot( @@ -38,6 +40,7 @@ public struct CostUsageFetcher: Sendable { now: Date = Date(), forceRefresh: Bool = false, allowVertexClaudeFallback: Bool = false, + codexHomePath: String? = nil, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, piScannerOptions overridePiScannerOptions: PiSessionCostScanner .Options? = nil) async throws -> CostUsageTokenSnapshot @@ -51,6 +54,13 @@ public struct CostUsageFetcher: Sendable { let since = Calendar.current.date(byAdding: .day, value: -29, to: now) ?? now var options = overrideScannerOptions ?? CostUsageScanner.Options() + if provider == .codex, + let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !codexHomePath.isEmpty + { + options.codexSessionsRoot = URL(fileURLWithPath: codexHomePath, isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + } if provider == .codex || provider == .claude { await ModelsDevPricingPipeline.refreshIfNeeded(now: now, cacheRoot: options.cacheRoot) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift index d1eaceac24..4050ca8e44 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift @@ -4,7 +4,7 @@ import SQLite3 #endif extension CostUsageScanner { - struct CodexPriorityTurnMetadata: Codable, Equatable, Sendable { + struct CodexPriorityTurnMetadata: Codable, Equatable { var threadID: String? var turnID: String var model: String? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index f18183920b..1e82b8f34c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -1055,7 +1055,11 @@ enum CostUsageScanner { let nowMs = Int64(now.timeIntervalSince1970 * 1000) let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) + let roots = self.codexSessionsRoots(options: options) + let rootsFingerprint = Self.codexRootsFingerprint(roots) + let rootsChanged = cache.roots != nil && cache.roots != rootsFingerprint let shouldRefresh = options.forceRescan + || rootsChanged || refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs @@ -1065,10 +1069,7 @@ enum CostUsageScanner { cache = CostUsageCache() } - let roots = self.codexSessionsRoots(options: options) let includeRecursive = options.forceRescan - let rootsFingerprint = Self.codexRootsFingerprint(roots) - let rootsChanged = cache.roots != nil && cache.roots != rootsFingerprint let shouldRunColdCacheLookback = cache.files.isEmpty || rootsChanged let coldCacheLookbackStart = Self.parseDayKey(range.scanSinceKey) .map { Calendar.current.startOfDay(for: $0) } diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index f27e40e3f3..f7be5b42c3 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -3,6 +3,71 @@ import Testing @testable import CodexBarCore struct CostUsageFetcherTests { + @Test + func `fetcher scopes codex history to selected codex home`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + func writeSessionFile( + homeRoot: URL, + day: Date, + filename: String, + tokens: Int) throws + { + let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) + let dir = homeRoot + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(String(format: "%04d", comps.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent(filename, isDirectory: false) + let model = "openai/gpt-5.4" + try env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ], + ]).write(to: url, atomically: true, encoding: .utf8) + } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let otherHome = env.root.appendingPathComponent("other-codex-home", isDirectory: true) + try writeSessionFile(homeRoot: env.codexHomeRoot, day: day, filename: "ambient.jsonl", tokens: 100) + try writeSessionFile(homeRoot: otherHome, day: day, filename: "managed.jsonl", tokens: 10) + + let options = CostUsageScanner.Options(cacheRoot: env.cacheRoot) + let ambient = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + codexHomePath: env.codexHomeRoot.path, + scannerOptions: options) + let managed = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + codexHomePath: otherHome.path, + scannerOptions: options) + + #expect(ambient.sessionTokens == 100) + #expect(managed.sessionTokens == 10) + } + @Test func `fetcher merges native and pi codex history with normalized model names`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CursorLoginRunnerTests.swift b/Tests/CodexBarTests/CursorLoginRunnerTests.swift new file mode 100644 index 0000000000..7b173f08e4 --- /dev/null +++ b/Tests/CodexBarTests/CursorLoginRunnerTests.swift @@ -0,0 +1,71 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct CursorLoginRunnerTests { + @Test + func `login opens Cursor dashboard in browser before polling cookies`() async { + var openedURLs: [URL] = [] + var phases: [String] = [] + + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + timeout: 1, + pollInterval: 0.01, + openURL: { url in + openedURLs.append(url) + return true + }, + loadSnapshot: { + CursorStatusSnapshot( + planPercentUsed: 12, + planUsedUSD: 1, + planLimitUSD: 20, + onDemandUsedUSD: 0, + onDemandLimitUSD: nil, + teamOnDemandUsedUSD: nil, + teamOnDemandLimitUSD: nil, + billingCycleEnd: nil, + membershipType: "pro", + accountEmail: "cursor@example.com", + accountName: nil, + rawJSON: nil) + }, + sleeper: { _ in }) + + let result = await runner.run { phase in + switch phase { + case .loading: phases.append("loading") + case .waitingLogin: phases.append("waitingLogin") + case .success: phases.append("success") + case let .failed(message): phases.append("failed:\(message)") + } + } + + #expect(openedURLs == [CursorLoginRunner.dashboardURL]) + #expect(phases == ["loading", "waitingLogin", "success"]) + #expect(result.email == "cursor@example.com") + } + + @Test + func `login reports launch failure when browser cannot open`() async { + let runner = CursorLoginRunner( + browserDetection: BrowserDetection(cacheTTL: 0), + openURL: { _ in false }, + loadSnapshot: { + Issue.record("Should not poll cookies when browser launch fails") + throw CursorStatusProbeError.noSessionCookie + }, + sleeper: { _ in }) + + let result = await runner.run { _ in } + + guard case let .failed(message) = result.outcome else { + Issue.record("Expected failed outcome") + return + } + #expect(message.contains("Could not open Cursor login")) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index c0fc230a0a..366c4e0d14 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -532,6 +532,7 @@ struct MenuCardModelTests { #expect(model.tokenUsage?.monthLine.contains("456") == true) #expect(model.tokenUsage?.monthLine.contains("tokens") == true) + #expect(model.tokenUsage?.hintLine == "Estimated from local Codex logs for the selected account.") } @Test diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift index 41dbf926f5..d845e1f550 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -81,4 +81,27 @@ struct StatusMenuSwitcherClickTests { #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .codex) } + + @Test + func `switcher hover styling keeps layout stable`() { + let view = ProviderSwitcherView( + providers: [.codex, .claude, .cursor, .factory, .zai, .minimax, .alibaba], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + + let initialSize = view.intrinsicContentSize + let initialFrames = view._test_buttonFrames() + + view._test_setHoveredButtonTag(3) + view._test_setHoveredButtonTag(6) + view._test_setHoveredButtonTag(nil as Int?) + + #expect(view.intrinsicContentSize == initialSize) + #expect(view._test_buttonFrames() == initialFrames) + } } From a15df5ca5964c4d2372baa430ce64177eb7806c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Larry=20Hao=EF=BC=88=E9=83=9D=E5=8D=93=E8=BF=9C=EF=BC=89?= <107194248+hhh2210@users.noreply.github.com> Date: Fri, 15 May 2026 02:01:51 +0800 Subject: [PATCH 26/27] Add Antigravity account switching (#937) * Add Antigravity account switching * Force Antigravity account chooser * Persist Antigravity selected account updates --- Sources/CodexBar/ProviderRegistry.swift | 15 +- .../Antigravity/AntigravityLoginFlow.swift | 3 + .../Antigravity/AntigravityLoginRunner.swift | 4 +- .../AntigravityProviderImplementation.swift | 14 +- .../AntigravitySettingsStore.swift | 29 ++ .../CodexBar/UsageStore+TokenAccounts.swift | 15 +- .../AntigravityOAuthCredentialsStore.swift | 16 + .../AntigravityProviderDescriptor.swift | 14 +- .../AntigravityRemoteUsageFetcher.swift | 112 ++++--- .../Providers/ProviderFetchPlan.swift | 10 +- .../TokenAccountSupportCatalog+Data.swift | 7 + .../AntigravityLoginAlertTests.swift | 18 ++ .../AntigravityRemoteUsageFetcherTests.swift | 275 ++++++++++++++++++ .../SettingsStoreCoverageTests.swift | 51 ++++ docs/antigravity.md | 22 +- 15 files changed, 549 insertions(+), 56 deletions(-) diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index b072a393f6..838fce19c5 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -37,6 +37,10 @@ struct ProviderRegistry { isEnabled: { settings.isProviderEnabled(provider: provider, metadata: meta) }, descriptor: descriptor, makeFetchContext: { + let account = ProviderTokenAccountSelection.selectedAccount( + provider: provider, + settings: settings, + override: nil) let sourceMode = ProviderCatalog.implementation(for: provider)? .sourceMode(context: ProviderSourceModeContext(provider: provider, settings: settings)) ?? .auto @@ -59,7 +63,16 @@ struct ProviderRegistry { settings: snapshot, fetcher: fetcher, claudeFetcher: claudeFetcher, - browserDetection: browserDetection) + browserDetection: browserDetection, + selectedTokenAccountID: account?.id, + tokenAccountTokenUpdater: { provider, accountID, token in + await MainActor.run { + settings.updateTokenAccount( + provider: provider, + accountID: accountID, + token: token) + } + }) }) specs[provider] = spec } diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift index 74133bccbd..b42790e3ea 100644 --- a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginFlow.swift @@ -14,6 +14,9 @@ extension StatusItemController { } let result = await AntigravityLoginRunner.run(onPhaseChange: phaseHandler) { Task { @MainActor in + if let credentials = try? AntigravityOAuthCredentialsStore().load() { + self.store.settings.upsertAntigravityOAuthAccount(credentials) + } await store.refresh() CodexBarLog.logger(LogCategories.login).info("Auto-refreshed after Antigravity auth") } diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginRunner.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginRunner.swift index 48976e96d1..7578b7d7f8 100644 --- a/Sources/CodexBar/Providers/Antigravity/AntigravityLoginRunner.swift +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityLoginRunner.swift @@ -109,7 +109,7 @@ enum AntigravityLoginRunner { } } - private static func makeAuthorizationURL( + static func makeAuthorizationURL( redirectURL: URL, state: String, oauthClient: AntigravityOAuthClient) throws -> URL @@ -123,7 +123,7 @@ enum AntigravityLoginRunner { URLQueryItem(name: "response_type", value: "code"), URLQueryItem(name: "scope", value: AntigravityOAuthConfig.scopes.joined(separator: " ")), URLQueryItem(name: "access_type", value: "offline"), - URLQueryItem(name: "prompt", value: "consent"), + URLQueryItem(name: "prompt", value: "select_account consent"), URLQueryItem(name: "state", value: state), ] guard let url = components.url else { diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift index ec90457de6..bb3b010708 100644 --- a/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift @@ -11,6 +11,7 @@ struct AntigravityProviderImplementation: ProviderImplementation { @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.antigravityUsageDataSource + _ = settings.tokenAccountsData(for: .antigravity) } @MainActor @@ -56,11 +57,11 @@ struct AntigravityProviderImplementation: ProviderImplementation { @MainActor func settingsActions(context: ProviderSettingsContext) -> [ProviderSettingsActionsDescriptor] { - let credentialsPath = AntigravityOAuthCredentialsStore().fileURL.path - let credentialsExist = FileManager.default.fileExists(atPath: credentialsPath) - let loginTitle = credentialsExist ? "Re-authenticate" : "Login with Google" + let accountCount = context.settings.tokenAccounts(for: .antigravity).count + let loginTitle = accountCount > 0 ? "Add Google Account" : "Login with Google" let subtitle = """ - Stores credentials in ~/.codexbar/antigravity/oauth_creds.json. Uses Antigravity.app OAuth when available, \ + Stores each signed-in Google account for quick Antigravity switching. \ + Uses Antigravity.app OAuth when available, \ or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override. """ return [ @@ -89,6 +90,11 @@ struct AntigravityProviderImplementation: ProviderImplementation { @MainActor func appendUsageMenuEntries(context _: ProviderMenuUsageContext, entries _: inout [ProviderMenuEntry]) {} + @MainActor + func loginMenuAction(context _: ProviderMenuLoginContext) -> (label: String, action: MenuDescriptor.MenuAction)? { + ("Add Account...", .switchAccount(.antigravity)) + } + @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runAntigravityLoginFlow() diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravitySettingsStore.swift b/Sources/CodexBar/Providers/Antigravity/AntigravitySettingsStore.swift index a8deaeaa95..f32a9443de 100644 --- a/Sources/CodexBar/Providers/Antigravity/AntigravitySettingsStore.swift +++ b/Sources/CodexBar/Providers/Antigravity/AntigravitySettingsStore.swift @@ -19,6 +19,35 @@ extension SettingsStore { self.logProviderModeChange(provider: .antigravity, field: "usageSource", value: newValue.rawValue) } } + + func upsertAntigravityOAuthAccount(_ credentials: AntigravityOAuthCredentials) { + guard let token = try? AntigravityOAuthCredentialsStore.tokenAccountValue(for: credentials) else { return } + let trimmedEmail = credentials.email?.trimmingCharacters(in: .whitespacesAndNewlines) + let email = (trimmedEmail?.isEmpty == false) ? trimmedEmail : nil + let data = self.tokenAccountsData(for: .antigravity) + let label = email ?? "Google Account \((data?.accounts.count ?? 0) + 1)" + if let email, + let data, + let index = data.accounts.firstIndex(where: { account in + account.externalIdentifier == email + }) + { + let account = data.accounts[index] + self.updateTokenAccount( + provider: .antigravity, + accountID: account.id, + label: label, + token: token, + externalIdentifier: .some(email)) + self.setActiveTokenAccountIndex(index, for: .antigravity) + } else { + self.addTokenAccount( + provider: .antigravity, + label: label, + token: token, + externalIdentifier: email) + } + } } extension SettingsStore { diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 8e6063ad56..7ffd7ac7b5 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -265,6 +265,10 @@ extension UsageStore { override: TokenAccountOverride?, codexActiveSourceOverride: CodexActiveSource? = nil) -> ProviderFetchContext { + let account = ProviderTokenAccountSelection.selectedAccount( + provider: provider, + settings: self.settings, + override: override) let sourceMode = self.sourceMode(for: provider) let snapshot = ProviderRegistry.makeSettingsSnapshot( settings: self.settings, @@ -289,7 +293,16 @@ extension UsageStore { settings: snapshot, fetcher: fetcher, claudeFetcher: self.claudeFetcher, - browserDetection: self.browserDetection) + browserDetection: self.browserDetection, + selectedTokenAccountID: account?.id, + tokenAccountTokenUpdater: { [weak settings = self.settings] provider, accountID, token in + await MainActor.run { + settings?.updateTokenAccount( + provider: provider, + accountID: accountID, + token: token) + } + }) } func sourceMode(for provider: UsageProvider) -> ProviderSourceMode { diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityOAuthCredentialsStore.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityOAuthCredentialsStore.swift index b515a3f739..de75248d24 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityOAuthCredentialsStore.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityOAuthCredentialsStore.swift @@ -208,6 +208,8 @@ public enum AntigravityOAuthConfig { } public struct AntigravityOAuthCredentialsStore: @unchecked Sendable { + public static let environmentCredentialsKey = "ANTIGRAVITY_OAUTH_CREDENTIALS_JSON" + public let fileURL: URL private let fileManager: FileManager @@ -248,6 +250,20 @@ public struct AntigravityOAuthCredentialsStore: @unchecked Sendable { .appendingPathComponent("oauth_creds.json") } + public static func tokenAccountValue(for credentials: AntigravityOAuthCredentials) throws -> String { + let data = try JSONEncoder.antigravityCredentials.encode(credentials) + guard let value = String(data: data, encoding: .utf8) else { + throw CocoaError(.coderInvalidValue) + } + return value + } + + public static func credentials(fromTokenAccountValue value: String) -> AntigravityOAuthCredentials? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(AntigravityOAuthCredentials.self, from: data) + } + private func applySecurePermissionsIfNeeded() throws { #if os(macOS) || os(Linux) try self.fileManager.setAttributes([ diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index 796ead0cd7..27aeb81009 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -87,8 +87,18 @@ struct AntigravityOAuthFetchStrategy: ProviderFetchStrategy { true } - func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { - let fetcher = AntigravityRemoteUsageFetcher() + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let fetcher = AntigravityRemoteUsageFetcher( + environment: context.env, + credentialsUpdateHandler: { credentials in + guard let accountID = context.selectedTokenAccountID, + let updater = context.tokenAccountTokenUpdater + else { + return + } + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: credentials) + await updater(.antigravity, accountID, token) + }) let snapshot = try await fetcher.fetch() let usage = if snapshot.modelQuotas.isEmpty { UsageSnapshot( diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift index 5f97f4b6c4..f10808d5f2 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift @@ -26,8 +26,10 @@ public enum AntigravityRemoteFetchError: LocalizedError, Sendable, Equatable { public struct AntigravityRemoteUsageFetcher: Sendable { public var timeout: TimeInterval = 10.0 public var homeDirectory: String + public var environment: [String: String] public var dataLoader: @Sendable (URLRequest) async throws -> (Data, URLResponse) public var oauthClientResolver: @Sendable () -> AntigravityOAuthClient? + public var credentialsUpdateHandler: @Sendable (AntigravityOAuthCredentials) async throws -> Void private static let log = CodexBarLog.logger(LogCategories.antigravity) private static let userAgent = "antigravity" @@ -40,47 +42,53 @@ public struct AntigravityRemoteUsageFetcher: Sendable { private struct FetchContext { let timeout: TimeInterval - let store: AntigravityOAuthCredentialsStore + let store: AntigravityOAuthCredentialsStore? let dataLoader: @Sendable (URLRequest) async throws -> (Data, URLResponse) let oauthClientResolver: @Sendable () -> AntigravityOAuthClient? + let credentialsUpdateHandler: @Sendable (AntigravityOAuthCredentials) async throws -> Void + + func persistCredentials(_ credentials: AntigravityOAuthCredentials) async throws { + if let store { + try store.save(credentials) + } else { + try await self.credentialsUpdateHandler(credentials) + } + } } public init( timeout: TimeInterval = 10.0, homeDirectory: String = NSHomeDirectory(), + environment: [String: String] = ProcessInfo.processInfo.environment, dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = { request in try await URLSession.shared.data(for: request) }, oauthClientResolver: @escaping @Sendable () -> AntigravityOAuthClient? = { AntigravityOAuthConfig.resolvedClient() - }) + }, + credentialsUpdateHandler: @escaping @Sendable (AntigravityOAuthCredentials) async throws -> Void = { _ in }) { self.timeout = timeout self.homeDirectory = homeDirectory + self.environment = environment self.dataLoader = dataLoader self.oauthClientResolver = oauthClientResolver + self.credentialsUpdateHandler = credentialsUpdateHandler } public func fetch() async throws -> AntigravityStatusSnapshot { - let source = try Self.resolveCredentialSource(homeDirectory: self.homeDirectory) - let store = source.primaryStore + let source = try Self.resolveCredentialSource(homeDirectory: self.homeDirectory, environment: self.environment) guard let credentials = source.credentials else { throw AntigravityRemoteFetchError.notLoggedIn } - return try await Self.fetchSnapshot( + return try await self.fetchSnapshot( using: credentials, - timeout: self.timeout, - store: store, - dataLoader: self.dataLoader, - oauthClientResolver: self.oauthClientResolver) + store: source.store) } - private static func fetchSnapshot( + private func fetchSnapshot( using initialCredentials: AntigravityOAuthCredentials, - timeout: TimeInterval, - store: AntigravityOAuthCredentialsStore, - dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse), - oauthClientResolver: @escaping @Sendable () -> AntigravityOAuthClient?) async throws + store: AntigravityOAuthCredentialsStore?) async throws -> AntigravityStatusSnapshot { guard let storedAccessToken = initialCredentials.accessToken?.trimmedNonEmpty else { @@ -90,37 +98,50 @@ public struct AntigravityRemoteUsageFetcher: Sendable { var credentials = initialCredentials var accessToken = storedAccessToken let context = FetchContext( - timeout: timeout, + timeout: self.timeout, store: store, - dataLoader: dataLoader, - oauthClientResolver: oauthClientResolver) + dataLoader: self.dataLoader, + oauthClientResolver: self.oauthClientResolver, + credentialsUpdateHandler: self.credentialsUpdateHandler) if Self.shouldRefresh(expiryDate: credentials.expiryDate, now: Date()) { guard let refreshToken = credentials.refreshToken?.trimmedNonEmpty else { throw AntigravityRemoteFetchError.notLoggedIn } - accessToken = try await Self.refreshAccessToken( + let refreshed = try await Self.refreshAccessToken( credentials: credentials, refreshToken: refreshToken, context: context) - credentials = try store.load() ?? credentials + accessToken = refreshed.accessToken + credentials = refreshed.credentials + if let store { + credentials = try store.load() ?? credentials + } credentials.accessToken = credentials.accessToken?.trimmedNonEmpty ?? accessToken } let claims = Self.extractClaims(from: credentials) let codeAssist = try await Self.loadCodeAssist( accessToken: accessToken, - timeout: timeout, - dataLoader: dataLoader) + timeout: self.timeout, + dataLoader: self.dataLoader) let projectId = try await Self.resolveProjectID( accessToken: accessToken, storedProjectID: credentials.projectID?.trimmedNonEmpty, initialResponse: codeAssist, context: context) + if let projectId, credentials.projectID?.trimmedNonEmpty != projectId { + credentials.projectID = projectId + do { + try await context.persistCredentials(credentials) + } catch { + Self.log.warning("Could not persist Antigravity project ID: \(error.localizedDescription)") + } + } let models = try await Self.fetchModelQuotas( accessToken: accessToken, projectId: projectId, - timeout: timeout, - dataLoader: dataLoader) + timeout: self.timeout, + dataLoader: self.dataLoader) return AntigravityStatusSnapshot( modelQuotas: models, @@ -242,7 +263,6 @@ public struct AntigravityRemoteUsageFetcher: Sendable { } if let projectID = initialResponse.projectID { - try? Self.updateStoredProjectID(projectID, store: context.store) return projectID } @@ -267,7 +287,6 @@ public struct AntigravityRemoteUsageFetcher: Sendable { timeout: context.timeout, dataLoader: context.dataLoader) if let projectID = onboardResponse.projectID { - try? Self.updateStoredProjectID(projectID, store: context.store) return projectID } } catch { @@ -283,7 +302,6 @@ public struct AntigravityRemoteUsageFetcher: Sendable { timeout: context.timeout, dataLoader: context.dataLoader) if let projectID = refreshed.projectID { - try? Self.updateStoredProjectID(projectID, store: context.store) return projectID } } @@ -439,19 +457,33 @@ public struct AntigravityRemoteUsageFetcher: Sendable { return AntigravityOAuthCredentialsStore(fileURL: AntigravityOAuthCredentialsStore.defaultURL(home: homeURL)) } - private static func resolveCredentialSource(homeDirectory: String) throws -> ( + private static func resolveCredentialSource( + homeDirectory: String, + environment: [String: String]) throws -> ( credentials: AntigravityOAuthCredentials?, - primaryStore: AntigravityOAuthCredentialsStore) + store: AntigravityOAuthCredentialsStore?) { let primaryStore = Self.credentialsStore(homeDirectory: homeDirectory) + if let tokenValue = environment[AntigravityOAuthCredentialsStore.environmentCredentialsKey] { + guard let credentials = AntigravityOAuthCredentialsStore.credentials(fromTokenAccountValue: tokenValue) + else { + throw AntigravityRemoteFetchError.parseFailed("Could not decode selected account credentials.") + } + return (credentials, nil) + } return try (primaryStore.load(), primaryStore) } + private struct RefreshResult { + let accessToken: String + let credentials: AntigravityOAuthCredentials + } + private static func refreshAccessToken( credentials: AntigravityOAuthCredentials, refreshToken: String, context: FetchContext) async throws - -> String + -> RefreshResult { let oauthClient = try Self.refreshOAuthClient( from: credentials, @@ -481,8 +513,9 @@ public struct AntigravityRemoteUsageFetcher: Sendable { throw AntigravityRemoteFetchError.parseFailed("Could not parse refresh response") } - try Self.updateStoredCredentials(json, store: context.store) - return accessToken + let updatedCredentials = Self.updatedCredentials(credentials, refreshResponse: json) + try await context.persistCredentials(updatedCredentials) + return RefreshResult(accessToken: accessToken, credentials: updatedCredentials) } private static func refreshOAuthClient( @@ -502,11 +535,11 @@ public struct AntigravityRemoteUsageFetcher: Sendable { return client } - private static func updateStoredCredentials( - _ refreshResponse: [String: Any], - store: AntigravityOAuthCredentialsStore) throws + private static func updatedCredentials( + _ credentials: AntigravityOAuthCredentials, + refreshResponse: [String: Any]) -> AntigravityOAuthCredentials { - guard var credentials = try store.load() else { return } + var credentials = credentials if let accessToken = refreshResponse["access_token"] as? String { credentials.accessToken = accessToken } @@ -519,14 +552,7 @@ public struct AntigravityRemoteUsageFetcher: Sendable { if let idToken = refreshResponse["id_token"] as? String { credentials.idToken = idToken } - try store.save(credentials) - } - - private static func updateStoredProjectID(_ projectID: String, store: AntigravityOAuthCredentialsStore) throws { - guard var credentials = try store.load() else { return } - guard credentials.projectID?.trimmedNonEmpty != projectID else { return } - credentials.projectID = projectID - try store.save(credentials) + return credentials } private static func formBody(_ values: [String: String]) -> Data? { diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index ac94c6db41..c5031ddbf4 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -18,6 +18,8 @@ public enum ProviderSourceMode: String, CaseIterable, Sendable, Codable { } public struct ProviderFetchContext: Sendable { + public typealias TokenAccountTokenUpdater = @Sendable (UsageProvider, UUID, String) async -> Void + public let runtime: ProviderRuntime public let sourceMode: ProviderSourceMode public let includeCredits: Bool @@ -29,6 +31,8 @@ public struct ProviderFetchContext: Sendable { public let fetcher: UsageFetcher public let claudeFetcher: any ClaudeUsageFetching public let browserDetection: BrowserDetection + public let selectedTokenAccountID: UUID? + public let tokenAccountTokenUpdater: TokenAccountTokenUpdater? public init( runtime: ProviderRuntime, @@ -41,7 +45,9 @@ public struct ProviderFetchContext: Sendable { settings: ProviderSettingsSnapshot?, fetcher: UsageFetcher, claudeFetcher: any ClaudeUsageFetching, - browserDetection: BrowserDetection) + browserDetection: BrowserDetection, + selectedTokenAccountID: UUID? = nil, + tokenAccountTokenUpdater: TokenAccountTokenUpdater? = nil) { self.runtime = runtime self.sourceMode = sourceMode @@ -54,6 +60,8 @@ public struct ProviderFetchContext: Sendable { self.fetcher = fetcher self.claudeFetcher = claudeFetcher self.browserDetection = browserDetection + self.selectedTokenAccountID = selectedTokenAccountID + self.tokenAccountTokenUpdater = tokenAccountTokenUpdater } } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 36e053995d..d3d63f7f45 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -23,6 +23,13 @@ extension TokenAccountSupportCatalog { injection: .environment(key: DeepSeekSettingsReader.apiKeyEnvironmentKey), requiresManualCookieSource: false, cookieName: nil), + .antigravity: TokenAccountSupport( + title: "Google accounts", + subtitle: "Store multiple Antigravity Google OAuth accounts for quick switching.", + placeholder: "Antigravity OAuth credentials JSON", + injection: .environment(key: AntigravityOAuthCredentialsStore.environmentCredentialsKey), + requiresManualCookieSource: false, + cookieName: nil), .zai: TokenAccountSupport( title: "API tokens", subtitle: "Stored in the CodexBar config file.", diff --git a/Tests/CodexBarTests/AntigravityLoginAlertTests.swift b/Tests/CodexBarTests/AntigravityLoginAlertTests.swift index 102d635be9..a11ee63a7f 100644 --- a/Tests/CodexBarTests/AntigravityLoginAlertTests.swift +++ b/Tests/CodexBarTests/AntigravityLoginAlertTests.swift @@ -1,7 +1,25 @@ +import CodexBarCore +import Foundation import Testing @testable import CodexBar struct AntigravityLoginAlertTests { + @Test + func `authorization URL asks Google to select an account`() throws { + let redirectURL = try #require(URL(string: "http://127.0.0.1:54321/callback")) + let url = try AntigravityLoginRunner.makeAuthorizationURL( + redirectURL: redirectURL, + state: "state", + oauthClient: AntigravityOAuthClient( + clientID: "client.apps.googleusercontent.com", + clientSecret: "secret")) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let prompt = components.queryItems?.first(where: { $0.name == "prompt" })?.value + + #expect(prompt?.split(separator: " ").contains("select_account") == true) + #expect(prompt?.split(separator: " ").contains("consent") == true) + } + @Test func `returns alert for timeout`() { let result = AntigravityLoginRunner.Result(outcome: .timedOut) diff --git a/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift b/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift index 3ba377b125..7772818aa0 100644 --- a/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AntigravityRemoteUsageFetcherTests.swift @@ -2,8 +2,283 @@ import CodexBarCore import Foundation import Testing +private actor AntigravityCredentialUpdateCapture { + private var captured: [AntigravityOAuthCredentials] = [] + + func append(_ credentials: AntigravityOAuthCredentials) { + self.captured.append(credentials) + } + + func values() -> [AntigravityOAuthCredentials] { + self.captured + } +} + @Suite(.serialized) struct AntigravityRemoteUsageFetcherTests { + @Test + func `antigravity supports token accounts for quick account switching`() { + let support = TokenAccountSupportCatalog.support(for: .antigravity) + + #expect(support?.title == "Google accounts") + #expect(support?.requiresManualCookieSource == false) + #expect(TokenAccountSupportCatalog.envOverride( + for: .antigravity, + token: "serialized-credentials")?[AntigravityOAuthCredentialsStore.environmentCredentialsKey] == + "serialized-credentials") + } + + @Test + func `oauth credentials round trip through token account value`() throws { + let credentials = AntigravityOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + email: "user@example.com", + projectID: "project-123", + clientID: "client-id", + clientSecret: "client-secret") + + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: credentials) + let decoded = try #require(AntigravityOAuthCredentialsStore.credentials(fromTokenAccountValue: token)) + + #expect(decoded == credentials) + } + + @Test + func `remote fetch uses selected token account credentials before shared credentials`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "shared-token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "shared@example.com"), + email: "shared@example.com") + let selectedCredentials = AntigravityOAuthCredentials( + accessToken: "selected-token", + refreshToken: nil, + expiryDate: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "selected@example.com"), + email: "selected@example.com", + projectID: nil, + clientID: nil, + clientSecret: nil) + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: selectedCredentials) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer selected-token") + + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "free-tier", "name": "free"], + "cloudaicompanionProject": "managed-project-123", + ])) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.availableModelsResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + environment: [AntigravityOAuthCredentialsStore.environmentCredentialsKey: token], + dataLoader: dataLoader) + let snapshot = try await fetcher.fetch() + + #expect(snapshot.accountEmail == "selected@example.com") + } + + @Test + func `remote fetch refreshes selected token account without mutating shared credentials`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "shared-token", + refreshToken: "shared-refresh", + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "shared@example.com"), + email: "shared@example.com", + clientID: "shared-client-id", + clientSecret: "shared-client-secret") + let selectedCredentials = AntigravityOAuthCredentials( + accessToken: "selected-old-token", + refreshToken: "selected-refresh", + expiryDate: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "selected-old@example.com"), + email: "selected-old@example.com", + projectID: nil, + clientID: "selected-client-id", + clientSecret: "selected-client-secret") + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: selectedCredentials) + let updateCapture = AntigravityCredentialUpdateCapture() + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + let body = String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "" + #expect(body.contains("client_id=selected-client-id")) + #expect(body.contains("refresh_token=selected-refresh")) + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "access_token": "selected-new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "selected-new@example.com"), + ])) + case "cloudcode-pa.googleapis.com": + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer selected-new-token") + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "free-tier", "name": "free"], + "cloudaicompanionProject": "selected-project-123", + ])) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.availableModelsResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + environment: [AntigravityOAuthCredentialsStore.environmentCredentialsKey: token], + dataLoader: dataLoader, + credentialsUpdateHandler: { credentials in + await updateCapture.append(credentials) + }) + let snapshot = try await fetcher.fetch() + let shared = try env.readAntigravityCredentials() + let updatedCredentials = await updateCapture.values() + + #expect(snapshot.accountEmail == "selected-new@example.com") + #expect(shared["access_token"] as? String == "shared-token") + #expect(shared["email"] as? String == "shared@example.com") + #expect(updatedCredentials.last?.accessToken == "selected-new-token") + #expect(updatedCredentials.last?.projectID == "selected-project-123") + } + + @Test + func `remote fetch ignores selected token account project id persistence failure`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let selectedCredentials = AntigravityOAuthCredentials( + accessToken: "selected-token", + refreshToken: nil, + expiryDate: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "selected@example.com"), + email: "selected@example.com", + projectID: nil, + clientID: nil, + clientSecret: nil) + let token = try AntigravityOAuthCredentialsStore.tokenAccountValue(for: selectedCredentials) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData([ + "currentTier": ["id": "free-tier", "name": "free"], + "cloudaicompanionProject": "selected-project-123", + ])) + } + if url.path == "/v1internal:fetchAvailableModels" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.availableModelsResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + environment: [AntigravityOAuthCredentialsStore.environmentCredentialsKey: token], + dataLoader: dataLoader, + credentialsUpdateHandler: { _ in + throw CocoaError(.fileWriteUnknown) + }) + let snapshot = try await fetcher.fetch() + + #expect(snapshot.accountEmail == "selected@example.com") + } + + @Test + func `remote fetch rejects invalid selected token account`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeAntigravityCredentials( + accessToken: "shared-token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "shared@example.com"), + email: "shared@example.com") + + let fetcher = AntigravityRemoteUsageFetcher( + timeout: 1, + homeDirectory: env.homeURL.path, + environment: [AntigravityOAuthCredentialsStore.environmentCredentialsKey: "not-json"], + dataLoader: GeminiAPITestHelpers.dataLoader { _ in + throw URLError(.badServerResponse) + }) + + do { + _ = try await fetcher.fetch() + #expect(Bool(false), "Expected selected account decode failure") + } catch let error as AntigravityRemoteFetchError { + guard case let .parseFailed(message) = error else { + #expect(Bool(false), "Unexpected Antigravity error: \(error)") + return + } + #expect(message.contains("selected account")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + @Test func `remote fetch maps cloud code models into antigravity usage`() async throws { let env = try GeminiTestEnvironment() diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 1c0266d610..36079a7849 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -434,6 +434,57 @@ struct SettingsStoreCoverageTests { #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) } + @Test + func `upsert antigravity oauth account adds and updates active token account`() throws { + let settings = Self.makeSettingsStore() + let first = AntigravityOAuthCredentials( + accessToken: "first-access", + refreshToken: "first-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: "user@example.com") + let updated = AntigravityOAuthCredentials( + accessToken: "updated-access", + refreshToken: "first-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_100), + email: "user@example.com") + + settings.upsertAntigravityOAuthAccount(first) + settings.upsertAntigravityOAuthAccount(updated) + + let accounts = settings.tokenAccounts(for: .antigravity) + #expect(accounts.count == 1) + let account = try #require(accounts.first) + #expect(account.label == "user@example.com") + #expect(account.externalIdentifier == "user@example.com") + #expect(settings.selectedTokenAccount(for: .antigravity)?.id == account.id) + + let decoded = try #require(AntigravityOAuthCredentialsStore.credentials(fromTokenAccountValue: account.token)) + #expect(decoded.accessToken == "updated-access") + } + + @Test + func `upsert antigravity oauth account does not merge missing email accounts by fallback label`() { + let settings = Self.makeSettingsStore() + let first = AntigravityOAuthCredentials( + accessToken: "first-access", + refreshToken: "first-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: nil) + let second = AntigravityOAuthCredentials( + accessToken: "second-access", + refreshToken: "second-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_100), + email: nil) + + settings.upsertAntigravityOAuthAccount(first) + settings.upsertAntigravityOAuthAccount(second) + + let accounts = settings.tokenAccounts(for: .antigravity) + #expect(accounts.count == 2) + #expect(accounts.map(\.label) == ["Google Account 1", "Google Account 2"]) + #expect(settings.selectedTokenAccount(for: .antigravity)?.id == accounts.last?.id) + } + private static func makeSettingsStore(suiteName: String = "SettingsStoreCoverageTests") -> SettingsStore { let defaults = UserDefaults(suiteName: suiteName)! defaults.removePersistentDomain(forName: suiteName) diff --git a/docs/antigravity.md b/docs/antigravity.md index ab99af30f3..5ba4978da8 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -1,14 +1,32 @@ --- -summary: "Antigravity provider notes: local LSP probing, port discovery, quota parsing, and UI mapping." +summary: "Antigravity provider notes: OAuth usage, multi-account switching, local LSP probing, and quota parsing." read_when: - Adding or modifying the Antigravity provider - Debugging Antigravity port detection or quota parsing - Adjusting Antigravity menu labels or model mapping + - Working with Antigravity OAuth or account switching --- # Antigravity provider -Antigravity is a local-only provider. We talk directly to the Antigravity language server running on the same machine. +Antigravity supports local IDE probing and Google OAuth-backed remote usage. The OAuth path can store multiple Google accounts through the shared token-account switcher. + +## OAuth account switching + +- Login still uses Antigravity's Google OAuth client, discovered from `Antigravity.app` or overridden with `ANTIGRAVITY_OAUTH_CLIENT_ID` and `ANTIGRAVITY_OAUTH_CLIENT_SECRET`. +- A successful login writes the latest shared credentials to `~/.codexbar/antigravity/oauth_creds.json` and upserts a token-account entry for the Google account. +- Each token-account entry stores serialized `AntigravityOAuthCredentials` and is injected into remote fetches through `ANTIGRAVITY_OAUTH_CREDENTIALS_JSON`. +- When a token account is selected, the OAuth fetcher uses that account before falling back to the shared credentials file. +- The menu action is labeled `Add Account...`; switching between saved accounts uses the existing segmented/stacked token-account menu UI. + +## Remote OAuth data sources + +- `POST https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` +- `POST https://cloudcode-pa.googleapis.com/v1internal:onboardUser` +- `POST https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` +- `POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota` + +## Local data sources + fallback order ## Data sources + fallback order From 9902384a5ac0dab9123348c3593e912c59d565fc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 14 May 2026 19:02:12 +0100 Subject: [PATCH 27/27] docs: add Antigravity account switching changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4efb6ba550..7c56bcc2d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.26 — Unreleased ### Added +- Antigravity: add Google OAuth token-account switching with selected-account refresh persistence (#937, fixes #936). Thanks @hhh2210! - Moonshot / Kimi API: add API-key balance tracking, CLI support, docs, and menu bar balance copy (#899). Thanks @giuseppebisemi! - Menu: add an opt-in setting for provider changelog links, starting with Codex, Claude Code, and Gemini CLI (#929, fixes #660). Thanks @ThiagoCAltoe! - Display: add a setting to hide quota-warning tick marks on usage bars while keeping quota warning notifications active (#918, fixes #916). Thanks @ThiagoCAltoe!