Skip to content
Open
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: 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 Mistral 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.
- [Mistral](docs/providers.md#mistral) — AI Studio billing via cookies with public API-key fallback for model access.
- Open to new providers: [provider authoring guide](docs/provider.md).

## Icon & Screenshot
Expand Down
126 changes: 109 additions & 17 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ struct UsageMenuCardView: View {
let title: String
let percentUsed: Double
let spendLine: String
let showsProgress: Bool
let trailingText: String?
}

let provider: UsageProvider
Expand Down Expand Up @@ -330,17 +332,21 @@ private struct ProviderCostContent: View {
Text(self.section.title)
.font(.body)
.fontWeight(.medium)
UsageProgressBar(
percent: self.section.percentUsed,
tint: self.progressColor,
accessibilityLabel: "Extra usage spent")
if self.section.showsProgress {
UsageProgressBar(
percent: self.section.percentUsed,
tint: self.progressColor,
accessibilityLabel: "Extra usage spent")
}
HStack(alignment: .firstTextBaseline) {
Text(self.section.spendLine)
.font(.footnote)
Spacer()
Text(String(format: "%.0f%% used", min(100, max(0, self.section.percentUsed))))
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
if let trailingText = self.section.trailingText {
Spacer()
Text(trailingText)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
}
}
}
}
Expand Down Expand Up @@ -785,6 +791,18 @@ extension UsageMenuCardView.Model {
return notes
}

if input.provider == .mistral {
guard let snapshot = input.snapshot else { return [] }
if let summary = snapshot.mistralUsage {
return Self.mistralUsageNotes(summary: summary, snapshot: snapshot)
}
guard snapshot.primary == nil, snapshot.secondary == nil else { return [] }
return [
"Connected to the public Mistral API",
"Usage and billing totals are currently only available in Mistral AI Studio",
]
}

