Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9ac791b
feat(abacus): add Abacus AI provider with cookie-based usage fetching
ChrisGVE Mar 4, 2026
9552ffe
fix(abacus): fix usage display formatting and match Claude pattern
ChrisGVE Mar 4, 2026
957ed3d
feat(abacus): add pace tick and detail lines to card view
ChrisGVE Mar 4, 2026
e316c9d
fix(abacus): use correct API endpoints for credits and billing date
ChrisGVE Mar 4, 2026
74fa5de
fix(abacus): validate session cookies and preserve API errors
ChrisGVE Mar 4, 2026
8451710
Merge branch 'main' into abacus.ai
ChrisGVE Mar 9, 2026
d1462c0
fix(abacus): fix compilation after UsagePaceText API refactor
ChrisGVE Mar 9, 2026
d6a3d98
fix(abacus): fix menu bar metric options and pace indicator
ChrisGVE Mar 9, 2026
b86be58
Merge branch 'steipete:main' into abacus.ai
ChrisGVE Mar 11, 2026
81933b6
Merge branch 'steipete:main' into abacus.ai
ChrisGVE Mar 13, 2026
4e1822b
Merge branch 'steipete:main' into abacus.ai
ChrisGVE Mar 14, 2026
7dde253
Merge branch 'steipete:main' into abacus.ai
ChrisGVE Mar 15, 2026
d7e8c97
docs(abacus): add provider documentation and update provider listings
ChrisGVE Mar 15, 2026
f3d8811
fix(abacus): merge upstream/main and resolve conflicts
ChrisGVE Mar 17, 2026
31102c4
test(abacus): add unit tests for Abacus AI provider
ChrisGVE Mar 17, 2026
031e64e
Merge branch 'steipete:main' into abacus.ai
ChrisGVE Mar 18, 2026
7927408
fix(abacus): merge upstream/main adding Alibaba Coding Plan provider
ChrisGVE Mar 19, 2026
ea2d128
Merge branch 'steipete:main' into abacus.ai
ChrisGVE Mar 20, 2026
3acf8ea
Merge branch 'steipete:main' into abacus.ai
ChrisGVE Mar 22, 2026
ff6b76f
Merge branch 'steipete:main' into abacus.ai
ChrisGVE Mar 25, 2026
eb834c3
Merge branch 'upstream/main' into abacus.ai
ChrisGVE Mar 28, 2026
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: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CodexBar 🎚️ - May your tokens never run out.

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, OpenRouter, and Perplexity 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.
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, OpenRouter, Perplexity, and Abacus AI 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="codexbar.png" alt="CodexBar menu screenshot" width="520" />

