Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
159 changes: 159 additions & 0 deletions Sources/CodexBar/MenuCardCompactAccountRow.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
}
151 changes: 133 additions & 18 deletions Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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")
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ extension StatusItemController {
if wasHostedSubviewMenu {
self.refreshOpenMenusAfterHostedSubviewClose()
}
self.resetClaudeSwapMenuExpansionStateIfIdle()
}

func forgetClosedMenu(_ menu: NSMenu) {
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderAccountIdentity> = []
/// 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]]
= [:]
Expand Down
Loading