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/AccountCostsMenuCardView.swift b/Sources/CodexBar/AccountCostsMenuCardView.swift new file mode 100644 index 000000000..6eb98bb1d --- /dev/null +++ b/Sources/CodexBar/AccountCostsMenuCardView.swift @@ -0,0 +1,198 @@ +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) + Text("Credits") + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .frame(width: Self.colWidth, alignment: .trailing) + } + .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 { + 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, + resetDescription: self.entry.primaryResetDescription) + 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 { + 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/CodexBarShellIntegration.swift b/Sources/CodexBar/CodexBarShellIntegration.swift new file mode 100644 index 000000000..e9f2e4696 --- /dev/null +++ b/Sources/CodexBar/CodexBarShellIntegration.swift @@ -0,0 +1,108 @@ +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 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?, + 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: activeFile, atomically: true, encoding: .utf8) + } else { + 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(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) + } + guard let existing = try? String(contentsOfFile: zshrc, encoding: .utf8) else { return } + 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. + static var isZshHookInstalled: Bool { + guard let content = try? String(contentsOf: zshrcFile, encoding: .utf8) else { return false } + return content.contains(hookMarker) + } + + /// 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) + .resolvingSymlinksInPath() + .standardizedFileURL) + 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()) + .resolvingSymlinksInPath() + .standardizedFileURL + if destinationURL.path == defaultSessions.path { + try? fm.removeItem(at: accountSessions) + } else { + return + } + } + + guard !fm.fileExists(atPath: accountSessions.path) else { return } + try? fm.createDirectory(at: accountSessions, withIntermediateDirectories: true) + } +} 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..661c97d05 --- /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 : self.spacing + size.width + if !isFirstInRow, currentX + neededWidth > maxWidth { + totalHeight += currentRowHeight + self.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 : 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 += self.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 5f685af23..8966d66ff 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -126,13 +126,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 @@ -153,7 +147,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 { @@ -213,8 +209,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 } @@ -234,6 +297,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) { @@ -431,24 +497,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 { @@ -500,6 +643,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, @@ -580,6 +724,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 { @@ -587,6 +733,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") @@ -634,13 +783,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) } } } @@ -666,6 +818,8 @@ extension UsageMenuCardView.Model { let resetTimeDisplayStyle: ResetTimeDisplayStyle let tokenCostUsageEnabled: Bool let showOptionalCreditsAndExtraUsage: Bool + /// When true, Codex credits line shows unlimited prepaid (OAuth). + let codexCreditsUnlimited: Bool let sourceLabel: String? let kiloAutoMode: Bool let hidePersonalInfo: Bool @@ -689,6 +843,7 @@ extension UsageMenuCardView.Model { resetTimeDisplayStyle: ResetTimeDisplayStyle, tokenCostUsageEnabled: Bool, showOptionalCreditsAndExtraUsage: Bool, + codexCreditsUnlimited: Bool = false, sourceLabel: String? = nil, kiloAutoMode: Bool = false, hidePersonalInfo: Bool, @@ -711,6 +866,7 @@ extension UsageMenuCardView.Model { self.resetTimeDisplayStyle = resetTimeDisplayStyle self.tokenCostUsageEnabled = tokenCostUsageEnabled self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage + self.codexCreditsUnlimited = codexCreditsUnlimited self.sourceLabel = sourceLabel self.kiloAutoMode = kiloAutoMode self.hidePersonalInfo = hidePersonalInfo @@ -729,11 +885,15 @@ extension UsageMenuCardView.Model { let usageNotes = Self.usageNotes(input: input) let creditsText: String? = if input.provider == .openrouter { nil - } else if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { - 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 { @@ -761,7 +921,7 @@ extension UsageMenuCardView.Model { metrics: metrics, usageNotes: usageNotes, creditsText: creditsText, - creditsRemaining: input.credits?.remaining, + creditsRemaining: input.codexCreditsUnlimited ? nil : creditsRemaining, creditsHintText: redacted.creditsHintText, creditsHintCopyText: redacted.creditsHintCopyText, providerCost: providerCost, @@ -903,6 +1063,7 @@ extension UsageMenuCardView.Model { input: Input, subtitle: (text: String, style: SubtitleStyle)) -> RedactedText { + let dashboardErrorForHints = input.dashboardError let email = PersonalInfoRedactor.redactEmail( Self.email( for: input.provider, @@ -913,10 +1074,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, @@ -1192,24 +1353,32 @@ 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? { 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 } @@ -1221,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) } @@ -1242,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/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index fa41695f5..8679a897d 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): @@ -102,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) } } } @@ -113,11 +117,13 @@ 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 let quit: () -> Void let copyError: (String) -> Void + let openCodexDashboard: (_ accountIdentifier: String?, _ viewOnly: Bool) -> Void } @MainActor @@ -135,7 +141,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/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 05aa55fff..339cc7729 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -39,12 +39,14 @@ struct MenuDescriptor { case dashboard case statusPage case switchAccount(UsageProvider) + case addTokenAccount(UsageProvider) case openTerminal(command: String) case loginToProvider(url: String) case settings case about case quit case copyError(String) + case codexDashboard(accountIdentifier: String?, viewOnly: Bool) } var sections: [Section] @@ -343,7 +345,14 @@ struct MenuDescriptor { } if metadata?.dashboardURL != nil { - entries.append(.action("Usage Dashboard", .dashboard)) + 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)) @@ -356,6 +365,27 @@ 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) { + selected.token + } else { + ("~/.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 { @@ -392,10 +422,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,9 +500,11 @@ 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 + case .codexDashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue } } } diff --git a/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift b/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift new file mode 100644 index 000000000..b776c405b --- /dev/null +++ b/Sources/CodexBar/OpenAIDashboardLoginWindowController.swift @@ -0,0 +1,382 @@ +import AppKit +import CodexBarCore +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 + } +} + +/// 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) + 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? + /// 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 = """ + (() => { + 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 = !isLogin && body.length > 200 && + (href.includes('chatgpt.com') && !href.includes('auth')); + return { href, isLogin, isDashboard, bodyLength: body.length }; + })(); + """ + + /// 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 { + self.buildWindow() + } + 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() + // 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 + + let titleLabel = self.displayName ?? self.accountEmail ?? "new account" + let window = WebViewWindow( + contentRect: Self.defaultFrame(), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false) + window.title = self.autoCloseOnLogin + ? "Sign in to ChatGPT — \(titleLabel)" + : "Dashboard — \(titleLabel)" + window.isReleasedWhenClosed = false + window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary] + window.contentView = webView + window.initialFirstResponder = webView + 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.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, 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 + + if isDashboard { + self.logger.info("Dashboard login detected for \(self.accountEmail ?? "new account")") + self.loginDetected = true + + if self.isUnifiedMode { + self.extractSessionAndCreateAccount(webView: webView) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.onComplete?(true) + self.close() + } + } + return + } + + // 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() + } + + 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: - 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 { + 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.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 58a55deb5..94f499084 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 @@ -17,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) } @@ -45,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, @@ -53,13 +61,84 @@ struct ProviderDetailView: View { subtitle: self.subtitle, model: self.model, labelWidth: labelWidth, + hideAccountAndPlan: self.codexHidesHeaderAccountAndPlan(defaultLabel: codexDefaultLabel), onRefresh: self.onRefresh) - ProviderMetricsInlineView( - provider: self.provider, - model: self.model, - isEnabled: self.isEnabled, - labelWidth: labelWidth) + // 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 { + if self.provider == .codex, self.codexShowsUsageAccountSwitcher(defaultLabel: codexDefaultLabel) { + let accounts = self.settings.tokenAccounts(for: .codex) + let defaultLabel = codexDefaultLabel + let displaySelection = self.settings.displayTokenAccountActiveIndex(for: .codex) + ProviderMetricsInlineView( + provider: self.provider, + model: self.model, + isEnabled: self.isEnabled, + labelWidth: labelWidth, + titleTrailingNote: self.codexUsageTitleTrailingNote, + accountSwitcher: { + let identity = self.codexUsageAccountSwitcherIdentity + let widthKey = String(Int(self.codexAccountSwitcherLayoutWidth)) + let switcherID = "\(identity)-\(widthKey)" + TokenAccountSwitcherRepresentable( + accounts: accounts, + defaultAccountLabel: defaultLabel, + selectedIndex: displaySelection, + width: self.codexAccountSwitcherLayoutWidth, + 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(switcherID) + .frame(height: TokenAccountSwitcherView.preferredHeight( + accounts: accounts, + defaultAccountLabel: defaultLabel)) + }) + } else { + ProviderMetricsInlineView( + provider: self.provider, + model: self.model, + isEnabled: self.isEnabled, + labelWidth: labelWidth, + titleTrailingNote: self.codexUsageTitleTrailingNote) + } + } + + if let tokenUsage = self.model.tokenUsage { + ProviderCostSettingsSection( + accountLabel: self.costSectionAccountLabel, + tokenUsage: tokenUsage) + } if let errorDisplay { ProviderErrorView( @@ -71,50 +150,188 @@ 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.settingsToggles) { toggle in - ProviderSettingsToggleRowView(toggle: toggle) + ForEach(self.optionsSectionPickers) { picker in + ProviderSettingsPickerRowView(picker: picker) + } + ForEach(self.optionsSectionToggles) { toggle in + if toggle.isVisible?() ?? true { + ProviderSettingsToggleRowView(toggle: toggle) + .id(toggle.id) + } + } + if self.provider == .codex { + Text(self.codexOptionsFooterExplanation) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) } } } } - .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) } .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.optionsSectionToggles.isEmpty || !self.optionsSectionPickers.isEmpty + } + + private static let earlyToggleIDs: Set = [ + "codex-multiple-accounts", + "codex-explicit-accounts-only", + "codex-openai-web-dashboard", + ] + + 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 { + 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). \ + Use "Make Default" on each row to choose which one 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. \ + 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. 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 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. + /// 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 + } + 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 display = self.settings.displayTokenAccountActiveIndex(for: .codex) + return "\(self.settings.configRevision)-\(ids)-\(display)" } - private var detailLabelWidth: CGFloat { + /// `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) + } + + 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 + if TokenAccountSupportCatalog.support(for: provider) != nil { + let accounts = self.settings.tokenAccounts(for: provider) + if self.settings.isDefaultTokenAccountActive(for: provider) || 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 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 + } + let email = self.model.email.trimmingCharacters(in: .whitespacesAndNewlines) + return email.isEmpty ? nil : email + } + + private func detailLabelWidth(defaultLabel: String?) -> CGFloat { var infoLabels = ["State", "Source", "Version", "Updated"] if self.store.status(for: self.provider) != nil { infoLabels.append("Status") } - if !self.model.email.isEmpty { + let hideAccountPlan = self.codexHidesHeaderAccountAndPlan(defaultLabel: defaultLabel) + 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) } @@ -141,6 +358,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 @@ -149,6 +379,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 +418,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 +462,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 +485,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 +526,29 @@ private struct ProviderDetailInfoRow: View { } @MainActor -struct ProviderMetricsInlineView: View { +struct ProviderMetricsInlineView: View { let provider: UsageProvider let model: UsageMenuCardView.Model let isEnabled: Bool let labelWidth: CGFloat + let titleTrailingNote: String? + @ViewBuilder private var accountSwitcher: AccountSwitcher + + init( + provider: UsageProvider, + 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() + } var body: some View { let hasMetrics = !self.model.metrics.isEmpty @@ -303,13 +556,16 @@ 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.titleTrailingNote, 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 +598,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 +610,65 @@ struct ProviderMetricsInlineView: View { } } +extension ProviderMetricsInlineView where AccountSwitcher == EmptyView { + 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() }) + } +} + +@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..88c1f8c6c 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -1,7 +1,11 @@ +import CodexBarCore import SwiftUI +import UniformTypeIdentifiers 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 +13,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 +29,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) @@ -39,6 +56,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) @@ -47,11 +66,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 { @@ -131,7 +153,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) @@ -200,11 +224,44 @@ 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? + /// 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 + /// 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 { + 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 with “Default” above. " + + "Other toggles (Buy Credits, web extras, etc.) are under Options too." + } + 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. " + + "Cost appears only for API key rows. Buy Credits is also under Options." + } var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -215,48 +272,474 @@ 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() - if accounts.isEmpty { - Text("No token accounts yet.") + let defaultLabel = self.descriptor.defaultAccountLabel?() + let hasDefaultTab = defaultLabel != nil + let activeIndex = self.descriptor.activeIndex() + let defaultIsActive = (activeIndex < 0 || accounts.isEmpty) && hasDefaultTab + let selectedIndex = defaultIsActive + ? -1 + : min(max(activeIndex, 0), 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(self.codexAccountsFooterHint) + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .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 { + 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) { + if !self.descriptor.codexExplicitAccountsOnly { + Button("Open config file") { + self.descriptor.openConfigFile() + } + .buttonStyle(.link) + .controlSize(.small) + } + Button("Reload") { + self.descriptor.reloadFromDisk() + } + .buttonStyle(.link) .controlSize(.small) } + } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + + 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) + .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)) + } + } + } + + private func menuBarActiveBadge() -> some View { + Text("Default") + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Color.accentColor.opacity(0.22))) + .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 + let highlightSelection = !self.useCodexDiscoveryHints + 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(.roundedBorder) + .frame(minWidth: 100, maxWidth: 180) + .focused(self.$renameFieldFocused) + .onSubmit { self.commitRenameDefault() } + } else { + Group { + if self.useCodexDiscoveryHints { + Text(label) + .font(.footnote.weight(.medium)) + .lineLimit(2) + .multilineTextAlignment(.leading) + .foregroundStyle(.primary) + } else { + Button(action: { self.descriptor.setActiveIndex(-1) }, label: { + Text(label) + .font(.footnote.weight(.medium)) + .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() + } + if !self.useCodexDiscoveryHints, !isActive, !isRenaming { + Button("Make Default") { + self.descriptor.setActiveIndex(-1) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .font(.caption2.weight(.medium)) + .help("Make this the default account") + } + if let dashboardLogin = self.descriptor.dashboardLogin { + let loggedIn = self.descriptor.isDashboardLoggedIn?(self.defaultAccountDashboardKey) ?? false + Button(loggedIn ? "Dashboard" : "Login to Dashboard") { + dashboardLogin(self.defaultAccountDashboardKey) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .font(.caption2.weight(.medium)) + .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 + } + }, label: { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .imageScale(.small) + }) + .buttonStyle(.plain) + .help("Rename tab") + } + } + } + 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) + .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 = "" + self.restoreAccessoryPolicyIfNeeded() + } + + @ViewBuilder + private func accountTab(account: ProviderTokenAccount, index: Int, isActive: Bool) -> some View { + let isRenaming = self.renamingAccountID == account.id + let highlightSelection = !self.useCodexDiscoveryHints + 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) + .accessibilityLabel("Added account") + if isRenaming { + TextField("Name", text: self.$renameText) + .font(.footnote) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 100, maxWidth: 180) + .focused(self.$renameFieldFocused) + .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") { + 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 { + Text(account.displayName) + .font(.footnote.weight(.medium)) + .lineLimit(2) + .multilineTextAlignment(.leading) + .foregroundStyle(.primary) + } else { + Button(action: { self.descriptor.setActiveIndex(index) }, label: { + Text(account.displayName) + .font(.footnote.weight(.medium)) + .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() + } + if !self.useCodexDiscoveryHints, !isActive { + Button("Make Default") { + self.descriptor.setActiveIndex(index) + } + .buttonStyle(.bordered) + .controlSize(.mini) + .font(.caption2.weight(.medium)) + .help("Make this the default account") + } + 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) + } + .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 + } + }, label: { + Image(systemName: "pencil") + .foregroundStyle(.secondary) + .imageScale(.small) + }) + .buttonStyle(.plain) + .help("Rename tab") + } + } + 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) + .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 = "" + 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 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). " + + "Cost and 30-day history require an OpenAI key with usage access.") + .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,20 +748,126 @@ 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() + 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) + } + } + } + + 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) + } + } +} + +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 } } diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index e2dce0a7f..bcce88b00 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,18 @@ enum ProvidersPaneTestHarness { setActiveIndex: { _ in }, addAccount: { _, _ in }, removeAccount: { _ in }, + moveAccount: { _, _ in }, + renameAccount: { _, _ in }, openConfigFile: {}, - reloadFromDisk: {}) + reloadFromDisk: {}, + defaultAccountLabel: nil, + renameDefaultAccount: nil, + loginAction: nil, + dashboardLogin: nil, + isDashboardLoggedIn: nil, + dashboardLogout: nil, + activeAccountStatusText: nil, + codexExplicitAccountsOnly: false) return ProviderListTestDescriptors( toggle: toggle, diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index f3d5bc112..f58fece57 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), @@ -144,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] { @@ -168,22 +168,47 @@ 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) } + } + 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, 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) }, 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) @@ -202,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) { @@ -209,6 +243,17 @@ 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) + }, openConfigFile: { self.settings.openTokenAccountsFile() }, @@ -219,7 +264,64 @@ struct ProvidersPane: View { await self.store.refreshProvider(provider, allowDisabled: true) } } - }) + }, + defaultAccountLabel: defaultAccountLabel, + renameDefaultAccount: renameDefaultAccount, + loginAction: loginAction, + dashboardLogin: provider == .codex && self.settings.openAIWebAccessEnabled + ? { [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: 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()) + // 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.forceRefreshOpenAIDashboard() } + } + } + : nil, + activeAccountStatusText: provider == .codex + ? { [store] in + store.activeCodexAPIKeySettingsNotice() + } + : nil, + codexExplicitAccountsOnly: codexExplicit) } private func makeSettingsContext(provider: UsageProvider) -> ProviderSettingsContext { @@ -327,9 +429,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) @@ -371,6 +476,7 @@ struct ProvidersPane: View { resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + codexCreditsUnlimited: codexCreditsUnlimited, hidePersonalInfo: self.settings.hidePersonalInfo, weeklyPace: weeklyPace, now: now) @@ -388,6 +494,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/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/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..b1e4e6f93 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 @@ -20,6 +21,8 @@ struct CodexProviderImplementation: ProviderImplementation { _ = settings.codexUsageDataSource _ = settings.codexCookieSource _ = settings.codexCookieHeader + _ = settings.codexExplicitAccountsOnly + _ = settings.codexMultipleAccountsEnabled } @MainActor @@ -70,7 +73,113 @@ struct CodexProviderImplementation: ProviderImplementation { } }) + let buyCreditsBinding = Binding( + 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 + } + 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) + } + } + } + }) + + 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-multiple-accounts", + title: "Multiple Accounts", + subtitle: + "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: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "codex-explicit-accounts-only", + title: "CodexBar accounts only", + subtitle: + "Ignore the default machine codex home path (~/.codex). Use only rows under Accounts " + + "(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: [], + isVisible: { context.settings.codexMultipleAccountsEnabled }, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), + 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", @@ -83,13 +192,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.", + 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), @@ -180,20 +289,109 @@ 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)) } } + @MainActor + func loginMenuAction(context: ProviderMenuLoginContext) + -> (label: String, action: MenuDescriptor.MenuAction)? + { + guard context.settings.codexMultipleAccountsEnabled else { return nil } + return ("Add Account...", .addTokenAccount(.codex)) + } + @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runCodexLoginFlow() return true } + + @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 + } + + 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)? + { + { @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/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/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 335dbf411..252c83834 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -2,6 +2,56 @@ import CodexBarCore import Foundation 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 { + // 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 + if newValue, + let token = entry.tokenAccounts, + !token.accounts.isEmpty, + token.activeIndex < 0 + { + entry.tokenAccounts = ProviderTokenAccountData( + version: token.version, + accounts: token.accounts, + activeIndex: 0) + } + } + self.repairCodexShellIntegrationIfNeeded() + } + } + + /// 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 + } + self.repairCodexShellIntegrationIfNeeded() + } + } + + /// 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 @@ -52,7 +102,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 7d5e22bd2..a473e33e2 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift @@ -73,6 +73,21 @@ 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 +180,20 @@ 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..9b9d71fff 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,38 @@ 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 + /// 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)? + /// 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)? + /// 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 +} + +/// 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 +147,7 @@ struct ProviderSettingsPickerDescriptor: Identifiable { let isEnabled: (() -> Bool)? let onChange: ((_ selection: String) async -> Void)? let trailingText: (() -> String?)? + let section: ProviderSettingsPickerSection init( id: String, @@ -126,7 +159,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 +172,7 @@ struct ProviderSettingsPickerDescriptor: Identifiable { self.isEnabled = isEnabled self.onChange = onChange self.trailingText = trailingText + self.section = section } } 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 1f8a0277b..9155e74a3 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -3,6 +3,32 @@ 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 { + if provider == .codex, self.codexExplicitAccountsOnly { + return false + } + 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,13 +40,40 @@ extension SettingsStore { func selectedTokenAccount(for provider: UsageProvider) -> ProviderTokenAccount? { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return nil } + guard !self.isDefaultTokenAccountActive(for: provider) else { return nil } let index = data.clampedActiveIndex() return data.accounts[index] } + 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: 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) + 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 } - 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, @@ -28,6 +81,9 @@ extension SettingsStore { self.updateProviderConfig(provider: provider) { entry in entry.tokenAccounts = updated } + if provider == .codex { + self.repairCodexShellIntegrationIfNeeded() + } CodexBarLog.logger(LogCategories.tokenAccounts).info( "Active token account updated", metadata: [ @@ -58,6 +114,9 @@ extension SettingsStore { entry.tokenAccounts = updated } self.applyTokenAccountCookieSourceIfNeeded(provider: provider) + if provider == .codex { + self.repairCodexShellIntegrationIfNeeded() + } CodexBarLog.logger(LogCategories.tokenAccounts).info( "Token account added", metadata: [ @@ -66,20 +125,93 @@ 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 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 }) + { + newIndex + } else { + // 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, + 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 } + // 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 clamped = min(max(data.activeIndex, 0), filtered.count - 1) entry.tokenAccounts = ProviderTokenAccountData( version: data.version, accounts: filtered, - activeIndex: clamped) + activeIndex: computedActiveIndex) } } + if provider == .codex { + self.repairCodexShellIntegrationIfNeeded() + } CodexBarLog.logger(LogCategories.tokenAccounts).info( "Token account removed", metadata: [ @@ -126,4 +258,45 @@ extension SettingsStore { else { return } ProviderCatalog.implementation(for: provider)?.applyTokenAccountCookieSource(settings: self) } + + 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) } + .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:") + { + activeToken + } else { + 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+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index ad65b0879..525783308 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) @@ -68,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 @@ -135,6 +164,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+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 484be310a..da76e5e2e 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) } } @@ -61,6 +47,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 +204,6 @@ extension StatusItemController { self.lastMergedSwitcherSelection = switcherSelection self.lastSwitcherIncludesOverview = includesOverview } - self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) let menuContext = MenuCardContext( currentProvider: currentProvider, selectedProvider: selectedProvider, @@ -225,6 +211,7 @@ extension StatusItemController { tokenAccountDisplay: tokenAccountDisplay, openAIContext: openAIContext) if isOverviewSelected { + self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay) if self.addOverviewRows( to: menu, enabledProviders: enabledProviders, @@ -236,13 +223,20 @@ 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, 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()) } } @@ -294,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) @@ -305,6 +300,7 @@ extension StatusItemController { let hasCreditsHistory: Bool let hasCostHistory: Bool let hasOpenAIWebMenuItems: Bool + let usesSectionedMenuCard: Bool } private struct MenuCardContext { @@ -329,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( @@ -419,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,11 +442,14 @@ extension StatusItemController { menu.addItem(.separator()) } } + if context.currentProvider == .codex, self.settings.codexBuyCreditsMenuEnabled { + menu.addItem(self.makeBuyCreditsItem()) + } return false } 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, @@ -463,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, @@ -488,6 +505,14 @@ extension StatusItemController { if context.hasCostHistory { _ = self.addCostHistorySubmenu(to: menu, provider: currentProvider) } + } 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()) } @@ -647,19 +672,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 @@ -696,9 +729,15 @@ 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) - 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 } + let activeIndex = self.settings.displayTokenAccountActiveIndex(for: provider) let showAll = self.settings.showAllTokenAccountsInMenu let snapshots = showAll ? (self.store.accountSnapshots[provider] ?? []) : [] return TokenAccountMenuDisplay( @@ -707,7 +746,8 @@ extension StatusItemController { snapshots: snapshots, activeIndex: activeIndex, showAll: showAll, - showSwitcher: !showAll) + showSwitcher: !showAll, + defaultAccountLabel: defaultLabel) } private func menuNeedsRefresh(_ menu: NSMenu) -> Bool { @@ -898,84 +938,78 @@ extension StatusItemController { webItems: OpenAIWebMenuItems) { let hasUsageBlock = !model.metrics.isEmpty || model.placeholder != nil + // 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 usageSubmenu = self.makeUsageSubmenu( + provider: provider, + snapshot: self.store.snapshot(for: provider), + webItems: webItems) + let creditsSubmenu = webItems.hasCreditsHistory ? self.makeCreditsHistorySubmenu() : nil - let headerView = UsageMenuCardHeaderSectionView( + 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 provider == .codex { - menu.addItem(self.makeBuyCreditsItem()) - } + 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 { + 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, @@ -1012,7 +1046,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) @@ -1055,12 +1089,18 @@ 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) 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]) } } @@ -1203,33 +1243,60 @@ 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 { + 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) { + 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?, @@ -1421,26 +1488,31 @@ 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? let dashboardError: String? let tokenSnapshot: CostUsageTokenSnapshot? let tokenError: String? - if target == .codex, snapshotOverride == nil { - credits = self.store.credits - creditsError = self.store.lastCreditsError + var codexCreditsUnlimited = false + if target == .codex, !usesSnapshotOverride { + 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) 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 @@ -1456,7 +1528,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 @@ -1479,6 +1551,7 @@ extension StatusItemController { resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + 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 92c334231..eec49c60c 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -793,28 +793,62 @@ 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? + /// 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) + 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.menuLayoutWidth = width + 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() + self.setContentHuggingPriority(.required, for: .vertical) + self.setContentCompressionResistancePriority(.required, for: .vertical) } @available(*, unavailable) @@ -822,23 +856,37 @@ 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) { - 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 +894,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 +909,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 +940,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/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 1d95ccd91..d6e6cfe73 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 @@ -145,7 +148,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 @@ -176,6 +179,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin selector: #selector(self.handleProviderConfigDidChange), name: .codexbarProviderConfigDidChange, object: nil) + self.lastOpenMenuStructureFingerprint = self.openMenuStructureFingerprint() } private func wireBindings() { @@ -268,42 +272,59 @@ 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 + 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))" + return [ + order, + "\(s.mergeIcons)", + "\(s.switcherShowsIcons)", + "\(s.usageBarsShowUsed)", + "\(s.showAllTokenAccountsInMenu)", + "\(s.openAIWebAccessEnabled)", + "\(s.codexBuyCreditsMenuEnabled)", + "\(s.codexExplicitAccountsOnly)", + "\(s.codexMultipleAccountsEnabled)", + 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 configChanged = self.settings.configRevision != self.lastConfigRevision let orderChanged = self.settings.providerOrder != self.lastProviderOrder - let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForProviderSwitcher() + let shouldRefreshOpenMenus = self.shouldRefreshOpenMenusForMenuStructureChange() 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() @@ -311,6 +332,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if shouldRefreshOpenMenus { self.refreshOpenMenusIfNeeded() } + self.lastProviderOrder = self.settings.providerOrder } private func updateIcons() { 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..aaa463d85 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,13 +70,11 @@ struct UsageProgressBar: View { bar .compositingGroup() .drawingGroup() - } else if needsPunchCompositing { - bar - .compositingGroup() } else { bar } } + .frame(maxWidth: .infinity) .frame(height: 6) .accessibilityLabel(self.accessibilityLabel) .accessibilityValue("\(Int(self.clamped)) percent") @@ -82,6 +88,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 = 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..cadd7ff61 --- /dev/null +++ b/Sources/CodexBar/UsageStore+AccountCosts.swift @@ -0,0 +1,218 @@ +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 } + // 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) } + + 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) — 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 + 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, + 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 + baseOffset, entry) + } + let entry = await Self.fetchCredits( + env: env, + id: account.id.uuidString, + label: account.label, + isDefault: false) + return (offset + baseOffset, 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: "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 new file mode 100644 index 000000000..a7f2b775f --- /dev/null +++ b/Sources/CodexBar/UsageStore+CodexActiveCredits.swift @@ -0,0 +1,95 @@ +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) { + 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 + // 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) { + 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 { + 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 + } + + /// 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" } + return error + } +} diff --git a/Sources/CodexBar/UsageStore+ObservationHelpers.swift b/Sources/CodexBar/UsageStore+ObservationHelpers.swift new file mode 100644 index 000000000..47fbaf38a --- /dev/null +++ b/Sources/CodexBar/UsageStore+ObservationHelpers.swift @@ -0,0 +1,71 @@ +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 + _ = self.settings.codexMultipleAccountsEnabled + } 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+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index af164cc62..a43f01c7c 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -28,6 +28,14 @@ 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+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 06fccc774..4340b5a9e 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 { @@ -43,6 +45,35 @@ extension UsageStore { } } + // When "CodexBar accounts only" is on, do not fall back to ~/.codex implicit credentials. + // If there are no explicit accounts, clear all cached Codex data 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) + 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 + } + 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. @@ -69,6 +100,7 @@ extension UsageStore { self.failureGates[.claude]?.reset() self.tokenFailureGates[.claude]?.reset() self.lastTokenFetchAt.removeValue(forKey: .claude) + self.lastTokenCostSelectionIdentity.removeValue(forKey: .claude) } } await MainActor.run { @@ -95,6 +127,7 @@ extension UsageStore { } if provider == .codex { self.recordCodexHistoricalSampleIfNeeded(snapshot: scoped) + Task { await self.refreshAllAccountCredits(for: .codex) } } case let .failure(error): await MainActor.run { @@ -115,5 +148,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 80a41b3d9..265085259 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,15 +40,22 @@ 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) - let effectiveSelected = selectedAccount ?? limitedAccounts.first var snapshots: [TokenAccountUsageSnapshot] = [] var historySamples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)] = [] var selectedOutcome: ProviderFetchOutcome? @@ -45,7 +69,21 @@ extension UsageStore { if let usage = resolved.usage { historySamples.append((account: account, snapshot: usage)) } - if account.id == effectiveSelected?.id { + if !defaultIsActive, account.id == selectedAccount?.id { + selectedOutcome = outcome + selectedSnapshot = resolved.usage + } + } + + // 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) + let resolved = self.resolveDefaultAccountOutcome( + outcome, + provider: provider, + displayName: defaultAccountLabel) + snapshots.insert(resolved.snapshot, at: 0) + if defaultIsActive { selectedOutcome = outcome selectedSnapshot = resolved.usage } @@ -59,14 +97,17 @@ extension UsageStore { await self.applySelectedOutcome( selectedOutcome, provider: provider, - account: effectiveSelected, + account: defaultIsActive ? nil : selectedAccount, fallbackSnapshot: selectedSnapshot) } + if provider == .codex { + await self.refreshAllAccountCredits(for: .codex) + } await self.recordFetchedTokenAccountPlanUtilizationHistory( provider: provider, samples: historySamples, - selectedAccount: effectiveSelected) + selectedAccount: selectedAccount) } func limitedTokenAccounts( @@ -162,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, @@ -210,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/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index f00f14504..36115b568 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -2,6 +2,92 @@ 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`. + /// 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 + } + + /// 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 { + 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:") ? 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? { + guard let support = TokenAccountSupportCatalog.support(for: .codex), + case .codexHome = support.injection + else { return nil } + 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) + 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 + } + 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:") { + return "codex:apikey:\(account.id.uuidString)" + } + } + return "codex:default" + } + return "\(provider.rawValue):default" + } + func tokenSnapshot(for provider: UsageProvider) -> CostUsageTokenSnapshot? { self.tokenSnapshots[provider] } @@ -18,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 { @@ -30,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 f8699128d..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.credits?.remaining : 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 47efc5b63..6d1f52114 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -4,70 +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.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 { @@ -101,6 +37,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? @@ -108,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] = [:] @@ -156,11 +99,12 @@ final class UsageStore { @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] + @ObservationIgnored var lastTokenCostSelectionIdentity: [UsageProvider: String] = [:] @ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] @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 @ObservationIgnored let planUtilizationPersistenceCoordinator: PlanUtilizationHistoryPersistenceCoordinator init( @@ -386,7 +330,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) { @@ -454,7 +398,7 @@ final class UsageStore { self.observeSettingsChanges() } - private func startTimer() { + func startTimer() { self.timerTask?.cancel() guard let wait = self.settings.refreshFrequency.seconds else { return } @@ -641,7 +585,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 @@ -695,12 +643,24 @@ 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 + } + + // 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.codexAccountEmailForOpenAIDashboard() + let targetEmail = self.codexDashboardAccountIdentifier() self.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: targetEmail) let now = Date() @@ -775,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(for: targetEmail) } return } @@ -784,7 +744,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 @@ -808,7 +768,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 @@ -828,7 +788,7 @@ extension UsageStore { "then update OpenAI cookies in Providers → Codex.", ].joined(separator: " ") self.openAIDashboard = self.lastOpenAIDashboardSnapshot - self.openAIDashboardRequiresLogin = true + self.markDashboardLoginRequired(for: targetEmail) } } catch { await self.applyOpenAIDashboardFailure(message: error.localizedDescription) @@ -864,21 +824,49 @@ extension UsageStore { self.openAIDashboard = nil self.lastOpenAIDashboardSnapshot = nil self.lastOpenAIDashboardError = nil + // 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 - 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 } } + /// 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) + } + + /// 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.codexAccountEmailForOpenAIDashboard() + let targetEmail = self.codexDashboardAccountIdentifier() _ = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) await self.refreshOpenAIDashboardIfNeeded(force: true) } 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 @@ -1000,7 +988,7 @@ extension UsageStore { "Found \(foundText).", ].joined(separator: " ") // Treat mismatch like "not logged in" for the current Codex account. - self.openAIDashboardRequiresLogin = true + self.markDashboardLoginRequired(for: normalizedTarget) self.openAIDashboard = nil } case .noCookiesFound, @@ -1011,7 +999,7 @@ extension UsageStore { await MainActor.run { self.openAIDashboardCookieImportStatus = "OpenAI cookie import failed: \(err.localizedDescription)" - self.openAIDashboardRequiresLogin = true + self.markDashboardLoginRequired(for: normalizedTarget) } } } catch { @@ -1053,6 +1041,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(for precomputedKey: String? = nil) { + self.openAIDashboardRequiresLogin = true + let key = precomputedKey ?? 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 } @@ -1071,6 +1071,22 @@ 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) { + let token = selected.token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.lowercased().hasPrefix("apikey:") else { return nil } + return selected.token + } + // Default account uses ~/.codex path + return ("~/.codex" as NSString).expandingTildeInPath + } + return self.codexAccountEmailForOpenAIDashboard() + } } extension UsageStore { @@ -1517,49 +1533,67 @@ 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 { + 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.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.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.clearTokenUsageState(for: provider) + return + } + + if provider == .codex, !self.settings.isCostUsageEffectivelyEnabled(for: .codex) { + self.clearTokenUsageState(for: 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) } + // 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 @@ -1568,26 +1602,49 @@ extension UsageStore { do { let fetcher = self.costUsageFetcher let timeoutSeconds = self.tokenFetchTimeout - 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)) + 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 } @@ -1601,8 +1658,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/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/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 04c85409d..cc98e476a 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -83,6 +83,16 @@ 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? + /// 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, @@ -94,7 +104,11 @@ 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, + codexExplicitAccountsOnly: Bool? = nil, + codexMultipleAccountsEnabled: Bool? = nil) { self.id = id self.enabled = enabled @@ -106,6 +120,10 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.region = region self.workspaceID = workspaceID self.tokenAccounts = tokenAccounts + self.defaultAccountLabel = defaultAccountLabel + self.buyCreditsMenuEnabled = buyCreditsMenuEnabled + self.codexExplicitAccountsOnly = codexExplicitAccountsOnly + self.codexMultipleAccountsEnabled = codexMultipleAccountsEnabled } 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/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/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. diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebsiteDataStore.swift index e23ef66a1..1773c58e1 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: 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: self.loggedInKey) ?? []) + set.insert(normalized) + 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: self.loggedInKey) ?? []) + set.remove(normalized) + UserDefaults.standard.set(Array(set), forKey: self.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) } 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/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index f7f5c3191..252a26d59 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -46,19 +46,34 @@ 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 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 } @@ -105,8 +120,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..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, @@ -134,16 +135,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/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index 6e8667036..6d5e9a784 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -59,21 +59,37 @@ public struct CodexStatusProbe { public var codexBinary: String = "codex" public var timeout: TimeInterval = Self.defaultTimeoutSeconds public var keepCLISessionsAlive: Bool = false - - public init() {} + private let processEnvironment: [String: String] + private let isolateForEnvironment: Bool + + 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 { @@ -194,7 +210,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, @@ -218,7 +234,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/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/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/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..8ffa80510 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -2,6 +2,14 @@ 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/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 98859b18e..7866ed208 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -350,7 +350,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 @@ -358,7 +359,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 { @@ -366,7 +369,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) @@ -534,7 +537,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") @@ -568,7 +571,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 } @@ -599,7 +604,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 @@ -609,7 +614,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()) } @@ -632,7 +639,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() @@ -645,8 +652,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/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/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 new file mode 100644 index 000000000..599d43c71 --- /dev/null +++ b/Tests/CodexBarTests/CodexActiveCreditsTests.swift @@ -0,0 +1,167 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct CodexActiveCreditsTests { + @Test + func `primary account uses store credits`() { + 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 `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( + 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) + } + + @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/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/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 60eddeb6a..ab69f577d 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -297,225 +297,54 @@ struct MenuCardModelTests { } @Test - func `claude model does not leak codex plan`() throws { - let metadata = try #require(ProviderDefaults.metadata[.claude]) - let model = UsageMenuCardView.Model.make(.init( - provider: .claude, - metadata: metadata, - snapshot: nil, - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: "codex@example.com", plan: "plus"), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: Date())) - - #expect(model.planText == nil) - #expect(model.email.isEmpty) - } - - @Test - func `hides codex credits when disabled`() throws { - let now = Date() - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: "codex@example.com", - accountOrganization: nil, - loginMethod: nil) + 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: now, - identity: identity) - let metadata = try #require(ProviderDefaults.metadata[.codex]) - + 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: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: "codex@example.com", plan: nil), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: false, - hidePersonalInfo: false, - now: now)) - - #expect(model.creditsText == nil) - } - - @Test - func `hides claude extra usage when disabled`() throws { - let now = Date() - let identity = ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: "claude@example.com", - accountOrganization: nil, - loginMethod: nil) - let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: nil, - tertiary: nil, - providerCost: ProviderCostSnapshot(used: 12, limit: 200, currencyCode: "USD", updatedAt: now), - updatedAt: now, - identity: identity) - let metadata = try #require(ProviderDefaults.metadata[.claude]) - - let model = UsageMenuCardView.Model.make(.init( - provider: .claude, - 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: false, - hidePersonalInfo: false, - now: now)) - - #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, + tokenError: error, account: AccountInfo(email: nil, plan: nil), isRefreshing: false, lastError: nil, usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, + tokenCostUsageEnabled: true, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, - now: now)) + now: Date())) - #expect(model.metrics.isEmpty) - #expect(model.creditsText == nil) - #expect(model.placeholder == nil) - #expect(model.usageNotes == ["No limit set for the API key"]) + #expect(model.tokenUsage?.sessionLine == "Today: —") + #expect(model.tokenUsage?.monthLine == "Last 30 days: —") + #expect(model.tokenUsage?.errorLine == error) } @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() - + func `claude model does not leak codex plan`() throws { + let metadata = try #require(ProviderDefaults.metadata[.claude]) let model = UsageMenuCardView.Model.make(.init( - provider: .openrouter, + provider: .claude, metadata: metadata, - snapshot: snapshot, + snapshot: nil, credits: nil, creditsError: nil, dashboard: nil, dashboardError: nil, tokenSnapshot: nil, tokenError: nil, - account: AccountInfo(email: nil, plan: nil), + account: AccountInfo(email: "codex@example.com", plan: "plus"), isRefreshing: false, lastError: nil, usageBarsShowUsed: false, @@ -523,14 +352,14 @@ struct MenuCardModelTests { tokenCostUsageEnabled: false, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, - now: now)) + now: Date())) - #expect(model.metrics.isEmpty) - #expect(model.usageNotes == ["API key limit unavailable right now"]) + #expect(model.planText == nil) + #expect(model.email.isEmpty) } @Test - func `hides email when personal info hidden`() throws { + func `shows codex credits section when optional extras disabled`() throws { let now = Date() let identity = ProviderIdentitySnapshot( providerID: .codex, @@ -549,204 +378,35 @@ struct MenuCardModelTests { 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, + credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), creditsError: nil, dashboard: nil, dashboardError: nil, tokenSnapshot: nil, tokenError: nil, - account: AccountInfo(email: nil, plan: nil), + account: AccountInfo(email: "codex@example.com", plan: nil), isRefreshing: false, lastError: nil, usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - sourceLabel: "cli", - kiloAutoMode: false, + showOptionalCreditsAndExtraUsage: false, hidePersonalInfo: false, now: now)) - #expect(!apiModel.usageNotes.contains("Using CLI fallback")) - #expect(!nonAutoModel.usageNotes.contains("Using CLI fallback")) + #expect(model.creditsText == UsageFormatter.creditsString(from: 12)) + #expect(model.creditsRemaining == 12) } @Test - func `kilo model shows primary detail when reset date missing`() throws { + func `codex credits section shows no credits when balance zero`() 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 metadata = try #require(ProviderDefaults.metadata[.codex]) let model = UsageMenuCardView.Model.make(.init( - provider: .kilo, + provider: .codex, metadata: metadata, - snapshot: snapshot, - credits: nil, + snapshot: nil, + credits: CreditsSnapshot(remaining: 0, events: [], updatedAt: now), creditsError: nil, dashboard: nil, dashboardError: nil, @@ -762,28 +422,18 @@ struct MenuCardModelTests { hidePersonalInfo: false, now: now)) - let primary = try #require(model.metrics.first) - #expect(primary.resetText == nil) - #expect(primary.detailText == "10/100 credits") + #expect(model.creditsText == "No credits available") + #expect(model.creditsRemaining == nil) } @Test - func `kilo model keeps zero total edge state visible`() throws { + func `codex credits section shows no credits when snapshot missing`() 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 metadata = try #require(ProviderDefaults.metadata[.codex]) let model = UsageMenuCardView.Model.make(.init( - provider: .kilo, + provider: .codex, metadata: metadata, - snapshot: snapshot, + snapshot: nil, credits: nil, creditsError: nil, dashboard: nil, @@ -800,34 +450,29 @@ struct MenuCardModelTests { 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) + #expect(model.creditsText == "No credits available") + #expect(model.creditsRemaining == nil) } @Test - func `warp model shows primary detail when reset date missing`() throws { + func `hides claude extra usage when disabled`() throws { let now = Date() let identity = ProviderIdentitySnapshot( - providerID: .warp, - accountEmail: nil, + providerID: .claude, + accountEmail: "claude@example.com", accountOrganization: nil, loginMethod: nil) let snapshot = UsageSnapshot( - primary: RateWindow( - usedPercent: 10, - windowMinutes: nil, - resetsAt: nil, - resetDescription: "10/100 credits"), + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: nil, tertiary: nil, + providerCost: ProviderCostSnapshot(used: 12, limit: 200, currencyCode: "USD", updatedAt: now), updatedAt: now, identity: identity) - let metadata = try #require(ProviderDefaults.metadata[.warp]) + let metadata = try #require(ProviderDefaults.metadata[.claude]) let model = UsageMenuCardView.Model.make(.init( - provider: .warp, + provider: .claude, metadata: metadata, snapshot: snapshot, credits: nil, @@ -839,15 +484,13 @@ struct MenuCardModelTests { account: AccountInfo(email: nil, plan: nil), isRefreshing: false, lastError: nil, - usageBarsShowUsed: true, + usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, + showOptionalCreditsAndExtraUsage: false, hidePersonalInfo: false, now: now)) - let primary = try #require(model.metrics.first) - #expect(primary.resetText == nil) - #expect(primary.detailText == "10/100 credits") + #expect(model.providerCost == nil) } } 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/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 924da2a05..7e40563ac 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -119,11 +119,43 @@ 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(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 e76290615..93ecf3259 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -79,6 +79,86 @@ 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 `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") + + 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 @@ -94,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 45956f0b6..0c0a6dee3 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 } @@ -563,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"), @@ -618,25 +621,165 @@ 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 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) #expect( - creditsItem?.submenu?.items + creditsHistoryItem?.submenu?.items .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true) } @Test - func `shows credits before cost in codex menu card sections`() throws { + 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) + } + } +} + +extension StatusMenuTests { + @Test + func `shows credits before cost in codex menu card sections`() { 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: "API", token: "apikey:sk-test") let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -685,14 +828,74 @@ struct StatusMenuTests { preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + // 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) + if let u = usageMenuIndex, let c = costMenuIndex, let b = buyIndex { + #expect(u < b && b < c) + } + } + + @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 } - let creditsIndex = ids.firstIndex(of: "menuCardCredits") - let costIndex = ids.firstIndex(of: "menuCardCost") - #expect(creditsIndex != nil) - #expect(costIndex != nil) - #expect(try #require(creditsIndex) < costIndex!) + #expect(!ids.contains("menuCardCost")) } @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") + } } diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 67b0a323a..e53d9e61e 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -133,6 +133,54 @@ 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], + defaultAccountLabel: "")) + #expect( + store.shouldFetchAllTokenAccounts( + provider: .codex, + accounts: [account], + defaultAccountLabel: "Primary")) + + settings.codexMultipleAccountsEnabled = true + settings.codexExplicitAccountsOnly = true + + #expect( + !store.shouldFetchAllTokenAccounts( + provider: .codex, + accounts: [account], + defaultAccountLabel: "Primary")) + } + + @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") + + 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) 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