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.
-
+
> ![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