diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ff90fbe3..3ac849dfa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 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. + ### Changed - About: link the Website entry to codex.bar. 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+ClaudeSwapMenu.swift b/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift index 6d2cc8fb87..4c3ecaa637 100644 --- a/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift +++ b/Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift @@ -7,25 +7,100 @@ extension StatusItemController { captureMenu: NSMenu, context: MenuCardContext) { - let cardRows = self.store.claudeSwapAccountSnapshots.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 + let accounts = self.store.claudeSwapAccountSnapshots + let plan = AccountMenuLayoutPlanner.plan( + accounts: accounts, + expandedAccountIDs: self.claudeSwapExpandedAccountIDs, + healthyTailExpanded: self.claudeSwapHealthyTailExpanded) + guard plan.usesCompactLayout else { + self.addStackedClaudeSwapMenuCards(accounts: accounts, to: menu, captureMenu: captureMenu, context: context) + return + } + + let accountsByID = Dictionary(uniqueKeysWithValues: accounts.map { ($0.id, $0) }) + let progressColor = UsageMenuCardView.Model.progressColor(for: .claude) + var previousRowWasCard = false + for (index, row) in plan.rows.enumerated() { + switch row { + case let .card(accountID): + guard let account = accountsByID[accountID], + let model = self.claudeSwapCardModel(for: account) else { continue } + if index > 0 { + menu.addItem(.separator()) + } + let collapseClick: (() -> Void)? = account.isActive ? nil : { [weak self, weak captureMenu] in + self?.toggleClaudeSwapAccountExpansion(accountID, menu: captureMenu) + } + menu.addItem(self.makeMenuCardItem( + UsageMenuCardView( + model: model, + width: context.menuWidth, + planAction: self.claudeSwapAccountSwitchAction(account, menu: captureMenu)), + id: "claudeSwapCard-\(accountID.opaqueID)", + width: context.menuWidth, + heightCacheScope: "claude-swap-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: "claudeSwapCompact-\(accountID.opaqueID)", + width: context.menuWidth, + heightCacheScope: "claude-swap-compact-\(accountID.opaqueID)", + heightCacheFingerprint: rowModel.heightFingerprint, + onClick: { [weak self, weak captureMenu] in + self?.toggleClaudeSwapAccountExpansion(accountID, menu: captureMenu) + })) + previousRowWasCard = false + case let .collapsedHealthy(count): + let view = MenuCardCollapsedAccountsRowView(count: count, width: context.menuWidth) + menu.addItem(self.makeMenuCardItem( + view, + id: "claudeSwapCollapsed", + width: context.menuWidth, + heightCacheScope: "claude-swap-collapsed", + heightCacheFingerprint: "collapsed-\(count)", + onClick: { [weak self, weak captureMenu] in + self?.expandClaudeSwapHealthyTail(menu: captureMenu) + })) + previousRowWasCard = false } + } + if !plan.rows.isEmpty { + menu.addItem(.separator()) + } + if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) { + menu.addItem(.separator()) + } + } + + 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.claudeSwapCardModel(for: account) else { return nil } return (account, model) } self.addStackedMenuCards( @@ -38,6 +113,46 @@ 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 toggleClaudeSwapAccountExpansion(_ accountID: ProviderAccountIdentity, menu: NSMenu?) { + self.advanceMenuInteraction(for: menu) + if self.claudeSwapExpandedAccountIDs.contains(accountID) { + self.claudeSwapExpandedAccountIDs.remove(accountID) + } else { + self.claudeSwapExpandedAccountIDs.insert(accountID) + } + self.invalidateMenus(refreshOpenMenus: true) + } + + private func expandClaudeSwapHealthyTail(menu: NSMenu?) { + self.advanceMenuInteraction(for: menu) + self.claudeSwapHealthyTailExpanded = true + self.invalidateMenus(refreshOpenMenus: true) + } + + /// Compact-layout expansion is per-open transient UI state; reset when the last menu closes. + func resetClaudeSwapMenuExpansionStateIfIdle() { + guard self.openMenus.isEmpty else { return } + self.claudeSwapExpandedAccountIDs.removeAll() + self.claudeSwapHealthyTailExpanded = false + } + private func claudeSwapAccountActionLabel(_ account: ProviderAccountUsageSnapshot) -> String? { if account.isActive { return L("Active") diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index ec361edf8b..1e617c5189 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -159,6 +159,7 @@ extension StatusItemController { if wasHostedSubviewMenu { self.refreshOpenMenusAfterHostedSubviewClose() } + self.resetClaudeSwapMenuExpansionStateIfIdle() } func forgetClosedMenu(_ menu: NSMenu) { diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index f511ace591..ca168a51e4 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -274,6 +274,10 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var lastCodexAccountMenuDisplay: CodexAccountMenuDisplay? /// Tracks the visible token account switcher contents for merged-menu smart updates. var lastTokenAccountMenuDisplay: TokenAccountMenuDisplay? + /// Claude-swap compact layout: accounts the user expanded to full cards this menu session. + var claudeSwapExpandedAccountIDs: Set = [] + /// Claude-swap compact layout: whether the collapsed healthy tail is revealed this menu session. + var claudeSwapHealthyTailExpanded = false /// 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..ca449617e7 --- /dev/null +++ b/Sources/CodexBarCore/AccountMenuLayoutPlanner.swift @@ -0,0 +1,182 @@ +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 [] } + var windows: [(label: String, remainingPercent: Double)] = [] + if let primary = snapshot.primary, !primary.isSyntheticPlaceholder { + windows.append(("Session", primary.remainingPercent)) + } + if let secondary = snapshot.secondary { + windows.append(("Weekly", secondary.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/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..e8cb9e6092 --- /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.claudeSwapExpandedAccountIDs = [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.claudeSwapHealthyTailExpanded = true + + 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.claudeSwapExpandedAccountIDs = [accounts[4].id] + controller.claudeSwapHealthyTailExpanded = true + + let menu = controller.makeMenu(for: .claude) + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + XCTAssertTrue(controller.claudeSwapExpandedAccountIDs.isEmpty) + XCTAssertFalse(controller.claudeSwapHealthyTailExpanded) + } +} diff --git a/docs/claude.md b/docs/claude.md index 8e90142c62..d80fc0fe58 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -134,7 +134,12 @@ 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. 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