guard input.provider == .openrouter,
let openRouter = input.snapshot?.openRouterUsage
else {
Expand Down Expand Up @@ -819,6 +837,9 @@ extension UsageMenuCardView.Model {
account: AccountInfo,
metadata: ProviderMetadata) -> String?
{
if provider == .mistral, snapshot?.mistralUsage != nil {
return nil
}
if provider == .kilo {
guard let pass = self.kiloLoginPass(snapshot: snapshot) else {
return nil
Expand Down Expand Up @@ -941,7 +962,9 @@ extension UsageMenuCardView.Model {
let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit)
let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit)
let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot)
if let primary = snapshot.primary {
if let primary = snapshot.primary,
Self.shouldRenderPrimaryMetric(provider: input.provider, snapshot: snapshot)
{
var primaryDetailText: String? = input.provider == .zai ? zaiTokenDetail : nil
var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now)
if input.provider == .openrouter,
Expand Down Expand Up @@ -1267,29 +1290,98 @@ extension UsageMenuCardView.Model {
cost: ProviderCostSnapshot?) -> ProviderCostSection?
{
guard let cost else { return nil }
guard cost.limit > 0 else { return nil }

let used: String
let limit: String
let title: String
let trailingText: String?
let showsProgress: Bool

if cost.currencyCode == "Quota" {
if cost.limit <= 0 {
guard provider == .mistral else { return nil }
title = "Billing"
used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
trailingText = nil
showsProgress = false
} else if cost.currencyCode == "Quota" {
title = "Quota usage"
used = String(format: "%.0f", cost.used)
limit = String(format: "%.0f", cost.limit)
let limit = String(format: "%.0f", cost.limit)
trailingText = "\(String(format: "%.0f%% used", Self.clamped((cost.used / cost.limit) * 100)))"
showsProgress = true
let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
let periodLabel = cost.period ?? "This month"

return ProviderCostSection(
title: title,
percentUsed: percentUsed,
spendLine: "\(periodLabel): \(used) / \(limit)",
showsProgress: showsProgress,
trailingText: trailingText)
} else {
title = "Extra usage"
used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
trailingText = "\(String(format: "%.0f%% used", Self.clamped((cost.used / cost.limit) * 100)))"
showsProgress = true
let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
let periodLabel = cost.period ?? "This month"

return ProviderCostSection(
title: title,
percentUsed: percentUsed,
spendLine: "\(periodLabel): \(used) / \(limit)",
showsProgress: showsProgress,
trailingText: trailingText)
}

let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
let periodLabel = cost.period ?? "This month"

return ProviderCostSection(
title: title,
percentUsed: percentUsed,
spendLine: "\(periodLabel): \(used) / \(limit)")
percentUsed: 0,
spendLine: "\(periodLabel): \(used)",
showsProgress: false,
trailingText: nil)
}

private static func shouldRenderPrimaryMetric(provider: UsageProvider, snapshot: UsageSnapshot) -> Bool {
guard provider == .mistral, snapshot.mistralUsage != nil, let primary = snapshot.primary else {
return true
}
guard snapshot.secondary == nil else { return true }
guard let providerCost = snapshot.providerCost, providerCost.limit <= 0 else { return true }
return primary.usedPercent > 0
}

private static func mistralUsageNotes(summary: MistralUsageSummarySnapshot, snapshot: UsageSnapshot) -> [String] {
var notes: [String] = []
switch summary.sourceKind {
case .web:
if let tokenLine = summary.tokenSummaryLine, !tokenLine.isEmpty {
notes.append(tokenLine)
}
if let workspaceLine = summary.workspaceLine, !workspaceLine.isEmpty {
notes.append(workspaceLine)
}
if let modelsLine = summary.modelsLine, !modelsLine.isEmpty {
notes.append(modelsLine)
}
case .api:
notes.append("API fallback active")
if let modelsLine = summary.modelsLine, !modelsLine.isEmpty {
notes.append(modelsLine)
}
if let preview = summary.previewModelNames, !preview.isEmpty {
notes.append(preview)
}
if let workspaceLine = summary.workspaceLine, !workspaceLine.isEmpty {
notes.append(workspaceLine)
}
if snapshot.primary == nil, snapshot.secondary == nil {
notes.append("Sign into Mistral AI Studio in Chrome to unlock billing totals automatically")
}
}
return notes
}

private static func clamped(_ value: Double) -> Double {
Expand Down
24 changes: 14 additions & 10 deletions Sources/CodexBar/PreferencesProviderDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -537,21 +537,25 @@ private struct ProviderMetricInlineCostRow: View {
.frame(width: self.labelWidth, alignment: .leading)

VStack(alignment: .leading, spacing: 4) {
UsageProgressBar(
percent: self.section.percentUsed,
tint: self.progressColor,
accessibilityLabel: "Usage used")
.frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity)
if self.section.showsProgress {
UsageProgressBar(
percent: self.section.percentUsed,
tint: self.progressColor,
accessibilityLabel: "Usage used")
.frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity)
}

HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(String(format: "%.0f%% used", self.section.percentUsed))
.font(.footnote)
.foregroundStyle(.secondary)
.monospacedDigit()
Spacer(minLength: 8)
Text(self.section.spendLine)
.font(.footnote)
.foregroundStyle(.secondary)
if let trailingText = self.section.trailingText {
Spacer(minLength: 8)
Text(trailingText)
.font(.footnote)
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
}

Expand Down
15 changes: 15 additions & 0 deletions Sources/CodexBar/PreferencesProviderSettingsRows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,21 @@ struct ProviderSettingsPickerRowView: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}

let actions = self.picker.actions.filter { $0.isVisible?() ?? true }
if !actions.isEmpty {
HStack(spacing: 10) {
ForEach(actions) { action in
Button(action.title) {
Task { @MainActor in
await action.perform()
}
}
.applyProviderSettingsButtonStyle(action.style)
.controlSize(.small)
}
}
}
}
.disabled(!isEnabled)
.onChange(of: self.picker.binding.wrappedValue) { _, selection in
Expand Down
Loading