Expand Down Expand Up @@ -47,6 +47,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex
- [Amp](docs/amp.md) — Browser cookie-based authentication with Amp Free usage tracking.
- [JetBrains AI](docs/jetbrains.md) — Local XML-based quota from JetBrains IDE configuration; monthly credits tracking.
- [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers.
- [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking.
- Open to new providers: [provider authoring guide](docs/provider.md).

## Icon & Screenshot
Expand Down
31 changes: 27 additions & 4 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,29 @@ extension UsageMenuCardView.Model {
if input.provider == .warp || input.provider == .kilo, primary.resetsAt == nil {
primaryResetText = nil
}
// Abacus: show credits as detail, compute pace on the primary monthly window
var primaryDetailLeft: String?
var primaryDetailRight: String?
var primaryPacePercent: Double?
var primaryPaceOnTop = true
if input.provider == .abacus {
if let detail = primary.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
primaryDetailText = detail
}
if let paceDetail = Self.weeklyPaceDetail(
window: primary,
now: input.now,
pace: input.weeklyPace,
showUsed: input.usageBarsShowUsed)
{
primaryDetailLeft = paceDetail.leftLabel
primaryDetailRight = paceDetail.rightLabel
primaryPacePercent = paceDetail.pacePercent
primaryPaceOnTop = paceDetail.paceOnTop
}
}
metrics.append(Metric(
id: "primary",
title: input.metadata.sessionLabel,
Expand All @@ -972,10 +995,10 @@ extension UsageMenuCardView.Model {
percentStyle: percentStyle,
resetText: primaryResetText,
detailText: primaryDetailText,
detailLeftText: nil,
detailRightText: nil,
pacePercent: nil,
paceOnTop: true))
detailLeftText: primaryDetailLeft,
detailRightText: primaryDetailRight,
pacePercent: primaryPacePercent,
paceOnTop: primaryPaceOnTop))
}
if let weekly = snapshot.secondary {
let paceDetail = Self.weeklyPaceDetail(
Expand Down
14 changes: 10 additions & 4 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,9 @@ struct MenuDescriptor {
if let snap = store.snapshot(for: provider) {
let resetStyle = settings.resetTimeDisplayStyle
if let primary = snap.primary {
let primaryWindow = if provider == .warp || provider == .kilo {
// Warp/Kilo primary uses resetDescription for non-reset detail (e.g., "Unlimited", "X/Y credits").
// Avoid rendering it as a "Resets ..." line.
let primaryWindow = if provider == .warp || provider == .kilo || provider == .abacus {
// Warp/Kilo/Abacus primary uses resetDescription for non-reset detail
// (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line.
RateWindow(
usedPercent: primary.usedPercent,
windowMinutes: primary.windowMinutes,
Expand All @@ -135,12 +135,18 @@ struct MenuDescriptor {
window: primaryWindow,
resetStyle: resetStyle,
showUsed: settings.usageBarsShowUsed)
if provider == .warp || provider == .kilo,
if provider == .warp || provider == .kilo || provider == .abacus,
let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
!detail.isEmpty
{
entries.append(.text(detail, .secondary))
}
if provider == .abacus,
let pace = store.weeklyPace(provider: provider, window: primary)
{
let paceSummary = UsagePaceText.weeklySummary(pace: pace)
entries.append(.text(paceSummary, .secondary))
}
}
if let weekly = snap.secondary {
let weeklyResetOverride: String? = {
Expand Down
12 changes: 11 additions & 1 deletion Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,14 @@ struct ProvidersPane: View {
id: MenuBarMetricPreference.primary.rawValue,
title: "Primary (API key limit)"),
]
} else if provider == .abacus {
let metadata = self.store.metadata(for: provider)
options = [
ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"),
ProviderSettingsPickerOption(
id: MenuBarMetricPreference.primary.rawValue,
title: "Primary (\(metadata.sessionLabel))"),
]
} else {
let metadata = self.store.metadata(for: provider)
let snapshot = self.store.snapshot(for: provider)
Expand Down Expand Up @@ -351,7 +359,9 @@ struct ProvidersPane: View {
}

let now = Date()
let weeklyPace = snapshot?.secondary.flatMap { window in
// Abacus uses primary for monthly credits (no secondary window)
let paceWindow = provider == .abacus ? snapshot?.primary : snapshot?.secondary
let weeklyPace = paceWindow.flatMap { window in
self.store.weeklyPace(provider: provider, window: window, now: now)
}
let input = UsageMenuCardView.Model.Input(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import CodexBarCore
import CodexBarMacroSupport
import Foundation
import SwiftUI

@ProviderImplementationRegistration
struct AbacusProviderImplementation: ProviderImplementation {
let id: UsageProvider = .abacus

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.abacusCookieSource
_ = settings.abacusCookieHeader
}

@MainActor
func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
.abacus(context.settings.abacusSettingsSnapshot(tokenOverride: context.tokenOverride))
}

@MainActor
func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool {
guard support.requiresManualCookieSource else { return true }
if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true }
return context.settings.abacusCookieSource == .manual
}

@MainActor
func applyTokenAccountCookieSource(settings: SettingsStore) {
if settings.abacusCookieSource != .manual {
settings.abacusCookieSource = .manual
}
}

@MainActor
func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
let cookieBinding = Binding(
get: { context.settings.abacusCookieSource.rawValue },
set: { raw in
context.settings.abacusCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
})
let cookieOptions = ProviderCookieSourceUI.options(
allowsOff: false,
keychainDisabled: context.settings.debugDisableKeychainAccess)

let cookieSubtitle: () -> String? = {
ProviderCookieSourceUI.subtitle(
source: context.settings.abacusCookieSource,
keychainDisabled: context.settings.debugDisableKeychainAccess,
auto: "Automatic imports browser cookies.",
manual: "Paste a Cookie header or cURL capture from the Abacus AI dashboard.",
off: "Abacus AI cookies are disabled.")
}

return [
ProviderSettingsPickerDescriptor(
id: "abacus-cookie-source",
title: "Cookie source",
subtitle: "Automatic imports browser cookies.",
dynamicSubtitle: cookieSubtitle,
binding: cookieBinding,
options: cookieOptions,
isVisible: nil,
onChange: nil,
trailingText: {
guard let entry = CookieHeaderCache.load(provider: .abacus) else { return nil }
let when = entry.storedAt.relativeDescription()
return "Cached: \(entry.sourceLabel) • \(when)"
}),
]
}

@MainActor
func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[]
}
}
61 changes: 61 additions & 0 deletions Sources/CodexBar/Providers/Abacus/AbacusSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import CodexBarCore
import Foundation

extension SettingsStore {
var abacusCookieHeader: String {
get { self.configSnapshot.providerConfig(for: .abacus)?.sanitizedCookieHeader ?? "" }
set {
self.updateProviderConfig(provider: .abacus) { entry in
entry.cookieHeader = self.normalizedConfigValue(newValue)
}
self.logSecretUpdate(provider: .abacus, field: "cookieHeader", value: newValue)
}
}

var abacusCookieSource: ProviderCookieSource {
get { self.resolvedCookieSource(provider: .abacus, fallback: .auto) }
set {
self.updateProviderConfig(provider: .abacus) { entry in
entry.cookieSource = newValue
}
self.logProviderModeChange(provider: .abacus, field: "cookieSource", value: newValue.rawValue)
}
}
}

extension SettingsStore {
func abacusSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot
.AbacusProviderSettings {
ProviderSettingsSnapshot.AbacusProviderSettings(
cookieSource: self.abacusSnapshotCookieSource(tokenOverride: tokenOverride),
manualCookieHeader: self.abacusSnapshotCookieHeader(tokenOverride: tokenOverride))
}

private func abacusSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String {
let fallback = self.abacusCookieHeader
guard let support = TokenAccountSupportCatalog.support(for: .abacus),
case .cookieHeader = support.injection
else {
return fallback
}
guard let account = ProviderTokenAccountSelection.selectedAccount(
provider: .abacus,
settings: self,
override: tokenOverride)
else {
return fallback
}
return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support)
}

private func abacusSnapshotCookieSource(tokenOverride _: TokenAccountOverride?) -> ProviderCookieSource {
let fallback = self.abacusCookieSource
guard let support = TokenAccountSupportCatalog.support(for: .abacus),
support.requiresManualCookieSource
else {
return fallback
}
if self.tokenAccounts(for: .abacus).isEmpty { return fallback }
return .manual
}
}
Loading