Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ad95010
feat(abacus): add Abacus AI provider with cookie-based usage fetching
ChrisGVE Mar 4, 2026
7864ba8
fix(abacus): fix usage display formatting and match Claude pattern
ChrisGVE Mar 4, 2026
64ba5a7
feat(abacus): add pace tick and detail lines to card view
ChrisGVE Mar 4, 2026
1f29cf8
fix(abacus): use correct API endpoints for credits and billing date
ChrisGVE Mar 4, 2026
eb0f559
fix(abacus): validate session cookies and preserve API errors
ChrisGVE Mar 4, 2026
32f3e05
fix(abacus): fix compilation after UsagePaceText API refactor
ChrisGVE Mar 9, 2026
714781c
fix(abacus): fix menu bar metric options and pace indicator
ChrisGVE Mar 9, 2026
e57f11a
docs(abacus): add provider documentation and update provider listings
ChrisGVE Mar 15, 2026
c348a64
test(abacus): add unit tests for Abacus AI provider
ChrisGVE Mar 17, 2026
b90ee1e
fix(abacus): hoist paceWindow binding out of if-expression
ChrisGVE Apr 10, 2026
df742ac
fix(abacus): tighten session cookie matching to avoid false positives
ChrisGVE Apr 12, 2026
878c338
fix(abacus): add manual cookie header field to settings UI
ChrisGVE Apr 12, 2026
63e51c0
fix(abacus): route cookie reads through BrowserCookieAccessGate
ChrisGVE Apr 13, 2026
9020793
fix(abacus): suppress reset line when billing date unavailable
ChrisGVE Apr 13, 2026
d29f5dd
fix(abacus): remove CSRF from session cookies, propagate billing auth…
ChrisGVE Apr 13, 2026
8230a1a
fix(abacus): tighten cookie matching and retry on auth failure
ChrisGVE Apr 13, 2026
3990a03
refactor(abacus): comprehensive provider overhaul addressing review f…
ChrisGVE Apr 13, 2026
b385e4d
fix(abacus): derive pace window from actual billing cycle length
ChrisGVE Apr 13, 2026
f7e86a5
fix(abacus): require both credit fields and fix menu bar pace display
ChrisGVE Apr 13, 2026
0879072
fix(abacus): restrict primary-window pace fallback to Abacus only
ChrisGVE Apr 13, 2026
8ad450f
fix(abacus): correct dashboard URL in manual cookie settings action
ChrisGVE Apr 13, 2026
1aae2bd
fix(abacus): default cookie import to Chrome-only per AGENTS.md
ChrisGVE Apr 13, 2026
82b4ffe
fix(abacus): comprehensive quality pass from multi-agent review
ChrisGVE Apr 13, 2026
a87fc94
fix(abacus): classify unauthorized JSON errors as auth failures
ChrisGVE Apr 13, 2026
0c8324f
fix(abacus): Chrome-first cookie import with multi-browser fallback
ChrisGVE Apr 14, 2026
551bb24
fix(abacus): fall back to all browsers after Chrome auth exhaustion
ChrisGVE Apr 14, 2026
718e76b
fix(abacus): register in TokenAccountSupportCatalog and bound billing…
ChrisGVE Apr 14, 2026
6fea294
Merge branch 'steipete:main' into abacus.ai
ChrisGVE Apr 14, 2026
632f395
Merge branch 'steipete:main' into abacus.ai
ChrisGVE Apr 15, 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
36 changes: 32 additions & 4 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,34 @@ 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
Comment thread
ChrisGVE marked this conversation as resolved.
}
if primary.resetsAt == nil {
primaryResetText = nil
}
if let pace = input.weeklyPace {
let paceDetail = Self.weeklyPaceDetail(
window: primary,
now: input.now,
pace: pace,
showUsed: input.usageBarsShowUsed)
if let paceDetail {
primaryDetailLeft = paceDetail.leftLabel
primaryDetailRight = paceDetail.rightLabel
primaryPacePercent = paceDetail.pacePercent
primaryPaceOnTop = paceDetail.paceOnTop
}
}
}
return Metric(
id: "primary",
title: input.metadata.sessionLabel,
Expand All @@ -1065,10 +1093,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)
}

