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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<img src="docs/codexbar.png" alt="CodexBar menu screenshot" width="520" />
<img src="docs/codexbar.png" alt="CodexBar — every AI coding limit in your menu bar. 66 providers." width="520" />

> ![IMPORTANT]
> This is a FORKED project. I'll tell you why I did that below.
Expand Down Expand Up @@ -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.
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"))
}
}
6 changes: 6 additions & 0 deletions Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down Expand Up @@ -403,6 +404,8 @@ extension StatusItemController {
return false
}

// swiftlint:enable function_body_length

private func applyStoredUnifiedMenuBarLayoutIfNeeded(
provider: UsageProvider,
snapshot: UsageSnapshot?,
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Expand Down
65 changes: 47 additions & 18 deletions Sources/CodexBar/StatusItemController+ClaudeSwapMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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")
Expand Down
Loading