Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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