private static func secondaryMetric(
Expand Down
14 changes: 10 additions & 4 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,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 @@ -163,12 +163,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 @@ -452,6 +452,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 @@ -535,12 +543,14 @@ struct ProvidersPane: View {
tokenError = nil
}

// Abacus uses primary for monthly credits (no secondary window)
let paceWindow = provider == .abacus ? snapshot?.primary : snapshot?.secondary
let weeklyPace = if let codexProjection,
let weekly = codexProjection.rateWindow(for: .weekly)
{
self.store.weeklyPace(provider: provider, window: weekly, now: now)
} else {
snapshot?.secondary.flatMap { window in
paceWindow.flatMap { window in
self.store.weeklyPace(provider: provider, window: window, now: now)
}
}
Expand Down
100 changes: 100 additions & 0 deletions Sources/CodexBar/Providers/Abacus/AbacusProviderImplementation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import AppKit
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] {
[
ProviderSettingsFieldDescriptor(
id: "abacus-cookie",
title: "",
subtitle: "",
kind: .secure,
placeholder: "Cookie: \u{2026}\n\nor paste a cURL capture from the Abacus AI dashboard",
binding: context.stringBinding(\.abacusCookieHeader),
actions: [
ProviderSettingsActionDescriptor(
id: "abacus-open-dashboard",
title: "Open Dashboard",
style: .link,
isVisible: nil,
perform: {
if let url = URL(string: "https://apps.abacus.ai/chatllm/admin/compute-points-usage") {
NSWorkspace.shared.open(url)
}
}),
],
isVisible: { context.settings.abacusCookieSource == .manual },
onActivate: nil),
]
}
}
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
Comment thread
ChrisGVE marked this conversation as resolved.
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ enum ProviderImplementationRegistry {
case .openrouter: OpenRouterProviderImplementation()
case .warp: WarpProviderImplementation()
case .perplexity: PerplexityProviderImplementation()
case .abacus: AbacusProviderImplementation()
}
}

Expand Down
18 changes: 18 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-abacus.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,10 @@ extension StatusItemController {
case .percent:
pace = nil
case .pace, .both:
let weeklyWindow = codexProjection?.rateWindow(for: .weekly) ?? snapshot?.secondary
let weeklyWindow = codexProjection?.rateWindow(for: .weekly)
?? snapshot?.secondary
// Abacus has no secondary window; pace is computed on primary monthly credits
?? (provider == .abacus ? snapshot?.primary : nil)
pace = weeklyWindow.flatMap { window in
self.store.weeklyPace(provider: provider, window: window, now: now)
}
Expand Down
4 changes: 3 additions & 1 deletion Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1339,12 +1339,14 @@ extension StatusItemController {

let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil
let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto
// Abacus uses primary for monthly credits (no secondary window)
let paceWindow = target == .abacus ? snapshot?.primary : snapshot?.secondary
let weeklyPace = if let codexProjection,
Comment thread
ChrisGVE marked this conversation as resolved.
let weekly = codexProjection.rateWindow(for: .weekly)
{
self.store.weeklyPace(provider: target, window: weekly, now: now)
} else {
snapshot?.secondary.flatMap { window in
paceWindow.flatMap { window in
self.store.weeklyPace(provider: target, window: window, now: now)
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/UsageStore+HistoricalPace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Foundation
extension UsageStore {
func supportsWeeklyPace(for provider: UsageProvider) -> Bool {
switch provider {
case .codex, .claude, .opencode:
case .codex, .claude, .opencode, .abacus:
true
default:
false
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -876,7 +876,7 @@ extension UsageStore {
let source = resolution?.source.rawValue ?? "none"
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
.kimik2, .jetbrains, .perplexity:
.kimik2, .jetbrains, .perplexity, .abacus:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
Expand Down
13 changes: 11 additions & 2 deletions Sources/CodexBarCLI/TokenAccountCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ struct TokenAccountCLIContext {
perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
cookieSource: cookieSource,
manualCookieHeader: cookieHeader))
case .abacus:
let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config)
let cookieSource = self.cookieSource(provider: provider, account: account, config: config)
return self.makeSnapshot(
abacus: ProviderSettingsSnapshot.AbacusProviderSettings(
cookieSource: cookieSource,
manualCookieHeader: cookieHeader))
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
return nil
}
Expand All @@ -207,7 +214,8 @@ struct TokenAccountCLIContext {
amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil,
ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil,
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil,
perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot
perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil,
abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot.make(
codex: codex,
Expand All @@ -225,7 +233,8 @@ struct TokenAccountCLIContext {
amp: amp,
ollama: ollama,
jetbrains: jetbrains,
perplexity: perplexity)
perplexity: perplexity,
abacus: abacus)
}

private func makeCodexSettingsSnapshot(account: ProviderTokenAccount?) ->
Expand Down
Loading