diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ff90fbe3..63279b3765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.46.1 — Unreleased +### Added +- Claude: compact multi-account menu for claude-swap — with four or more accounts the active account keeps its full card while the others become one-line rows sorted by remaining headroom, constrained accounts surface in red/amber, the healthiest switch target gets a star, and the healthy tail folds behind a summary row. Click a row to expand its full card. +- Menu: the compact multi-account layout now covers every stacked multi-account list — token accounts on any provider and Codex accounts (flat lists; workspace-grouped Codex lists keep their sections). + ### Changed - About: link the Website entry to codex.bar. diff --git a/README.md b/README.md index 515817a814..e532198d7f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, and OpenRouter limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. -CodexBar menu screenshot +CodexBar — every AI coding limit in your menu bar. 66 providers. > ![IMPORTANT] > This is a FORKED project. I'll tell you why I did that below. @@ -34,7 +34,6 @@ That's fine, but the beauty of claudecodeusage is... it just works based on your - [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API. - [z.ai](docs/zai.md) — API token (Keychain) for quota + MCP windows. - [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5‑hour rate limit. -- [Kimi K2](docs/kimi-k2.md) — API key for credit-based usage totals. - [Kiro](docs/kiro.md) — CLI-based usage via `kiro-cli /usage` command; monthly credits + bonus credits. - [Vertex AI](docs/vertexai.md) — Google Cloud gcloud OAuth with token cost tracking from local Claude logs. - [Augment](docs/augment.md) — Browser cookie-based authentication with automatic session keepalive; credits tracking and usage monitoring. diff --git a/Sources/CodexBar/MenuCardCompactAccountRow.swift b/Sources/CodexBar/MenuCardCompactAccountRow.swift new file mode 100644 index 0000000000..c28e8a6362 --- /dev/null +++ b/Sources/CodexBar/MenuCardCompactAccountRow.swift @@ -0,0 +1,159 @@ +import CodexBarCore +import SwiftUI + +/// One-line menu row for an inactive account in the compact multi-account layout: +/// label on the left, constraint summary plus a mini headroom bar on the right. +/// Clicking the row expands it into the full usage card. +struct MenuCardCompactAccountRowView: View { + struct Model: Equatable { + let label: String + let headroomPercent: Double? + let severity: AccountMenuLayoutPlanner.Severity? + let constraintDetail: String? + let hasError: Bool + let showsBestBadge: Bool + + var headroomLabel: String? { + self.headroomPercent.map { "\(Int($0.rounded()))%" } + } + + var heightFingerprint: String { + [ + "compactAccount", + self.label, + self.headroomLabel ?? "-", + self.constraintDetail ?? "-", + self.hasError ? "error" : "ok", + self.showsBestBadge ? "best" : "plain", + ].joined(separator: "|") + } + } + + static let miniBarWidth: CGFloat = 56 + static let miniBarHeight: CGFloat = 4 + + let model: Model + let progressColor: Color + let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(self.model.label) + .font(.subheadline) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.middle) + if self.model.showsBestBadge { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(self.isHighlighted + ? MenuHighlightStyle.selectionText + : Color(nsColor: .systemYellow)) + .accessibilityLabel(L("Most usable account")) + } + Spacer(minLength: 12) + if self.model.hasError, self.model.headroomPercent == nil { + Image(systemName: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted)) + .accessibilityLabel(L("Account unavailable")) + } else if let headroom = self.model.headroomPercent, let label = self.model.headroomLabel { + Capsule() + .fill(MenuHighlightStyle.progressTrack(self.isHighlighted)) + .frame(width: Self.miniBarWidth, height: Self.miniBarHeight) + .overlay(alignment: .leading) { + Capsule() + .fill(self.severityColor) + .frame(width: Self.miniBarWidth * min(100, max(0, headroom)) / 100) + } + .accessibilityHidden(true) + Text(label) + .font(.footnote.monospacedDigit()) + .foregroundStyle(self.percentColor) + .lineLimit(1) + .frame(minWidth: 34, alignment: .trailing) + } + } + if let detail = self.model.constraintDetail { + Text(detail) + .font(.footnote) + .foregroundStyle(self.model.severity == .critical + ? MenuHighlightStyle.error(self.isHighlighted) + : MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + } + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding(.vertical, 5) + .frame(width: self.width, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel(self.accessibilityText) + } + + private var severityColor: Color { + guard !self.isHighlighted else { return MenuHighlightStyle.selectionText } + switch self.model.severity { + case .critical: return Color(nsColor: .systemRed) + case .warning: return Color(nsColor: .systemOrange) + case .healthy, .none: return self.progressColor + } + } + + private var percentColor: Color { + guard !self.isHighlighted else { return MenuHighlightStyle.selectionText } + switch self.model.severity { + case .critical: return Color(nsColor: .systemRed) + case .warning: return Color(nsColor: .systemOrange) + case .healthy, .none: return MenuHighlightStyle.normalSecondaryText + } + } + + private var accessibilityText: String { + var parts = [self.model.label] + if let label = self.model.headroomLabel { + parts.append(String(format: L("%@ remaining"), label)) + } + if let detail = self.model.constraintDetail { + parts.append(detail) + } + if self.model.hasError { + parts.append(L("Account unavailable")) + } + return parts.joined(separator: ", ") + } +} + +/// Summary row standing in for the healthy accounts hidden by the compact +/// multi-account layout; clicking it reveals the individual rows. +struct MenuCardCollapsedAccountsRowView: View { + let count: Int + let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + var title: String { + String(format: L("%d more accounts ready"), self.count) + } + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle") + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + Text(self.title) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + Spacer(minLength: 12) + Image(systemName: "chevron.down") + .font(.caption2) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + } + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding(.vertical, 5) + .frame(width: self.width, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel(self.title) + .accessibilityHint(L("Shows the hidden accounts")) + } +} diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 3f1346e12b..15ce52a7da 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -231,6 +231,7 @@ extension StatusItemController { return false } + // swiftlint:disable function_body_length - at the limit before the usage tint; see IconRenderer.makeIcon @discardableResult func applyIcon( phase: Double?, @@ -403,6 +404,8 @@ extension StatusItemController { return false } + // swiftlint:enable function_body_length + private func applyStoredUnifiedMenuBarLayoutIfNeeded( provider: UsageProvider, snapshot: UsageSnapshot?, @@ -463,6 +466,7 @@ extension StatusItemController { return false } + // swiftlint:disable function_body_length - at the limit before the usage tint; see IconRenderer.makeIcon @discardableResult func applyIcon(for provider: UsageProvider, phase: Double?) -> Bool { guard let button = self.statusItems[provider]?.button else { return false } @@ -629,6 +633,8 @@ extension StatusItemController { return false } + // swiftlint:enable function_body_length + static func iconSignatureValue(_ value: Double?) -> String { guard let value else { return "nil" } return String(format: "%.3f", value) diff --git a/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift b/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift index 6d2cc8fb87..ac2ac3a728 100644 --- a/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift +++ b/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift @@ -7,25 +7,37 @@ extension StatusItemController { captureMenu: NSMenu, context: MenuCardContext) { - let cardRows = self.store.claudeSwapAccountSnapshots.compactMap { account -> + let accounts = self.store.claudeSwapAccountSnapshots + let plan = self.compactAccountPlan(for: .claude, accounts: accounts) + guard plan.usesCompactLayout else { + self.addStackedClaudeSwapMenuCards(accounts: accounts, to: menu, captureMenu: captureMenu, context: context) + return + } + self.addCompactAccountMenuRows( + CompactAccountMenuRendering( + plan: plan, + accounts: accounts, + idPrefix: "claudeSwap", + cardModel: { [weak self] account in + self?.claudeSwapCardModel(for: account) + }, + planAction: { [weak self] account in + self?.claudeSwapAccountSwitchAction(account, menu: captureMenu) + }), + to: menu, + captureMenu: captureMenu, + context: context) + } + + private func addStackedClaudeSwapMenuCards( + accounts: [ProviderAccountUsageSnapshot], + to menu: NSMenu, + captureMenu: NSMenu, + context: MenuCardContext) + { + let cardRows = accounts.compactMap { account -> (account: ProviderAccountUsageSnapshot, model: UsageMenuCardView.Model)? in - guard let model = self.menuCardModel( - for: .claude, - snapshotOverride: account.snapshot, - errorOverride: ClaudeSwapAccountProjection.displayError( - accountError: account.error, - adapterError: self.store.claudeSwapLastError, - switchError: self.store.claudeSwapTransientState.lastErrorAccountID == account.id - ? self.store.claudeSwapTransientState.lastError - : nil), - forceOverrideCard: account.snapshot == nil, - accountOverride: AccountInfo( - email: account.displayLabel, - plan: nil), - planOverride: self.claudeSwapAccountActionLabel(account)) - else { - return nil - } + guard let model = self.claudeSwapCardModel(for: account) else { return nil } return (account, model) } self.addStackedMenuCards( @@ -38,6 +50,23 @@ extension StatusItemController { }) } + private func claudeSwapCardModel(for account: ProviderAccountUsageSnapshot) -> UsageMenuCardView.Model? { + self.menuCardModel( + for: .claude, + snapshotOverride: account.snapshot, + errorOverride: ClaudeSwapAccountProjection.displayError( + accountError: account.error, + adapterError: self.store.claudeSwapLastError, + switchError: self.store.claudeSwapTransientState.lastErrorAccountID == account.id + ? self.store.claudeSwapTransientState.lastError + : nil), + forceOverrideCard: account.snapshot == nil, + accountOverride: AccountInfo( + email: account.displayLabel, + plan: nil), + planOverride: self.claudeSwapAccountActionLabel(account)) + } + private func claudeSwapAccountActionLabel(_ account: ProviderAccountUsageSnapshot) -> String? { if account.isActive { return L("Active") diff --git a/Sources/CodexBar/StatusItemController+CompactAccountMenu.swift b/Sources/CodexBar/StatusItemController+CompactAccountMenu.swift new file mode 100644 index 0000000000..113d78f8d6 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+CompactAccountMenu.swift @@ -0,0 +1,298 @@ +import AppKit +import CodexBarCore + +/// Shared renderer for the compact multi-account menu layout: full cards for the +/// active and explicitly expanded accounts, one-line rows for the rest, and a +/// summary row standing in for the collapsed healthy tail. Used by every +/// multi-account presentation (claude-swap, token accounts, Codex accounts). +extension StatusItemController { + func compactAccountPlan( + for provider: UsageProvider, + accounts: [ProviderAccountUsageSnapshot]) -> AccountMenuLayoutPlanner.Plan + { + AccountMenuLayoutPlanner.plan( + accounts: accounts, + expandedAccountIDs: self.compactAccountExpandedIDs, + healthyTailExpanded: self.compactAccountExpandedHealthyTailProviders.contains(provider)) + } + + struct CompactAccountMenuRendering { + let plan: AccountMenuLayoutPlanner.Plan + let accounts: [ProviderAccountUsageSnapshot] + let idPrefix: String + let cardModel: (ProviderAccountUsageSnapshot) -> UsageMenuCardView.Model? + var planAction: ((ProviderAccountUsageSnapshot) -> (() -> Void)?)? + } + + /// Renders the token-account list with the compact plan when it applies. + /// Returns false when the caller should fall back to the stacked cards. + func addCompactTokenAccountMenuIfPlanned( + display: TokenAccountMenuDisplay, + to menu: NSMenu, + captureMenu: NSMenu, + context: MenuCardContext) -> Bool + { + let provider = context.currentProvider + let projected = Self.projectedTokenAccounts( + provider: provider, + snapshots: display.snapshots, + selectedAccountID: self.settings.effectiveSelectedTokenAccount(for: provider)?.id) + let plan = self.compactAccountPlan(for: provider, accounts: projected) + guard plan.usesCompactLayout else { return false } + let snapshotsByID = Dictionary( + uniqueKeysWithValues: display.snapshots.map { ($0.account.id.uuidString, $0) }) + self.addCompactAccountMenuRows( + CompactAccountMenuRendering( + plan: plan, + accounts: projected, + idPrefix: "tokenAccount", + cardModel: { [weak self] projectedAccount in + guard let self, + let accountSnapshot = snapshotsByID[projectedAccount.id.opaqueID] else { return nil } + return self.tokenAccountMenuCardModel(for: provider, accountSnapshot: accountSnapshot) + }, + planAction: nil), + to: menu, + captureMenu: captureMenu, + context: context) + return true + } + + /// Renders the Codex account list with the compact plan when it applies. + /// Workspace-grouped lists keep their sectioned stacked layout; the flat + /// compact plan would lose the grouping headers. + func addCompactCodexAccountMenuIfPlanned( + display: CodexAccountMenuDisplay, + to menu: NSMenu, + captureMenu: NSMenu, + context: MenuCardContext) -> Bool + { + guard !display.showsWorkspaceGroups else { return false } + let projected = Self.projectedCodexAccounts(display: display) + let plan = self.compactAccountPlan(for: .codex, accounts: projected) + guard plan.usesCompactLayout else { return false } + let snapshotsByAccountID = Dictionary( + uniqueKeysWithValues: display.snapshots.map { ($0.account.id, $0) }) + let accountsByID = Dictionary( + uniqueKeysWithValues: display.accounts.map { ($0.id, $0) }) + self.addCompactAccountMenuRows( + CompactAccountMenuRendering( + plan: plan, + accounts: projected, + idPrefix: "codexAccount", + cardModel: { [weak self] projectedAccount in + guard let self, + let account = accountsByID[projectedAccount.id.opaqueID] else { return nil } + let accountSnapshot = snapshotsByAccountID[account.id] + let health = CodexAccountHealth.status(for: account, error: accountSnapshot?.error) + return self.menuCardModel( + for: .codex, + snapshotOverride: accountSnapshot?.snapshot, + errorOverride: health.label, + forceOverrideCard: accountSnapshot == nil, + accountOverride: self.accountInfo(for: account), + historySelectionOverride: self.store.codexPlanUtilizationHistorySelection( + forVisibleAccount: account)) + }, + planAction: nil), + to: menu, + captureMenu: captureMenu, + context: context) + return true + } + + func addCompactAccountMenuRows( + _ rendering: CompactAccountMenuRendering, + to menu: NSMenu, + captureMenu: NSMenu, + context: MenuCardContext) + { + let plan = rendering.plan + let idPrefix = rendering.idPrefix + let cardModel = rendering.cardModel + let planAction = rendering.planAction + let provider = context.currentProvider + let accountsByID = Dictionary(uniqueKeysWithValues: rendering.accounts.map { ($0.id, $0) }) + let progressColor = UsageMenuCardView.Model.progressColor(for: provider) + var previousRowWasCard = false + for (index, row) in plan.rows.enumerated() { + switch row { + case let .card(accountID): + guard let account = accountsByID[accountID], + let model = cardModel(account) else { continue } + if index > 0 { + menu.addItem(.separator()) + } + let collapseClick: (() -> Void)? = account.isActive ? nil : { [weak self, weak captureMenu] in + self?.toggleCompactAccountExpansion(accountID, menu: captureMenu) + } + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView( + model: model, + width: context.menuWidth, + planAction: planAction?(account)), + id: "\(idPrefix)Card-\(accountID.opaqueID)", + width: context.menuWidth, + heightCacheScope: "\(idPrefix)-card-\(accountID.opaqueID)", + heightCacheFingerprint: model.heightFingerprint(section: "card"), + containsInteractiveControls: true, + onClick: collapseClick)) + previousRowWasCard = true + case let .compact(compactRow): + if previousRowWasCard { + menu.addItem(.separator()) + } + let rowModel = MenuCardCompactAccountRowView.Model( + label: PersonalInfoRedactor.redactEmail( + compactRow.label, + isEnabled: self.settings.hidePersonalInfo), + headroomPercent: compactRow.headroomPercent, + severity: compactRow.severity, + constraintDetail: compactRow.constraintDetail, + hasError: compactRow.hasError, + showsBestBadge: compactRow.isBestCandidate) + let accountID = compactRow.accountID + menu.addItem(self.makeMenuCardItem( + MenuCardCompactAccountRowView( + model: rowModel, + progressColor: progressColor, + width: context.menuWidth), + id: "\(idPrefix)Compact-\(accountID.opaqueID)", + width: context.menuWidth, + heightCacheScope: "\(idPrefix)-compact-\(accountID.opaqueID)", + heightCacheFingerprint: rowModel.heightFingerprint, + onClick: { [weak self, weak captureMenu] in + self?.toggleCompactAccountExpansion(accountID, menu: captureMenu) + })) + previousRowWasCard = false + case let .collapsedHealthy(count): + let view = MenuCardCollapsedAccountsRowView(count: count, width: context.menuWidth) + menu.addItem(self.makeMenuCardItem( + view, + id: "\(idPrefix)Collapsed", + width: context.menuWidth, + heightCacheScope: "\(idPrefix)-collapsed", + heightCacheFingerprint: "collapsed-\(count)", + onClick: { [weak self, weak captureMenu] in + self?.expandCompactAccountHealthyTail(for: provider, menu: captureMenu) + })) + previousRowWasCard = false + } + } + if !plan.rows.isEmpty { + menu.addItem(.separator()) + } + if self.addStorageMenuCardSection(to: menu, provider: provider, width: context.menuWidth) { + menu.addItem(.separator()) + } + } + + /// Classic stacked layout: one full card per account. Shared fallback for + /// every multi-account list below the compact-layout threshold. + func addStackedMenuCards( + _ cards: [UsageMenuCardView.Model], + to menu: NSMenu, + context: MenuCardContext, + planAction: ((Int) -> (() -> Void)?)? = nil) + { + if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { + let renderedModel = self.menuCardRefreshMonitor.model(for: model.provider, fallback: model) + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView(model: model, layoutModel: renderedModel, width: context.menuWidth), + id: "menuCard", + width: context.menuWidth, + heightCacheScope: context.currentProvider.rawValue, + heightCacheFingerprint: renderedModel.heightFingerprint(section: "card"), + containsInteractiveControls: true)) + menu.addItem(.separator()) + } else { + for (index, model) in cards.enumerated() { + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView( + model: model, + width: context.menuWidth, + planAction: planAction?(index)), + id: "menuCard-\(index)", + width: context.menuWidth, + heightCacheScope: "\(context.currentProvider.rawValue)-\(index)", + heightCacheFingerprint: model.heightFingerprint(section: "card"), + containsInteractiveControls: true)) + if index < cards.count - 1 { + menu.addItem(.separator()) + } + } + if !cards.isEmpty { + menu.addItem(.separator()) + } + } + if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { + menu.addItem(.separator()) + } + } + + // MARK: - Projections + + static func projectedTokenAccounts( + provider: UsageProvider, + snapshots: [TokenAccountUsageSnapshot], + selectedAccountID: UUID?) -> [ProviderAccountUsageSnapshot] + { + snapshots.map { accountSnapshot in + let isActive = accountSnapshot.account.id == selectedAccountID + return ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity( + source: "token-account", + opaqueID: accountSnapshot.account.id.uuidString), + provider: provider, + displayLabel: accountSnapshot.account.displayName, + isActive: isActive, + canActivate: !isActive, + snapshot: accountSnapshot.snapshot, + error: accountSnapshot.error, + sourceLabel: accountSnapshot.sourceLabel) + } + } + + static func projectedCodexAccounts(display: CodexAccountMenuDisplay) -> [ProviderAccountUsageSnapshot] { + let snapshotsByAccountID = Dictionary(uniqueKeysWithValues: display.snapshots.map { ($0.account.id, $0) }) + return display.accounts.map { account in + let accountSnapshot = snapshotsByAccountID[account.id] + let health = CodexAccountHealth.status(for: account, error: accountSnapshot?.error) + let isActive = account.id == display.activeVisibleAccountID || account.isActive + return ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity(source: "codex-account", opaqueID: account.id), + provider: .codex, + displayLabel: account.menuDisplayName, + isActive: isActive, + canActivate: !isActive, + snapshot: accountSnapshot?.snapshot, + error: health.label, + sourceLabel: accountSnapshot?.sourceLabel) + } + } + + // MARK: - Expansion state + + private func toggleCompactAccountExpansion(_ accountID: ProviderAccountIdentity, menu: NSMenu?) { + self.advanceMenuInteraction(for: menu) + if self.compactAccountExpandedIDs.contains(accountID) { + self.compactAccountExpandedIDs.remove(accountID) + } else { + self.compactAccountExpandedIDs.insert(accountID) + } + self.invalidateMenus(refreshOpenMenus: true) + } + + private func expandCompactAccountHealthyTail(for provider: UsageProvider, menu: NSMenu?) { + self.advanceMenuInteraction(for: menu) + self.compactAccountExpandedHealthyTailProviders.insert(provider) + self.invalidateMenus(refreshOpenMenus: true) + } + + /// Compact-layout expansion is per-open transient UI state; reset when the last menu closes. + func resetCompactAccountMenuExpansionStateIfIdle() { + guard self.openMenus.isEmpty else { return } + self.compactAccountExpandedIDs.removeAll() + self.compactAccountExpandedHealthyTailProviders.removeAll() + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index ec361edf8b..be4ada0571 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -159,6 +159,10 @@ extension StatusItemController { if wasHostedSubviewMenu { self.refreshOpenMenusAfterHostedSubviewClose() } + if self.openMenus.isEmpty { + self.cancelMergedSwitcherSiblingWarmup() + } + self.resetCompactAccountMenuExpansionStateIfIdle() } func forgetClosedMenu(_ menu: NSMenu) { @@ -229,6 +233,9 @@ extension StatusItemController { breadcrumb: "populateMenu:\(provider?.rawValue ?? "merged")") defer { self.endMenuOperationTrace(trace, menu: menu, provider: provider) } defer { self.refreshMenuCardHeights(in: menu) } + // Re-warm sibling tab caches after every populate of the open merged menu so a + // tab switch attaches pre-rendered rows; no-ops for closed or non-merged menus. + defer { self.scheduleMergedSwitcherSiblingWarmup(for: menu) } let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) @@ -405,6 +412,7 @@ extension StatusItemController { context: MenuRebuildContext) { self.performMenuMutationWithoutAnimation { + defer { self.flushHostedMenuRowRendering(in: menu) } let displacedSelection = self.lastMergedMenuContentSelection self.lastMergedMenuContentSelection = nil self.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: displacedSelection) @@ -466,7 +474,7 @@ extension StatusItemController { } } - private func openAIWebContext( + func openAIWebContext( currentProvider: UsageProvider, showAllAccounts: Bool) -> OpenAIWebContext { @@ -610,7 +618,14 @@ extension StatusItemController { private func addMenuCards(to menu: NSMenu, context: MenuCardContext, captureMenu: NSMenu? = nil) -> Bool { if let codexAccountDisplay = context.codexAccountDisplay, codexAccountDisplay.showAll { - self.addStackedCodexMenuCards(codexAccountDisplay, to: menu, context: context) + if !self.addCompactCodexAccountMenuIfPlanned( + display: codexAccountDisplay, + to: menu, + captureMenu: captureMenu ?? menu, + context: context) + { + self.addStackedCodexMenuCards(codexAccountDisplay, to: menu, context: context) + } return false } @@ -626,6 +641,14 @@ extension StatusItemController { } if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll { + if self.addCompactTokenAccountMenuIfPlanned( + display: tokenAccountDisplay, + to: menu, + captureMenu: captureMenu ?? menu, + context: context) + { + return false + } let accountSnapshots = tokenAccountDisplay.snapshots let cards = accountSnapshots.isEmpty ? [] @@ -686,47 +709,6 @@ extension StatusItemController { return false } - func addStackedMenuCards( - _ cards: [UsageMenuCardView.Model], - to menu: NSMenu, - context: MenuCardContext, - planAction: ((Int) -> (() -> Void)?)? = nil) - { - if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) { - let renderedModel = self.menuCardRefreshMonitor.model(for: model.provider, fallback: model) - menu.addItem(self.makeMenuCardItem( - UsageMenuCardView(model: model, layoutModel: renderedModel, width: context.menuWidth), - id: "menuCard", - width: context.menuWidth, - heightCacheScope: context.currentProvider.rawValue, - heightCacheFingerprint: renderedModel.heightFingerprint(section: "card"), - containsInteractiveControls: true)) - menu.addItem(.separator()) - } else { - for (index, model) in cards.enumerated() { - menu.addItem(self.makeMenuCardItem( - UsageMenuCardView( - model: model, - width: context.menuWidth, - planAction: planAction?(index)), - id: "menuCard-\(index)", - width: context.menuWidth, - heightCacheScope: "\(context.currentProvider.rawValue)-\(index)", - heightCacheFingerprint: model.heightFingerprint(section: "card"), - containsInteractiveControls: true)) - if index < cards.count - 1 { - menu.addItem(.separator()) - } - } - if !cards.isEmpty { - menu.addItem(.separator()) - } - } - if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { - menu.addItem(.separator()) - } - } - private func addOpenAIWebItemsIfNeeded( to menu: NSMenu, currentProvider: UsageProvider, @@ -1099,13 +1081,13 @@ extension StatusItemController { return enabled.first(where: { self.store.isProviderAvailable($0) }) ?? enabled.first } - private func includesOverviewTab(enabledProviders: [UsageProvider]) -> Bool { + func includesOverviewTab(enabledProviders: [UsageProvider]) -> Bool { !self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, maxVisibleProviders: Self.maxOverviewProviders).isEmpty } - private func resolvedSwitcherSelection( + func resolvedSwitcherSelection( enabledProviders: [UsageProvider], includesOverview: Bool) -> ProviderSwitcherSelection { diff --git a/Sources/CodexBar/StatusItemController+MenuReconcile.swift b/Sources/CodexBar/StatusItemController+MenuReconcile.swift index 6fc7cb02b4..b0ca3925a6 100644 --- a/Sources/CodexBar/StatusItemController+MenuReconcile.swift +++ b/Sources/CodexBar/StatusItemController+MenuReconcile.swift @@ -142,6 +142,27 @@ extension StatusItemController { return displacedItems } + /// Forces hosted rows to lay out and draw inside the caller's disabled-actions + /// transaction. `NSHostingView` commits SwiftUI updates asynchronously by + /// default, so a provider-tab switch could paint the previous card content for + /// a frame after the item mutation — visible as a brief flicker. Flushing + /// synchronously makes the content swap composite atomically with the menu + /// update. Views without a window (closed or detached menus) are skipped. + func flushHostedMenuRowRendering(in menu: NSMenu) { + // Freshly inserted item views may not be parented into the menu window yet + // when this runs, so lay out every hosted row unconditionally and then flush + // pending drawing once at the window level. + var menuWindow: NSWindow? + for item in menu.items { + guard let view = item.view else { continue } + view.layoutSubtreeIfNeeded() + if menuWindow == nil { + menuWindow = view.window + } + } + menuWindow?.displayIfNeeded() + } + private func finishReconciledHighlightTracking(in menu: NSMenu) { let menuKey = ObjectIdentifier(menu) guard let highlightedItem = self.highlightedMenuItems[menuKey] else { return } diff --git a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift index 13e6fd34ae..50f148ac11 100644 --- a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift +++ b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift @@ -21,6 +21,7 @@ extension StatusItemController { context: MenuUpdateContext) { self.performMenuMutationWithoutAnimation { + defer { self.flushHostedMenuRowRendering(in: menu) } let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) if let switcherView = menu.items.first?.view as? ProviderSwitcherView { switcherView.updateSelection(context.switcherSelection) @@ -102,7 +103,7 @@ extension StatusItemController { /// Adds everything below the provider switcher (account switchers, card content, and /// actionable sections) to `target`, which may be a detached scratch menu; interaction /// closures always capture `captureMenu`, the live menu the rows will serve. - private func addSwitcherScopedMenuContent( + func addSwitcherScopedMenuContent( into target: NSMenu, captureMenu: NSMenu, context: MenuUpdateContext) diff --git a/Sources/CodexBar/StatusItemController+MenuSwitcherWarmup.swift b/Sources/CodexBar/StatusItemController+MenuSwitcherWarmup.swift new file mode 100644 index 0000000000..18fd2c3db9 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuSwitcherWarmup.swift @@ -0,0 +1,119 @@ +import AppKit +import CodexBarCore + +/// Pre-builds the merged switcher's sibling tab content shortly after the menu +/// opens (and after each open-menu rebuild), so switching tabs attaches fully +/// laid-out cached rows instead of rendering fresh hosting views mid-click. +/// `NSHostingView` commits SwiftUI content asynchronously; a freshly built card +/// swapped in during a switch paints a frame late, which reads as a flicker. +extension StatusItemController { + private static let mergedSwitcherWarmupDelay: Duration = .milliseconds(120) + + func scheduleMergedSwitcherSiblingWarmup(for menu: NSMenu) { + guard self.isMenuRefreshEnabled else { return } + guard self.shouldMergeIcons, menu === self.mergedMenu else { return } + self.mergedSwitcherWarmupTask?.cancel() + self.mergedSwitcherWarmupTask = Task { @MainActor [weak self, weak menu] in + try? await Task.sleep(for: Self.mergedSwitcherWarmupDelay) + guard !Task.isCancelled, let self else { return } + self.mergedSwitcherWarmupTask = nil + guard let menu, self.openMenus[ObjectIdentifier(menu)] === menu else { return } + self.warmMergedSwitcherSiblingContent(in: menu) + } + } + + func cancelMergedSwitcherSiblingWarmup() { + self.mergedSwitcherWarmupTask?.cancel() + self.mergedSwitcherWarmupTask = nil + } + + func warmMergedSwitcherSiblingContent(in menu: NSMenu) { + guard menu.items.first?.view is ProviderSwitcherView else { return } + let enabledProviders = self.store.enabledProvidersForDisplay() + guard enabledProviders.count > 1 else { return } + let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) + let currentSelection = self.resolvedSwitcherSelection( + enabledProviders: enabledProviders, + includesOverview: includesOverview) + var selections: [ProviderSwitcherSelection] = enabledProviders.map { .provider($0) } + if includesOverview { + selections.insert(.overview, at: 0) + } + for selection in selections where selection != currentSelection { + self.warmMergedSwitcherContentIfMissing( + for: selection, + in: menu, + enabledProviders: enabledProviders) + } + } + + private func warmMergedSwitcherContentIfMissing( + for selection: ProviderSwitcherSelection, + in menu: NSMenu, + enabledProviders: [UsageProvider]) + { + let isOverviewSelected = selection == .overview + let selectedProvider = isOverviewSelected + ? self.resolvedMenuProvider(enabledProviders: enabledProviders) + : selection.provider + let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex + let codexAccountDisplay = isOverviewSelected ? nil : self.codexAccountMenuDisplay(for: currentProvider) + let tokenAccountDisplay = isOverviewSelected ? nil : self.tokenAccountMenuDisplay(for: currentProvider) + let showAllAccounts = (tokenAccountDisplay?.showAll ?? false) || (codexAccountDisplay?.showAll ?? false) + let descriptor = self.makeMenuDescriptor( + provider: selectedProvider, + includeContextualActions: !isOverviewSelected) + let menuWidth = self.menuCardWidth( + for: enabledProviders, + selectedProvider: selectedProvider, + descriptor: descriptor) + guard self.reusableMergedSwitcherContent( + for: selection, + in: menu, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay) == nil + else { return } + + // Building sibling content updates the "last rendered display" trackers used by + // smart-update compatibility checks; restore them so the live tab's state wins. + let savedCodexDisplay = self.lastCodexAccountMenuDisplay + let savedTokenDisplay = self.lastTokenAccountMenuDisplay + let scratch = NSMenu() + scratch.autoenablesItems = false + self.addSwitcherScopedMenuContent( + into: scratch, + captureMenu: menu, + context: MenuUpdateContext( + provider: selectedProvider, + currentProvider: currentProvider, + switcherSelection: selection, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + openAIContext: self.openAIWebContext( + currentProvider: currentProvider, + showAllAccounts: showAllAccounts), + descriptor: descriptor)) + self.lastCodexAccountMenuDisplay = savedCodexDisplay + self.lastTokenAccountMenuDisplay = savedTokenDisplay + + let items = scratch.items + scratch.removeAllItems() + guard !items.isEmpty else { return } + // Force SwiftUI layout now so the first attach draws in the same transaction + // as the menu mutation instead of one frame later. + for item in items { + item.view?.layoutSubtreeIfNeeded() + } + self.cacheMergedSwitcherContent( + items, + in: menu, + selection: selection, + context: MergedSwitcherContentCacheContext( + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + contentVersion: self.menuSession.contentVersion)) + } +} diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 9bcef6a293..8f8bbc0784 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -1439,13 +1439,17 @@ final class CodexAccountSwitcherView: NSView { var emailWidth = max(minimumEmailWidth, contentWidth * 0.58) var workspaceWidth = max(minimumWorkspaceWidth, contentWidth - emailWidth) - func makeTitle() -> String { - let email = self.truncateMiddle(account.email, toFit: emailWidth) - let workspace = self.truncateTail(workspace, toFit: workspaceWidth) - return "\(email)\(separator)\(workspace)" + /// Note: takes the widths as parameters rather than capturing the mutable + /// `emailWidth` / `workspaceWidth` vars below. Capturing those `var`s in a + /// nested function crashes swift-frontend (IRGen, SIGABRT) under the + /// Swift 6.2.3 + macOS 26.4 SDK toolchain. + func makeTitle(emailWidth: CGFloat, workspaceWidth: CGFloat) -> String { + let emailText = self.truncateMiddle(account.email, toFit: emailWidth) + let workspaceText = self.truncateTail(workspace, toFit: workspaceWidth) + return "\(emailText)\(separator)\(workspaceText)" } - var title = makeTitle() + var title = makeTitle(emailWidth: emailWidth, workspaceWidth: workspaceWidth) var attempts = 0 while self.textWidth(title) > availableTextWidth, attempts < 16 { let emailText = self.truncateMiddle(account.email, toFit: emailWidth) @@ -1461,7 +1465,7 @@ final class CodexAccountSwitcherView: NSView { break } - title = makeTitle() + title = makeTitle(emailWidth: emailWidth, workspaceWidth: workspaceWidth) attempts += 1 } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index f511ace591..0d874d0981 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -274,6 +274,12 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastCodexAccountMenuDisplay: CodexAccountMenuDisplay? /// Tracks the visible token account switcher contents for merged-menu smart updates. var lastTokenAccountMenuDisplay: TokenAccountMenuDisplay? + /// Debounced pre-build of sibling switcher tabs for flicker-free tab switches. + var mergedSwitcherWarmupTask: Task? + /// Compact multi-account layout: accounts the user expanded to full cards this menu session. + var compactAccountExpandedIDs: Set = [] + /// Compact multi-account layout: providers whose collapsed healthy tail is revealed this menu session. + var compactAccountExpandedHealthyTailProviders: Set = [] /// Keeps detached merged-menu tab content reusable while the same menu remains open. var mergedSwitcherContentCaches: [ObjectIdentifier: [ProviderSwitcherSelection: CachedMergedSwitcherMenuContent]] = [:] diff --git a/Sources/CodexBarCore/AccountMenuLayoutPlanner.swift b/Sources/CodexBarCore/AccountMenuLayoutPlanner.swift new file mode 100644 index 0000000000..c4c5352fd0 --- /dev/null +++ b/Sources/CodexBarCore/AccountMenuLayoutPlanner.swift @@ -0,0 +1,186 @@ +import Foundation + +/// Plans the compact ("smart") multi-account menu layout used when a provider +/// surfaces many account rows: the active account keeps its full usage card, +/// inactive accounts collapse to one-line rows sorted most-constrained-first, +/// and a long healthy tail folds behind a single summary row. +/// +/// Pure projection: inputs are account snapshots plus menu expansion state, +/// output is an ordered row plan. Rendering and interaction stay in the app layer. +public enum AccountMenuLayoutPlanner { + /// Compact layout only engages once a provider has this many account rows; + /// below that the classic stacked cards still fit on screen. + public static let compactLayoutMinimumAccountCount = 4 + public static let criticalHeadroomPercent: Double = 10 + public static let warningHeadroomPercent: Double = 50 + /// Folding fewer healthy rows than this behind a summary row would not save + /// meaningful menu height. + public static let minimumCollapsedHealthyRowCount = 2 + static let constraintDetailWindowLimit = 2 + + public enum Severity: Equatable, Sendable { + case critical + case warning + case healthy + } + + public struct CompactRow: Equatable, Sendable { + public let accountID: ProviderAccountIdentity + public let label: String + /// Lowest remaining percent across the account's usage windows; nil when + /// the account reported no usable windows (for example an error row). + public let headroomPercent: Double? + public let severity: Severity? + /// Short summary of the constrained windows, e.g. "Fable 0% · Weekly 43%". + public let constraintDetail: String? + public let hasError: Bool + public let canActivate: Bool + /// Marks the inactive account with the most usable headroom — the best + /// candidate to switch to next. Only healthy accounts qualify. + public let isBestCandidate: Bool + } + + public enum Row: Equatable, Sendable { + case card(ProviderAccountIdentity) + case compact(CompactRow) + /// Healthy accounts folded behind one summary row; `count` is how many are hidden. + case collapsedHealthy(count: Int) + } + + public struct Plan: Equatable, Sendable { + public let rows: [Row] + public let usesCompactLayout: Bool + } + + public static func plan( + accounts: [ProviderAccountUsageSnapshot], + expandedAccountIDs: Set = [], + healthyTailExpanded: Bool = false) -> Plan + { + guard accounts.count >= self.compactLayoutMinimumAccountCount else { + return Plan(rows: accounts.map { .card($0.id) }, usesCompactLayout: false) + } + + var rows: [Row] = accounts.filter(\.isActive).map { .card($0.id) } + + let inactive = accounts.filter { !$0.isActive } + let compactRows = self.sortedCompactRows(for: inactive) + + let collapsible = healthyTailExpanded + ? [] + : compactRows.filter { $0.severity == .healthy && !$0.isBestCandidate && !$0.hasError && + !expandedAccountIDs.contains($0.accountID) + } + let collapsedIDs: Set = + collapsible.count >= self.minimumCollapsedHealthyRowCount + ? Set(collapsible.map(\.accountID)) + : [] + + for row in compactRows where !collapsedIDs.contains(row.accountID) { + if expandedAccountIDs.contains(row.accountID) { + rows.append(.card(row.accountID)) + } else { + rows.append(.compact(row)) + } + } + if !collapsedIDs.isEmpty { + rows.append(.collapsedHealthy(count: collapsedIDs.count)) + } + return Plan(rows: rows, usesCompactLayout: true) + } + + public static func severity(forHeadroom headroom: Double) -> Severity { + if headroom <= self.criticalHeadroomPercent { + return .critical + } + if headroom <= self.warningHeadroomPercent { + return .warning + } + return .healthy + } + + /// Lowest remaining percent across the account's real usage windows. + public static func headroomPercent(for account: ProviderAccountUsageSnapshot) -> Double? { + self.labeledWindows(for: account).map(\.remainingPercent).min() + } + + private static func sortedCompactRows(for accounts: [ProviderAccountUsageSnapshot]) -> [CompactRow] { + let bestCandidateID = self.bestCandidateID(in: accounts) + let unsorted = accounts.map { account in + self.compactRow(for: account, isBestCandidate: account.id == bestCandidateID) + } + return unsorted.enumerated() + .sorted { lhs, rhs in + let lhsKey = (lhs.element.headroomPercent ?? 0, lhs.offset) + let rhsKey = (rhs.element.headroomPercent ?? 0, rhs.offset) + return lhsKey < rhsKey + } + .map(\.element) + } + + private static func compactRow( + for account: ProviderAccountUsageSnapshot, + isBestCandidate: Bool) -> CompactRow + { + let windows = self.labeledWindows(for: account) + let headroom = windows.map(\.remainingPercent).min() + let constrained = windows + .filter { $0.remainingPercent <= self.warningHeadroomPercent } + .sorted { $0.remainingPercent < $1.remainingPercent } + .prefix(self.constraintDetailWindowLimit) + .map { "\($0.label) \(Int($0.remainingPercent.rounded()))%" } + return CompactRow( + accountID: account.id, + label: account.displayLabel, + headroomPercent: headroom, + severity: headroom.map(self.severity(forHeadroom:)), + constraintDetail: constrained.isEmpty ? nil : constrained.joined(separator: " · "), + hasError: account.error != nil, + canActivate: account.canActivate, + isBestCandidate: isBestCandidate) + } + + private static func bestCandidateID( + in accounts: [ProviderAccountUsageSnapshot]) -> ProviderAccountIdentity? + { + accounts + .compactMap { account -> (id: ProviderAccountIdentity, headroom: Double)? in + guard account.canActivate, account.error == nil, + let headroom = self.headroomPercent(for: account), + self.severity(forHeadroom: headroom) == .healthy + else { return nil } + return (account.id, headroom) + } + .max { $0.headroom < $1.headroom }? + .id + } + + private static func labeledWindows( + for account: ProviderAccountUsageSnapshot) -> [(label: String, remainingPercent: Double)] + { + guard let snapshot = account.snapshot else { return [] } + let metadata = ProviderDefaults.metadata[account.provider] + var windows: [(label: String, remainingPercent: Double)] = [] + if let primary = snapshot.primary, !primary.isSyntheticPlaceholder { + windows.append((metadata?.sessionLabel ?? "Session", primary.remainingPercent)) + } + if let secondary = snapshot.secondary { + windows.append((metadata?.weeklyLabel ?? "Weekly", secondary.remainingPercent)) + } + if let tertiary = snapshot.tertiary { + windows.append((metadata?.opusLabel ?? "Monthly", tertiary.remainingPercent)) + } + for extra in snapshot.extraRateWindows ?? [] where extra.usageKnown { + windows.append((self.shortLabel(forWindowTitle: extra.title), extra.window.remainingPercent)) + } + return windows + } + + /// Scoped weekly windows are titled " only" for the card view; the + /// compact row's constraint summary reads better without the suffix. + public static func shortLabel(forWindowTitle title: String) -> String { + guard title.hasSuffix(" only") else { return title } + let trimmed = String(title.dropLast(" only".count)) + return trimmed.isEmpty ? title : trimmed + } +} diff --git a/Tests/CodexBarTests/AccountMenuLayoutPlannerTests.swift b/Tests/CodexBarTests/AccountMenuLayoutPlannerTests.swift new file mode 100644 index 0000000000..360ffd7977 --- /dev/null +++ b/Tests/CodexBarTests/AccountMenuLayoutPlannerTests.swift @@ -0,0 +1,242 @@ +import CodexBarCore +import Foundation +import Testing + +/// Coverage for the compact multi-account menu plan: active card pinned first, +/// inactive accounts as headroom-sorted compact rows, healthy tail collapsed +/// behind a summary row, and per-account expansion back to full cards. +struct AccountMenuLayoutPlannerTests { + private static let now = Date(timeIntervalSince1970: 1_782_000_000) + + private func account( + slot: Int, + email: String, + isActive: Bool = false, + canActivate: Bool = true, + sessionUsed: Double? = nil, + weeklyUsed: Double? = nil, + scopedUsed: [(name: String, used: Double)] = [], + sessionIsSyntheticPlaceholder: Bool = false, + error: String? = nil, + hasSnapshot: Bool = true) -> ProviderAccountUsageSnapshot + { + let primary = sessionUsed.map { used in + RateWindow( + usedPercent: used, + windowMinutes: 300, + resetsAt: Self.now.addingTimeInterval(3600), + resetDescription: nil, + isSyntheticPlaceholder: sessionIsSyntheticPlaceholder) + } + let secondary = weeklyUsed.map { used in + RateWindow( + usedPercent: used, + windowMinutes: 7 * 24 * 60, + resetsAt: Self.now.addingTimeInterval(86400), + resetDescription: nil) + } + let extras = scopedUsed.map { scoped in + NamedRateWindow( + id: "claude-weekly-scoped-\(scoped.name.lowercased())", + title: "\(scoped.name) only", + window: RateWindow( + usedPercent: scoped.used, + windowMinutes: 7 * 24 * 60, + resetsAt: Self.now.addingTimeInterval(86400), + resetDescription: nil)) + } + let snapshot: UsageSnapshot? = hasSnapshot + ? UsageSnapshot( + primary: primary, + secondary: secondary, + extraRateWindows: extras.isEmpty ? nil : extras, + updatedAt: Self.now, + identity: nil) + : nil + return ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity(source: "claude-swap", opaqueID: String(slot)), + provider: .claude, + displayLabel: email, + isActive: isActive, + canActivate: canActivate, + snapshot: snapshot, + error: error, + sourceLabel: "claude-swap") + } + + /// Six accounts mirroring the motivating screenshot: one active, one badly + /// constrained, the rest healthy. + private func screenshotFixture() -> [ProviderAccountUsageSnapshot] { + [ + self.account(slot: 1, email: "alice@example.com", isActive: true, sessionUsed: 4, weeklyUsed: 1), + self.account(slot: 2, email: "work@example.com", sessionUsed: 3, weeklyUsed: 0, scopedUsed: [("Fable", 3)]), + self.account(slot: 3, email: "spare@example.com", sessionUsed: 0, weeklyUsed: 0), + self.account(slot: 4, email: "team@example.com", weeklyUsed: 4, scopedUsed: [("Fable", 8)]), + self.account( + slot: 5, + email: "burner@example.com", + sessionUsed: 0, + weeklyUsed: 57, + scopedUsed: [("Fable", 100)]), + self.account(slot: 6, email: "backup@example.com", sessionUsed: 0, weeklyUsed: 2), + ] + } + + private func compactRows(in plan: AccountMenuLayoutPlanner.Plan) -> [AccountMenuLayoutPlanner.CompactRow] { + plan.rows.compactMap { row in + if case let .compact(compact) = row { return compact } + return nil + } + } + + @Test + func `fewer than four accounts keeps stacked cards`() { + let accounts = [ + self.account(slot: 1, email: "a@example.com", isActive: true, sessionUsed: 10), + self.account(slot: 2, email: "b@example.com", sessionUsed: 20), + self.account(slot: 3, email: "c@example.com", sessionUsed: 30), + ] + + let plan = AccountMenuLayoutPlanner.plan(accounts: accounts) + + #expect(!plan.usesCompactLayout) + #expect(plan.rows == accounts.map { .card($0.id) }) + } + + @Test + func `active card first then constrained rows then best candidate then collapsed tail`() { + let plan = AccountMenuLayoutPlanner.plan(accounts: self.screenshotFixture()) + + #expect(plan.usesCompactLayout) + guard case let .card(first) = plan.rows.first else { + Issue.record("expected active card first, got \(plan.rows)") + return + } + #expect(first.opaqueID == "1") + + let compacts = self.compactRows(in: plan) + #expect(compacts.map(\.label) == ["burner@example.com", "spare@example.com"]) + + let constrained = compacts[0] + #expect(constrained.headroomPercent == 0) + #expect(constrained.severity == .critical) + #expect(constrained.constraintDetail == "Fable 0% · Weekly 43%") + #expect(!constrained.isBestCandidate) + + let best = compacts[1] + #expect(best.severity == .healthy) + #expect(best.isBestCandidate) + #expect(best.constraintDetail == nil) + + #expect(plan.rows.last == .collapsedHealthy(count: 3)) + } + + @Test + func `expanded account renders as card in its sorted position`() { + let accounts = self.screenshotFixture() + let constrained = accounts[4].id + + let plan = AccountMenuLayoutPlanner.plan(accounts: accounts, expandedAccountIDs: [constrained]) + + #expect(plan.rows[0] == .card(accounts[0].id)) + #expect(plan.rows[1] == .card(constrained)) + let compacts = self.compactRows(in: plan) + #expect(compacts.map(\.label) == ["spare@example.com"]) + #expect(plan.rows.last == .collapsedHealthy(count: 3)) + } + + @Test + func `expanded healthy tail lists every account sorted by headroom`() { + let plan = AccountMenuLayoutPlanner.plan(accounts: self.screenshotFixture(), healthyTailExpanded: true) + + let compacts = self.compactRows(in: plan) + #expect(compacts.map(\.label) == [ + "burner@example.com", + "team@example.com", + "work@example.com", + "backup@example.com", + "spare@example.com", + ]) + #expect(!plan.rows.contains { row in + if case .collapsedHealthy = row { return true } + return false + }) + } + + @Test + func `error account without snapshot sorts with the constrained rows`() { + var accounts = self.screenshotFixture() + accounts[3] = self.account( + slot: 4, + email: "team@example.com", + canActivate: false, + error: "Token expired.", + hasSnapshot: false) + + let plan = AccountMenuLayoutPlanner.plan(accounts: accounts) + + let compacts = self.compactRows(in: plan) + let errorRow = compacts.first { $0.label == "team@example.com" } + #expect(errorRow != nil) + #expect(errorRow?.hasError == true) + #expect(errorRow?.headroomPercent == nil) + #expect(errorRow?.severity == nil) + // Unknown headroom sorts alongside the critical rows, never into the healthy tail. + #expect(plan.rows.last == .collapsedHealthy(count: 2)) + } + + @Test + func `best candidate requires activation support`() { + var accounts = self.screenshotFixture() + accounts[2] = self.account( + slot: 3, + email: "spare@example.com", + canActivate: false, + sessionUsed: 0, + weeklyUsed: 0) + + let plan = AccountMenuLayoutPlanner.plan(accounts: accounts) + + let best = self.compactRows(in: plan).filter(\.isBestCandidate) + #expect(best.map(\.label) == ["backup@example.com"]) + } + + @Test + func `synthetic session placeholder does not count toward headroom`() { + let account = self.account( + slot: 9, + email: "placeholder@example.com", + sessionUsed: 100, + weeklyUsed: 20, + sessionIsSyntheticPlaceholder: true) + + #expect(AccountMenuLayoutPlanner.headroomPercent(for: account) == 80) + } + + @Test + func `collapse only engages once enough healthy rows exist`() { + let accounts = [ + self.account(slot: 1, email: "a@example.com", isActive: true, sessionUsed: 5), + self.account(slot: 2, email: "b@example.com", sessionUsed: 95), + self.account(slot: 3, email: "c@example.com", sessionUsed: 60), + self.account(slot: 4, email: "d@example.com", sessionUsed: 10), + ] + + let plan = AccountMenuLayoutPlanner.plan(accounts: accounts) + + // Healthy rows: only "d" (90% headroom, best candidate) — nothing left to fold. + #expect(!plan.rows.contains { row in + if case .collapsedHealthy = row { return true } + return false + }) + let compacts = self.compactRows(in: plan) + #expect(compacts.map(\.label) == ["b@example.com", "c@example.com", "d@example.com"]) + } + + @Test + func `scoped window titles drop the only suffix`() { + #expect(AccountMenuLayoutPlanner.shortLabel(forWindowTitle: "Fable only") == "Fable") + #expect(AccountMenuLayoutPlanner.shortLabel(forWindowTitle: "Weekly") == "Weekly") + #expect(AccountMenuLayoutPlanner.shortLabel(forWindowTitle: " only") == " only") + } +} diff --git a/Tests/CodexBarTests/MenuBarUsageTintTests.swift b/Tests/CodexBarTests/MenuBarUsageTintTests.swift index d489e746a3..6e360f75be 100644 --- a/Tests/CodexBarTests/MenuBarUsageTintTests.swift +++ b/Tests/CodexBarTests/MenuBarUsageTintTests.swift @@ -18,10 +18,20 @@ struct MenuBarUsageTintTests { let samples = [0.0, 20, 40, 60, 70, 80, 90, 100] let ramp = try samples.map { try self.components(#require(MenuBarUsageTint.color(forUsedPercent: $0))) } + // Green falls the whole way. It is the channel that carries "toward red" across both + // segments, so it is the one that must be monotonic end to end. for (lower, higher) in zip(ramp, ramp.dropFirst()) { - #expect(higher.red >= lower.red) #expect(higher.green <= lower.green) } + + // Red only climbs on the green → orange leg. The orange and red anchors are picked for + // legibility rather than to form a monotonic red ramp (0.85 then 0.80), so red dips + // slightly past the medium threshold by design. + let towardOrange = zip(samples, ramp).filter { $0.0 <= 70 }.map(\.1) + for (lower, higher) in zip(towardOrange, towardOrange.dropFirst()) { + #expect(higher.red >= lower.red) + } + let lowest = try #require(ramp.first) let highest = try #require(ramp.last) #expect(highest.red > lowest.red) @@ -60,7 +70,8 @@ struct MenuBarUsageTintTests { dark = try? self.components(color) } - #expect(light != nil) - #expect(light == dark) + let resolvedLight = try #require(light) + let resolvedDark = try #require(dark) + #expect(resolvedLight == resolvedDark) } } diff --git a/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift b/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift new file mode 100644 index 0000000000..95a0e0c6a5 --- /dev/null +++ b/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift @@ -0,0 +1,179 @@ +import AppKit +import CodexBarCore +import SwiftUI +import XCTest +@testable import CodexBar + +/// Developer tool, skipped by default: renders the stacked (before) and compact +/// (after) claude-swap multi-account menu layouts to PNGs for documentation. +/// +/// Run with: +/// CODEXBAR_SCREENSHOT_DIR=docs/screenshots swift test --filter MenuLayoutScreenshotRenderTests +@MainActor +final class MenuLayoutScreenshotRenderTests: XCTestCase { + private static let width: CGFloat = 320 + private static let now = Date(timeIntervalSince1970: 1_782_000_000) + + func test_renderMultiAccountLayoutScreenshots() throws { + guard let dir = ProcessInfo.processInfo.environment["CODEXBAR_SCREENSHOT_DIR"] else { + throw XCTSkip("Set CODEXBAR_SCREENSHOT_DIR to render menu layout screenshots.") + } + let directory = URL(fileURLWithPath: dir, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let accounts = Self.screenshotAccounts() + let before = AnyView(Self.stackedPreview(accounts: accounts)) + let after = AnyView(Self.compactPreview(accounts: accounts)) + for (name, view) in [ + ("claude-multi-account-stacked-before", before), + ("claude-multi-account-compact-after", after), + ] { + let data = try XCTUnwrap(Self.pngData(for: view), "render failed for \(name)") + let url = directory.appendingPathComponent("\(name).png") + try data.write(to: url, options: .atomic) + print("Wrote \(url.path)") + } + } + + // MARK: - Fixture + + private static func screenshotAccounts() -> [ProviderAccountUsageSnapshot] { + let list = ClaudeSwapAccountList( + activeAccountNumber: 1, + accounts: [ + self.row(1, "alice@example.com", active: true, session: 4, weekly: 1, fable: 0), + self.row(2, "work@example.com", session: 3, weekly: 0, fable: 0), + self.row(3, "spare@example.com", session: 0, weekly: 0, fable: 0), + self.row(4, "team@example.com", session: 0, weekly: 4, fable: 8), + self.row(5, "burner@example.com", session: 0, weekly: 57, fable: 100), + self.row(6, "backup@example.com", session: 0, weekly: 0, fable: 0), + ]) + return ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now) + } + + private static func row( + _ number: Int, + _ email: String, + active: Bool = false, + session: Double, + weekly: Double, + fable: Double) -> ClaudeSwapAccountRow + { + ClaudeSwapAccountRow( + number: number, + email: email, + isActive: active, + usageStatus: .ok, + fiveHour: ClaudeSwapUsageWindow(usedPercent: session, resetsAt: self.now.addingTimeInterval(4.75 * 3600)), + sevenDay: ClaudeSwapUsageWindow(usedPercent: weekly, resetsAt: self.now.addingTimeInterval(6.9 * 3600)), + scoped: [ + ClaudeSwapScopedUsageWindow( + name: "Fable", + usedPercent: fable, + resetsAt: self.now.addingTimeInterval(6.9 * 3600)), + ]) + } + + private static func cardModel(for account: ProviderAccountUsageSnapshot) -> UsageMenuCardView.Model? { + guard let metadata = ProviderDefaults.metadata[.claude] else { return nil } + return UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: account.snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: account.displayLabel, plan: nil), + planOverride: account.isActive ? L("Active") : L("Switch Account..."), + isRefreshing: false, + lastError: account.error, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: self.now)) + } + + // MARK: - Preview composition + + private static func stackedPreview(accounts: [ProviderAccountUsageSnapshot]) -> some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(accounts.enumerated()), id: \.offset) { index, account in + if let model = self.cardModel(for: account) { + UsageMenuCardView(model: model, width: self.width) + if index < accounts.count - 1 { + Divider().padding(.horizontal, 10) + } + } + } + } + .background(Color(nsColor: .windowBackgroundColor)) + } + + @ViewBuilder + private static func compactPreview(accounts: [ProviderAccountUsageSnapshot]) -> some View { + let plan = AccountMenuLayoutPlanner.plan(accounts: accounts) + let accountsByID = Dictionary(uniqueKeysWithValues: accounts.map { ($0.id, $0) }) + let progressColor = UsageMenuCardView.Model.progressColor(for: .claude) + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(plan.rows.enumerated()), id: \.offset) { index, row in + switch row { + case let .card(accountID): + if let account = accountsByID[accountID], let model = self.cardModel(for: account) { + UsageMenuCardView(model: model, width: self.width) + if index < plan.rows.count - 1 { + Divider().padding(.horizontal, 10) + } + } + case let .compact(compactRow): + MenuCardCompactAccountRowView( + model: MenuCardCompactAccountRowView.Model( + label: compactRow.label, + headroomPercent: compactRow.headroomPercent, + severity: compactRow.severity, + constraintDetail: compactRow.constraintDetail, + hasError: compactRow.hasError, + showsBestBadge: compactRow.isBestCandidate), + progressColor: progressColor, + width: self.width) + case let .collapsedHealthy(count): + MenuCardCollapsedAccountsRowView(count: count, width: self.width) + } + } + } + .background(Color(nsColor: .windowBackgroundColor)) + } + + // MARK: - Rendering + + private static func pngData(for view: AnyView) -> Data? { + let hosting = NSHostingView(rootView: view) + hosting.appearance = NSAppearance(named: .darkAqua) + let size = hosting.fittingSize + guard size.width > 0, size.height > 0 else { return nil } + hosting.frame = CGRect(origin: .zero, size: size) + hosting.layoutSubtreeIfNeeded() + + let scale: CGFloat = 2 + guard let representation = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(size.width * scale), + pixelsHigh: Int(size.height * scale), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0) + else { return nil } + representation.size = size + guard let context = NSGraphicsContext(bitmapImageRep: representation) else { return nil } + hosting.displayIgnoringOpacity(hosting.bounds, in: context) + return representation.representation(using: .png, properties: [:]) + } +} diff --git a/Tests/CodexBarTests/StatusMenuClaudeSwapCompactTests.swift b/Tests/CodexBarTests/StatusMenuClaudeSwapCompactTests.swift new file mode 100644 index 0000000000..e999bb2c20 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuClaudeSwapCompactTests.swift @@ -0,0 +1,174 @@ +import AppKit +import CodexBarCore +import Foundation +import XCTest +@testable import CodexBar + +/// Menu-structure coverage for the compact claude-swap layout: with four or more +/// accounts the active account keeps its card, inactive accounts become compact +/// rows, the healthy tail collapses, and expansion state restores full cards. +@MainActor +final class StatusMenuClaudeSwapCompactTests: XCTestCase { + private func makeController( + accounts: [ProviderAccountUsageSnapshot]) -> (controller: StatusItemController, store: UsageStore) + { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + let settings = testSettingsStore( + suiteName: "StatusMenuClaudeSwapCompactTests", + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.claudeSwapAccountSnapshots = accounts + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + return (controller, store) + } + + private func account( + slot: Int, + email: String, + isActive: Bool = false, + sessionUsed: Double, + weeklyUsed: Double) -> ProviderAccountUsageSnapshot + { + ProviderAccountUsageSnapshot( + id: ProviderAccountIdentity(source: "claude-swap", opaqueID: String(slot)), + provider: .claude, + displayLabel: email, + isActive: isActive, + canActivate: !isActive, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 7 * 24 * 60, + resetsAt: Date().addingTimeInterval(86400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: email, + accountOrganization: nil, + loginMethod: "claude-swap")), + error: nil, + sourceLabel: "claude-swap") + } + + private func sixAccounts() -> [ProviderAccountUsageSnapshot] { + [ + self.account(slot: 1, email: "active@example.com", isActive: true, sessionUsed: 4, weeklyUsed: 1), + self.account(slot: 2, email: "healthy-a@example.com", sessionUsed: 3, weeklyUsed: 5), + self.account(slot: 3, email: "best@example.com", sessionUsed: 0, weeklyUsed: 0), + self.account(slot: 4, email: "healthy-b@example.com", sessionUsed: 8, weeklyUsed: 4), + self.account(slot: 5, email: "constrained@example.com", sessionUsed: 20, weeklyUsed: 97), + self.account(slot: 6, email: "healthy-c@example.com", sessionUsed: 0, weeklyUsed: 2), + ] + } + + private func representedIDs(in menu: NSMenu) -> [String] { + menu.items.compactMap { $0.representedObject as? String } + } + + func test_manyAccountsRenderCompactLayoutRows() { + let (controller, _) = self.makeController(accounts: self.sixAccounts()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .claude) + controller.menuWillOpen(menu) + + let ids = self.representedIDs(in: menu).filter { + $0.hasPrefix("claudeSwap") || $0.hasPrefix("menuCard") + } + XCTAssertEqual(ids, [ + "claudeSwapCard-1", + "claudeSwapCompact-5", + "claudeSwapCompact-3", + "claudeSwapCollapsed", + ]) + } + + func test_expandedAccountRendersFullCard() { + let accounts = self.sixAccounts() + let (controller, _) = self.makeController(accounts: accounts) + defer { controller.releaseStatusItemsForTesting() } + controller.compactAccountExpandedIDs = [accounts[4].id] + + let menu = controller.makeMenu(for: .claude) + controller.menuWillOpen(menu) + + let ids = self.representedIDs(in: menu).filter { $0.hasPrefix("claudeSwap") } + XCTAssertEqual(ids, [ + "claudeSwapCard-1", + "claudeSwapCard-5", + "claudeSwapCompact-3", + "claudeSwapCollapsed", + ]) + } + + func test_expandedHealthyTailShowsAllCompactRows() { + let (controller, _) = self.makeController(accounts: self.sixAccounts()) + defer { controller.releaseStatusItemsForTesting() } + controller.compactAccountExpandedHealthyTailProviders = [.claude] + + let menu = controller.makeMenu(for: .claude) + controller.menuWillOpen(menu) + + let ids = self.representedIDs(in: menu).filter { $0.hasPrefix("claudeSwap") } + XCTAssertEqual(ids, [ + "claudeSwapCard-1", + "claudeSwapCompact-5", + "claudeSwapCompact-4", + "claudeSwapCompact-2", + "claudeSwapCompact-6", + "claudeSwapCompact-3", + ]) + } + + func test_fewAccountsKeepStackedCards() { + let (controller, _) = self.makeController(accounts: Array(self.sixAccounts().prefix(3))) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .claude) + controller.menuWillOpen(menu) + + let ids = self.representedIDs(in: menu).filter { + $0.hasPrefix("claudeSwap") || $0.hasPrefix("menuCard") + } + XCTAssertEqual(ids, ["menuCard-0", "menuCard-1", "menuCard-2"]) + } + + func test_menuCloseResetsExpansionState() { + let accounts = self.sixAccounts() + let (controller, _) = self.makeController(accounts: accounts) + defer { controller.releaseStatusItemsForTesting() } + controller.compactAccountExpandedIDs = [accounts[4].id] + controller.compactAccountExpandedHealthyTailProviders = [.claude] + + let menu = controller.makeMenu(for: .claude) + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + XCTAssertTrue(controller.compactAccountExpandedIDs.isEmpty) + XCTAssertTrue(controller.compactAccountExpandedHealthyTailProviders.isEmpty) + } +} diff --git a/Tests/CodexBarTests/StatusMenuCompactAccountLayoutTests.swift b/Tests/CodexBarTests/StatusMenuCompactAccountLayoutTests.swift new file mode 100644 index 0000000000..a5c25d8abe --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuCompactAccountLayoutTests.swift @@ -0,0 +1,162 @@ +import AppKit +import CodexBarCore +import Foundation +import XCTest +@testable import CodexBar + +/// Coverage for the compact multi-account layout on the token-account and Codex +/// paths (the claude-swap path is covered by StatusMenuClaudeSwapCompactTests). +@MainActor +final class StatusMenuCompactAccountLayoutTests: XCTestCase { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private func makeSettings() -> SettingsStore { + let settings = testSettingsStore( + suiteName: "StatusMenuCompactAccountLayoutTests", + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .copilot) + } + return settings + } + + private func snapshot(usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: nil) + } + + func test_tokenAccountsUseCompactLayoutAtFourOrMoreAccounts() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + for label in ["One", "Two", "Three", "Four", "Five"] { + settings.addTokenAccount(provider: .copilot, label: label, token: "gh_\(label)") + } + settings.setActiveTokenAccountIndex(0, for: .copilot) + let accounts = settings.tokenAccounts(for: .copilot) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let usedPercents: [Double] = [10, 95, 20, 30, 40] + store.accountSnapshots[.copilot] = accounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(usedPercent: usedPercents[index]), + error: nil, + sourceLabel: "test", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .copilot, account: account)) + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + // Active card + critical row + best-candidate row + two healthy rows folded. + let ids = menu.items.compactMap { $0.representedObject as? String } + .filter { $0.hasPrefix("tokenAccount") || $0.hasPrefix("menuCard") } + XCTAssertEqual(ids, [ + "tokenAccountCard-\(accounts[0].id.uuidString)", + "tokenAccountCompact-\(accounts[1].id.uuidString)", + "tokenAccountCompact-\(accounts[2].id.uuidString)", + "tokenAccountCollapsed", + ]) + } + + func test_tokenAccountsBelowThresholdKeepStackedCards() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + for label in ["One", "Two", "Three"] { + settings.addTokenAccount(provider: .copilot, label: label, token: "gh_\(label)") + } + let accounts = settings.tokenAccounts(for: .copilot) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.accountSnapshots[.copilot] = accounts.map { account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(usedPercent: 10), + error: nil, + sourceLabel: "test", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .copilot, account: account)) + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + let ids = menu.items.compactMap { $0.representedObject as? String } + .filter { $0.hasPrefix("tokenAccount") || $0.hasPrefix("menuCard") } + XCTAssertEqual(ids, ["menuCard-0", "menuCard-1", "menuCard-2"]) + } + + func test_codexAccountProjectionMapsActiveHealthAndIdentity() { + let accounts = (1...4).map { index in + CodexVisibleAccount( + id: "account-\(index)", + email: "codex\(index)@example.com", + // account-4: stored account without live auth → "Missing auth" health. + storedAccountID: index == 4 ? UUID() : nil, + selectionSource: .liveSystem, + isActive: index == 2, + isLive: index != 4, + canReauthenticate: false, + canRemove: false) + } + let snapshots = accounts.prefix(3).map { account in + CodexAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(usedPercent: 50), + error: nil, + sourceLabel: "test") + } + let display = CodexAccountMenuDisplay( + accounts: accounts, + snapshots: Array(snapshots), + activeVisibleAccountID: "account-2", + layout: .stacked) + + let projected = StatusItemController.projectedCodexAccounts(display: display) + + XCTAssertEqual(projected.map(\ProviderAccountUsageSnapshot.id.opaqueID), [ + "account-1", "account-2", "account-3", "account-4", + ]) + XCTAssertEqual(projected.map(\ProviderAccountUsageSnapshot.isActive), [false, true, false, false]) + XCTAssertEqual(projected[0].id.source, "codex-account") + XCTAssertEqual(projected[0].displayLabel, "codex1@example.com") + // account-4 has no snapshot: unavailable health surfaces as an error row. + XCTAssertNil(projected[3].snapshot) + XCTAssertNotNil(projected[3].error) + XCTAssertNil(projected[0].error) + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherWarmupTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherWarmupTests.swift new file mode 100644 index 0000000000..0f1adedc87 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherWarmupTests.swift @@ -0,0 +1,83 @@ +import AppKit +import CodexBarCore +import Foundation +import XCTest +@testable import CodexBar + +/// The merged menu pre-builds sibling switcher tabs after opening so a tab +/// switch attaches cached, pre-laid-out rows (flicker fix follow-up). +@MainActor +final class StatusMenuSwitcherWarmupTests: XCTestCase { + private func makeController() -> (controller: StatusItemController, menu: NSMenu) { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + let settings = testSettingsStore( + suiteName: "StatusMenuSwitcherWarmupTests", + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .claude || provider == .codex) + } + + 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: testStatusBar()) + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + return (controller, menu) + } + + func test_warmupCachesSiblingSelections() { + let (controller, menu) = self.makeController() + defer { controller.releaseStatusItemsForTesting() } + guard menu.items.first?.view is ProviderSwitcherView else { + return XCTFail("expected merged menu with provider switcher") + } + + controller.warmMergedSwitcherSiblingContent(in: menu) + + let caches = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] ?? [:] + let enabledProviders = controller.store.enabledProvidersForDisplay() + let cachedSelections = Set(caches.keys) + let siblingProviders = enabledProviders.filter { cachedSelections.contains(.provider($0)) } + // Every non-visible provider tab gets a cache entry; the visible tab is + // cached separately by the populate path. + XCTAssertGreaterThanOrEqual(siblingProviders.count, enabledProviders.count - 1) + for (_, entry) in caches { + XCTAssertFalse(entry.items.isEmpty) + } + } + + func test_warmupSkipsSelectionsAlreadyCached() { + let (controller, menu) = self.makeController() + defer { controller.releaseStatusItemsForTesting() } + + controller.warmMergedSwitcherSiblingContent(in: menu) + let firstItems = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]? + .mapValues { $0.items } + + controller.warmMergedSwitcherSiblingContent(in: menu) + let secondItems = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]? + .mapValues { $0.items } + + // Re-warming with unchanged inputs must reuse the cached items, not rebuild them. + XCTAssertEqual(firstItems?.count, secondItems?.count) + for (selection, items) in firstItems ?? [:] { + XCTAssertTrue(secondItems?[selection]?.elementsEqual(items, by: ===) == true) + } + } +} diff --git a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift index 8d88ac1a38..10c65da829 100644 --- a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift +++ b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift @@ -310,9 +310,17 @@ final class StatusMenuTokenAccountSwitcherTests: XCTestCase { controller.menuWillOpen(menu) XCTAssertNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + // Stale snapshots stay ignored and the 6-account cap still applies before the + // compact plan: capped accounts 0-4 plus the selected account 7. With 8 accounts + // the compact layout pins the active card, keeps the best candidate row visible, + // and folds the remaining healthy accounts. XCTAssertEqual( - self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, - ["menuCard-0", "menuCard-1", "menuCard-2", "menuCard-3", "menuCard-4", "menuCard-5"]) + self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") || $0.hasPrefix("tokenAccount") }, + [ + "tokenAccountCard-\(accounts[7].id.uuidString)", + "tokenAccountCompact-\(accounts[0].id.uuidString)", + "tokenAccountCollapsed", + ]) } func test_multiAccountStackedLayoutRejectsSnapshotsAfterCredentialOrBaseURLChanges() throws { diff --git a/docs/claude.md b/docs/claude.md index 8e90142c62..55d5c5f973 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -134,7 +134,14 @@ The accepted multi-account design in active state, usage status, email (display only), the 5-hour/7-day windows, and optional display-only model-scoped weekly windows from `usage.scoped`. - Display: when claude-swap reports more than one account, the Claude menu and `codexbar cards` show one card per - account (active account first, then numeric slot) instead of ambient/token-account Claude cards. To use this + account (active account first, then numeric slot) instead of ambient/token-account Claude cards. With four or more + accounts the app menu switches to a compact layout (`AccountMenuLayoutPlanner`): the active account keeps its full + card, inactive accounts become one-line rows sorted by remaining headroom (most constrained first, red/amber below + 50%/10% left, a star on the healthiest activatable account), and healthy rows fold behind a "N more accounts ready" + summary row. Clicking a compact row expands that account's full card for the current menu session; the summary row + reveals the hidden rows. `codexbar cards` keeps the full per-account output. The same compact layout applies to + every stacked multi-account list (token accounts on any provider, and flat Codex account lists; workspace-grouped + Codex lists keep their sectioned stacked layout). To use this presentation with one account, enable “Show account card when only one account is available” or set `claudeSwapShowSingleAccount: true` on the Claude provider in the resolved config file (normally `~/.config/codexbar/config.json`; legacy installs may use `~/.codexbar/config.json`). The option defaults off, diff --git a/docs/screenshots/claude-multi-account-compact-after.png b/docs/screenshots/claude-multi-account-compact-after.png new file mode 100644 index 0000000000..2209d31630 Binary files /dev/null and b/docs/screenshots/claude-multi-account-compact-after.png differ diff --git a/docs/screenshots/claude-multi-account-stacked-before.png b/docs/screenshots/claude-multi-account-stacked-before.png new file mode 100644 index 0000000000..22921cde1e Binary files /dev/null and b/docs/screenshots/claude-multi-account-stacked-before.png differ