From 35e707a3fe7df0577da18da337232802751b2c5f Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Thu, 19 Mar 2026 20:13:27 -0400 Subject: [PATCH 01/25] Merge branch 'feat/codex-multi-account' into main --- .../CodexBar/AccountCostsMenuCardView.swift | 187 ++++++++ Sources/CodexBar/CodexLoginRunner.swift | 6 +- Sources/CodexBar/FlowLayout.swift | 58 +++ Sources/CodexBar/MenuCardView.swift | 18 +- Sources/CodexBar/MenuContent.swift | 3 + Sources/CodexBar/MenuDescriptor.swift | 31 +- .../PreferencesProviderDetailView.swift | 247 ++++++++-- .../PreferencesProviderSettingsRows.swift | 446 ++++++++++++++++-- .../PreferencesProvidersPane+Testing.swift | 8 +- .../CodexBar/PreferencesProvidersPane.swift | 27 +- .../CodexBar/ProviderSwitcherButtons.swift | 38 ++ .../Codex/CodexProviderImplementation.swift | 174 +++++++ .../Providers/Codex/CodexSettingsStore.swift | 10 + .../Shared/ProviderImplementation.swift | 31 ++ .../Shared/ProviderSettingsDescriptors.swift | 26 +- .../SettingsStore+TokenAccounts.swift | 35 +- .../StatusItemController+Actions.swift | 63 +++ .../CodexBar/StatusItemController+Menu.swift | 72 ++- .../StatusItemController+SwitcherViews.swift | 101 ++-- .../TokenAccountSwitcherRepresentable.swift | 27 ++ Sources/CodexBar/UsageProgressBar.swift | 31 +- .../CodexBar/UsageStore+AccountCosts.swift | 175 +++++++ Sources/CodexBar/UsageStore+Refresh.swift | 13 + .../CodexBar/UsageStore+TokenAccounts.swift | 19 +- Sources/CodexBar/UsageStore+TokenCost.swift | 28 ++ Sources/CodexBar/UsageStore.swift | 20 +- .../CodexBarCore/Config/CodexBarConfig.swift | 10 +- Sources/CodexBarCore/CostUsageFetcher.swift | 34 +- .../CodexOAuth/CodexOAuthCredentials.swift | 18 +- .../Codex/CodexProviderDescriptor.swift | 10 +- .../CodexBarCore/TokenAccountSupport.swift | 13 + .../TokenAccountSupportCatalog+Data.swift | 7 + Sources/CodexBarCore/TokenAccounts.swift | 8 +- ...enuDescriptorAccountActionLabelTests.swift | 102 ++++ .../ProviderSettingsDescriptorTests.swift | 2 + Tests/CodexBarTests/StatusMenuTests.swift | 11 +- 36 files changed, 1958 insertions(+), 151 deletions(-) create mode 100644 Sources/CodexBar/AccountCostsMenuCardView.swift create mode 100644 Sources/CodexBar/FlowLayout.swift create mode 100644 Sources/CodexBar/TokenAccountSwitcherRepresentable.swift create mode 100644 Sources/CodexBar/UsageStore+AccountCosts.swift create mode 100644 Tests/CodexBarTests/MenuDescriptorAccountActionLabelTests.swift diff --git a/Sources/CodexBar/AccountCostsMenuCardView.swift b/Sources/CodexBar/AccountCostsMenuCardView.swift new file mode 100644 index 000000000..288bc8a45 --- /dev/null +++ b/Sources/CodexBar/AccountCostsMenuCardView.swift @@ -0,0 +1,187 @@ +import CodexBarCore +import SwiftUI + +/// Menu card showing plan/tier info for every connected Codex account. +struct AccountCostsMenuCardView: View { + let entries: [AccountCostEntry] + let isLoading: Bool + let width: CGFloat + + @Environment(\.menuItemHighlighted) private var isHighlighted + + static let nameWidth: CGFloat = 70 + static let badgeWidth: CGFloat = 42 + static let colWidth: CGFloat = 72 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + // Mirror the row layout: icon(small) + name + badge, then columns + Spacer() + .frame(width: 14) // icon space + Spacer() + .frame(width: Self.nameWidth) + Spacer() + .frame(width: Self.badgeWidth) + Text("Session") + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .frame(width: Self.colWidth, alignment: .leading) + Text("Weekly") + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .frame(width: Self.colWidth, alignment: .leading) + } + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 6) + + Divider() + .padding(.horizontal, 16) + + if self.isLoading && self.entries.isEmpty { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Loading…") + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } else if self.entries.isEmpty { + Text("No accounts connected.") + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(self.entries) { entry in + AccountCostRow(entry: entry, isHighlighted: self.isHighlighted) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 10) + } + } + .frame(width: self.width, alignment: .leading) + } +} + +private struct AccountCostRow: View { + let entry: AccountCostEntry + let isHighlighted: Bool + + private static let colWidth: CGFloat = AccountCostsMenuCardView.colWidth + + private static let nameWidth: CGFloat = AccountCostsMenuCardView.nameWidth + private static let badgeWidth: CGFloat = AccountCostsMenuCardView.badgeWidth + + var body: some View { + HStack(alignment: .center, spacing: 4) { + Image(systemName: self.entry.isDefault ? "person.circle.fill" : "person.circle") + .imageScale(.small) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + + Text(self.entry.label) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + .frame(width: Self.nameWidth, alignment: .leading) + + if self.entry.error == nil { + if self.entry.isUnlimited { + self.planBadge("Unlimited") + .frame(width: Self.badgeWidth, alignment: .leading) + } else if let plan = self.entry.planType { + self.planBadge(plan) + .frame(width: Self.badgeWidth, alignment: .leading) + } else { + Spacer() + .frame(width: Self.badgeWidth) + } + } else { + Spacer() + .frame(width: Self.badgeWidth) + } + + // Right columns: Session | Weekly + if let error = self.entry.error { + if self.entry.isDefault { + Text(self.shortError(error)) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) + .frame(width: Self.colWidth * 2 + 8, alignment: .trailing) + } else { + Text("Credit information is available only for the primary account.") + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .multilineTextAlignment(.leading) + .frame(maxWidth: Self.colWidth * 2 + 24, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + } else if let balance = self.entry.creditsRemaining { + // Prepaid credits: span both columns + Text(UsageFormatter.usdString(balance) + " left") + .font(.caption2.monospacedDigit()) + .foregroundStyle(balance < 5 ? Color.orange : MenuHighlightStyle.secondary(self.isHighlighted)) + .frame(width: Self.colWidth * 2 + 8, alignment: .trailing) + } else { + self.percentCell( + usedPercent: self.entry.primaryUsedPercent, + resetDescription: self.entry.primaryResetDescription) + self.percentCell( + usedPercent: self.entry.secondaryUsedPercent, + resetDescription: self.entry.secondaryResetDescription) + } + } + } + + private static let pctWidth: CGFloat = 30 + + @ViewBuilder + private func percentCell(usedPercent: Double?, resetDescription: String?) -> some View { + if let used = usedPercent { + let remaining = max(0, 100 - used) + let isLow = remaining < 20 + let pctColor: Color = isLow ? .orange : MenuHighlightStyle.secondary(self.isHighlighted) + HStack(alignment: .firstTextBaseline, spacing: 1) { + Text(String(format: "%.0f%%", remaining)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(pctColor) + .frame(width: Self.pctWidth, alignment: .leading) + if let reset = resetDescription { + Text(reset) + .font(.system(size: 9).monospacedDigit()) + .foregroundStyle(pctColor.opacity(0.65)) + } + } + .frame(width: Self.colWidth, alignment: .leading) + } else { + Text("—") + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.5)) + .frame(width: Self.colWidth, alignment: .leading) + } + } + + private func planBadge(_ text: String) -> some View { + Text(text) + .font(.caption2.weight(.medium)) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.12))) + } + + private func shortError(_ error: String) -> String { + if error.contains("not found") || error.contains("notFound") { return "Not signed in" } + if error.contains("unauthorized") || error.contains("401") { return "Token expired" } + return "Error" + } +} diff --git a/Sources/CodexBar/CodexLoginRunner.swift b/Sources/CodexBar/CodexLoginRunner.swift index 8f1f654f2..7a442915a 100644 --- a/Sources/CodexBar/CodexLoginRunner.swift +++ b/Sources/CodexBar/CodexLoginRunner.swift @@ -16,9 +16,13 @@ struct CodexLoginRunner { let output: String } - static func run(timeout: TimeInterval = 120) async -> Result { + static func run(codexHome: String? = nil, timeout: TimeInterval = 120) async -> Result { await Task(priority: .userInitiated) { var env = ProcessInfo.processInfo.environment + if let codexHome { + let expanded = (codexHome as NSString).expandingTildeInPath + env["CODEX_HOME"] = expanded + } env["PATH"] = PathBuilder.effectivePATH( purposes: [.rpc, .tty, .nodeTooling], env: env, diff --git a/Sources/CodexBar/FlowLayout.swift b/Sources/CodexBar/FlowLayout.swift new file mode 100644 index 000000000..feb75248f --- /dev/null +++ b/Sources/CodexBar/FlowLayout.swift @@ -0,0 +1,58 @@ +import SwiftUI + +/// A layout that arranges children left-to-right, wrapping to a new row when they overflow. +struct FlowLayout: Layout { + var spacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var currentX: CGFloat = 0 + var currentRowHeight: CGFloat = 0 + var totalHeight: CGFloat = 0 + var isFirstInRow = true + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + let neededWidth = isFirstInRow ? size.width : spacing + size.width + if !isFirstInRow && currentX + neededWidth > maxWidth { + totalHeight += currentRowHeight + spacing + currentX = size.width + currentRowHeight = size.height + isFirstInRow = false + } else { + currentX += neededWidth + currentRowHeight = max(currentRowHeight, size.height) + isFirstInRow = false + } + } + totalHeight += currentRowHeight + return CGSize(width: maxWidth, height: max(totalHeight, 0)) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { + let maxWidth = bounds.width + var currentX = bounds.minX + var currentY = bounds.minY + var currentRowHeight: CGFloat = 0 + var isFirstInRow = true + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + let neededWidth = isFirstInRow ? size.width : spacing + size.width + if !isFirstInRow && currentX - bounds.minX + neededWidth > maxWidth { + currentY += currentRowHeight + spacing + currentX = bounds.minX + currentRowHeight = size.height + subview.place(at: CGPoint(x: currentX, y: currentY), proposal: .unspecified) + currentX += size.width + isFirstInRow = false + } else { + if !isFirstInRow { currentX += spacing } + subview.place(at: CGPoint(x: currentX, y: currentY), proposal: .unspecified) + currentX += size.width + currentRowHeight = max(currentRowHeight, size.height) + isFirstInRow = false + } + } + } +} diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 1a5d25389..9580ec9ee 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -632,6 +632,8 @@ extension UsageMenuCardView.Model { let resetTimeDisplayStyle: ResetTimeDisplayStyle let tokenCostUsageEnabled: Bool let showOptionalCreditsAndExtraUsage: Bool + /// When set (non-primary Codex account), replaces credits line + suppresses dashboard/credits error hints. + let codexMenuCreditsPrimaryAccountNotice: String? let sourceLabel: String? let kiloAutoMode: Bool let hidePersonalInfo: Bool @@ -655,6 +657,7 @@ extension UsageMenuCardView.Model { resetTimeDisplayStyle: ResetTimeDisplayStyle, tokenCostUsageEnabled: Bool, showOptionalCreditsAndExtraUsage: Bool, + codexMenuCreditsPrimaryAccountNotice: String? = nil, sourceLabel: String? = nil, kiloAutoMode: Bool = false, hidePersonalInfo: Bool, @@ -677,6 +680,7 @@ extension UsageMenuCardView.Model { self.resetTimeDisplayStyle = resetTimeDisplayStyle self.tokenCostUsageEnabled = tokenCostUsageEnabled self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage + self.codexMenuCreditsPrimaryAccountNotice = codexMenuCreditsPrimaryAccountNotice self.sourceLabel = sourceLabel self.kiloAutoMode = kiloAutoMode self.hidePersonalInfo = hidePersonalInfo @@ -693,7 +697,11 @@ extension UsageMenuCardView.Model { metadata: input.metadata) let metrics = Self.metrics(input: input) let usageNotes = Self.usageNotes(input: input) - let creditsText: String? = if input.provider == .openrouter { + let creditsText: String? = if let notice = input.codexMenuCreditsPrimaryAccountNotice, + !notice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + notice + } else if input.provider == .openrouter { nil } else if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { nil @@ -727,7 +735,7 @@ extension UsageMenuCardView.Model { metrics: metrics, usageNotes: usageNotes, creditsText: creditsText, - creditsRemaining: input.credits?.remaining, + creditsRemaining: input.codexMenuCreditsPrimaryAccountNotice != nil ? nil : input.credits?.remaining, creditsHintText: redacted.creditsHintText, creditsHintCopyText: redacted.creditsHintCopyText, providerCost: providerCost, @@ -869,6 +877,8 @@ extension UsageMenuCardView.Model { input: Input, subtitle: (text: String, style: SubtitleStyle)) -> RedactedText { + let dashboardErrorForHints = + input.codexMenuCreditsPrimaryAccountNotice != nil ? nil : input.dashboardError let email = PersonalInfoRedactor.redactEmail( Self.email( for: input.provider, @@ -879,10 +889,10 @@ extension UsageMenuCardView.Model { let subtitleText = PersonalInfoRedactor.redactEmails(in: subtitle.text, isEnabled: input.hidePersonalInfo) ?? subtitle.text let creditsHintText = PersonalInfoRedactor.redactEmails( - in: Self.dashboardHint(provider: input.provider, error: input.dashboardError), + in: Self.dashboardHint(provider: input.provider, error: dashboardErrorForHints), isEnabled: input.hidePersonalInfo) let creditsHintCopyText = Self.creditsHintCopyText( - dashboardError: input.dashboardError, + dashboardError: dashboardErrorForHints, hidePersonalInfo: input.hidePersonalInfo) return RedactedText( email: email, diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index fa41695f5..9696336d5 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -88,6 +88,8 @@ struct MenuContent: View { self.actions.openStatusPage() case let .switchAccount(provider): self.actions.switchAccount(provider) + case let .addTokenAccount(provider): + self.actions.addTokenAccount(provider) case let .openTerminal(command): self.actions.openTerminal(command) case let .loginToProvider(url): @@ -113,6 +115,7 @@ struct MenuActions { let openDashboard: () -> Void let openStatusPage: () -> Void let switchAccount: (UsageProvider) -> Void + let addTokenAccount: (UsageProvider) -> Void let openTerminal: (String) -> Void let openSettings: () -> Void let openAbout: () -> Void diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 05aa55fff..66061b61d 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -39,6 +39,7 @@ struct MenuDescriptor { case dashboard case statusPage case switchAccount(UsageProvider) + case addTokenAccount(UsageProvider) case openTerminal(command: String) case loginToProvider(url: String) case settings @@ -327,7 +328,13 @@ struct MenuDescriptor { } else { let loginAction = self.switchAccountTarget(for: provider, store: store) let hasAccount = self.hasAccount(for: provider, store: store, account: account) - let accountLabel = hasAccount ? "Switch Account..." : "Add Account..." + // For Codex, switching is done via account tabs — this action is only for adding new accounts. + let accountLabel: String + if targetProvider == .codex { + accountLabel = "Add Account..." + } else { + accountLabel = hasAccount ? "Switch Account..." : "Add Account..." + } entries.append(.action(accountLabel, loginAction)) } } @@ -392,10 +399,23 @@ struct MenuDescriptor { private static func hasAccount(for provider: UsageProvider?, store: UsageStore, account: AccountInfo) -> Bool { let target = provider ?? store.enabledProviders().first ?? .codex - if let email = store.snapshot(for: target)?.accountEmail(for: target), - !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true + if let snap = store.snapshot(for: target) { + let email = snap.accountEmail(for: target)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let email, !email.isEmpty { + return true + } + let login = snap.loginMethod(for: target)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let login, !login.isEmpty { + return true + } + let org = snap.accountOrganization(for: target)?.trimmingCharacters(in: .whitespacesAndNewlines) + if let org, !org.isEmpty { + return true + } + // Usage or cost without profile email still means a connected session (e.g. Claude OAuth without email). + if snap.primary != nil || snap.secondary != nil || snap.tertiary != nil || snap.providerCost != nil { + return true + } } let metadata = store.metadata(for: target) if metadata.usesAccountFallback, @@ -457,6 +477,7 @@ extension MenuDescriptor.MenuAction { case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue case .statusPage: MenuDescriptor.MenuActionSystemImage.statusPage.rawValue case .switchAccount: MenuDescriptor.MenuActionSystemImage.switchAccount.rawValue + case .addTokenAccount: MenuDescriptor.MenuActionSystemImage.switchAccount.rawValue case .openTerminal: MenuDescriptor.MenuActionSystemImage.openTerminal.rawValue case .loginToProvider: MenuDescriptor.MenuActionSystemImage.loginToProvider.rawValue case .copyError: MenuDescriptor.MenuActionSystemImage.copyError.rawValue diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 58a55deb5..be34d5b1d 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -5,6 +5,7 @@ import SwiftUI struct ProviderDetailView: View { let provider: UsageProvider @Bindable var store: UsageStore + @Bindable var settings: SettingsStore @Binding var isEnabled: Bool let subtitle: String let model: UsageMenuCardView.Model @@ -53,13 +54,62 @@ struct ProviderDetailView: View { subtitle: self.subtitle, model: self.model, labelWidth: labelWidth, + hideAccountAndPlan: self.codexHidesHeaderAccountAndPlan, onRefresh: self.onRefresh) - ProviderMetricsInlineView( - provider: self.provider, - model: self.model, - isEnabled: self.isEnabled, - labelWidth: labelWidth) + // Accounts section shown prominently at the top, before usage metrics. + if let tokenAccounts = self.settingsTokenAccounts, + tokenAccounts.isVisible?() ?? true + { + ProviderSettingsSection(title: "Accounts") { + ProviderSettingsTokenAccountsRowView(descriptor: tokenAccounts) + } + } + + Group { + if self.provider == .codex, self.codexShowsUsageAccountSwitcher { + let accounts = self.settings.tokenAccounts(for: .codex) + let defaultLabel = CodexProviderImplementation() + .tokenAccountDefaultLabel(settings: self.settings) + let rawSelection = self.settings.tokenAccountsData(for: .codex)?.activeIndex ?? -1 + ProviderMetricsInlineView( + provider: self.provider, + model: self.model, + isEnabled: self.isEnabled, + labelWidth: labelWidth, + accountSwitcher: { + TokenAccountSwitcherRepresentable( + accounts: accounts, + defaultAccountLabel: defaultLabel, + selectedIndex: rawSelection, + width: ProviderSettingsMetrics.detailMaxWidth, + onSelect: { index in + self.settings.setActiveTokenAccountIndex(index, for: .codex) + Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.store.refreshProvider(.codex, allowDisabled: true) + } + } + }) + .id(self.codexUsageAccountSwitcherIdentity) + .frame(height: TokenAccountSwitcherView.preferredHeight( + accounts: accounts, + defaultAccountLabel: defaultLabel)) + }) + } else { + ProviderMetricsInlineView( + provider: self.provider, + model: self.model, + isEnabled: self.isEnabled, + labelWidth: labelWidth) + } + } + + if let tokenUsage = self.model.tokenUsage { + ProviderCostSettingsSection( + accountLabel: self.costSectionAccountLabel, + tokenUsage: tokenUsage) + } if let errorDisplay { ProviderErrorView( @@ -71,25 +121,31 @@ struct ProviderDetailView: View { if self.hasSettings { ProviderSettingsSection(title: "Settings") { - ForEach(self.settingsPickers) { picker in + ForEach(self.settingsSectionPickers) { picker in ProviderSettingsPickerRowView(picker: picker) } - if let tokenAccounts = self.settingsTokenAccounts, - tokenAccounts.isVisible?() ?? true - { - ProviderSettingsTokenAccountsRowView(descriptor: tokenAccounts) - } ForEach(self.settingsFields) { field in ProviderSettingsFieldRowView(field: field) } } } - if !self.settingsToggles.isEmpty { + if self.hasOptionsSection { ProviderSettingsSection(title: "Options") { + ForEach(self.optionsSectionPickers) { picker in + ProviderSettingsPickerRowView(picker: picker) + } ForEach(self.settingsToggles) { toggle in ProviderSettingsToggleRowView(toggle: toggle) } + if self.provider == .codex { + Text( + "The primary account is whichever identity Codex has configured in ~/.codex on this Mac. Other rows in Accounts are separate credentials/folders. “Menu bar account” chooses which one CodexBar shows in the menu bar.") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + } } } } @@ -100,10 +156,68 @@ struct ProviderDetailView: View { .frame(maxWidth: .infinity, alignment: .leading) } + private var settingsSectionPickers: [ProviderSettingsPickerDescriptor] { + self.settingsPickers.filter { $0.section == .settings } + } + + private var optionsSectionPickers: [ProviderSettingsPickerDescriptor] { + self.settingsPickers.filter { $0.section == .options } + } + private var hasSettings: Bool { - !self.settingsPickers.isEmpty || - !self.settingsFields.isEmpty || - self.settingsTokenAccounts != nil + !self.settingsSectionPickers.isEmpty || + !self.settingsFields.isEmpty + } + + private var hasOptionsSection: Bool { + !self.settingsToggles.isEmpty || !self.optionsSectionPickers.isEmpty + } + + /// When Codex has more than one selectable account, summary email/plan reflect only the active fetch — hide to avoid confusion. + private var codexHidesHeaderAccountAndPlan: Bool { + guard self.provider == .codex else { return false } + let hasPrimary = CodexProviderImplementation().tokenAccountDefaultLabel(settings: self.settings) != nil + let addedCount = self.settings.tokenAccounts(for: .codex).count + return (hasPrimary ? 1 : 0) + addedCount >= 2 + } + + /// Same rule as the menu-bar token switcher: default ~/.codex + ≥1 added account, or 2+ added accounts. + private var codexShowsUsageAccountSwitcher: Bool { + guard self.provider == .codex else { return false } + let accounts = self.settings.tokenAccounts(for: .codex) + let defaultLabel = CodexProviderImplementation().tokenAccountDefaultLabel(settings: self.settings) + return (accounts.count >= 1 && defaultLabel != nil) || accounts.count > 1 + } + + private var codexUsageAccountSwitcherIdentity: String { + let accounts = self.settings.tokenAccounts(for: .codex) + let ids = accounts.map(\.id.uuidString).sorted().joined(separator: ",") + let raw = self.settings.tokenAccountsData(for: .codex)?.activeIndex ?? -1 + return "\(self.settings.configRevision)-\(ids)-\(raw)" + } + + /// Display name for the account whose usage/cost is shown (token selection or primary or menu card email). + private var costSectionAccountLabel: String? { + let provider = self.provider + if TokenAccountSupportCatalog.support(for: provider) != nil { + let accounts = self.settings.tokenAccounts(for: provider) + let raw = self.settings.tokenAccountsData(for: provider)?.activeIndex ?? -1 + if raw < 0 || accounts.isEmpty { + if let custom = self.settings.providerConfig(for: provider)?.defaultAccountLabel? + .trimmingCharacters(in: .whitespacesAndNewlines), + !custom.isEmpty + { + return custom + } + return ProviderCatalog.implementation(for: provider)? + .tokenAccountDefaultLabel(settings: self.settings) + } + let index = min(max(raw, 0), max(0, accounts.count - 1)) + guard index < accounts.count else { return nil } + return accounts[index].displayName + } + let email = self.model.email.trimmingCharacters(in: .whitespacesAndNewlines) + return email.isEmpty ? nil : email } private var detailLabelWidth: CGFloat { @@ -111,10 +225,11 @@ struct ProviderDetailView: View { if self.store.status(for: self.provider) != nil { infoLabels.append("Status") } - if !self.model.email.isEmpty { + let hideAccountPlan = self.codexHidesHeaderAccountAndPlan + if !hideAccountPlan, !self.model.email.isEmpty { infoLabels.append("Account") } - if let planRow = Self.planRow(provider: self.provider, planText: self.model.planText) { + if !hideAccountPlan, let planRow = Self.planRow(provider: self.provider, planText: self.model.planText) { infoLabels.append(planRow.label) } @@ -149,6 +264,7 @@ private struct ProviderDetailHeaderView: View { let subtitle: String let model: UsageMenuCardView.Model let labelWidth: CGFloat + let hideAccountAndPlan: Bool let onRefresh: () -> Void var body: some View { @@ -187,7 +303,8 @@ private struct ProviderDetailHeaderView: View { store: self.store, isEnabled: self.isEnabled, model: self.model, - labelWidth: self.labelWidth) + labelWidth: self.labelWidth, + hideAccountAndPlan: self.hideAccountAndPlan) } } @@ -230,6 +347,7 @@ private struct ProviderDetailInfoGrid: View { let isEnabled: Bool let model: UsageMenuCardView.Model let labelWidth: CGFloat + let hideAccountAndPlan: Bool var body: some View { let status = self.store.status(for: self.provider) @@ -252,11 +370,13 @@ private struct ProviderDetailInfoGrid: View { labelWidth: self.labelWidth) } - if !email.isEmpty { + if !self.hideAccountAndPlan, !email.isEmpty { ProviderDetailInfoRow(label: "Account", value: email, labelWidth: self.labelWidth) } - if let planRow = ProviderDetailView.planRow(provider: self.provider, planText: self.model.planText) { + if !self.hideAccountAndPlan, + let planRow = ProviderDetailView.planRow(provider: self.provider, planText: self.model.planText) + { ProviderDetailInfoRow(label: planRow.label, value: planRow.value, labelWidth: self.labelWidth) } } @@ -291,11 +411,26 @@ private struct ProviderDetailInfoRow: View { } @MainActor -struct ProviderMetricsInlineView: View { +struct ProviderMetricsInlineView: View { let provider: UsageProvider let model: UsageMenuCardView.Model let isEnabled: Bool let labelWidth: CGFloat + @ViewBuilder private var accountSwitcher: AccountSwitcher + + init( + provider: UsageProvider, + model: UsageMenuCardView.Model, + isEnabled: Bool, + labelWidth: CGFloat, + @ViewBuilder accountSwitcher: () -> AccountSwitcher) + { + self.provider = provider + self.model = model + self.isEnabled = isEnabled + self.labelWidth = labelWidth + self.accountSwitcher = accountSwitcher() + } var body: some View { let hasMetrics = !self.model.metrics.isEmpty @@ -303,13 +438,18 @@ struct ProviderMetricsInlineView: View { let hasCredits = self.model.creditsText != nil let hasProviderCost = self.model.providerCost != nil let hasTokenUsage = self.model.tokenUsage != nil + let hasUsageRows = hasMetrics || hasUsageNotes || hasProviderCost || hasCredits ProviderSettingsSection( title: "Usage", + titleTrailingNote: self.provider == .codex + ? "(Cost only available for API configured accounts)" + : nil, spacing: 8, verticalPadding: 6, horizontalPadding: 0) { - if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage { + self.accountSwitcher + if !hasUsageRows, !hasTokenUsage { Text(self.placeholderText) .font(.footnote) .foregroundStyle(.secondary) @@ -342,17 +482,6 @@ struct ProviderMetricsInlineView: View { progressColor: self.model.progressColor, labelWidth: self.labelWidth) } - - if let tokenUsage = self.model.tokenUsage { - ProviderMetricInlineTextRow( - title: "Cost", - value: tokenUsage.sessionLine, - labelWidth: self.labelWidth) - ProviderMetricInlineTextRow( - title: "", - value: tokenUsage.monthLine, - labelWidth: self.labelWidth) - } } } } @@ -365,6 +494,58 @@ struct ProviderMetricsInlineView: View { } } +extension ProviderMetricsInlineView where AccountSwitcher == EmptyView { + init(provider: UsageProvider, model: UsageMenuCardView.Model, isEnabled: Bool, labelWidth: CGFloat) { + self.init( + provider: provider, + model: model, + isEnabled: isEnabled, + labelWidth: labelWidth, + accountSwitcher: { EmptyView() }) + } +} + +@MainActor +private struct ProviderCostSettingsSection: View { + let accountLabel: String? + let tokenUsage: UsageMenuCardView.Model.TokenUsageSection + + var body: some View { + ProviderSettingsSection( + title: "Cost", + spacing: 8, + verticalPadding: 6, + horizontalPadding: 0) + { + VStack(alignment: .leading, spacing: 6) { + if let accountLabel, !accountLabel.isEmpty { + Text("Account: \(accountLabel)") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + } + Text(self.tokenUsage.sessionLine) + .font(.footnote) + .foregroundStyle(.secondary) + Text(self.tokenUsage.monthLine) + .font(.footnote) + .foregroundStyle(.secondary) + if let hint = self.tokenUsage.hintLine, !hint.isEmpty { + Text(hint) + .font(.footnote) + .foregroundStyle(.tertiary) + } + if let error = self.tokenUsage.errorLine, !error.isEmpty { + Text(error) + .font(.footnote) + .foregroundStyle(.red) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 2) + } + } +} + private struct ProviderMetricInlineRow: View { let metric: UsageMenuCardView.Model.Metric let title: String diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 414f41c55..9fcb4befc 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -1,7 +1,10 @@ +import CodexBarCore import SwiftUI struct ProviderSettingsSection: View { let title: String + /// Shown to the right of the title (e.g. usage hints), typically `.caption2` secondary text. + let titleTrailingNote: String? let spacing: CGFloat let verticalPadding: CGFloat let horizontalPadding: CGFloat @@ -9,12 +12,14 @@ struct ProviderSettingsSection: View { init( title: String, + titleTrailingNote: String? = nil, spacing: CGFloat = 12, verticalPadding: CGFloat = 10, horizontalPadding: CGFloat = 4, @ViewBuilder content: @escaping () -> Content) { self.title = title + self.titleTrailingNote = titleTrailingNote self.spacing = spacing self.verticalPadding = verticalPadding self.horizontalPadding = horizontalPadding @@ -23,8 +28,19 @@ struct ProviderSettingsSection: View { var body: some View { VStack(alignment: .leading, spacing: self.spacing) { - Text(self.title) - .font(.headline) + if let note = self.titleTrailingNote, !note.isEmpty { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.title) + .font(.headline) + Text(note) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + Text(self.title) + .font(.headline) + } self.content() } .frame(maxWidth: .infinity, alignment: .leading) @@ -200,11 +216,27 @@ struct ProviderSettingsFieldRowView: View { } } +private enum CodexAddAccountMode: String, CaseIterable { + case oauth + case apiKey +} + @MainActor struct ProviderSettingsTokenAccountsRowView: View { let descriptor: ProviderSettingsTokenAccountsDescriptor + @State private var codexAddAccountMode: CodexAddAccountMode = .oauth @State private var newLabel: String = "" @State private var newToken: String = "" + @State private var isSigningIn: Bool = false + @State private var signInProgress: String = "" + @State private var signInError: String = "" + /// ID of the token account currently being renamed (nil = none). + @State private var renamingAccountID: UUID? = nil + /// Whether the default account tab is being renamed. + @State private var renamingDefault: Bool = false + /// Current text inside the active rename field. + @State private var renameText: String = "" + @FocusState private var renameFieldFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -219,44 +251,337 @@ struct ProviderSettingsTokenAccountsRowView: View { } let accounts = self.descriptor.accounts() - if accounts.isEmpty { - Text("No token accounts yet.") + let defaultLabel = self.descriptor.defaultAccountLabel?() + let hasDefaultTab = defaultLabel != nil + // activeIndex < 0 means the default account is selected + let activeIndex = self.descriptor.activeIndex() + let defaultIsActive = activeIndex < 0 || (accounts.isEmpty && hasDefaultTab) + let selectedIndex = defaultIsActive ? -1 : min(activeIndex, max(0, accounts.count - 1)) + + if !hasDefaultTab && accounts.isEmpty { + Text("No accounts added yet.") .font(.footnote) .foregroundStyle(.secondary) } else { - let selectedIndex = min(self.descriptor.activeIndex(), max(0, accounts.count - 1)) - Picker("", selection: Binding( - get: { selectedIndex }, - set: { index in self.descriptor.setActiveIndex(index) })) - { - ForEach(Array(accounts.enumerated()), id: \.offset) { index, account in - Text(account.displayName).tag(index) + self.accountTabsView( + defaultLabel: defaultLabel, + accounts: accounts, + selectedIndex: selectedIndex) + if self.descriptor.provider == .codex { + Text( + "Only one account is active at a time. Choose “Menu bar account” under Options below. The house row is your primary ~/.codex sign-in; added rows use a separate OAuth folder or API key. Buy Credits is also under Options.") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + if self.descriptor.provider == .codex { + VStack(alignment: .leading, spacing: 6) { + Text("Add account") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Picker("Add account mode", selection: self.$codexAddAccountMode) { + Text("OAuth").tag(CodexAddAccountMode.oauth) + Text("API key").tag(CodexAddAccountMode.apiKey) } + .labelsHidden() + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize(horizontal: true, vertical: false) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityLabel("Add account mode") + .help("OAuth: browser sign-in. API key: paste an OpenAI key.") } - .labelsHidden() - .pickerStyle(.menu) - .controlSize(.small) + .frame(maxWidth: .infinity, alignment: .leading) + } - Button("Remove selected account") { - let account = accounts[selectedIndex] - self.descriptor.removeAccount(account.id) + if self.descriptor.provider == .codex { + if self.codexAddAccountMode == .oauth { + if let loginAction = self.descriptor.loginAction { + self.signInSection(loginAction: loginAction, addAccount: self.descriptor.addAccount) + } else { + Text("Browser OAuth requires the Codex CLI. You can still add an account with an API key (other tab).") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + self.codexAPIKeyAddSection() } - .buttonStyle(.bordered) + } else if let loginAction = self.descriptor.loginAction { + self.signInSection(loginAction: loginAction, addAccount: self.descriptor.addAccount) + } else { + self.manualAddSection() + } + + HStack(spacing: 10) { + Button("Open config file") { + self.descriptor.openConfigFile() + } + .buttonStyle(.link) .controlSize(.small) + Button("Reload") { + self.descriptor.reloadFromDisk() + } + .buttonStyle(.link) + .controlSize(.small) + } + } + } + + @ViewBuilder + private func accountTabsView( + defaultLabel: String?, + accounts: [ProviderTokenAccount], + selectedIndex: Int) -> some View + { + VStack(alignment: .leading, spacing: 8) { + if let defaultLabel { + self.defaultAccountTab(label: defaultLabel, isActive: selectedIndex < 0) + .frame(maxWidth: .infinity, alignment: .leading) + } + ForEach(Array(accounts.enumerated()), id: \.1.id) { index, account in + self.accountTab(account: account, index: index, isActive: index == selectedIndex) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + @ViewBuilder + private func menuBarActiveBadge() -> some View { + Text("Menu bar") + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Color.accentColor.opacity(0.22))) + .foregroundStyle(Color.accentColor) + } + + @ViewBuilder + private func defaultAccountTab(label: String, isActive: Bool) -> some View { + let isRenaming = self.renamingDefault && self.descriptor.renameDefaultAccount != nil + let showCodexHints = self.descriptor.provider == .codex + let highlightSelection = !showCodexHints + let rowActive = highlightSelection && isActive + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "house.fill") + .foregroundStyle(rowActive ? Color.accentColor : .secondary) + .imageScale(.small) + .accessibilityLabel("Primary account") + if isRenaming { + TextField("Name", text: self.$renameText) + .font(.footnote) + .textFieldStyle(.plain) + .frame(minWidth: 100, maxWidth: 180) + .focused(self.$renameFieldFocused) + .onSubmit { self.commitRenameDefault() } + } else { + Group { + if showCodexHints { + Text(label) + .font(.footnote.weight(.medium)) + .lineLimit(1) + .foregroundStyle(.primary) + } else { + Button(action: { self.descriptor.setActiveIndex(-1) }) { + Text(label) + .font(.footnote.weight(.medium)) + .lineLimit(1) + } + .buttonStyle(.plain) + .foregroundStyle(rowActive ? Color.accentColor : .primary) + } + } + Spacer(minLength: 8) + if rowActive { + self.menuBarActiveBadge() + } + if self.descriptor.renameDefaultAccount != nil { + Button(action: { + self.renameText = label + self.renamingDefault = true + self.renamingAccountID = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self.renameFieldFocused = true + } + }) { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .imageScale(.small) + } + .buttonStyle(.plain) + .help("Rename tab") + } + if !showCodexHints, !isActive, !isRenaming { + Button("Use") { + self.descriptor.setActiveIndex(-1) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .font(.caption2.weight(.medium)) + .help("Use this ~/.codex account for the menu bar") + } + } + } + if showCodexHints, !isRenaming { + Text("Primary · ~/.codex on this Mac") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(rowActive + ? Color.accentColor.opacity(0.12) + : Color(NSColor.controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(rowActive + ? Color.accentColor.opacity(0.45) + : Color(NSColor.separatorColor), + lineWidth: rowActive ? 1.5 : 1))) + .onChange(of: self.renameFieldFocused) { _, focused in + if !focused && self.renamingDefault { self.commitRenameDefault() } + } + } + + private func commitRenameDefault() { + let trimmed = self.renameText.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + self.descriptor.renameDefaultAccount?(trimmed) + } + self.renamingDefault = false + self.renameText = "" + } + + @ViewBuilder + private func accountTab(account: ProviderTokenAccount, index: Int, isActive: Bool) -> some View { + let isRenaming = self.renamingAccountID == account.id + let showCodexHints = self.descriptor.provider == .codex + let highlightSelection = !showCodexHints + let rowActive = highlightSelection && isActive + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "rectangle.stack.fill") + .foregroundStyle(rowActive ? Color.accentColor : .secondary) + .imageScale(.small) + .accessibilityLabel("Added account") + if isRenaming { + TextField("Name", text: self.$renameText) + .font(.footnote) + .textFieldStyle(.plain) + .frame(minWidth: 100, maxWidth: 180) + .focused(self.$renameFieldFocused) + .onSubmit { self.commitRename(account: account) } + } else { + Group { + if showCodexHints { + Text(account.displayName) + .font(.footnote.weight(.medium)) + .lineLimit(1) + .foregroundStyle(.primary) + } else { + Button(action: { self.descriptor.setActiveIndex(index) }) { + Text(account.displayName) + .font(.footnote.weight(.medium)) + .lineLimit(1) + } + .buttonStyle(.plain) + .foregroundStyle(rowActive ? Color.accentColor : .primary) + } + } + Spacer(minLength: 8) + if rowActive { + self.menuBarActiveBadge() + } + Button(action: { + self.renameText = account.displayName + self.renamingAccountID = account.id + self.renamingDefault = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self.renameFieldFocused = true + } + }) { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .imageScale(.small) + } + .buttonStyle(.plain) + .help("Rename tab") + Button(action: { self.descriptor.removeAccount(account.id) }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary.opacity(0.85)) + .imageScale(.small) + } + .buttonStyle(.plain) + .help("Remove account") + if !showCodexHints, !isActive, !isRenaming { + Button("Use") { + self.descriptor.setActiveIndex(index) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .font(.caption2.weight(.medium)) + .help("Use this account for the menu bar") + } + } + } + if showCodexHints, !isRenaming { + Text("Added account · OAuth folder or API key") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(rowActive + ? Color.accentColor.opacity(0.12) + : Color(NSColor.controlBackgroundColor)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(rowActive + ? Color.accentColor.opacity(0.45) + : Color(NSColor.separatorColor), + lineWidth: rowActive ? 1.5 : 1))) + .onChange(of: self.renameFieldFocused) { _, focused in + if !focused, self.renamingAccountID == account.id { self.commitRename(account: account) } + } + } + + private func commitRename(account: ProviderTokenAccount) { + let trimmed = self.renameText.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + self.descriptor.renameAccount(account.id, trimmed) + } + self.renamingAccountID = nil + self.renameText = "" + } + @ViewBuilder + private func codexAPIKeyAddSection() -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("Adds an account that sets OPENAI_API_KEY for Codex (stored securely in your config).") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) HStack(spacing: 8) { TextField("Label", text: self.$newLabel) .textFieldStyle(.roundedBorder) .font(.footnote) - SecureField(self.descriptor.placeholder, text: self.$newToken) + SecureField("OpenAI API key", 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.descriptor.addAccount(label, "apikey:\(token)") self.newLabel = "" self.newToken = "" } @@ -265,19 +590,84 @@ struct ProviderSettingsTokenAccountsRowView: View { .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } + } + } - HStack(spacing: 10) { - Button("Open token file") { - self.descriptor.openConfigFile() + @ViewBuilder + private func signInSection( + loginAction: @escaping ( + _ setProgress: @escaping @MainActor (String) -> Void, + _ addAccount: @escaping @MainActor (String, String) -> Void + ) async -> Bool, + addAccount: @escaping (String, String) -> Void) -> some View + { + VStack(alignment: .leading, spacing: 6) { + if self.isSigningIn { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text(self.signInProgress.isEmpty ? "Starting login…" : self.signInProgress) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } - .buttonStyle(.link) - .controlSize(.small) - Button("Reload") { - self.descriptor.reloadFromDisk() + } else { + Button("Sign in to new account") { + self.signInError = "" + self.isSigningIn = true + self.signInProgress = "Opening browser for login…" + Task { @MainActor in + let success = await loginAction( + { @MainActor progress in self.signInProgress = progress }, + { @MainActor label, token in addAccount(label, token) }) + self.isSigningIn = false + self.signInProgress = "" + if !success { + self.signInError = "Login failed or was cancelled. Try again." + } + } } - .buttonStyle(.link) + .buttonStyle(.borderedProminent) .controlSize(.small) } + + if !self.signInError.isEmpty { + Text(self.signInError) + .font(.footnote) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + @ViewBuilder + private func manualAddSection() -> some View { + HStack(spacing: 8) { + TextField("Label", text: self.$newLabel) + .textFieldStyle(.roundedBorder) + .font(.footnote) + if self.descriptor.isSecureToken { + SecureField(self.descriptor.placeholder, text: self.$newToken) + .textFieldStyle(.roundedBorder) + .font(.footnote) + } else { + TextField(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 = "" + } + .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 e2dce0a7f..ceba08702 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -90,6 +90,7 @@ enum ProvidersPaneTestHarness { _ = ProviderDetailView( provider: .codex, store: store, + settings: store.settings, isEnabled: enabledBinding, subtitle: "Subtitle", model: model, @@ -166,6 +167,7 @@ enum ProvidersPaneTestHarness { title: "Accounts", subtitle: "Accounts subtitle", placeholder: "Token", + isSecureToken: true, provider: .codex, isVisible: { true }, accounts: { [] }, @@ -173,8 +175,12 @@ enum ProvidersPaneTestHarness { setActiveIndex: { _ in }, addAccount: { _, _ in }, removeAccount: { _ in }, + renameAccount: { _, _ in }, openConfigFile: {}, - reloadFromDisk: {}) + reloadFromDisk: {}, + defaultAccountLabel: nil, + renameDefaultAccount: nil, + loginAction: nil) return ProviderListTestDescriptors( toggle: toggle, diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 7a040dafd..eb946b774 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -32,6 +32,7 @@ struct ProvidersPane: View { ProviderDetailView( provider: provider, store: self.store, + settings: self.settings, isEnabled: self.binding(for: provider), subtitle: self.providerSubtitle(provider), model: self.menuCardModel(for: provider), @@ -168,11 +169,24 @@ struct ProvidersPane: View { func tokenAccountDescriptor(for provider: UsageProvider) -> ProviderSettingsTokenAccountsDescriptor? { guard let support = TokenAccountSupportCatalog.support(for: provider) else { return nil } let context = self.makeSettingsContext(provider: provider) + let isSecureToken: Bool = { + if case .codexHome = support.injection { return false } + return true + }() + let impl = ProviderCatalog.implementation(for: provider) + let loginAction = impl?.tokenAccountLoginAction(context: context) + let defaultAccountLabel: (() -> String?)? = impl.map { imp in + { imp.tokenAccountDefaultLabel(settings: self.settings) } + } + let renameDefaultAccount: ((_ newLabel: String) -> Void)? = impl.map { _ in + { newLabel in self.settings.setDefaultAccountLabel(provider: provider, label: newLabel) } + } return ProviderSettingsTokenAccountsDescriptor( id: "token-accounts-\(provider.rawValue)", title: support.title, subtitle: support.subtitle, placeholder: support.placeholder, + isSecureToken: isSecureToken, provider: provider, isVisible: { ProviderCatalog.implementation(for: provider)? @@ -182,8 +196,8 @@ struct ProvidersPane: View { }, accounts: { self.settings.tokenAccounts(for: provider) }, activeIndex: { - let data = self.settings.tokenAccountsData(for: provider) - return data?.clampedActiveIndex() ?? 0 + guard let data = self.settings.tokenAccountsData(for: provider) else { return -1 } + return data.activeIndex }, setActiveIndex: { index in self.settings.setActiveTokenAccountIndex(index, for: provider) @@ -209,6 +223,9 @@ struct ProvidersPane: View { } } }, + renameAccount: { accountID, newLabel in + self.settings.renameTokenAccount(provider: provider, accountID: accountID, newLabel: newLabel) + }, openConfigFile: { self.settings.openTokenAccountsFile() }, @@ -219,7 +236,10 @@ struct ProvidersPane: View { await self.store.refreshProvider(provider, allowDisabled: true) } } - }) + }, + defaultAccountLabel: defaultAccountLabel, + renameDefaultAccount: renameDefaultAccount, + loginAction: loginAction) } private func makeSettingsContext(provider: UsageProvider) -> ProviderSettingsContext { @@ -359,6 +379,7 @@ struct ProvidersPane: View { resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + codexMenuCreditsPrimaryAccountNotice: self.settings.codexMenuCreditsPrimaryAccountOnlyMessage(), hidePersonalInfo: self.settings.hidePersonalInfo, weeklyPace: weeklyPace, now: now) diff --git a/Sources/CodexBar/ProviderSwitcherButtons.swift b/Sources/CodexBar/ProviderSwitcherButtons.swift index 05ce53c53..0d9cbf0dd 100644 --- a/Sources/CodexBar/ProviderSwitcherButtons.swift +++ b/Sources/CodexBar/ProviderSwitcherButtons.swift @@ -1,5 +1,21 @@ import AppKit +/// Gives token-account (and other text-only) toggle buttons comfortable horizontal insets; plain +/// `NSButton` title drawing sits flush against the pill edges when `fillEqually` makes cells wide. +final class PaddedTitleButtonCell: NSButtonCell { + var horizontalTitlePadding: CGFloat = 8 + + override func titleRect(forBounds rect: NSRect) -> NSRect { + let base = super.titleRect(forBounds: rect) + let p = self.horizontalTitlePadding + return NSRect( + x: base.origin.x + p, + y: base.origin.y, + width: max(1, base.size.width - 2 * p), + height: base.size.height) + } +} + final class PaddedToggleButton: NSButton { var contentPadding = NSEdgeInsets(top: 4, left: 7, bottom: 4, right: 7) { didSet { @@ -13,6 +29,28 @@ final class PaddedToggleButton: NSButton { } } + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + convenience init(title: String, target: AnyObject?, action: Selector?) { + self.init(frame: .zero) + let cell = PaddedTitleButtonCell(textCell: title) + cell.isBordered = false + cell.lineBreakMode = .byTruncatingTail + cell.usesSingleLineMode = true + cell.alignment = .center + self.cell = cell + self.setButtonType(.toggle) + self.target = target + self.action = action + } + override var intrinsicContentSize: NSSize { let size = super.intrinsicContentSize return NSSize( diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 61aa3a501..00fa20512 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation @@ -70,7 +71,42 @@ struct CodexProviderImplementation: ProviderImplementation { } }) + let buyCreditsBinding = Binding( + get: { context.settings.codexBuyCreditsMenuEnabled }, + set: { context.settings.codexBuyCreditsMenuEnabled = $0 }) + return [ + ProviderSettingsToggleDescriptor( + id: "codex-buy-credits-menu", + title: "Show Buy Credits in menu", + subtitle: "Adds a “Buy Credits…” item to the Codex menu for ChatGPT billing.", + binding: buyCreditsBinding, + statusText: { + guard context.settings.codexBuyCreditsMenuEnabled else { return nil } + let hasKey = !(context.settings.providerConfig(for: .codex)?.sanitizedAPIKey ?? "").isEmpty + if !hasKey { + return "No API key saved — only OAuth / browser-based flows are configured for Codex." + } + return nil + }, + actions: [], + isVisible: nil, + onChange: { enabled in + guard enabled else { return } + let hasKey = !(context.settings.providerConfig(for: .codex)?.sanitizedAPIKey ?? "").isEmpty + guard !hasKey else { return } + await MainActor.run { + let alert = NSAlert() + alert.messageText = "No API key configured" + alert.informativeText = + "Buy Credits opens the ChatGPT billing page. You don’t have an API key saved for Codex — only OAuth-based usage is configured. You can still continue; add an API key in Codex settings if you use one." + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } + }, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-historical-tracking", title: "Historical tracking", @@ -109,6 +145,45 @@ struct CodexProviderImplementation: ProviderImplementation { context.settings.codexCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) + let menuBarAccountBinding = Binding( + get: { + let accounts = context.settings.tokenAccounts(for: .codex) + let hasPrimary = self.tokenAccountDefaultLabel(settings: context.settings) != nil + let raw = context.settings.tokenAccountsData(for: .codex)?.activeIndex ?? -1 + if hasPrimary, raw < 0 { return "default" } + guard !accounts.isEmpty else { return hasPrimary ? "default" : "0" } + let idx = min(max(raw < 0 ? 0 : raw, 0), accounts.count - 1) + return String(idx) + }, + set: { newId in + if newId == "default" { + context.settings.setActiveTokenAccountIndex(-1, for: .codex) + } else if let idx = Int(newId) { + context.settings.setActiveTokenAccountIndex(idx, for: .codex) + } + }) + + var menuBarAccountOptions: [ProviderSettingsPickerOption] = [] + if self.tokenAccountDefaultLabel(settings: context.settings) != nil { + let custom = context.settings.providerConfig(for: .codex)?.defaultAccountLabel? + .trimmingCharacters(in: .whitespacesAndNewlines) + let title: String + if let custom, !custom.isEmpty { + title = custom + } else if let email = self.tokenAccountDefaultLabel(settings: context.settings) { + title = email + } else { + title = "Primary" + } + menuBarAccountOptions.append( + ProviderSettingsPickerOption( + id: "default", + title: "\(title) (primary ~/.codex)")) + } + for (i, acc) in context.settings.tokenAccounts(for: .codex).enumerated() { + menuBarAccountOptions.append(ProviderSettingsPickerOption(id: String(i), title: acc.displayName)) + } + let usageOptions = CodexUsageDataSource.allCases.map { ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) } @@ -126,6 +201,23 @@ struct CodexProviderImplementation: ProviderImplementation { } return [ + ProviderSettingsPickerDescriptor( + id: "codex-menu-bar-account", + title: "Menu bar account", + subtitle: "Which Codex account drives the menu bar and usage on this Mac.", + binding: menuBarAccountBinding, + options: menuBarAccountOptions, + isVisible: { + let accounts = context.settings.tokenAccounts(for: .codex) + let hasPrimary = self.tokenAccountDefaultLabel(settings: context.settings) != nil + return (hasPrimary ? 1 : 0) + accounts.count >= 2 + }, + onChange: { _ in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await context.store.refreshProvider(.codex, allowDisabled: true) + } + }, + section: .options), ProviderSettingsPickerDescriptor( id: "codex-usage-source", title: "Usage source", @@ -191,9 +283,91 @@ struct CodexProviderImplementation: ProviderImplementation { } } + @MainActor + func loginMenuAction(context _: ProviderMenuLoginContext) + -> (label: String, action: MenuDescriptor.MenuAction)? + { + ("Add Account...", .addTokenAccount(.codex)) + } + @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runCodexLoginFlow() return true } + + @MainActor + func tokenAccountDefaultLabel(settings: SettingsStore?) -> String? { + if let custom = settings?.providerConfig(for: .codex)?.defaultAccountLabel, + !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return custom + } + guard let credentials = try? CodexOAuthCredentialsStore.load() else { return nil } + + if let idToken = credentials.idToken, + let payload = UsageFetcher.parseJWT(idToken) + { + let profileDict = payload["https://api.openai.com/profile"] as? [String: Any] + let email = (payload["email"] as? String) ?? (profileDict?["email"] as? String) + let trimmed = email?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { return trimmed } + } + + // API-key auth (`auth.json` with OPENAI_API_KEY): valid credentials but no id_token/JWT. + let access = credentials.accessToken.trimmingCharacters(in: .whitespacesAndNewlines) + let refresh = credentials.refreshToken.trimmingCharacters(in: .whitespacesAndNewlines) + if !access.isEmpty, refresh.isEmpty { + return "API key" + } + + // OAuth loaded but no usable email/id_token (unusual); still treat default account as present. + if !refresh.isEmpty { + return "Codex" + } + + return nil + } + + @MainActor + func tokenAccountLoginAction(context _: ProviderSettingsContext) + -> (( + _ setProgress: @escaping @MainActor (String) -> Void, + _ addAccount: @escaping @MainActor (String, String) -> Void + ) async -> Bool)? + { + return { @MainActor setProgress, addAccount in + let accountsDir = (("~/.codex-accounts") as NSString).expandingTildeInPath + let uniqueDir = "\(accountsDir)/\(UUID().uuidString.prefix(8))" + try? FileManager.default.createDirectory( + atPath: uniqueDir, + withIntermediateDirectories: true) + + setProgress("Opening browser for login…") + let result = await CodexLoginRunner.run(codexHome: uniqueDir, timeout: 180) + + switch result.outcome { + case .success: + setProgress("Signed in — reading account info…") + let env = ["CODEX_HOME": uniqueDir] + let label: String + if let credentials = try? CodexOAuthCredentialsStore.load(env: env), + let idToken = credentials.idToken, + let payload = UsageFetcher.parseJWT(idToken) + { + let profileDict = payload["https://api.openai.com/profile"] as? [String: Any] + let email = (payload["email"] as? String) ?? (profileDict?["email"] as? String) + label = email?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Account" + } else { + label = "Account" + } + addAccount(label, uniqueDir) + return true + + case .missingBinary, .timedOut, .failed, .launchFailed: + try? FileManager.default.removeItem(atPath: uniqueDir) + return false + } + } + } } diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 335dbf411..4981696f0 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -2,6 +2,16 @@ import CodexBarCore import Foundation extension SettingsStore { + /// When `true` (default), shows "Buy Credits…" in the Codex menu. Persisted per-provider; `nil` in config means enabled. + var codexBuyCreditsMenuEnabled: Bool { + get { self.configSnapshot.providerConfig(for: .codex)?.buyCreditsMenuEnabled ?? true } + set { + self.updateProviderConfig(provider: .codex) { entry in + entry.buyCreditsMenuEnabled = newValue + } + } + } + var codexUsageDataSource: CodexUsageDataSource { get { let source = self.configSnapshot.providerConfig(for: .codex)?.source diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift index 7d5e22bd2..0d19a4592 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift @@ -73,6 +73,22 @@ protocol ProviderImplementation: Sendable { /// Optional provider-specific login flow. Returns whether to refresh after completion. @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool + + /// Optional label for the default (non-token-account) account shown as the first tab. + /// Returns the display label (e.g. email) or nil if no default account is signed in. + /// Called from both the Settings tab UI and the menu bar switcher. + /// Pass `settings` to allow a custom override label stored in `ProviderConfig.defaultAccountLabel`. + @MainActor + func tokenAccountDefaultLabel(settings: SettingsStore?) -> String? + + /// Optional guided login action for adding a new token account interactively. + /// The closure calls addAccount(label, token) directly and returns true on success. + @MainActor + func tokenAccountLoginAction(context: ProviderSettingsContext) + -> (( + _ setProgress: @escaping @MainActor (String) -> Void, + _ addAccount: @escaping @MainActor (String, String) -> Void + ) async -> Bool)? } extension ProviderImplementation { @@ -165,6 +181,21 @@ extension ProviderImplementation { func runLoginFlow(context _: ProviderLoginContext) async -> Bool { false } + + @MainActor + func tokenAccountDefaultLabel(settings _: SettingsStore?) -> String? { + nil + } + + @MainActor + func tokenAccountLoginAction(context _: ProviderSettingsContext) + -> (( + _ setProgress: @escaping @MainActor (String) -> Void, + _ addAccount: @escaping @MainActor (String, String) -> Void + ) async -> Bool)? + { + nil + } } struct ProviderLoginContext { diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index d5a85b8f7..d249bbd81 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -91,6 +91,8 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let title: String let subtitle: String let placeholder: String + /// When false, the token input is shown as plain text (e.g. file paths). Defaults to true. + let isSecureToken: Bool let provider: UsageProvider let isVisible: (() -> Bool)? let accounts: () -> [ProviderTokenAccount] @@ -98,8 +100,27 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let setActiveIndex: (Int) -> Void let addAccount: (_ label: String, _ token: String) -> Void let removeAccount: (_ accountID: UUID) -> Void + let renameAccount: (_ accountID: UUID, _ newLabel: String) -> Void let openConfigFile: () -> Void let reloadFromDisk: () -> Void + /// When set, the default (non-token-account) account label is shown as the first tab. + /// Returns the display label (e.g. email) for the default account, or nil if unavailable. + let defaultAccountLabel: (() -> String?)? + /// When set, allows the user to set a custom display name for the default account. + let renameDefaultAccount: ((_ newLabel: String) -> Void)? + /// When set, shows a "Sign in to new account" button that calls this closure. + /// The closure receives progress and addAccount callbacks; it adds the account itself and returns + /// true on success or false on failure/cancellation. + let loginAction: (( + _ setProgress: @escaping @MainActor (String) -> Void, + _ addAccount: @escaping @MainActor (String, String) -> Void + ) async -> Bool)? +} + +/// Which detail section a provider settings picker appears in. +enum ProviderSettingsPickerSection: Sendable { + case settings + case options } /// Shared picker descriptor rendered in the Providers settings pane. @@ -115,6 +136,7 @@ struct ProviderSettingsPickerDescriptor: Identifiable { let isEnabled: (() -> Bool)? let onChange: ((_ selection: String) async -> Void)? let trailingText: (() -> String?)? + let section: ProviderSettingsPickerSection init( id: String, @@ -126,7 +148,8 @@ struct ProviderSettingsPickerDescriptor: Identifiable { isVisible: (() -> Bool)?, isEnabled: (() -> Bool)? = nil, onChange: ((_ selection: String) async -> Void)?, - trailingText: (() -> String?)? = nil) + trailingText: (() -> String?)? = nil, + section: ProviderSettingsPickerSection = .settings) { self.id = id self.title = title @@ -138,6 +161,7 @@ struct ProviderSettingsPickerDescriptor: Identifiable { self.isEnabled = isEnabled self.onChange = onChange self.trailingText = trailingText + self.section = section } } diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 1f8a0277b..68deddf60 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -12,15 +12,24 @@ extension SettingsStore { self.tokenAccountsData(for: provider)?.accounts ?? [] } + /// When a non-primary Codex account is selected, menu credits should not show OAuth/cookie errors for add-on accounts. + func codexMenuCreditsPrimaryAccountOnlyMessage() -> String? { + guard let data = self.tokenAccountsData(for: .codex), !data.accounts.isEmpty else { return nil } + guard !data.isDefaultActive else { return nil } + return "Credit information is available only for the primary account." + } + func selectedTokenAccount(for provider: UsageProvider) -> ProviderTokenAccount? { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return nil } + guard !data.isDefaultActive else { return nil } let index = data.clampedActiveIndex() return data.accounts[index] } func setActiveTokenAccountIndex(_ index: Int, for provider: UsageProvider) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } - let clamped = min(max(index, 0), data.accounts.count - 1) + // index == -1 means "use default account" (no CODEX_HOME override) + let clamped = index < 0 ? -1 : min(max(index, 0), data.accounts.count - 1) let updated = ProviderTokenAccountData( version: data.version, accounts: data.accounts, @@ -66,6 +75,30 @@ extension SettingsStore { ]) } + func renameTokenAccount(provider: UsageProvider, accountID: UUID, newLabel: String) { + guard let data = self.tokenAccountsData(for: provider) else { return } + let trimmed = newLabel.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let updated = ProviderTokenAccountData( + version: data.version, + accounts: data.accounts.map { account in + var a = account + if a.id == accountID { a.label = trimmed } + return a + }, + activeIndex: data.activeIndex) + self.updateProviderConfig(provider: provider) { entry in + entry.tokenAccounts = updated + } + } + + func setDefaultAccountLabel(provider: UsageProvider, label: String) { + let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) + self.updateProviderConfig(provider: provider) { entry in + entry.defaultAccountLabel = trimmed.isEmpty ? nil : trimmed + } + } + func removeTokenAccount(provider: UsageProvider, accountID: UUID) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } let filtered = data.accounts.filter { $0.id != accountID } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index ad65b0879..c2b2fbaff 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -135,6 +135,69 @@ extension StatusItemController { } } + @objc func runAddTokenAccount(_ sender: NSMenuItem) { + if self.loginTask != nil { + self.loginLogger.info("Add Account tap ignored: login already in-flight") + return + } + + let rawProvider = sender.representedObject as? String + let provider = rawProvider.flatMap(UsageProvider.init(rawValue:)) ?? .codex + self.loginLogger.info("Add Account tapped", metadata: ["provider": provider.rawValue]) + + self.loginTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + self.activeLoginProvider = nil + self.loginTask = nil + } + self.activeLoginProvider = provider + self.loginPhase = .requesting + + let success = await self.runTokenAccountLogin(provider: provider) + + if success { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.store.refresh() + } + self.loginLogger.info("Triggered refresh after add account", metadata: ["provider": provider.rawValue]) + } + } + } + + private func runTokenAccountLogin(provider: UsageProvider) async -> Bool { + guard provider == .codex else { return false } + let accountsDir = (("~/.codex-accounts") as NSString).expandingTildeInPath + let uniqueDir = "\(accountsDir)/\(UUID().uuidString.prefix(8))" + try? FileManager.default.createDirectory( + atPath: uniqueDir, + withIntermediateDirectories: true) + + let result = await CodexLoginRunner.run(codexHome: uniqueDir, timeout: 180) + + switch result.outcome { + case .success: + let env = ["CODEX_HOME": uniqueDir] + let label: String + if let credentials = try? CodexOAuthCredentialsStore.load(env: env), + let idToken = credentials.idToken, + let payload = UsageFetcher.parseJWT(idToken) + { + let profileDict = payload["https://api.openai.com/profile"] as? [String: Any] + let email = (payload["email"] as? String) ?? (profileDict?["email"] as? String) + label = email?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Account" + } else { + label = "Account" + } + self.settings.addTokenAccount(provider: provider, label: label, token: uniqueDir) + return true + + case .missingBinary, .timedOut, .failed, .launchFailed: + try? FileManager.default.removeItem(atPath: uniqueDir) + return false + } + } + @objc func showSettingsGeneral() { self.openSettings(tab: .general) } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 1d7c6e35d..186319059 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -61,6 +61,7 @@ extension StatusItemController { let activeIndex: Int let showAll: Bool let showSwitcher: Bool + let defaultAccountLabel: String? } private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat { @@ -217,7 +218,6 @@ extension StatusItemController { self.lastMergedSwitcherSelection = switcherSelection self.lastSwitcherIncludesOverview = includesOverview } - self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) let menuContext = MenuCardContext( currentProvider: currentProvider, selectedProvider: selectedProvider, @@ -225,6 +225,7 @@ extension StatusItemController { tokenAccountDisplay: tokenAccountDisplay, openAIContext: openAIContext) if isOverviewSelected { + self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) if self.addOverviewRows( to: menu, enabledProviders: enabledProviders, @@ -236,6 +237,12 @@ extension StatusItemController { menu.addItem(.separator()) } } else { + if currentProvider == .codex { + self.addCostsCardIfNeeded(to: menu, width: menuWidth) + self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) + } else { + self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) + } let addedOpenAIWebItems = self.addMenuCards(to: menu, context: menuContext) self.addOpenAIWebItemsIfNeeded( to: menu, @@ -435,6 +442,9 @@ extension StatusItemController { menu.addItem(.separator()) } } + if context.currentProvider == .codex, self.settings.codexBuyCreditsMenuEnabled { + menu.addItem(self.makeBuyCreditsItem()) + } return false } @@ -457,13 +467,26 @@ extension StatusItemController { UsageMenuCardView(model: model, width: context.menuWidth), id: "menuCard", width: context.menuWidth)) - if context.currentProvider == .codex, model.creditsText != nil { + if context.currentProvider == .codex, self.settings.codexBuyCreditsMenuEnabled { menu.addItem(self.makeBuyCreditsItem()) } menu.addItem(.separator()) return false } + private func addCostsCardIfNeeded(to menu: NSMenu, width: CGFloat) { + let entries = self.store.allAccountCredits[.codex] ?? [] + let isLoading = self.store.accountCostRefreshInFlight.contains(.codex) + // Only show the card if we have at least one account with data (or it's loading). + guard !entries.isEmpty || isLoading else { return } + let card = AccountCostsMenuCardView( + entries: entries, + isLoading: isLoading, + width: width) + menu.addItem(self.makeMenuCardItem(card, id: "costsSummaryCard", width: width)) + menu.addItem(.separator()) + } + private func addOpenAIWebItemsIfNeeded( to menu: NSMenu, currentProvider: UsageProvider, @@ -482,6 +505,9 @@ extension StatusItemController { if context.hasCostHistory { _ = self.addCostHistorySubmenu(to: menu, provider: currentProvider) } + } else if currentProvider == .codex, context.hasCreditsHistory { + // Codex hides the credits card row; still expose credits history as a top-level submenu. + _ = self.addCreditsHistorySubmenu(to: menu) } menu.addItem(.separator()) } @@ -641,19 +667,27 @@ extension StatusItemController { { let view = TokenAccountSwitcherView( accounts: display.accounts, + defaultAccountLabel: display.defaultAccountLabel, selectedIndex: display.activeIndex, width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu), onSelect: { [weak self, weak menu] index in guard let self, let menu else { return } self.settings.setActiveTokenAccountIndex(index, for: display.provider) + // Immediate rebuild so the tab highlight updates without waiting for network. + self.populateMenu(menu, provider: display.provider) + self.markMenuFresh(menu) Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { - await self.store.refresh() + // Use refreshProvider (not refresh) so it always runs even if a global + // refresh is currently in progress (refresh() has an isRefreshing guard). + await self.store.refreshProvider(display.provider, allowDisabled: true) } + self.applyIcon(phase: nil) + // Re-populate with the freshly fetched snapshot for the newly selected account. + // invalidateMenus() skips open menus, so we must explicitly update here. + self.populateMenu(menu, provider: display.provider) + self.markMenuFresh(menu) } - self.populateMenu(menu, provider: display.provider) - self.markMenuFresh(menu) - self.applyIcon(phase: nil) }) let item = NSMenuItem() item.view = view @@ -691,8 +725,13 @@ extension StatusItemController { private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } let accounts = self.settings.tokenAccounts(for: provider) - guard accounts.count > 1 else { return nil } - let activeIndex = self.settings.tokenAccountsData(for: provider)?.clampedActiveIndex() ?? 0 + let defaultLabel = ProviderCatalog.implementation(for: provider)?.tokenAccountDefaultLabel(settings: self.settings) + // Show switcher when there's a default account + at least 1 token account, or 2+ token accounts + let hasMultiple = accounts.count >= 1 && defaultLabel != nil || accounts.count > 1 + guard hasMultiple else { return nil } + // Use raw activeIndex so -1 (default account selected) passes through + let rawActiveIndex = self.settings.tokenAccountsData(for: provider)?.activeIndex ?? -1 + let activeIndex = rawActiveIndex < 0 ? -1 : min(rawActiveIndex, max(0, accounts.count - 1)) let showAll = self.settings.showAllTokenAccountsInMenu let snapshots = showAll ? (self.store.accountSnapshots[provider] ?? []) : [] return TokenAccountMenuDisplay( @@ -701,7 +740,8 @@ extension StatusItemController { snapshots: snapshots, activeIndex: activeIndex, showAll: showAll, - showSwitcher: !showAll) + showSwitcher: !showAll, + defaultAccountLabel: defaultLabel) } private func menuNeedsRefresh(_ menu: NSMenu) -> Bool { @@ -888,7 +928,7 @@ extension StatusItemController { webItems: OpenAIWebMenuItems) { let hasUsageBlock = !model.metrics.isEmpty || model.placeholder != nil - let hasCredits = model.creditsText != nil + let hasCredits = model.creditsText != nil && provider != .codex let hasExtraUsage = model.providerCost != nil let hasCost = model.tokenUsage != nil let bottomPadding = CGFloat(hasCredits ? 4 : 6) @@ -939,9 +979,6 @@ extension StatusItemController { id: "menuCardCredits", width: width, submenu: creditsSubmenu)) - if provider == .codex { - menu.addItem(self.makeBuyCreditsItem()) - } } if hasExtraUsage { if hasCredits { @@ -973,6 +1010,13 @@ extension StatusItemController { width: width, submenu: costSubmenu)) } + + if provider == .codex, self.settings.codexBuyCreditsMenuEnabled { + if hasUsageBlock || hasCredits || hasExtraUsage || hasCost { + menu.addItem(.separator()) + } + menu.addItem(self.makeBuyCreditsItem()) + } } private func switcherIcon(for provider: UsageProvider) -> NSImage { @@ -1045,6 +1089,7 @@ extension StatusItemController { case .dashboard: (#selector(self.openDashboard), nil) case .statusPage: (#selector(self.openStatusPage), nil) case let .switchAccount(provider): (#selector(self.runSwitchAccount(_:)), provider.rawValue) + case let .addTokenAccount(provider): (#selector(self.runAddTokenAccount(_:)), provider.rawValue) case let .openTerminal(command): (#selector(self.openTerminalCommand(_:)), command) case let .loginToProvider(url): (#selector(self.openLoginToProvider(_:)), url) case .settings: (#selector(self.showSettingsGeneral), nil) @@ -1462,6 +1507,7 @@ extension StatusItemController { resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + codexMenuCreditsPrimaryAccountNotice: self.settings.codexMenuCreditsPrimaryAccountOnlyMessage(), sourceLabel: sourceLabel, kiloAutoMode: kiloAutoMode, hidePersonalInfo: self.settings.hidePersonalInfo, diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 92c334231..e728fcea2 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -793,25 +793,54 @@ final class ProviderSwitcherView: NSView { } final class TokenAccountSwitcherView: NSView { + /// Height for the switcher given account count (matches internal layout). + static func preferredHeight(accounts: [ProviderTokenAccount], defaultAccountLabel: String?) -> CGFloat { + let totalCount = accounts.count + (defaultAccountLabel != nil ? 1 : 0) + guard totalCount > 0 else { return 0 } + let useTwoRows = totalCount > 3 + let rows = useTwoRows ? 2 : 1 + let rowSpacing: CGFloat = 4 + let rowHeight: CGFloat = 26 + return rowHeight * CGFloat(rows) + (useTwoRows ? rowSpacing : 0) + } + private let accounts: [ProviderTokenAccount] + private let defaultAccountLabel: String? private let onSelect: (Int) -> Void private var selectedIndex: Int + /// Maps button tag → logical index (-1 for default, 0+ for token accounts) + private var buttonTagToIndex: [Int: Int] = [:] private var buttons: [NSButton] = [] private let rowSpacing: CGFloat = 4 private let rowHeight: CGFloat = 26 + /// Horizontal inset of the button stack inside this view (menu uses a little breathing room; preferences flush). + private let contentMargin: CGFloat private let selectedBackground = NSColor.controlAccentColor.cgColor private let unselectedBackground = NSColor.clear.cgColor private let selectedTextColor = NSColor.white private let unselectedTextColor = NSColor.secondaryLabelColor - init(accounts: [ProviderTokenAccount], selectedIndex: Int, width: CGFloat, onSelect: @escaping (Int) -> Void) { + init( + accounts: [ProviderTokenAccount], + defaultAccountLabel: String? = nil, + selectedIndex: Int, + width: CGFloat, + contentMargin: CGFloat = 6, + onSelect: @escaping (Int) -> Void) + { self.accounts = accounts + self.defaultAccountLabel = defaultAccountLabel + self.contentMargin = contentMargin self.onSelect = onSelect - self.selectedIndex = min(max(selectedIndex, 0), max(0, accounts.count - 1)) - let useTwoRows = accounts.count > 3 - let rows = useTwoRows ? 2 : 1 - let height = self.rowHeight * CGFloat(rows) + (useTwoRows ? self.rowSpacing : 0) + // selectedIndex == -1 means default account is active + let totalCount = accounts.count + (defaultAccountLabel != nil ? 1 : 0) + let clampedIndex = defaultAccountLabel != nil && selectedIndex < 0 + ? -1 + : min(max(selectedIndex, 0), max(0, accounts.count - 1)) + self.selectedIndex = clampedIndex + let height = Self.preferredHeight(accounts: accounts, defaultAccountLabel: defaultAccountLabel) super.init(frame: NSRect(x: 0, y: 0, width: width, height: height)) + let useTwoRows = totalCount > 3 self.wantsLayer = true self.buildButtons(useTwoRows: useTwoRows) self.updateButtonStyles() @@ -823,22 +852,30 @@ final class TokenAccountSwitcherView: NSView { } private func buildButtons(useTwoRows: Bool) { - let perRow = useTwoRows ? Int(ceil(Double(self.accounts.count) / 2.0)) : self.accounts.count - let rows: [[ProviderTokenAccount]] = { - if !useTwoRows { return [self.accounts] } - let first = Array(self.accounts.prefix(perRow)) - let second = Array(self.accounts.dropFirst(perRow)) - return [first, second] - }() + // Build the flat ordered list: default (index -1) first, then token accounts (0, 1, 2…) + struct Entry { let title: String; let logicalIndex: Int } + var entries: [Entry] = [] + if let label = self.defaultAccountLabel { + entries.append(Entry(title: label, logicalIndex: -1)) + } + for (i, account) in self.accounts.enumerated() { + entries.append(Entry(title: account.displayName, logicalIndex: i)) + } + + let perRow = useTwoRows ? Int(ceil(Double(entries.count) / 2.0)) : entries.count + let chunks: [[Entry]] = useTwoRows + ? [Array(entries.prefix(perRow)), Array(entries.dropFirst(perRow))] + : [entries] let stack = NSStackView() stack.orientation = .vertical - stack.alignment = .centerX + // Leading keeps account tabs aligned with section titles; centerX left empty space on the left. + stack.alignment = .leading stack.spacing = self.rowSpacing stack.translatesAutoresizingMaskIntoConstraints = false - var globalIndex = 0 - for rowAccounts in rows { + var buttonTag = 0 + for rowEntries in chunks { let row = NSStackView() row.orientation = .horizontal row.alignment = .centerY @@ -846,13 +883,13 @@ final class TokenAccountSwitcherView: NSView { row.spacing = self.rowSpacing row.translatesAutoresizingMaskIntoConstraints = false - for account in rowAccounts { + for entry in rowEntries { let button = PaddedToggleButton( - title: account.displayName, + title: entry.title, target: self, action: #selector(self.handleSelect)) - button.tag = globalIndex - button.toolTip = account.displayName + button.tag = buttonTag + button.toolTip = entry.title button.isBordered = false button.setButtonType(.toggle) button.controlSize = .small @@ -861,26 +898,30 @@ final class TokenAccountSwitcherView: NSView { button.layer?.cornerRadius = 6 row.addArrangedSubview(button) self.buttons.append(button) - globalIndex += 1 + self.buttonTagToIndex[buttonTag] = entry.logicalIndex + buttonTag += 1 } stack.addArrangedSubview(row) + row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } self.addSubview(stack) NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 6), - stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -6), + stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: self.contentMargin), + stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -self.contentMargin), stack.topAnchor.constraint(equalTo: self.topAnchor), stack.bottomAnchor.constraint(equalTo: self.bottomAnchor), - stack.heightAnchor.constraint(equalToConstant: self.rowHeight * CGFloat(rows.count) + + stack.heightAnchor.constraint(equalToConstant: self.rowHeight * CGFloat(chunks.count) + (useTwoRows ? self.rowSpacing : 0)), ]) } private func updateButtonStyles() { - for (index, button) in self.buttons.enumerated() { - let selected = index == self.selectedIndex + for button in self.buttons { + let tag = button.tag + let logicalIndex = self.buttonTagToIndex[tag] ?? tag + let selected = logicalIndex == self.selectedIndex button.state = selected ? .on : .off button.layer?.backgroundColor = selected ? self.selectedBackground : self.unselectedBackground button.contentTintColor = selected ? self.selectedTextColor : self.unselectedTextColor @@ -888,10 +929,12 @@ final class TokenAccountSwitcherView: NSView { } @objc private func handleSelect(_ sender: NSButton) { - let index = sender.tag - guard index >= 0, index < self.accounts.count else { return } - self.selectedIndex = index + let tag = sender.tag + guard let logicalIndex = self.buttonTagToIndex[tag] else { return } + // Allow -1 (default account) as a valid selection + guard logicalIndex >= -1, logicalIndex < self.accounts.count else { return } + self.selectedIndex = logicalIndex self.updateButtonStyles() - self.onSelect(index) + self.onSelect(logicalIndex) } } diff --git a/Sources/CodexBar/TokenAccountSwitcherRepresentable.swift b/Sources/CodexBar/TokenAccountSwitcherRepresentable.swift new file mode 100644 index 000000000..f20e69e93 --- /dev/null +++ b/Sources/CodexBar/TokenAccountSwitcherRepresentable.swift @@ -0,0 +1,27 @@ +import AppKit +import CodexBarCore +import SwiftUI + +/// Hosts the same `TokenAccountSwitcherView` used in the menu bar so Codex settings can mirror that UX. +@MainActor +struct TokenAccountSwitcherRepresentable: NSViewRepresentable { + let accounts: [ProviderTokenAccount] + let defaultAccountLabel: String? + let selectedIndex: Int + let width: CGFloat + let onSelect: (Int) -> Void + + func makeNSView(context _: Context) -> TokenAccountSwitcherView { + TokenAccountSwitcherView( + accounts: self.accounts, + defaultAccountLabel: self.defaultAccountLabel, + selectedIndex: self.selectedIndex, + width: self.width, + contentMargin: 0, + onSelect: self.onSelect) + } + + func updateNSView(_: TokenAccountSwitcherView, context _: Context) { + // Selection and accounts are refreshed via `.id(...)` from the parent when settings change. + } +} diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index 28b467b86..42d2d913e 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -47,14 +47,22 @@ struct UsageProgressBar: View { let stripeInset = 1 / scale let tipOffset = paceWidth - tipWidth + (Self.paceStripeSpan(for: scale) / 2) + stripeInset let showTip = self.pacePercent != nil && tipWidth > 0.5 - let needsPunchCompositing = showTip + let barHeight = proxy.size.height let bar = ZStack(alignment: .leading) { Capsule() .fill(MenuHighlightStyle.progressTrack(self.isHighlighted)) self.actualBar(width: fillWidth) if showTip { - self.paceTip(width: tipWidth) - .offset(x: tipOffset) + if self.isHighlighted { + self.paceTip(width: tipWidth) + .frame(height: barHeight) + .offset(x: tipOffset) + } else { + // In preferences and other non-menu surfaces, the punched Canvas + compositingGroup + // was carving holes through the fill and exposing the track (white “gaps”). + self.simplePaceTip(width: tipWidth, height: barHeight) + .offset(x: tipOffset) + } } } .clipped() @@ -62,9 +70,6 @@ struct UsageProgressBar: View { bar .compositingGroup() .drawingGroup() - } else if needsPunchCompositing { - bar - .compositingGroup() } else { bar } @@ -82,6 +87,20 @@ struct UsageProgressBar: View { .allowsHitTesting(false) } + /// Solid pace marker for non-menu contexts (avoids `destinationOut` punch-through on the fill). + private func simplePaceTip(width: CGFloat, height: CGFloat) -> some View { + let isDeficit = self.paceOnTop == false + let fill: Color = if isDeficit { + Color.red.opacity(0.92) + } else { + Color.green + } + return Capsule() + .fill(fill) + .frame(width: width, height: height) + .allowsHitTesting(false) + } + private func paceTip(width: CGFloat) -> some View { let isDeficit = self.paceOnTop == false let useDeficitRed = isDeficit && self.isHighlighted == false diff --git a/Sources/CodexBar/UsageStore+AccountCosts.swift b/Sources/CodexBar/UsageStore+AccountCosts.swift new file mode 100644 index 000000000..4857e0ed5 --- /dev/null +++ b/Sources/CodexBar/UsageStore+AccountCosts.swift @@ -0,0 +1,175 @@ +import CodexBarCore +import Foundation + +/// A single account's usage snapshot, used in the Costs summary card. +struct AccountCostEntry: Identifiable, Sendable { + let id: String // "default" or account UUID string + let label: String + let isDefault: Bool + /// Prepaid credits balance (nil when on a subscription plan or not available). + let creditsRemaining: Double? + let isUnlimited: Bool + /// Plan name, e.g. "Pro", "Team", "Free". + let planType: String? + /// Primary (session) rate-window usage percent (0-100). + let primaryUsedPercent: Double? + /// Secondary (weekly) rate-window usage percent (0-100). Preferred for display. + let secondaryUsedPercent: Double? + /// Compact countdown reset time for the session window, e.g. "in 3h 31m". + let primaryResetDescription: String? + /// Compact countdown reset time for the weekly window, e.g. "in 1d 2h". + let secondaryResetDescription: String? + let error: String? + let updatedAt: Date +} + +extension UsageStore { + /// Fetches the credits balance for every Codex account (default + all token accounts) + /// concurrently via the OAuth API and stores results in `allAccountCredits[provider]`. + func refreshAllAccountCredits(for provider: UsageProvider) async { + guard provider == .codex else { return } + guard !self.accountCostRefreshInFlight.contains(provider) else { return } + self.accountCostRefreshInFlight.insert(provider) + defer { self.accountCostRefreshInFlight.remove(provider) } + + let tokenAccounts = self.settings.tokenAccounts(for: provider) + let defaultLabel = ProviderCatalog.implementation(for: provider)? + .tokenAccountDefaultLabel(settings: self.settings) ?? "Default" + + // Fetch all accounts in parallel. + var entries: [AccountCostEntry] = await withTaskGroup( + of: (index: Int, entry: AccountCostEntry).self, + returning: [AccountCostEntry].self) + { group in + // Default account (index 0) + group.addTask { + let entry = await Self.fetchCredits( + env: [:], + id: "default", + label: defaultLabel, + isDefault: true) + return (0, entry) + } + // Token accounts (index 1…) + for (offset, account) in tokenAccounts.enumerated() { + group.addTask { + let env = ["CODEX_HOME": account.token] + let entry = await Self.fetchCredits( + env: env, + id: account.id.uuidString, + label: account.label, + isDefault: false) + return (offset + 1, entry) + } + } + + var results: [(Int, AccountCostEntry)] = [] + for await pair in group { results.append(pair) } + return results.sorted { $0.0 < $1.0 }.map(\.1) + } + + // Only keep accounts that returned something useful (or an error worth surfacing). + // Drop the default entry entirely if there's no auth.json (not logged in at all). + entries = entries.filter { entry in + if entry.isDefault && entry.creditsRemaining == nil && entry.error?.contains("not found") == true { + return false + } + return true + } + + await MainActor.run { + self.allAccountCredits[provider] = entries + } + } + + private static func fetchCredits( + env: [String: String], + id: String, + label: String, + isDefault: Bool) async -> AccountCostEntry + { + do { + var credentials = try CodexOAuthCredentialsStore.load(env: env) + if credentials.needsRefresh, !credentials.refreshToken.isEmpty { + if let refreshed = try? await CodexTokenRefresher.refresh(credentials) { + try? CodexOAuthCredentialsStore.save(refreshed, env: env) + credentials = refreshed + } + } + let response = try await CodexOAuthUsageFetcher.fetchUsage( + accessToken: credentials.accessToken, + accountId: credentials.accountId) + + // Credits balance: only meaningful when > 0 (subscription plans return 0). + let rawBalance = response.credits?.balance + let balance: Double? = (rawBalance ?? 0) > 0 ? rawBalance : nil + let unlimited = response.credits?.unlimited ?? false + + // Plan type display name. + let planType = response.planType.map { Self.planDisplayName($0) } + + // Rate-window usage — prefer weekly (secondary) for display, keep primary as fallback. + let primaryWindow = response.rateLimit?.primaryWindow + let secondaryWindow = response.rateLimit?.secondaryWindow + let primaryUsedPercent = primaryWindow.map { Double($0.usedPercent) } + let secondaryUsedPercent = secondaryWindow.map { Double($0.usedPercent) } + let primaryResetDesc: String? = primaryWindow.map { + let date = Date(timeIntervalSince1970: TimeInterval($0.resetAt)) + let s = UsageFormatter.resetCountdownDescription(from: date) + return s.hasPrefix("in ") ? String(s.dropFirst(3)) : s + } + let secondaryResetDesc: String? = secondaryWindow.map { + let date = Date(timeIntervalSince1970: TimeInterval($0.resetAt)) + let s = UsageFormatter.resetCountdownDescription(from: date) + return s.hasPrefix("in ") ? String(s.dropFirst(3)) : s + } + + return AccountCostEntry( + id: id, + label: label, + isDefault: isDefault, + creditsRemaining: balance, + isUnlimited: unlimited, + planType: planType, + primaryUsedPercent: primaryUsedPercent, + secondaryUsedPercent: secondaryUsedPercent, + primaryResetDescription: primaryResetDesc, + secondaryResetDescription: secondaryResetDesc, + error: nil, + updatedAt: Date()) + } catch { + return AccountCostEntry( + id: id, + label: label, + isDefault: isDefault, + creditsRemaining: nil, + isUnlimited: false, + planType: nil, + primaryUsedPercent: nil, + secondaryUsedPercent: nil, + primaryResetDescription: nil, + secondaryResetDescription: nil, + error: error.localizedDescription, + updatedAt: Date()) + } + } + + private static func planDisplayName(_ plan: CodexUsageResponse.PlanType) -> String { + switch plan { + case .guest: return "Guest" + case .free: return "Free" + case .go: return "Go" + case .plus: return "Plus" + case .pro: return "Pro" + case .freeWorkspace: return "Free Workspace" + case .team: return "Team" + case .business: return "Business" + case .education: return "Education" + case .quorum: return "Quorum" + case .k12: return "K-12" + case .enterprise: return "Enterprise" + case .edu: return "Edu" + case let .unknown(raw): return raw.capitalized + } + } +} diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 1e60dfb37..7e3edab33 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -26,6 +26,7 @@ extension UsageStore { self.lastKnownSessionRemaining.removeValue(forKey: provider) self.lastKnownSessionWindowSource.removeValue(forKey: provider) self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenCostSelectionIdentity.removeValue(forKey: provider) } return } @@ -36,6 +37,7 @@ extension UsageStore { let tokenAccounts = self.tokenAccounts(for: provider) if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts) + await self.refreshTokenUsageIfConfigured(provider) return } else { _ = await MainActor.run { @@ -69,6 +71,7 @@ extension UsageStore { self.failureGates[.claude]?.reset() self.tokenFailureGates[.claude]?.reset() self.lastTokenFetchAt.removeValue(forKey: .claude) + self.lastTokenCostSelectionIdentity.removeValue(forKey: .claude) } } await MainActor.run { @@ -92,6 +95,7 @@ extension UsageStore { } if provider == .codex { self.recordCodexHistoricalSampleIfNeeded(snapshot: scoped) + Task { await self.refreshAllAccountCredits(for: .codex) } } case let .failure(error): await MainActor.run { @@ -112,5 +116,14 @@ extension UsageStore { runtime.providerDidFail(context: context, provider: provider, error: error) } } + + await self.refreshTokenUsageIfConfigured(provider) + } + + /// Local token/cost scan from session logs — must run after account switches, not only on full `refresh()`. + private func refreshTokenUsageIfConfigured(_ provider: UsageProvider) async { + guard provider == .codex || provider == .claude || provider == .vertexai else { return } + guard self.settings.costUsageEnabled else { return } + await self.refreshTokenUsage(provider, force: false) } } diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index f8cfd2f87..54d3792af 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -30,8 +30,8 @@ extension UsageStore { func refreshTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) async { let selectedAccount = self.settings.selectedTokenAccount(for: provider) + let defaultIsActive = self.settings.tokenAccountsData(for: provider)?.isDefaultActive ?? true let limitedAccounts = self.limitedTokenAccounts(accounts, selected: selectedAccount) - let effectiveSelected = selectedAccount ?? limitedAccounts.first var snapshots: [TokenAccountUsageSnapshot] = [] var selectedOutcome: ProviderFetchOutcome? var selectedSnapshot: UsageSnapshot? @@ -41,12 +41,22 @@ extension UsageStore { let outcome = await self.fetchOutcome(provider: provider, override: override) let resolved = self.resolveAccountOutcome(outcome, provider: provider, account: account) snapshots.append(resolved.snapshot) - if account.id == effectiveSelected?.id { + if !defaultIsActive, account.id == selectedAccount?.id { selectedOutcome = outcome selectedSnapshot = resolved.usage } } + // When the default account is active, fetch it with no token override + // so it uses the standard ~/.codex credentials. + if defaultIsActive { + let outcome = await self.fetchOutcome(provider: provider, override: nil) + selectedOutcome = outcome + if case let .success(result) = outcome.result { + selectedSnapshot = result.usage.scoped(to: provider) + } + } + await MainActor.run { self.accountSnapshots[provider] = snapshots } @@ -55,9 +65,12 @@ extension UsageStore { await self.applySelectedOutcome( selectedOutcome, provider: provider, - account: effectiveSelected, + account: defaultIsActive ? nil : selectedAccount, fallbackSnapshot: selectedSnapshot) } + if provider == .codex { + Task { await self.refreshAllAccountCredits(for: .codex) } + } } func limitedTokenAccounts( diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index f00f14504..d709d8e93 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -2,6 +2,34 @@ import CodexBarCore import Foundation extension UsageStore { + /// Codex `…/sessions` directory for the credentials dir selected in Settings (path-based token accounts), or `nil` to use the process environment / `~/.codex`. + func codexCostUsageSessionsRootForActiveSelection() -> URL? { + guard let support = TokenAccountSupportCatalog.support(for: .codex), + case .codexHome = support.injection + else { return nil } + let data = self.settings.tokenAccountsData(for: .codex) + let defaultActive = data?.isDefaultActive ?? true + if defaultActive { return nil } + guard let account = self.settings.selectedTokenAccount(for: .codex) else { return nil } + let token = account.token.trimmingCharacters(in: .whitespacesAndNewlines) + let apiPrefix = "apikey:" + if token.lowercased().hasPrefix(apiPrefix) { return nil } + let expanded = (token as NSString).expandingTildeInPath + guard !expanded.isEmpty else { return nil } + return URL(fileURLWithPath: expanded).appendingPathComponent("sessions", isDirectory: true) + } + + /// Stable key for the local logs backing token-cost; when it changes, cached cost data must refresh. + func tokenCostSelectionIdentity(for provider: UsageProvider) -> String { + if provider == .codex { + if let root = self.codexCostUsageSessionsRootForActiveSelection() { + return root.path + } + return "codex:default" + } + return "\(provider.rawValue):default" + } + func tokenSnapshot(for provider: UsageProvider) -> CostUsageTokenSnapshot? { self.tokenSnapshots[provider] } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 35fdd234d..cfe0b3e31 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -101,6 +101,9 @@ final class UsageStore { var lastFetchAttempts: [UsageProvider: [ProviderFetchAttempt]] = [:] var accountSnapshots: [UsageProvider: [TokenAccountUsageSnapshot]] = [:] var tokenSnapshots: [UsageProvider: CostUsageTokenSnapshot] = [:] + /// Per-account credits balance fetched via OAuth, keyed by provider. + var allAccountCredits: [UsageProvider: [AccountCostEntry]] = [:] + @ObservationIgnored var accountCostRefreshInFlight: Set = [] var tokenErrors: [UsageProvider: String] = [:] var tokenRefreshInFlight: Set = [] var credits: CreditsSnapshot? @@ -154,6 +157,7 @@ final class UsageStore { @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] + @ObservationIgnored var lastTokenCostSelectionIdentity: [UsageProvider: String] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 @@ -1547,17 +1551,19 @@ extension UsageStore { self.tokenSnapshots.removeAll() self.tokenErrors.removeAll() self.lastTokenFetchAt.removeAll() + self.lastTokenCostSelectionIdentity.removeAll() self.tokenFailureGates[.codex]?.reset() self.tokenFailureGates[.claude]?.reset() return nil } - private func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { + func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { guard provider == .codex || provider == .claude || provider == .vertexai else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenCostSelectionIdentity.removeValue(forKey: provider) return } @@ -1566,6 +1572,7 @@ extension UsageStore { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenCostSelectionIdentity.removeValue(forKey: provider) return } @@ -1574,18 +1581,24 @@ extension UsageStore { self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenCostSelectionIdentity.removeValue(forKey: provider) return } guard !self.tokenRefreshInFlight.contains(provider) else { return } + let selectionIdentity = self.tokenCostSelectionIdentity(for: provider) + let selectionChanged = self.lastTokenCostSelectionIdentity[provider] != selectionIdentity + let now = Date() if !force, + !selectionChanged, let last = self.lastTokenFetchAt[provider], now.timeIntervalSince(last) < self.tokenFetchTTL { return } + self.lastTokenCostSelectionIdentity[provider] = selectionIdentity self.lastTokenFetchAt[provider] = now self.tokenRefreshInFlight.insert(provider) defer { self.tokenRefreshInFlight.remove(provider) } @@ -1598,13 +1611,16 @@ extension UsageStore { do { let fetcher = self.costUsageFetcher let timeoutSeconds = self.tokenFetchTimeout + let codexRoot = provider == .codex ? self.codexCostUsageSessionsRootForActiveSelection() : nil 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), + codexSessionsRoot: codexRoot, + claudeProjectsRoots: nil) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index 04c85409d..2f8dcbf05 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -83,6 +83,10 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { public var region: String? public var workspaceID: String? public var tokenAccounts: ProviderTokenAccountData? + /// Custom display name for the default (non-token-account) account of this provider. + public var defaultAccountLabel: String? + /// When `false`, hides the menu-bar "Buy Credits…" action for Codex. `nil` means enabled (default). + public var buyCreditsMenuEnabled: Bool? public init( id: UsageProvider, @@ -94,7 +98,9 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { cookieSource: ProviderCookieSource? = nil, region: String? = nil, workspaceID: String? = nil, - tokenAccounts: ProviderTokenAccountData? = nil) + tokenAccounts: ProviderTokenAccountData? = nil, + defaultAccountLabel: String? = nil, + buyCreditsMenuEnabled: Bool? = nil) { self.id = id self.enabled = enabled @@ -106,6 +112,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.region = region self.workspaceID = workspaceID self.tokenAccounts = tokenAccounts + self.defaultAccountLabel = defaultAccountLabel + self.buyCreditsMenuEnabled = buyCreditsMenuEnabled } public var sanitizedAPIKey: String? { diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 2243d5218..13f4c5058 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -24,7 +24,9 @@ public struct CostUsageFetcher: Sendable { provider: UsageProvider, now: Date = Date(), forceRefresh: Bool = false, - allowVertexClaudeFallback: Bool = false) async throws -> CostUsageTokenSnapshot + allowVertexClaudeFallback: Bool = false, + codexSessionsRoot: URL? = nil, + claudeProjectsRoots: [URL]? = nil) async throws -> CostUsageTokenSnapshot { guard provider == .codex || provider == .claude || provider == .vertexai else { throw CostUsageError.unsupportedProvider(provider) @@ -35,6 +37,13 @@ public struct CostUsageFetcher: Sendable { let since = Calendar.current.date(byAdding: .day, value: -29, to: now) ?? now var options = CostUsageScanner.Options() + options.codexSessionsRoot = codexSessionsRoot + options.claudeProjectsRoots = claudeProjectsRoots + // Isolate on-disk cost cache per Codex credentials tree. Otherwise switching accounts within the + // refresh TTL reuses the previous account's aggregated cache and shows identical totals. + if provider == .codex { + options.cacheRoot = Self.codexCostCacheParentURL(forSessionsRoot: codexSessionsRoot) + } if provider == .vertexai { options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly } else if provider == .claude { @@ -69,6 +78,29 @@ public struct CostUsageFetcher: Sendable { return Self.tokenSnapshot(from: daily, now: now) } + /// Parent directory for `cost-usage/` (see `CostUsageCacheIO`). `nil` = default `~/Library/Caches/CodexBar/`. + private static func codexCostCacheParentURL(forSessionsRoot sessionsRoot: URL?) -> URL? { + guard let sessionsRoot else { return nil } + let tag = Self.stablePathFingerprint(sessionsRoot.path) + return Self.defaultCodexBarCachesParent() + .appendingPathComponent("codex-sessions-\(tag)", isDirectory: true) + } + + private static func defaultCodexBarCachesParent() -> URL { + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) + } + + /// Stable short id for a filesystem path (FNV-1a 64-bit). + private static func stablePathFingerprint(_ path: String) -> String { + var hash: UInt64 = 14_695_981_039_346_656_003 + for byte in path.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(format: "%016llx", hash) + } + static func tokenSnapshot(from daily: CostUsageDailyReport, now: Date) -> CostUsageTokenSnapshot { // Pick the most recent day; break ties by cost/tokens to keep a stable "session" row. let currentDay = daily.data.max { lhs, rhs in diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index f7f5c3191..4cf98ebb7 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -46,19 +46,18 @@ public enum CodexOAuthCredentialsError: LocalizedError, Sendable { } public enum CodexOAuthCredentialsStore { - private static var authFilePath: URL { + private static func authFilePath(env: [String: String]) -> URL { let home = FileManager.default.homeDirectoryForCurrentUser - if let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"]?.trimmingCharacters( - in: .whitespacesAndNewlines), - !codexHome.isEmpty + if let codexHome = env["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !codexHome.isEmpty { return URL(fileURLWithPath: codexHome).appendingPathComponent("auth.json") } return home.appendingPathComponent(".codex").appendingPathComponent("auth.json") } - public static func load() throws -> CodexOAuthCredentials { - let url = self.authFilePath + public static func load(env: [String: String] = ProcessInfo.processInfo.environment) throws -> CodexOAuthCredentials { + let url = self.authFilePath(env: env) guard FileManager.default.fileExists(atPath: url.path) else { throw CodexOAuthCredentialsError.notFound } @@ -105,8 +104,11 @@ public enum CodexOAuthCredentialsStore { lastRefresh: lastRefresh) } - public static func save(_ credentials: CodexOAuthCredentials) throws { - let url = self.authFilePath + public static func save( + _ credentials: CodexOAuthCredentials, + env: [String: String] = ProcessInfo.processInfo.environment) throws + { + let url = self.authFilePath(env: env) var json: [String: Any] = [:] if let data = try? Data(contentsOf: url), diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 12b68cd22..2fb67fc99 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -134,16 +134,16 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy { let id: String = "codex.oauth" let kind: ProviderFetchKind = .oauth - func isAvailable(_: ProviderFetchContext) async -> Bool { - (try? CodexOAuthCredentialsStore.load()) != nil + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + (try? CodexOAuthCredentialsStore.load(env: context.env)) != nil } - func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { - var credentials = try CodexOAuthCredentialsStore.load() + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + var credentials = try CodexOAuthCredentialsStore.load(env: context.env) if credentials.needsRefresh, !credentials.refreshToken.isEmpty { credentials = try await CodexTokenRefresher.refresh(credentials) - try CodexOAuthCredentialsStore.save(credentials) + try CodexOAuthCredentialsStore.save(credentials, env: context.env) } let usage = try await CodexOAuthUsageFetcher.fetchUsage( diff --git a/Sources/CodexBarCore/TokenAccountSupport.swift b/Sources/CodexBarCore/TokenAccountSupport.swift index cadd22be0..04b5d0339 100644 --- a/Sources/CodexBarCore/TokenAccountSupport.swift +++ b/Sources/CodexBarCore/TokenAccountSupport.swift @@ -3,6 +3,8 @@ import Foundation public enum TokenAccountInjection: Sendable { case cookieHeader case environment(key: String) + /// Inject the token as the `CODEX_HOME` environment variable (path to a Codex credentials directory). + case codexHome } public struct TokenAccountSupport: Sendable { @@ -49,6 +51,17 @@ public enum TokenAccountSupportCatalog { return [ClaudeOAuthCredentialsStore.environmentTokenKey: accessToken] } return nil + case .codexHome: + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let apiPrefix = "apikey:" + if trimmed.lowercased().hasPrefix(apiPrefix) { + let key = trimmed.dropFirst(apiPrefix.count).trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return nil } + return ["OPENAI_API_KEY": key] + } + let expanded = (trimmed as NSString).expandingTildeInPath + return ["CODEX_HOME": expanded] } } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 2a1d0f1d4..6b12e96df 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -2,6 +2,13 @@ import Foundation extension TokenAccountSupportCatalog { static let supportByProvider: [UsageProvider: TokenAccountSupport] = [ + .codex: TokenAccountSupport( + title: "Codex accounts", + subtitle: "OAuth adds a credentials directory (CODEX_HOME). You can also add an API key account from Settings. Manual paths use ~/.codex-account2 style directories.", + placeholder: "~/.codex-account2", + injection: .codexHome, + requiresManualCookieSource: false, + cookieName: nil), .claude: TokenAccountSupport( title: "Session tokens", subtitle: "Store Claude sessionKey cookies or OAuth access tokens.", diff --git a/Sources/CodexBarCore/TokenAccounts.swift b/Sources/CodexBarCore/TokenAccounts.swift index 519386aec..ec383da78 100644 --- a/Sources/CodexBarCore/TokenAccounts.swift +++ b/Sources/CodexBarCore/TokenAccounts.swift @@ -2,7 +2,7 @@ import Foundation public struct ProviderTokenAccount: Codable, Identifiable, Sendable { public let id: UUID - public let label: String + public var label: String public let token: String public let addedAt: TimeInterval public let lastUsed: TimeInterval? @@ -31,6 +31,12 @@ public struct ProviderTokenAccountData: Codable, Sendable { self.activeIndex = activeIndex } + /// True when the user has explicitly selected the default (non-token-account) account. + /// Stored as activeIndex < 0. + public var isDefaultActive: Bool { + self.activeIndex < 0 || self.accounts.isEmpty + } + public func clampedActiveIndex() -> Int { guard !self.accounts.isEmpty else { return 0 } return min(max(self.activeIndex, 0), self.accounts.count - 1) diff --git a/Tests/CodexBarTests/MenuDescriptorAccountActionLabelTests.swift b/Tests/CodexBarTests/MenuDescriptorAccountActionLabelTests.swift new file mode 100644 index 000000000..afd8ee65d --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorAccountActionLabelTests.swift @@ -0,0 +1,102 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorAccountActionLabelTests { + @Test + func `claude shows Switch Account when usage exists without email`() throws { + let suite = "MenuDescriptorAccountActionLabelTests-claude-usage-no-email" + 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 registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 5, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: identity) + store._setSnapshotForTesting(snapshot, provider: .claude) + + let descriptor = MenuDescriptor.build( + provider: .claude, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: true) + + let actionTitles = descriptor.sections.flatMap(\.entries).compactMap { entry -> String? in + if case let .action(title, _) = entry { return title } + return nil + } + #expect(actionTitles.contains("Switch Account...")) + #expect(!actionTitles.contains("Add Account...")) + } + + @Test + func `claude shows Add Account when no usage snapshot`() throws { + let suite = "MenuDescriptorAccountActionLabelTests-claude-empty" + 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 registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + store._setSnapshotForTesting(nil, provider: .claude) + + let descriptor = MenuDescriptor.build( + provider: .claude, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: true) + + let actionTitles = descriptor.sections.flatMap(\.entries).compactMap { entry -> String? in + if case let .action(title, _) = entry { return title } + return nil + } + #expect(actionTitles.contains("Add Account...")) + #expect(!actionTitles.contains("Switch Account...")) + } +} diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 924da2a05..9e611089c 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -119,8 +119,10 @@ struct ProviderSettingsDescriptorTests { let pickers = CodexProviderImplementation().settingsPickers(context: context) let toggles = CodexProviderImplementation().settingsToggles(context: context) + #expect(pickers.contains(where: { $0.id == "codex-menu-bar-account" })) #expect(pickers.contains(where: { $0.id == "codex-usage-source" })) #expect(pickers.contains(where: { $0.id == "codex-cookie-source" })) + #expect(pickers.first(where: { $0.id == "codex-menu-bar-account" })?.section == .options) #expect(toggles.contains(where: { $0.id == "codex-historical-tracking" })) } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 45956f0b6..fe8d223a5 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -619,12 +619,12 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } - let creditsItem = menu.items.first { ($0.representedObject as? String) == "menuCardCredits" } + let creditsHistoryItem = menu.items.first { $0.title == "Credits history" } #expect( usageItem?.submenu?.items .contains { ($0.representedObject as? String) == "usageBreakdownChart" } == true) #expect( - creditsItem?.submenu?.items + creditsHistoryItem?.submenu?.items .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true) } @@ -688,11 +688,12 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) let ids = menu.items.compactMap { $0.representedObject as? String } - let creditsIndex = ids.firstIndex(of: "menuCardCredits") + #expect(!ids.contains("menuCardCredits")) let costIndex = ids.firstIndex(of: "menuCardCost") - #expect(creditsIndex != nil) #expect(costIndex != nil) - #expect(try #require(creditsIndex) < costIndex!) + let buyIndex = menu.items.firstIndex { $0.title == "Buy Credits..." } + #expect(buyIndex != nil) + #expect(try #require(buyIndex) > costIndex!) } @Test From d4c3992163f66aaeb19d502fe31d167d873c29e0 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Fri, 20 Mar 2026 14:02:04 -0400 Subject: [PATCH 02/25] fix: skip status item rebuild when only config revision changes Token-account and other provider config updates bump configRevision; rebuilding every NSStatusItem via removeStatusItem reflows the system menu bar and makes other apps' extras flicker. Rebuild only when provider order changes. Made-with: Cursor --- Sources/CodexBar/StatusItemController.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index a83420ee9..c44558a66 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -318,11 +318,15 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } private func handleSettingsChange(reason: String) { - let configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher() self.invalidateMenus() - if orderChanged || configChanged { + // Rebuilding status items calls `removeStatusItem` for every provider slot, which reflows the + // system menu bar and can make *other* apps' menu extras disappear briefly. Token-account + // switches (and most provider config edits) bump `configRevision` without changing order; + // `updateVisibility` + `lazyStatusItem` already keep items correct—only reorder needs a rebuild + // so multi-icon layout matches `orderedProviders()`. + if orderChanged { self.rebuildProviderStatusItems() } self.updateVisibility() From 069128bfbd14e0a2ef5bec98c009fcbf820085dd Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Fri, 20 Mar 2026 16:36:10 -0400 Subject: [PATCH 03/25] feat: Codex credits follow active account (OAuth per tab) Resolve menu/icon/widget credits from codexActiveMenuCredits using allAccountCredits for add-on accounts; primary keeps RPC/dashboard store. Remove primary-only notice; show unlimited and loading states; add tests. Made-with: Cursor --- .../CodexBar/AccountCostsMenuCardView.swift | 49 ++-- Sources/CodexBar/MenuCardView.swift | 233 +++++++++++++++--- Sources/CodexBar/MenuContent.swift | 2 +- .../CodexBar/PreferencesProvidersPane.swift | 9 +- .../Codex/CodexProviderImplementation.swift | 7 +- .../SettingsStore+TokenAccounts.swift | 7 - .../StatusItemController+Animation.swift | 6 +- .../CodexBar/StatusItemController+Menu.swift | 144 +++++------ .../StatusItemController+SwitcherViews.swift | 11 + Sources/CodexBar/StatusItemController.swift | 75 +++--- Sources/CodexBar/UsageProgressBar.swift | 1 + .../UsageStore+CodexActiveCredits.swift | 56 +++++ .../CodexBar/UsageStore+WidgetSnapshot.swift | 2 +- Sources/CodexBar/UsageStore.swift | 1 + Sources/CodexBar/UsageStoreSupport.swift | 4 + Sources/CodexBarCore/UsageFormatter.swift | 9 + .../CodexActiveCreditsTests.swift | 96 ++++++++ Tests/CodexBarTests/MenuCardModelTests.swift | 61 ++++- Tests/CodexBarTests/StatusMenuTests.swift | 18 +- Tests/CodexBarTests/UsageFormatterTests.swift | 6 + 20 files changed, 604 insertions(+), 193 deletions(-) create mode 100644 Sources/CodexBar/UsageStore+CodexActiveCredits.swift create mode 100644 Tests/CodexBarTests/CodexActiveCreditsTests.swift diff --git a/Sources/CodexBar/AccountCostsMenuCardView.swift b/Sources/CodexBar/AccountCostsMenuCardView.swift index 288bc8a45..dfa3f8af5 100644 --- a/Sources/CodexBar/AccountCostsMenuCardView.swift +++ b/Sources/CodexBar/AccountCostsMenuCardView.swift @@ -31,6 +31,10 @@ struct AccountCostsMenuCardView: View { .font(.caption2) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .frame(width: Self.colWidth, alignment: .leading) + Text("Credits") + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .frame(width: Self.colWidth, alignment: .trailing) } .padding(.horizontal, 16) .padding(.top, 10) @@ -110,25 +114,10 @@ private struct AccountCostRow: View { // Right columns: Session | Weekly if let error = self.entry.error { - if self.entry.isDefault { - Text(self.shortError(error)) - .font(.caption2) - .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) - .frame(width: Self.colWidth * 2 + 8, alignment: .trailing) - } else { - Text("Credit information is available only for the primary account.") - .font(.caption2) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .multilineTextAlignment(.leading) - .frame(maxWidth: Self.colWidth * 2 + 24, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - } - } else if let balance = self.entry.creditsRemaining { - // Prepaid credits: span both columns - Text(UsageFormatter.usdString(balance) + " left") - .font(.caption2.monospacedDigit()) - .foregroundStyle(balance < 5 ? Color.orange : MenuHighlightStyle.secondary(self.isHighlighted)) - .frame(width: Self.colWidth * 2 + 8, alignment: .trailing) + Text(self.shortError(error)) + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) + .frame(width: Self.colWidth * 3 + 16, alignment: .trailing) } else { self.percentCell( usedPercent: self.entry.primaryUsedPercent, @@ -136,12 +125,34 @@ private struct AccountCostRow: View { self.percentCell( usedPercent: self.entry.secondaryUsedPercent, resetDescription: self.entry.secondaryResetDescription) + self.creditsCell() } } } private static let pctWidth: CGFloat = 30 + @ViewBuilder + private func creditsCell() -> some View { + if self.entry.isUnlimited { + Text("∞") + .font(.caption2.monospacedDigit()) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .frame(width: Self.colWidth, alignment: .trailing) + } else if let balance = self.entry.creditsRemaining, balance > 0 { + let isLow = balance < 5 + Text(UsageFormatter.creditsBalanceString(from: balance)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(isLow ? Color.orange : MenuHighlightStyle.secondary(self.isHighlighted)) + .frame(width: Self.colWidth, alignment: .trailing) + } else { + Text("—") + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted).opacity(0.5)) + .frame(width: Self.colWidth, alignment: .trailing) + } + } + @ViewBuilder private func percentCell(usedPercent: Double?, resetDescription: String?) -> some View { if let used = usedPercent { diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 9580ec9ee..13be5505a 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -99,13 +99,7 @@ struct UsageMenuCardView: View { } if self.model.metrics.isEmpty { - if !self.model.usageNotes.isEmpty { - UsageNotesContent(notes: self.model.usageNotes) - } else if let placeholder = self.model.placeholder { - Text(placeholder) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .font(.subheadline) - } + self.emptyMetricsUsageBlock } else { let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty let hasCredits = self.model.creditsText != nil @@ -126,7 +120,9 @@ struct UsageMenuCardView: View { } } } - if hasUsage, hasCredits || hasCost { + // No divider before credits: VStack spacing already matches Session↔Weekly; a divider would add + // ~12 + hairline + ~12 and read as a much looser gap than between metrics. + if hasUsage, !hasCredits, hasCost { Divider() } if let credits = self.model.creditsText { @@ -186,8 +182,75 @@ struct UsageMenuCardView: View { .frame(width: self.width, alignment: .leading) } + @ViewBuilder + private var emptyMetricsUsageBlock: some View { + let hasCreditsEmpty = self.model.creditsText != nil + let hasProviderCostEmpty = self.model.providerCost != nil + let hasCostEmpty = self.model.tokenUsage != nil || hasProviderCostEmpty + let hadUsageBlockEmpty = !self.model.usageNotes.isEmpty || self.model.placeholder != nil + + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } else if let placeholder = self.model.placeholder { + Text(placeholder) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .font(.subheadline) + } + if hadUsageBlockEmpty, hasCreditsEmpty || hasCostEmpty { + Divider() + } + if let credits = self.model.creditsText { + CreditsBarContent( + creditsText: credits, + creditsRemaining: self.model.creditsRemaining, + hintText: self.model.creditsHintText, + hintCopyText: self.model.creditsHintCopyText, + progressColor: self.model.progressColor) + } + if hasCreditsEmpty, hasCostEmpty { + Divider() + } + if let providerCost = self.model.providerCost { + ProviderCostContent( + section: providerCost, + progressColor: self.model.progressColor) + } + if hasProviderCostEmpty, self.model.tokenUsage != nil { + Divider() + } + if let tokenUsage = self.model.tokenUsage { + VStack(alignment: .leading, spacing: 6) { + Text("Cost") + .font(.body) + .fontWeight(.medium) + Text(tokenUsage.sessionLine) + .font(.footnote) + Text(tokenUsage.monthLine) + .font(.footnote) + if let hint = tokenUsage.hintLine, !hint.isEmpty { + Text(hint) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + } + if let error = tokenUsage.errorLine, !error.isEmpty { + Text(error) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + .overlay { + ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error) + } + } + } + } + } + private var hasDetails: Bool { !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil || + self.model.creditsText != nil || self.model.tokenUsage != nil || self.model.providerCost != nil } @@ -207,6 +270,9 @@ private struct UsageMenuCardHeaderView: View { Text(self.model.email) .font(.subheadline) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + .multilineTextAlignment(.trailing) } let subtitleAlignment: VerticalAlignment = self.model.subtitleStyle == .error ? .top : .firstTextBaseline HStack(alignment: subtitleAlignment) { @@ -397,24 +463,101 @@ private struct UsageNotesContent: View { } } -struct UsageMenuCardHeaderSectionView: View { +/// Header + usage + optional credits in one view, matching `UsageMenuCardView` spacing (6pt after divider, no extra +/// top padding). Used for OpenAI web sectioned menus so Codex→Session matches the single-card layout. +struct UsageMenuCardWebPrimarySectionView: View { let model: UsageMenuCardView.Model - let showDivider: Bool + let bottomPadding: CGFloat let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + private var hasUsageBlock: Bool { + !self.model.metrics.isEmpty || self.model.placeholder != nil + } + + private var hasCredits: Bool { + self.model.creditsText != nil + } + + private var showDividerBelowHeader: Bool { + !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil + || self.hasCredits + } var body: some View { VStack(alignment: .leading, spacing: 6) { UsageMenuCardHeaderView(model: self.model) - if self.showDivider { + if self.showDividerBelowHeader { Divider() } + + if self.model.metrics.isEmpty { + self.emptyMetricsBody + } else { + self.metricsAndCreditsBody + } } .padding(.horizontal, 16) .padding(.top, 2) - .padding(.bottom, self.model.subtitleStyle == .error ? 2 : 0) + .padding(.bottom, self.bottomPadding) .frame(width: self.width, alignment: .leading) } + + @ViewBuilder + private var emptyMetricsBody: some View { + let hasCreditsEmpty = self.model.creditsText != nil + let hadUsageBlockEmpty = !self.model.usageNotes.isEmpty || self.model.placeholder != nil + + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } else if let placeholder = self.model.placeholder { + Text(placeholder) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .font(.subheadline) + } + if hadUsageBlockEmpty, hasCreditsEmpty { + Divider() + } + if let credits = self.model.creditsText { + CreditsBarContent( + creditsText: credits, + creditsRemaining: self.model.creditsRemaining, + hintText: self.model.creditsHintText, + hintCopyText: self.model.creditsHintCopyText, + progressColor: self.model.progressColor) + } + } + + @ViewBuilder + private var metricsAndCreditsBody: some View { + let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty + + VStack(alignment: .leading, spacing: 12) { + if hasUsage { + VStack(alignment: .leading, spacing: 12) { + ForEach(self.model.metrics, id: \.id) { metric in + MetricRow( + metric: metric, + title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric), + progressColor: self.model.progressColor) + } + if !self.model.usageNotes.isEmpty { + UsageNotesContent(notes: self.model.usageNotes) + } + } + } + if hasUsage, let credits = self.model.creditsText { + CreditsBarContent( + creditsText: credits, + creditsRemaining: self.model.creditsRemaining, + hintText: self.model.creditsHintText, + hintCopyText: self.model.creditsHintCopyText, + progressColor: self.model.progressColor) + } + } + .padding(.bottom, self.model.creditsText == nil ? 6 : 0) + } } struct UsageMenuCardUsageSectionView: View { @@ -466,6 +609,7 @@ struct UsageMenuCardCreditsSectionView: View { var body: some View { if let credits = self.model.creditsText { VStack(alignment: .leading, spacing: 6) { + Divider() CreditsBarContent( creditsText: credits, creditsRemaining: self.model.creditsRemaining, @@ -546,6 +690,8 @@ struct UsageMenuCardCostSectionView: View { let topPadding: CGFloat let bottomPadding: CGFloat let width: CGFloat + /// When `false`, omits the top divider (e.g. Cost follows “Buy Credits…” so a second hairline isn’t stacked). + var showsLeadingDivider: Bool = true @Environment(\.menuItemHighlighted) private var isHighlighted var body: some View { @@ -553,6 +699,9 @@ struct UsageMenuCardCostSectionView: View { return Group { if hasTokenCost { VStack(alignment: .leading, spacing: 10) { + if self.showsLeadingDivider { + Divider() + } if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { Text("Cost") @@ -600,13 +749,16 @@ struct UsageMenuCardExtraUsageSectionView: View { var body: some View { Group { if let providerCost = self.model.providerCost { - ProviderCostContent( - section: providerCost, - progressColor: self.model.progressColor) - .padding(.horizontal, 16) - .padding(.top, self.topPadding) - .padding(.bottom, self.bottomPadding) - .frame(width: self.width, alignment: .leading) + VStack(alignment: .leading, spacing: 6) { + Divider() + ProviderCostContent( + section: providerCost, + progressColor: self.model.progressColor) + } + .padding(.horizontal, 16) + .padding(.top, self.topPadding) + .padding(.bottom, self.bottomPadding) + .frame(width: self.width, alignment: .leading) } } } @@ -632,8 +784,8 @@ extension UsageMenuCardView.Model { let resetTimeDisplayStyle: ResetTimeDisplayStyle let tokenCostUsageEnabled: Bool let showOptionalCreditsAndExtraUsage: Bool - /// When set (non-primary Codex account), replaces credits line + suppresses dashboard/credits error hints. - let codexMenuCreditsPrimaryAccountNotice: String? + /// When true, Codex credits line shows unlimited prepaid (OAuth). + let codexCreditsUnlimited: Bool let sourceLabel: String? let kiloAutoMode: Bool let hidePersonalInfo: Bool @@ -657,7 +809,7 @@ extension UsageMenuCardView.Model { resetTimeDisplayStyle: ResetTimeDisplayStyle, tokenCostUsageEnabled: Bool, showOptionalCreditsAndExtraUsage: Bool, - codexMenuCreditsPrimaryAccountNotice: String? = nil, + codexCreditsUnlimited: Bool = false, sourceLabel: String? = nil, kiloAutoMode: Bool = false, hidePersonalInfo: Bool, @@ -680,7 +832,7 @@ extension UsageMenuCardView.Model { self.resetTimeDisplayStyle = resetTimeDisplayStyle self.tokenCostUsageEnabled = tokenCostUsageEnabled self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage - self.codexMenuCreditsPrimaryAccountNotice = codexMenuCreditsPrimaryAccountNotice + self.codexCreditsUnlimited = codexCreditsUnlimited self.sourceLabel = sourceLabel self.kiloAutoMode = kiloAutoMode self.hidePersonalInfo = hidePersonalInfo @@ -697,17 +849,17 @@ extension UsageMenuCardView.Model { metadata: input.metadata) let metrics = Self.metrics(input: input) let usageNotes = Self.usageNotes(input: input) - let creditsText: String? = if let notice = input.codexMenuCreditsPrimaryAccountNotice, - !notice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - notice - } else if input.provider == .openrouter { - nil - } else if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { + let creditsText: String? = if input.provider == .openrouter { nil + } else if input.provider == .codex, input.codexCreditsUnlimited { + "Unlimited credits" } else { - Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) + Self.creditsSectionText(metadata: input.metadata, credits: input.credits, error: input.creditsError) } + let creditsRemaining: Double? = { + guard let remaining = input.credits?.remaining, remaining.isFinite, remaining > 0 else { return nil } + return remaining + }() let providerCost: ProviderCostSection? = if input.provider == .claude, !input.showOptionalCreditsAndExtraUsage { nil } else { @@ -735,7 +887,7 @@ extension UsageMenuCardView.Model { metrics: metrics, usageNotes: usageNotes, creditsText: creditsText, - creditsRemaining: input.codexMenuCreditsPrimaryAccountNotice != nil ? nil : input.credits?.remaining, + creditsRemaining: input.codexCreditsUnlimited ? nil : creditsRemaining, creditsHintText: redacted.creditsHintText, creditsHintCopyText: redacted.creditsHintCopyText, providerCost: providerCost, @@ -877,8 +1029,7 @@ extension UsageMenuCardView.Model { input: Input, subtitle: (text: String, style: SubtitleStyle)) -> RedactedText { - let dashboardErrorForHints = - input.codexMenuCreditsPrimaryAccountNotice != nil ? nil : input.dashboardError + let dashboardErrorForHints = input.dashboardError let email = PersonalInfoRedactor.redactEmail( Self.email( for: input.provider, @@ -1102,19 +1253,25 @@ extension UsageMenuCardView.Model { paceOnTop: paceOnTop) } - private static func creditsLine( + /// Body text for the **Credits** block (header is always "Credits" in `CreditsBarContent`). + /// OpenRouter uses API-key quota metrics instead; caller passes `.openrouter` → nil. + private static func creditsSectionText( metadata: ProviderMetadata, credits: CreditsSnapshot?, error: String?) -> String? { guard metadata.supportsCredits else { return nil } if let credits { - return UsageFormatter.creditsString(from: credits.remaining) + let remaining = credits.remaining + if remaining.isFinite, remaining > 0 { + return UsageFormatter.creditsString(from: remaining) + } + return "No credits available" } - if let error, !error.isEmpty { + if let error, !error.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return error.trimmingCharacters(in: .whitespacesAndNewlines) } - return metadata.creditsHint + return "No credits available" } private static func dashboardHint(provider: UsageProvider, error: String?) -> String? { diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index 9696336d5..94819697e 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -138,7 +138,7 @@ struct StatusIconView: View { IconRenderer.makeIcon( primaryRemaining: self.store.snapshot(for: self.provider)?.primary?.remainingPercent, weeklyRemaining: self.store.snapshot(for: self.provider)?.secondary?.remainingPercent, - creditsRemaining: self.provider == .codex ? self.store.credits?.remaining : nil, + creditsRemaining: self.provider == .codex ? self.store.codexActiveCreditsRemaining() : nil, stale: self.store.isStale(provider: self.provider), style: self.store.style(for: self.provider), statusIndicator: self.store.statusIndicator(for: self.provider)) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index eb946b774..c24c23d4c 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -335,9 +335,12 @@ struct ProvidersPane: View { let dashboardError: String? let tokenSnapshot: CostUsageTokenSnapshot? let tokenError: String? + var codexCreditsUnlimited = false if provider == .codex { - credits = self.store.credits - creditsError = self.store.lastCreditsError + let active = self.store.codexActiveMenuCredits() + credits = active.snapshot + creditsError = active.error + codexCreditsUnlimited = active.unlimited dashboard = self.store.openAIDashboardRequiresLogin ? nil : self.store.openAIDashboard dashboardError = self.store.lastOpenAIDashboardError tokenSnapshot = self.store.tokenSnapshot(for: provider) @@ -379,7 +382,7 @@ struct ProvidersPane: View { resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, - codexMenuCreditsPrimaryAccountNotice: self.settings.codexMenuCreditsPrimaryAccountOnlyMessage(), + codexCreditsUnlimited: codexCreditsUnlimited, hidePersonalInfo: self.settings.hidePersonalInfo, weeklyPace: weeklyPace, now: now) diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 00fa20512..db3cf8453 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -272,11 +272,16 @@ struct CodexProviderImplementation: ProviderImplementation { context.metadata.supportsCredits else { return } - if let credits = context.store.credits { + let active = context.store.codexActiveMenuCredits() + if let credits = active.snapshot, credits.remaining.isFinite, credits.remaining > 0 { entries.append(.text("Credits: \(UsageFormatter.creditsString(from: credits.remaining))", .primary)) if let latest = credits.events.first { entries.append(.text("Last spend: \(UsageFormatter.creditEventSummary(latest))", .secondary)) } + } else if active.unlimited { + entries.append(.text("Credits: Unlimited", .primary)) + } else if let err = active.error, !err.isEmpty { + entries.append(.text(err, .secondary)) } else { let hint = context.store.lastCreditsError ?? context.metadata.creditsHint entries.append(.text(hint, .secondary)) diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 68deddf60..0e6d80f0c 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -12,13 +12,6 @@ extension SettingsStore { self.tokenAccountsData(for: provider)?.accounts ?? [] } - /// When a non-primary Codex account is selected, menu credits should not show OAuth/cookie errors for add-on accounts. - func codexMenuCreditsPrimaryAccountOnlyMessage() -> String? { - guard let data = self.tokenAccountsData(for: .codex), !data.accounts.isEmpty else { return nil } - guard !data.isDefaultActive else { return nil } - return "Credit information is available only for the primary account." - } - func selectedTokenAccount(for provider: UsageProvider) -> ProviderTokenAccount? { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return nil } guard !data.isDefaultActive else { return nil } diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 5f422862f..6e2f1b9ae 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -247,7 +247,7 @@ extension StatusItemController { // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. weekly = Self.loadingPercentEpsilon } - var credits: Double? = primaryProvider == .codex ? self.store.credits?.remaining : nil + var credits: Double? = primaryProvider == .codex ? self.store.codexActiveCreditsRemaining() : nil var stale = self.store.isStale(provider: primaryProvider) var morphProgress: Double? @@ -370,7 +370,7 @@ extension StatusItemController { // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. weekly = Self.loadingPercentEpsilon } - var credits: Double? = provider == .codex ? self.store.credits?.remaining : nil + var credits: Double? = provider == .codex ? self.store.codexActiveCreditsRemaining() : nil var stale = self.store.isStale(provider: provider) var morphProgress: Double? @@ -465,7 +465,7 @@ extension StatusItemController { mode == .percent, !self.settings.usageBarsShowUsed, sessionExhausted || weeklyExhausted, - let creditsRemaining = self.store.credits?.remaining, + let creditsRemaining = self.store.codexActiveCreditsRemaining(), creditsRemaining > 0 { return UsageFormatter diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 186319059..92a55a974 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -20,24 +20,10 @@ private struct OverviewMenuCardRowView: View { let width: CGFloat var body: some View { - VStack(alignment: .leading, spacing: 0) { - UsageMenuCardHeaderSectionView( - model: self.model, - showDivider: self.hasUsageBlock, - width: self.width) - if self.hasUsageBlock { - UsageMenuCardUsageSectionView( - model: self.model, - showBottomDivider: false, - bottomPadding: 6, - width: self.width) - } - } - .frame(width: self.width, alignment: .leading) - } - - private var hasUsageBlock: Bool { - !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil + UsageMenuCardWebPrimarySectionView( + model: self.model, + bottomPadding: 6, + width: self.width) } } @@ -928,81 +914,81 @@ extension StatusItemController { webItems: OpenAIWebMenuItems) { let hasUsageBlock = !model.metrics.isEmpty || model.placeholder != nil - let hasCredits = model.creditsText != nil && provider != .codex + // Always honor `creditsText` for Codex too. Omitting it caused OAuth credits to vanish when the menu + // switched from the single-card layout to this sectioned layout (after OpenAI web data loads). + let hasCredits = model.creditsText != nil let hasExtraUsage = model.providerCost != nil let hasCost = model.tokenUsage != nil - let bottomPadding = CGFloat(hasCredits ? 4 : 6) - let sectionSpacing = CGFloat(6) - let usageBottomPadding = bottomPadding - let creditsBottomPadding = bottomPadding + let sectionSpacing = CGFloat(12) + let bodyBottomPadding = CGFloat(hasCredits ? 4 : 6) + let primaryHasBody = hasUsageBlock || hasCredits || !model.usageNotes.isEmpty || model.placeholder != nil + let primaryBottomPadding = primaryHasBody ? bodyBottomPadding : 2 - let headerView = UsageMenuCardHeaderSectionView( + let usageSubmenu = self.makeUsageSubmenu( + provider: provider, + snapshot: self.store.snapshot(for: provider), + webItems: webItems) + let creditsSubmenu = webItems.hasCreditsHistory ? self.makeCreditsHistorySubmenu() : nil + + let primaryView = UsageMenuCardWebPrimarySectionView( model: model, - showDivider: hasUsageBlock, + bottomPadding: primaryBottomPadding, width: width) - menu.addItem(self.makeMenuCardItem(headerView, id: "menuCardHeader", width: width)) - - if hasUsageBlock { - let usageView = UsageMenuCardUsageSectionView( - model: model, - showBottomDivider: false, - bottomPadding: usageBottomPadding, - width: width) - let usageSubmenu = self.makeUsageSubmenu( - provider: provider, - snapshot: self.store.snapshot(for: provider), - webItems: webItems) - menu.addItem(self.makeMenuCardItem( - usageView, - id: "menuCardUsage", - width: width, - submenu: usageSubmenu)) + let primarySubmenu: NSMenu? = if hasUsageBlock { + usageSubmenu + } else if hasCredits { + creditsSubmenu + } else { + nil } - - if hasCredits || hasExtraUsage || hasCost { - menu.addItem(.separator()) + let primaryId = if hasUsageBlock { + "menuCardUsage" + } else if hasCredits { + "menuCardCredits" + } else { + "menuCardHeader" } + menu.addItem(self.makeMenuCardItem( + primaryView, + id: primaryId, + width: width, + submenu: primarySubmenu)) - if hasCredits { - if hasExtraUsage || hasCost { - menu.addItem(.separator()) - } - let creditsView = UsageMenuCardCreditsSectionView( - model: model, - showBottomDivider: false, - topPadding: sectionSpacing, - bottomPadding: creditsBottomPadding, - width: width) - let creditsSubmenu = webItems.hasCreditsHistory ? self.makeCreditsHistorySubmenu() : nil - menu.addItem(self.makeMenuCardItem( - creditsView, - id: "menuCardCredits", - width: width, - submenu: creditsSubmenu)) + if hasUsageBlock, hasCredits, webItems.hasCreditsHistory { + self.addCreditsHistorySubmenu(to: menu) } + if hasExtraUsage { - if hasCredits { - menu.addItem(.separator()) - } let extraUsageView = UsageMenuCardExtraUsageSectionView( model: model, topPadding: sectionSpacing, - bottomPadding: bottomPadding, + bottomPadding: bodyBottomPadding, width: width) menu.addItem(self.makeMenuCardItem( extraUsageView, id: "menuCardExtraUsage", width: width)) } - if hasCost { - if hasCredits || hasExtraUsage { + + if provider == .codex, self.settings.codexBuyCreditsMenuEnabled { + // Avoid an `NSMenuItem.separator()` right under the credits block: the card already ends the panel; a + // separator here reads as a duplicate line. Still separate after `menuCardExtraUsage` or when the + // primary ends with usage-only (no credits row). + let buyCreditsSeparatorBefore = hasExtraUsage || (hasUsageBlock && !hasCredits) + if buyCreditsSeparatorBefore { menu.addItem(.separator()) } + menu.addItem(self.makeBuyCreditsItem()) + } + + if hasCost { + let buyCreditsShown = provider == .codex && self.settings.codexBuyCreditsMenuEnabled let costView = UsageMenuCardCostSectionView( model: model, - topPadding: sectionSpacing, - bottomPadding: bottomPadding, - width: width) + topPadding: buyCreditsShown ? 8 : sectionSpacing, + bottomPadding: bodyBottomPadding, + width: width, + showsLeadingDivider: !buyCreditsShown) let costSubmenu = webItems.hasCostHistory ? self.makeCostHistorySubmenu(provider: provider) : nil menu.addItem(self.makeMenuCardItem( costView, @@ -1010,13 +996,6 @@ extension StatusItemController { width: width, submenu: costSubmenu)) } - - if provider == .codex, self.settings.codexBuyCreditsMenuEnabled { - if hasUsageBlock || hasCredits || hasExtraUsage || hasCost { - menu.addItem(.separator()) - } - menu.addItem(self.makeBuyCreditsItem()) - } } private func switcherIcon(for provider: UsageProvider) -> NSImage { @@ -1046,7 +1025,7 @@ extension StatusItemController { // In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present. weekly = 0.0001 } - let credits = provider == .codex ? self.store.credits?.remaining : nil + let credits = provider == .codex ? self.store.codexActiveCreditsRemaining() : nil let stale = self.store.isStale(provider: provider) let style = self.store.style(for: provider) let indicator = self.store.statusIndicator(for: provider) @@ -1461,9 +1440,12 @@ extension StatusItemController { let dashboardError: String? let tokenSnapshot: CostUsageTokenSnapshot? let tokenError: String? + var codexCreditsUnlimited = false if target == .codex, snapshotOverride == nil { - credits = self.store.credits - creditsError = self.store.lastCreditsError + let active = self.store.codexActiveMenuCredits() + credits = active.snapshot + creditsError = active.error + codexCreditsUnlimited = active.unlimited dashboard = self.store.openAIDashboardRequiresLogin ? nil : self.store.openAIDashboard dashboardError = self.store.lastOpenAIDashboardError tokenSnapshot = self.store.tokenSnapshot(for: target) @@ -1507,7 +1489,7 @@ extension StatusItemController { resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, - codexMenuCreditsPrimaryAccountNotice: self.settings.codexMenuCreditsPrimaryAccountOnlyMessage(), + codexCreditsUnlimited: codexCreditsUnlimited, sourceLabel: sourceLabel, kiloAutoMode: kiloAutoMode, hidePersonalInfo: self.settings.hidePersonalInfo, diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index e728fcea2..eec49c60c 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -806,6 +806,8 @@ final class TokenAccountSwitcherView: NSView { private let accounts: [ProviderTokenAccount] private let defaultAccountLabel: String? + /// Menu width passed at creation; used for a stable intrinsic size so NSMenu does not stretch this row vertically. + private let menuLayoutWidth: CGFloat private let onSelect: (Int) -> Void private var selectedIndex: Int /// Maps button tag → logical index (-1 for default, 0+ for token accounts) @@ -830,6 +832,7 @@ final class TokenAccountSwitcherView: NSView { { self.accounts = accounts self.defaultAccountLabel = defaultAccountLabel + self.menuLayoutWidth = width self.contentMargin = contentMargin self.onSelect = onSelect // selectedIndex == -1 means default account is active @@ -844,6 +847,8 @@ final class TokenAccountSwitcherView: NSView { self.wantsLayer = true self.buildButtons(useTwoRows: useTwoRows) self.updateButtonStyles() + self.setContentHuggingPriority(.required, for: .vertical) + self.setContentCompressionResistancePriority(.required, for: .vertical) } @available(*, unavailable) @@ -851,6 +856,12 @@ final class TokenAccountSwitcherView: NSView { nil } + override var intrinsicContentSize: NSSize { + NSSize( + width: self.menuLayoutWidth, + height: Self.preferredHeight(accounts: self.accounts, defaultAccountLabel: self.defaultAccountLabel)) + } + private func buildButtons(useTwoRows: Bool) { // Build the flat ordered list: default (index -1) first, then token accounts (0, 1, 2…) struct Entry { let title: String; let logicalIndex: Int } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index c44558a66..f0bf018f5 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -77,7 +77,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var animationDriver: DisplayLinkDriver? var animationPhase: Double = 0 var animationPattern: LoadingPattern = .knightRider - private var lastConfigRevision: Int + /// Fingerprint of settings that affect merged/per-provider menu **structure** (not usage snapshots). + /// Avoids rebuilding an open menu when only `configRevision` bumps (e.g. debounced persist), which caused + /// layout jumps ~1s after opening as the menu switched to a freshly measured structure. + private var lastOpenMenuStructureFingerprint: String private var lastProviderOrder: [UsageProvider] private var lastMergeIcons: Bool private var lastSwitcherShowsIcons: Bool @@ -164,7 +167,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.account = account self.updater = updater self.preferencesSelection = preferencesSelection - self.lastConfigRevision = settings.configRevision + self.lastOpenMenuStructureFingerprint = "" self.lastProviderOrder = settings.providerOrder self.lastMergeIcons = settings.mergeIcons self.lastSwitcherShowsIcons = settings.switcherShowsIcons @@ -195,6 +198,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin selector: #selector(self.handleProviderConfigDidChange), name: .codexbarProviderConfigDidChange, object: nil) + self.lastOpenMenuStructureFingerprint = self.openMenuStructureFingerprint() } private func wireBindings() { @@ -287,39 +291,47 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - private func shouldRefreshOpenMenusForProviderSwitcher() -> Bool { - var shouldRefresh = false - let revision = self.settings.configRevision - if revision != self.lastConfigRevision { - self.lastConfigRevision = revision - shouldRefresh = true - } - let order = self.settings.providerOrder - if order != self.lastProviderOrder { - self.lastProviderOrder = order - shouldRefresh = true - } - let mergeIcons = self.settings.mergeIcons - if mergeIcons != self.lastMergeIcons { - self.lastMergeIcons = mergeIcons - shouldRefresh = true - } - let showsIcons = self.settings.switcherShowsIcons - if showsIcons != self.lastSwitcherShowsIcons { - self.lastSwitcherShowsIcons = showsIcons - shouldRefresh = true - } - let usageBarsShowUsed = self.settings.usageBarsShowUsed - if usageBarsShowUsed != self.lastObservedUsageBarsShowUsed { - self.lastObservedUsageBarsShowUsed = usageBarsShowUsed - shouldRefresh = true - } - return shouldRefresh + private func openMenuStructureFingerprint() -> String { + let s = self.settings + let order = s.providerOrder.map(\.rawValue).joined(separator: ",") + let overview = s.mergedOverviewSelectedProviders.map(\.rawValue).sorted().joined(separator: ",") + let selectedMenu = s.selectedMenuProvider?.rawValue ?? "" + let tokenSig = UsageProvider.allCases.map { p -> String in + let data = s.tokenAccountsData(for: p) + let count = data?.accounts.count ?? 0 + let active = data?.activeIndex ?? -999 + return "\(p.rawValue):\(count):\(active)" + }.joined(separator: "|") + let costSig = + "\(s.isCostUsageEffectivelyEnabled(for: .codex))|\(s.isCostUsageEffectivelyEnabled(for: .claude))|\(s.isCostUsageEffectivelyEnabled(for: .vertexai))" + return [ + order, + "\(s.mergeIcons)", + "\(s.switcherShowsIcons)", + "\(s.usageBarsShowUsed)", + "\(s.showAllTokenAccountsInMenu)", + "\(s.openAIWebAccessEnabled)", + "\(s.codexBuyCreditsMenuEnabled)", + overview, + "\(s.mergedMenuLastSelectedWasOverview)", + selectedMenu, + "\(s.showOptionalCreditsAndExtraUsage)", + "\(s.hidePersonalInfo)", + costSig, + tokenSig, + ].joined(separator: "\u{1e}") + } + + private func shouldRefreshOpenMenusForMenuStructureChange() -> Bool { + let fingerprint = self.openMenuStructureFingerprint() + guard fingerprint != self.lastOpenMenuStructureFingerprint else { return false } + self.lastOpenMenuStructureFingerprint = fingerprint + return true } private func handleSettingsChange(reason: String) { let orderChanged = self.settings.providerOrder != self.lastProviderOrder - let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher() + let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForMenuStructureChange() self.invalidateMenus() // Rebuilding status items calls `removeStatusItem` for every provider slot, which reflows the // system menu bar and can make *other* apps' menu extras disappear briefly. Token-account @@ -334,6 +346,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if shouldRefreshOpenMenus { self.refreshOpenMenusIfNeeded() } + self.lastProviderOrder = self.settings.providerOrder } private func updateIcons() { diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index 42d2d913e..624b5dbf8 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -74,6 +74,7 @@ struct UsageProgressBar: View { bar } } + .frame(maxWidth: .infinity) .frame(height: 6) .accessibilityLabel(self.accessibilityLabel) .accessibilityValue("\(Int(self.clamped)) percent") diff --git a/Sources/CodexBar/UsageStore+CodexActiveCredits.swift b/Sources/CodexBar/UsageStore+CodexActiveCredits.swift new file mode 100644 index 000000000..bfb48a704 --- /dev/null +++ b/Sources/CodexBar/UsageStore+CodexActiveCredits.swift @@ -0,0 +1,56 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + /// Credits for the Codex account selected in the menu (tabs / Menu bar account). + /// Primary (`~/.codex`) uses RPC/dashboard `credits`; add-on accounts use OAuth rows in `allAccountCredits`. + func codexActiveMenuCredits() -> (snapshot: CreditsSnapshot?, error: String?, unlimited: Bool) { + guard let data = self.settings.tokenAccountsData(for: .codex), !data.accounts.isEmpty else { + return (self.credits, self.lastCreditsError, false) + } + if data.isDefaultActive { + return (self.credits, self.lastCreditsError, false) + } + let index = data.clampedActiveIndex() + guard index >= 0, index < data.accounts.count else { + return (self.credits, self.lastCreditsError, false) + } + let account = data.accounts[index] + let entries = self.allAccountCredits[.codex] ?? [] + let entry = entries.first { $0.id == account.id.uuidString } + if let entry { + let trimmedErr = entry.error?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedErr.isEmpty { + return (nil, Self.shortCodexOAuthErrorMessage(trimmedErr), false) + } + if entry.isUnlimited { + return (nil, nil, true) + } + if let bal = entry.creditsRemaining, bal > 0, bal.isFinite { + return ( + CreditsSnapshot(remaining: bal, events: [], updatedAt: entry.updatedAt), + nil, + false) + } + return (nil, nil, false) + } + if self.accountCostRefreshInFlight.contains(.codex) { + return (nil, "Loading credits…", false) + } + return (nil, nil, false) + } + + /// Remaining prepaid balance for the active Codex account (menu bar icon, switcher, widget). + func codexActiveCreditsRemaining() -> Double? { + let (snapshot, _, unlimited) = self.codexActiveMenuCredits() + if unlimited { return nil } + guard let remaining = snapshot?.remaining, remaining.isFinite, remaining > 0 else { return nil } + return remaining + } + + private static func shortCodexOAuthErrorMessage(_ error: String) -> String { + if error.contains("not found") || error.contains("notFound") { return "Not signed in" } + if error.localizedCaseInsensitiveContains("unauthorized") || error.contains("401") { return "Token expired" } + return error + } +} diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index f8699128d..25fd36bf5 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -37,7 +37,7 @@ extension UsageStore { } ?? [] let tokenUsage = Self.widgetTokenUsageSummary(from: tokenSnapshot) - let creditsRemaining = provider == .codex ? self.credits?.remaining : nil + let creditsRemaining = provider == .codex ? self.codexActiveCreditsRemaining() : nil let codeReviewRemaining = provider == .codex ? self.openAIDashboard?.codeReviewRemainingPercent : nil return WidgetSnapshot.ProviderEntry( diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index cfe0b3e31..045ebeaef 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -19,6 +19,7 @@ extension UsageStore { _ = self.tokenRefreshInFlight _ = self.credits _ = self.lastCreditsError + _ = self.allAccountCredits _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin diff --git a/Sources/CodexBar/UsageStoreSupport.swift b/Sources/CodexBar/UsageStoreSupport.swift index 522416898..517ec2fbc 100644 --- a/Sources/CodexBar/UsageStoreSupport.swift +++ b/Sources/CodexBar/UsageStoreSupport.swift @@ -77,5 +77,9 @@ extension UsageStore { self.codexHistoricalDatasetAccountKey = accountKey self.historicalPaceRevision += 1 } + + func _setAllAccountCreditsForTesting(_ entries: [AccountCostEntry], provider: UsageProvider) { + self.allAccountCredits[provider] = entries + } } #endif diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 85c011d6d..46dc7ba83 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -102,6 +102,15 @@ public enum UsageFormatter { return "\(formatted) left" } + /// Credit balance count from the API (not USD). Use in tables that already label the column “Credits”. + public static func creditsBalanceString(from value: Double) -> String { + let number = NumberFormatter() + number.numberStyle = .decimal + number.maximumFractionDigits = 2 + number.locale = Locale(identifier: "en_US") + return number.string(from: NSNumber(value: value)) ?? 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/CodexActiveCreditsTests.swift b/Tests/CodexBarTests/CodexActiveCreditsTests.swift new file mode 100644 index 000000000..4194a3f95 --- /dev/null +++ b/Tests/CodexBarTests/CodexActiveCreditsTests.swift @@ -0,0 +1,96 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct CodexActiveCreditsTests { + @Test + func `primary account uses store credits`() throws { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "CodexActiveCreditsTests-primary"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let now = Date() + store.credits = CreditsSnapshot(remaining: 99, events: [], updatedAt: now) + store.lastCreditsError = nil + + let result = store.codexActiveMenuCredits() + #expect(result.snapshot?.remaining == 99) + #expect(result.error == nil) + #expect(result.unlimited == false) + #expect(store.codexActiveCreditsRemaining() == 99) + } + + @Test + func `add-on account uses oauth account cost entry`() throws { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "CodexActiveCreditsTests-addon"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.addTokenAccount(provider: .codex, label: "Work", token: "/tmp/codex-work") + let account = try #require(settings.tokenAccounts(for: .codex).first) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + store.credits = CreditsSnapshot(remaining: 1, events: [], updatedAt: Date()) + let updatedAt = Date() + let entry = AccountCostEntry( + id: account.id.uuidString, + label: account.label, + isDefault: false, + creditsRemaining: 55, + isUnlimited: false, + planType: "Pro", + primaryUsedPercent: 10, + secondaryUsedPercent: 20, + primaryResetDescription: "3h", + secondaryResetDescription: "2d", + error: nil, + updatedAt: updatedAt) + store._setAllAccountCreditsForTesting([entry], provider: .codex) + + let result = store.codexActiveMenuCredits() + #expect(result.snapshot?.remaining == 55) + #expect(result.error == nil) + #expect(result.unlimited == false) + #expect(store.codexActiveCreditsRemaining() == 55) + } + + @Test + func `add-on unlimited reports unlimited flag`() throws { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "CodexActiveCreditsTests-unlimited"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.addTokenAccount(provider: .codex, label: "Team", token: "/tmp/codex-team") + let account = try #require(settings.tokenAccounts(for: .codex).first) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let entry = AccountCostEntry( + id: account.id.uuidString, + label: account.label, + isDefault: false, + creditsRemaining: nil, + isUnlimited: true, + planType: "Team", + primaryUsedPercent: nil, + secondaryUsedPercent: nil, + primaryResetDescription: nil, + secondaryResetDescription: nil, + error: nil, + updatedAt: Date()) + store._setAllAccountCreditsForTesting([entry], provider: .codex) + + let result = store.codexActiveMenuCredits() + #expect(result.snapshot == nil) + #expect(result.unlimited == true) + #expect(store.codexActiveCreditsRemaining() == nil) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 052bd0dad..3977d1288 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -312,7 +312,7 @@ struct MenuCardModelTests { } @Test - func `hides codex credits when disabled`() throws { + func `shows codex credits section when optional extras disabled`() throws { let now = Date() let identity = ProviderIdentitySnapshot( providerID: .codex, @@ -347,7 +347,64 @@ struct MenuCardModelTests { hidePersonalInfo: false, now: now)) - #expect(model.creditsText == nil) + #expect(model.creditsText == UsageFormatter.creditsString(from: 12)) + #expect(model.creditsRemaining == 12) + } + + @Test + func `codex credits section shows no credits when balance zero`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: CreditsSnapshot(remaining: 0, events: [], updatedAt: now), + 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.creditsText == "No credits available") + #expect(model.creditsRemaining == nil) + } + + @Test + func `codex credits section shows no credits when snapshot missing`() 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: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.creditsText == "No credits available") + #expect(model.creditsRemaining == nil) } @Test diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index fe8d223a5..3ad14d61c 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -194,11 +194,14 @@ struct StatusMenuTests { func hasOpenAIWebSubmenus(_ menu: NSMenu) -> Bool { let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } - let creditsItem = menu.items.first { ($0.representedObject as? String) == "menuCardCredits" } let hasUsageBreakdown = usageItem?.submenu?.items .contains { ($0.representedObject as? String) == "usageBreakdownChart" } == true + let creditsItem = menu.items.first { ($0.representedObject as? String) == "menuCardCredits" } + let creditsHistoryTextItem = menu.items.first { $0.title == "Credits history" } let hasCreditsHistory = creditsItem?.submenu?.items .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true + || creditsHistoryTextItem?.submenu?.items + .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true return hasUsageBreakdown || hasCreditsHistory } @@ -687,13 +690,16 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) - let ids = menu.items.compactMap { $0.representedObject as? String } - #expect(!ids.contains("menuCardCredits")) - let costIndex = ids.firstIndex(of: "menuCardCost") - #expect(costIndex != nil) + // Credits are in the primary card; order: usage block → Buy Credits… → Cost. + let usageMenuIndex = menu.items.firstIndex { ($0.representedObject as? String) == "menuCardUsage" } + let costMenuIndex = menu.items.firstIndex { ($0.representedObject as? String) == "menuCardCost" } + #expect(usageMenuIndex != nil) + #expect(costMenuIndex != nil) let buyIndex = menu.items.firstIndex { $0.title == "Buy Credits..." } #expect(buyIndex != nil) - #expect(try #require(buyIndex) > costIndex!) + if let u = usageMenuIndex, let c = costMenuIndex, let b = buyIndex { + #expect(u < b && b < c) + } } @Test diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index c6804a719..1a5aedec9 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -222,4 +222,10 @@ struct UsageFormatterTests { let result = UsageFormatter.creditsString(from: 42.5) #expect(result == "42.5 left") } + + @Test + func `credits balance string is decimal not currency`() { + #expect(UsageFormatter.creditsBalanceString(from: 2500) == "2,500") + #expect(UsageFormatter.creditsBalanceString(from: 42.5) == "42.5") + } } From ec0960610f0bae1845d9621107873c574ca94302 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Fri, 20 Mar 2026 17:48:01 -0400 Subject: [PATCH 04/25] Fix Codex token-account env for costs; align default selection when primary missing - Use TokenAccountSupportCatalog envOverride for per-account credit refresh; load OPENAI_API_KEY-only env as API-key creds - isDefaultTokenAccountActive + display index for fetches/UI when ~/.codex primary is gone - Tests for env load and Claude token-account selection Made-with: Cursor --- .../PreferencesProviderDetailView.swift | 17 +++--- .../PreferencesProviderSettingsRows.swift | 36 ++++++------- .../SettingsStore+TokenAccounts.swift | 25 ++++++++- .../CodexBar/StatusItemController+Menu.swift | 7 ++- Sources/CodexBar/StatusItemController.swift | 3 +- .../CodexBar/UsageStore+AccountCosts.swift | 53 ++++++++++++------- .../UsageStore+CodexActiveCredits.swift | 2 +- .../CodexBar/UsageStore+TokenAccounts.swift | 2 +- Sources/CodexBar/UsageStore+TokenCost.swift | 6 +-- .../CodexOAuth/CodexOAuthCredentials.swift | 18 ++++++- Tests/CodexBarTests/CodexOAuthTests.swift | 8 +++ .../SettingsStoreAdditionalTests.swift | 14 +++++ 12 files changed, 134 insertions(+), 57 deletions(-) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index be34d5b1d..df20d3b38 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -71,7 +71,7 @@ struct ProviderDetailView: View { let accounts = self.settings.tokenAccounts(for: .codex) let defaultLabel = CodexProviderImplementation() .tokenAccountDefaultLabel(settings: self.settings) - let rawSelection = self.settings.tokenAccountsData(for: .codex)?.activeIndex ?? -1 + let displaySelection = self.settings.displayTokenAccountActiveIndex(for: .codex) ProviderMetricsInlineView( provider: self.provider, model: self.model, @@ -81,7 +81,7 @@ struct ProviderDetailView: View { TokenAccountSwitcherRepresentable( accounts: accounts, defaultAccountLabel: defaultLabel, - selectedIndex: rawSelection, + selectedIndex: displaySelection, width: ProviderSettingsMetrics.detailMaxWidth, onSelect: { index in self.settings.setActiveTokenAccountIndex(index, for: .codex) @@ -173,7 +173,8 @@ struct ProviderDetailView: View { !self.settingsToggles.isEmpty || !self.optionsSectionPickers.isEmpty } - /// When Codex has more than one selectable account, summary email/plan reflect only the active fetch — hide to avoid confusion. + /// When Codex has more than one selectable account, summary email/plan reflect only the active fetch — hide to + /// avoid confusion. private var codexHidesHeaderAccountAndPlan: Bool { guard self.provider == .codex else { return false } let hasPrimary = CodexProviderImplementation().tokenAccountDefaultLabel(settings: self.settings) != nil @@ -192,8 +193,8 @@ struct ProviderDetailView: View { private var codexUsageAccountSwitcherIdentity: String { let accounts = self.settings.tokenAccounts(for: .codex) let ids = accounts.map(\.id.uuidString).sorted().joined(separator: ",") - let raw = self.settings.tokenAccountsData(for: .codex)?.activeIndex ?? -1 - return "\(self.settings.configRevision)-\(ids)-\(raw)" + let display = self.settings.displayTokenAccountActiveIndex(for: .codex) + return "\(self.settings.configRevision)-\(ids)-\(display)" } /// Display name for the account whose usage/cost is shown (token selection or primary or menu card email). @@ -201,8 +202,7 @@ struct ProviderDetailView: View { let provider = self.provider if TokenAccountSupportCatalog.support(for: provider) != nil { let accounts = self.settings.tokenAccounts(for: provider) - let raw = self.settings.tokenAccountsData(for: provider)?.activeIndex ?? -1 - if raw < 0 || accounts.isEmpty { + if self.settings.isDefaultTokenAccountActive(for: provider) || accounts.isEmpty { if let custom = self.settings.providerConfig(for: provider)?.defaultAccountLabel? .trimmingCharacters(in: .whitespacesAndNewlines), !custom.isEmpty @@ -212,7 +212,8 @@ struct ProviderDetailView: View { return ProviderCatalog.implementation(for: provider)? .tokenAccountDefaultLabel(settings: self.settings) } - let index = min(max(raw, 0), max(0, accounts.count - 1)) + let raw = self.settings.tokenAccountsData(for: provider)?.activeIndex ?? -1 + let index = min(max(raw < 0 ? 0 : raw, 0), max(0, accounts.count - 1)) guard index < accounts.count else { return nil } return accounts[index].displayName } diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 9fcb4befc..794bed036 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -253,12 +253,13 @@ struct ProviderSettingsTokenAccountsRowView: View { let accounts = self.descriptor.accounts() let defaultLabel = self.descriptor.defaultAccountLabel?() let hasDefaultTab = defaultLabel != nil - // activeIndex < 0 means the default account is selected let activeIndex = self.descriptor.activeIndex() - let defaultIsActive = activeIndex < 0 || (accounts.isEmpty && hasDefaultTab) - let selectedIndex = defaultIsActive ? -1 : min(activeIndex, max(0, accounts.count - 1)) + let defaultIsActive = (activeIndex < 0 || accounts.isEmpty) && hasDefaultTab + let selectedIndex = defaultIsActive + ? -1 + : min(max(activeIndex, 0), max(0, accounts.count - 1)) - if !hasDefaultTab && accounts.isEmpty { + if !hasDefaultTab, accounts.isEmpty { Text("No accounts added yet.") .font(.footnote) .foregroundStyle(.secondary) @@ -301,7 +302,8 @@ struct ProviderSettingsTokenAccountsRowView: View { if let loginAction = self.descriptor.loginAction { self.signInSection(loginAction: loginAction, addAccount: self.descriptor.addAccount) } else { - Text("Browser OAuth requires the Codex CLI. You can still add an account with an API key (other tab).") + Text( + "Browser OAuth requires the Codex CLI. You can still add an account with an API key (other tab).") .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -330,7 +332,6 @@ struct ProviderSettingsTokenAccountsRowView: View { } } - @ViewBuilder private func accountTabsView( defaultLabel: String?, accounts: [ProviderTokenAccount], @@ -348,7 +349,6 @@ struct ProviderSettingsTokenAccountsRowView: View { } } - @ViewBuilder private func menuBarActiveBadge() -> some View { Text("Menu bar") .font(.system(size: 10, weight: .semibold)) @@ -440,12 +440,13 @@ struct ProviderSettingsTokenAccountsRowView: View { : Color(NSColor.controlBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 8) - .strokeBorder(rowActive - ? Color.accentColor.opacity(0.45) - : Color(NSColor.separatorColor), + .strokeBorder( + rowActive + ? Color.accentColor.opacity(0.45) + : Color(NSColor.separatorColor), lineWidth: rowActive ? 1.5 : 1))) .onChange(of: self.renameFieldFocused) { _, focused in - if !focused && self.renamingDefault { self.commitRenameDefault() } + if !focused, self.renamingDefault { self.commitRenameDefault() } } } @@ -545,9 +546,10 @@ struct ProviderSettingsTokenAccountsRowView: View { : Color(NSColor.controlBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 8) - .strokeBorder(rowActive - ? Color.accentColor.opacity(0.45) - : Color(NSColor.separatorColor), + .strokeBorder( + rowActive + ? Color.accentColor.opacity(0.45) + : Color(NSColor.separatorColor), lineWidth: rowActive ? 1.5 : 1))) .onChange(of: self.renameFieldFocused) { _, focused in if !focused, self.renamingAccountID == account.id { self.commitRename(account: account) } @@ -563,7 +565,6 @@ struct ProviderSettingsTokenAccountsRowView: View { self.renameText = "" } - @ViewBuilder private func codexAPIKeyAddSection() -> some View { VStack(alignment: .leading, spacing: 6) { Text("Adds an account that sets OPENAI_API_KEY for Codex (stored securely in your config).") @@ -593,12 +594,10 @@ struct ProviderSettingsTokenAccountsRowView: View { } } - @ViewBuilder private func signInSection( loginAction: @escaping ( _ setProgress: @escaping @MainActor (String) -> Void, - _ addAccount: @escaping @MainActor (String, String) -> Void - ) async -> Bool, + _ addAccount: @escaping @MainActor (String, String) -> Void) async -> Bool, addAccount: @escaping (String, String) -> Void) -> some View { VStack(alignment: .leading, spacing: 6) { @@ -641,7 +640,6 @@ struct ProviderSettingsTokenAccountsRowView: View { } } - @ViewBuilder private func manualAddSection() -> some View { HStack(spacing: 8) { TextField("Label", text: self.$newLabel) diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 0e6d80f0c..44a62113b 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -3,6 +3,29 @@ import CodexBarCore import Foundation extension SettingsStore { + /// Whether fetches should use the primary credential path without a token-account override. + /// For Codex, if the user had primary selected (`activeIndex < 0`) but `~/.codex` has no usable credentials, + /// this returns `false` so usage/credits/costs follow the visible add-on tab. + func isDefaultTokenAccountActive(for provider: UsageProvider) -> Bool { + guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { + return true + } + guard data.activeIndex < 0 else { return false } + if provider != .codex { return true } + return ProviderCatalog.implementation(for: .codex)?.tokenAccountDefaultLabel(settings: self) != nil + } + + /// Menu/settings switcher highlight: maps stored primary selection to add-on index `0` when primary is unavailable. + func displayTokenAccountActiveIndex(for provider: UsageProvider) -> Int { + let accounts = self.tokenAccounts(for: provider) + guard !accounts.isEmpty else { return -1 } + let raw = self.tokenAccountsData(for: provider)?.activeIndex ?? -1 + if raw < 0 { + return self.isDefaultTokenAccountActive(for: provider) ? -1 : 0 + } + return min(raw, accounts.count - 1) + } + func tokenAccountsData(for provider: UsageProvider) -> ProviderTokenAccountData? { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } return self.configSnapshot.providerConfig(for: provider)?.tokenAccounts @@ -14,7 +37,7 @@ extension SettingsStore { func selectedTokenAccount(for provider: UsageProvider) -> ProviderTokenAccount? { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return nil } - guard !data.isDefaultActive else { return nil } + guard !self.isDefaultTokenAccountActive(for: provider) else { return nil } let index = data.clampedActiveIndex() return data.accounts[index] } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 92a55a974..7c36ec5b6 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -711,13 +711,12 @@ extension StatusItemController { private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } let accounts = self.settings.tokenAccounts(for: provider) - let defaultLabel = ProviderCatalog.implementation(for: provider)?.tokenAccountDefaultLabel(settings: self.settings) + let defaultLabel = ProviderCatalog.implementation(for: provider)? + .tokenAccountDefaultLabel(settings: self.settings) // Show switcher when there's a default account + at least 1 token account, or 2+ token accounts let hasMultiple = accounts.count >= 1 && defaultLabel != nil || accounts.count > 1 guard hasMultiple else { return nil } - // Use raw activeIndex so -1 (default account selected) passes through - let rawActiveIndex = self.settings.tokenAccountsData(for: provider)?.activeIndex ?? -1 - let activeIndex = rawActiveIndex < 0 ? -1 : min(rawActiveIndex, max(0, accounts.count - 1)) + let activeIndex = self.settings.displayTokenAccountActiveIndex(for: provider) let showAll = self.settings.showAllTokenAccountsInMenu let snapshots = showAll ? (self.store.accountSnapshots[provider] ?? []) : [] return TokenAccountMenuDisplay( diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index f0bf018f5..5b5fabbd0 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -300,7 +300,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let data = s.tokenAccountsData(for: p) let count = data?.accounts.count ?? 0 let active = data?.activeIndex ?? -999 - return "\(p.rawValue):\(count):\(active)" + let display = s.displayTokenAccountActiveIndex(for: p) + return "\(p.rawValue):\(count):\(active):\(display)" }.joined(separator: "|") let costSig = "\(s.isCostUsageEffectivelyEnabled(for: .codex))|\(s.isCostUsageEffectivelyEnabled(for: .claude))|\(s.isCostUsageEffectivelyEnabled(for: .vertexai))" diff --git a/Sources/CodexBar/UsageStore+AccountCosts.swift b/Sources/CodexBar/UsageStore+AccountCosts.swift index 4857e0ed5..60f522276 100644 --- a/Sources/CodexBar/UsageStore+AccountCosts.swift +++ b/Sources/CodexBar/UsageStore+AccountCosts.swift @@ -3,7 +3,7 @@ import Foundation /// A single account's usage snapshot, used in the Costs summary card. struct AccountCostEntry: Identifiable, Sendable { - let id: String // "default" or account UUID string + let id: String // "default" or account UUID string let label: String let isDefault: Bool /// Prepaid credits balance (nil when on a subscription plan or not available). @@ -53,7 +53,22 @@ extension UsageStore { // Token accounts (index 1…) for (offset, account) in tokenAccounts.enumerated() { group.addTask { - let env = ["CODEX_HOME": account.token] + guard let env = TokenAccountSupportCatalog.envOverride(for: .codex, token: account.token) else { + let entry = AccountCostEntry( + id: account.id.uuidString, + label: account.label, + isDefault: false, + creditsRemaining: nil, + isUnlimited: false, + planType: nil, + primaryUsedPercent: nil, + secondaryUsedPercent: nil, + primaryResetDescription: nil, + secondaryResetDescription: nil, + error: "Invalid Codex account token", + updatedAt: Date()) + return (offset + 1, entry) + } let entry = await Self.fetchCredits( env: env, id: account.id.uuidString, @@ -64,14 +79,16 @@ extension UsageStore { } var results: [(Int, AccountCostEntry)] = [] - for await pair in group { results.append(pair) } + for await pair in group { + results.append(pair) + } return results.sorted { $0.0 < $1.0 }.map(\.1) } // Only keep accounts that returned something useful (or an error worth surfacing). // Drop the default entry entirely if there's no auth.json (not logged in at all). entries = entries.filter { entry in - if entry.isDefault && entry.creditsRemaining == nil && entry.error?.contains("not found") == true { + if entry.isDefault, entry.creditsRemaining == nil, entry.error?.contains("not found") == true { return false } return true @@ -156,20 +173,20 @@ extension UsageStore { private static func planDisplayName(_ plan: CodexUsageResponse.PlanType) -> String { switch plan { - case .guest: return "Guest" - case .free: return "Free" - case .go: return "Go" - case .plus: return "Plus" - case .pro: return "Pro" - case .freeWorkspace: return "Free Workspace" - case .team: return "Team" - case .business: return "Business" - case .education: return "Education" - case .quorum: return "Quorum" - case .k12: return "K-12" - case .enterprise: return "Enterprise" - case .edu: return "Edu" - case let .unknown(raw): return raw.capitalized + case .guest: "Guest" + case .free: "Free" + case .go: "Go" + case .plus: "Plus" + case .pro: "Pro" + case .freeWorkspace: "Free Workspace" + case .team: "Team" + case .business: "Business" + case .education: "Education" + case .quorum: "Quorum" + case .k12: "K-12" + case .enterprise: "Enterprise" + case .edu: "Edu" + case let .unknown(raw): raw.capitalized } } } diff --git a/Sources/CodexBar/UsageStore+CodexActiveCredits.swift b/Sources/CodexBar/UsageStore+CodexActiveCredits.swift index bfb48a704..4932610c8 100644 --- a/Sources/CodexBar/UsageStore+CodexActiveCredits.swift +++ b/Sources/CodexBar/UsageStore+CodexActiveCredits.swift @@ -8,7 +8,7 @@ extension UsageStore { guard let data = self.settings.tokenAccountsData(for: .codex), !data.accounts.isEmpty else { return (self.credits, self.lastCreditsError, false) } - if data.isDefaultActive { + if self.settings.isDefaultTokenAccountActive(for: .codex) { return (self.credits, self.lastCreditsError, false) } let index = data.clampedActiveIndex() diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 54d3792af..8767c68da 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -30,7 +30,7 @@ extension UsageStore { func refreshTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) async { let selectedAccount = self.settings.selectedTokenAccount(for: provider) - let defaultIsActive = self.settings.tokenAccountsData(for: provider)?.isDefaultActive ?? true + let defaultIsActive = self.settings.isDefaultTokenAccountActive(for: provider) let limitedAccounts = self.limitedTokenAccounts(accounts, selected: selectedAccount) var snapshots: [TokenAccountUsageSnapshot] = [] var selectedOutcome: ProviderFetchOutcome? diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index d709d8e93..37272e3f3 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -2,13 +2,13 @@ import CodexBarCore import Foundation extension UsageStore { - /// Codex `…/sessions` directory for the credentials dir selected in Settings (path-based token accounts), or `nil` to use the process environment / `~/.codex`. + /// Codex `…/sessions` directory for the credentials dir selected in Settings (path-based token accounts), or `nil` + /// to use the process environment / `~/.codex`. func codexCostUsageSessionsRootForActiveSelection() -> URL? { guard let support = TokenAccountSupportCatalog.support(for: .codex), case .codexHome = support.injection else { return nil } - let data = self.settings.tokenAccountsData(for: .codex) - let defaultActive = data?.isDefaultActive ?? true + let defaultActive = self.settings.isDefaultTokenAccountActive(for: .codex) if defaultActive { return nil } guard let account = self.settings.selectedTokenAccount(for: .codex) else { return nil } let token = account.token.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index 4cf98ebb7..252a26d59 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -56,7 +56,23 @@ public enum CodexOAuthCredentialsStore { return home.appendingPathComponent(".codex").appendingPathComponent("auth.json") } - public static func load(env: [String: String] = ProcessInfo.processInfo.environment) throws -> CodexOAuthCredentials { + public static func load(env: [String: String] = ProcessInfo.processInfo + .environment) throws -> CodexOAuthCredentials + { + let codexHome = env["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if codexHome.isEmpty, + let apiKey = env["OPENAI_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !apiKey.isEmpty + { + // Token-account `apikey:` injection supplies only OPENAI_API_KEY; avoid reading ~/.codex/auth.json. + return CodexOAuthCredentials( + accessToken: apiKey, + refreshToken: "", + idToken: nil, + accountId: nil, + lastRefresh: nil) + } + let url = self.authFilePath(env: env) guard FileManager.default.fileExists(atPath: url.path) else { throw CodexOAuthCredentialsError.notFound diff --git a/Tests/CodexBarTests/CodexOAuthTests.swift b/Tests/CodexBarTests/CodexOAuthTests.swift index fdc0a2850..6534a2b41 100644 --- a/Tests/CodexBarTests/CodexOAuthTests.swift +++ b/Tests/CodexBarTests/CodexOAuthTests.swift @@ -25,6 +25,14 @@ struct CodexOAuthTests { #expect(creds.lastRefresh != nil) } + @Test + func `loads API key from environment when CODEX HOME unset`() throws { + let creds = try CodexOAuthCredentialsStore.load(env: ["OPENAI_API_KEY": "sk-env-only"]) + #expect(creds.accessToken == "sk-env-only") + #expect(creds.refreshToken.isEmpty) + #expect(creds.accountId == nil) + } + @Test func `parses API key credentials`() throws { let json = """ diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index 049219a49..078719d46 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -65,6 +65,20 @@ struct SettingsStoreAdditionalTests { #expect(settings.ollamaCookieSource == .manual) } + @Test + func `claude default token account active follows stored primary selection`() { + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-claude-default-active") + + settings.addTokenAccount(provider: .claude, label: "Work", token: "token-1") + settings.setActiveTokenAccountIndex(-1, for: .claude) + #expect(settings.isDefaultTokenAccountActive(for: .claude)) + #expect(settings.displayTokenAccountActiveIndex(for: .claude) == -1) + + settings.setActiveTokenAccountIndex(0, for: .claude) + #expect(!settings.isDefaultTokenAccountActive(for: .claude)) + #expect(settings.displayTokenAccountActiveIndex(for: .claude) == 0) + } + @Test func `detects token cost usage sources from filesystem`() throws { let fm = FileManager.default From e1df48e645635d6c25c7b6cf555bd4e75394dd4b Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Sat, 21 Mar 2026 14:48:45 -0400 Subject: [PATCH 05/25] Refactor CodexBar account handling to support explicit account management - Introduced a new setting to allow CodexBar to ignore ~/.codex as an implicit account, requiring users to manage accounts explicitly through the UI. - Updated various components to reflect this change, including adjustments to account selection logic and UI elements. - Enhanced the handling of account-related actions and settings to improve user experience and clarity. Made-with: Cursor --- .../CodexBar/AccountCostsMenuCardView.swift | 2 +- Sources/CodexBar/FlowLayout.swift | 14 +- Sources/CodexBar/MenuDescriptor.swift | 7 +- .../PreferencesProviderDetailView.swift | 76 ++- .../PreferencesProviderSettingsRows.swift | 107 ++-- .../PreferencesProvidersPane+Testing.swift | 3 +- .../CodexBar/PreferencesProvidersPane.swift | 13 +- Sources/CodexBar/ProviderRegistry.swift | 9 + .../Codex/CodexProviderImplementation.swift | 52 +- .../Providers/Codex/CodexSettingsStore.swift | 27 +- .../Shared/ProviderImplementation.swift | 6 +- .../Shared/ProviderSettingsDescriptors.swift | 5 +- .../SettingsStore+TokenAccounts.swift | 12 +- .../StatusItemController+Actions.swift | 2 +- Sources/CodexBar/StatusItemController.swift | 5 +- Sources/CodexBar/UsageProgressBar.swift | 2 +- .../UsageStore+CodexActiveCredits.swift | 28 +- .../UsageStore+ObservationHelpers.swift | 70 +++ Sources/CodexBar/UsageStore.swift | 71 +-- Sources/CodexBarCLI/TokenAccountCLI.swift | 3 +- .../CodexBarCore/Config/CodexBarConfig.swift | 7 +- .../Host/PTY/TTYCommandRunner.swift | 9 +- .../Codex/CodexDefaultHomeIsolation.swift | 11 + .../Codex/CodexProviderDescriptor.swift | 5 +- .../Providers/Codex/CodexStatusProbe.swift | 27 +- .../Providers/ProviderSettingsSnapshot.swift | 5 +- .../TokenAccountSupportCatalog+Data.swift | 3 +- Sources/CodexBarCore/UsageFetcher.swift | 28 +- .../Vendored/CostUsage/CostUsageScanner.swift | 29 +- .../CodexActiveCreditsTests.swift | 54 ++- .../CostUsageScannerBreakdownTests.swift | 64 +++ .../MenuCardModelOpenRouterTests.swift | 456 ++++++++++++++++++ Tests/CodexBarTests/MenuCardModelTests.swift | 449 ----------------- .../SettingsStoreAdditionalTests.swift | 16 + Tests/CodexBarTests/StatusMenuTests.swift | 2 +- docs/configuration.md | 4 +- 36 files changed, 1058 insertions(+), 625 deletions(-) create mode 100644 Sources/CodexBar/UsageStore+ObservationHelpers.swift create mode 100644 Sources/CodexBarCore/Providers/Codex/CodexDefaultHomeIsolation.swift create mode 100644 Tests/CodexBarTests/MenuCardModelOpenRouterTests.swift diff --git a/Sources/CodexBar/AccountCostsMenuCardView.swift b/Sources/CodexBar/AccountCostsMenuCardView.swift index dfa3f8af5..6eb98bb1d 100644 --- a/Sources/CodexBar/AccountCostsMenuCardView.swift +++ b/Sources/CodexBar/AccountCostsMenuCardView.swift @@ -43,7 +43,7 @@ struct AccountCostsMenuCardView: View { Divider() .padding(.horizontal, 16) - if self.isLoading && self.entries.isEmpty { + if self.isLoading, self.entries.isEmpty { HStack(spacing: 8) { ProgressView() .controlSize(.small) diff --git a/Sources/CodexBar/FlowLayout.swift b/Sources/CodexBar/FlowLayout.swift index feb75248f..661c97d05 100644 --- a/Sources/CodexBar/FlowLayout.swift +++ b/Sources/CodexBar/FlowLayout.swift @@ -13,9 +13,9 @@ struct FlowLayout: Layout { for subview in subviews { let size = subview.sizeThatFits(.unspecified) - let neededWidth = isFirstInRow ? size.width : spacing + size.width - if !isFirstInRow && currentX + neededWidth > maxWidth { - totalHeight += currentRowHeight + spacing + let neededWidth = isFirstInRow ? size.width : self.spacing + size.width + if !isFirstInRow, currentX + neededWidth > maxWidth { + totalHeight += currentRowHeight + self.spacing currentX = size.width currentRowHeight = size.height isFirstInRow = false @@ -38,16 +38,16 @@ struct FlowLayout: Layout { for subview in subviews { let size = subview.sizeThatFits(.unspecified) - let neededWidth = isFirstInRow ? size.width : spacing + size.width - if !isFirstInRow && currentX - bounds.minX + neededWidth > maxWidth { - currentY += currentRowHeight + spacing + let neededWidth = isFirstInRow ? size.width : self.spacing + size.width + if !isFirstInRow, currentX - bounds.minX + neededWidth > maxWidth { + currentY += currentRowHeight + self.spacing currentX = bounds.minX currentRowHeight = size.height subview.place(at: CGPoint(x: currentX, y: currentY), proposal: .unspecified) currentX += size.width isFirstInRow = false } else { - if !isFirstInRow { currentX += spacing } + if !isFirstInRow { currentX += self.spacing } subview.place(at: CGPoint(x: currentX, y: currentY), proposal: .unspecified) currentX += size.width currentRowHeight = max(currentRowHeight, size.height) diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 66061b61d..b841ad779 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -329,11 +329,10 @@ struct MenuDescriptor { let loginAction = self.switchAccountTarget(for: provider, store: store) let hasAccount = self.hasAccount(for: provider, store: store, account: account) // For Codex, switching is done via account tabs — this action is only for adding new accounts. - let accountLabel: String - if targetProvider == .codex { - accountLabel = "Add Account..." + let accountLabel: String = if targetProvider == .codex { + "Add Account..." } else { - accountLabel = hasAccount ? "Switch Account..." : "Add Account..." + hasAccount ? "Switch Account..." : "Add Account..." } entries.append(.action(accountLabel, loginAction)) } diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index df20d3b38..7dcdfedb7 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -18,6 +18,9 @@ struct ProviderDetailView: View { let onCopyError: (String) -> Void let onRefresh: () -> Void + /// Width of the scroll view’s content column (drives Codex token switcher — must not use `detailMaxWidth` there). + @State private var measuredDetailContentWidth: CGFloat = 0 + static func metricTitle(provider: UsageProvider, metric: UsageMenuCardView.Model.Metric) -> String { UsageMenuCardView.popupMetricTitle(provider: provider, metric: metric) } @@ -78,11 +81,14 @@ struct ProviderDetailView: View { isEnabled: self.isEnabled, labelWidth: labelWidth, accountSwitcher: { + let identity = self.codexUsageAccountSwitcherIdentity + let widthKey = String(Int(self.codexAccountSwitcherLayoutWidth)) + let switcherID = "\(identity)-\(widthKey)" TokenAccountSwitcherRepresentable( accounts: accounts, defaultAccountLabel: defaultLabel, selectedIndex: displaySelection, - width: ProviderSettingsMetrics.detailMaxWidth, + width: self.codexAccountSwitcherLayoutWidth, onSelect: { index in self.settings.setActiveTokenAccountIndex(index, for: .codex) Task { @MainActor in @@ -91,7 +97,7 @@ struct ProviderDetailView: View { } } }) - .id(self.codexUsageAccountSwitcherIdentity) + .id(switcherID) .frame(height: TokenAccountSwitcherView.preferredHeight( accounts: accounts, defaultAccountLabel: defaultLabel)) @@ -139,8 +145,7 @@ struct ProviderDetailView: View { ProviderSettingsToggleRowView(toggle: toggle) } if self.provider == .codex { - Text( - "The primary account is whichever identity Codex has configured in ~/.codex on this Mac. Other rows in Accounts are separate credentials/folders. “Menu bar account” chooses which one CodexBar shows in the menu bar.") + Text(self.codexOptionsFooterExplanation) .font(.caption2) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -149,7 +154,19 @@ struct ProviderDetailView: View { } } } - .frame(maxWidth: ProviderSettingsMetrics.detailMaxWidth, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + GeometryReader { proxy in + Color.clear.preference( + key: DetailContentWidthPreference.self, + value: proxy.size.width) + }) + .onPreferenceChange(DetailContentWidthPreference.self) { width in + guard width > 1 else { return } + if abs(width - self.measuredDetailContentWidth) > 0.5 { + self.measuredDetailContentWidth = width + } + } .padding(.vertical, 12) .padding(.horizontal, 8) } @@ -173,12 +190,35 @@ struct ProviderDetailView: View { !self.settingsToggles.isEmpty || !self.optionsSectionPickers.isEmpty } + private var codexOptionsFooterExplanation: String { + if self.settings.codexExplicitAccountsOnly { + return """ + CodexBar accounts only is on: ~/.codex is not used as an implicit account. \ + Add identities under Accounts (OAuth, API key, or manual CODEX_HOME path). \ + “Menu bar account” chooses which row drives the menu bar. + """ + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespaces) + } + return """ + The primary account is whichever identity Codex has configured in ~/.codex on this Mac. \ + Other rows in Accounts are separate credentials/folders. \ + “Menu bar account” chooses which one CodexBar shows in the menu bar. + """ + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespaces) + } + /// When Codex has more than one selectable account, summary email/plan reflect only the active fetch — hide to /// avoid confusion. private var codexHidesHeaderAccountAndPlan: Bool { guard self.provider == .codex else { return false } - let hasPrimary = CodexProviderImplementation().tokenAccountDefaultLabel(settings: self.settings) != nil + let hasPrimary = !self.settings.codexExplicitAccountsOnly && + CodexProviderImplementation().tokenAccountDefaultLabel(settings: self.settings) != nil let addedCount = self.settings.tokenAccounts(for: .codex).count + if self.settings.codexExplicitAccountsOnly { + return addedCount >= 2 + } return (hasPrimary ? 1 : 0) + addedCount >= 2 } @@ -186,6 +226,9 @@ struct ProviderDetailView: View { private var codexShowsUsageAccountSwitcher: Bool { guard self.provider == .codex else { return false } let accounts = self.settings.tokenAccounts(for: .codex) + if self.settings.codexExplicitAccountsOnly { + return accounts.count >= 2 + } let defaultLabel = CodexProviderImplementation().tokenAccountDefaultLabel(settings: self.settings) return (accounts.count >= 1 && defaultLabel != nil) || accounts.count > 1 } @@ -197,6 +240,14 @@ struct ProviderDetailView: View { return "\(self.settings.configRevision)-\(ids)-\(display)" } + /// `TokenAccountSwitcherView` uses a fixed AppKit width; it must match the providers pane column (~400pt), not + /// `detailMaxWidth` (640). + private var codexAccountSwitcherLayoutWidth: CGFloat { + let measured = self.measuredDetailContentWidth + let column = measured > 1 ? measured : 400 + return max(220, column - 16) + } + /// Display name for the account whose usage/cost is shown (token selection or primary or menu card email). private var costSectionAccountLabel: String? { let provider = self.provider @@ -257,6 +308,19 @@ struct ProviderDetailView: View { } } +private enum DetailContentWidthPreference: PreferenceKey { + static var defaultValue: CGFloat { + 0 + } + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + let next = nextValue() + if next > 0 { + value = next + } + } +} + @MainActor private struct ProviderDetailHeaderView: View { let provider: UsageProvider diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 794bed036..d54fca5f5 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -55,6 +55,8 @@ struct ProviderSettingsToggleRowView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { + // Long subtitles must wrap within the pane width; otherwise the HStack grows past the ScrollView + // and the trailing switch is clipped (looks like “labels only, no toggles”). HStack(alignment: .firstTextBaseline, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(self.toggle.title) @@ -63,11 +65,14 @@ struct ProviderSettingsToggleRowView: View { .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.leading) } - Spacer(minLength: 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + Toggle("", isOn: self.toggle.binding) .labelsHidden() .toggleStyle(.switch) + .fixedSize(horizontal: true, vertical: false) } if self.toggle.binding.wrappedValue { @@ -147,7 +152,9 @@ struct ProviderSettingsPickerRowView: View { Text(subtitle) .font(.footnote) .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } } .disabled(!isEnabled) @@ -231,13 +238,28 @@ struct ProviderSettingsTokenAccountsRowView: View { @State private var signInProgress: String = "" @State private var signInError: String = "" /// ID of the token account currently being renamed (nil = none). - @State private var renamingAccountID: UUID? = nil + @State private var renamingAccountID: UUID? /// Whether the default account tab is being renamed. @State private var renamingDefault: Bool = false /// Current text inside the active rename field. @State private var renameText: String = "" @FocusState private var renameFieldFocused: Bool + /// Explainer rows for Codex when the implicit ~/.codex primary tab is shown (`show` = discovery UX). + private var useCodexDiscoveryHints: Bool { + self.descriptor.provider == .codex && !self.descriptor.codexExplicitAccountsOnly + } + + private var codexAccountsFooterHint: String { + if self.descriptor.codexExplicitAccountsOnly { + return "Only one account drives the menu bar at a time. Choose it under Options → “Menu bar account”. " + + "Other toggles (Buy Credits, web extras, etc.) are under Options too." + } + return "Only one account is active at a time. Choose “Menu bar account” under Options below. " + + "The house row is your primary ~/.codex sign-in; added rows use a separate OAuth folder or API key. " + + "Buy Credits is also under Options." + } + var body: some View { VStack(alignment: .leading, spacing: 8) { Text(self.descriptor.title) @@ -247,7 +269,9 @@ struct ProviderSettingsTokenAccountsRowView: View { Text(self.descriptor.subtitle) .font(.footnote) .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } let accounts = self.descriptor.accounts() @@ -269,11 +293,12 @@ struct ProviderSettingsTokenAccountsRowView: View { accounts: accounts, selectedIndex: selectedIndex) if self.descriptor.provider == .codex { - Text( - "Only one account is active at a time. Choose “Menu bar account” under Options below. The house row is your primary ~/.codex sign-in; added rows use a separate OAuth folder or API key. Buy Credits is also under Options.") + Text(self.codexAccountsFooterHint) .font(.caption2) .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } } @@ -303,7 +328,8 @@ struct ProviderSettingsTokenAccountsRowView: View { self.signInSection(loginAction: loginAction, addAccount: self.descriptor.addAccount) } else { Text( - "Browser OAuth requires the Codex CLI. You can still add an account with an API key (other tab).") + "Browser OAuth requires the Codex CLI. " + + "You can still add an account with an API key (other tab).") .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -330,6 +356,7 @@ struct ProviderSettingsTokenAccountsRowView: View { .controlSize(.small) } } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } private func accountTabsView( @@ -361,8 +388,7 @@ struct ProviderSettingsTokenAccountsRowView: View { @ViewBuilder private func defaultAccountTab(label: String, isActive: Bool) -> some View { let isRenaming = self.renamingDefault && self.descriptor.renameDefaultAccount != nil - let showCodexHints = self.descriptor.provider == .codex - let highlightSelection = !showCodexHints + let highlightSelection = !self.useCodexDiscoveryHints let rowActive = highlightSelection && isActive VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { @@ -379,21 +405,24 @@ struct ProviderSettingsTokenAccountsRowView: View { .onSubmit { self.commitRenameDefault() } } else { Group { - if showCodexHints { + if self.useCodexDiscoveryHints { Text(label) .font(.footnote.weight(.medium)) - .lineLimit(1) + .lineLimit(2) + .multilineTextAlignment(.leading) .foregroundStyle(.primary) } else { - Button(action: { self.descriptor.setActiveIndex(-1) }) { + Button(action: { self.descriptor.setActiveIndex(-1) }, label: { Text(label) .font(.footnote.weight(.medium)) - .lineLimit(1) - } + .lineLimit(2) + .multilineTextAlignment(.leading) + }) .buttonStyle(.plain) .foregroundStyle(rowActive ? Color.accentColor : .primary) } } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) Spacer(minLength: 8) if rowActive { self.menuBarActiveBadge() @@ -406,15 +435,15 @@ struct ProviderSettingsTokenAccountsRowView: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { self.renameFieldFocused = true } - }) { + }, label: { Image(systemName: "pencil") .foregroundStyle(.secondary) .imageScale(.small) - } + }) .buttonStyle(.plain) .help("Rename tab") } - if !showCodexHints, !isActive, !isRenaming { + if !self.useCodexDiscoveryHints, !isActive, !isRenaming { Button("Use") { self.descriptor.setActiveIndex(-1) } @@ -425,10 +454,12 @@ struct ProviderSettingsTokenAccountsRowView: View { } } } - if showCodexHints, !isRenaming { + if self.useCodexDiscoveryHints, !isRenaming { Text("Primary · ~/.codex on this Mac") .font(.system(size: 10)) .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } } .padding(.horizontal, 10) @@ -462,8 +493,7 @@ struct ProviderSettingsTokenAccountsRowView: View { @ViewBuilder private func accountTab(account: ProviderTokenAccount, index: Int, isActive: Bool) -> some View { let isRenaming = self.renamingAccountID == account.id - let showCodexHints = self.descriptor.provider == .codex - let highlightSelection = !showCodexHints + let highlightSelection = !self.useCodexDiscoveryHints let rowActive = highlightSelection && isActive VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { @@ -480,21 +510,24 @@ struct ProviderSettingsTokenAccountsRowView: View { .onSubmit { self.commitRename(account: account) } } else { Group { - if showCodexHints { + if self.useCodexDiscoveryHints { Text(account.displayName) .font(.footnote.weight(.medium)) - .lineLimit(1) + .lineLimit(2) + .multilineTextAlignment(.leading) .foregroundStyle(.primary) } else { - Button(action: { self.descriptor.setActiveIndex(index) }) { + Button(action: { self.descriptor.setActiveIndex(index) }, label: { Text(account.displayName) .font(.footnote.weight(.medium)) - .lineLimit(1) - } + .lineLimit(2) + .multilineTextAlignment(.leading) + }) .buttonStyle(.plain) .foregroundStyle(rowActive ? Color.accentColor : .primary) } } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) Spacer(minLength: 8) if rowActive { self.menuBarActiveBadge() @@ -506,21 +539,21 @@ struct ProviderSettingsTokenAccountsRowView: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { self.renameFieldFocused = true } - }) { + }, label: { Image(systemName: "pencil") .foregroundStyle(.secondary) .imageScale(.small) - } + }) .buttonStyle(.plain) .help("Rename tab") - Button(action: { self.descriptor.removeAccount(account.id) }) { + Button(action: { self.descriptor.removeAccount(account.id) }, label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary.opacity(0.85)) .imageScale(.small) - } + }) .buttonStyle(.plain) .help("Remove account") - if !showCodexHints, !isActive, !isRenaming { + if !self.useCodexDiscoveryHints, !isActive, !isRenaming { Button("Use") { self.descriptor.setActiveIndex(index) } @@ -531,10 +564,20 @@ struct ProviderSettingsTokenAccountsRowView: View { } } } - if showCodexHints, !isRenaming { - Text("Added account · OAuth folder or API key") - .font(.system(size: 10)) - .foregroundStyle(.tertiary) + if self.descriptor.provider == .codex, !isRenaming { + if self.useCodexDiscoveryHints { + Text("Added account · OAuth folder or API key") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } else if self.descriptor.codexExplicitAccountsOnly { + Text("OAuth folder or API key") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } } } .padding(.horizontal, 10) diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index ceba08702..9812e9dbe 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -180,7 +180,8 @@ enum ProvidersPaneTestHarness { reloadFromDisk: {}, defaultAccountLabel: nil, renameDefaultAccount: nil, - loginAction: nil) + loginAction: nil, + codexExplicitAccountsOnly: false) return ProviderListTestDescriptors( toggle: toggle, diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index c24c23d4c..93f57d398 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -181,10 +181,18 @@ struct ProvidersPane: View { let renameDefaultAccount: ((_ newLabel: String) -> Void)? = impl.map { _ in { newLabel in self.settings.setDefaultAccountLabel(provider: provider, label: newLabel) } } + let codexExplicit = provider == .codex && self.settings.codexExplicitAccountsOnly + let accountsSubtitle: String = { + if codexExplicit { + return "Each row is its own sign-in: OAuth uses a separate credentials folder (CODEX_HOME), " + + "or use an API key. ~/.codex is not listed here while “CodexBar accounts only” is on." + } + return support.subtitle + }() return ProviderSettingsTokenAccountsDescriptor( id: "token-accounts-\(provider.rawValue)", title: support.title, - subtitle: support.subtitle, + subtitle: accountsSubtitle, placeholder: support.placeholder, isSecureToken: isSecureToken, provider: provider, @@ -239,7 +247,8 @@ struct ProvidersPane: View { }, defaultAccountLabel: defaultAccountLabel, renameDefaultAccount: renameDefaultAccount, - loginAction: loginAction) + loginAction: loginAction, + codexExplicitAccountsOnly: codexExplicit) } private func makeSettingsContext(provider: UsageProvider) -> ProviderSettingsContext { diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index 1e26c4c86..050939e4c 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -107,6 +107,15 @@ struct ProviderRegistry { env[key] = value } } + if provider == .codex, + settings.codexExplicitAccountsOnly, + account == nil, + tokenOverride == nil + { + var patched = env + patched["CODEX_HOME"] = CodexDefaultHomeIsolation.sentinelCodexHomePath() + return patched + } return env } } diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index db3cf8453..d7a82b11b 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -21,6 +21,7 @@ struct CodexProviderImplementation: ProviderImplementation { _ = settings.codexUsageDataSource _ = settings.codexCookieSource _ = settings.codexCookieHeader + _ = settings.codexExplicitAccountsOnly } @MainActor @@ -75,7 +76,31 @@ struct CodexProviderImplementation: ProviderImplementation { get: { context.settings.codexBuyCreditsMenuEnabled }, set: { context.settings.codexBuyCreditsMenuEnabled = $0 }) + let explicitAccountsBinding = Binding( + get: { context.settings.codexExplicitAccountsOnly }, + set: { newValue in + context.settings.codexExplicitAccountsOnly = newValue + Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await context.store.refreshProvider(.codex, allowDisabled: true) + } + } + }) + return [ + ProviderSettingsToggleDescriptor( + id: "codex-explicit-accounts-only", + title: "CodexBar accounts only", + subtitle: + "Ignore ~/.codex as an implicit account. Use only rows under Accounts " + + "(OAuth, API key, or manual CODEX_HOME path).", + binding: explicitAccountsBinding, + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-buy-credits-menu", title: "Show Buy Credits in menu", @@ -99,7 +124,9 @@ struct CodexProviderImplementation: ProviderImplementation { let alert = NSAlert() alert.messageText = "No API key configured" alert.informativeText = - "Buy Credits opens the ChatGPT billing page. You don’t have an API key saved for Codex — only OAuth-based usage is configured. You can still continue; add an API key in Codex settings if you use one." + "Buy Credits opens the ChatGPT billing page. You don’t have an API key saved for Codex — " + + "only OAuth-based usage is configured. You can still continue; add an API key in Codex " + + "settings if you use one." alert.alertStyle = .informational alert.addButton(withTitle: "OK") alert.runModal() @@ -167,13 +194,12 @@ struct CodexProviderImplementation: ProviderImplementation { if self.tokenAccountDefaultLabel(settings: context.settings) != nil { let custom = context.settings.providerConfig(for: .codex)?.defaultAccountLabel? .trimmingCharacters(in: .whitespacesAndNewlines) - let title: String - if let custom, !custom.isEmpty { - title = custom + let title: String = if let custom, !custom.isEmpty { + custom } else if let email = self.tokenAccountDefaultLabel(settings: context.settings) { - title = email + email } else { - title = "Primary" + "Primary" } menuBarAccountOptions.append( ProviderSettingsPickerOption( @@ -210,6 +236,9 @@ struct CodexProviderImplementation: ProviderImplementation { isVisible: { let accounts = context.settings.tokenAccounts(for: .codex) let hasPrimary = self.tokenAccountDefaultLabel(settings: context.settings) != nil + if context.settings.codexExplicitAccountsOnly { + return accounts.count >= 1 + } return (hasPrimary ? 1 : 0) + accounts.count >= 2 }, onChange: { _ in @@ -303,12 +332,14 @@ struct CodexProviderImplementation: ProviderImplementation { @MainActor func tokenAccountDefaultLabel(settings: SettingsStore?) -> String? { + if settings?.codexExplicitAccountsOnly == true { return nil } + guard let credentials = try? CodexOAuthCredentialsStore.load() else { return nil } + if let custom = settings?.providerConfig(for: .codex)?.defaultAccountLabel, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return custom } - guard let credentials = try? CodexOAuthCredentialsStore.load() else { return nil } if let idToken = credentials.idToken, let payload = UsageFetcher.parseJWT(idToken) @@ -338,11 +369,10 @@ struct CodexProviderImplementation: ProviderImplementation { func tokenAccountLoginAction(context _: ProviderSettingsContext) -> (( _ setProgress: @escaping @MainActor (String) -> Void, - _ addAccount: @escaping @MainActor (String, String) -> Void - ) async -> Bool)? + _ addAccount: @escaping @MainActor (String, String) -> Void) async -> Bool)? { - return { @MainActor setProgress, addAccount in - let accountsDir = (("~/.codex-accounts") as NSString).expandingTildeInPath + { @MainActor setProgress, addAccount in + let accountsDir = ("~/.codex-accounts" as NSString).expandingTildeInPath let uniqueDir = "\(accountsDir)/\(UUID().uuidString.prefix(8))" try? FileManager.default.createDirectory( atPath: uniqueDir, diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 4981696f0..6b0a61202 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -2,7 +2,29 @@ import CodexBarCore import Foundation extension SettingsStore { - /// When `true` (default), shows "Buy Credits…" in the Codex menu. Persisted per-provider; `nil` in config means enabled. + /// When `true`, CodexBar never treats `~/.codex` as an implicit menu-bar account; add accounts under Accounts + /// (OAuth, API key, or path). + var codexExplicitAccountsOnly: Bool { + get { self.configSnapshot.providerConfig(for: .codex)?.codexExplicitAccountsOnly ?? false } + set { + self.updateProviderConfig(provider: .codex) { entry in + entry.codexExplicitAccountsOnly = newValue + if newValue, + let token = entry.tokenAccounts, + !token.accounts.isEmpty, + token.activeIndex < 0 + { + entry.tokenAccounts = ProviderTokenAccountData( + version: token.version, + accounts: token.accounts, + activeIndex: 0) + } + } + } + } + + /// When `true` (default), shows "Buy Credits…" in the Codex menu. Persisted per-provider; `nil` in config means + /// enabled. var codexBuyCreditsMenuEnabled: Bool { get { self.configSnapshot.providerConfig(for: .codex)?.buyCreditsMenuEnabled ?? true } set { @@ -62,7 +84,8 @@ extension SettingsStore { ProviderSettingsSnapshot.CodexProviderSettings( usageDataSource: self.codexUsageDataSource, cookieSource: self.codexSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride)) + manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride), + explicitAccountsOnly: self.codexExplicitAccountsOnly) } private static func codexUsageDataSource(from source: ProviderSourceMode?) -> CodexUsageDataSource { diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift index 0d19a4592..a473e33e2 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift @@ -87,8 +87,7 @@ protocol ProviderImplementation: Sendable { func tokenAccountLoginAction(context: ProviderSettingsContext) -> (( _ setProgress: @escaping @MainActor (String) -> Void, - _ addAccount: @escaping @MainActor (String, String) -> Void - ) async -> Bool)? + _ addAccount: @escaping @MainActor (String, String) -> Void) async -> Bool)? } extension ProviderImplementation { @@ -191,8 +190,7 @@ extension ProviderImplementation { func tokenAccountLoginAction(context _: ProviderSettingsContext) -> (( _ setProgress: @escaping @MainActor (String) -> Void, - _ addAccount: @escaping @MainActor (String, String) -> Void - ) async -> Bool)? + _ addAccount: @escaping @MainActor (String, String) -> Void) async -> Bool)? { nil } diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index d249bbd81..20fc58019 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -113,8 +113,9 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { /// true on success or false on failure/cancellation. let loginAction: (( _ setProgress: @escaping @MainActor (String) -> Void, - _ addAccount: @escaping @MainActor (String, String) -> Void - ) async -> Bool)? + _ addAccount: @escaping @MainActor (String, String) -> Void) async -> Bool)? + /// Codex only: mirrors **CodexBar accounts only**; hides ~/.codex primary tab and adjusts copy. + let codexExplicitAccountsOnly: Bool } /// Which detail section a provider settings picker appears in. diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 44a62113b..8bcab8203 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -7,6 +7,9 @@ extension SettingsStore { /// For Codex, if the user had primary selected (`activeIndex < 0`) but `~/.codex` has no usable credentials, /// this returns `false` so usage/credits/costs follow the visible add-on tab. func isDefaultTokenAccountActive(for provider: UsageProvider) -> Bool { + if provider == .codex, self.codexExplicitAccountsOnly { + return false + } guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return true } @@ -122,11 +125,16 @@ extension SettingsStore { if filtered.isEmpty { entry.tokenAccounts = nil } else { - let clamped = min(max(data.activeIndex, 0), filtered.count - 1) + let newActiveIndex: Int = if data.activeIndex < 0 { + // Keep "primary / default credentials" selected; do not coerce -1 to first add-on. + -1 + } else { + min(max(data.activeIndex, 0), filtered.count - 1) + } entry.tokenAccounts = ProviderTokenAccountData( version: data.version, accounts: filtered, - activeIndex: clamped) + activeIndex: newActiveIndex) } } CodexBarLog.logger(LogCategories.tokenAccounts).info( diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index c2b2fbaff..f3f15b569 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -167,7 +167,7 @@ extension StatusItemController { private func runTokenAccountLogin(provider: UsageProvider) async -> Bool { guard provider == .codex else { return false } - let accountsDir = (("~/.codex-accounts") as NSString).expandingTildeInPath + let accountsDir = ("~/.codex-accounts" as NSString).expandingTildeInPath let uniqueDir = "\(accountsDir)/\(UUID().uuidString.prefix(8))" try? FileManager.default.createDirectory( atPath: uniqueDir, diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 5b5fabbd0..4319da103 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -304,7 +304,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin return "\(p.rawValue):\(count):\(active):\(display)" }.joined(separator: "|") let costSig = - "\(s.isCostUsageEffectivelyEnabled(for: .codex))|\(s.isCostUsageEffectivelyEnabled(for: .claude))|\(s.isCostUsageEffectivelyEnabled(for: .vertexai))" + "\(s.isCostUsageEffectivelyEnabled(for: .codex))|" + + "\(s.isCostUsageEffectivelyEnabled(for: .claude))|" + + "\(s.isCostUsageEffectivelyEnabled(for: .vertexai))" return [ order, "\(s.mergeIcons)", @@ -313,6 +315,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin "\(s.showAllTokenAccountsInMenu)", "\(s.openAIWebAccessEnabled)", "\(s.codexBuyCreditsMenuEnabled)", + "\(s.codexExplicitAccountsOnly)", overview, "\(s.mergedMenuLastSelectedWasOverview)", selectedMenu, diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index 624b5dbf8..aaa463d85 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -91,7 +91,7 @@ struct UsageProgressBar: View { /// Solid pace marker for non-menu contexts (avoids `destinationOut` punch-through on the fill). private func simplePaceTip(width: CGFloat, height: CGFloat) -> some View { let isDeficit = self.paceOnTop == false - let fill: Color = if isDeficit { + let fill = if isDeficit { Color.red.opacity(0.92) } else { Color.green diff --git a/Sources/CodexBar/UsageStore+CodexActiveCredits.swift b/Sources/CodexBar/UsageStore+CodexActiveCredits.swift index 4932610c8..34d72cae0 100644 --- a/Sources/CodexBar/UsageStore+CodexActiveCredits.swift +++ b/Sources/CodexBar/UsageStore+CodexActiveCredits.swift @@ -9,7 +9,11 @@ extension UsageStore { return (self.credits, self.lastCreditsError, false) } if self.settings.isDefaultTokenAccountActive(for: .codex) { - return (self.credits, self.lastCreditsError, false) + return Self.resolvePrimaryCodexCreditsFromOAuth( + entries: self.allAccountCredits[.codex] ?? [], + rpcCredits: self.credits, + rpcError: self.lastCreditsError, + costRefreshInFlight: self.accountCostRefreshInFlight.contains(.codex)) } let index = data.clampedActiveIndex() guard index >= 0, index < data.accounts.count else { @@ -48,6 +52,28 @@ extension UsageStore { return remaining } + /// Merge OAuth cost-row data for the primary (`id == "default"`) account with RPC/dashboard credits. + static func resolvePrimaryCodexCreditsFromOAuth( + entries: [AccountCostEntry], + rpcCredits: CreditsSnapshot?, + rpcError: String?, + costRefreshInFlight: Bool) -> (snapshot: CreditsSnapshot?, error: String?, unlimited: Bool) + { + let entry = entries.first { $0.id == "default" } + if let entry { + let trimmedErr = entry.error?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedErr.isEmpty { + return (nil, Self.shortCodexOAuthErrorMessage(trimmedErr), false) + } + if entry.isUnlimited { + return (nil, nil, true) + } + } else if costRefreshInFlight { + return (nil, "Loading credits…", false) + } + return (rpcCredits, rpcError, false) + } + private static func shortCodexOAuthErrorMessage(_ error: String) -> String { if error.contains("not found") || error.contains("notFound") { return "Not signed in" } if error.localizedCaseInsensitiveContains("unauthorized") || error.contains("401") { return "Token expired" } diff --git a/Sources/CodexBar/UsageStore+ObservationHelpers.swift b/Sources/CodexBar/UsageStore+ObservationHelpers.swift new file mode 100644 index 000000000..3b77c8885 --- /dev/null +++ b/Sources/CodexBar/UsageStore+ObservationHelpers.swift @@ -0,0 +1,70 @@ +import CodexBarCore +import Foundation +import Observation +import SweetCookieKit + +// MARK: - Observation helpers + +@MainActor +extension UsageStore { + var menuObservationToken: Int { + _ = self.snapshots + _ = self.errors + _ = self.lastSourceLabels + _ = self.lastFetchAttempts + _ = self.accountSnapshots + _ = self.tokenSnapshots + _ = self.tokenErrors + _ = self.tokenRefreshInFlight + _ = self.credits + _ = self.lastCreditsError + _ = self.allAccountCredits + _ = self.openAIDashboard + _ = self.lastOpenAIDashboardError + _ = self.openAIDashboardRequiresLogin + _ = self.openAIDashboardCookieImportStatus + _ = self.openAIDashboardCookieImportDebugLog + _ = self.versions + _ = self.isRefreshing + _ = self.refreshingProviders + _ = self.pathDebugInfo + _ = self.statuses + _ = self.probeLogs + _ = self.historicalPaceRevision + return 0 + } + + func observeSettingsChanges() { + withObservationTracking { + _ = self.settings.refreshFrequency + _ = self.settings.statusChecksEnabled + _ = self.settings.sessionQuotaNotificationsEnabled + _ = self.settings.usageBarsShowUsed + _ = self.settings.costUsageEnabled + _ = self.settings.randomBlinkEnabled + _ = self.settings.configRevision + for implementation in ProviderCatalog.all { + implementation.observeSettings(self.settings) + } + _ = self.settings.showAllTokenAccountsInMenu + _ = self.settings.tokenAccountsByProvider + _ = self.settings.mergeIcons + _ = self.settings.selectedMenuProvider + _ = self.settings.debugLoadingPattern + _ = self.settings.debugKeepCLISessionsAlive + _ = self.settings.historicalTrackingEnabled + _ = self.settings.codexExplicitAccountsOnly + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.observeSettingsChanges() + self.probeLogs = [:] + guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } + self.startTimer() + self.updateProviderRuntimes() + await self.refreshHistoricalDatasetIfNeeded() + await self.refresh() + } + } + } +} diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 045ebeaef..d63fa34ae 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -4,71 +4,6 @@ import Foundation import Observation import SweetCookieKit -// MARK: - Observation helpers - -@MainActor -extension UsageStore { - var menuObservationToken: Int { - _ = self.snapshots - _ = self.errors - _ = self.lastSourceLabels - _ = self.lastFetchAttempts - _ = self.accountSnapshots - _ = self.tokenSnapshots - _ = self.tokenErrors - _ = self.tokenRefreshInFlight - _ = self.credits - _ = self.lastCreditsError - _ = self.allAccountCredits - _ = self.openAIDashboard - _ = self.lastOpenAIDashboardError - _ = self.openAIDashboardRequiresLogin - _ = self.openAIDashboardCookieImportStatus - _ = self.openAIDashboardCookieImportDebugLog - _ = self.versions - _ = self.isRefreshing - _ = self.refreshingProviders - _ = self.pathDebugInfo - _ = self.statuses - _ = self.probeLogs - _ = self.historicalPaceRevision - return 0 - } - - func observeSettingsChanges() { - withObservationTracking { - _ = self.settings.refreshFrequency - _ = self.settings.statusChecksEnabled - _ = self.settings.sessionQuotaNotificationsEnabled - _ = self.settings.usageBarsShowUsed - _ = self.settings.costUsageEnabled - _ = self.settings.randomBlinkEnabled - _ = self.settings.configRevision - for implementation in ProviderCatalog.all { - implementation.observeSettings(self.settings) - } - _ = self.settings.showAllTokenAccountsInMenu - _ = self.settings.tokenAccountsByProvider - _ = self.settings.mergeIcons - _ = self.settings.selectedMenuProvider - _ = self.settings.debugLoadingPattern - _ = self.settings.debugKeepCLISessionsAlive - _ = self.settings.historicalTrackingEnabled - } onChange: { [weak self] in - Task { @MainActor [weak self] in - guard let self else { return } - self.observeSettingsChanges() - self.probeLogs = [:] - guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } - self.startTimer() - self.updateProviderRuntimes() - await self.refreshHistoricalDatasetIfNeeded() - await self.refresh() - } - } - } -} - @MainActor @Observable final class UsageStore { @@ -162,7 +97,7 @@ final class UsageStore { @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 - @ObservationIgnored private let startupBehavior: StartupBehavior + @ObservationIgnored let startupBehavior: StartupBehavior init( fetcher: UsageFetcher, @@ -382,7 +317,7 @@ final class UsageStore { await runtime.perform(action: action, context: context) } - private func updateProviderRuntimes() { + func updateProviderRuntimes() { for (provider, runtime) in self.providerRuntimes { let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self) if self.isEnabled(provider) { @@ -449,7 +384,7 @@ final class UsageStore { self.observeSettingsChanges() } - private func startTimer() { + func startTimer() { self.timerTask?.cancel() guard let wait = self.settings.refreshFrequency.seconds else { return } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 937b37aa0..51d3b9e45 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -86,7 +86,8 @@ struct TokenAccountCLIContext { codex: ProviderSettingsSnapshot.CodexProviderSettings( usageDataSource: .auto, cookieSource: cookieSource, - manualCookieHeader: cookieHeader)) + manualCookieHeader: cookieHeader, + explicitAccountsOnly: config?.codexExplicitAccountsOnly ?? false)) case .claude: let routing = self.claudeCredentialRouting(account: account, config: config) let claudeSource: ClaudeUsageDataSource = routing.isOAuth ? .oauth : .auto diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index 2f8dcbf05..e59e7ea1b 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -87,6 +87,9 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { public var defaultAccountLabel: String? /// When `false`, hides the menu-bar "Buy Credits…" action for Codex. `nil` means enabled (default). public var buyCreditsMenuEnabled: Bool? + /// When `true` (Codex only), CodexBar does not treat `~/.codex` as an implicit account; usage requires Accounts + /// added in Settings (OAuth, API key, or manual `CODEX_HOME`). + public var codexExplicitAccountsOnly: Bool? public init( id: UsageProvider, @@ -100,7 +103,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { workspaceID: String? = nil, tokenAccounts: ProviderTokenAccountData? = nil, defaultAccountLabel: String? = nil, - buyCreditsMenuEnabled: Bool? = nil) + buyCreditsMenuEnabled: Bool? = nil, + codexExplicitAccountsOnly: Bool? = nil) { self.id = id self.enabled = enabled @@ -114,6 +118,7 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.tokenAccounts = tokenAccounts self.defaultAccountLabel = defaultAccountLabel self.buyCreditsMenuEnabled = buyCreditsMenuEnabled + self.codexExplicitAccountsOnly = codexExplicitAccountsOnly } public var sanitizedAPIKey: String? { diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index cdab14aba..4fb3ea320 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -101,6 +101,8 @@ public struct TTYCommandRunner { public var stopOnURL: Bool public var stopOnSubstrings: [String] public var settleAfterStop: TimeInterval + /// When set, used as the base for `enrichedEnvironment` instead of `ProcessInfo.processInfo.environment`. + public var baseProcessEnvironment: [String: String]? public init( rows: UInt16 = 50, @@ -114,7 +116,8 @@ public struct TTYCommandRunner { sendOnSubstrings: [String: String] = [:], stopOnURL: Bool = false, stopOnSubstrings: [String] = [], - settleAfterStop: TimeInterval = 0.25) + settleAfterStop: TimeInterval = 0.25, + baseProcessEnvironment: [String: String]? = nil) { self.rows = rows self.cols = cols @@ -128,6 +131,7 @@ public struct TTYCommandRunner { self.stopOnURL = stopOnURL self.stopOnSubstrings = stopOnSubstrings self.settleAfterStop = settleAfterStop + self.baseProcessEnvironment = baseProcessEnvironment } } @@ -375,7 +379,8 @@ public struct TTYCommandRunner { proc.standardError = secondaryHandle // Use login-shell PATH when available, but keep the caller’s environment (HOME, LANG, etc.) so // the CLIs can find their auth/config files. - var env = Self.enrichedEnvironment() + var env = Self.enrichedEnvironment( + baseEnv: options.baseProcessEnvironment ?? ProcessInfo.processInfo.environment) if let workingDirectory = options.workingDirectory { proc.currentDirectoryURL = workingDirectory env["PWD"] = workingDirectory.path diff --git a/Sources/CodexBarCore/Providers/Codex/CodexDefaultHomeIsolation.swift b/Sources/CodexBarCore/Providers/Codex/CodexDefaultHomeIsolation.swift new file mode 100644 index 000000000..c261161c6 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexDefaultHomeIsolation.swift @@ -0,0 +1,11 @@ +import Foundation + +/// When Codex must not fall back to `~/.codex`, subprocess env uses this non-existent `CODEX_HOME`. +public enum CodexDefaultHomeIsolation { + public static func sentinelCodexHomePath(fileManager: FileManager = .default) -> String { + fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("isolated-no-default-codex-home", isDirectory: true) + .path + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift index 2fb67fc99..0a6eddbf2 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift @@ -115,9 +115,10 @@ struct CodexCLIUsageStrategy: ProviderFetchStrategy { func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { let keepAlive = context.settings?.debugKeepCLISessionsAlive ?? false - let usage = try await context.fetcher.loadLatestUsage(keepCLISessionsAlive: keepAlive) + let fetcher = UsageFetcher(environment: context.env) + let usage = try await fetcher.loadLatestUsage(keepCLISessionsAlive: keepAlive) let credits = await context.includeCredits - ? (try? context.fetcher.loadLatestCredits(keepCLISessionsAlive: keepAlive)) + ? (try? fetcher.loadLatestCredits(keepCLISessionsAlive: keepAlive)) : nil return self.makeResult( usage: usage, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index a04aee558..6a1579d19 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -53,21 +53,37 @@ public struct CodexStatusProbe { public var codexBinary: String = "codex" public var timeout: TimeInterval = Self.defaultTimeoutSeconds public var keepCLISessionsAlive: Bool = false + private let processEnvironment: [String: String] + private let isolateForEnvironment: Bool - public init() {} + public init() { + self.codexBinary = "codex" + self.timeout = Self.defaultTimeoutSeconds + self.keepCLISessionsAlive = false + self.processEnvironment = ProcessInfo.processInfo.environment + self.isolateForEnvironment = false + } public init( codexBinary: String = "codex", timeout: TimeInterval = 8.0, - keepCLISessionsAlive: Bool = false) + keepCLISessionsAlive: Bool = false, + processEnvironment: [String: String]? = nil) { self.codexBinary = codexBinary self.timeout = timeout self.keepCLISessionsAlive = keepCLISessionsAlive + if let processEnvironment { + self.processEnvironment = processEnvironment + self.isolateForEnvironment = true + } else { + self.processEnvironment = ProcessInfo.processInfo.environment + self.isolateForEnvironment = false + } } public func fetch() async throws -> CodexStatusSnapshot { - let env = ProcessInfo.processInfo.environment + let env = self.processEnvironment let resolved = BinaryLocator.resolveCodexBinary(env: env, loginPATH: LoginShellPathCache.shared.current) ?? self.codexBinary guard FileManager.default.isExecutableFile(atPath: resolved) || TTYCommandRunner.which(resolved) != nil else { @@ -129,7 +145,7 @@ public struct CodexStatusProbe { timeout: TimeInterval) async throws -> CodexStatusSnapshot { let text: String - if self.keepCLISessionsAlive { + if self.keepCLISessionsAlive, !self.isolateForEnvironment { do { text = try await CodexCLISession.shared.captureStatus( binary: binary, @@ -153,7 +169,8 @@ public struct CodexStatusProbe { rows: rows, cols: cols, timeout: timeout, - extraArgs: ["-s", "read-only", "-a", "untrusted"])) + extraArgs: ["-s", "read-only", "-a", "untrusted"], + baseProcessEnvironment: self.isolateForEnvironment ? self.processEnvironment : nil)) text = result.text } return try Self.parse(text: text) diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index c5d9af4f7..4a6a03dce 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -44,15 +44,18 @@ public struct ProviderSettingsSnapshot: Sendable { public let usageDataSource: CodexUsageDataSource public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? + public let explicitAccountsOnly: Bool public init( usageDataSource: CodexUsageDataSource, cookieSource: ProviderCookieSource, - manualCookieHeader: String?) + manualCookieHeader: String?, + explicitAccountsOnly: Bool = false) { self.usageDataSource = usageDataSource self.cookieSource = cookieSource self.manualCookieHeader = manualCookieHeader + self.explicitAccountsOnly = explicitAccountsOnly } } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 6b12e96df..8ffa80510 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -4,7 +4,8 @@ extension TokenAccountSupportCatalog { static let supportByProvider: [UsageProvider: TokenAccountSupport] = [ .codex: TokenAccountSupport( title: "Codex accounts", - subtitle: "OAuth adds a credentials directory (CODEX_HOME). You can also add an API key account from Settings. Manual paths use ~/.codex-account2 style directories.", + subtitle: "OAuth adds a credentials directory (CODEX_HOME). You can also add an API key account from " + + "Settings. Manual paths use ~/.codex-account2 style directories.", placeholder: "~/.codex-account2", injection: .codexHome, requiresManualCookieSource: false, diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index f2370e9ca..5aaaa9bb4 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -343,7 +343,8 @@ private final class CodexRPCClient: @unchecked Sendable { init( executable: String = "codex", - arguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"]) throws + arguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"], + environment: [String: String] = ProcessInfo.processInfo.environment) throws { var stdoutContinuation: AsyncStream.Continuation! self.stdoutLineStream = AsyncStream { continuation in @@ -351,7 +352,9 @@ private final class CodexRPCClient: @unchecked Sendable { } self.stdoutLineContinuation = stdoutContinuation - let resolvedExec = BinaryLocator.resolveCodexBinary() + let resolvedExec = BinaryLocator.resolveCodexBinary( + env: environment, + loginPATH: LoginShellPathCache.shared.current) ?? TTYCommandRunner.which(executable) guard let resolvedExec else { @@ -359,7 +362,7 @@ private final class CodexRPCClient: @unchecked Sendable { throw RPCWireError.startFailed( "Codex CLI not found. Install with `npm i -g @openai/codex` (or bun) then relaunch CodexBar.") } - var env = ProcessInfo.processInfo.environment + var env = environment env["PATH"] = PathBuilder.effectivePATH( purposes: [.rpc, .nodeTooling], env: env) @@ -527,7 +530,7 @@ public struct UsageFetcher: Sendable { } private func loadRPCUsage() async throws -> UsageSnapshot { - let rpc = try CodexRPCClient() + let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") @@ -561,7 +564,9 @@ public struct UsageFetcher: Sendable { } private func loadTTYUsage(keepCLISessionsAlive: Bool) async throws -> UsageSnapshot { - let status = try await CodexStatusProbe(keepCLISessionsAlive: keepCLISessionsAlive).fetch() + let status = try await CodexStatusProbe( + keepCLISessionsAlive: keepCLISessionsAlive, + processEnvironment: self.environment).fetch() guard let fiveLeft = status.fiveHourPercentLeft, let weekLeft = status.weeklyPercentLeft else { throw UsageError.noRateLimitsFound } @@ -592,7 +597,7 @@ public struct UsageFetcher: Sendable { } private func loadRPCCredits() async throws -> CreditsSnapshot { - let rpc = try CodexRPCClient() + let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") let limits = try await rpc.fetchRateLimits().rateLimits @@ -602,7 +607,9 @@ public struct UsageFetcher: Sendable { } private func loadTTYCredits(keepCLISessionsAlive: Bool) async throws -> CreditsSnapshot { - let status = try await CodexStatusProbe(keepCLISessionsAlive: keepCLISessionsAlive).fetch() + let status = try await CodexStatusProbe( + keepCLISessionsAlive: keepCLISessionsAlive, + processEnvironment: self.environment).fetch() guard let credits = status.credits else { throw UsageError.noRateLimitsFound } return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) } @@ -625,7 +632,7 @@ public struct UsageFetcher: Sendable { public func debugRawRateLimits() async -> String { do { - let rpc = try CodexRPCClient() + let rpc = try CodexRPCClient(environment: self.environment) defer { rpc.shutdown() } try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") let limits = try await rpc.fetchRateLimits() @@ -638,8 +645,9 @@ public struct UsageFetcher: Sendable { public func loadAccountInfo() -> AccountInfo { // Keep using auth.json for quick startup (non-blocking, no RPC spin-up required). - let authURL = URL(fileURLWithPath: self.environment["CODEX_HOME"] ?? "\(NSHomeDirectory())/.codex") - .appendingPathComponent("auth.json") + let homeRoot = self.environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let base = (homeRoot?.isEmpty == false) ? homeRoot! : "\(NSHomeDirectory())/.codex" + let authURL = URL(fileURLWithPath: base).appendingPathComponent("auth.json") guard let data = try? Data(contentsOf: authURL), let auth = try? JSONDecoder().decode(AuthFile.self, from: data), let idToken = auth.tokens?.idToken diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 0e6767e15..fd87dfeb1 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -9,6 +9,9 @@ enum CostUsageScanner { struct Options { var codexSessionsRoot: URL? + /// Extra `…/sessions` directories to scan (e.g. primary `~/.codex/sessions` when it matches the add-on + /// account). + var codexExtraSessionsRoots: [URL] = [] var claudeProjectsRoots: [URL]? var cacheRoot: URL? var refreshMinIntervalSeconds: TimeInterval = 60 @@ -18,12 +21,14 @@ enum CostUsageScanner { init( codexSessionsRoot: URL? = nil, + codexExtraSessionsRoots: [URL] = [], claudeProjectsRoots: [URL]? = nil, cacheRoot: URL? = nil, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, forceRescan: Bool = false) { self.codexSessionsRoot = codexSessionsRoot + self.codexExtraSessionsRoots = codexExtraSessionsRoots self.claudeProjectsRoots = claudeProjectsRoots self.cacheRoot = cacheRoot self.claudeLogProviderFilter = claudeLogProviderFilter @@ -122,11 +127,27 @@ enum CostUsageScanner { } private static func codexSessionsRoots(options: Options) -> [URL] { - let root = self.defaultCodexSessionsRoot(options: options) - if let archived = self.codexArchivedSessionsRoot(sessionsRoot: root) { - return [root, archived] + let baseRoot = self.defaultCodexSessionsRoot(options: options) + var ordered: [URL] = [baseRoot] + ordered.append(contentsOf: options.codexExtraSessionsRoots) + + var seen = Set() + var roots: [URL] = [] + func appendSessionsAndArchived(_ sessionsURL: URL) { + let p = sessionsURL.resolvingSymlinksInPath().standardizedFileURL.path + if seen.contains(p) { return } + seen.insert(p) + roots.append(sessionsURL) + guard let archived = self.codexArchivedSessionsRoot(sessionsRoot: sessionsURL) else { return } + let ap = archived.resolvingSymlinksInPath().standardizedFileURL.path + if seen.contains(ap) { return } + seen.insert(ap) + roots.append(archived) } - return [root] + for sessionsURL in ordered { + appendSessionsAndArchived(sessionsURL) + } + return roots } private static func codexArchivedSessionsRoot(sessionsRoot: URL) -> URL? { diff --git a/Tests/CodexBarTests/CodexActiveCreditsTests.swift b/Tests/CodexBarTests/CodexActiveCreditsTests.swift index 4194a3f95..1749bf529 100644 --- a/Tests/CodexBarTests/CodexActiveCreditsTests.swift +++ b/Tests/CodexBarTests/CodexActiveCreditsTests.swift @@ -6,7 +6,7 @@ import Testing @MainActor struct CodexActiveCreditsTests { @Test - func `primary account uses store credits`() throws { + func `primary account uses store credits`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "CodexActiveCreditsTests-primary"), zaiTokenStore: NoopZaiTokenStore(), @@ -61,6 +61,58 @@ struct CodexActiveCreditsTests { #expect(store.codexActiveCreditsRemaining() == 55) } + @Test + func `primary oauth default row unlimited merges into menu credits`() { + let now = Date() + let defaultEntry = AccountCostEntry( + id: "default", + label: "Primary", + isDefault: true, + creditsRemaining: nil, + isUnlimited: true, + planType: "Pro", + primaryUsedPercent: nil, + secondaryUsedPercent: nil, + primaryResetDescription: nil, + secondaryResetDescription: nil, + error: nil, + updatedAt: now) + let result = UsageStore.resolvePrimaryCodexCreditsFromOAuth( + entries: [defaultEntry], + rpcCredits: CreditsSnapshot(remaining: 5, events: [], updatedAt: now), + rpcError: nil, + costRefreshInFlight: false) + #expect(result.snapshot == nil) + #expect(result.error == nil) + #expect(result.unlimited == true) + } + + @Test + func `primary oauth default row error wins over rpc credits`() { + let now = Date() + let defaultEntry = AccountCostEntry( + id: "default", + label: "Primary", + isDefault: true, + creditsRemaining: nil, + isUnlimited: false, + planType: nil, + primaryUsedPercent: nil, + secondaryUsedPercent: nil, + primaryResetDescription: nil, + secondaryResetDescription: nil, + error: "unauthorized", + updatedAt: now) + let result = UsageStore.resolvePrimaryCodexCreditsFromOAuth( + entries: [defaultEntry], + rpcCredits: CreditsSnapshot(remaining: 99, events: [], updatedAt: now), + rpcError: nil, + costRefreshInFlight: false) + #expect(result.snapshot == nil) + #expect(result.error == "Token expired") + #expect(result.unlimited == false) + } + @Test func `add-on unlimited reports unlimited flag`() throws { let settings = SettingsStore( diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 32288c2ea..947949e69 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -340,4 +340,68 @@ struct CostUsageScannerBreakdownTests { ]) #expect(report.data[0].modelBreakdowns?.map(\.totalTokens) == [110, 40, 30, 15]) } + + @Test + func `codex daily report merges codexExtraSessionsRoots`() throws { + let addonEnv = try CostUsageTestEnvironment() + let primaryEnv = try CostUsageTestEnvironment() + defer { + addonEnv.cleanup() + primaryEnv.cleanup() + } + + let day = try addonEnv.makeLocalNoon(year: 2025, month: 12, day: 21) + let iso0 = addonEnv.isoString(for: day) + let iso1 = addonEnv.isoString(for: day.addingTimeInterval(1)) + let model = "openai/gpt-5.2-codex" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "payload": [ + "session_id": "sess-primary-extra", + ], + ] + let turnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": [ + "model": model, + ], + ] + let tokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": 50, + "cached_input_tokens": 0, + "output_tokens": 25, + ], + "model": model, + ], + ], + ] + _ = try primaryEnv.writeCodexSessionFile( + day: day, + filename: "primary-only.jsonl", + contents: primaryEnv.jsonl([sessionMeta, turnContext, tokenCount])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: addonEnv.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: addonEnv.cacheRoot) + options.codexExtraSessionsRoots = [primaryEnv.codexSessionsRoot] + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].totalTokens == 75) + } } diff --git a/Tests/CodexBarTests/MenuCardModelOpenRouterTests.swift b/Tests/CodexBarTests/MenuCardModelOpenRouterTests.swift new file mode 100644 index 000000000..f949abcf4 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardModelOpenRouterTests.swift @@ -0,0 +1,456 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +struct MenuCardModelOpenRouterTests { + @Test + @MainActor + func `open router model uses API key quota bar and quota detail`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.openrouter]) + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45.3895596325, + balance: 4.6104403675, + usedPercent: 90.779119265, + keyLimit: 20, + keyUsage: 0.5, + rateLimit: nil, + updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .openrouter, + 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.creditsText == nil) + #expect(model.metrics.count == 1) + #expect(model.usageNotes.isEmpty) + let metric = try #require(model.metrics.first) + let popupTitle = UsageMenuCardView.popupMetricTitle( + provider: .openrouter, + metric: metric) + #expect(popupTitle == "API key limit") + #expect(metric.resetText == "$19.50/$20.00 left") + #expect(metric.detailRightText == nil) + } + + @Test + func `open router model without key limit shows text only summary`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.openrouter]) + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45.3895596325, + balance: 4.6104403675, + usedPercent: 90.779119265, + keyDataFetched: true, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .openrouter, + 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.metrics.isEmpty) + #expect(model.creditsText == nil) + #expect(model.placeholder == nil) + #expect(model.usageNotes == ["No limit set for the API key"]) + } + + @Test + func `open router model when key fetch unavailable shows unavailable note`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.openrouter]) + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45.3895596325, + balance: 4.6104403675, + usedPercent: 90.779119265, + keyDataFetched: false, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .openrouter, + 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.metrics.isEmpty) + #expect(model.usageNotes == ["API key limit unavailable right now"]) + } + + @Test + func `hides email when personal info hidden`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: "OpenAI dashboard signed in as codex@example.com.", + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: "OpenAI dashboard signed in as codex@example.com.", + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: true, + now: now)) + + #expect(model.email == "Hidden") + #expect(model.subtitleText.contains("codex@example.com") == false) + #expect(model.creditsHintCopyText?.isEmpty == true) + #expect(model.creditsHintText?.contains("codex@example.com") == false) + } + + @Test + func `kilo model splits pass and activity and shows fallback note`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.kilo]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "40/100 credits"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .kilo, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Kilo Pass Pro · Auto top-up: visa")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .kilo, + 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, + sourceLabel: "cli", + kiloAutoMode: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.planText == "Kilo Pass Pro") + #expect(model.usageNotes.contains("Auto top-up: visa")) + #expect(model.usageNotes.contains("Using CLI fallback")) + } + + @Test + func `kilo model treats auto top up only login as activity`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.kilo]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .kilo, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Auto top-up: off")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .kilo, + 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.planText == nil) + #expect(model.usageNotes.contains("Auto top-up: off")) + } + + @Test + func `kilo model does not show fallback note when not auto to CLI`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.kilo]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "40/100 credits"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .kilo, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Kilo Pass Pro · Auto top-up: visa")) + + let apiModel = UsageMenuCardView.Model.make(.init( + provider: .kilo, + 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, + sourceLabel: "api", + kiloAutoMode: true, + hidePersonalInfo: false, + now: now)) + + let nonAutoModel = UsageMenuCardView.Model.make(.init( + provider: .kilo, + 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, + sourceLabel: "cli", + kiloAutoMode: false, + hidePersonalInfo: false, + now: now)) + + #expect(!apiModel.usageNotes.contains("Using CLI fallback")) + #expect(!nonAutoModel.usageNotes.contains("Using CLI fallback")) + } + + @Test + func `kilo model shows primary detail when reset date missing`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.kilo]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "10/100 credits"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .kilo, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Kilo Pass Pro")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .kilo, + 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)) + + let primary = try #require(model.metrics.first) + #expect(primary.resetText == nil) + #expect(primary.detailText == "10/100 credits") + } + + @Test + func `kilo model keeps zero total edge state visible`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.kilo]) + let snapshot = KiloUsageSnapshot( + creditsUsed: 0, + creditsTotal: 0, + creditsRemaining: 0, + planName: "Kilo Pass Pro", + autoTopUpEnabled: true, + autoTopUpMethod: "visa", + updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .kilo, + 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)) + + let primary = try #require(model.metrics.first) + #expect(primary.percent == 0) + #expect(primary.detailText == "0/0 credits") + #expect(model.placeholder == nil) + } + + @Test + func `warp model shows primary detail when reset date missing`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .warp, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "10/100 credits"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.warp]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .warp, + 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: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.resetText == nil) + #expect(primary.detailText == "10/100 credits") + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 3977d1288..be2deda18 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -446,453 +446,4 @@ struct MenuCardModelTests { #expect(model.providerCost == nil) } - - @Test - @MainActor - func `open router model uses API key quota bar and quota detail`() throws { - let now = Date() - let metadata = try #require(ProviderDefaults.metadata[.openrouter]) - let snapshot = OpenRouterUsageSnapshot( - totalCredits: 50, - totalUsage: 45.3895596325, - balance: 4.6104403675, - usedPercent: 90.779119265, - keyLimit: 20, - keyUsage: 0.5, - rateLimit: nil, - updatedAt: now).toUsageSnapshot() - - let model = UsageMenuCardView.Model.make(.init( - provider: .openrouter, - 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.creditsText == nil) - #expect(model.metrics.count == 1) - #expect(model.usageNotes.isEmpty) - let metric = try #require(model.metrics.first) - let popupTitle = UsageMenuCardView.popupMetricTitle( - provider: .openrouter, - metric: metric) - #expect(popupTitle == "API key limit") - #expect(metric.resetText == "$19.50/$20.00 left") - #expect(metric.detailRightText == nil) - } - - @Test - func `open router model without key limit shows text only summary`() throws { - let now = Date() - let metadata = try #require(ProviderDefaults.metadata[.openrouter]) - let snapshot = OpenRouterUsageSnapshot( - totalCredits: 50, - totalUsage: 45.3895596325, - balance: 4.6104403675, - usedPercent: 90.779119265, - keyDataFetched: true, - keyLimit: nil, - keyUsage: nil, - rateLimit: nil, - updatedAt: now).toUsageSnapshot() - - let model = UsageMenuCardView.Model.make(.init( - provider: .openrouter, - 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.metrics.isEmpty) - #expect(model.creditsText == nil) - #expect(model.placeholder == nil) - #expect(model.usageNotes == ["No limit set for the API key"]) - } - - @Test - func `open router model when key fetch unavailable shows unavailable note`() throws { - let now = Date() - let metadata = try #require(ProviderDefaults.metadata[.openrouter]) - let snapshot = OpenRouterUsageSnapshot( - totalCredits: 50, - totalUsage: 45.3895596325, - balance: 4.6104403675, - usedPercent: 90.779119265, - keyDataFetched: false, - keyLimit: nil, - keyUsage: nil, - rateLimit: nil, - updatedAt: now).toUsageSnapshot() - - let model = UsageMenuCardView.Model.make(.init( - provider: .openrouter, - 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.metrics.isEmpty) - #expect(model.usageNotes == ["API key limit unavailable right now"]) - } - - @Test - func `hides email when personal info hidden`() throws { - let now = Date() - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: "codex@example.com", - accountOrganization: nil, - loginMethod: nil) - let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: nil, - tertiary: nil, - updatedAt: now, - identity: identity) - let metadata = try #require(ProviderDefaults.metadata[.codex]) - - let model = UsageMenuCardView.Model.make(.init( - provider: .codex, - metadata: metadata, - snapshot: snapshot, - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: "OpenAI dashboard signed in as codex@example.com.", - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: "codex@example.com", plan: nil), - isRefreshing: false, - lastError: "OpenAI dashboard signed in as codex@example.com.", - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: true, - now: now)) - - #expect(model.email == "Hidden") - #expect(model.subtitleText.contains("codex@example.com") == false) - #expect(model.creditsHintCopyText?.isEmpty == true) - #expect(model.creditsHintText?.contains("codex@example.com") == false) - } - - @Test - func `kilo model splits pass and activity and shows fallback note`() throws { - let now = Date() - let metadata = try #require(ProviderDefaults.metadata[.kilo]) - let snapshot = UsageSnapshot( - primary: RateWindow( - usedPercent: 40, - windowMinutes: nil, - resetsAt: nil, - resetDescription: "40/100 credits"), - secondary: nil, - tertiary: nil, - updatedAt: now, - identity: ProviderIdentitySnapshot( - providerID: .kilo, - accountEmail: nil, - accountOrganization: nil, - loginMethod: "Kilo Pass Pro · Auto top-up: visa")) - - let model = UsageMenuCardView.Model.make(.init( - provider: .kilo, - 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, - sourceLabel: "cli", - kiloAutoMode: true, - hidePersonalInfo: false, - now: now)) - - #expect(model.planText == "Kilo Pass Pro") - #expect(model.usageNotes.contains("Auto top-up: visa")) - #expect(model.usageNotes.contains("Using CLI fallback")) - } - - @Test - func `kilo model treats auto top up only login as activity`() throws { - let now = Date() - let metadata = try #require(ProviderDefaults.metadata[.kilo]) - let snapshot = UsageSnapshot( - primary: nil, - secondary: nil, - tertiary: nil, - updatedAt: now, - identity: ProviderIdentitySnapshot( - providerID: .kilo, - accountEmail: nil, - accountOrganization: nil, - loginMethod: "Auto top-up: off")) - - let model = UsageMenuCardView.Model.make(.init( - provider: .kilo, - 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.planText == nil) - #expect(model.usageNotes.contains("Auto top-up: off")) - } - - @Test - func `kilo model does not show fallback note when not auto to CLI`() throws { - let now = Date() - let metadata = try #require(ProviderDefaults.metadata[.kilo]) - let snapshot = UsageSnapshot( - primary: RateWindow( - usedPercent: 40, - windowMinutes: nil, - resetsAt: nil, - resetDescription: "40/100 credits"), - secondary: nil, - tertiary: nil, - updatedAt: now, - identity: ProviderIdentitySnapshot( - providerID: .kilo, - accountEmail: nil, - accountOrganization: nil, - loginMethod: "Kilo Pass Pro · Auto top-up: visa")) - - let apiModel = UsageMenuCardView.Model.make(.init( - provider: .kilo, - 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, - sourceLabel: "api", - kiloAutoMode: true, - hidePersonalInfo: false, - now: now)) - - let nonAutoModel = UsageMenuCardView.Model.make(.init( - provider: .kilo, - 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, - sourceLabel: "cli", - kiloAutoMode: false, - hidePersonalInfo: false, - now: now)) - - #expect(!apiModel.usageNotes.contains("Using CLI fallback")) - #expect(!nonAutoModel.usageNotes.contains("Using CLI fallback")) - } - - @Test - func `kilo model shows primary detail when reset date missing`() throws { - let now = Date() - let metadata = try #require(ProviderDefaults.metadata[.kilo]) - let snapshot = UsageSnapshot( - primary: RateWindow( - usedPercent: 10, - windowMinutes: nil, - resetsAt: nil, - resetDescription: "10/100 credits"), - secondary: nil, - tertiary: nil, - updatedAt: now, - identity: ProviderIdentitySnapshot( - providerID: .kilo, - accountEmail: nil, - accountOrganization: nil, - loginMethod: "Kilo Pass Pro")) - - let model = UsageMenuCardView.Model.make(.init( - provider: .kilo, - 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)) - - let primary = try #require(model.metrics.first) - #expect(primary.resetText == nil) - #expect(primary.detailText == "10/100 credits") - } - - @Test - func `kilo model keeps zero total edge state visible`() throws { - let now = Date() - let metadata = try #require(ProviderDefaults.metadata[.kilo]) - let snapshot = KiloUsageSnapshot( - creditsUsed: 0, - creditsTotal: 0, - creditsRemaining: 0, - planName: "Kilo Pass Pro", - autoTopUpEnabled: true, - autoTopUpMethod: "visa", - updatedAt: now).toUsageSnapshot() - - let model = UsageMenuCardView.Model.make(.init( - provider: .kilo, - 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)) - - let primary = try #require(model.metrics.first) - #expect(primary.percent == 0) - #expect(primary.detailText == "0/0 credits") - #expect(model.placeholder == nil) - } - - @Test - func `warp model shows primary detail when reset date missing`() throws { - let now = Date() - let identity = ProviderIdentitySnapshot( - providerID: .warp, - accountEmail: nil, - accountOrganization: nil, - loginMethod: nil) - let snapshot = UsageSnapshot( - primary: RateWindow( - usedPercent: 10, - windowMinutes: nil, - resetsAt: nil, - resetDescription: "10/100 credits"), - secondary: nil, - tertiary: nil, - updatedAt: now, - identity: identity) - let metadata = try #require(ProviderDefaults.metadata[.warp]) - - let model = UsageMenuCardView.Model.make(.init( - provider: .warp, - 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: true, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: now)) - - let primary = try #require(model.metrics.first) - #expect(primary.resetText == nil) - #expect(primary.detailText == "10/100 credits") - } } diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index 078719d46..5c3b602d2 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -65,6 +65,22 @@ struct SettingsStoreAdditionalTests { #expect(settings.ollamaCookieSource == .manual) } + @Test + func `removing add-on preserves default account selection index`() throws { + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-remove-preserves-default") + + settings.addTokenAccount(provider: .codex, label: "Work", token: "/tmp/codex-work") + settings.addTokenAccount(provider: .codex, label: "Home", token: "/tmp/codex-home") + settings.setActiveTokenAccountIndex(-1, for: .codex) + #expect(settings.tokenAccountsData(for: .codex)?.activeIndex == -1) + + let toRemove = try #require(settings.tokenAccounts(for: .codex).first) + settings.removeTokenAccount(provider: .codex, accountID: toRemove.id) + + #expect(settings.tokenAccounts(for: .codex).count == 1) + #expect(settings.tokenAccountsData(for: .codex)?.activeIndex == -1) + } + @Test func `claude default token account active follows stored primary selection`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-claude-default-active") diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 3ad14d61c..71285b4bc 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -632,7 +632,7 @@ struct StatusMenuTests { } @Test - func `shows credits before cost in codex menu card sections`() throws { + func `shows credits before cost in codex menu card sections`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false diff --git a/docs/configuration.md b/docs/configuration.md index fecfd198a..68f0c1d8c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,7 +30,8 @@ Secrets (API keys, cookies, tokens) live here; Keychain is not used. "apiKey": null, "region": null, "workspaceID": null, - "tokenAccounts": null + "tokenAccounts": null, + "codexExplicitAccountsOnly": false } ] } @@ -52,6 +53,7 @@ All provider fields are optional unless noted. - `region`: provider-specific region (e.g. `zai`, `minimax`). - `workspaceID`: provider-specific workspace ID (e.g. `opencode`). - `tokenAccounts`: multi-account tokens for a provider. +- `codexExplicitAccountsOnly` (Codex only): when `true`, CodexBar never uses the implicit default `~/.codex` account; usage and CLI probes use only Accounts you add in Settings (OAuth, API key, or a token account with a custom `CODEX_HOME` path). Matches the **CodexBar accounts only** toggle in Codex preferences. ### tokenAccounts ```json From 91ba2217dec14d562626426ada29288f7bb491f0 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Sat, 21 Mar 2026 23:06:39 -0400 Subject: [PATCH 06/25] Multi Account clarificatoin --- .../PreferencesProviderDetailView.swift | 4 +- .../PreferencesProviderSettingsRows.swift | 100 ++++++++++++++---- .../PreferencesProvidersPane+Testing.swift | 1 + .../CodexBar/PreferencesProvidersPane.swift | 8 ++ .../Codex/CodexProviderImplementation.swift | 58 ---------- .../Shared/ProviderSettingsDescriptors.swift | 1 + .../SettingsStore+TokenAccounts.swift | 30 ++++++ .../CodexBar/UsageStore+AccountCosts.swift | 29 +++-- .../ProviderSettingsDescriptorTests.swift | 3 +- 9 files changed, 141 insertions(+), 93 deletions(-) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 7dcdfedb7..3a095fba1 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -195,7 +195,7 @@ struct ProviderDetailView: View { return """ CodexBar accounts only is on: ~/.codex is not used as an implicit account. \ Add identities under Accounts (OAuth, API key, or manual CODEX_HOME path). \ - “Menu bar account” chooses which row drives the menu bar. + Use "Menu Bar Icon" on each row to choose which one drives the menu bar. """ .replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespaces) @@ -203,7 +203,7 @@ struct ProviderDetailView: View { return """ The primary account is whichever identity Codex has configured in ~/.codex on this Mac. \ Other rows in Accounts are separate credentials/folders. \ - “Menu bar account” chooses which one CodexBar shows in the menu bar. + Use "Menu Bar Icon" on each row to choose which one CodexBar shows in the menu bar. """ .replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespaces) diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index d54fca5f5..381efbb61 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -1,5 +1,6 @@ import CodexBarCore import SwiftUI +import UniformTypeIdentifiers struct ProviderSettingsSection: View { let title: String @@ -244,6 +245,8 @@ struct ProviderSettingsTokenAccountsRowView: View { /// Current text inside the active rename field. @State private var renameText: String = "" @FocusState private var renameFieldFocused: Bool + /// ID of the token account currently being dragged for reordering (nil = none). + @State private var draggingAccountID: UUID? /// Explainer rows for Codex when the implicit ~/.codex primary tab is shown (`show` = discovery UX). private var useCodexDiscoveryHints: Bool { @@ -252,10 +255,10 @@ struct ProviderSettingsTokenAccountsRowView: View { private var codexAccountsFooterHint: String { if self.descriptor.codexExplicitAccountsOnly { - return "Only one account drives the menu bar at a time. Choose it under Options → “Menu bar account”. " + + return "Only one account drives the menu bar at a time. Choose it with “Menu Bar Icon” above. " + "Other toggles (Buy Credits, web extras, etc.) are under Options too." } - return "Only one account is active at a time. Choose “Menu bar account” under Options below. " + + return "Only one account is active at a time. Choose “Menu Bar Icon” on the row you want. " + "The house row is your primary ~/.codex sign-in; added rows use a separate OAuth folder or API key. " + "Buy Credits is also under Options." } @@ -372,6 +375,17 @@ struct ProviderSettingsTokenAccountsRowView: View { ForEach(Array(accounts.enumerated()), id: \.1.id) { index, account in self.accountTab(account: account, index: index, isActive: index == selectedIndex) .frame(maxWidth: .infinity, alignment: .leading) + .onDrag { + self.draggingAccountID = account.id + return NSItemProvider(object: account.id.uuidString as NSString) + } + .onDrop( + of: [UTType.plainText], + delegate: TokenAccountDropDelegate( + item: account, + accounts: accounts, + dragging: self.$draggingAccountID, + moveAccounts: self.descriptor.moveAccount)) } } } @@ -427,6 +441,15 @@ struct ProviderSettingsTokenAccountsRowView: View { if rowActive { self.menuBarActiveBadge() } + if !self.useCodexDiscoveryHints, !isActive, !isRenaming { + Button("Menu Bar Icon") { + self.descriptor.setActiveIndex(-1) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .font(.caption2.weight(.medium)) + .help("Use this ~/.codex account for the menu bar") + } if self.descriptor.renameDefaultAccount != nil { Button(action: { self.renameText = label @@ -443,15 +466,6 @@ struct ProviderSettingsTokenAccountsRowView: View { .buttonStyle(.plain) .help("Rename tab") } - if !self.useCodexDiscoveryHints, !isActive, !isRenaming { - Button("Use") { - self.descriptor.setActiveIndex(-1) - } - .buttonStyle(.bordered) - .controlSize(.mini) - .font(.caption2.weight(.medium)) - .help("Use this ~/.codex account for the menu bar") - } } } if self.useCodexDiscoveryHints, !isRenaming { @@ -497,6 +511,8 @@ struct ProviderSettingsTokenAccountsRowView: View { let rowActive = highlightSelection && isActive VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { + AccountReorderHandle() + .help("Drag to reorder") Image(systemName: "rectangle.stack.fill") .foregroundStyle(rowActive ? Color.accentColor : .secondary) .imageScale(.small) @@ -532,6 +548,15 @@ struct ProviderSettingsTokenAccountsRowView: View { if rowActive { self.menuBarActiveBadge() } + if !self.useCodexDiscoveryHints, !isActive, !isRenaming { + Button("Menu Bar Icon") { + self.descriptor.setActiveIndex(index) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .font(.caption2.weight(.medium)) + .help("Use this account for the menu bar") + } Button(action: { self.renameText = account.displayName self.renamingAccountID = account.id @@ -553,15 +578,6 @@ struct ProviderSettingsTokenAccountsRowView: View { }) .buttonStyle(.plain) .help("Remove account") - if !self.useCodexDiscoveryHints, !isActive, !isRenaming { - Button("Use") { - self.descriptor.setActiveIndex(index) - } - .buttonStyle(.bordered) - .controlSize(.mini) - .font(.caption2.weight(.medium)) - .help("Use this account for the menu bar") - } } } if self.descriptor.provider == .codex, !isRenaming { @@ -713,6 +729,50 @@ struct ProviderSettingsTokenAccountsRowView: View { } } +private struct AccountReorderHandle: View { + var body: some View { + VStack(spacing: 2) { + ForEach(0..<3, id: \.self) { _ in + HStack(spacing: 2) { + Circle() + .frame(width: 2.5, height: 2.5) + Circle() + .frame(width: 2.5, height: 2.5) + } + } + } + .frame(width: 12, height: 16) + .foregroundStyle(.tertiary) + .accessibilityLabel("Reorder") + } +} + +private struct TokenAccountDropDelegate: DropDelegate { + let item: ProviderTokenAccount + let accounts: [ProviderTokenAccount] + @Binding var dragging: UUID? + let moveAccounts: (IndexSet, Int) -> Void + + func dropEntered(info _: DropInfo) { + guard let dragging, dragging != self.item.id else { return } + guard let fromIndex = self.accounts.firstIndex(where: { $0.id == dragging }), + let toIndex = self.accounts.firstIndex(where: { $0.id == self.item.id }) + else { return } + guard fromIndex != toIndex else { return } + let adjustedIndex = toIndex > fromIndex ? toIndex + 1 : toIndex + self.moveAccounts(IndexSet(integer: fromIndex), adjustedIndex) + } + + func dropUpdated(info _: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func performDrop(info _: DropInfo) -> Bool { + self.dragging = nil + return true + } +} + extension View { @ViewBuilder fileprivate func applyProviderSettingsButtonStyle(_ style: ProviderSettingsActionDescriptor.Style) -> some View { diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index 9812e9dbe..e3eef9fcb 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -175,6 +175,7 @@ enum ProvidersPaneTestHarness { setActiveIndex: { _ in }, addAccount: { _, _ in }, removeAccount: { _ in }, + moveAccount: { _, _ in }, renameAccount: { _, _ in }, openConfigFile: {}, reloadFromDisk: {}, diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 93f57d398..03d0540f0 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -231,6 +231,14 @@ struct ProvidersPane: View { } } }, + moveAccount: { fromOffsets, toOffset in + self.settings.moveTokenAccount(provider: provider, fromOffsets: fromOffsets, toOffset: toOffset) + Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.store.refreshProvider(provider, allowDisabled: true) + } + } + }, renameAccount: { accountID, newLabel in self.settings.renameTokenAccount(provider: provider, accountID: accountID, newLabel: newLabel) }, diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index d7a82b11b..7e92fe759 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -172,44 +172,6 @@ struct CodexProviderImplementation: ProviderImplementation { context.settings.codexCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) - let menuBarAccountBinding = Binding( - get: { - let accounts = context.settings.tokenAccounts(for: .codex) - let hasPrimary = self.tokenAccountDefaultLabel(settings: context.settings) != nil - let raw = context.settings.tokenAccountsData(for: .codex)?.activeIndex ?? -1 - if hasPrimary, raw < 0 { return "default" } - guard !accounts.isEmpty else { return hasPrimary ? "default" : "0" } - let idx = min(max(raw < 0 ? 0 : raw, 0), accounts.count - 1) - return String(idx) - }, - set: { newId in - if newId == "default" { - context.settings.setActiveTokenAccountIndex(-1, for: .codex) - } else if let idx = Int(newId) { - context.settings.setActiveTokenAccountIndex(idx, for: .codex) - } - }) - - var menuBarAccountOptions: [ProviderSettingsPickerOption] = [] - if self.tokenAccountDefaultLabel(settings: context.settings) != nil { - let custom = context.settings.providerConfig(for: .codex)?.defaultAccountLabel? - .trimmingCharacters(in: .whitespacesAndNewlines) - let title: String = if let custom, !custom.isEmpty { - custom - } else if let email = self.tokenAccountDefaultLabel(settings: context.settings) { - email - } else { - "Primary" - } - menuBarAccountOptions.append( - ProviderSettingsPickerOption( - id: "default", - title: "\(title) (primary ~/.codex)")) - } - for (i, acc) in context.settings.tokenAccounts(for: .codex).enumerated() { - menuBarAccountOptions.append(ProviderSettingsPickerOption(id: String(i), title: acc.displayName)) - } - let usageOptions = CodexUsageDataSource.allCases.map { ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName) } @@ -227,26 +189,6 @@ struct CodexProviderImplementation: ProviderImplementation { } return [ - ProviderSettingsPickerDescriptor( - id: "codex-menu-bar-account", - title: "Menu bar account", - subtitle: "Which Codex account drives the menu bar and usage on this Mac.", - binding: menuBarAccountBinding, - options: menuBarAccountOptions, - isVisible: { - let accounts = context.settings.tokenAccounts(for: .codex) - let hasPrimary = self.tokenAccountDefaultLabel(settings: context.settings) != nil - if context.settings.codexExplicitAccountsOnly { - return accounts.count >= 1 - } - return (hasPrimary ? 1 : 0) + accounts.count >= 2 - }, - onChange: { _ in - await ProviderInteractionContext.$current.withValue(.userInitiated) { - await context.store.refreshProvider(.codex, allowDisabled: true) - } - }, - section: .options), ProviderSettingsPickerDescriptor( id: "codex-usage-source", title: "Usage source", diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index 20fc58019..632ec2204 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -100,6 +100,7 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let setActiveIndex: (Int) -> Void let addAccount: (_ label: String, _ token: String) -> Void let removeAccount: (_ accountID: UUID) -> Void + let moveAccount: (_ fromOffsets: IndexSet, _ toOffset: Int) -> Void let renameAccount: (_ accountID: UUID, _ newLabel: String) -> Void let openConfigFile: () -> Void let reloadFromDisk: () -> Void diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 8bcab8203..ad4183f86 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -118,6 +118,36 @@ extension SettingsStore { } } + func moveTokenAccount(provider: UsageProvider, fromOffsets: IndexSet, toOffset: Int) { + guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } + var accounts = data.accounts + let previousActiveAccount: ProviderTokenAccount? = data.activeIndex >= 0 && data.activeIndex < accounts.count + ? accounts[data.activeIndex] + : nil + accounts.move(fromOffsets: fromOffsets, toOffset: toOffset) + let newActiveIndex: Int + if let activeAccount = previousActiveAccount, + let newIndex = accounts.firstIndex(where: { $0.id == activeAccount.id }) + { + newActiveIndex = newIndex + } else { + newActiveIndex = data.activeIndex + } + let updated = ProviderTokenAccountData( + version: data.version, + accounts: accounts, + activeIndex: newActiveIndex) + self.updateProviderConfig(provider: provider) { entry in + entry.tokenAccounts = updated + } + CodexBarLog.logger(LogCategories.tokenAccounts).info( + "Token account reordered", + metadata: [ + "provider": provider.rawValue, + "count": "\(accounts.count)", + ]) + } + func removeTokenAccount(provider: UsageProvider, accountID: UUID) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } let filtered = data.accounts.filter { $0.id != accountID } diff --git a/Sources/CodexBar/UsageStore+AccountCosts.swift b/Sources/CodexBar/UsageStore+AccountCosts.swift index 60f522276..a90aa559b 100644 --- a/Sources/CodexBar/UsageStore+AccountCosts.swift +++ b/Sources/CodexBar/UsageStore+AccountCosts.swift @@ -35,22 +35,29 @@ extension UsageStore { let tokenAccounts = self.settings.tokenAccounts(for: provider) let defaultLabel = ProviderCatalog.implementation(for: provider)? .tokenAccountDefaultLabel(settings: self.settings) ?? "Default" + let includeDefault = !self.settings.codexExplicitAccountsOnly // Fetch all accounts in parallel. var entries: [AccountCostEntry] = await withTaskGroup( of: (index: Int, entry: AccountCostEntry).self, returning: [AccountCostEntry].self) { group in - // Default account (index 0) - group.addTask { - let entry = await Self.fetchCredits( - env: [:], - id: "default", - label: defaultLabel, - isDefault: true) - return (0, entry) + // Default account (index 0) — skip when "CodexBar accounts only" is on. + let baseOffset: Int + if includeDefault { + group.addTask { + let entry = await Self.fetchCredits( + env: [:], + id: "default", + label: defaultLabel, + isDefault: true) + return (0, entry) + } + baseOffset = 1 + } else { + baseOffset = 0 } - // Token accounts (index 1…) + // Token accounts for (offset, account) in tokenAccounts.enumerated() { group.addTask { guard let env = TokenAccountSupportCatalog.envOverride(for: .codex, token: account.token) else { @@ -67,14 +74,14 @@ extension UsageStore { secondaryResetDescription: nil, error: "Invalid Codex account token", updatedAt: Date()) - return (offset + 1, entry) + return (offset + baseOffset, entry) } let entry = await Self.fetchCredits( env: env, id: account.id.uuidString, label: account.label, isDefault: false) - return (offset + 1, entry) + return (offset + baseOffset, entry) } } diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 9e611089c..7646b1419 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -119,10 +119,9 @@ struct ProviderSettingsDescriptorTests { let pickers = CodexProviderImplementation().settingsPickers(context: context) let toggles = CodexProviderImplementation().settingsToggles(context: context) - #expect(pickers.contains(where: { $0.id == "codex-menu-bar-account" })) + #expect(!pickers.contains(where: { $0.id == "codex-menu-bar-account" })) #expect(pickers.contains(where: { $0.id == "codex-usage-source" })) #expect(pickers.contains(where: { $0.id == "codex-cookie-source" })) - #expect(pickers.first(where: { $0.id == "codex-menu-bar-account" })?.section == .options) #expect(toggles.contains(where: { $0.id == "codex-historical-tracking" })) } From 7e392c66c054ba02a0b4aaeb6a99fcae0bf0bcf2 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Sun, 22 Mar 2026 10:01:12 -0400 Subject: [PATCH 07/25] feat: Multiple Accounts toggle, drag-reorder, scroll-stable layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Multi-Account toggles above the Accounts section so toggling never shifts scroll position. Rename Use → Default/Make Default, add drag-and-drop reordering, gate multi-account UI behind a toggle. Co-Authored-By: Claude Opus 4.6 --- .../PreferencesProviderDetailView.swift | 58 ++++++++++++++++--- .../PreferencesProviderSettingsRows.swift | 14 ++--- .../CodexBar/PreferencesProvidersPane.swift | 7 ++- .../Codex/CodexProviderImplementation.swift | 23 +++++++- .../Providers/Codex/CodexSettingsStore.swift | 11 ++++ .../CodexBar/StatusItemController+Menu.swift | 2 + .../CodexBar/UsageStore+AccountCosts.swift | 2 + .../CodexBarCore/Config/CodexBarConfig.swift | 7 ++- 8 files changed, 104 insertions(+), 20 deletions(-) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 3a095fba1..901d11355 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -60,13 +60,31 @@ struct ProviderDetailView: View { hideAccountAndPlan: self.codexHidesHeaderAccountAndPlan, onRefresh: self.onRefresh) - // Accounts section shown prominently at the top, before usage metrics. - if let tokenAccounts = self.settingsTokenAccounts, - tokenAccounts.isVisible?() ?? true - { + // Multi-account toggles rendered ABOVE the Accounts section so that + // expanding/collapsing accounts never shifts the toggle's scroll position. + if !self.codexEarlyToggles.isEmpty { + ProviderSettingsSection(title: "Multi-Account") { + ForEach(self.codexEarlyToggles) { toggle in + if toggle.isVisible?() ?? true { + ProviderSettingsToggleRowView(toggle: toggle) + .id(toggle.id) + } + } + } + } + + // Accounts section: always in the view tree to prevent ScrollView + // from resetting scroll position when the section appears/disappears. + if let tokenAccounts = self.settingsTokenAccounts { + let accountsVisible = tokenAccounts.isVisible?() ?? true ProviderSettingsSection(title: "Accounts") { ProviderSettingsTokenAccountsRowView(descriptor: tokenAccounts) } + .frame(maxHeight: accountsVisible ? nil : 0) + .opacity(accountsVisible ? 1 : 0) + .clipped() + .allowsHitTesting(accountsVisible) + .accessibilityHidden(!accountsVisible) } Group { @@ -141,8 +159,11 @@ struct ProviderDetailView: View { ForEach(self.optionsSectionPickers) { picker in ProviderSettingsPickerRowView(picker: picker) } - ForEach(self.settingsToggles) { toggle in - ProviderSettingsToggleRowView(toggle: toggle) + ForEach(self.optionsSectionToggles) { toggle in + if toggle.isVisible?() ?? true { + ProviderSettingsToggleRowView(toggle: toggle) + .id(toggle.id) + } } if self.provider == .codex { Text(self.codexOptionsFooterExplanation) @@ -187,7 +208,24 @@ struct ProviderDetailView: View { } private var hasOptionsSection: Bool { - !self.settingsToggles.isEmpty || !self.optionsSectionPickers.isEmpty + !self.optionsSectionToggles.isEmpty || !self.optionsSectionPickers.isEmpty + } + + private static let earlyToggleIDs: Set = [ + "codex-multiple-accounts", + "codex-explicit-accounts-only", + ] + + private var codexEarlyToggles: [ProviderSettingsToggleDescriptor] { + guard self.provider == .codex else { return [] } + return self.settingsToggles.filter { Self.earlyToggleIDs.contains($0.id) } + } + + private var optionsSectionToggles: [ProviderSettingsToggleDescriptor] { + if self.provider == .codex { + return self.settingsToggles.filter { !Self.earlyToggleIDs.contains($0.id) } + } + return self.settingsToggles } private var codexOptionsFooterExplanation: String { @@ -195,7 +233,7 @@ struct ProviderDetailView: View { return """ CodexBar accounts only is on: ~/.codex is not used as an implicit account. \ Add identities under Accounts (OAuth, API key, or manual CODEX_HOME path). \ - Use "Menu Bar Icon" on each row to choose which one drives the menu bar. + Use "Default" on each row to choose which one drives the menu bar. """ .replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespaces) @@ -203,7 +241,7 @@ struct ProviderDetailView: View { return """ The primary account is whichever identity Codex has configured in ~/.codex on this Mac. \ Other rows in Accounts are separate credentials/folders. \ - Use "Menu Bar Icon" on each row to choose which one CodexBar shows in the menu bar. + Use "Default" on each row to choose which one CodexBar shows in the menu bar. """ .replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespaces) @@ -213,6 +251,7 @@ struct ProviderDetailView: View { /// avoid confusion. private var codexHidesHeaderAccountAndPlan: Bool { guard self.provider == .codex else { return false } + guard self.settings.codexMultipleAccountsEnabled else { return false } let hasPrimary = !self.settings.codexExplicitAccountsOnly && CodexProviderImplementation().tokenAccountDefaultLabel(settings: self.settings) != nil let addedCount = self.settings.tokenAccounts(for: .codex).count @@ -225,6 +264,7 @@ struct ProviderDetailView: View { /// Same rule as the menu-bar token switcher: default ~/.codex + ≥1 added account, or 2+ added accounts. private var codexShowsUsageAccountSwitcher: Bool { guard self.provider == .codex else { return false } + guard self.settings.codexMultipleAccountsEnabled else { return false } let accounts = self.settings.tokenAccounts(for: .codex) if self.settings.codexExplicitAccountsOnly { return accounts.count >= 2 diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 381efbb61..6255396fb 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -255,10 +255,10 @@ struct ProviderSettingsTokenAccountsRowView: View { private var codexAccountsFooterHint: String { if self.descriptor.codexExplicitAccountsOnly { - return "Only one account drives the menu bar at a time. Choose it with “Menu Bar Icon” above. " + + return "Only one account drives the menu bar at a time. Choose it with “Default” above. " + "Other toggles (Buy Credits, web extras, etc.) are under Options too." } - return "Only one account is active at a time. Choose “Menu Bar Icon” on the row you want. " + + return "Only one account is active at a time. Choose “Default” on the row you want. " + "The house row is your primary ~/.codex sign-in; added rows use a separate OAuth folder or API key. " + "Buy Credits is also under Options." } @@ -391,7 +391,7 @@ struct ProviderSettingsTokenAccountsRowView: View { } private func menuBarActiveBadge() -> some View { - Text("Menu bar") + Text("Default") .font(.system(size: 10, weight: .semibold)) .padding(.horizontal, 6) .padding(.vertical, 3) @@ -442,13 +442,13 @@ struct ProviderSettingsTokenAccountsRowView: View { self.menuBarActiveBadge() } if !self.useCodexDiscoveryHints, !isActive, !isRenaming { - Button("Menu Bar Icon") { + Button("Make Default") { self.descriptor.setActiveIndex(-1) } .buttonStyle(.bordered) .controlSize(.mini) .font(.caption2.weight(.medium)) - .help("Use this ~/.codex account for the menu bar") + .help("Make this the default account") } if self.descriptor.renameDefaultAccount != nil { Button(action: { @@ -549,13 +549,13 @@ struct ProviderSettingsTokenAccountsRowView: View { self.menuBarActiveBadge() } if !self.useCodexDiscoveryHints, !isActive, !isRenaming { - Button("Menu Bar Icon") { + Button("Make Default") { self.descriptor.setActiveIndex(index) } .buttonStyle(.bordered) .controlSize(.mini) .font(.caption2.weight(.medium)) - .help("Use this account for the menu bar") + .help("Make this the default account") } Button(action: { self.renameText = account.displayName diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 03d0540f0..f56ac12c9 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -145,7 +145,6 @@ struct ProvidersPane: View { guard let impl = ProviderCatalog.implementation(for: provider) else { return [] } let context = self.makeSettingsContext(provider: provider) return impl.settingsToggles(context: context) - .filter { $0.isVisible?() ?? true } } private func extraSettingsPickers(for provider: UsageProvider) -> [ProviderSettingsPickerDescriptor] { @@ -197,7 +196,11 @@ struct ProvidersPane: View { isSecureToken: isSecureToken, provider: provider, isVisible: { - ProviderCatalog.implementation(for: provider)? + // For Codex, hide accounts section unless Multiple Accounts is enabled. + if provider == .codex, !self.settings.codexMultipleAccountsEnabled { + return false + } + return ProviderCatalog.implementation(for: provider)? .tokenAccountsVisibility(context: context, support: support) ?? (!support.requiresManualCookieSource || !context.settings.tokenAccounts(for: provider).isEmpty) diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 7e92fe759..d7952cf6c 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -22,6 +22,7 @@ struct CodexProviderImplementation: ProviderImplementation { _ = settings.codexCookieSource _ = settings.codexCookieHeader _ = settings.codexExplicitAccountsOnly + _ = settings.codexMultipleAccountsEnabled } @MainActor @@ -76,6 +77,14 @@ struct CodexProviderImplementation: ProviderImplementation { get: { context.settings.codexBuyCreditsMenuEnabled }, set: { context.settings.codexBuyCreditsMenuEnabled = $0 }) + let multipleAccountsBinding = Binding( + get: { context.settings.codexMultipleAccountsEnabled }, + set: { newValue in + withAnimation(.easeInOut(duration: 0.2)) { + context.settings.codexMultipleAccountsEnabled = newValue + } + }) + let explicitAccountsBinding = Binding( get: { context.settings.codexExplicitAccountsOnly }, set: { newValue in @@ -88,6 +97,18 @@ struct CodexProviderImplementation: ProviderImplementation { }) return [ + ProviderSettingsToggleDescriptor( + id: "codex-multiple-accounts", + title: "Multiple Accounts", + subtitle: + "Enable multi-account support: add, reorder, and switch between multiple Codex accounts.", + binding: multipleAccountsBinding, + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-explicit-accounts-only", title: "CodexBar accounts only", @@ -97,7 +118,7 @@ struct CodexProviderImplementation: ProviderImplementation { binding: explicitAccountsBinding, statusText: nil, actions: [], - isVisible: nil, + isVisible: { context.settings.codexMultipleAccountsEnabled }, onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 6b0a61202..83a620f78 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -23,6 +23,17 @@ extension SettingsStore { } } + /// When `true`, enables multi-account support for Codex (account switcher, drag reorder, per-account tabs). + /// Defaults to `false` (single-account / upstream behavior). + var codexMultipleAccountsEnabled: Bool { + get { self.configSnapshot.providerConfig(for: .codex)?.codexMultipleAccountsEnabled ?? false } + set { + self.updateProviderConfig(provider: .codex) { entry in + entry.codexMultipleAccountsEnabled = newValue + } + } + } + /// When `true` (default), shows "Buy Credits…" in the Codex menu. Persisted per-provider; `nil` in config means /// enabled. var codexBuyCreditsMenuEnabled: Bool { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 7c36ec5b6..90df2e04f 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -710,6 +710,8 @@ extension StatusItemController { private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil } + // Hide account switcher when Multiple Accounts is disabled for Codex. + if provider == .codex, !self.settings.codexMultipleAccountsEnabled { return nil } let accounts = self.settings.tokenAccounts(for: provider) let defaultLabel = ProviderCatalog.implementation(for: provider)? .tokenAccountDefaultLabel(settings: self.settings) diff --git a/Sources/CodexBar/UsageStore+AccountCosts.swift b/Sources/CodexBar/UsageStore+AccountCosts.swift index a90aa559b..17da7aa2b 100644 --- a/Sources/CodexBar/UsageStore+AccountCosts.swift +++ b/Sources/CodexBar/UsageStore+AccountCosts.swift @@ -28,6 +28,8 @@ extension UsageStore { /// concurrently via the OAuth API and stores results in `allAccountCredits[provider]`. func refreshAllAccountCredits(for provider: UsageProvider) async { guard provider == .codex else { return } + // Skip multi-account credits refresh when Multiple Accounts is disabled. + guard self.settings.codexMultipleAccountsEnabled else { return } guard !self.accountCostRefreshInFlight.contains(provider) else { return } self.accountCostRefreshInFlight.insert(provider) defer { self.accountCostRefreshInFlight.remove(provider) } diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index e59e7ea1b..cc98e476a 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -90,6 +90,9 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { /// When `true` (Codex only), CodexBar does not treat `~/.codex` as an implicit account; usage requires Accounts /// added in Settings (OAuth, API key, or manual `CODEX_HOME`). public var codexExplicitAccountsOnly: Bool? + /// When `true` (Codex only), enables multiple account support (account switcher, drag reorder, per-account tabs). + /// `nil` or `false` means single-account mode (upstream default). + public var codexMultipleAccountsEnabled: Bool? public init( id: UsageProvider, @@ -104,7 +107,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { tokenAccounts: ProviderTokenAccountData? = nil, defaultAccountLabel: String? = nil, buyCreditsMenuEnabled: Bool? = nil, - codexExplicitAccountsOnly: Bool? = nil) + codexExplicitAccountsOnly: Bool? = nil, + codexMultipleAccountsEnabled: Bool? = nil) { self.id = id self.enabled = enabled @@ -119,6 +123,7 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.defaultAccountLabel = defaultAccountLabel self.buyCreditsMenuEnabled = buyCreditsMenuEnabled self.codexExplicitAccountsOnly = codexExplicitAccountsOnly + self.codexMultipleAccountsEnabled = codexMultipleAccountsEnabled } public var sanitizedAPIKey: String? { From fe936d38457d6a2317e1f98c228ed733a381cd46 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Sun, 22 Mar 2026 12:49:12 -0400 Subject: [PATCH 08/25] feat: per-account dashboard login with combined OAuth flow Add per-account WKWebView login windows for chatgpt.com dashboard, replacing the failed synthetic OAuth cookie approach. Each account gets isolated cookie storage. Dashboard login auto-opens after OAuth account creation when web extras is enabled. Co-Authored-By: Claude Opus 4.6 --- ...OpenAIDashboardLoginWindowController.swift | 168 ++++++++++++++++++ .../PreferencesProviderSettingsRows.swift | 18 ++ .../PreferencesProvidersPane+Testing.swift | 1 + .../CodexBar/PreferencesProvidersPane.swift | 13 ++ .../Codex/CodexProviderImplementation.swift | 18 +- .../Shared/ProviderSettingsDescriptors.swift | 3 + Sources/CodexBar/UsageStore.swift | 9 + .../OpenAIDashboardOAuthSessionSeeder.swift | 3 + 8 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 Sources/CodexBar/OpenAIDashboardLoginWindowController.swift create mode 100644 Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardOAuthSessionSeeder.swift diff --git a/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift b/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift new file mode 100644 index 000000000..7dad13324 --- /dev/null +++ b/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift @@ -0,0 +1,168 @@ +import AppKit +import CodexBarCore +import WebKit + +/// Shows a WKWebView window where the user can sign in to chatgpt.com for a specific account. +/// The session cookies are stored in a per-account `WKWebsiteDataStore` so dashboard scraping +/// works independently for each OAuth account without needing browser cookie import. +@MainActor +final class OpenAIDashboardLoginWindowController: NSWindowController, WKNavigationDelegate { + private static let defaultSize = NSSize(width: 520, height: 700) + private static let loginURL = URL(string: "https://chatgpt.com/codex/settings/usage")! + + /// Retains active login windows so they aren't deallocated while open. + private static var activeControllers: [OpenAIDashboardLoginWindowController] = [] + + private let logger = CodexBarLog.logger(LogCategories.openAIWebview) + private var webView: WKWebView? + private var accountEmail: String? + private var onComplete: ((Bool) -> Void)? + private var loginDetected = false + + /// JS snippet that checks if the page shows a logged-in dashboard (not a login/auth page). + private static let loginCheckScript = """ + (() => { + const href = location.href || ''; + const body = (document.body && document.body.innerText) || ''; + const isLogin = href.includes('auth.openai.com') || + href.includes('/login') || + body.includes('Welcome back') || + body.includes('Log in') || + body.includes('Sign up'); + const isDashboard = href.includes('/codex/settings/usage') && + !isLogin && + body.length > 200; + return { href, isLogin, isDashboard, bodyLength: body.length }; + })(); + """ + + init(accountEmail: String?, onComplete: ((Bool) -> Void)? = nil) { + self.accountEmail = Self.normalizeEmail(accountEmail) + self.onComplete = onComplete + super.init(window: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + Self.activeControllers.append(self) + if self.window == nil { + self.buildWindow() + } + self.loginDetected = false + self.load() + self.window?.center() + self.showWindow(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private func buildWindow() { + let config = WKWebViewConfiguration() + config.websiteDataStore = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: self.accountEmail) + + let webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = self + webView.allowsBackForwardNavigationGestures = true + webView.translatesAutoresizingMaskIntoConstraints = false + + let container = NSView(frame: .zero) + container.addSubview(webView) + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + webView.topAnchor.constraint(equalTo: container.topAnchor), + webView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + let emailLabel = self.accountEmail ?? "account" + let window = NSWindow( + contentRect: Self.defaultFrame(), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false) + window.title = "Sign in to ChatGPT — \(emailLabel)" + window.isReleasedWhenClosed = false + window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] + window.contentView = container + window.center() + window.delegate = self + + self.window = window + self.webView = webView + } + + private func load() { + guard let webView else { return } + webView.load(URLRequest(url: Self.loginURL)) + } + + // MARK: - WKNavigationDelegate + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + self.checkLoginStatus(webView: webView) + } + + private func checkLoginStatus(webView: WKWebView) { + guard !self.loginDetected else { return } + + webView.evaluateJavaScript(Self.loginCheckScript) { [weak self] result, _ in + guard let self, let dict = result as? [String: Any] else { return } + let isDashboard = (dict["isDashboard"] as? Bool) ?? false + let href = (dict["href"] as? String) ?? "" + + if isDashboard { + self.logger.info("Dashboard login detected for \(self.accountEmail ?? "account")") + self.loginDetected = true + + // Brief delay to let cookies finalize, then close. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.onComplete?(true) + self.close() + } + return + } + + // If we're on chatgpt.com (not auth.openai.com), keep polling for login completion. + if href.contains("chatgpt.com"), !href.contains("auth.openai.com") { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self, let webView = self.webView else { return } + self.checkLoginStatus(webView: webView) + } + } + } + } + + private static func normalizeEmail(_ email: String?) -> String? { + guard let raw = email?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + return raw.lowercased() + } + + private static func defaultFrame() -> NSRect { + let visible = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 900) + let width = min(Self.defaultSize.width, visible.width * 0.8) + let height = min(Self.defaultSize.height, visible.height * 0.85) + let origin = NSPoint(x: visible.midX - width / 2, y: visible.midY - height / 2) + return NSRect(origin: origin, size: NSSize(width: width, height: height)) + } +} + +// MARK: - NSWindowDelegate + +extension OpenAIDashboardLoginWindowController: NSWindowDelegate { + func windowWillClose(_ notification: Notification) { + guard let window = self.window else { return } + let webView = self.webView + let didLogin = self.loginDetected + self.webView = nil + self.window = nil + self.logger.info("Dashboard login window closing (logged_in=\(didLogin))") + if !didLogin { + self.onComplete?(false) + } + Self.activeControllers.removeAll { $0 === self } + WebKitTeardown.scheduleCleanup(owner: window, window: window, webView: webView) + } +} diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 6255396fb..3f68748d9 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -450,6 +450,15 @@ struct ProviderSettingsTokenAccountsRowView: View { .font(.caption2.weight(.medium)) .help("Make this the default account") } + if let dashboardLogin = self.descriptor.dashboardLogin { + Button("Dashboard") { + dashboardLogin(label) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .font(.caption2.weight(.medium)) + .help("Sign in to ChatGPT dashboard for this account") + } if self.descriptor.renameDefaultAccount != nil { Button(action: { self.renameText = label @@ -557,6 +566,15 @@ struct ProviderSettingsTokenAccountsRowView: View { .font(.caption2.weight(.medium)) .help("Make this the default account") } + if let dashboardLogin = self.descriptor.dashboardLogin { + Button(action: { dashboardLogin(account.displayName) }, label: { + Image(systemName: "globe") + .foregroundStyle(.secondary) + .imageScale(.small) + }) + .buttonStyle(.plain) + .help("Sign in to ChatGPT dashboard for this account") + } Button(action: { self.renameText = account.displayName self.renamingAccountID = account.id diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index e3eef9fcb..2cf86d36b 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -182,6 +182,7 @@ enum ProvidersPaneTestHarness { defaultAccountLabel: nil, renameDefaultAccount: nil, loginAction: nil, + dashboardLogin: nil, codexExplicitAccountsOnly: false) return ProviderListTestDescriptors( diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index f56ac12c9..14652b7fc 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -259,6 +259,19 @@ struct ProvidersPane: View { defaultAccountLabel: defaultAccountLabel, renameDefaultAccount: renameDefaultAccount, loginAction: loginAction, + dashboardLogin: provider == .codex && self.settings.openAIWebAccessEnabled + ? { [store] accountEmail in + let controller = OpenAIDashboardLoginWindowController( + accountEmail: accountEmail, + onComplete: { success in + guard success else { return } + Task { @MainActor in + await store.refreshOpenAIDashboardAfterLogin() + } + }) + controller.show() + } + : nil, codexExplicitAccountsOnly: codexExplicit) } diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index d7952cf6c..49b00599b 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -169,7 +169,7 @@ struct CodexProviderImplementation: ProviderImplementation { ProviderSettingsToggleDescriptor( id: "codex-openai-web-extras", title: "OpenAI web extras", - subtitle: "Show usage breakdown, credits history, and code review via chatgpt.com.", + subtitle: "Show usage breakdown, credits history, and code review via chatgpt.com. Use the globe icon on each account to sign in.", binding: extrasBinding, statusText: nil, actions: [], @@ -329,7 +329,7 @@ struct CodexProviderImplementation: ProviderImplementation { } @MainActor - func tokenAccountLoginAction(context _: ProviderSettingsContext) + func tokenAccountLoginAction(context: ProviderSettingsContext) -> (( _ setProgress: @escaping @MainActor (String) -> Void, _ addAccount: @escaping @MainActor (String, String) -> Void) async -> Bool)? @@ -360,6 +360,20 @@ struct CodexProviderImplementation: ProviderImplementation { label = "Account" } addAccount(label, uniqueDir) + + // Auto-open dashboard login if web extras is enabled. + if context.settings.openAIWebAccessEnabled { + setProgress("Signing in to dashboard…") + let controller = OpenAIDashboardLoginWindowController( + accountEmail: label, + onComplete: { success in + guard success else { return } + Task { @MainActor in + await context.store.refreshOpenAIDashboardAfterLogin() + } + }) + controller.show() + } return true case .missingBinary, .timedOut, .failed, .launchFailed: diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index 632ec2204..3597b7bd5 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -115,6 +115,9 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let loginAction: (( _ setProgress: @escaping @MainActor (String) -> Void, _ addAccount: @escaping @MainActor (String, String) -> Void) async -> Bool)? + /// Opens a per-account dashboard login window (chatgpt.com) for web extras. + /// The closure receives the account email (or nil for default). + let dashboardLogin: ((_ accountEmail: String?) -> Void)? /// Codex only: mirrors **CodexBar accounts only**; hides ~/.codex primary tab and adjusts copy. let codexExplicitAccountsOnly: Bool } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index d63fa34ae..de51f3849 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -841,6 +841,15 @@ extension UsageStore { } } + /// Called after the user signs in to chatgpt.com via the dashboard login window. + /// Skips cookie import (the WKWebView already has session cookies) and refreshes directly. + func refreshOpenAIDashboardAfterLogin() async { + self.openAIDashboardRequiresLogin = false + self.openAIDashboardCookieImportStatus = "Signed in via dashboard login window." + self.resetOpenAIWebDebugLog(context: "dashboard login") + await self.refreshOpenAIDashboardIfNeeded(force: true) + } + func importOpenAIDashboardBrowserCookiesNow() async { self.resetOpenAIWebDebugLog(context: "manual import") let targetEmail = self.codexAccountEmailForOpenAIDashboard() diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardOAuthSessionSeeder.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardOAuthSessionSeeder.swift new file mode 100644 index 000000000..72c09f9e9 --- /dev/null +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardOAuthSessionSeeder.swift @@ -0,0 +1,3 @@ +// This file is intentionally left minimal. OAuth session seeding via synthetic cookies +// does not work because chatgpt.com session tokens differ from auth.openai.com OAuth tokens. +// Dashboard login for OAuth accounts is handled by OpenAIDashboardLoginWindowController instead. From 2e9ba4cd44cd689f6e1ade20178ef9c7c26d6466 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Sun, 22 Mar 2026 22:01:40 -0400 Subject: [PATCH 09/25] feat: per-account OpenAI dashboard with workspace isolation - Add per-account WKWebsiteDataStore keyed by CODEX_HOME path (not email) so accounts with the same email but different workspaces get separate dashboard sessions - Add "OpenAI Web Dashboard" toggle in Multi-Account settings section - Show "Dashboard" / "Login to Dashboard" button per account row based on individual login state, with logout option in edit mode - Add "Usage Dashboard" / "Login to OpenAI Dashboard" in menu dropdown (below Buy Credits) reflecting active account's dashboard state - Fix WKWebView and rename TextField keyboard input by switching to NSApp.activationPolicy(.regular) temporarily - Fix dashboard window not closing Settings when dismissed - View-only mode for already-logged-in accounts (no auto-close polling) - Track dashboard login state via observable set on UsageStore for reactive SwiftUI updates - Suppress cost data when "CodexBar accounts only" is on with no accounts Co-Authored-By: Claude Opus 4.6 --- Sources/CodexBar/MenuContent.swift | 3 + Sources/CodexBar/MenuDescriptor.swift | 9 +- ...OpenAIDashboardLoginWindowController.swift | 276 +++++++++++++++--- .../PreferencesProviderDetailView.swift | 1 + .../PreferencesProviderSettingsRows.swift | 88 ++++-- .../PreferencesProvidersPane+Testing.swift | 2 + .../CodexBar/PreferencesProvidersPane.swift | 52 +++- .../Codex/CodexProviderImplementation.swift | 30 +- .../Shared/ProviderSettingsDescriptors.swift | 4 + .../StatusItemController+Actions.swift | 26 ++ .../CodexBar/StatusItemController+Menu.swift | 49 +++- Sources/CodexBar/UsageStore+Refresh.swift | 14 + Sources/CodexBar/UsageStore+TokenCost.swift | 9 + Sources/CodexBar/UsageStore.swift | 12 + .../OpenAIDashboardWebsiteDataStore.swift | 30 +- 15 files changed, 515 insertions(+), 90 deletions(-) diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index 94819697e..8679a897d 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -104,6 +104,8 @@ struct MenuContent: View { self.actions.quit() case let .copyError(message): self.actions.copyError(message) + case let .codexDashboard(accountIdentifier, viewOnly): + self.actions.openCodexDashboard(accountIdentifier, viewOnly) } } } @@ -121,6 +123,7 @@ struct MenuActions { let openAbout: () -> Void let quit: () -> Void let copyError: (String) -> Void + let openCodexDashboard: (_ accountIdentifier: String?, _ viewOnly: Bool) -> Void } @MainActor diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index b841ad779..8b7be20e7 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -46,6 +46,7 @@ struct MenuDescriptor { case about case quit case copyError(String) + case codexDashboard(accountIdentifier: String?, viewOnly: Bool) } var sections: [Section] @@ -348,7 +349,12 @@ struct MenuDescriptor { .appendActionMenuEntries(context: actionContext, entries: &entries) } - if metadata?.dashboardURL != nil { + // For Codex with multi-account + dashboard enabled, the dashboard item is added + // directly to the NSMenu (above Buy Credits), so skip the generic entry here. + let codexHandlesDashboard = targetProvider == .codex + && store.settings.codexMultipleAccountsEnabled + && store.settings.openAIWebAccessEnabled + if metadata?.dashboardURL != nil, !codexHandlesDashboard { entries.append(.action("Usage Dashboard", .dashboard)) } if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { @@ -480,6 +486,7 @@ extension MenuDescriptor.MenuAction { case .openTerminal: MenuDescriptor.MenuActionSystemImage.openTerminal.rawValue case .loginToProvider: MenuDescriptor.MenuActionSystemImage.loginToProvider.rawValue case .copyError: MenuDescriptor.MenuActionSystemImage.copyError.rawValue + case .codexDashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue } } } diff --git a/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift b/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift index 7dad13324..159d544ea 100644 --- a/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift +++ b/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift @@ -2,9 +2,18 @@ import AppKit import CodexBarCore import WebKit -/// Shows a WKWebView window where the user can sign in to chatgpt.com for a specific account. -/// The session cookies are stored in a per-account `WKWebsiteDataStore` so dashboard scraping -/// works independently for each OAuth account without needing browser cookie import. +/// NSWindow subclass that always accepts key status so the WKWebView inside can receive keyboard input. +private class WebViewWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } +} + +/// Shows a WKWebView window where the user can sign in to chatgpt.com. +/// +/// Supports two modes: +/// - **Dashboard-only** (`init(accountEmail:onComplete:)`): Signs in for dashboard cookie scraping. +/// - **Unified add-account** (`init(onAccountCreated:)`): Single sign-in that creates a CODEX_HOME +/// with `auth.json` (for API usage) AND stores dashboard cookies (for web extras) in one flow. @MainActor final class OpenAIDashboardLoginWindowController: NSWindowController, WKNavigationDelegate { private static let defaultSize = NSSize(width: 520, height: 700) @@ -16,9 +25,41 @@ final class OpenAIDashboardLoginWindowController: NSWindowController, WKNavigati private let logger = CodexBarLog.logger(LogCategories.openAIWebview) private var webView: WKWebView? private var accountEmail: String? - private var onComplete: ((Bool) -> Void)? + /// Display name for the window title (e.g. resolved email). Falls back to accountEmail. + private var displayName: String? private var loginDetected = false + // Dashboard-only mode callback. + private var onComplete: ((Bool) -> Void)? + + /// When true, the window auto-closes after login is detected. When false (view-only mode), + /// the window stays open for the user to browse the dashboard. + private var autoCloseOnLogin: Bool = true + + // Unified add-account mode callbacks. + private var onAccountCreated: ((_ email: String, _ codexHome: String) -> Void)? + private var onDismissedWithoutLogin: (() -> Void)? + private var isUnifiedMode: Bool { self.onAccountCreated != nil } + + /// JS that fetches the session endpoint to extract accessToken + user info. + private static let sessionExtractScript = """ + (async () => { + try { + const resp = await fetch('/api/auth/session', { credentials: 'include' }); + if (!resp.ok) return { error: 'status ' + resp.status }; + const json = await resp.json(); + return { + accessToken: json.accessToken || null, + email: (json.user && json.user.email) || null, + name: (json.user && json.user.name) || null, + accountId: (json.user && json.user.id) || null, + }; + } catch (e) { + return { error: String(e) }; + } + })(); + """ + /// JS snippet that checks if the page shows a logged-in dashboard (not a login/auth page). private static let loginCheckScript = """ (() => { @@ -29,24 +70,42 @@ final class OpenAIDashboardLoginWindowController: NSWindowController, WKNavigati body.includes('Welcome back') || body.includes('Log in') || body.includes('Sign up'); - const isDashboard = href.includes('/codex/settings/usage') && - !isLogin && - body.length > 200; + const isDashboard = !isLogin && body.length > 200 && + (href.includes('chatgpt.com') && !href.includes('auth')); return { href, isLogin, isDashboard, bodyLength: body.length }; })(); """ - init(accountEmail: String?, onComplete: ((Bool) -> Void)? = nil) { + /// Dashboard-only mode: sign in for an existing account's web extras. + /// - Parameter accountEmail: Unique key for cookie store isolation (CODEX_HOME path or email). + /// - Parameter displayName: Human-readable label for the window title. Falls back to accountEmail. + /// - Parameter viewOnly: When true, skips login detection polling and keeps the window open for browsing. + init(accountEmail: String?, displayName: String? = nil, viewOnly: Bool = false, onComplete: ((Bool) -> Void)? = nil) { self.accountEmail = Self.normalizeEmail(accountEmail) + self.displayName = displayName + self.autoCloseOnLogin = !viewOnly self.onComplete = onComplete super.init(window: nil) } + /// Unified add-account mode: single sign-in creates both CODEX_HOME credentials + dashboard cookies. + init( + onAccountCreated: @escaping (_ email: String, _ codexHome: String) -> Void, + onDismissed: (() -> Void)? = nil) + { + self.onAccountCreated = onAccountCreated + self.onDismissedWithoutLogin = onDismissed + super.init(window: nil) + } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + /// Saved activation policy to restore when the window closes. + private var previousActivationPolicy: NSApplication.ActivationPolicy? + func show() { Self.activeControllers.append(self) if self.window == nil { @@ -55,38 +114,49 @@ final class OpenAIDashboardLoginWindowController: NSWindowController, WKNavigati self.loginDetected = false self.load() self.window?.center() + + // Menu bar apps run as .accessory which prevents WKWebView from receiving + // keyboard input. Temporarily switch to .regular so the window gets full + // key event handling, then restore when the window closes. + self.previousActivationPolicy = NSApp.activationPolicy() + NSApp.setActivationPolicy(.regular) + self.showWindow(nil) + self.window?.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) + if let webView = self.webView { + self.window?.makeFirstResponder(webView) + } } private func buildWindow() { let config = WKWebViewConfiguration() - config.websiteDataStore = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: self.accountEmail) + // For unified mode, we don't know the email yet — use a temporary data store. + // It will be migrated after we learn the email from the session. + if self.isUnifiedMode { + config.websiteDataStore = .nonPersistent() + } else { + config.websiteDataStore = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: self.accountEmail) + } let webView = WKWebView(frame: .zero, configuration: config) webView.navigationDelegate = self + webView.uiDelegate = self webView.allowsBackForwardNavigationGestures = true - webView.translatesAutoresizingMaskIntoConstraints = false - - let container = NSView(frame: .zero) - container.addSubview(webView) - NSLayoutConstraint.activate([ - webView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - webView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - webView.topAnchor.constraint(equalTo: container.topAnchor), - webView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - - let emailLabel = self.accountEmail ?? "account" - let window = NSWindow( + + let titleLabel = self.displayName ?? self.accountEmail ?? "new account" + let window = WebViewWindow( contentRect: Self.defaultFrame(), - styleMask: [.titled, .closable, .resizable], + styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false) - window.title = "Sign in to ChatGPT — \(emailLabel)" + window.title = self.autoCloseOnLogin + ? "Sign in to ChatGPT — \(titleLabel)" + : "Dashboard — \(titleLabel)" window.isReleasedWhenClosed = false window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] - window.contentView = container + window.contentView = webView + window.initialFirstResponder = webView window.center() window.delegate = self @@ -102,39 +172,138 @@ final class OpenAIDashboardLoginWindowController: NSWindowController, WKNavigati // MARK: - WKNavigationDelegate func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + self.window?.makeFirstResponder(webView) self.checkLoginStatus(webView: webView) } + func windowDidBecomeKey(_ notification: Notification) { + if let webView = self.webView { + self.window?.makeFirstResponder(webView) + } + } + + // MARK: - Login detection + private func checkLoginStatus(webView: WKWebView) { - guard !self.loginDetected else { return } + guard !self.loginDetected, self.autoCloseOnLogin else { return } webView.evaluateJavaScript(Self.loginCheckScript) { [weak self] result, _ in guard let self, let dict = result as? [String: Any] else { return } let isDashboard = (dict["isDashboard"] as? Bool) ?? false - let href = (dict["href"] as? String) ?? "" if isDashboard { - self.logger.info("Dashboard login detected for \(self.accountEmail ?? "account")") + self.logger.info("Dashboard login detected for \(self.accountEmail ?? "new account")") self.loginDetected = true - // Brief delay to let cookies finalize, then close. - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.onComplete?(true) - self.close() + if self.isUnifiedMode { + self.extractSessionAndCreateAccount(webView: webView) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.onComplete?(true) + self.close() + } } return } - // If we're on chatgpt.com (not auth.openai.com), keep polling for login completion. - if href.contains("chatgpt.com"), !href.contains("auth.openai.com") { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - guard let self, let webView = self.webView else { return } - self.checkLoginStatus(webView: webView) - } + // Keep polling regardless of current URL. + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + guard let self, let webView = self.webView else { return } + self.checkLoginStatus(webView: webView) + } + } + } + + // MARK: - Unified mode: extract session and create account + + private func extractSessionAndCreateAccount(webView: WKWebView) { + self.window?.title = "Extracting account credentials…" + + webView.evaluateJavaScript(Self.sessionExtractScript) { [weak self] result, error in + guard let self else { return } + + guard let dict = result as? [String: Any], + let accessToken = dict["accessToken"] as? String, !accessToken.isEmpty + else { + let errorMsg = (result as? [String: Any])?["error"] as? String ?? error?.localizedDescription ?? "unknown" + self.logger.error("Failed to extract session token: \(errorMsg)") + self.onComplete?(false) + self.close() + return + } + + let email = (dict["email"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Account" + + self.logger.info("Session extracted for \(email)") + + // Create CODEX_HOME and write auth.json. + let accountsDir = ("~/.codex-accounts" as NSString).expandingTildeInPath + let uniqueDir = "\(accountsDir)/\(UUID().uuidString.prefix(8))" + + do { + try FileManager.default.createDirectory( + atPath: uniqueDir, + withIntermediateDirectories: true) + + let credentials = CodexOAuthCredentials( + accessToken: accessToken, + refreshToken: "", // Session-based; no refresh token available. + idToken: nil, + accountId: dict["accountId"] as? String, + lastRefresh: Date()) + + try CodexOAuthCredentialsStore.save( + credentials, + env: ["CODEX_HOME": uniqueDir]) + + self.logger.info("Saved auth.json to \(uniqueDir)") + } catch { + self.logger.error("Failed to save credentials: \(error)") + try? FileManager.default.removeItem(atPath: uniqueDir) + self.onComplete?(false) + self.close() + return + } + + // Copy cookies to the per-account persistent data store so dashboard scraping works. + self.migrateCookiesToPersistentStore(email: email, webView: webView) { + self.onAccountCreated?(email, uniqueDir) + self.close() + } + } + } + + /// Copies cookies from the non-persistent (temp) data store to the per-account persistent store. + private func migrateCookiesToPersistentStore( + email: String, webView: WKWebView, completion: @escaping () -> Void) + { + let sourceStore = webView.configuration.websiteDataStore.httpCookieStore + let targetStore = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: email) + let targetCookieStore = targetStore.httpCookieStore + + sourceStore.getAllCookies { cookies in + let chatgptCookies = cookies.filter { cookie in + cookie.domain.contains("openai.com") || cookie.domain.contains("chatgpt.com") + } + + guard !chatgptCookies.isEmpty else { + completion() + return + } + + let group = DispatchGroup() + for cookie in chatgptCookies { + group.enter() + targetCookieStore.setCookie(cookie) { group.leave() } + } + group.notify(queue: .main) { + completion() } } } + // MARK: - Helpers + private static func normalizeEmail(_ email: String?) -> String? { guard let raw = email?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } return raw.lowercased() @@ -149,6 +318,22 @@ final class OpenAIDashboardLoginWindowController: NSWindowController, WKNavigati } } +// MARK: - WKUIDelegate + +extension OpenAIDashboardLoginWindowController: WKUIDelegate { + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if navigationAction.targetFrame == nil || !(navigationAction.targetFrame?.isMainFrame ?? false) { + webView.load(navigationAction.request) + } + return nil + } +} + // MARK: - NSWindowDelegate extension OpenAIDashboardLoginWindowController: NSWindowDelegate { @@ -161,7 +346,22 @@ extension OpenAIDashboardLoginWindowController: NSWindowDelegate { self.logger.info("Dashboard login window closing (logged_in=\(didLogin))") if !didLogin { self.onComplete?(false) + self.onDismissedWithoutLogin?() } + + // Restore the original activation policy only if no other dashboard windows + // or visible app windows remain. Switching back to .accessory while the + // Settings window is open would dismiss it. + let otherDashboardWindows = Self.activeControllers.contains { $0 !== self } + let hasVisibleWindows = NSApp.windows.contains { win in + win !== window && win.isVisible && !win.className.contains("StatusBar") + } + if !otherDashboardWindows, !hasVisibleWindows, + let policy = self.previousActivationPolicy + { + NSApp.setActivationPolicy(policy) + } + Self.activeControllers.removeAll { $0 === self } WebKitTeardown.scheduleCleanup(owner: window, window: window, webView: webView) } diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 901d11355..0a9321c83 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -214,6 +214,7 @@ struct ProviderDetailView: View { private static let earlyToggleIDs: Set = [ "codex-multiple-accounts", "codex-explicit-accounts-only", + "codex-openai-web-dashboard", ] private var codexEarlyToggles: [ProviderSettingsToggleDescriptor] { diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 3f68748d9..d64f8b8b3 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -347,11 +347,13 @@ struct ProviderSettingsTokenAccountsRowView: View { } HStack(spacing: 10) { - Button("Open config file") { - self.descriptor.openConfigFile() + if !self.descriptor.codexExplicitAccountsOnly { + Button("Open config file") { + self.descriptor.openConfigFile() + } + .buttonStyle(.link) + .controlSize(.small) } - .buttonStyle(.link) - .controlSize(.small) Button("Reload") { self.descriptor.reloadFromDisk() } @@ -413,7 +415,7 @@ struct ProviderSettingsTokenAccountsRowView: View { if isRenaming { TextField("Name", text: self.$renameText) .font(.footnote) - .textFieldStyle(.plain) + .textFieldStyle(.roundedBorder) .frame(minWidth: 100, maxWidth: 180) .focused(self.$renameFieldFocused) .onSubmit { self.commitRenameDefault() } @@ -451,19 +453,22 @@ struct ProviderSettingsTokenAccountsRowView: View { .help("Make this the default account") } if let dashboardLogin = self.descriptor.dashboardLogin { - Button("Dashboard") { + let loggedIn = self.descriptor.isDashboardLoggedIn?(label) ?? false + Button(loggedIn ? "Dashboard" : "Login to Dashboard") { dashboardLogin(label) } .buttonStyle(.bordered) .controlSize(.mini) .font(.caption2.weight(.medium)) - .help("Sign in to ChatGPT dashboard for this account") + .help(loggedIn ? "Open ChatGPT dashboard" : "Sign in to ChatGPT dashboard for this account") } if self.descriptor.renameDefaultAccount != nil { Button(action: { self.renameText = label self.renamingDefault = true self.renamingAccountID = nil + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { self.renameFieldFocused = true } @@ -511,6 +516,7 @@ struct ProviderSettingsTokenAccountsRowView: View { } self.renamingDefault = false self.renameText = "" + self.restoreAccessoryPolicyIfNeeded() } @ViewBuilder @@ -529,10 +535,35 @@ struct ProviderSettingsTokenAccountsRowView: View { if isRenaming { TextField("Name", text: self.$renameText) .font(.footnote) - .textFieldStyle(.plain) + .textFieldStyle(.roundedBorder) .frame(minWidth: 100, maxWidth: 180) .focused(self.$renameFieldFocused) .onSubmit { self.commitRename(account: account) } + Spacer(minLength: 8) + if let dashboardLogout = self.descriptor.dashboardLogout, + self.descriptor.isDashboardLoggedIn?(account.token) ?? false + { + Button("Logout Dashboard") { + Task { + await dashboardLogout(account.token) + } + } + .buttonStyle(.bordered) + .controlSize(.mini) + .font(.caption2.weight(.medium)) + .help("Sign out of ChatGPT dashboard for this account") + } + Button("Delete") { + self.renamingAccountID = nil + self.renameText = "" + self.descriptor.removeAccount(account.id) + self.restoreAccessoryPolicyIfNeeded() + } + .buttonStyle(.bordered) + .controlSize(.mini) + .font(.caption2.weight(.medium)) + .foregroundStyle(.red) + .help("Remove account") } else { Group { if self.useCodexDiscoveryHints { @@ -557,7 +588,7 @@ struct ProviderSettingsTokenAccountsRowView: View { if rowActive { self.menuBarActiveBadge() } - if !self.useCodexDiscoveryHints, !isActive, !isRenaming { + if !self.useCodexDiscoveryHints, !isActive { Button("Make Default") { self.descriptor.setActiveIndex(index) } @@ -567,18 +598,23 @@ struct ProviderSettingsTokenAccountsRowView: View { .help("Make this the default account") } if let dashboardLogin = self.descriptor.dashboardLogin { - Button(action: { dashboardLogin(account.displayName) }, label: { - Image(systemName: "globe") - .foregroundStyle(.secondary) - .imageScale(.small) - }) - .buttonStyle(.plain) - .help("Sign in to ChatGPT dashboard for this account") + let loggedIn = self.descriptor.isDashboardLoggedIn?(account.token) ?? false + Button(loggedIn ? "Dashboard" : "Login to Dashboard") { + dashboardLogin(account.token) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .font(.caption2.weight(.medium)) + .help(loggedIn ? "Open ChatGPT dashboard" : "Sign in to ChatGPT dashboard for this account") } Button(action: { self.renameText = account.displayName self.renamingAccountID = account.id self.renamingDefault = false + // Menu bar apps (.accessory) can't receive keyboard input in TextFields. + // Temporarily switch to .regular so the rename field works. + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { self.renameFieldFocused = true } @@ -589,13 +625,6 @@ struct ProviderSettingsTokenAccountsRowView: View { }) .buttonStyle(.plain) .help("Rename tab") - Button(action: { self.descriptor.removeAccount(account.id) }, label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary.opacity(0.85)) - .imageScale(.small) - }) - .buttonStyle(.plain) - .help("Remove account") } } if self.descriptor.provider == .codex, !isRenaming { @@ -640,6 +669,19 @@ struct ProviderSettingsTokenAccountsRowView: View { } self.renamingAccountID = nil self.renameText = "" + self.restoreAccessoryPolicyIfNeeded() + } + + /// Restores `.accessory` activation policy after rename mode ends, + /// but only if no other windows (like dashboard login) are still open. + private func restoreAccessoryPolicyIfNeeded() { + let hasOtherWindows = NSApp.windows.contains { win in + win.isVisible && !win.className.contains("StatusBar") + && !win.className.contains("Settings") && !win.className.contains("Preferences") + } + if !hasOtherWindows { + NSApp.setActivationPolicy(.accessory) + } } private func codexAPIKeyAddSection() -> some View { diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index 2cf86d36b..de23cef84 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -183,6 +183,8 @@ enum ProvidersPaneTestHarness { renameDefaultAccount: nil, loginAction: nil, dashboardLogin: nil, + isDashboardLoggedIn: nil, + dashboardLogout: nil, codexExplicitAccountsOnly: false) return ProviderListTestDescriptors( diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 14652b7fc..1cc9ce4dc 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -260,18 +260,44 @@ struct ProvidersPane: View { renameDefaultAccount: renameDefaultAccount, loginAction: loginAction, dashboardLogin: provider == .codex && self.settings.openAIWebAccessEnabled - ? { [store] accountEmail in + ? { [store] accountIdentifier in + // Use accountIdentifier (CODEX_HOME path) as the unique key for cookie + // isolation and login tracking. This ensures accounts with the same email + // but different workspaces get separate dashboard sessions. + guard let key = accountIdentifier, !key.isEmpty else { return } + let normalizedKey = key.lowercased() + let alreadyLoggedIn = store.dashboardLoggedInEmails.contains(normalizedKey) + // Resolve email only for window title display. + let displayEmail = Self.resolveEmail(fromIdentifier: accountIdentifier) let controller = OpenAIDashboardLoginWindowController( - accountEmail: accountEmail, + accountEmail: key, + displayName: displayEmail, + viewOnly: alreadyLoggedIn, onComplete: { success in guard success else { return } Task { @MainActor in + OpenAIDashboardWebsiteDataStore.markDashboardLoggedIn( + forAccountEmail: key) + store.dashboardLoggedInEmails.insert(normalizedKey) await store.refreshOpenAIDashboardAfterLogin() } }) controller.show() } : nil, + isDashboardLoggedIn: provider == .codex + ? { [store] accountIdentifier in + guard let key = accountIdentifier, !key.isEmpty else { return false } + return store.dashboardLoggedInEmails.contains(key.lowercased()) + } + : nil, + dashboardLogout: provider == .codex + ? { [store] accountIdentifier in + guard let key = accountIdentifier, !key.isEmpty else { return } + await OpenAIDashboardWebsiteDataStore.clearStore(forAccountEmail: key) + store.dashboardLoggedInEmails.remove(key.lowercased()) + } + : nil, codexExplicitAccountsOnly: codexExplicit) } @@ -433,6 +459,28 @@ struct ProvidersPane: View { } } + /// Resolves an account identifier to an email address. + /// + /// For the default account tab the identifier is already an email. For token accounts + /// it's a CODEX_HOME path — we extract the email from the stored OAuth credentials. + static func resolveEmail(fromIdentifier identifier: String?) -> String? { + guard let id = identifier, !id.isEmpty else { return nil } + // If it looks like an email already (contains @), return as-is. + if id.contains("@") { return id } + // Otherwise treat it as a CODEX_HOME path and try to read the email from credentials. + let env = ["CODEX_HOME": id] + if let credentials = try? CodexOAuthCredentialsStore.load(env: env), + let idToken = credentials.idToken, + let payload = UsageFetcher.parseJWT(idToken) + { + let profileDict = payload["https://api.openai.com/profile"] as? [String: Any] + let email = (payload["email"] as? String) ?? (profileDict?["email"] as? String) + let trimmed = email?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { return trimmed } + } + return nil + } + private func truncated(_ text: String, prefix: String, maxLength: Int = 160) -> String { var message = text.trimmingCharacters(in: .whitespacesAndNewlines) if message.count > maxLength { diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 49b00599b..ff01f9250 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -101,7 +101,7 @@ struct CodexProviderImplementation: ProviderImplementation { id: "codex-multiple-accounts", title: "Multiple Accounts", subtitle: - "Enable multi-account support: add, reorder, and switch between multiple Codex accounts.", + "Enable multi-account support: add, reorder, and switch between multiple Codex accounts. Costs are disabled for accounts configured without the default machine codex home path (~/.codex).", binding: multipleAccountsBinding, statusText: nil, actions: [], @@ -113,8 +113,8 @@ struct CodexProviderImplementation: ProviderImplementation { id: "codex-explicit-accounts-only", title: "CodexBar accounts only", subtitle: - "Ignore ~/.codex as an implicit account. Use only rows under Accounts " + - "(OAuth, API key, or manual CODEX_HOME path).", + "Ignore the default machine codex home path (~/.codex). Use only rows under Accounts " + + "(OAuth, API key). Cost will only work for the default machine codex home path account.", binding: explicitAccountsBinding, statusText: nil, actions: [], @@ -167,13 +167,13 @@ struct CodexProviderImplementation: ProviderImplementation { onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( - id: "codex-openai-web-extras", - title: "OpenAI web extras", - subtitle: "Show usage breakdown, credits history, and code review via chatgpt.com. Use the globe icon on each account to sign in.", + id: "codex-openai-web-dashboard", + title: "OpenAI Web Dashboard", + subtitle: "Login to view OpenAI native dashboard after adding account via OAuth/API.", binding: extrasBinding, statusText: nil, actions: [], - isVisible: nil, + isVisible: { context.settings.codexMultipleAccountsEnabled }, onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), @@ -329,7 +329,7 @@ struct CodexProviderImplementation: ProviderImplementation { } @MainActor - func tokenAccountLoginAction(context: ProviderSettingsContext) + func tokenAccountLoginAction(context _: ProviderSettingsContext) -> (( _ setProgress: @escaping @MainActor (String) -> Void, _ addAccount: @escaping @MainActor (String, String) -> Void) async -> Bool)? @@ -360,20 +360,6 @@ struct CodexProviderImplementation: ProviderImplementation { label = "Account" } addAccount(label, uniqueDir) - - // Auto-open dashboard login if web extras is enabled. - if context.settings.openAIWebAccessEnabled { - setProgress("Signing in to dashboard…") - let controller = OpenAIDashboardLoginWindowController( - accountEmail: label, - onComplete: { success in - guard success else { return } - Task { @MainActor in - await context.store.refreshOpenAIDashboardAfterLogin() - } - }) - controller.show() - } return true case .missingBinary, .timedOut, .failed, .launchFailed: diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index 3597b7bd5..8bb4d67ac 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -118,6 +118,10 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { /// Opens a per-account dashboard login window (chatgpt.com) for web extras. /// The closure receives the account email (or nil for default). let dashboardLogin: ((_ accountEmail: String?) -> Void)? + /// Returns true if the given account email has dashboard cookies (is logged in). + let isDashboardLoggedIn: ((_ accountEmail: String?) -> Bool)? + /// Logs out of the dashboard for the given account identifier (clears cookies and tracked state). + let dashboardLogout: ((_ accountIdentifier: String?) async -> Void)? /// Codex only: mirrors **CodexBar accounts only**; hides ~/.codex primary tab and adjusts copy. let codexExplicitAccountsOnly: Bool } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index f3f15b569..0778f7589 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -30,6 +30,32 @@ extension StatusItemController { self.updater.checkForUpdates(nil) } + @objc func openCodexDashboard(_ sender: Any?) { + let menuItem = sender as? NSMenuItem + let payload = menuItem?.representedObject as? [String: Any] + let accountIdentifier = payload?["accountIdentifier"] as? String + let viewOnly = (payload?["viewOnly"] as? Bool) ?? false + + guard let key = accountIdentifier, !key.isEmpty else { return } + + // Resolve email only for window title display. + let displayEmail = ProvidersPane.resolveEmail(fromIdentifier: accountIdentifier) + + let controller = OpenAIDashboardLoginWindowController( + accountEmail: key, + displayName: displayEmail, + viewOnly: viewOnly, + onComplete: { [weak self] success in + guard success else { return } + Task { @MainActor in + OpenAIDashboardWebsiteDataStore.markDashboardLoggedIn(forAccountEmail: key) + self?.store.dashboardLoggedInEmails.insert(key.lowercased()) + await self?.store.refreshOpenAIDashboardAfterLogin() + } + }) + controller.show() + } + @objc func openDashboard() { let preferred = self.lastMenuProvider ?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 90df2e04f..fb643af12 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -972,9 +972,6 @@ extension StatusItemController { } if provider == .codex, self.settings.codexBuyCreditsMenuEnabled { - // Avoid an `NSMenuItem.separator()` right under the credits block: the card already ends the panel; a - // separator here reads as a duplicate line. Still separate after `menuCardExtraUsage` or when the - // primary ends with usage-only (no credits row). let buyCreditsSeparatorBefore = hasExtraUsage || (hasUsageBlock && !hasCredits) if buyCreditsSeparatorBefore { menu.addItem(.separator()) @@ -982,6 +979,15 @@ extension StatusItemController { menu.addItem(self.makeBuyCreditsItem()) } + // Codex dashboard item (below Buy Credits). + if provider == .codex, self.settings.openAIWebAccessEnabled, + self.settings.codexMultipleAccountsEnabled + { + if let dashboardItem = self.makeCodexDashboardItem() { + menu.addItem(dashboardItem) + } + } + if hasCost { let buyCreditsShown = provider == .codex && self.settings.codexBuyCreditsMenuEnabled let costView = UsageMenuCardCostSectionView( @@ -1076,6 +1082,11 @@ extension StatusItemController { case .about: (#selector(self.showSettingsAbout), nil) case .quit: (#selector(self.quit), nil) case let .copyError(message): (#selector(self.copyError(_:)), message) + case let .codexDashboard(accountIdentifier, viewOnly): + (#selector(self.openCodexDashboard(_:)), [ + "accountIdentifier": accountIdentifier as Any, + "viewOnly": viewOnly, + ] as [String: Any]) } } @@ -1198,6 +1209,38 @@ extension StatusItemController { } } + private func makeCodexDashboardItem() -> NSMenuItem? { + let impl = CodexProviderImplementation() + let accountIdentifier: String? + if let selected = self.settings.selectedTokenAccount(for: .codex) { + accountIdentifier = selected.token + } else { + accountIdentifier = impl.tokenAccountDefaultLabel(settings: self.settings) + } + + guard let key = accountIdentifier, !key.isEmpty else { return nil } + + let loggedIn = self.store.dashboardLoggedInEmails.contains(key.lowercased()) + + let title = loggedIn ? "Usage Dashboard" : "Login to OpenAI Dashboard" + let payload: [String: Any] = [ + "accountIdentifier": key, + "viewOnly": loggedIn, + ] + let item = NSMenuItem(title: title, action: #selector(self.openCodexDashboard(_:)), keyEquivalent: "") + item.target = self + item.representedObject = payload + if let image = NSImage( + systemSymbolName: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue, + accessibilityDescription: nil) + { + image.isTemplate = true + image.size = NSSize(width: 16, height: 16) + item.image = image + } + return item + } + private func makeBuyCreditsItem() -> NSMenuItem { let item = NSMenuItem(title: "Buy Credits...", action: #selector(self.openCreditsPurchase), keyEquivalent: "") item.target = self diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 7e3edab33..5c730bb9f 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -45,6 +45,20 @@ extension UsageStore { } } + // When "CodexBar accounts only" is on, do not fall back to ~/.codex implicit credentials. + // If there are no explicit accounts, clear usage and stop. + if provider == .codex, + self.settings.codexExplicitAccountsOnly, + tokenAccounts.isEmpty + { + await MainActor.run { + self.snapshots.removeValue(forKey: .codex) + self.errors[.codex] = nil + self.lastSourceLabels.removeValue(forKey: .codex) + } + return + } + let fetchContext = spec.makeFetchContext() let descriptor = spec.descriptor // Keep provider fetch work off MainActor so slow keychain/process reads don't stall menu/UI responsiveness. diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 37272e3f3..decf8a3f9 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -4,6 +4,15 @@ import Foundation extension UsageStore { /// Codex `…/sessions` directory for the credentials dir selected in Settings (path-based token accounts), or `nil` /// to use the process environment / `~/.codex`. + /// Whether cost data should be suppressed because "CodexBar accounts only" is on + /// and no explicit account is selected (would otherwise fall back to ~/.codex). + var shouldSuppressDefaultCostData: Bool { + guard self.settings.codexExplicitAccountsOnly else { return false } + let defaultActive = self.settings.isDefaultTokenAccountActive(for: .codex) + if defaultActive { return true } + return self.settings.selectedTokenAccount(for: .codex) == nil + } + func codexCostUsageSessionsRootForActiveSelection() -> URL? { guard let support = TokenAccountSupportCatalog.support(for: .codex), case .codexHome = support.injection diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index de51f3849..bf1c17823 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -47,6 +47,10 @@ final class UsageStore { var openAIDashboard: OpenAIDashboardSnapshot? var lastOpenAIDashboardError: String? var openAIDashboardRequiresLogin: Bool = false + /// Observable set of account emails that have completed dashboard login. + /// Mirrors `OpenAIDashboardWebsiteDataStore` UserDefaults so SwiftUI can react to changes. + var dashboardLoggedInEmails: Set = Set( + UserDefaults.standard.stringArray(forKey: "OpenAIDashboardLoggedInEmails") ?? []) var openAIDashboardCookieImportStatus: String? var openAIDashboardCookieImportDebugLog: String? var versions: [UsageProvider: String] = [:] @@ -1548,6 +1552,14 @@ extension UsageStore { self.tokenRefreshInFlight.insert(provider) defer { self.tokenRefreshInFlight.remove(provider) } + // When "CodexBar accounts only" is on and no explicit account is active, + // suppress cost data instead of falling back to ~/.codex. + if provider == .codex, self.shouldSuppressDefaultCostData { + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + return + } + let startedAt = Date() let providerText = provider.rawValue self.tokenCostLogger diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift index e23ef66a1..5999fd5b6 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift @@ -34,6 +34,33 @@ public enum OpenAIDashboardWebsiteDataStore { return store } + // MARK: - Dashboard login tracking + + private static let loggedInKey = "OpenAIDashboardLoggedInEmails" + + /// Returns true if the given account email has completed a dashboard login. + public static func isDashboardLoggedIn(forAccountEmail email: String?) -> Bool { + guard let normalized = normalizeEmail(email) else { return false } + let set = UserDefaults.standard.stringArray(forKey: loggedInKey) ?? [] + return set.contains(normalized) + } + + /// Marks an account as having completed dashboard login. + public static func markDashboardLoggedIn(forAccountEmail email: String?) { + guard let normalized = normalizeEmail(email) else { return } + var set = Set(UserDefaults.standard.stringArray(forKey: loggedInKey) ?? []) + set.insert(normalized) + UserDefaults.standard.set(Array(set), forKey: loggedInKey) + } + + /// Marks an account as logged out from dashboard. + public static func markDashboardLoggedOut(forAccountEmail email: String?) { + guard let normalized = normalizeEmail(email) else { return } + var set = Set(UserDefaults.standard.stringArray(forKey: loggedInKey) ?? []) + set.remove(normalized) + UserDefaults.standard.set(Array(set), forKey: loggedInKey) + } + /// Clears the persistent cookie store for a single account email. /// /// Note: this does *not* impact other accounts, and is safe to use when the stored session is "stuck" @@ -54,7 +81,8 @@ public enum OpenAIDashboardWebsiteDataStore { } } - // Remove from cache so a fresh instance is created on next access + // Mark as logged out and remove from cache so a fresh instance is created on next access + self.markDashboardLoggedOut(forAccountEmail: email) if let normalized = normalizeEmail(email) { self.cachedStores.removeValue(forKey: normalized) } From 67b8b867aa864a015db337e68d646aeaea1b2550 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Sun, 22 Mar 2026 22:29:12 -0400 Subject: [PATCH 10/25] fix: suppress browser cookie import in multi-account dashboard mode Each account uses its own WKWebView cookie store via the dashboard login window, making browser cookie import redundant and error-prone. Co-Authored-By: Claude Opus 4.6 --- .../Codex/CodexProviderRuntime.swift | 10 ++++++-- Sources/CodexBar/UsageStore.swift | 24 ++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift b/Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift index b09a3f9b3..2108655b3 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderRuntime.swift @@ -8,8 +8,14 @@ final class CodexProviderRuntime: ProviderRuntime { func perform(action: ProviderRuntimeAction, context: ProviderRuntimeContext) async { switch action { case let .openAIWebAccessToggled(enabled): - guard enabled == false else { return } - context.store.resetOpenAIWebState() + if enabled { + // Clear stale cookie import errors when enabling per-account dashboard mode. + if context.store.settings.codexMultipleAccountsEnabled { + context.store.openAIDashboardCookieImportStatus = nil + } + } else { + context.store.resetOpenAIWebState() + } case .forceSessionRefresh: break } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index bf1c17823..bf04e2f48 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -615,7 +615,11 @@ extension UsageStore { } func requestOpenAIDashboardRefreshIfStale(reason: String) { - guard self.isEnabled(.codex), self.settings.codexCookieSource.isEnabled else { return } + let multiAccountDash = self.settings.codexMultipleAccountsEnabled + && self.settings.openAIWebAccessEnabled + guard self.isEnabled(.codex), + self.settings.codexCookieSource.isEnabled || multiAccountDash + else { return } let now = Date() let refreshInterval = self.openAIWebRefreshIntervalSeconds() let lastUpdatedAt = self.openAIDashboard?.updatedAt ?? self.lastOpenAIDashboardSnapshot?.updatedAt @@ -669,7 +673,11 @@ extension UsageStore { } private func refreshOpenAIDashboardIfNeeded(force: Bool = false) async { - guard self.isEnabled(.codex), self.settings.codexCookieSource.isEnabled else { + let multiAccountDashboard = self.settings.codexMultipleAccountsEnabled + && self.settings.openAIWebAccessEnabled + guard self.isEnabled(.codex), + self.settings.codexCookieSource.isEnabled || multiAccountDashboard + else { self.resetOpenAIWebState() return } @@ -839,7 +847,11 @@ extension UsageStore { self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardError = nil self.openAIDashboardRequiresLogin = true - self.openAIDashboardCookieImportStatus = "Codex account changed; importing browser cookies…" + if self.settings.codexMultipleAccountsEnabled, self.settings.openAIWebAccessEnabled { + self.openAIDashboardCookieImportStatus = nil + } else { + self.openAIDashboardCookieImportStatus = "Codex account changed; importing browser cookies…" + } self.lastOpenAIDashboardCookieImportAttemptAt = nil self.lastOpenAIDashboardCookieImportEmail = nil } @@ -862,6 +874,12 @@ extension UsageStore { } private func importOpenAIDashboardCookiesIfNeeded(targetEmail: String?, force: Bool) async -> String? { + // In multi-account mode with per-account dashboard login, skip browser cookie import entirely. + // Each account uses its own WKWebView cookie store managed via the dashboard login window. + if self.settings.codexMultipleAccountsEnabled, self.settings.openAIWebAccessEnabled { + return targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + } + let normalizedTarget = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let allowAnyAccount = normalizedTarget == nil || normalizedTarget?.isEmpty == true let cookieSource = self.settings.codexCookieSource From 3747c2c7895520360decb9d67d627ad22ff60dce Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 09:39:47 -0400 Subject: [PATCH 11/25] fix: clear stale Codex data, reset account on multi-account off, stable dashboard key P1: Clear all cached credits/dashboard/cost data when codexExplicitAccountsOnly has no accounts, not just snapshots/errors/sourceLabel. P1: Reset selected token account to primary when multi-account toggle is turned off, so Codex stops fetching against a hidden CODEX_HOME override. P2: Use stable ~/.codex path as the default account dashboard key instead of the editable display label, so renaming doesn't break dashboard session persistence. Co-Authored-By: Claude Opus 4.6 --- .../PreferencesProviderSettingsRows.swift | 9 +++++++-- .../Codex/CodexProviderImplementation.swift | 10 ++++++++++ .../CodexBar/StatusItemController+Menu.swift | 4 ++-- Sources/CodexBar/UsageStore+Refresh.swift | 17 ++++++++++++++++- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index d64f8b8b3..8739f948e 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -401,6 +401,11 @@ struct ProviderSettingsTokenAccountsRowView: View { .foregroundStyle(Color.accentColor) } + /// Stable key for the default account's dashboard session (not the editable display label). + private var defaultAccountDashboardKey: String { + ("~/.codex" as NSString).expandingTildeInPath + } + @ViewBuilder private func defaultAccountTab(label: String, isActive: Bool) -> some View { let isRenaming = self.renamingDefault && self.descriptor.renameDefaultAccount != nil @@ -453,9 +458,9 @@ struct ProviderSettingsTokenAccountsRowView: View { .help("Make this the default account") } if let dashboardLogin = self.descriptor.dashboardLogin { - let loggedIn = self.descriptor.isDashboardLoggedIn?(label) ?? false + let loggedIn = self.descriptor.isDashboardLoggedIn?(self.defaultAccountDashboardKey) ?? false Button(loggedIn ? "Dashboard" : "Login to Dashboard") { - dashboardLogin(label) + dashboardLogin(self.defaultAccountDashboardKey) } .buttonStyle(.bordered) .controlSize(.mini) diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index ff01f9250..168b3c036 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -83,6 +83,16 @@ struct CodexProviderImplementation: ProviderImplementation { withAnimation(.easeInOut(duration: 0.2)) { context.settings.codexMultipleAccountsEnabled = newValue } + if !newValue { + // Revert to primary account so Codex stops using a hidden token override. + context.settings.setActiveTokenAccountIndex(-1, for: .codex) + context.settings.codexExplicitAccountsOnly = false + Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await context.store.refreshProvider(.codex) + } + } + } }) let explicitAccountsBinding = Binding( diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 0c2db394e..8df9d180b 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1226,12 +1226,12 @@ extension StatusItemController { } private func makeCodexDashboardItem() -> NSMenuItem? { - let impl = CodexProviderImplementation() let accountIdentifier: String? if let selected = self.settings.selectedTokenAccount(for: .codex) { accountIdentifier = selected.token } else { - accountIdentifier = impl.tokenAccountDefaultLabel(settings: self.settings) + // Use the stable ~/.codex path as key for the default account dashboard session. + accountIdentifier = ("~/.codex" as NSString).expandingTildeInPath } guard let key = accountIdentifier, !key.isEmpty else { return nil } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 7b511125b..4340b5a9e 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -46,7 +46,7 @@ extension UsageStore { } // When "CodexBar accounts only" is on, do not fall back to ~/.codex implicit credentials. - // If there are no explicit accounts, clear usage and stop. + // If there are no explicit accounts, clear all cached Codex data and stop. if provider == .codex, self.settings.codexExplicitAccountsOnly, tokenAccounts.isEmpty @@ -55,7 +55,22 @@ extension UsageStore { self.snapshots.removeValue(forKey: .codex) self.errors[.codex] = nil self.lastSourceLabels.removeValue(forKey: .codex) + self.lastFetchAttempts.removeValue(forKey: .codex) + self.accountSnapshots.removeValue(forKey: .codex) + self.tokenSnapshots.removeValue(forKey: .codex) + self.tokenErrors[.codex] = nil + self.allAccountCredits.removeValue(forKey: .codex) + self.credits = nil + self.lastCreditsError = nil + self.statuses.removeValue(forKey: .codex) + self.lastKnownSessionRemaining.removeValue(forKey: .codex) + self.lastKnownSessionWindowSource.removeValue(forKey: .codex) + self.lastTokenFetchAt.removeValue(forKey: .codex) + self.lastTokenCostSelectionIdentity.removeValue(forKey: .codex) + self.failureGates[.codex]?.reset() + self.tokenFailureGates[.codex]?.reset() } + self.resetOpenAIWebState() return } From 0aaf96c5c393a7b7e880c4ecea3801dee460d425 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 10:31:20 -0400 Subject: [PATCH 12/25] fix: use account identifier for dashboard refresh, hide API-key dashboard, guard Add Account P1: refreshOpenAIDashboardIfNeeded now uses CODEX_HOME path (same key as the dashboard login window) instead of email, so per-account WKWebView cookie stores are correctly matched after login. P2: Skip dashboard login/logout UI for API-key accounts (apikey: prefix) to prevent raw secrets from leaking into UserDefaults and menu payloads. P2: Hide "Add Account..." menu item when codexMultipleAccountsEnabled is off, preventing silent switch to a hidden account. Co-Authored-By: Claude Opus 4.6 --- .../PreferencesProviderSettingsRows.swift | 5 ++++- .../Codex/CodexProviderImplementation.swift | 5 +++-- .../CodexBar/StatusItemController+Menu.swift | 2 +- Sources/CodexBar/UsageStore.swift | 22 +++++++++++++++---- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 8739f948e..ff2de07b3 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -546,6 +546,7 @@ struct ProviderSettingsTokenAccountsRowView: View { .onSubmit { self.commitRename(account: account) } Spacer(minLength: 8) if let dashboardLogout = self.descriptor.dashboardLogout, + !account.token.hasPrefix("apikey:"), self.descriptor.isDashboardLoggedIn?(account.token) ?? false { Button("Logout Dashboard") { @@ -602,7 +603,9 @@ struct ProviderSettingsTokenAccountsRowView: View { .font(.caption2.weight(.medium)) .help("Make this the default account") } - if let dashboardLogin = self.descriptor.dashboardLogin { + if let dashboardLogin = self.descriptor.dashboardLogin, + !account.token.hasPrefix("apikey:") + { let loggedIn = self.descriptor.isDashboardLoggedIn?(account.token) ?? false Button(loggedIn ? "Dashboard" : "Login to Dashboard") { dashboardLogin(account.token) diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 168b3c036..51ec0fd70 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -291,10 +291,11 @@ struct CodexProviderImplementation: ProviderImplementation { } @MainActor - func loginMenuAction(context _: ProviderMenuLoginContext) + func loginMenuAction(context: ProviderMenuLoginContext) -> (label: String, action: MenuDescriptor.MenuAction)? { - ("Add Account...", .addTokenAccount(.codex)) + guard context.settings.codexMultipleAccountsEnabled else { return nil } + return ("Add Account...", .addTokenAccount(.codex)) } @MainActor diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 8df9d180b..0411cdfbf 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1234,7 +1234,7 @@ extension StatusItemController { accountIdentifier = ("~/.codex" as NSString).expandingTildeInPath } - guard let key = accountIdentifier, !key.isEmpty else { return nil } + guard let key = accountIdentifier, !key.isEmpty, !key.hasPrefix("apikey:") else { return nil } let loggedIn = self.store.dashboardLoggedInEmails.contains(key.lowercased()) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 775c3864d..cab91a193 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -652,7 +652,7 @@ extension UsageStore { return } - let targetEmail = self.codexAccountEmailForOpenAIDashboard() + let targetEmail = self.codexDashboardAccountIdentifier() self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail) let now = Date() @@ -736,7 +736,7 @@ extension UsageStore { } catch let OpenAIDashboardFetcher.FetchError.noDashboardData(body) { // Often indicates a missing/stale session without an obvious login prompt. Retry once after // importing cookies from the user's browser. - let targetEmail = self.codexAccountEmailForOpenAIDashboard() + let targetEmail = self.codexDashboardAccountIdentifier() var effectiveEmail = targetEmail if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) { effectiveEmail = imported @@ -760,7 +760,7 @@ extension UsageStore { await self.applyOpenAIDashboardFailure(message: error.localizedDescription) } } catch OpenAIDashboardFetcher.FetchError.loginRequired { - let targetEmail = self.codexAccountEmailForOpenAIDashboard() + let targetEmail = self.codexDashboardAccountIdentifier() var effectiveEmail = targetEmail if let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) { effectiveEmail = imported @@ -838,7 +838,7 @@ extension UsageStore { func importOpenAIDashboardBrowserCookiesNow() async { self.resetOpenAIWebDebugLog(context: "manual import") - let targetEmail = self.codexAccountEmailForOpenAIDashboard() + let targetEmail = self.codexDashboardAccountIdentifier() _ = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) await self.refreshOpenAIDashboardIfNeeded(force: true) } @@ -1042,6 +1042,20 @@ extension UsageStore { if let imported, !imported.isEmpty { return imported } return nil } + + /// In multi-account dashboard mode, returns the same identifier used by the dashboard login + /// window (CODEX_HOME path or `~/.codex` for default) so the refresh reads the correct + /// WKWebsiteDataStore. Falls back to email-based lookup for non-multi-account mode. + func codexDashboardAccountIdentifier() -> String? { + if self.settings.codexMultipleAccountsEnabled, self.settings.openAIWebAccessEnabled { + if let selected = self.settings.selectedTokenAccount(for: .codex) { + return selected.token + } + // Default account uses ~/.codex path + return ("~/.codex" as NSString).expandingTildeInPath + } + return self.codexAccountEmailForOpenAIDashboard() + } } extension UsageStore { From 3d1f8a7bce87a74c99af1a48b8a9dba60b932781 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 14:00:37 -0400 Subject: [PATCH 13/25] fix: multi-account UX polish - Remove stale Add Account label override; use standard Switch/Add Account - Fix Usage history (30 days) missing in multi-account sectioned card path - Use card-style items for Credits history, Usage breakdown, Usage history buttons so chevron aligns with Subscription Utilization - Fix stale openAIDashboardCookieImportStatus shown when enabling multi-account - Fix dashboard error message showing raw file path in multi-account mode - Persist widget snapshot immediately on multi-account toggle for instant update - Include codexMultipleAccountsEnabled in menu structure fingerprint so account switcher disappears immediately on toggle off --- Sources/CodexBar/MenuDescriptor.swift | 45 ++++-- .../Codex/CodexProviderImplementation.swift | 14 +- .../CodexBar/StatusItemController+Menu.swift | 125 ++++++++-------- Sources/CodexBar/StatusItemController.swift | 1 + Sources/CodexBar/UsageStore+OpenAIWeb.swift | 7 + .../CodexBar/UsageStore+TokenAccounts.swift | 96 ++++++++++-- Tests/CodexBarTests/StatusMenuTests.swift | 141 +++++++++++++++++- .../UsageStoreCoverageTests.swift | 28 ++++ 8 files changed, 363 insertions(+), 94 deletions(-) diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 8b7be20e7..a0b78ac66 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -329,12 +329,7 @@ struct MenuDescriptor { } else { let loginAction = self.switchAccountTarget(for: provider, store: store) let hasAccount = self.hasAccount(for: provider, store: store, account: account) - // For Codex, switching is done via account tabs — this action is only for adding new accounts. - let accountLabel: String = if targetProvider == .codex { - "Add Account..." - } else { - hasAccount ? "Switch Account..." : "Add Account..." - } + let accountLabel = hasAccount ? "Switch Account..." : "Add Account..." entries.append(.action(accountLabel, loginAction)) } } @@ -349,13 +344,15 @@ struct MenuDescriptor { .appendActionMenuEntries(context: actionContext, entries: &entries) } - // For Codex with multi-account + dashboard enabled, the dashboard item is added - // directly to the NSMenu (above Buy Credits), so skip the generic entry here. - let codexHandlesDashboard = targetProvider == .codex - && store.settings.codexMultipleAccountsEnabled - && store.settings.openAIWebAccessEnabled - if metadata?.dashboardURL != nil, !codexHandlesDashboard { - entries.append(.action("Usage Dashboard", .dashboard)) + if metadata?.dashboardURL != nil { + if let codexDashboardEntry = self.codexDashboardActionEntry( + for: targetProvider, + store: store) + { + entries.append(codexDashboardEntry) + } else { + entries.append(.action("Usage Dashboard", .dashboard)) + } } if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { entries.append(.action("Status Page", .statusPage)) @@ -368,6 +365,28 @@ struct MenuDescriptor { return Section(entries: entries) } + private static func codexDashboardActionEntry( + for provider: UsageProvider?, + store: UsageStore) -> Entry? + { + guard provider == .codex else { return nil } + guard store.settings.codexMultipleAccountsEnabled else { return nil } + guard store.settings.openAIWebAccessEnabled else { return nil } + + let accountIdentifier: String? + if let selected = store.settings.selectedTokenAccount(for: .codex) { + accountIdentifier = selected.token + } else { + accountIdentifier = ("~/.codex" as NSString).expandingTildeInPath + } + + guard let key = accountIdentifier, !key.isEmpty, !key.hasPrefix("apikey:") else { return nil } + + let loggedIn = store.dashboardLoggedInEmails.contains(key.lowercased()) + let title = loggedIn ? "Usage Dashboard" : "Login to OpenAI Dashboard" + return .action(title, .codexDashboard(accountIdentifier: key, viewOnly: loggedIn)) + } + private static func metaSection(updateReady: Bool) -> Section { var entries: [Entry] = [] if updateReady { diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 51ec0fd70..11c91f301 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -83,10 +83,22 @@ struct CodexProviderImplementation: ProviderImplementation { withAnimation(.easeInOut(duration: 0.2)) { context.settings.codexMultipleAccountsEnabled = newValue } - if !newValue { + if newValue { + // Clear any stale single-account cookie import errors when entering multi-account mode. + context.store.openAIDashboardCookieImportStatus = nil + // Persist current snapshot immediately so the widget reflects the change without + // waiting for the full provider refresh to complete (~15s). + context.store.persistWidgetSnapshot(reason: "multi-account-enabled") + Task { @MainActor in + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await context.store.refreshProvider(.codex) + } + } + } else { // Revert to primary account so Codex stops using a hidden token override. context.settings.setActiveTokenAccountIndex(-1, for: .codex) context.settings.codexExplicitAccountsOnly = false + context.store.persistWidgetSnapshot(reason: "multi-account-disabled") Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { await context.store.refreshProvider(.codex) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 0411cdfbf..9342689f4 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -235,7 +235,8 @@ extension StatusItemController { currentProvider: currentProvider, context: openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) - if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) { + let addedUsageHistory = self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) + if addedUsageHistory { menu.addItem(.separator()) } } @@ -287,7 +288,8 @@ extension StatusItemController { currentProvider: currentProvider, context: openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) - if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) { + let addedUsageHistory = self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) + if addedUsageHistory { menu.addItem(.separator()) } self.addActionableSections(descriptor.sections, to: menu, width: menuWidth) @@ -298,6 +300,7 @@ extension StatusItemController { let hasCreditsHistory: Bool let hasCostHistory: Bool let hasOpenAIWebMenuItems: Bool + let usesSectionedMenuCard: Bool } private struct MenuCardContext { @@ -322,11 +325,14 @@ extension StatusItemController { (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) let hasOpenAIWebMenuItems = !showAllTokenAccounts && (hasCreditsHistory || hasUsageBreakdown || hasCostHistory) + let usesSectionedMenuCard = hasOpenAIWebMenuItems && + (currentProvider != .codex || self.settings.codexMultipleAccountsEnabled) return OpenAIWebContext( hasUsageBreakdown: hasUsageBreakdown, hasCreditsHistory: hasCreditsHistory, hasCostHistory: hasCostHistory, - hasOpenAIWebMenuItems: hasOpenAIWebMenuItems) + hasOpenAIWebMenuItems: hasOpenAIWebMenuItems, + usesSectionedMenuCard: usesSectionedMenuCard) } private func addProviderSwitcherIfNeeded( @@ -412,7 +418,9 @@ extension StatusItemController { self.menuCardModel( for: context.currentProvider, snapshotOverride: accountSnapshot.snapshot, - errorOverride: accountSnapshot.error) + errorOverride: accountSnapshot.error, + sourceLabelOverride: accountSnapshot.sourceLabel, + usesSnapshotOverride: true) } if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { menu.addItem(self.makeMenuCardItem( @@ -441,7 +449,7 @@ extension StatusItemController { } guard let model = self.menuCardModel(for: context.selectedProvider) else { return false } - if context.openAIContext.hasOpenAIWebMenuItems { + if context.openAIContext.usesSectionedMenuCard { let webItems = OpenAIWebMenuItems( hasUsageBreakdown: context.openAIContext.hasUsageBreakdown, hasCreditsHistory: context.openAIContext.hasCreditsHistory, @@ -497,9 +505,14 @@ extension StatusItemController { if context.hasCostHistory { _ = self.addCostHistorySubmenu(to: menu, provider: currentProvider) } - } else if currentProvider == .codex, context.hasCreditsHistory { - // Codex hides the credits card row; still expose credits history as a top-level submenu. - _ = self.addCreditsHistorySubmenu(to: menu) + } else if currentProvider == .codex, context.hasCreditsHistory || context.hasCostHistory { + // Codex uses a sectioned card; still expose web history items as top-level menu items. + if context.hasCreditsHistory { + _ = self.addCreditsHistorySubmenu(to: menu) + } + if context.hasCostHistory { + _ = self.addCostHistorySubmenu(to: menu, provider: currentProvider) + } } menu.addItem(.separator()) } @@ -989,15 +1002,6 @@ extension StatusItemController { menu.addItem(self.makeBuyCreditsItem()) } - // Codex dashboard item (below Buy Credits). - if provider == .codex, self.settings.openAIWebAccessEnabled, - self.settings.codexMultipleAccountsEnabled - { - if let dashboardItem = self.makeCodexDashboardItem() { - menu.addItem(dashboardItem) - } - } - if hasCost { let buyCreditsShown = provider == .codex && self.settings.codexBuyCreditsMenuEnabled let costView = UsageMenuCardCostSectionView( @@ -1225,38 +1229,6 @@ extension StatusItemController { } } - private func makeCodexDashboardItem() -> NSMenuItem? { - let accountIdentifier: String? - if let selected = self.settings.selectedTokenAccount(for: .codex) { - accountIdentifier = selected.token - } else { - // Use the stable ~/.codex path as key for the default account dashboard session. - accountIdentifier = ("~/.codex" as NSString).expandingTildeInPath - } - - guard let key = accountIdentifier, !key.isEmpty, !key.hasPrefix("apikey:") else { return nil } - - let loggedIn = self.store.dashboardLoggedInEmails.contains(key.lowercased()) - - let title = loggedIn ? "Usage Dashboard" : "Login to OpenAI Dashboard" - let payload: [String: Any] = [ - "accountIdentifier": key, - "viewOnly": loggedIn, - ] - let item = NSMenuItem(title: title, action: #selector(self.openCodexDashboard(_:)), keyEquivalent: "") - item.target = self - item.representedObject = payload - if let image = NSImage( - systemSymbolName: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue, - accessibilityDescription: nil) - { - image.isTemplate = true - image.size = NSSize(width: 16, height: 16) - item.image = image - } - return item - } - private func makeBuyCreditsItem() -> NSMenuItem { let item = NSMenuItem(title: "Buy Credits...", action: #selector(self.openCreditsPurchase), keyEquivalent: "") item.target = self @@ -1271,33 +1243,52 @@ extension StatusItemController { @discardableResult private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeCreditsHistorySubmenu() else { return false } - let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "") - item.isEnabled = true - item.submenu = submenu - menu.addItem(item) + menu.addItem(self.makeWebHistoryMenuItem( + title: "Credits history", + id: "creditsHistorySubmenu", + submenu: submenu)) return true } @discardableResult private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeUsageBreakdownSubmenu() else { return false } - let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") - item.isEnabled = true - item.submenu = submenu - menu.addItem(item) + menu.addItem(self.makeWebHistoryMenuItem( + title: "Usage breakdown", + id: "usageBreakdownSubmenu", + submenu: submenu)) return true } @discardableResult private func addCostHistorySubmenu(to menu: NSMenu, provider: UsageProvider) -> Bool { guard let submenu = self.makeCostHistorySubmenu(provider: provider) else { return false } - let item = NSMenuItem(title: "Usage history (30 days)", action: nil, keyEquivalent: "") - item.isEnabled = true - item.submenu = submenu - menu.addItem(item) + menu.addItem(self.makeWebHistoryMenuItem( + title: "Usage history (30 days)", + id: "costHistorySubmenu", + submenu: submenu)) return true } + private func makeWebHistoryMenuItem(title: String, id: String, submenu: NSMenu) -> NSMenuItem { + let width: CGFloat = 310 + return self.makeMenuCardItem( + HStack(spacing: 0) { + Text(title) + .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 14) + .padding(.trailing, 28) + .padding(.vertical, 8) + }, + id: id, + width: width, + submenu: submenu, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0) + } + private func makeUsageSubmenu( provider: UsageProvider, snapshot: UsageSnapshot?, @@ -1489,12 +1480,14 @@ extension StatusItemController { private func menuCardModel( for provider: UsageProvider?, snapshotOverride: UsageSnapshot? = nil, - errorOverride: String? = nil) -> UsageMenuCardView.Model? + errorOverride: String? = nil, + sourceLabelOverride: String? = nil, + usesSnapshotOverride: Bool = false) -> UsageMenuCardView.Model? { let target = provider ?? self.store.enabledProvidersForDisplay().first ?? .codex let metadata = self.store.metadata(for: target) - let snapshot = snapshotOverride ?? self.store.snapshot(for: target) + let snapshot = usesSnapshotOverride ? snapshotOverride : self.store.snapshot(for: target) let credits: CreditsSnapshot? let creditsError: String? let dashboard: OpenAIDashboardSnapshot? @@ -1502,7 +1495,7 @@ extension StatusItemController { let tokenSnapshot: CostUsageTokenSnapshot? let tokenError: String? var codexCreditsUnlimited = false - if target == .codex, snapshotOverride == nil { + if target == .codex, !usesSnapshotOverride { let active = self.store.codexActiveMenuCredits() credits = active.snapshot creditsError = active.error @@ -1511,7 +1504,7 @@ extension StatusItemController { dashboardError = self.store.lastOpenAIDashboardError tokenSnapshot = self.store.tokenSnapshot(for: target) tokenError = self.store.tokenError(for: target) - } else if target == .claude || target == .vertexai, snapshotOverride == nil { + } else if target == .claude || target == .vertexai, !usesSnapshotOverride { credits = nil creditsError = nil dashboard = nil @@ -1527,7 +1520,7 @@ extension StatusItemController { tokenError = nil } - let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil + let sourceLabel = usesSnapshotOverride ? sourceLabelOverride : self.store.sourceLabel(for: target) let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto let now = Date() let weeklyPace = snapshot?.secondary.flatMap { window in diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index bf65a9d38..d6e6cfe73 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -297,6 +297,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin "\(s.openAIWebAccessEnabled)", "\(s.codexBuyCreditsMenuEnabled)", "\(s.codexExplicitAccountsOnly)", + "\(s.codexMultipleAccountsEnabled)", overview, "\(s.mergedMenuLastSelectedWasOverview)", selectedMenu, diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index af164cc62..006224d4b 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -28,6 +28,13 @@ extension UsageStore { || lower.contains("continue with microsoft") guard looksLikePublicLanding || looksLoggedOut else { return nil } + + // In multi-account mode, dashboard access uses per-account login (not browser cookies). + // Direct the user to the "Login to OpenAI Dashboard" menu action instead. + if self.settings.codexMultipleAccountsEnabled, self.settings.openAIWebAccessEnabled { + return "OpenAI web dashboard: not signed in. Use \"Login to OpenAI Dashboard\" to authenticate this account." + } + let emailLabel = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let targetLabel = (emailLabel?.isEmpty == false) ? emailLabel! : "your OpenAI account" if let status, !status.isEmpty { diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 66e388676..8c649b867 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -2,15 +2,32 @@ import CodexBarCore import Foundation struct TokenAccountUsageSnapshot: Identifiable { - let id: UUID - let account: ProviderTokenAccount + let id: String + let account: ProviderTokenAccount? + let displayName: String let snapshot: UsageSnapshot? let error: String? let sourceLabel: String? init(account: ProviderTokenAccount, snapshot: UsageSnapshot?, error: String?, sourceLabel: String?) { - self.id = account.id + self.id = account.id.uuidString self.account = account + self.displayName = account.displayName + self.snapshot = snapshot + self.error = error + self.sourceLabel = sourceLabel + } + + init( + provider: UsageProvider, + defaultDisplayName: String, + snapshot: UsageSnapshot?, + error: String?, + sourceLabel: String?) + { + self.id = "default-\(provider.rawValue)" + self.account = nil + self.displayName = defaultDisplayName self.snapshot = snapshot self.error = error self.sourceLabel = sourceLabel @@ -23,14 +40,21 @@ extension UsageStore { return self.settings.tokenAccounts(for: provider) } - func shouldFetchAllTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) -> Bool { + func shouldFetchAllTokenAccounts( + provider: UsageProvider, + accounts: [ProviderTokenAccount], + defaultAccountLabel: String? = nil) -> Bool + { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return false } - return self.settings.showAllTokenAccountsInMenu && accounts.count > 1 + guard self.settings.showAllTokenAccountsInMenu else { return false } + let defaultLabel = self.defaultTokenAccountLabel(for: provider, override: defaultAccountLabel) + return accounts.count + (defaultLabel != nil ? 1 : 0) > 1 } func refreshTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) async { let selectedAccount = self.settings.selectedTokenAccount(for: provider) let defaultIsActive = self.settings.isDefaultTokenAccountActive(for: provider) + let defaultAccountLabel = self.defaultTokenAccountLabel(for: provider) let limitedAccounts = self.limitedTokenAccounts(accounts, selected: selectedAccount) var snapshots: [TokenAccountUsageSnapshot] = [] var historySamples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)] = [] @@ -51,13 +75,17 @@ extension UsageStore { } } - // When the default account is active, fetch it with no token override - // so it uses the standard ~/.codex credentials. - if defaultIsActive { + // Fetch the default account separately so show-all menus can render it alongside explicit rows. + if let defaultAccountLabel { let outcome = await self.fetchOutcome(provider: provider, override: nil) - selectedOutcome = outcome - if case let .success(result) = outcome.result { - selectedSnapshot = result.usage.scoped(to: provider) + let resolved = self.resolveDefaultAccountOutcome( + outcome, + provider: provider, + displayName: defaultAccountLabel) + snapshots.insert(resolved.snapshot, at: 0) + if defaultIsActive { + selectedOutcome = outcome + selectedSnapshot = resolved.usage } } @@ -175,6 +203,33 @@ extension UsageStore { } } + private func resolveDefaultAccountOutcome( + _ outcome: ProviderFetchOutcome, + provider: UsageProvider, + displayName: String) -> ResolvedAccountOutcome + { + switch outcome.result { + case let .success(result): + let scoped = result.usage.scoped(to: provider) + let labeled = self.applyDisplayLabel(scoped, provider: provider, label: displayName) + let snapshot = TokenAccountUsageSnapshot( + provider: provider, + defaultDisplayName: displayName, + snapshot: labeled, + error: nil, + sourceLabel: result.sourceLabel) + return ResolvedAccountOutcome(snapshot: snapshot, usage: labeled) + case let .failure(error): + let snapshot = TokenAccountUsageSnapshot( + provider: provider, + defaultDisplayName: displayName, + snapshot: nil, + error: error.localizedDescription, + sourceLabel: nil) + return ResolvedAccountOutcome(snapshot: snapshot, usage: nil) + } + } + func applySelectedOutcome( _ outcome: ProviderFetchOutcome, provider: UsageProvider, @@ -223,7 +278,24 @@ extension UsageStore { provider: UsageProvider, account: ProviderTokenAccount) -> UsageSnapshot { - let label = account.label.trimmingCharacters(in: .whitespacesAndNewlines) + self.applyDisplayLabel(snapshot, provider: provider, label: account.label) + } + + private func defaultTokenAccountLabel(for provider: UsageProvider, override: String? = nil) -> String? { + if provider == .codex, self.settings.codexExplicitAccountsOnly { return nil } + let label = override ?? ProviderCatalog.implementation(for: provider)? + .tokenAccountDefaultLabel(settings: self.settings) + let trimmed = label?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let trimmed, !trimmed.isEmpty else { return nil } + return trimmed + } + + private func applyDisplayLabel( + _ snapshot: UsageSnapshot, + provider: UsageProvider, + label: String) -> UsageSnapshot + { + let label = label.trimmingCharacters(in: .whitespacesAndNewlines) guard !label.isEmpty else { return snapshot } let existing = snapshot.identity(for: provider) let email = existing?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 71285b4bc..69d2db047 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -566,7 +566,7 @@ struct StatusMenuTests { } @Test - func `shows open AI web submenus when history exists`() throws { + func `shows upstream codex web actions when history exists in single account mode`() throws { self.disableMenuCardsForTesting() let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusMenuTests-history"), @@ -621,8 +621,11 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) - let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } + let usageItem = menu.items.first { $0.title == "Usage breakdown" } let creditsHistoryItem = menu.items.first { $0.title == "Credits history" } + let titles = Set(menu.items.map(\.title)) + #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardUsage" } == false) + #expect(titles.contains("Usage Dashboard")) #expect( usageItem?.submenu?.items .contains { ($0.representedObject as? String) == "usageBreakdownChart" } == true) @@ -631,6 +634,139 @@ struct StatusMenuTests { .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true) } + @Test + func `uses sectioned codex web card and action section dashboard when multiple accounts enabled`() throws { + self.disableMenuCardsForTesting() + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusMenuTests-history-multi-account"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.codexMultipleAccountsEnabled = true + settings.costUsageEnabled = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + if let geminiMeta = registry.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let calendar = Calendar(identifier: .gregorian) + var components = DateComponents() + components.calendar = calendar + components.timeZone = TimeZone(secondsFromGMT: 0) + components.year = 2025 + components.month = 12 + components.day = 18 + let date = try #require(components.date) + + let events = [CreditEvent(date: date, service: "CLI", creditsUsed: 1)] + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: events, maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: events, + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: Date()) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } + let planHistoryIndex = menu.items.firstIndex { ($0.representedObject as? String) == "usageHistorySubmenu" } + let dashboardIndex = menu.items.firstIndex { $0.title == "Login to OpenAI Dashboard" } + #expect( + usageItem?.submenu?.items + .contains { ($0.representedObject as? String) == "usageBreakdownChart" } == true) + #expect(dashboardIndex != nil) + #expect(planHistoryIndex != nil) + if let planHistoryIndex, let dashboardIndex { + #expect(planHistoryIndex < dashboardIndex) + } + } + + @Test + func `shows login to dashboard in multi account fallback action section when not logged in`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.codexMultipleAccountsEnabled = true + settings.costUsageEnabled = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + if let geminiMeta = registry.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let planHistoryIndex = menu.items.firstIndex { ($0.representedObject as? String) == "usageHistorySubmenu" } + let dashboardIndex = menu.items.firstIndex { $0.title == "Login to OpenAI Dashboard" } + let titles = Set(menu.items.map(\.title)) + #expect(!titles.contains("Usage Dashboard")) + #expect(!titles.contains("Usage history (30 days)")) + #expect(dashboardIndex != nil) + #expect(planHistoryIndex != nil) + if let planHistoryIndex, let dashboardIndex { + #expect(planHistoryIndex < dashboardIndex) + } + } + @Test func `shows credits before cost in codex menu card sections`() { self.disableMenuCardsForTesting() @@ -639,6 +775,7 @@ struct StatusMenuTests { settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex + settings.codexMultipleAccountsEnabled = true settings.costUsageEnabled = true let registry = ProviderRegistry.shared diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 67b0a323a..3617053d8 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -133,6 +133,34 @@ struct UsageStoreCoverageTests { #expect(!UsageStore.isSubscriptionPlan("api")) } + @Test + func `show all token accounts counts the default codex account unless explicit only is on`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-show-all-default") + settings.showAllTokenAccountsInMenu = true + let store = Self.makeUsageStore(settings: settings) + let account = ProviderTokenAccount( + id: UUID(), + label: "Work", + token: "/tmp/codex-work", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + + #expect(!store.shouldFetchAllTokenAccounts(provider: .codex, accounts: [account])) + #expect( + store.shouldFetchAllTokenAccounts( + provider: .codex, + accounts: [account], + defaultAccountLabel: "Primary")) + + settings.codexExplicitAccountsOnly = true + + #expect( + !store.shouldFetchAllTokenAccounts( + provider: .codex, + accounts: [account], + defaultAccountLabel: "Primary")) + } + @Test func `status indicators and failure gate`() { #expect(!ProviderStatusIndicator.none.hasIssue) From 7c2735cb07184789a58af4202f5df52ea9e70375 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 14:11:34 -0400 Subject: [PATCH 14/25] fix: suppress redundant 'not signed in' hint from Credits block The Login/Dashboard button label already communicates dashboard login state. Credits come from the Codex RPC/OAuth API, not the web dashboard scrape, so a dashboard-login error in the Credits section is both misleading and redundant. --- Sources/CodexBar/MenuCardView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 7afe1c0aa..ae6efd88f 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1377,6 +1377,8 @@ extension UsageMenuCardView.Model { private static func dashboardHint(provider: UsageProvider, error: String?) -> String? { guard provider == .codex else { return nil } guard let error, !error.isEmpty else { return nil } + // "Not signed in" is already communicated by the Login/Dashboard button label — suppress here. + if error.localizedCaseInsensitiveContains("not signed in") { return nil } return error } From 6676a9f7c01de6f3ea7f1eb53eda06c5fc71856d Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 14:17:55 -0400 Subject: [PATCH 15/25] fix: API-key cost isolation and active-account preserve on delete --- .claude/settings.local.json | 7 +++ .../SettingsStore+TokenAccounts.swift | 14 ++++-- Sources/CodexBar/UsageStore+TokenCost.swift | 9 ++++ Sources/CodexBar/UsageStore.swift | 10 ++++ .../SettingsStoreAdditionalTests.swift | 50 +++++++++++++++++++ 5 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..323855cdb --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(swift build:*)" + ] + } +} diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index ad4183f86..76ad90a2b 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -155,11 +155,19 @@ extension SettingsStore { if filtered.isEmpty { entry.tokenAccounts = nil } else { - let newActiveIndex: Int = if data.activeIndex < 0 { + let newActiveIndex: Int + if data.activeIndex < 0 { // Keep "primary / default credentials" selected; do not coerce -1 to first add-on. - -1 + newActiveIndex = -1 } else { - min(max(data.activeIndex, 0), filtered.count - 1) + // Preserve the active account by identity: if it still exists after the delete, + // find its new position; if it was the deleted row, clamp to nearest valid index. + let activeID = data.accounts[data.activeIndex].id + if let newIndex = filtered.firstIndex(where: { $0.id == activeID }) { + newActiveIndex = newIndex + } else { + newActiveIndex = min(data.activeIndex, filtered.count - 1) + } } entry.tokenAccounts = ProviderTokenAccountData( version: data.version, diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index decf8a3f9..a4608a0a8 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -13,6 +13,15 @@ extension UsageStore { return self.settings.selectedTokenAccount(for: .codex) == nil } + /// True when the currently selected Codex account uses an API key ("apikey:" prefix). + /// API-key accounts have no local session logs, so session-based cost data does not apply + /// and falling back to ~/.codex would leak the primary account's cost totals. + var isActiveCodexAccountApiKey: Bool { + guard let account = self.settings.selectedTokenAccount(for: .codex) else { return false } + let token = account.token.trimmingCharacters(in: .whitespacesAndNewlines) + return token.lowercased().hasPrefix("apikey:") + } + func codexCostUsageSessionsRootForActiveSelection() -> URL? { guard let support = TokenAccountSupportCatalog.support(for: .codex), case .codexHome = support.injection diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index cab91a193..20dd2ecda 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1562,6 +1562,16 @@ extension UsageStore { return } + // API-key accounts have no local session logs. Return nil from + // codexCostUsageSessionsRootForActiveSelection, which causes loadTokenSnapshot + // to fall back to ~/.codex and surface the primary account's cost data instead. + // Suppress cost entirely to preserve multi-account isolation. + if provider == .codex, self.isActiveCodexAccountApiKey { + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + return + } + let startedAt = Date() let providerText = provider.rawValue self.tokenCostLogger diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index 083c09607..53fde8602 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -95,6 +95,56 @@ struct SettingsStoreAdditionalTests { #expect(settings.tokenAccountsData(for: .codex)?.activeIndex == -1) } + @Test + func `removing earlier account preserves active account by identity`() throws { + // [A, B, C] active=B (index 1). Delete A → [B, C], active must stay on B (index 0). + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-remove-preserves-identity") + + settings.addTokenAccount(provider: .codex, label: "A", token: "/tmp/codex-a") + settings.addTokenAccount(provider: .codex, label: "B", token: "/tmp/codex-b") + settings.addTokenAccount(provider: .codex, label: "C", token: "/tmp/codex-c") + + let accounts = settings.tokenAccounts(for: .codex) + let accountA = try #require(accounts.first { $0.label == "A" }) + let accountB = try #require(accounts.first { $0.label == "B" }) + + // Activate B (index 1). + settings.setActiveTokenAccountIndex(1, for: .codex) + #expect(settings.tokenAccountsData(for: .codex)?.activeIndex == 1) + + // Delete A (index 0, which is before the active account). + settings.removeTokenAccount(provider: .codex, accountID: accountA.id) + + let remaining = settings.tokenAccounts(for: .codex) + #expect(remaining.count == 2) + // Active account must still be B, now at index 0. + let newIndex = try #require(settings.tokenAccountsData(for: .codex)?.activeIndex) + #expect(newIndex == 0) + #expect(remaining[newIndex].id == accountB.id) + } + + @Test + func `removing active account clamps to nearest remaining account`() throws { + // [A, B, C] active=B (index 1). Delete B → [A, C], active clamps to index 1 (C). + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-remove-active-clamps") + + settings.addTokenAccount(provider: .codex, label: "A", token: "/tmp/codex-a") + settings.addTokenAccount(provider: .codex, label: "B", token: "/tmp/codex-b") + settings.addTokenAccount(provider: .codex, label: "C", token: "/tmp/codex-c") + + let accountB = try #require(settings.tokenAccounts(for: .codex).first { $0.label == "B" }) + + settings.setActiveTokenAccountIndex(1, for: .codex) + settings.removeTokenAccount(provider: .codex, accountID: accountB.id) + + let remaining = settings.tokenAccounts(for: .codex) + #expect(remaining.count == 2) + // B was deleted; activeIndex should clamp (1 is still valid → points to C). + let newIndex = try #require(settings.tokenAccountsData(for: .codex)?.activeIndex) + #expect(newIndex == 1) + #expect(remaining[newIndex].label == "C") + } + @Test func `claude default token account active follows stored primary selection`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-claude-default-active") From 94ec6e661c1ceca173dbd59b74ff0da555935d56 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 14:39:46 -0400 Subject: [PATCH 16/25] fix: three multi-account review items --- Sources/CodexBar/StatusItemController+Actions.swift | 5 ++++- Sources/CodexBar/UsageStore+CodexActiveCredits.swift | 10 ++++++++++ Sources/CodexBar/UsageStore.swift | 8 ++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 0778f7589..525783308 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -94,7 +94,10 @@ extension StatusItemController { let url = URL(string: urlString) else { return } let autoStart = true - let accountEmail = self.store.codexAccountEmailForOpenAIDashboard() + // Use the same account identifier as the dashboard refresh so the purchase window + // opens against the correct WKWebsiteDataStore (CODEX_HOME path in multi-account mode, + // email-based lookup in single-account mode). + let accountEmail = self.store.codexDashboardAccountIdentifier() let controller = self.creditsPurchaseWindow ?? OpenAICreditsPurchaseWindowController() controller.show(purchaseURL: url, accountEmail: accountEmail, autoStartPurchase: autoStart) self.creditsPurchaseWindow = controller diff --git a/Sources/CodexBar/UsageStore+CodexActiveCredits.swift b/Sources/CodexBar/UsageStore+CodexActiveCredits.swift index 34d72cae0..709bd8551 100644 --- a/Sources/CodexBar/UsageStore+CodexActiveCredits.swift +++ b/Sources/CodexBar/UsageStore+CodexActiveCredits.swift @@ -6,6 +6,16 @@ extension UsageStore { /// Primary (`~/.codex`) uses RPC/dashboard `credits`; add-on accounts use OAuth rows in `allAccountCredits`. func codexActiveMenuCredits() -> (snapshot: CreditsSnapshot?, error: String?, unlimited: Bool) { guard let data = self.settings.tokenAccountsData(for: .codex), !data.accounts.isEmpty else { + // No add-on accounts configured yet. In multi-account mode, still consult the OAuth + // "default" row so Plus/Pro users see "Unlimited credits" immediately rather than + // having to add a second account first. + if self.settings.codexMultipleAccountsEnabled { + return Self.resolvePrimaryCodexCreditsFromOAuth( + entries: self.allAccountCredits[.codex] ?? [], + rpcCredits: self.credits, + rpcError: self.lastCreditsError, + costRefreshInFlight: self.accountCostRefreshInFlight.contains(.codex)) + } return (self.credits, self.lastCreditsError, false) } if self.settings.isDefaultTokenAccountActive(for: .codex) { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 20dd2ecda..b44095376 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -652,6 +652,14 @@ extension UsageStore { return } + // API-key accounts have no ChatGPT sessions. Skip web scraping to avoid + // creating an isolated WKWebsiteDataStore that can never hold a real session + // and permanently shows the account as "not signed in". + if multiAccountDashboard, self.isActiveCodexAccountApiKey { + self.resetOpenAIWebState() + return + } + let targetEmail = self.codexDashboardAccountIdentifier() self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail) From b92358c6417e25e7f2ff0714b807481bf29db17f Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 14:52:36 -0400 Subject: [PATCH 17/25] fix: dashboard login-required drops logged-in flag, API-key cost TTL, logout clears snapshot --- .../CodexBar/PreferencesProvidersPane.swift | 9 ++++++++ Sources/CodexBar/UsageStore+TokenCost.swift | 9 ++++++++ Sources/CodexBar/UsageStore.swift | 22 ++++++++++++++----- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 69aafd330..b44786e26 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -296,6 +296,15 @@ struct ProvidersPane: View { guard let key = accountIdentifier, !key.isEmpty else { return } await OpenAIDashboardWebsiteDataStore.clearStore(forAccountEmail: key) store.dashboardLoggedInEmails.remove(key.lowercased()) + // If the logged-out account is the currently selected one, immediately + // clear the cached snapshot so stale data isn't shown until the next refresh. + let currentKey = store.codexDashboardAccountIdentifier() + if currentKey?.lowercased() == key.lowercased() { + store.openAIDashboard = nil + store.lastOpenAIDashboardError = nil + store.openAIDashboardRequiresLogin = true + Task { await store.refreshOpenAIDashboardAfterLogin() } + } } : nil, codexExplicitAccountsOnly: codexExplicit) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index a4608a0a8..cfa52f6c5 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -43,6 +43,15 @@ extension UsageStore { if let root = self.codexCostUsageSessionsRootForActiveSelection() { return root.path } + // API-key accounts have no session logs and cost data is suppressed, but they must + // still get a unique identity so switching default→apikey invalidates the TTL cache + // before the suppression guard is reached. + if let account = self.settings.selectedTokenAccount(for: .codex) { + let token = account.token.trimmingCharacters(in: .whitespacesAndNewlines) + if token.lowercased().hasPrefix("apikey:") { + return "codex:apikey:\(account.id.uuidString)" + } + } return "codex:default" } return "\(provider.rawValue):default" diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index b44095376..e12e1fe68 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -735,7 +735,7 @@ extension UsageStore { "OpenAI dashboard signed in as \(signedIn), but Codex uses \(normalized ?? "unknown").", "Switch accounts in your browser and update OpenAI cookies in Providers → Codex.", ].joined(separator: " ") - self.openAIDashboardRequiresLogin = true + self.markDashboardLoginRequired() } return } @@ -788,7 +788,7 @@ extension UsageStore { "then update OpenAI cookies in Providers → Codex.", ].joined(separator: " ") self.openAIDashboard = self.lastOpenAIDashboardSnapshot - self.openAIDashboardRequiresLogin = true + self.markDashboardLoginRequired() } } catch { await self.applyOpenAIDashboardFailure(message: error.localizedDescription) @@ -824,7 +824,7 @@ extension UsageStore { self.openAIDashboard = nil self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardError = nil - self.openAIDashboardRequiresLogin = true + self.markDashboardLoginRequired() if self.settings.codexMultipleAccountsEnabled, self.settings.openAIWebAccessEnabled { self.openAIDashboardCookieImportStatus = nil } else { @@ -979,7 +979,7 @@ extension UsageStore { "Found \(foundText).", ].joined(separator: " ") // Treat mismatch like "not logged in" for the current Codex account. - self.openAIDashboardRequiresLogin = true + self.markDashboardLoginRequired() self.openAIDashboard = nil } case .noCookiesFound, @@ -990,7 +990,7 @@ extension UsageStore { await MainActor.run { self.openAIDashboardCookieImportStatus = "OpenAI cookie import failed: \(err.localizedDescription)" - self.openAIDashboardRequiresLogin = true + self.markDashboardLoginRequired() } } } catch { @@ -1032,6 +1032,18 @@ extension UsageStore { self.lastOpenAIDashboardCookieImportEmail = nil } + /// Sets `openAIDashboardRequiresLogin` and simultaneously drops the logged-in flag for the + /// current account so the menu/settings render "Login to OpenAI Dashboard" (not "Usage Dashboard"), + /// and `OpenAIDashboardLoginWindowController` re-enables its login detection flow. + func markDashboardLoginRequired() { + self.openAIDashboardRequiresLogin = true + let key = self.codexDashboardAccountIdentifier() + if let key, !key.isEmpty { + self.dashboardLoggedInEmails.remove(key.lowercased()) + OpenAIDashboardWebsiteDataStore.markDashboardLoggedOut(forAccountEmail: key) + } + } + private func dashboardEmailMismatch(expected: String?, actual: String?) -> Bool { guard let expected, !expected.isEmpty else { return false } guard let raw = actual?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return false } From 598c6da60d3ae5f62648a3c97426612ef8e70a38 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 15:04:09 -0400 Subject: [PATCH 18/25] Fix 3 dashboard bugs: logout flicker, stale key in markDashboardLoginRequired, apikey leaking as data-store key --- .../CodexBar/PreferencesProvidersPane.swift | 2 +- Sources/CodexBar/UsageStore.swift | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index b44786e26..ed8259928 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -303,7 +303,7 @@ struct ProvidersPane: View { store.openAIDashboard = nil store.lastOpenAIDashboardError = nil store.openAIDashboardRequiresLogin = true - Task { await store.refreshOpenAIDashboardAfterLogin() } + Task { await store.forceRefreshOpenAIDashboard() } } } : nil, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index e12e1fe68..61d7b3f6c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -735,7 +735,7 @@ extension UsageStore { "OpenAI dashboard signed in as \(signedIn), but Codex uses \(normalized ?? "unknown").", "Switch accounts in your browser and update OpenAI cookies in Providers → Codex.", ].joined(separator: " ") - self.markDashboardLoginRequired() + self.markDashboardLoginRequired(for: targetEmail) } return } @@ -788,7 +788,7 @@ extension UsageStore { "then update OpenAI cookies in Providers → Codex.", ].joined(separator: " ") self.openAIDashboard = self.lastOpenAIDashboardSnapshot - self.markDashboardLoginRequired() + self.markDashboardLoginRequired(for: targetEmail) } } catch { await self.applyOpenAIDashboardFailure(message: error.localizedDescription) @@ -824,7 +824,7 @@ extension UsageStore { self.openAIDashboard = nil self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardError = nil - self.markDashboardLoginRequired() + self.markDashboardLoginRequired(for: targetEmail) if self.settings.codexMultipleAccountsEnabled, self.settings.openAIWebAccessEnabled { self.openAIDashboardCookieImportStatus = nil } else { @@ -844,6 +844,12 @@ extension UsageStore { await self.refreshOpenAIDashboardIfNeeded(force: true) } + /// Force-refresh the dashboard snapshot without resetting the login-required flag. + /// Use this after a logout to reload state without accidentally marking the account as signed in. + func forceRefreshOpenAIDashboard() async { + await self.refreshOpenAIDashboardIfNeeded(force: true) + } + func importOpenAIDashboardBrowserCookiesNow() async { self.resetOpenAIWebDebugLog(context: "manual import") let targetEmail = self.codexDashboardAccountIdentifier() @@ -979,7 +985,7 @@ extension UsageStore { "Found \(foundText).", ].joined(separator: " ") // Treat mismatch like "not logged in" for the current Codex account. - self.markDashboardLoginRequired() + self.markDashboardLoginRequired(for: normalizedTarget) self.openAIDashboard = nil } case .noCookiesFound, @@ -990,7 +996,7 @@ extension UsageStore { await MainActor.run { self.openAIDashboardCookieImportStatus = "OpenAI cookie import failed: \(err.localizedDescription)" - self.markDashboardLoginRequired() + self.markDashboardLoginRequired(for: normalizedTarget) } } } catch { @@ -1035,9 +1041,9 @@ extension UsageStore { /// Sets `openAIDashboardRequiresLogin` and simultaneously drops the logged-in flag for the /// current account so the menu/settings render "Login to OpenAI Dashboard" (not "Usage Dashboard"), /// and `OpenAIDashboardLoginWindowController` re-enables its login detection flow. - func markDashboardLoginRequired() { + func markDashboardLoginRequired(for precomputedKey: String? = nil) { self.openAIDashboardRequiresLogin = true - let key = self.codexDashboardAccountIdentifier() + let key = precomputedKey ?? self.codexDashboardAccountIdentifier() if let key, !key.isEmpty { self.dashboardLoggedInEmails.remove(key.lowercased()) OpenAIDashboardWebsiteDataStore.markDashboardLoggedOut(forAccountEmail: key) @@ -1069,6 +1075,8 @@ extension UsageStore { func codexDashboardAccountIdentifier() -> String? { if self.settings.codexMultipleAccountsEnabled, self.settings.openAIWebAccessEnabled { if let selected = self.settings.selectedTokenAccount(for: .codex) { + let token = selected.token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.lowercased().hasPrefix("apikey:") else { return nil } return selected.token } // Default account uses ~/.codex path From 7ab804bfab24fcf50e46c6c2084b742158a0c549 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 15:31:34 -0400 Subject: [PATCH 19/25] Fix account-switch destroying new account login flag; guard removeTokenAccount index bounds --- Sources/CodexBar/SettingsStore+TokenAccounts.swift | 9 ++++++--- Sources/CodexBar/UsageStore.swift | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 76ad90a2b..0f160b978 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -162,11 +162,14 @@ extension SettingsStore { } else { // Preserve the active account by identity: if it still exists after the delete, // find its new position; if it was the deleted row, clamp to nearest valid index. - let activeID = data.accounts[data.activeIndex].id - if let newIndex = filtered.firstIndex(where: { $0.id == activeID }) { + // Guard against corrupted/out-of-range activeIndex (data migration edge case). + let activeID = data.activeIndex < data.accounts.count + ? data.accounts[data.activeIndex].id + : nil + if let activeID, let newIndex = filtered.firstIndex(where: { $0.id == activeID }) { newActiveIndex = newIndex } else { - newActiveIndex = min(data.activeIndex, filtered.count - 1) + newActiveIndex = min(max(data.activeIndex, 0), filtered.count - 1) } } entry.tokenAccounts = ProviderTokenAccountData( diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 61d7b3f6c..f6ef2ff5e 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -824,7 +824,10 @@ extension UsageStore { self.openAIDashboard = nil self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardError = nil - self.markDashboardLoginRequired(for: targetEmail) + // Only set the flag; do NOT call markDashboardLoginRequired here because targetEmail is + // already the NEW account's key. Removing it from dashboardLoggedInEmails would destroy + // the new account's login state before its dashboard refresh even starts. + self.openAIDashboardRequiresLogin = true if self.settings.codexMultipleAccountsEnabled, self.settings.openAIWebAccessEnabled { self.openAIDashboardCookieImportStatus = nil } else { From 6e10f4e4837640722dd702c76595a6812e5d0950 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 15:41:16 -0400 Subject: [PATCH 20/25] Clean up dashboardLoggedInEmails when token account is deleted --- Sources/CodexBar/PreferencesProvidersPane.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index ed8259928..3e6cafbab 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -227,6 +227,15 @@ struct ProvidersPane: View { } }, removeAccount: { accountID in + // Clean up dashboard login state for the deleted account before removing it, + // so the token doesn't linger in dashboardLoggedInEmails or UserDefaults. + if let data = self.settings.tokenAccountsData(for: provider), + let account = data.accounts.first(where: { $0.id == accountID }) + { + let key = account.token.lowercased() + self.store.dashboardLoggedInEmails.remove(key) + OpenAIDashboardWebsiteDataStore.markDashboardLoggedOut(forAccountEmail: account.token) + } self.settings.removeTokenAccount(provider: provider, accountID: accountID) Task { @MainActor in await ProviderInteractionContext.$current.withValue(.userInitiated) { From e053b0b947be38052e142a074d1afbacbfc3510b Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 16:17:44 -0400 Subject: [PATCH 21/25] Auto-install zsh shell integration on first OAuth account add; keep active-codex-home in sync on switch/delete --- .../CodexBar/CodexBarShellIntegration.swift | 76 +++++++++++++++++++ .../SettingsStore+TokenAccounts.swift | 64 +++++++++++----- 2 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 Sources/CodexBar/CodexBarShellIntegration.swift diff --git a/Sources/CodexBar/CodexBarShellIntegration.swift b/Sources/CodexBar/CodexBarShellIntegration.swift new file mode 100644 index 000000000..6970069f2 --- /dev/null +++ b/Sources/CodexBar/CodexBarShellIntegration.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Manages the `~/.codexbar/active-codex-home` file and optional one-time `.zshrc` hook injection. +/// +/// The file contains the absolute path of the currently selected Codex account's CODEX_HOME directory. +/// A shell `precmd` hook installed in `.zshrc` re-exports `CODEX_HOME` on every prompt: +/// +/// precmd_codexbar() { export CODEX_HOME=$(cat ~/.codexbar/active-codex-home 2>/dev/null); } +/// autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar +/// +/// This means switching accounts in CodexBar immediately takes effect at the next shell prompt, +/// so `codex` CLI sessions are written to the correct per-account `sessions/` directory. +enum CodexBarShellIntegration { + // MARK: - Paths + + private static var codexbarDir: URL { + URL(fileURLWithPath: ("~/.codexbar" as NSString).expandingTildeInPath) + } + + private static var activeCodexHomeFile: URL { + codexbarDir.appendingPathComponent("active-codex-home") + } + + private static var zshrcFile: URL { + URL(fileURLWithPath: ("~/.zshrc" as NSString).expandingTildeInPath) + } + + // MARK: - Shell hook snippet + + /// A unique sentinel so we never double-insert the hook. + private static let hookMarker = "# CodexBar shell integration" + + private static let hookSnippet = """ + +# CodexBar shell integration — auto-switches CODEX_HOME when you change accounts in CodexBar +precmd_codexbar() { export CODEX_HOME=$(cat ~/.codexbar/active-codex-home 2>/dev/null); } +autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar +""" + + // MARK: - Public API + + /// Write the given CODEX_HOME path as the active account. + /// Pass `nil` to clear (e.g. when reverting to the default ~/.codex account). + static func setActiveCodexHome(_ path: String?) { + let fm = FileManager.default + let dir = codexbarDir.path + if !fm.fileExists(atPath: dir) { + try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true) + } + if let path, !path.isEmpty { + try? path.write(to: activeCodexHomeFile, atomically: true, encoding: .utf8) + } else { + try? fm.removeItem(at: activeCodexHomeFile) + } + } + + /// Append the precmd hook to ~/.zshrc if it isn't already there. + /// Called once on first OAuth account creation — silently does nothing if already set up. + static func installZshHookIfNeeded() { + let zshrc = zshrcFile.path + let fm = FileManager.default + // If .zshrc doesn't exist yet, create it. + if !fm.fileExists(atPath: zshrc) { + fm.createFile(atPath: zshrc, contents: nil) + } + guard let existing = try? String(contentsOfFile: zshrc, encoding: .utf8) else { return } + guard !existing.contains(hookMarker) else { return } + try? (existing + hookSnippet).write(toFile: zshrc, atomically: true, encoding: .utf8) + } + + /// Returns true if the zsh hook is already installed. + static var isZshHookInstalled: Bool { + guard let content = try? String(contentsOf: zshrcFile, encoding: .utf8) else { return false } + return content.contains(hookMarker) + } +} diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 0f160b978..57f8df901 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -56,6 +56,18 @@ extension SettingsStore { self.updateProviderConfig(provider: provider) { entry in entry.tokenAccounts = updated } + // Keep active-codex-home in sync so the shell hook reflects the new selection. + if provider == .codex { + let path: String? + if clamped >= 0, clamped < updated.accounts.count { + let token = updated.accounts[clamped].token.trimmingCharacters(in: .whitespacesAndNewlines) + path = token.lowercased().hasPrefix("apikey:") ? nil : token + } else { + // Reverted to default ~/.codex — clear override so shell falls back to its own default + path = nil + } + CodexBarShellIntegration.setActiveCodexHome(path) + } CodexBarLog.logger(LogCategories.tokenAccounts).info( "Active token account updated", metadata: [ @@ -86,6 +98,12 @@ extension SettingsStore { entry.tokenAccounts = updated } self.applyTokenAccountCookieSourceIfNeeded(provider: provider) + // For OAuth Codex accounts (path-based token), update the active-codex-home file and + // ensure the zsh precmd hook is installed so new terminal sessions inherit CODEX_HOME. + if provider == .codex, !trimmedToken.lowercased().hasPrefix("apikey:") { + CodexBarShellIntegration.setActiveCodexHome(trimmedToken) + CodexBarShellIntegration.installZshHookIfNeeded() + } CodexBarLog.logger(LogCategories.tokenAccounts).info( "Token account added", metadata: [ @@ -151,32 +169,42 @@ extension SettingsStore { func removeTokenAccount(provider: UsageProvider, accountID: UUID) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } let filtered = data.accounts.filter { $0.id != accountID } + // Compute the new active index outside the closure so it's available for shell integration. + let computedActiveIndex: Int + if filtered.isEmpty { + computedActiveIndex = -1 + } else if data.activeIndex < 0 { + computedActiveIndex = -1 + } else { + let activeID = data.activeIndex < data.accounts.count + ? data.accounts[data.activeIndex].id + : nil + if let activeID, let newIndex = filtered.firstIndex(where: { $0.id == activeID }) { + computedActiveIndex = newIndex + } else { + computedActiveIndex = min(max(data.activeIndex, 0), filtered.count - 1) + } + } self.updateProviderConfig(provider: provider) { entry in if filtered.isEmpty { entry.tokenAccounts = nil } else { - let newActiveIndex: Int - if data.activeIndex < 0 { - // Keep "primary / default credentials" selected; do not coerce -1 to first add-on. - newActiveIndex = -1 - } else { - // Preserve the active account by identity: if it still exists after the delete, - // find its new position; if it was the deleted row, clamp to nearest valid index. - // Guard against corrupted/out-of-range activeIndex (data migration edge case). - let activeID = data.activeIndex < data.accounts.count - ? data.accounts[data.activeIndex].id - : nil - if let activeID, let newIndex = filtered.firstIndex(where: { $0.id == activeID }) { - newActiveIndex = newIndex - } else { - newActiveIndex = min(max(data.activeIndex, 0), filtered.count - 1) - } - } entry.tokenAccounts = ProviderTokenAccountData( version: data.version, accounts: filtered, - activeIndex: newActiveIndex) + activeIndex: computedActiveIndex) + } + } + // Update active-codex-home to reflect the new active account after deletion. + if provider == .codex { + let newActive: String? + if !filtered.isEmpty, computedActiveIndex >= 0, computedActiveIndex < filtered.count { + let token = filtered[computedActiveIndex].token.trimmingCharacters(in: .whitespacesAndNewlines) + newActive = token.lowercased().hasPrefix("apikey:") ? nil : token + } else { + newActive = nil } + CodexBarShellIntegration.setActiveCodexHome(newActive) } CodexBarLog.logger(LogCategories.tokenAccounts).info( "Token account removed", From 162c828af7cab2aa75200d9da445a5b5f2df9f4a Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 16:48:07 -0400 Subject: [PATCH 22/25] Auto-symlink ~/.codex/sessions into new OAuth account dir on creation --- Sources/CodexBar/CodexBarShellIntegration.swift | 14 ++++++++++++++ Sources/CodexBar/SettingsStore+TokenAccounts.swift | 1 + 2 files changed, 15 insertions(+) diff --git a/Sources/CodexBar/CodexBarShellIntegration.swift b/Sources/CodexBar/CodexBarShellIntegration.swift index 6970069f2..df3a5e719 100644 --- a/Sources/CodexBar/CodexBarShellIntegration.swift +++ b/Sources/CodexBar/CodexBarShellIntegration.swift @@ -73,4 +73,18 @@ autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar guard let content = try? String(contentsOf: zshrcFile, encoding: .utf8) else { return false } return content.contains(hookMarker) } + + /// If `~/.codex/sessions` exists and `/sessions` does not yet exist, + /// create a symlink so the new account immediately shows historical cost data. + /// Safe to call multiple times — does nothing if the target already exists. + static func symlinkDefaultSessionsIfNeeded(into codexHomePath: String) { + let fm = FileManager.default + let defaultSessions = fm.homeDirectoryForCurrentUser + .appendingPathComponent(".codex/sessions", isDirectory: true) + guard fm.fileExists(atPath: defaultSessions.path) else { return } + let accountSessions = URL(fileURLWithPath: (codexHomePath as NSString).expandingTildeInPath) + .appendingPathComponent("sessions") + guard !fm.fileExists(atPath: accountSessions.path) else { return } + try? fm.createSymbolicLink(at: accountSessions, withDestinationURL: defaultSessions) + } } diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 57f8df901..2903330dc 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -101,6 +101,7 @@ extension SettingsStore { // For OAuth Codex accounts (path-based token), update the active-codex-home file and // ensure the zsh precmd hook is installed so new terminal sessions inherit CODEX_HOME. if provider == .codex, !trimmedToken.lowercased().hasPrefix("apikey:") { + CodexBarShellIntegration.symlinkDefaultSessionsIfNeeded(into: trimmedToken) CodexBarShellIntegration.setActiveCodexHome(trimmedToken) CodexBarShellIntegration.installZshHookIfNeeded() } From 14721e9ced216be6f99e9931266f2b101a05740f Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 20:35:54 -0400 Subject: [PATCH 23/25] Add OpenAI REST API cost fetching for API-key Codex accounts; isolate per-account sessions directories - API-key Codex accounts now fetch cost/usage via the OpenAI REST API (OpenAIAPIUsageFetcher) instead of scanning local session logs, which were absent for those accounts - OAuth/path accounts continue to scan local CODEX_HOME/sessions as before; cost fetch is suppressed entirely when the active account is not API-key backed - Replace the ~/.codex/sessions symlink with a dedicated per-account sessions/ directory to keep cost data isolated across accounts - Surface token cost errors in the menu card even when no snapshot data is available (error-only state) - Add inline orange warning notice in Preferences when the active account's cost fetch fails (e.g. missing api.usage.read scope) - Make ShellIntegration helpers injectable (FileManager, directory URLs) for unit testability - Conditionally show "Cost only available for API configured accounts" hint based on multi-account state Co-Authored-By: Claude Sonnet 4.6 --- .../CodexBar/CodexBarShellIntegration.swift | 59 +- Sources/CodexBar/MenuCardView.swift | 14 +- .../PreferencesProviderDetailView.swift | 25 +- .../PreferencesProviderSettingsRows.swift | 34 +- .../PreferencesProvidersPane+Testing.swift | 1 + .../CodexBar/PreferencesProvidersPane.swift | 5 + .../Codex/CodexProviderImplementation.swift | 7 +- .../Shared/ProviderSettingsDescriptors.swift | 2 + .../SettingsStore+ConfigPersistence.swift | 1 + .../SettingsStore+MenuPreferences.swift | 12 +- .../SettingsStore+TokenAccounts.swift | 85 ++- Sources/CodexBar/SettingsStore.swift | 1 + .../CodexBar/StatusItemController+Menu.swift | 8 + .../CodexBar/UsageStore+AccountCosts.swift | 17 + .../UsageStore+CodexActiveCredits.swift | 3 + Sources/CodexBar/UsageStore+TokenCost.swift | 65 ++- .../CodexBar/UsageStore+WidgetSnapshot.swift | 10 +- Sources/CodexBar/UsageStore.swift | 69 ++- .../Codex/OpenAIAPIUsageFetcher.swift | 533 ++++++++++++++++++ .../CodexActiveCreditsTests.swift | 19 + .../CodexBarShellIntegrationTests.swift | 80 +++ Tests/CodexBarTests/MenuCardModelTests.swift | 35 ++ .../OpenAIAPIUsageFetcherTests.swift | 202 +++++++ .../ProviderSettingsDescriptorTests.swift | 31 + .../SettingsStoreAdditionalTests.swift | 38 ++ Tests/CodexBarTests/StatusMenuTests.swift | 57 ++ .../UsageStoreCoverageTests.swift | 20 +- 27 files changed, 1340 insertions(+), 93 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Codex/OpenAIAPIUsageFetcher.swift create mode 100644 Tests/CodexBarTests/CodexBarShellIntegrationTests.swift create mode 100644 Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift diff --git a/Sources/CodexBar/CodexBarShellIntegration.swift b/Sources/CodexBar/CodexBarShellIntegration.swift index df3a5e719..d841fe8d9 100644 --- a/Sources/CodexBar/CodexBarShellIntegration.swift +++ b/Sources/CodexBar/CodexBarShellIntegration.swift @@ -17,10 +17,6 @@ enum CodexBarShellIntegration { URL(fileURLWithPath: ("~/.codexbar" as NSString).expandingTildeInPath) } - private static var activeCodexHomeFile: URL { - codexbarDir.appendingPathComponent("active-codex-home") - } - private static var zshrcFile: URL { URL(fileURLWithPath: ("~/.zshrc" as NSString).expandingTildeInPath) } @@ -41,24 +37,28 @@ autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar /// Write the given CODEX_HOME path as the active account. /// Pass `nil` to clear (e.g. when reverting to the default ~/.codex account). - static func setActiveCodexHome(_ path: String?) { - let fm = FileManager.default - let dir = codexbarDir.path + static func setActiveCodexHome( + _ path: String?, + fileManager fm: FileManager = .default, + codexbarDirectory: URL? = nil) + { + let directory = codexbarDirectory ?? self.codexbarDir + let activeFile = directory.appendingPathComponent("active-codex-home") + let dir = directory.path if !fm.fileExists(atPath: dir) { try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true) } if let path, !path.isEmpty { - try? path.write(to: activeCodexHomeFile, atomically: true, encoding: .utf8) + try? path.write(to: activeFile, atomically: true, encoding: .utf8) } else { - try? fm.removeItem(at: activeCodexHomeFile) + try? fm.removeItem(at: activeFile) } } /// Append the precmd hook to ~/.zshrc if it isn't already there. /// Called once on first OAuth account creation — silently does nothing if already set up. - static func installZshHookIfNeeded() { - let zshrc = zshrcFile.path - let fm = FileManager.default + static func installZshHookIfNeeded(fileManager fm: FileManager = .default, zshrcURL: URL? = nil) { + let zshrc = (zshrcURL ?? self.zshrcFile).path // If .zshrc doesn't exist yet, create it. if !fm.fileExists(atPath: zshrc) { fm.createFile(atPath: zshrc, contents: nil) @@ -74,17 +74,34 @@ autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar return content.contains(hookMarker) } - /// If `~/.codex/sessions` exists and `/sessions` does not yet exist, - /// create a symlink so the new account immediately shows historical cost data. - /// Safe to call multiple times — does nothing if the target already exists. - static func symlinkDefaultSessionsIfNeeded(into codexHomePath: String) { - let fm = FileManager.default - let defaultSessions = fm.homeDirectoryForCurrentUser + /// Ensure each Codex account has its own dedicated `sessions/` directory. + /// If a legacy symlink points back to the shared `~/.codex/sessions`, replace it with a real + /// per-account directory so future cost data stays isolated by account. + static func ensureDedicatedSessionsDirectoryIfNeeded( + into codexHomePath: String, + fileManager fm: FileManager = .default, + defaultSessionsRoot: URL? = nil) + { + let defaultSessions = (defaultSessionsRoot ?? fm.homeDirectoryForCurrentUser .appendingPathComponent(".codex/sessions", isDirectory: true) - guard fm.fileExists(atPath: defaultSessions.path) else { return } + .resolvingSymlinksInPath() + .standardizedFileURL) let accountSessions = URL(fileURLWithPath: (codexHomePath as NSString).expandingTildeInPath) - .appendingPathComponent("sessions") + .appendingPathComponent("sessions", isDirectory: true) + + if let destination = try? fm.destinationOfSymbolicLink(atPath: accountSessions.path) + { + let destinationURL = URL(fileURLWithPath: destination, relativeTo: accountSessions.deletingLastPathComponent()) + .resolvingSymlinksInPath() + .standardizedFileURL + if destinationURL.path == defaultSessions.path { + try? fm.removeItem(at: accountSessions) + } else { + return + } + } + guard !fm.fileExists(atPath: accountSessions.path) else { return } - try? fm.createSymbolicLink(at: accountSessions, withDestinationURL: defaultSessions) + try? fm.createDirectory(at: accountSessions, withIntermediateDirectories: true) } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index ae6efd88f..8966d66ff 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1390,7 +1390,16 @@ extension UsageMenuCardView.Model { { guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } guard enabled else { return nil } - guard let snapshot else { return nil } + let err = error?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let snapshot else { + guard let err, !err.isEmpty else { return nil } + return TokenUsageSection( + sessionLine: "Today: —", + monthLine: "Last 30 days: —", + hintLine: nil, + errorLine: err, + errorCopyText: err) + } let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } @@ -1411,13 +1420,12 @@ extension UsageMenuCardView.Model { } 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) + errorCopyText: err) } private static func providerCostSection( diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 0a9321c83..aefb30a58 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -98,6 +98,7 @@ struct ProviderDetailView: View { model: self.model, isEnabled: self.isEnabled, labelWidth: labelWidth, + titleTrailingNote: self.codexUsageTitleTrailingNote, accountSwitcher: { let identity = self.codexUsageAccountSwitcherIdentity let widthKey = String(Int(self.codexAccountSwitcherLayoutWidth)) @@ -125,7 +126,8 @@ struct ProviderDetailView: View { provider: self.provider, model: self.model, isEnabled: self.isEnabled, - labelWidth: labelWidth) + labelWidth: labelWidth, + titleTrailingNote: self.codexUsageTitleTrailingNote) } } @@ -289,6 +291,11 @@ struct ProviderDetailView: View { return max(220, column - 16) } + private var codexUsageTitleTrailingNote: String? { + guard self.provider == .codex, self.settings.codexMultipleAccountsEnabled else { return nil } + return "(Cost only available for API configured accounts)" + } + /// Display name for the account whose usage/cost is shown (token selection or primary or menu card email). private var costSectionAccountLabel: String? { let provider = self.provider @@ -522,6 +529,7 @@ struct ProviderMetricsInlineView: View { let model: UsageMenuCardView.Model let isEnabled: Bool let labelWidth: CGFloat + let titleTrailingNote: String? @ViewBuilder private var accountSwitcher: AccountSwitcher init( @@ -529,12 +537,14 @@ struct ProviderMetricsInlineView: View { model: UsageMenuCardView.Model, isEnabled: Bool, labelWidth: CGFloat, + titleTrailingNote: String? = nil, @ViewBuilder accountSwitcher: () -> AccountSwitcher) { self.provider = provider self.model = model self.isEnabled = isEnabled self.labelWidth = labelWidth + self.titleTrailingNote = titleTrailingNote self.accountSwitcher = accountSwitcher() } @@ -547,9 +557,7 @@ struct ProviderMetricsInlineView: View { let hasUsageRows = hasMetrics || hasUsageNotes || hasProviderCost || hasCredits ProviderSettingsSection( title: "Usage", - titleTrailingNote: self.provider == .codex - ? "(Cost only available for API configured accounts)" - : nil, + titleTrailingNote: self.titleTrailingNote, spacing: 8, verticalPadding: 6, horizontalPadding: 0) @@ -601,12 +609,19 @@ struct ProviderMetricsInlineView: View { } extension ProviderMetricsInlineView where AccountSwitcher == EmptyView { - init(provider: UsageProvider, model: UsageMenuCardView.Model, isEnabled: Bool, labelWidth: CGFloat) { + init( + provider: UsageProvider, + model: UsageMenuCardView.Model, + isEnabled: Bool, + labelWidth: CGFloat, + titleTrailingNote: String? = nil) + { self.init( provider: provider, model: model, isEnabled: isEnabled, labelWidth: labelWidth, + titleTrailingNote: titleTrailingNote, accountSwitcher: { EmptyView() }) } } diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index ff2de07b3..88c1f8c6c 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -260,7 +260,7 @@ struct ProviderSettingsTokenAccountsRowView: View { } return "Only one account is active at a time. Choose “Default” on the row you want. " + "The house row is your primary ~/.codex sign-in; added rows use a separate OAuth folder or API key. " + - "Buy Credits is also under Options." + "Cost appears only for API key rows. Buy Credits is also under Options." } var body: some View { @@ -303,6 +303,11 @@ struct ProviderSettingsTokenAccountsRowView: View { .fixedSize(horizontal: false, vertical: true) .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } + let status = self.descriptor.activeAccountStatusText?()? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !status.isEmpty { + self.inlineAccountStatusNotice(status) + } } if self.descriptor.provider == .codex { @@ -692,9 +697,34 @@ struct ProviderSettingsTokenAccountsRowView: View { } } + private func inlineAccountStatusNotice(_ text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .imageScale(.small) + .padding(.top, 1) + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.orange.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.orange.opacity(0.22), lineWidth: 1))) + } + private func codexAPIKeyAddSection() -> some View { VStack(alignment: .leading, spacing: 6) { - Text("Adds an account that sets OPENAI_API_KEY for Codex (stored securely in your config).") + Text( + "Adds an account that sets OPENAI_API_KEY for Codex (stored securely in your config). " + + "Cost and 30-day history require an OpenAI key with usage access.") .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index de23cef84..bcce88b00 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -185,6 +185,7 @@ enum ProvidersPaneTestHarness { dashboardLogin: nil, isDashboardLoggedIn: nil, dashboardLogout: nil, + activeAccountStatusText: nil, codexExplicitAccountsOnly: false) return ProviderListTestDescriptors( diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 3e6cafbab..f58fece57 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -316,6 +316,11 @@ struct ProvidersPane: View { } } : nil, + activeAccountStatusText: provider == .codex + ? { [store] in + store.activeCodexAPIKeySettingsNotice() + } + : nil, codexExplicitAccountsOnly: codexExplicit) } diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 11c91f301..b1e4e6f93 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -123,7 +123,9 @@ struct CodexProviderImplementation: ProviderImplementation { id: "codex-multiple-accounts", title: "Multiple Accounts", subtitle: - "Enable multi-account support: add, reorder, and switch between multiple Codex accounts. Costs are disabled for accounts configured without the default machine codex home path (~/.codex).", + "Enable multi-account support: add, reorder, and switch between multiple Codex accounts. " + + "OAuth accounts keep separate local history; API key accounts use OpenAI usage APIs " + + "when the key has the required scopes, and only API key accounts show Cost.", binding: multipleAccountsBinding, statusText: nil, actions: [], @@ -136,7 +138,8 @@ struct CodexProviderImplementation: ProviderImplementation { title: "CodexBar accounts only", subtitle: "Ignore the default machine codex home path (~/.codex). Use only rows under Accounts " + - "(OAuth, API key). Cost will only work for the default machine codex home path account.", + "(OAuth, API key). OAuth rows keep their own CODEX_HOME history, but only API key rows " + + "show Cost, and they need OpenAI usage access.", binding: explicitAccountsBinding, statusText: nil, actions: [], diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index 8bb4d67ac..9b9d71fff 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -122,6 +122,8 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let isDashboardLoggedIn: ((_ accountEmail: String?) -> Bool)? /// Logs out of the dashboard for the given account identifier (clears cookies and tracked state). let dashboardLogout: ((_ accountIdentifier: String?) async -> Void)? + /// Optional inline status shown below the account rows (for example: account-specific warnings). + let activeAccountStatusText: (() -> String?)? /// Codex only: mirrors **CodexBar accounts only**; hides ~/.codex primary tab and adjusts copy. let codexExplicitAccountsOnly: Bool } diff --git a/Sources/CodexBar/SettingsStore+ConfigPersistence.swift b/Sources/CodexBar/SettingsStore+ConfigPersistence.swift index 5b17761dd..87358a537 100644 --- a/Sources/CodexBar/SettingsStore+ConfigPersistence.swift +++ b/Sources/CodexBar/SettingsStore+ConfigPersistence.swift @@ -80,6 +80,7 @@ extension SettingsStore { config.providers.append(ProviderConfig(id: provider, tokenAccounts: data)) } } + self.repairCodexShellIntegrationIfNeeded() } func setProviderOrder(_ order: [UsageProvider]) { diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index cc8a89396..a1a5a80e4 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -69,9 +69,17 @@ extension SettingsStore { return preference } - func isCostUsageEffectivelyEnabled(for provider: UsageProvider) -> Bool { - self.costUsageEnabled + func isCostUsageEffectivelyEnabled( + for provider: UsageProvider, + baseEnvironment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + let enabled = self.costUsageEnabled && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost + guard enabled else { return false } + if provider == .codex { + return self.isActiveCodexAPIAccount(baseEnvironment: baseEnvironment) + } + return true } var resetTimeDisplayStyle: ResetTimeDisplayStyle { diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 2903330dc..fde79adef 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -45,6 +45,27 @@ extension SettingsStore { return data.accounts[index] } + func activeCodexAPIKey( + baseEnvironment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + let env = ProviderRegistry.makeEnvironment( + base: baseEnvironment, + provider: .codex, + settings: self, + tokenOverride: nil) + guard let credentials = try? CodexOAuthCredentialsStore.load(env: env) else { return nil } + let accessToken = credentials.accessToken.trimmingCharacters(in: .whitespacesAndNewlines) + let refreshToken = credentials.refreshToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !accessToken.isEmpty, refreshToken.isEmpty else { return nil } + return accessToken + } + + func isActiveCodexAPIAccount( + baseEnvironment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + self.activeCodexAPIKey(baseEnvironment: baseEnvironment) != nil + } + func setActiveTokenAccountIndex(_ index: Int, for provider: UsageProvider) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } // index == -1 means "use default account" (no CODEX_HOME override) @@ -56,17 +77,8 @@ extension SettingsStore { self.updateProviderConfig(provider: provider) { entry in entry.tokenAccounts = updated } - // Keep active-codex-home in sync so the shell hook reflects the new selection. if provider == .codex { - let path: String? - if clamped >= 0, clamped < updated.accounts.count { - let token = updated.accounts[clamped].token.trimmingCharacters(in: .whitespacesAndNewlines) - path = token.lowercased().hasPrefix("apikey:") ? nil : token - } else { - // Reverted to default ~/.codex — clear override so shell falls back to its own default - path = nil - } - CodexBarShellIntegration.setActiveCodexHome(path) + self.repairCodexShellIntegrationIfNeeded() } CodexBarLog.logger(LogCategories.tokenAccounts).info( "Active token account updated", @@ -98,12 +110,8 @@ extension SettingsStore { entry.tokenAccounts = updated } self.applyTokenAccountCookieSourceIfNeeded(provider: provider) - // For OAuth Codex accounts (path-based token), update the active-codex-home file and - // ensure the zsh precmd hook is installed so new terminal sessions inherit CODEX_HOME. - if provider == .codex, !trimmedToken.lowercased().hasPrefix("apikey:") { - CodexBarShellIntegration.symlinkDefaultSessionsIfNeeded(into: trimmedToken) - CodexBarShellIntegration.setActiveCodexHome(trimmedToken) - CodexBarShellIntegration.installZshHookIfNeeded() + if provider == .codex { + self.repairCodexShellIntegrationIfNeeded() } CodexBarLog.logger(LogCategories.tokenAccounts).info( "Token account added", @@ -196,16 +204,8 @@ extension SettingsStore { activeIndex: computedActiveIndex) } } - // Update active-codex-home to reflect the new active account after deletion. if provider == .codex { - let newActive: String? - if !filtered.isEmpty, computedActiveIndex >= 0, computedActiveIndex < filtered.count { - let token = filtered[computedActiveIndex].token.trimmingCharacters(in: .whitespacesAndNewlines) - newActive = token.lowercased().hasPrefix("apikey:") ? nil : token - } else { - newActive = nil - } - CodexBarShellIntegration.setActiveCodexHome(newActive) + self.repairCodexShellIntegrationIfNeeded() } CodexBarLog.logger(LogCategories.tokenAccounts).info( "Token account removed", @@ -253,4 +253,39 @@ extension SettingsStore { else { return } ProviderCatalog.implementation(for: provider)?.applyTokenAccountCookieSource(settings: self) } + + func repairCodexShellIntegrationIfNeeded() { + guard !Self.isRunningTests else { return } + + let pathAccounts = self.tokenAccounts(for: .codex) + .map(\.token) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { token in + !token.isEmpty && !token.lowercased().hasPrefix("apikey:") + } + + guard !pathAccounts.isEmpty else { + CodexBarShellIntegration.setActiveCodexHome(nil) + return + } + + CodexBarShellIntegration.installZshHookIfNeeded() + for path in pathAccounts { + CodexBarShellIntegration.ensureDedicatedSessionsDirectoryIfNeeded(into: path) + } + + let activeToken = self.selectedTokenAccount(for: .codex)? + .token + .trimmingCharacters(in: .whitespacesAndNewlines) + let activePath: String? + if let activeToken, + !activeToken.isEmpty, + !activeToken.lowercased().hasPrefix("apikey:") + { + activePath = activeToken + } else { + activePath = nil + } + CodexBarShellIntegration.setActiveCodexHome(activePath) + } } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 23872508c..45453cc92 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -155,6 +155,7 @@ final class SettingsStore { self.openAIWebAccessEnabled = self.codexCookieSource.isEnabled Self.sharedDefaults?.set(self.debugDisableKeychainAccess, forKey: "debugDisableKeychainAccess") KeychainAccessGate.isDisabled = self.debugDisableKeychainAccess + self.repairCodexShellIntegrationIfNeeded() } } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 9342689f4..da76e5e2e 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1271,6 +1271,14 @@ extension StatusItemController { } private func makeWebHistoryMenuItem(title: String, id: String, submenu: NSMenu) -> NSMenuItem { + if !Self.menuCardRenderingEnabled { + let item = NSMenuItem(title: title, action: #selector(self.menuCardNoOp(_:)), keyEquivalent: "") + item.target = self + item.representedObject = id + item.submenu = submenu + return item + } + let width: CGFloat = 310 return self.makeMenuCardItem( HStack(spacing: 0) { diff --git a/Sources/CodexBar/UsageStore+AccountCosts.swift b/Sources/CodexBar/UsageStore+AccountCosts.swift index 17da7aa2b..cadd7ff61 100644 --- a/Sources/CodexBar/UsageStore+AccountCosts.swift +++ b/Sources/CodexBar/UsageStore+AccountCosts.swift @@ -62,6 +62,23 @@ extension UsageStore { // Token accounts for (offset, account) in tokenAccounts.enumerated() { group.addTask { + let trimmedToken = account.token.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedToken.lowercased().hasPrefix("apikey:") { + let entry = AccountCostEntry( + id: account.id.uuidString, + label: account.label, + isDefault: false, + creditsRemaining: nil, + isUnlimited: false, + planType: nil, + primaryUsedPercent: nil, + secondaryUsedPercent: nil, + primaryResetDescription: nil, + secondaryResetDescription: nil, + error: nil, + updatedAt: Date()) + return (offset + baseOffset, entry) + } guard let env = TokenAccountSupportCatalog.envOverride(for: .codex, token: account.token) else { let entry = AccountCostEntry( id: account.id.uuidString, diff --git a/Sources/CodexBar/UsageStore+CodexActiveCredits.swift b/Sources/CodexBar/UsageStore+CodexActiveCredits.swift index 709bd8551..a7f2b775f 100644 --- a/Sources/CodexBar/UsageStore+CodexActiveCredits.swift +++ b/Sources/CodexBar/UsageStore+CodexActiveCredits.swift @@ -5,6 +5,9 @@ extension UsageStore { /// Credits for the Codex account selected in the menu (tabs / Menu bar account). /// Primary (`~/.codex`) uses RPC/dashboard `credits`; add-on accounts use OAuth rows in `allAccountCredits`. func codexActiveMenuCredits() -> (snapshot: CreditsSnapshot?, error: String?, unlimited: Bool) { + if let apiKeyMessage = self.activeCodexAPIKeyCreditsMessage() { + return (nil, apiKeyMessage, false) + } guard let data = self.settings.tokenAccountsData(for: .codex), !data.accounts.isEmpty else { // No add-on accounts configured yet. In multi-account mode, still consult the OAuth // "default" row so Plus/Pro users see "Unlimited credits" immediately rather than diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index cfa52f6c5..36115b568 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -13,13 +13,40 @@ extension UsageStore { return self.settings.selectedTokenAccount(for: .codex) == nil } - /// True when the currently selected Codex account uses an API key ("apikey:" prefix). - /// API-key accounts have no local session logs, so session-based cost data does not apply - /// and falling back to ~/.codex would leak the primary account's cost totals. + /// True when the active Codex credentials resolve to API-key auth. + /// API-authenticated accounts have no local session logs; cost is fetched via the OpenAI REST API instead. var isActiveCodexAccountApiKey: Bool { - guard let account = self.settings.selectedTokenAccount(for: .codex) else { return false } + self.activeCodexApiKey != nil + } + + /// The raw API key for the active Codex credentials, whether it comes from a selected API-key row or the + /// resolved `auth.json`. + var activeCodexApiKey: String? { + self.settings.activeCodexAPIKey() + } + + var activeCodexAPIKeyAccount: ProviderTokenAccount? { + guard let account = self.settings.selectedTokenAccount(for: .codex) else { return nil } let token = account.token.trimmingCharacters(in: .whitespacesAndNewlines) - return token.lowercased().hasPrefix("apikey:") + return token.lowercased().hasPrefix("apikey:") ? account : nil + } + + func activeCodexAPIKeySettingsNotice() -> String? { + guard let account = self.activeCodexAPIKeyAccount else { return nil } + let error = self.tokenError(for: .codex)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !error.isEmpty else { return nil } + return "\(account.displayName): \(error)" + } + + func activeCodexAPIKeyCreditsMessage() -> String? { + if let account = self.activeCodexAPIKeyAccount { + return "\(account.displayName) is an API key account. " + + "ChatGPT credits are not available here; use Subscription Utilization for API spend." + } + guard self.activeCodexApiKey != nil else { return nil } + return "This Codex account uses API key auth. " + + "ChatGPT credits are not available here; use Subscription Utilization for API spend." } func codexCostUsageSessionsRootForActiveSelection() -> URL? { @@ -43,9 +70,13 @@ extension UsageStore { if let root = self.codexCostUsageSessionsRootForActiveSelection() { return root.path } - // API-key accounts have no session logs and cost data is suppressed, but they must - // still get a unique identity so switching default→apikey invalidates the TTL cache - // before the suppression guard is reached. + if self.activeCodexApiKey != nil { + if let account = self.settings.selectedTokenAccount(for: .codex) { + return "codex:apikey:\(account.id.uuidString)" + } + return "codex:default:apikey" + } + // When no API auth is active, keep a stable identity for the default auth path. if let account = self.settings.selectedTokenAccount(for: .codex) { let token = account.token.trimmingCharacters(in: .whitespacesAndNewlines) if token.lowercased().hasPrefix("apikey:") { @@ -73,6 +104,13 @@ extension UsageStore { self.tokenRefreshInFlight.contains(provider) } + func resolvedTokenCostNoDataMessage(for provider: UsageProvider) -> String { + if provider == .codex, let sessionsRoot = self.codexCostUsageSessionsRootForActiveSelection() { + return Self.codexTokenCostNoDataMessage(sessionsRoot: sessionsRoot) + } + return Self.tokenCostNoDataMessage(for: provider) + } + nonisolated static func costUsageCacheDirectory( fileManager: FileManager = .default) -> URL { @@ -85,4 +123,15 @@ extension UsageStore { nonisolated static func tokenCostNoDataMessage(for provider: UsageProvider) -> String { ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.noDataMessage() } + + nonisolated static func codexTokenCostNoDataMessage(sessionsRoot: URL) -> String { + let sessionsPath = sessionsRoot.standardizedFileURL.path + let archivedPath = sessionsRoot + .deletingLastPathComponent() + .appendingPathComponent("archived_sessions", isDirectory: true) + .standardizedFileURL + .path + return "No Codex sessions found in \(sessionsPath) or \(archivedPath). " + + "Run `codex` once while this account is active to start tracking cost." + } } diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index 25fd36bf5..c0151f6bb 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -37,7 +37,15 @@ extension UsageStore { } ?? [] let tokenUsage = Self.widgetTokenUsageSummary(from: tokenSnapshot) - let creditsRemaining = provider == .codex ? self.codexActiveCreditsRemaining() : nil + let creditsRemaining: Double? = if provider == .codex { + if self.settings.codexMultipleAccountsEnabled { + self.codexActiveCreditsRemaining() + } else { + self.credits?.remaining + } + } else { + nil + } let codeReviewRemaining = provider == .codex ? self.openAIDashboard?.codeReviewRemainingPercent : nil return WidgetSnapshot.ProviderEntry( diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index f6ef2ff5e..d3c4c5dfa 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1567,6 +1567,15 @@ extension UsageStore { return } + if provider == .codex, !self.settings.isCostUsageEffectivelyEnabled(for: .codex) { + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.reset() + self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenCostSelectionIdentity.removeValue(forKey: provider) + return + } + guard !self.tokenRefreshInFlight.contains(provider) else { return } let selectionIdentity = self.tokenCostSelectionIdentity(for: provider) @@ -1593,16 +1602,6 @@ extension UsageStore { return } - // API-key accounts have no local session logs. Return nil from - // codexCostUsageSessionsRootForActiveSelection, which causes loadTokenSnapshot - // to fall back to ~/.codex and surface the primary account's cost data instead. - // Suppress cost entirely to preserve multi-account isolation. - if provider == .codex, self.isActiveCodexAccountApiKey { - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - return - } - let startedAt = Date() let providerText = provider.rawValue self.tokenCostLogger @@ -1611,29 +1610,49 @@ extension UsageStore { do { let fetcher = self.costUsageFetcher let timeoutSeconds = self.tokenFetchTimeout - let codexRoot = provider == .codex ? self.codexCostUsageSessionsRootForActiveSelection() : nil - let snapshot = try await withThrowingTaskGroup(of: CostUsageTokenSnapshot.self) { group in + // API-key accounts: fetch cost via the OpenAI REST API (no local session logs). + // OAuth/path accounts: scan the local CODEX_HOME/sessions directory. + let codexApiKey = provider == .codex ? self.activeCodexApiKey : nil + let codexRoot = provider == .codex && codexApiKey == nil + ? self.codexCostUsageSessionsRootForActiveSelection() + : nil + struct TokenCostFetchResult: Sendable { + let snapshot: CostUsageTokenSnapshot + let errorMessage: String? + } + + let result = try await withThrowingTaskGroup(of: TokenCostFetchResult.self) { group in group.addTask(priority: .utility) { - try await fetcher.loadTokenSnapshot( - provider: provider, - now: now, - forceRefresh: force, - allowVertexClaudeFallback: !self.isEnabled(.claude), - codexSessionsRoot: codexRoot, - claudeProjectsRoots: nil) + if let apiKey = codexApiKey { + let apiResult = await OpenAIAPIUsageFetcher.loadSnapshot(apiKey: apiKey, now: now) + return TokenCostFetchResult( + snapshot: apiResult.snapshot, + errorMessage: apiResult.errorMessage) + } + return try await TokenCostFetchResult( + snapshot: fetcher.loadTokenSnapshot( + provider: provider, + now: now, + forceRefresh: force, + allowVertexClaudeFallback: !self.isEnabled(.claude), + codexSessionsRoot: codexRoot, + claudeProjectsRoots: nil), + errorMessage: nil) } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) throw CostUsageError.timedOut(seconds: Int(timeoutSeconds)) } defer { group.cancelAll() } - guard let snapshot = try await group.next() else { throw CancellationError() } - return snapshot + guard let result = try await group.next() else { throw CancellationError() } + return result } + let snapshot = result.snapshot + guard !snapshot.daily.isEmpty else { self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = Self.tokenCostNoDataMessage(for: provider) + self.tokenErrors[provider] = result.errorMessage ?? self.resolvedTokenCostNoDataMessage(for: provider) self.tokenFailureGates[provider]?.recordSuccess() return } @@ -1647,8 +1666,12 @@ extension UsageStore { "today=\(sessionCost) " + "30d=\(monthCost)" self.tokenCostLogger.info(message) + if let errorMessage = result.errorMessage, !errorMessage.isEmpty { + self.tokenCostLogger.warning( + "cost usage partial provider=\(providerText) warning=\(errorMessage)") + } self.tokenSnapshots[provider] = snapshot - self.tokenErrors[provider] = nil + self.tokenErrors[provider] = result.errorMessage self.tokenFailureGates[provider]?.recordSuccess() self.persistWidgetSnapshot(reason: "token-usage") } catch { diff --git a/Sources/CodexBarCore/Providers/Codex/OpenAIAPIUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/OpenAIAPIUsageFetcher.swift new file mode 100644 index 000000000..035cd0d7d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/OpenAIAPIUsageFetcher.swift @@ -0,0 +1,533 @@ +import Foundation + +public struct OpenAIAPIUsageSnapshotResult: Sendable, Equatable { + public let snapshot: CostUsageTokenSnapshot + public let errorMessage: String? + + public init(snapshot: CostUsageTokenSnapshot, errorMessage: String?) { + self.snapshot = snapshot + self.errorMessage = errorMessage + } +} + +/// Fetches Codex API-key usage from the OpenAI REST API. +/// +/// Strategy: +/// - Prefer the current organization endpoints for 30-day cost + token history. +/// - Fallback to the legacy `/v1/usage` endpoint for today's token counts when org usage +/// permissions are unavailable. +/// - Return partial data plus a user-facing error when spend is blocked by key scopes. +public enum OpenAIAPIUsageFetcher { + private struct UTCWindow: Sendable { + let startInclusive: Date + let endExclusive: Date + } + + private struct PartialResult: Sendable { + let value: Value + let error: OpenAIAPIError? + } + + private struct OrganizationPage: Decodable { + let data: [Bucket] + let hasMore: Bool? + let nextPage: String? + + enum CodingKeys: String, CodingKey { + case data + case hasMore = "has_more" + case nextPage = "next_page" + } + } + + private struct Bucket: Decodable, Sendable { + let startTime: TimeInterval + let endTime: TimeInterval + let results: [Result] + + enum CodingKeys: String, CodingKey { + case startTime = "start_time" + case endTime = "end_time" + case results + } + } + + private struct OrganizationCostResult: Decodable, Sendable { + struct Amount: Decodable, Sendable { + let value: Double? + let currency: String? + } + + let amount: Amount? + let lineItem: String? + + enum CodingKeys: String, CodingKey { + case amount + case lineItem = "line_item" + } + } + + private struct OrganizationCompletionsResult: Decodable, Sendable { + let inputTokens: Int? + let outputTokens: Int? + let inputCachedTokens: Int? + let model: String? + + enum CodingKeys: String, CodingKey { + case inputTokens = "input_tokens" + case outputTokens = "output_tokens" + case inputCachedTokens = "input_cached_tokens" + case model + } + } + + private struct LegacyTokenUsageResponse: Decodable, Sendable { + let data: [LegacyTokenEntry] + } + + private struct LegacyTokenEntry: Decodable, Sendable { + let nContextTokensTotal: Int + let nGeneratedTokensTotal: Int + + enum CodingKeys: String, CodingKey { + case nContextTokensTotal = "n_context_tokens_total" + case nGeneratedTokensTotal = "n_generated_tokens_total" + } + } + + private struct DailyAccumulator: Sendable { + struct ModelAccumulator: Sendable { + var totalTokens: Int = 0 + } + + var inputTokens: Int = 0 + var outputTokens: Int = 0 + var cacheReadTokens: Int = 0 + var hasTokenData = false + var costUSD: Double = 0 + var hasCostData = false + var modelsUsed: Set = [] + var modelBreakdowns: [String: ModelAccumulator] = [:] + + mutating func addCost(_ cost: Double?) { + guard let cost else { return } + self.costUSD += cost + self.hasCostData = true + } + + mutating func addTokens( + input: Int, + output: Int, + cacheRead: Int = 0, + model: String? = nil) + { + guard input > 0 || output > 0 || cacheRead > 0 else { return } + self.inputTokens += input + self.outputTokens += output + self.cacheReadTokens += cacheRead + self.hasTokenData = true + + let trimmedModel = model?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmedModel.isEmpty else { return } + self.modelsUsed.insert(trimmedModel) + var breakdown = self.modelBreakdowns[trimmedModel] ?? ModelAccumulator() + breakdown.totalTokens += input + output + self.modelBreakdowns[trimmedModel] = breakdown + } + + func entry(for date: String) -> CostUsageDailyReport.Entry? { + guard self.hasTokenData || self.hasCostData else { return nil } + let modelNames = self.modelsUsed.sorted() + let breakdowns = self.modelBreakdowns + .keys + .sorted() + .compactMap { name -> CostUsageDailyReport.ModelBreakdown? in + guard let breakdown = self.modelBreakdowns[name] else { return nil } + return CostUsageDailyReport.ModelBreakdown( + modelName: name, + costUSD: nil, + totalTokens: breakdown.totalTokens > 0 ? breakdown.totalTokens : nil) + } + let totalTokens = self.hasTokenData ? self.inputTokens + self.outputTokens : nil + return CostUsageDailyReport.Entry( + date: date, + inputTokens: self.hasTokenData ? self.inputTokens : nil, + outputTokens: self.hasTokenData ? self.outputTokens : nil, + cacheReadTokens: self.cacheReadTokens > 0 ? self.cacheReadTokens : nil, + totalTokens: totalTokens, + costUSD: self.hasCostData ? self.costUSD : nil, + modelsUsed: modelNames.isEmpty ? nil : modelNames, + modelBreakdowns: breakdowns.isEmpty ? nil : breakdowns) + } + } + + // MARK: - Public entry point + + public static func loadSnapshot( + apiKey: String, + now: Date = Date(), + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = { request in + try await URLSession.shared.data(for: request) + }) + async -> OpenAIAPIUsageSnapshotResult + { + let window = self.utcWindow(containing: now) + + async let costsFetch = self.fetchOrganizationCostsSafe( + apiKey: apiKey, + start: window.startInclusive, + endExclusive: window.endExclusive, + dataLoader: dataLoader) + async let orgUsageFetch = self.fetchOrganizationUsageSafe( + apiKey: apiKey, + start: window.startInclusive, + endExclusive: window.endExclusive, + dataLoader: dataLoader) + async let legacyUsageFetch = self.fetchLegacyTodayUsageSafe( + apiKey: apiKey, + date: now, + dataLoader: dataLoader) + + let (costs, orgUsage, legacyUsage) = await (costsFetch, orgUsageFetch, legacyUsageFetch) + + let daily = self.buildDailyReport( + costs: costs.value, + organizationUsage: orgUsage.value, + legacyTodayUsage: legacyUsage.value, + now: now) + let snapshot = CostUsageFetcher.tokenSnapshot(from: daily, now: now) + let errorMessage = self.errorMessage( + costError: costs.error, + organizationUsageError: orgUsage.error, + hasData: !snapshot.daily.isEmpty) + + return OpenAIAPIUsageSnapshotResult( + snapshot: snapshot, + errorMessage: errorMessage) + } + + // MARK: - Current org usage endpoints + + private static func fetchOrganizationCostsSafe( + apiKey: String, + start: Date, + endExclusive: Date, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) + async -> PartialResult<[Bucket]> + { + do { + return try await PartialResult( + value: self.fetchOrganizationCosts( + apiKey: apiKey, + start: start, + endExclusive: endExclusive, + dataLoader: dataLoader), + error: nil) + } catch let error as OpenAIAPIError { + return PartialResult(value: [], error: error) + } catch { + return PartialResult( + value: [], + error: OpenAIAPIError(statusCode: -1, body: error.localizedDescription)) + } + } + + private static func fetchOrganizationCosts( + apiKey: String, + start: Date, + endExclusive: Date, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) + async throws -> [Bucket] + { + var components = URLComponents(string: "https://api.openai.com/v1/organization/costs")! + components.queryItems = [ + URLQueryItem(name: "start_time", value: String(Int(start.timeIntervalSince1970))), + URLQueryItem(name: "end_time", value: String(Int(endExclusive.timeIntervalSince1970))), + URLQueryItem(name: "bucket_width", value: "1d"), + URLQueryItem(name: "limit", value: "30"), + ] + let data = try await self.apiRequest(url: components.url!, apiKey: apiKey, dataLoader: dataLoader) + return try JSONDecoder().decode(OrganizationPage.self, from: data).data + } + + private static func fetchOrganizationUsageSafe( + apiKey: String, + start: Date, + endExclusive: Date, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) + async -> PartialResult<[Bucket]> + { + do { + return try await PartialResult( + value: self.fetchOrganizationUsage( + apiKey: apiKey, + start: start, + endExclusive: endExclusive, + dataLoader: dataLoader), + error: nil) + } catch let error as OpenAIAPIError { + return PartialResult(value: [], error: error) + } catch { + return PartialResult( + value: [], + error: OpenAIAPIError(statusCode: -1, body: error.localizedDescription)) + } + } + + private static func fetchOrganizationUsage( + apiKey: String, + start: Date, + endExclusive: Date, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) + async throws -> [Bucket] + { + var components = URLComponents(string: "https://api.openai.com/v1/organization/usage/completions")! + components.queryItems = [ + URLQueryItem(name: "start_time", value: String(Int(start.timeIntervalSince1970))), + URLQueryItem(name: "end_time", value: String(Int(endExclusive.timeIntervalSince1970))), + URLQueryItem(name: "bucket_width", value: "1d"), + URLQueryItem(name: "limit", value: "30"), + ] + let data = try await self.apiRequest(url: components.url!, apiKey: apiKey, dataLoader: dataLoader) + return try JSONDecoder().decode(OrganizationPage.self, from: data).data + } + + // MARK: - Legacy today-token fallback + + private static func fetchLegacyTodayUsageSafe( + apiKey: String, + date: Date, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) + async -> PartialResult<[LegacyTokenEntry]> + { + do { + return try await PartialResult( + value: self.fetchLegacyTodayUsage(apiKey: apiKey, date: date, dataLoader: dataLoader), + error: nil) + } catch let error as OpenAIAPIError { + return PartialResult(value: [], error: error) + } catch { + return PartialResult( + value: [], + error: OpenAIAPIError(statusCode: -1, body: error.localizedDescription)) + } + } + + private static func fetchLegacyTodayUsage( + apiKey: String, + date: Date, + dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) + async throws -> [LegacyTokenEntry] + { + var components = URLComponents(string: "https://api.openai.com/v1/usage")! + components.queryItems = [URLQueryItem(name: "date", value: self.dayKey(from: date))] + let data = try await self.apiRequest(url: components.url!, apiKey: apiKey, dataLoader: dataLoader) + return try JSONDecoder().decode(LegacyTokenUsageResponse.self, from: data).data + } + + // MARK: - Merge helpers + + private static func buildDailyReport( + costs: [Bucket], + organizationUsage: [Bucket], + legacyTodayUsage: [LegacyTokenEntry], + now: Date) -> CostUsageDailyReport + { + var dailyMap: [String: DailyAccumulator] = [:] + + for bucket in costs { + let day = self.dayKey(from: Date(timeIntervalSince1970: bucket.startTime)) + var accumulator = dailyMap[day] ?? DailyAccumulator() + for result in bucket.results { + accumulator.addCost(result.amount?.value) + } + dailyMap[day] = accumulator + } + + for bucket in organizationUsage { + let day = self.dayKey(from: Date(timeIntervalSince1970: bucket.startTime)) + var accumulator = dailyMap[day] ?? DailyAccumulator() + for result in bucket.results { + accumulator.addTokens( + input: result.inputTokens ?? 0, + output: result.outputTokens ?? 0, + cacheRead: result.inputCachedTokens ?? 0, + model: result.model) + } + dailyMap[day] = accumulator + } + + let today = self.dayKey(from: now) + let legacyInput = legacyTodayUsage.reduce(0) { $0 + $1.nContextTokensTotal } + let legacyOutput = legacyTodayUsage.reduce(0) { $0 + $1.nGeneratedTokensTotal } + if legacyInput > 0 || legacyOutput > 0 { + var accumulator = dailyMap[today] ?? DailyAccumulator() + if !accumulator.hasTokenData { + accumulator.addTokens(input: legacyInput, output: legacyOutput) + dailyMap[today] = accumulator + } + } + + let entries = dailyMap.keys.sorted().compactMap { day in + dailyMap[day]?.entry(for: day) + } + + let inputValues = entries.compactMap(\.inputTokens) + let outputValues = entries.compactMap(\.outputTokens) + let cacheValues = entries.compactMap(\.cacheReadTokens) + let tokenValues = entries.compactMap(\.totalTokens) + let costValues = entries.compactMap(\.costUSD) + + let finalSummary: CostUsageDailyReport.Summary? = + entries.isEmpty + ? nil + : CostUsageDailyReport.Summary( + totalInputTokens: inputValues.isEmpty ? nil : inputValues.reduce(0, +), + totalOutputTokens: outputValues.isEmpty ? nil : outputValues.reduce(0, +), + cacheReadTokens: cacheValues.isEmpty ? nil : cacheValues.reduce(0, +), + totalTokens: tokenValues.isEmpty ? nil : tokenValues.reduce(0, +), + totalCostUSD: costValues.isEmpty ? nil : costValues.reduce(0, +)) + + return CostUsageDailyReport(data: entries, summary: finalSummary) + } + + private static func errorMessage( + costError: OpenAIAPIError?, + organizationUsageError: OpenAIAPIError?, + hasData: Bool) -> String? + { + if let costError { + if let missingScopes = self.missingScopes(from: costError), !missingScopes.isEmpty { + let scopesText = missingScopes.map { "`\($0)`" }.joined(separator: ", ") + let grantText = missingScopes.count == 1 ? "Grant that scope" : "Grant those scopes" + if hasData { + return "OpenAI blocked spend data for this key: missing \(scopesText). " + + "Token usage is shown, but cost is unavailable. " + + "\(grantText) or use an organization/admin key with usage access." + } + return "OpenAI blocked spend data for this key: missing \(scopesText). " + + "\(grantText) or use an organization/admin key with usage access." + } + let reason = self.permissionHint(for: costError) + if hasData { + return "OpenAI blocked spend data for this key. \(reason)" + } + return "OpenAI blocked spend data for this key. \(reason)" + } + + if let organizationUsageError, !hasData { + return "This API key cannot read OpenAI usage history. \(self.permissionHint(for: organizationUsageError))" + } + + return nil + } + + private static func permissionHint(for error: OpenAIAPIError) -> String { + let message = (error.apiMessage ?? error.body) + .trimmingCharacters(in: .whitespacesAndNewlines) + let lower = message.lowercased() + + if lower.contains("api.usage.read") { + return "Grant the `api.usage.read` scope or use an organization/admin key with usage access." + } + if lower.contains("insufficient permissions") { + return "OpenAI says this key does not have permission to read organization usage/cost data." + } + if lower.contains("session key") { + return "OpenAI only allows that billing endpoint from a signed-in browser session." + } + if error.statusCode == 401 { + return "OpenAI rejected the API key." + } + if !message.isEmpty { + return message + } + return "OpenAI returned HTTP \(error.statusCode)." + } + + private static func missingScopes(from error: OpenAIAPIError) -> [String]? { + let message = (error.apiMessage ?? error.body) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { return nil } + let pattern = "(?i)missing scopes:\\s*(.+?)(?:\\.\\s*check|$)" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let nsRange = NSRange(message.startIndex.. (Data, URLResponse)) + async throws -> Data + { + var request = URLRequest(url: url, timeoutInterval: 20) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + let (data, response) = try await dataLoader(request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + let body = String(data: data, encoding: .utf8) ?? "" + throw OpenAIAPIError(statusCode: code, body: body) + } + return data + } + + private static func utcWindow(containing now: Date) -> UTCWindow { + let start = self.utcStartOfDay( + Calendar(identifier: .gregorian).date(byAdding: .day, value: -29, to: now) ?? now) + let end = self.utcStartOfDay( + Calendar(identifier: .gregorian).date(byAdding: .day, value: 1, to: now) ?? now) + return UTCWindow(startInclusive: start, endExclusive: end) + } + + private static func utcStartOfDay(_ date: Date) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar.startOfDay(for: date) + } + + private static func dayKey(from date: Date) -> String { + let utcDay = self.utcStartOfDay(date) + let components = Calendar(identifier: .gregorian).dateComponents(in: TimeZone(identifier: "UTC")!, from: utcDay) + return String(format: "%04d-%02d-%02d", components.year!, components.month!, components.day!) + } +} + +public struct OpenAIAPIError: LocalizedError, Sendable { + public let statusCode: Int + public let body: String + + public var apiMessage: String? { + guard let data = self.body.data(using: .utf8), !data.isEmpty else { return nil } + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let message = object["error"] as? String, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return message + } + if let error = object["error"] as? [String: Any], + let message = error["message"] as? String, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return message + } + } + return nil + } + + public var errorDescription: String? { + self.apiMessage ?? "OpenAI API error \(self.statusCode)" + } +} diff --git a/Tests/CodexBarTests/CodexActiveCreditsTests.swift b/Tests/CodexBarTests/CodexActiveCreditsTests.swift index 1749bf529..599d43c71 100644 --- a/Tests/CodexBarTests/CodexActiveCreditsTests.swift +++ b/Tests/CodexBarTests/CodexActiveCreditsTests.swift @@ -145,4 +145,23 @@ struct CodexActiveCreditsTests { #expect(result.unlimited == true) #expect(store.codexActiveCreditsRemaining() == nil) } + + @Test + func `api key account shows api credits message instead of oauth error`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "CodexActiveCreditsTests-apikey"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.addTokenAccount(provider: .codex, label: "Attune API Test", token: "apikey:sk-test") + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let result = store.codexActiveMenuCredits() + #expect(result.snapshot == nil) + #expect(result.unlimited == false) + #expect(result.error?.contains("Attune API Test") == true) + #expect(result.error?.contains("Subscription Utilization") == true) + #expect(result.error?.localizedCaseInsensitiveContains("oauth token expired") == false) + } } diff --git a/Tests/CodexBarTests/CodexBarShellIntegrationTests.swift b/Tests/CodexBarTests/CodexBarShellIntegrationTests.swift new file mode 100644 index 000000000..15010e159 --- /dev/null +++ b/Tests/CodexBarTests/CodexBarShellIntegrationTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing +@testable import CodexBar + +struct CodexBarShellIntegrationTests { + @Test + func `ensure dedicated sessions directory creates account root once`() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let accountRoot = root.appendingPathComponent("account", isDirectory: true) + try fm.createDirectory(at: accountRoot, withIntermediateDirectories: true) + + CodexBarShellIntegration.ensureDedicatedSessionsDirectoryIfNeeded( + into: accountRoot.path, + fileManager: fm) + CodexBarShellIntegration.ensureDedicatedSessionsDirectoryIfNeeded( + into: accountRoot.path, + fileManager: fm) + + let sessions = accountRoot.appendingPathComponent("sessions", isDirectory: true) + #expect(fm.fileExists(atPath: sessions.path)) + let values = try sessions.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + #expect(values.isDirectory == true) + #expect(values.isSymbolicLink != true) + } + + @Test + func `ensure dedicated sessions directory replaces legacy shared symlink`() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? fm.removeItem(at: root) } + + let defaultSessions = root + .appendingPathComponent(".codex", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try fm.createDirectory(at: defaultSessions, withIntermediateDirectories: true) + + let accountRoot = root.appendingPathComponent("account", isDirectory: true) + try fm.createDirectory(at: accountRoot, withIntermediateDirectories: true) + let sessions = accountRoot.appendingPathComponent("sessions", isDirectory: true) + try fm.createSymbolicLink(at: sessions, withDestinationURL: defaultSessions) + + CodexBarShellIntegration.ensureDedicatedSessionsDirectoryIfNeeded( + into: accountRoot.path, + fileManager: fm, + defaultSessionsRoot: defaultSessions) + + let values = try sessions.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + #expect(values.isDirectory == true) + #expect(values.isSymbolicLink != true) + } + + @Test + func `set active codex home writes and clears override file`() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? fm.removeItem(at: root) } + + CodexBarShellIntegration.setActiveCodexHome( + "/tmp/codex-work", + fileManager: fm, + codexbarDirectory: root) + + let activeFile = root.appendingPathComponent("active-codex-home") + let contents = try String(contentsOf: activeFile, encoding: .utf8) + #expect(contents == "/tmp/codex-work") + + CodexBarShellIntegration.setActiveCodexHome( + nil, + fileManager: fm, + codexbarDirectory: root) + + #expect(fm.fileExists(atPath: activeFile.path) == false) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index e5097d75f..ab69f577d 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -296,6 +296,41 @@ struct MenuCardModelTests { #expect(model.tokenUsage?.monthLine.contains("tokens") == true) } + @Test + func `cost section still renders when only api key error is available`() throws { + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date()) + let error = "OpenAI blocked spend data for this key: missing `api.usage.read`. " + + "Grant that scope or use an organization/admin key with usage access." + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: error, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: Date())) + + #expect(model.tokenUsage?.sessionLine == "Today: —") + #expect(model.tokenUsage?.monthLine == "Last 30 days: —") + #expect(model.tokenUsage?.errorLine == error) + } + @Test func `claude model does not leak codex plan`() throws { let metadata = try #require(ProviderDefaults.metadata[.claude]) diff --git a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift new file mode 100644 index 000000000..a35f60a52 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift @@ -0,0 +1,202 @@ +import CodexBarCore +import Foundation +import Testing + +struct OpenAIAPIUsageFetcherTests { + @Test + func `organization usage endpoints build token and cost snapshot`() async { + let now = Self.date("2026-03-23T12:00:00Z") + let result = await OpenAIAPIUsageFetcher.loadSnapshot( + apiKey: "sk-test", + now: now, + dataLoader: GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url else { throw URLError(.badURL) } + switch url.path { + case "/v1/organization/costs": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.jsonData([ + "data": [ + [ + "start_time": Self.unix("2026-03-22T00:00:00Z"), + "end_time": Self.unix("2026-03-23T00:00:00Z"), + "results": [ + [ + "amount": [ + "value": 1.25, + "currency": "usd", + ], + "line_item": "completions", + ], + ], + ], + [ + "start_time": Self.unix("2026-03-23T00:00:00Z"), + "end_time": Self.unix("2026-03-24T00:00:00Z"), + "results": [ + [ + "amount": [ + "value": 4.5, + "currency": "usd", + ], + "line_item": "completions", + ], + ], + ], + ], + ])) + case "/v1/organization/usage/completions": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.jsonData([ + "data": [ + [ + "start_time": Self.unix("2026-03-22T00:00:00Z"), + "end_time": Self.unix("2026-03-23T00:00:00Z"), + "results": [ + [ + "input_tokens": 100, + "output_tokens": 50, + "input_cached_tokens": 20, + "model": "gpt-5", + ], + ], + ], + [ + "start_time": Self.unix("2026-03-23T00:00:00Z"), + "end_time": Self.unix("2026-03-24T00:00:00Z"), + "results": [ + [ + "input_tokens": 200, + "output_tokens": 300, + "input_cached_tokens": 40, + "model": "gpt-5", + ], + ], + ], + ], + ])) + case "/v1/usage": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.jsonData([ + "object": "list", + "data": [], + ])) + default: + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 404, + body: Self.jsonData(["error": "unexpected path \(url.path)"])) + } + }) + + #expect(result.errorMessage == nil) + #expect(result.snapshot.daily.count == 2) + #expect(result.snapshot.sessionCostUSD == 4.5) + #expect(result.snapshot.sessionTokens == 500) + #expect(result.snapshot.last30DaysCostUSD == 5.75) + #expect(result.snapshot.last30DaysTokens == 650) + } + + @Test + func `falls back to legacy today usage when org usage scope is missing`() async throws { + let now = Self.date("2026-03-23T12:00:00Z") + let scopeError = "You have insufficient permissions for this operation. Missing scopes: api.usage.read." + let result = await OpenAIAPIUsageFetcher.loadSnapshot( + apiKey: "sk-test", + now: now, + dataLoader: GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url else { throw URLError(.badURL) } + switch url.path { + case "/v1/organization/costs", "/v1/organization/usage/completions": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 403, + body: Self.jsonData(["error": scopeError])) + case "/v1/usage": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.jsonData([ + "object": "list", + "data": [ + [ + "n_context_tokens_total": 120, + "n_generated_tokens_total": 30, + ], + ], + ])) + default: + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 404, + body: Self.jsonData(["error": "unexpected path \(url.path)"])) + } + }) + + #expect(result.snapshot.daily.count == 1) + #expect(result.snapshot.sessionCostUSD == nil) + #expect(result.snapshot.last30DaysCostUSD == nil) + #expect(result.snapshot.sessionTokens == 150) + #expect(result.snapshot.last30DaysTokens == 150) + let error = try #require(result.errorMessage) + #expect(error.contains("OpenAI blocked spend data for this key")) + #expect(error.contains("Token usage is shown, but cost is unavailable")) + #expect(error.contains("api.usage.read")) + } + + @Test + func `surfaces permission error when no usage data is accessible`() async throws { + let now = Self.date("2026-03-23T12:00:00Z") + let scopeError = "You have insufficient permissions for this operation. Missing scopes: api.usage.read." + let result = await OpenAIAPIUsageFetcher.loadSnapshot( + apiKey: "sk-test", + now: now, + dataLoader: GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url else { throw URLError(.badURL) } + switch url.path { + case "/v1/organization/costs", "/v1/organization/usage/completions": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 403, + body: Self.jsonData(["error": scopeError])) + case "/v1/usage": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: Self.jsonData([ + "object": "list", + "data": [], + ])) + default: + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 404, + body: Self.jsonData(["error": "unexpected path \(url.path)"])) + } + }) + + #expect(result.snapshot.daily.isEmpty) + let error = try #require(result.errorMessage) + #expect(error.contains("OpenAI blocked spend data for this key")) + #expect(error.contains("api.usage.read")) + } + + private static func date(_ value: String) -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value)! + } + + private static func unix(_ value: String) -> Int { + Int(self.date(value).timeIntervalSince1970) + } + + private static func jsonData(_ payload: [String: Any]) -> Data { + (try? JSONSerialization.data(withJSONObject: payload)) ?? Data() + } +} diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 7646b1419..7e40563ac 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -125,6 +125,37 @@ struct ProviderSettingsDescriptorTests { #expect(toggles.contains(where: { $0.id == "codex-historical-tracking" })) } + @Test + func `codex token account descriptor surfaces active api key usage warning`() throws { + let suite = "ProviderSettingsDescriptorTests-codex-apikey-warning" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.codexMultipleAccountsEnabled = true + settings.codexExplicitAccountsOnly = true + settings.addTokenAccount(provider: .codex, label: "Attune API Test", token: "apikey:sk-test") + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store.tokenErrors[.codex] = + "OpenAI blocked spend data for this key: missing `api.usage.read`. " + + "Grant that scope or use an organization/admin key with usage access." + + let pane = ProvidersPane(settings: settings, store: store) + let descriptor = try #require(pane._test_tokenAccountDescriptor(for: .codex)) + let warning = try #require(descriptor.activeAccountStatusText?()) + #expect(warning.contains("Attune API Test")) + #expect(warning.contains("blocked spend data")) + #expect(warning.contains("api.usage.read")) + } + @Test func `claude exposes usage and cookie pickers`() throws { let suite = "ProviderSettingsDescriptorTests-claude" diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index 53fde8602..93ecf3259 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -174,6 +174,44 @@ struct SettingsStoreAdditionalTests { #expect(SettingsStore.hasAnyTokenCostUsageSources(env: env, fileManager: fm)) } + @Test + func `codex cost usage is only enabled for active API key accounts`() { + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-codex-api-cost") + settings.costUsageEnabled = true + + settings.addTokenAccount(provider: .codex, label: "OAuth", token: "/tmp/codex-oauth") + #expect(!settings.isCostUsageEffectivelyEnabled(for: .codex)) + + settings.addTokenAccount(provider: .codex, label: "API", token: "apikey:sk-test") + settings.setActiveTokenAccountIndex(1, for: .codex) + #expect(settings.isCostUsageEffectivelyEnabled(for: .codex)) + + settings.setActiveTokenAccountIndex(0, for: .codex) + #expect(!settings.isCostUsageEffectivelyEnabled(for: .codex)) + } + + @Test + func `codex cost usage is enabled for default API auth json`() throws { + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-codex-default-api-cost") + settings.costUsageEnabled = true + + let fileManager = FileManager.default + let codexHome = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: codexHome, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: codexHome) } + + let authURL = codexHome.appendingPathComponent("auth.json", isDirectory: false) + try """ + { + "OPENAI_API_KEY": "sk-default-api" + } + """.write(to: authURL, atomically: true, encoding: .utf8) + + #expect(settings.isCostUsageEffectivelyEnabled( + for: .codex, + baseEnvironment: ["CODEX_HOME": codexHome.path])) + } + private static func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 69d2db047..85d564902 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -777,6 +777,7 @@ struct StatusMenuTests { settings.selectedMenuProvider = .codex settings.codexMultipleAccountsEnabled = true settings.costUsageEnabled = true + settings.addTokenAccount(provider: .codex, label: "API", token: "apikey:sk-test") let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -839,6 +840,62 @@ struct StatusMenuTests { } } + @Test + func `hides codex cost section for oauth token accounts even with stale cost data`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.codexMultipleAccountsEnabled = true + settings.costUsageEnabled = true + settings.addTokenAccount(provider: .codex, label: "OAuth", token: "/tmp/codex-oauth") + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + if let geminiMeta = registry.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let ids = menu.items.compactMap { $0.representedObject as? String } + #expect(!ids.contains("menuCardCost")) + } + @Test func `shows extra usage for claude when using menu card sections`() { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 3617053d8..80ff95f28 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -145,7 +145,11 @@ struct UsageStoreCoverageTests { addedAt: Date().timeIntervalSince1970, lastUsed: nil) - #expect(!store.shouldFetchAllTokenAccounts(provider: .codex, accounts: [account])) + #expect( + !store.shouldFetchAllTokenAccounts( + provider: .codex, + accounts: [account], + defaultAccountLabel: "")) #expect( store.shouldFetchAllTokenAccounts( provider: .codex, @@ -161,6 +165,20 @@ struct UsageStoreCoverageTests { defaultAccountLabel: "Primary")) } + @Test + func `codex no data message uses active account sessions path`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-codex-no-data-path") + settings.codexExplicitAccountsOnly = true + settings.addTokenAccount(provider: .codex, label: "Work", token: "/tmp/codex-work") + + let store = Self.makeUsageStore(settings: settings) + let message = store.resolvedTokenCostNoDataMessage(for: .codex) + + #expect(message.contains("/tmp/codex-work/sessions")) + #expect(message.contains("/tmp/codex-work/archived_sessions")) + #expect(message.contains("Run `codex` once while this account is active")) + } + @Test func `status indicators and failure gate`() { #expect(!ProviderStatusIndicator.none.hasIssue) From 712f1dd93de5b8436814514f4407284ab6a86d3e Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Mon, 23 Mar 2026 22:10:42 -0400 Subject: [PATCH 24/25] Fix unquoted CODEX_HOME in zsh hook and missing shell repair on explicit-accounts toggle - Quote the \$(cat ...) command substitution in the precmd hook so paths with spaces are not split by zsh word-splitting - Call repairCodexShellIntegrationIfNeeded() after setting codexExplicitAccountsOnly so active-codex-home is synced immediately when the toggle auto-selects the first explicit account Co-Authored-By: Claude Sonnet 4.6 --- Sources/CodexBar/CodexBarShellIntegration.swift | 4 ++-- Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/CodexBarShellIntegration.swift b/Sources/CodexBar/CodexBarShellIntegration.swift index d841fe8d9..85bd4cc93 100644 --- a/Sources/CodexBar/CodexBarShellIntegration.swift +++ b/Sources/CodexBar/CodexBarShellIntegration.swift @@ -5,7 +5,7 @@ import Foundation /// The file contains the absolute path of the currently selected Codex account's CODEX_HOME directory. /// A shell `precmd` hook installed in `.zshrc` re-exports `CODEX_HOME` on every prompt: /// -/// precmd_codexbar() { export CODEX_HOME=$(cat ~/.codexbar/active-codex-home 2>/dev/null); } +/// precmd_codexbar() { export CODEX_HOME="$(cat ~/.codexbar/active-codex-home 2>/dev/null)"; } /// autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar /// /// This means switching accounts in CodexBar immediately takes effect at the next shell prompt, @@ -29,7 +29,7 @@ enum CodexBarShellIntegration { private static let hookSnippet = """ # CodexBar shell integration — auto-switches CODEX_HOME when you change accounts in CodexBar -precmd_codexbar() { export CODEX_HOME=$(cat ~/.codexbar/active-codex-home 2>/dev/null); } +precmd_codexbar() { export CODEX_HOME="$(cat ~/.codexbar/active-codex-home 2>/dev/null)"; } autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar """ diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 83a620f78..778ec3fe6 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -20,6 +20,7 @@ extension SettingsStore { activeIndex: 0) } } + self.repairCodexShellIntegrationIfNeeded() } } From 71dbe185215bc8f34f6d8c830ff0effe7db59522 Mon Sep 17 00:00:00 2001 From: Raghav Ringshia Date: Tue, 24 Mar 2026 11:21:28 -0400 Subject: [PATCH 25/25] Fix six Codex multi-account bugs - moveTokenAccount: clamp already-invalid activeIndex instead of re-persisting it - codexMultipleAccountsEnabled setter: call repairCodexShellIntegrationIfNeeded so shell CODEX_HOME is updated immediately on enable, not only after a token-account write - codexExplicitAccountsOnly getter: return false when multipleAccounts is off, preventing hand-edited config from leaving the two flags in a contradictory state - refreshAllAccountCredits: await directly so credits and usage land in the same refresh cycle, eliminating the transient mismatch window in the menu - UsageStore+ObservationHelpers: observe codexMultipleAccountsEnabled explicitly so toggling it always triggers a refresh, independent of implementation internals - Fix two affected tests to enable codexMultipleAccountsEnabled before setting codexExplicitAccountsOnly, matching the new enforced invariant Made-with: Cursor --- .../CodexBar/CodexBarShellIntegration.swift | 19 +++++----- Sources/CodexBar/MenuDescriptor.swift | 7 ++-- ...OpenAIDashboardLoginWindowController.swift | 30 +++++++++++----- .../PreferencesProviderDetailView.swift | 32 +++++++++-------- .../Providers/Codex/CodexSettingsStore.swift | 8 ++++- .../SettingsStore+TokenAccounts.swift | 35 ++++++++++++------- .../UsageStore+ObservationHelpers.swift | 1 + Sources/CodexBar/UsageStore+OpenAIWeb.swift | 3 +- .../CodexBar/UsageStore+TokenAccounts.swift | 2 +- Sources/CodexBar/UsageStore.swift | 32 +++++++---------- .../OpenAIDashboardWebsiteDataStore.swift | 10 +++--- Tests/CodexBarTests/StatusMenuTests.swift | 2 ++ .../UsageStoreCoverageTests.swift | 2 ++ 13 files changed, 107 insertions(+), 76 deletions(-) diff --git a/Sources/CodexBar/CodexBarShellIntegration.swift b/Sources/CodexBar/CodexBarShellIntegration.swift index 85bd4cc93..e9f2e4696 100644 --- a/Sources/CodexBar/CodexBarShellIntegration.swift +++ b/Sources/CodexBar/CodexBarShellIntegration.swift @@ -28,10 +28,10 @@ enum CodexBarShellIntegration { private static let hookSnippet = """ -# CodexBar shell integration — auto-switches CODEX_HOME when you change accounts in CodexBar -precmd_codexbar() { export CODEX_HOME="$(cat ~/.codexbar/active-codex-home 2>/dev/null)"; } -autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar -""" + # CodexBar shell integration — auto-switches CODEX_HOME when you change accounts in CodexBar + precmd_codexbar() { export CODEX_HOME="$(cat ~/.codexbar/active-codex-home 2>/dev/null)"; } + autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar + """ // MARK: - Public API @@ -64,8 +64,8 @@ autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar fm.createFile(atPath: zshrc, contents: nil) } guard let existing = try? String(contentsOfFile: zshrc, encoding: .utf8) else { return } - guard !existing.contains(hookMarker) else { return } - try? (existing + hookSnippet).write(toFile: zshrc, atomically: true, encoding: .utf8) + guard !existing.contains(self.hookMarker) else { return } + try? (existing + self.hookSnippet).write(toFile: zshrc, atomically: true, encoding: .utf8) } /// Returns true if the zsh hook is already installed. @@ -89,9 +89,10 @@ autoload -Uz add-zsh-hook && add-zsh-hook precmd precmd_codexbar let accountSessions = URL(fileURLWithPath: (codexHomePath as NSString).expandingTildeInPath) .appendingPathComponent("sessions", isDirectory: true) - if let destination = try? fm.destinationOfSymbolicLink(atPath: accountSessions.path) - { - let destinationURL = URL(fileURLWithPath: destination, relativeTo: accountSessions.deletingLastPathComponent()) + if let destination = try? fm.destinationOfSymbolicLink(atPath: accountSessions.path) { + let destinationURL = URL( + fileURLWithPath: destination, + relativeTo: accountSessions.deletingLastPathComponent()) .resolvingSymlinksInPath() .standardizedFileURL if destinationURL.path == defaultSessions.path { diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index a0b78ac66..339cc7729 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -373,11 +373,10 @@ struct MenuDescriptor { guard store.settings.codexMultipleAccountsEnabled else { return nil } guard store.settings.openAIWebAccessEnabled else { return nil } - let accountIdentifier: String? - if let selected = store.settings.selectedTokenAccount(for: .codex) { - accountIdentifier = selected.token + let accountIdentifier: String? = if let selected = store.settings.selectedTokenAccount(for: .codex) { + selected.token } else { - accountIdentifier = ("~/.codex" as NSString).expandingTildeInPath + ("~/.codex" as NSString).expandingTildeInPath } guard let key = accountIdentifier, !key.isEmpty, !key.hasPrefix("apikey:") else { return nil } diff --git a/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift b/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift index 159d544ea..b776c405b 100644 --- a/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift +++ b/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift @@ -4,8 +4,13 @@ import WebKit /// NSWindow subclass that always accepts key status so the WKWebView inside can receive keyboard input. private class WebViewWindow: NSWindow { - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { true } + override var canBecomeKey: Bool { + true + } + + override var canBecomeMain: Bool { + true + } } /// Shows a WKWebView window where the user can sign in to chatgpt.com. @@ -29,7 +34,7 @@ final class OpenAIDashboardLoginWindowController: NSWindowController, WKNavigati private var displayName: String? private var loginDetected = false - // Dashboard-only mode callback. + /// Dashboard-only mode callback. private var onComplete: ((Bool) -> Void)? /// When true, the window auto-closes after login is detected. When false (view-only mode), @@ -39,7 +44,9 @@ final class OpenAIDashboardLoginWindowController: NSWindowController, WKNavigati // Unified add-account mode callbacks. private var onAccountCreated: ((_ email: String, _ codexHome: String) -> Void)? private var onDismissedWithoutLogin: (() -> Void)? - private var isUnifiedMode: Bool { self.onAccountCreated != nil } + private var isUnifiedMode: Bool { + self.onAccountCreated != nil + } /// JS that fetches the session endpoint to extract accessToken + user info. private static let sessionExtractScript = """ @@ -80,7 +87,12 @@ final class OpenAIDashboardLoginWindowController: NSWindowController, WKNavigati /// - Parameter accountEmail: Unique key for cookie store isolation (CODEX_HOME path or email). /// - Parameter displayName: Human-readable label for the window title. Falls back to accountEmail. /// - Parameter viewOnly: When true, skips login detection polling and keeps the window open for browsing. - init(accountEmail: String?, displayName: String? = nil, viewOnly: Bool = false, onComplete: ((Bool) -> Void)? = nil) { + init( + accountEmail: String?, + displayName: String? = nil, + viewOnly: Bool = false, + onComplete: ((Bool) -> Void)? = nil) + { self.accountEmail = Self.normalizeEmail(accountEmail) self.displayName = displayName self.autoCloseOnLogin = !viewOnly @@ -225,7 +237,9 @@ final class OpenAIDashboardLoginWindowController: NSWindowController, WKNavigati guard let dict = result as? [String: Any], let accessToken = dict["accessToken"] as? String, !accessToken.isEmpty else { - let errorMsg = (result as? [String: Any])?["error"] as? String ?? error?.localizedDescription ?? "unknown" + let errorMsg = (result as? [ + String: Any + ])?["error"] as? String ?? error?.localizedDescription ?? "unknown" self.logger.error("Failed to extract session token: \(errorMsg)") self.onComplete?(false) self.close() @@ -325,8 +339,8 @@ extension OpenAIDashboardLoginWindowController: WKUIDelegate { _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, - windowFeatures: WKWindowFeatures - ) -> WKWebView? { + windowFeatures: WKWindowFeatures) -> WKWebView? + { if navigationAction.targetFrame == nil || !(navigationAction.targetFrame?.isMainFrame ?? false) { webView.load(navigationAction.request) } diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index aefb30a58..94f499084 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -49,7 +49,11 @@ struct ProviderDetailView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { - let labelWidth = self.detailLabelWidth + // Compute once here — tokenAccountDefaultLabel reads auth.json from disk. + let codexDefaultLabel: String? = self.provider == .codex + ? CodexProviderImplementation().tokenAccountDefaultLabel(settings: self.settings) + : nil + let labelWidth = self.detailLabelWidth(defaultLabel: codexDefaultLabel) ProviderDetailHeaderView( provider: self.provider, store: self.store, @@ -57,7 +61,7 @@ struct ProviderDetailView: View { subtitle: self.subtitle, model: self.model, labelWidth: labelWidth, - hideAccountAndPlan: self.codexHidesHeaderAccountAndPlan, + hideAccountAndPlan: self.codexHidesHeaderAccountAndPlan(defaultLabel: codexDefaultLabel), onRefresh: self.onRefresh) // Multi-account toggles rendered ABOVE the Accounts section so that @@ -88,10 +92,9 @@ struct ProviderDetailView: View { } Group { - if self.provider == .codex, self.codexShowsUsageAccountSwitcher { + if self.provider == .codex, self.codexShowsUsageAccountSwitcher(defaultLabel: codexDefaultLabel) { let accounts = self.settings.tokenAccounts(for: .codex) - let defaultLabel = CodexProviderImplementation() - .tokenAccountDefaultLabel(settings: self.settings) + let defaultLabel = codexDefaultLabel let displaySelection = self.settings.displayTokenAccountActiveIndex(for: .codex) ProviderMetricsInlineView( provider: self.provider, @@ -236,7 +239,7 @@ struct ProviderDetailView: View { return """ CodexBar accounts only is on: ~/.codex is not used as an implicit account. \ Add identities under Accounts (OAuth, API key, or manual CODEX_HOME path). \ - Use "Default" on each row to choose which one drives the menu bar. + Use "Make Default" on each row to choose which one drives the menu bar. """ .replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespaces) @@ -244,35 +247,34 @@ struct ProviderDetailView: View { return """ The primary account is whichever identity Codex has configured in ~/.codex on this Mac. \ Other rows in Accounts are separate credentials/folders. \ - Use "Default" on each row to choose which one CodexBar shows in the menu bar. + Use "Make Default" on each row to choose which one CodexBar shows in the menu bar. """ .replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespaces) } /// When Codex has more than one selectable account, summary email/plan reflect only the active fetch — hide to - /// avoid confusion. - private var codexHidesHeaderAccountAndPlan: Bool { + /// avoid confusion. Accepts the pre-computed default label to avoid redundant disk reads. + private func codexHidesHeaderAccountAndPlan(defaultLabel: String?) -> Bool { guard self.provider == .codex else { return false } guard self.settings.codexMultipleAccountsEnabled else { return false } - let hasPrimary = !self.settings.codexExplicitAccountsOnly && - CodexProviderImplementation().tokenAccountDefaultLabel(settings: self.settings) != nil let addedCount = self.settings.tokenAccounts(for: .codex).count if self.settings.codexExplicitAccountsOnly { return addedCount >= 2 } + let hasPrimary = defaultLabel != nil return (hasPrimary ? 1 : 0) + addedCount >= 2 } /// Same rule as the menu-bar token switcher: default ~/.codex + ≥1 added account, or 2+ added accounts. - private var codexShowsUsageAccountSwitcher: Bool { + /// Accepts the pre-computed default label to avoid redundant disk reads. + private func codexShowsUsageAccountSwitcher(defaultLabel: String?) -> Bool { guard self.provider == .codex else { return false } guard self.settings.codexMultipleAccountsEnabled else { return false } let accounts = self.settings.tokenAccounts(for: .codex) if self.settings.codexExplicitAccountsOnly { return accounts.count >= 2 } - let defaultLabel = CodexProviderImplementation().tokenAccountDefaultLabel(settings: self.settings) return (accounts.count >= 1 && defaultLabel != nil) || accounts.count > 1 } @@ -320,12 +322,12 @@ struct ProviderDetailView: View { return email.isEmpty ? nil : email } - private var detailLabelWidth: CGFloat { + private func detailLabelWidth(defaultLabel: String?) -> CGFloat { var infoLabels = ["State", "Source", "Version", "Updated"] if self.store.status(for: self.provider) != nil { infoLabels.append("Status") } - let hideAccountPlan = self.codexHidesHeaderAccountAndPlan + let hideAccountPlan = self.codexHidesHeaderAccountAndPlan(defaultLabel: defaultLabel) if !hideAccountPlan, !self.model.email.isEmpty { infoLabels.append("Account") } diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 778ec3fe6..252c83834 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -5,7 +5,12 @@ extension SettingsStore { /// When `true`, CodexBar never treats `~/.codex` as an implicit menu-bar account; add accounts under Accounts /// (OAuth, API key, or path). var codexExplicitAccountsOnly: Bool { - get { self.configSnapshot.providerConfig(for: .codex)?.codexExplicitAccountsOnly ?? false } + get { + // Explicit-only only applies when multi-account is active; silently coerce stale + // config (e.g. hand-edited JSON) so the two flags are never contradictory. + guard self.codexMultipleAccountsEnabled else { return false } + return self.configSnapshot.providerConfig(for: .codex)?.codexExplicitAccountsOnly ?? false + } set { self.updateProviderConfig(provider: .codex) { entry in entry.codexExplicitAccountsOnly = newValue @@ -32,6 +37,7 @@ extension SettingsStore { self.updateProviderConfig(provider: .codex) { entry in entry.codexMultipleAccountsEnabled = newValue } + self.repairCodexShellIntegrationIfNeeded() } } diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index fde79adef..9155e74a3 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -48,11 +48,15 @@ extension SettingsStore { func activeCodexAPIKey( baseEnvironment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + // Respect the currently selected add-on account so that an API-key row doesn't get + // confused with ~/.codex credentials (and vice-versa) when codexExplicitAccountsOnly is on. + let selected = self.selectedTokenAccount(for: .codex) + let tokenOverride = selected.map { TokenAccountOverride(provider: .codex, account: $0) } let env = ProviderRegistry.makeEnvironment( base: baseEnvironment, provider: .codex, settings: self, - tokenOverride: nil) + tokenOverride: tokenOverride) guard let credentials = try? CodexOAuthCredentialsStore.load(env: env) else { return nil } let accessToken = credentials.accessToken.trimmingCharacters(in: .whitespacesAndNewlines) let refreshToken = credentials.refreshToken.trimmingCharacters(in: .whitespacesAndNewlines) @@ -152,13 +156,14 @@ extension SettingsStore { ? accounts[data.activeIndex] : nil accounts.move(fromOffsets: fromOffsets, toOffset: toOffset) - let newActiveIndex: Int - if let activeAccount = previousActiveAccount, - let newIndex = accounts.firstIndex(where: { $0.id == activeAccount.id }) + let newActiveIndex: Int = if let activeAccount = previousActiveAccount, + let newIndex = accounts.firstIndex(where: { $0.id == activeAccount.id }) { - newActiveIndex = newIndex + newIndex } else { - newActiveIndex = data.activeIndex + // Preserve the primary-account sentinel (-1) or clamp an out-of-range index so we + // never persist an index that points past the end of the reordered array. + max(-1, min(data.activeIndex, accounts.count - 1)) } let updated = ProviderTokenAccountData( version: data.version, @@ -257,6 +262,13 @@ extension SettingsStore { func repairCodexShellIntegrationIfNeeded() { guard !Self.isRunningTests else { return } + // When multiple-accounts mode is disabled, accounts may still linger in config but must + // not influence the shell environment – treat the primary ~/.codex as the only account. + guard self.codexMultipleAccountsEnabled else { + CodexBarShellIntegration.setActiveCodexHome(nil) + return + } + let pathAccounts = self.tokenAccounts(for: .codex) .map(\.token) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -277,14 +289,13 @@ extension SettingsStore { let activeToken = self.selectedTokenAccount(for: .codex)? .token .trimmingCharacters(in: .whitespacesAndNewlines) - let activePath: String? - if let activeToken, - !activeToken.isEmpty, - !activeToken.lowercased().hasPrefix("apikey:") + let activePath: String? = if let activeToken, + !activeToken.isEmpty, + !activeToken.lowercased().hasPrefix("apikey:") { - activePath = activeToken + activeToken } else { - activePath = nil + nil } CodexBarShellIntegration.setActiveCodexHome(activePath) } diff --git a/Sources/CodexBar/UsageStore+ObservationHelpers.swift b/Sources/CodexBar/UsageStore+ObservationHelpers.swift index 3b77c8885..47fbaf38a 100644 --- a/Sources/CodexBar/UsageStore+ObservationHelpers.swift +++ b/Sources/CodexBar/UsageStore+ObservationHelpers.swift @@ -54,6 +54,7 @@ extension UsageStore { _ = self.settings.debugKeepCLISessionsAlive _ = self.settings.historicalTrackingEnabled _ = self.settings.codexExplicitAccountsOnly + _ = self.settings.codexMultipleAccountsEnabled } onChange: { [weak self] in Task { @MainActor [weak self] in guard let self else { return } diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 006224d4b..a43f01c7c 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -32,7 +32,8 @@ extension UsageStore { // In multi-account mode, dashboard access uses per-account login (not browser cookies). // Direct the user to the "Login to OpenAI Dashboard" menu action instead. if self.settings.codexMultipleAccountsEnabled, self.settings.openAIWebAccessEnabled { - return "OpenAI web dashboard: not signed in. Use \"Login to OpenAI Dashboard\" to authenticate this account." + return "OpenAI web dashboard: not signed in. " + + "Use \"Login to OpenAI Dashboard\" to authenticate this account." } let emailLabel = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 8c649b867..265085259 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -101,7 +101,7 @@ extension UsageStore { fallbackSnapshot: selectedSnapshot) } if provider == .codex { - Task { await self.refreshAllAccountCredits(for: .codex) } + await self.refreshAllAccountCredits(for: .codex) } await self.recordFetchedTokenAccountPlanUtilizationHistory( diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index d3c4c5dfa..6d1f52114 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1539,40 +1539,32 @@ extension UsageStore { return nil } + private func clearTokenUsageState(for provider: UsageProvider) { + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.reset() + self.lastTokenFetchAt.removeValue(forKey: provider) + self.lastTokenCostSelectionIdentity.removeValue(forKey: provider) + } + func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { guard provider == .codex || provider == .claude || provider == .vertexai else { - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.tokenFailureGates[provider]?.reset() - self.lastTokenFetchAt.removeValue(forKey: provider) - self.lastTokenCostSelectionIdentity.removeValue(forKey: provider) + self.clearTokenUsageState(for: provider) return } guard self.settings.costUsageEnabled else { - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.tokenFailureGates[provider]?.reset() - self.lastTokenFetchAt.removeValue(forKey: provider) - self.lastTokenCostSelectionIdentity.removeValue(forKey: provider) + self.clearTokenUsageState(for: provider) return } guard self.isEnabled(provider) else { - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.tokenFailureGates[provider]?.reset() - self.lastTokenFetchAt.removeValue(forKey: provider) - self.lastTokenCostSelectionIdentity.removeValue(forKey: provider) + self.clearTokenUsageState(for: provider) return } if provider == .codex, !self.settings.isCostUsageEffectivelyEnabled(for: .codex) { - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.tokenFailureGates[provider]?.reset() - self.lastTokenFetchAt.removeValue(forKey: provider) - self.lastTokenCostSelectionIdentity.removeValue(forKey: provider) + self.clearTokenUsageState(for: provider) return } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift index 5999fd5b6..1773c58e1 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift @@ -41,24 +41,24 @@ public enum OpenAIDashboardWebsiteDataStore { /// Returns true if the given account email has completed a dashboard login. public static func isDashboardLoggedIn(forAccountEmail email: String?) -> Bool { guard let normalized = normalizeEmail(email) else { return false } - let set = UserDefaults.standard.stringArray(forKey: loggedInKey) ?? [] + let set = UserDefaults.standard.stringArray(forKey: self.loggedInKey) ?? [] return set.contains(normalized) } /// Marks an account as having completed dashboard login. public static func markDashboardLoggedIn(forAccountEmail email: String?) { guard let normalized = normalizeEmail(email) else { return } - var set = Set(UserDefaults.standard.stringArray(forKey: loggedInKey) ?? []) + var set = Set(UserDefaults.standard.stringArray(forKey: self.loggedInKey) ?? []) set.insert(normalized) - UserDefaults.standard.set(Array(set), forKey: loggedInKey) + UserDefaults.standard.set(Array(set), forKey: self.loggedInKey) } /// Marks an account as logged out from dashboard. public static func markDashboardLoggedOut(forAccountEmail email: String?) { guard let normalized = normalizeEmail(email) else { return } - var set = Set(UserDefaults.standard.stringArray(forKey: loggedInKey) ?? []) + var set = Set(UserDefaults.standard.stringArray(forKey: self.loggedInKey) ?? []) set.remove(normalized) - UserDefaults.standard.set(Array(set), forKey: loggedInKey) + UserDefaults.standard.set(Array(set), forKey: self.loggedInKey) } /// Clears the persistent cookie store for a single account email. diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 85d564902..0c0a6dee3 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -766,7 +766,9 @@ struct StatusMenuTests { #expect(planHistoryIndex < dashboardIndex) } } +} +extension StatusMenuTests { @Test func `shows credits before cost in codex menu card sections`() { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 80ff95f28..e53d9e61e 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -156,6 +156,7 @@ struct UsageStoreCoverageTests { accounts: [account], defaultAccountLabel: "Primary")) + settings.codexMultipleAccountsEnabled = true settings.codexExplicitAccountsOnly = true #expect( @@ -168,6 +169,7 @@ struct UsageStoreCoverageTests { @Test func `codex no data message uses active account sessions path`() { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-codex-no-data-path") + settings.codexMultipleAccountsEnabled = true settings.codexExplicitAccountsOnly = true settings.addTokenAccount(provider: .codex, label: "Work", token: "/tmp/codex-work")