diff --git a/CLIProxyAPI b/CLIProxyAPI
new file mode 160000
index 000000000..7e9d0db6a
--- /dev/null
+++ b/CLIProxyAPI
@@ -0,0 +1 @@
+Subproject commit 7e9d0db6aac21734d84038f843336306246ecadd
diff --git a/Package.swift b/Package.swift
index 83cbfca65..28401e8f2 100644
--- a/Package.swift
+++ b/Package.swift
@@ -13,6 +13,7 @@ let sweetCookieKitDependency: Package.Dependency =
let package = Package(
name: "CodexBar",
+ defaultLocalization: "en",
platforms: [
.macOS(.v14),
],
@@ -33,6 +34,9 @@ let package = Package(
.product(name: "Logging", package: "swift-log"),
.product(name: "SweetCookieKit", package: "SweetCookieKit"),
],
+ resources: [
+ .process("Resources"),
+ ],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
diff --git a/README.md b/README.md
index 757fbf030..ca4bdd080 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, and JetBrains AI limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar.
+Now includes first-class CLIProxyAPI usage paths, plus app language options (System / English / 简体中文).
+
## Install
@@ -28,14 +30,18 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex
- Open Settings → Providers and enable what you use.
- Install/sign in to the provider sources you rely on (e.g. `codex`, `claude`, `gemini`, browser cookies, or OAuth; Antigravity requires the Antigravity app running).
- Optional: Settings → Providers → Codex → OpenAI cookies (Automatic or Manual) to add dashboard extras.
+- Optional: Settings → General → CLIProxyAPI to set base URL, management key, and optional `auth_index`.
## Providers
- [Codex](docs/codex.md) — Local Codex CLI RPC (+ PTY fallback) and optional OpenAI web dashboard extras.
+- [CLIProxy Codex](docs/codex.md) — Codex quota via CLIProxyAPI management endpoints, with multi-auth aggregation + per-auth drill-down.
- [Claude](docs/claude.md) — OAuth API or browser cookies (+ CLI PTY fallback); session + weekly usage.
- [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets.
- [Gemini](docs/gemini.md) — OAuth-backed quota API using Gemini CLI credentials (no browser cookies).
+- CLIProxy Gemini — Gemini quota via CLIProxyAPI (`gemini` auth entries).
- [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth.
+- CLIProxy Antigravity — Antigravity quota via CLIProxyAPI (`antigravity` auth entries).
- [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing.
- [Copilot](docs/copilot.md) — GitHub device flow + Copilot internal usage API.
- [z.ai](docs/zai.md) — API token (Keychain) for quota + MCP windows.
@@ -58,12 +64,14 @@ The menu bar icon is a tiny two-bar meter:
- Multi-provider menu bar with per-provider toggles (Settings → Providers).
- Session + weekly meters with reset countdowns.
- Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history).
+- CLIProxyAPI integration for Codex/Gemini/Antigravity with multi-auth support.
- Local cost-usage scan for Codex + Claude (last 30 days).
- Provider status polling with incident badges in the menu and icon overlay.
- Merge Icons mode to combine providers into one status item + switcher.
- Refresh cadence presets (manual, 1m, 2m, 5m, 15m).
- Bundled CLI (`codexbar`) for scripts and CI (including `codexbar cost --provider codex|claude` for local cost usage); Linux CLI builds available.
- WidgetKit widget mirrors the menu card snapshot.
+- Built-in i18n language switcher: follow system, English, and Simplified Chinese.
- Privacy-first: on-device parsing by default; browser cookies are opt-in and reused (no passwords stored).
## Privacy note
diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh
index a6a3754b7..ce20b2ce4 100755
--- a/Scripts/package_app.sh
+++ b/Scripts/package_app.sh
@@ -190,6 +190,10 @@ cat > "$APP/Contents/Info.plist" <CFBundleVersion${BUILD_NUMBER}
LSMinimumSystemVersion14.0
LSUIElement
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
CFBundleIconFileIcon
NSHumanReadableCopyright© 2025 Peter Steinberger. MIT License.
SUFeedURL${FEED_URL}
diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift
index 5c0bb2d9b..0b1887a49 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -11,15 +11,19 @@ struct UsageMenuCardView: View {
var labelSuffix: String {
switch self {
- case .left: "left"
- case .used: "used"
+ case .left:
+ L10n.tr("menu.card.percent.left", fallback: "left")
+ case .used:
+ L10n.tr("menu.card.percent.used", fallback: "used")
}
}
var accessibilityLabel: String {
switch self {
- case .left: "Usage remaining"
- case .used: "Usage used"
+ case .left:
+ L10n.tr("menu.card.accessibility.usage_remaining", fallback: "Usage remaining")
+ case .used:
+ L10n.tr("menu.card.accessibility.usage_used", fallback: "Usage used")
}
}
}
@@ -135,7 +139,7 @@ struct UsageMenuCardView: View {
}
if let tokenUsage = self.model.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
- Text("Cost")
+ Text(L10n.tr("menu.card.cost.title", fallback: "Cost"))
.font(.body)
.fontWeight(.medium)
Text(tokenUsage.sessionLine)
@@ -460,19 +464,22 @@ private struct CreditsBarContent: View {
private var scaleText: String {
let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens))
- return "\(scale) tokens"
+ let format = L10n.tr("menu.card.tokens.unit", fallback: "%@ tokens")
+ return String(format: format, locale: .current, scale)
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
- Text("Credits")
+ Text(L10n.tr("menu.card.credits.title", fallback: "Credits"))
.font(.body)
.fontWeight(.medium)
if let percentLeft {
UsageProgressBar(
percent: percentLeft,
tint: self.progressColor,
- accessibilityLabel: "Credits remaining")
+ accessibilityLabel: L10n.tr(
+ "menu.card.accessibility.credits_remaining",
+ fallback: "Credits remaining"))
HStack(alignment: .firstTextBaseline) {
Text(self.creditsText)
.font(.caption)
@@ -513,7 +520,7 @@ struct UsageMenuCardCostSectionView: View {
VStack(alignment: .leading, spacing: 10) {
if let tokenUsage = self.model.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
- Text("Cost")
+ Text(L10n.tr("menu.card.cost.title", fallback: "Cost"))
.font(.body)
.fontWeight(.medium)
Text(tokenUsage.sessionLine)
@@ -576,6 +583,7 @@ extension UsageMenuCardView.Model {
struct Input {
let provider: UsageProvider
let metadata: ProviderMetadata
+ let sourceLabel: String?
let snapshot: UsageSnapshot?
let credits: CreditsSnapshot?
let creditsError: String?
@@ -601,10 +609,16 @@ extension UsageMenuCardView.Model {
account: input.account,
metadata: input.metadata)
let metrics = Self.metrics(input: input)
+ let isCodexCLIProxy = input.provider == .codex &&
+ (input.sourceLabel?.localizedCaseInsensitiveContains("cliproxy-api") ?? false)
let creditsText: String? = if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage {
nil
} else {
- Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError)
+ Self.creditsLine(
+ metadata: input.metadata,
+ credits: input.credits,
+ error: input.creditsError,
+ showUnavailableHint: !isCodexCLIProxy)
}
let providerCost: ProviderCostSection? = if input.provider == .claude, !input.showOptionalCreditsAndExtraUsage {
nil
@@ -613,6 +627,8 @@ extension UsageMenuCardView.Model {
}
let tokenUsage = Self.tokenUsageSection(
provider: input.provider,
+ sourceLabel: input.sourceLabel,
+ hasUsageSnapshot: input.snapshot != nil,
enabled: input.tokenCostUsageEnabled,
snapshot: input.tokenSnapshot,
error: input.tokenError)
@@ -745,7 +761,7 @@ extension UsageMenuCardView.Model {
if let primary = snapshot.primary {
metrics.append(Metric(
id: "primary",
- title: input.metadata.sessionLabel,
+ title: Self.primaryWindowLabel(for: input.provider, fallback: input.metadata.sessionLabel),
percent: Self.clamped(
input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent),
percentStyle: percentStyle,
@@ -764,7 +780,7 @@ extension UsageMenuCardView.Model {
showUsed: input.usageBarsShowUsed)
metrics.append(Metric(
id: "secondary",
- title: input.metadata.weeklyLabel,
+ title: Self.secondaryWindowLabel(for: input.provider, fallback: input.metadata.weeklyLabel),
percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent),
percentStyle: percentStyle,
resetText: Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now),
@@ -805,6 +821,20 @@ extension UsageMenuCardView.Model {
return metrics
}
+ private static func primaryWindowLabel(for provider: UsageProvider, fallback: String) -> String {
+ if provider == .codex || provider == .codexproxy {
+ return L10n.tr("provider.codex.metadata.session_label", fallback: "Session")
+ }
+ return fallback
+ }
+
+ private static func secondaryWindowLabel(for provider: UsageProvider, fallback: String) -> String {
+ if provider == .codex || provider == .codexproxy {
+ return L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly")
+ }
+ return fallback
+ }
+
private static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? {
guard let limit else { return nil }
let currentStr = UsageFormatter.tokenCountString(limit.currentValue)
@@ -844,12 +874,14 @@ extension UsageMenuCardView.Model {
private static func creditsLine(
metadata: ProviderMetadata,
credits: CreditsSnapshot?,
- error: String?) -> String?
+ error: String?,
+ showUnavailableHint: Bool = true) -> String?
{
guard metadata.supportsCredits else { return nil }
if let credits {
return UsageFormatter.creditsString(from: credits.remaining)
}
+ guard showUnavailableHint else { return nil }
if let error, !error.isEmpty {
return error.trimmingCharacters(in: .whitespacesAndNewlines)
}
@@ -864,11 +896,19 @@ extension UsageMenuCardView.Model {
private static func tokenUsageSection(
provider: UsageProvider,
+ sourceLabel: String?,
+ hasUsageSnapshot: Bool,
enabled: Bool,
snapshot: CostUsageTokenSnapshot?,
error: String?) -> TokenUsageSection?
{
- guard provider == .codex || provider == .claude || provider == .vertexai else { return nil }
+ guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else {
+ return nil
+ }
+ if provider == .codex {
+ guard hasUsageSnapshot else { return nil }
+ if sourceLabel?.localizedCaseInsensitiveContains("cliproxy-api") == true { return nil }
+ }
guard enabled else { return nil }
guard let snapshot else { return nil }
@@ -876,9 +916,11 @@ extension UsageMenuCardView.Model {
let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) }
let sessionLine: String = {
if let sessionTokens {
- return "Today: \(sessionCost) · \(sessionTokens) tokens"
+ let format = L10n.tr("menu.card.cost.today_with_tokens", fallback: "Today: %@ · %@ tokens")
+ return String(format: format, locale: .current, sessionCost, sessionTokens)
}
- return "Today: \(sessionCost)"
+ let format = L10n.tr("menu.card.cost.today", fallback: "Today: %@")
+ return String(format: format, locale: .current, sessionCost)
}()
let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—"
@@ -887,9 +929,13 @@ extension UsageMenuCardView.Model {
let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) }
let monthLine: String = {
if let monthTokens {
- return "Last 30 days: \(monthCost) · \(monthTokens) tokens"
+ let format = L10n.tr(
+ "menu.card.cost.last_30_days_with_tokens",
+ fallback: "Last 30 days: %@ · %@ tokens")
+ return String(format: format, locale: .current, monthCost, monthTokens)
}
- return "Last 30 days: \(monthCost)"
+ let format = L10n.tr("menu.card.cost.last_30_days", fallback: "Last 30 days: %@")
+ return String(format: format, locale: .current, monthCost)
}()
let err = (error?.isEmpty ?? true) ? nil : error
return TokenUsageSection(
@@ -912,17 +958,17 @@ extension UsageMenuCardView.Model {
let title: String
if cost.currencyCode == "Quota" {
- title = "Quota usage"
+ title = L10n.tr("menu.card.provider_cost.quota_usage", fallback: "Quota usage")
used = String(format: "%.0f", cost.used)
limit = String(format: "%.0f", cost.limit)
} else {
- title = "Extra usage"
+ title = L10n.tr("menu.card.provider_cost.extra_usage", fallback: "Extra usage")
used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode)
limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode)
}
let percentUsed = Self.clamped((cost.used / cost.limit) * 100)
- let periodLabel = cost.period ?? "This month"
+ let periodLabel = cost.period ?? L10n.tr("menu.card.provider_cost.this_month", fallback: "This month")
return ProviderCostSection(
title: title,
diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift
index 36a9c861b..7cbc15f35 100644
--- a/Sources/CodexBar/MenuDescriptor.swift
+++ b/Sources/CodexBar/MenuDescriptor.swift
@@ -117,7 +117,7 @@ struct MenuDescriptor {
if let primary = snap.primary {
Self.appendRateWindow(
entries: &entries,
- title: meta.sessionLabel,
+ title: Self.primaryWindowLabel(for: provider, fallback: meta.sessionLabel),
window: primary,
resetStyle: resetStyle,
showUsed: settings.usageBarsShowUsed)
@@ -125,7 +125,7 @@ struct MenuDescriptor {
if let weekly = snap.secondary {
Self.appendRateWindow(
entries: &entries,
- title: meta.weeklyLabel,
+ title: Self.secondaryWindowLabel(for: provider, fallback: meta.weeklyLabel),
window: weekly,
resetStyle: resetStyle,
showUsed: settings.usageBarsShowUsed)
@@ -165,6 +165,20 @@ struct MenuDescriptor {
return Section(entries: entries)
}
+ private static func primaryWindowLabel(for provider: UsageProvider, fallback: String) -> String {
+ if provider == .codex || provider == .codexproxy {
+ return L10n.tr("provider.codex.metadata.session_label", fallback: "Session")
+ }
+ return fallback
+ }
+
+ private static func secondaryWindowLabel(for provider: UsageProvider, fallback: String) -> String {
+ if provider == .codex || provider == .codexproxy {
+ return L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly")
+ }
+ return fallback
+ }
+
private static func accountSection(
for provider: UsageProvider,
store: UsageStore,
@@ -245,11 +259,15 @@ struct MenuDescriptor {
settings: store.settings,
account: account)
}
+ let hideSwitchAccountAction = Self.shouldHideSwitchAccountAction(
+ provider: targetProvider,
+ store: store)
// Show "Add Account" if no account, "Switch Account" if logged in
if let targetProvider,
let implementation = ProviderCatalog.implementation(for: targetProvider),
- implementation.supportsLoginFlow
+ implementation.supportsLoginFlow,
+ !hideSwitchAccountAction
{
if let loginContext,
let override = implementation.loginMenuAction(context: loginContext)
@@ -258,7 +276,9 @@ struct MenuDescriptor {
} else {
let loginAction = self.switchAccountTarget(for: provider, store: store)
let hasAccount = self.hasAccount(for: provider, store: store, account: account)
- let accountLabel = hasAccount ? "Switch Account..." : "Add Account..."
+ let accountLabel = hasAccount
+ ? L10n.tr("menu.action.switch_account", fallback: "Switch Account...")
+ : L10n.tr("menu.action.add_account", fallback: "Add Account...")
entries.append(.action(accountLabel, loginAction))
}
}
@@ -274,10 +294,10 @@ struct MenuDescriptor {
}
if metadata?.dashboardURL != nil {
- entries.append(.action("Usage Dashboard", .dashboard))
+ entries.append(.action(L10n.tr("menu.action.usage_dashboard", fallback: "Usage Dashboard"), .dashboard))
}
if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil {
- entries.append(.action("Status Page", .statusPage))
+ entries.append(.action(L10n.tr("menu.action.status_page", fallback: "Status Page"), .statusPage))
}
if let statusLine = self.statusLine(for: provider, store: store) {
@@ -290,12 +310,14 @@ struct MenuDescriptor {
private static func metaSection(updateReady: Bool) -> Section {
var entries: [Entry] = []
if updateReady {
- entries.append(.action("Update ready, restart now?", .installUpdate))
+ entries.append(.action(
+ L10n.tr("menu.action.install_update", fallback: "Update ready, restart now?"),
+ .installUpdate))
}
entries.append(contentsOf: [
- .action("Settings...", .settings),
- .action("About CodexBar", .about),
- .action("Quit", .quit),
+ .action(L10n.tr("menu.action.settings", fallback: "Settings..."), .settings),
+ .action(L10n.tr("menu.action.about", fallback: "About CodexBar"), .about),
+ .action(L10n.tr("menu.action.quit", fallback: "Quit"), .quit),
])
return Section(entries: entries)
}
@@ -315,6 +337,14 @@ struct MenuDescriptor {
return label
}
+ private static func shouldHideSwitchAccountAction(provider: UsageProvider?, store: UsageStore) -> Bool {
+ guard provider == .codexproxy else { return false }
+ let codexSettings = store.settings.codexSettingsSnapshot(tokenOverride: nil)
+ return CodexCLIProxySettings.resolve(
+ providerSettings: codexSettings,
+ environment: ProcessInfo.processInfo.environment) != nil
+ }
+
private static func switchAccountTarget(for provider: UsageProvider?, store: UsageStore) -> MenuAction {
if let provider { return .switchAccount(provider) }
if let enabled = store.enabledProviders().first { return .switchAccount(enabled) }
diff --git a/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift b/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift
index 99ec8eef6..89acd2399 100644
--- a/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift
+++ b/Sources/CodexBar/OpenAICreditsPurchaseWindowController.swift
@@ -420,7 +420,7 @@ final class OpenAICreditsPurchaseWindowController: NSWindowController, WKNavigat
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false)
- window.title = "Buy Credits"
+ window.title = L10n.tr("window.buy_credits.title", fallback: "Buy Credits")
window.isReleasedWhenClosed = false
window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary]
window.contentView = container
diff --git a/Sources/CodexBar/PreferencesAboutPane.swift b/Sources/CodexBar/PreferencesAboutPane.swift
index 16e27189e..d4a962dc4 100644
--- a/Sources/CodexBar/PreferencesAboutPane.swift
+++ b/Sources/CodexBar/PreferencesAboutPane.swift
@@ -1,4 +1,5 @@
import AppKit
+import CodexBarCore
import SwiftUI
@MainActor
@@ -51,14 +52,22 @@ struct AboutPane: View {
VStack(spacing: 2) {
Text("CodexBar")
.font(.title3).bold()
- Text("Version \(self.versionString)")
+ Text(String(
+ format: L10n.tr("settings.about.version", fallback: "Version %@"),
+ locale: .current,
+ self.versionString))
.foregroundStyle(.secondary)
if let buildTimestamp {
- Text("Built \(buildTimestamp)")
+ Text(String(
+ format: L10n.tr("settings.about.build", fallback: "Built %@"),
+ locale: .current,
+ buildTimestamp))
.font(.footnote)
.foregroundStyle(.secondary)
}
- Text("May your tokens never run out—keep agent limits in view.")
+ Text(L10n.tr(
+ "settings.about.tagline",
+ fallback: "May your tokens never run out—keep agent limits in view."))
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -66,11 +75,20 @@ struct AboutPane: View {
VStack(alignment: .center, spacing: 10) {
AboutLinkRow(
icon: "chevron.left.slash.chevron.right",
- title: "GitHub",
+ title: L10n.tr("settings.about.link.github", fallback: "GitHub"),
url: "https://github.com/steipete/CodexBar")
- AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me")
- AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete")
- AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me")
+ AboutLinkRow(
+ icon: "globe",
+ title: L10n.tr("settings.about.link.website", fallback: "Website"),
+ url: "https://steipete.me")
+ AboutLinkRow(
+ icon: "bird",
+ title: L10n.tr("settings.about.link.twitter", fallback: "Twitter"),
+ url: "https://twitter.com/steipete")
+ AboutLinkRow(
+ icon: "envelope",
+ title: L10n.tr("settings.about.link.email", fallback: "Email"),
+ url: "mailto:peter@steipete.me")
}
.padding(.top, 8)
.frame(maxWidth: .infinity)
@@ -80,12 +98,14 @@ struct AboutPane: View {
if self.updater.isAvailable {
VStack(spacing: 10) {
- Toggle("Check for updates automatically", isOn: self.$autoUpdateEnabled)
+ Toggle(
+ L10n.tr("settings.about.updates.auto_check", fallback: "Check for updates automatically"),
+ isOn: self.$autoUpdateEnabled)
.toggleStyle(.checkbox)
.frame(maxWidth: .infinity, alignment: .center)
VStack(spacing: 6) {
HStack(spacing: 12) {
- Text("Update Channel")
+ Text(L10n.tr("settings.about.updates.channel", fallback: "Update Channel"))
Spacer()
Picker("", selection: self.updateChannelBinding) {
ForEach(UpdateChannel.allCases) { channel in
@@ -102,14 +122,17 @@ struct AboutPane: View {
.multilineTextAlignment(.center)
.frame(maxWidth: 280)
}
- Button("Check for Updates…") { self.updater.checkForUpdates(nil) }
+ Button(L10n.tr("settings.about.updates.check_now", fallback: "Check for Updates…")) {
+ self.updater.checkForUpdates(nil)
+ }
}
} else {
- Text(self.updater.unavailableReason ?? "Updates unavailable in this build.")
+ Text(self.updater.unavailableReason ??
+ L10n.tr("settings.about.updates.unavailable", fallback: "Updates unavailable in this build."))
.foregroundStyle(.secondary)
}
- Text("© 2025 Peter Steinberger. MIT License.")
+ Text(L10n.tr("settings.about.copyright", fallback: "© 2025 Peter Steinberger. MIT License."))
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.top, 4)
diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift
index 1db4897f2..2e704068f 100644
--- a/Sources/CodexBar/PreferencesAdvancedPane.swift
+++ b/Sources/CodexBar/PreferencesAdvancedPane.swift
@@ -1,3 +1,4 @@
+import CodexBarCore
import KeyboardShortcuts
import SwiftUI
@@ -11,17 +12,19 @@ struct AdvancedPane: View {
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: 16) {
SettingsSection(contentSpacing: 8) {
- Text("Keyboard shortcut")
+ Text(L10n.tr("settings.advanced.keyboard.section", fallback: "Keyboard shortcut"))
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
HStack(alignment: .center, spacing: 12) {
- Text("Open menu")
+ Text(L10n.tr("settings.advanced.keyboard.open_menu.title", fallback: "Open menu"))
.font(.body)
Spacer()
KeyboardShortcuts.Recorder(for: .openMenu)
}
- Text("Trigger the menu bar menu from anywhere.")
+ Text(L10n.tr(
+ "settings.advanced.keyboard.open_menu.subtitle",
+ fallback: "Trigger the menu bar menu from anywhere."))
.font(.footnote)
.foregroundStyle(.tertiary)
}
@@ -36,7 +39,7 @@ struct AdvancedPane: View {
if self.isInstallingCLI {
ProgressView().controlSize(.small)
} else {
- Text("Install CLI")
+ Text(L10n.tr("settings.advanced.cli.install", fallback: "Install CLI"))
}
}
.disabled(self.isInstallingCLI)
@@ -48,7 +51,9 @@ struct AdvancedPane: View {
.lineLimit(2)
}
}
- Text("Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.")
+ Text(L10n.tr(
+ "settings.advanced.cli.install.subtitle",
+ fallback: "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar."))
.font(.footnote)
.foregroundStyle(.tertiary)
}
@@ -57,12 +62,16 @@ struct AdvancedPane: View {
SettingsSection(contentSpacing: 10) {
PreferenceToggleRow(
- title: "Show Debug Settings",
- subtitle: "Expose troubleshooting tools in the Debug tab.",
+ title: L10n.tr("settings.advanced.debug.title", fallback: "Show Debug Settings"),
+ subtitle: L10n.tr(
+ "settings.advanced.debug.subtitle",
+ fallback: "Expose troubleshooting tools in the Debug tab."),
binding: self.$settings.debugMenuEnabled)
PreferenceToggleRow(
- title: "Surprise me",
- subtitle: "Check if you like your agents having some fun up there.",
+ title: L10n.tr("settings.advanced.surprise.title", fallback: "Surprise me"),
+ subtitle: L10n.tr(
+ "settings.advanced.surprise.subtitle",
+ fallback: "Check if you like your agents having some fun up there."),
binding: self.$settings.randomBlinkEnabled)
}
@@ -70,22 +79,33 @@ struct AdvancedPane: View {
SettingsSection(contentSpacing: 10) {
PreferenceToggleRow(
- title: "Hide personal information",
- subtitle: "Obscure email addresses in the menu bar and menu UI.",
+ title: L10n.tr(
+ "settings.advanced.privacy.hide_personal_info.title",
+ fallback: "Hide personal information"),
+ subtitle: L10n.tr(
+ "settings.advanced.privacy.hide_personal_info.subtitle",
+ fallback: "Obscure email addresses in the menu bar and menu UI."),
binding: self.$settings.hidePersonalInfo)
}
Divider()
SettingsSection(
- title: "Keychain access",
- caption: """
+ title: L10n.tr("settings.advanced.keychain.title", fallback: "Keychain access"),
+ caption: L10n.tr(
+ "settings.advanced.keychain.caption",
+ fallback: """
Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie \
headers manually in Providers.
- """) {
+ """))
+ {
PreferenceToggleRow(
- title: "Disable Keychain access",
- subtitle: "Prevents any Keychain access while enabled.",
+ title: L10n.tr(
+ "settings.advanced.keychain.disable.title",
+ fallback: "Disable Keychain access"),
+ subtitle: L10n.tr(
+ "settings.advanced.keychain.disable.subtitle",
+ fallback: "Prevents any Keychain access while enabled."),
binding: self.$settings.debugDisableKeychainAccess)
}
}
@@ -105,7 +125,9 @@ extension AdvancedPane {
let helperURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/CodexBarCLI")
let fm = FileManager.default
guard fm.fileExists(atPath: helperURL.path) else {
- self.cliStatus = "CodexBarCLI not found in app bundle."
+ self.cliStatus = L10n.tr(
+ "settings.advanced.cli.status.helper_not_found",
+ fallback: "CodexBarCLI not found in app bundle.")
return
}
@@ -119,29 +141,36 @@ extension AdvancedPane {
let dir = (dest as NSString).deletingLastPathComponent
guard fm.fileExists(atPath: dir) else { continue }
guard fm.isWritableFile(atPath: dir) else {
- results.append("No write access: \(dir)")
+ let format = L10n.tr(
+ "settings.advanced.cli.status.no_write_access",
+ fallback: "No write access: %@")
+ results.append(String(format: format, locale: .current, dir))
continue
}
if fm.fileExists(atPath: dest) {
if Self.isLink(atPath: dest, pointingTo: helperURL.path) {
- results.append("Installed: \(dir)")
+ let format = L10n.tr("settings.advanced.cli.status.installed", fallback: "Installed: %@")
+ results.append(String(format: format, locale: .current, dir))
} else {
- results.append("Exists: \(dir)")
+ let format = L10n.tr("settings.advanced.cli.status.exists", fallback: "Exists: %@")
+ results.append(String(format: format, locale: .current, dir))
}
continue
}
do {
try fm.createSymbolicLink(atPath: dest, withDestinationPath: helperURL.path)
- results.append("Installed: \(dir)")
+ let format = L10n.tr("settings.advanced.cli.status.installed", fallback: "Installed: %@")
+ results.append(String(format: format, locale: .current, dir))
} catch {
- results.append("Failed: \(dir)")
+ let format = L10n.tr("settings.advanced.cli.status.failed", fallback: "Failed: %@")
+ results.append(String(format: format, locale: .current, dir))
}
}
self.cliStatus = results.isEmpty
- ? "No writable bin dirs found."
+ ? L10n.tr("settings.advanced.cli.status.no_writable_dirs", fallback: "No writable bin dirs found.")
: results.joined(separator: " · ")
}
diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift
index 003fa27ee..925f395b8 100644
--- a/Sources/CodexBar/PreferencesDisplayPane.swift
+++ b/Sources/CodexBar/PreferencesDisplayPane.swift
@@ -1,3 +1,4 @@
+import CodexBarCore
import SwiftUI
@MainActor
@@ -8,40 +9,59 @@ struct DisplayPane: View {
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: 16) {
SettingsSection(contentSpacing: 12) {
- Text("Menu bar")
+ Text(L10n.tr("settings.display.menu_bar.section", fallback: "Menu bar"))
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
PreferenceToggleRow(
- title: "Merge Icons",
- subtitle: "Use a single menu bar icon with a provider switcher.",
+ title: L10n.tr("settings.display.menu_bar.merge_icons.title", fallback: "Merge Icons"),
+ subtitle: L10n.tr(
+ "settings.display.menu_bar.merge_icons.subtitle",
+ fallback: "Use a single menu bar icon with a provider switcher."),
binding: self.$settings.mergeIcons)
PreferenceToggleRow(
- title: "Switcher shows icons",
- subtitle: "Show provider icons in the switcher (otherwise show a weekly progress line).",
+ title: L10n.tr(
+ "settings.display.menu_bar.switcher_icons.title",
+ fallback: "Switcher shows icons"),
+ subtitle: L10n.tr(
+ "settings.display.menu_bar.switcher_icons.subtitle",
+ fallback: "Show provider icons in the switcher (otherwise show a weekly progress line)."),
binding: self.$settings.switcherShowsIcons)
.disabled(!self.settings.mergeIcons)
.opacity(self.settings.mergeIcons ? 1 : 0.5)
PreferenceToggleRow(
- title: "Show most-used provider",
- subtitle: "Menu bar auto-shows the provider closest to its rate limit.",
+ title: L10n.tr(
+ "settings.display.menu_bar.highest_usage.title",
+ fallback: "Show most-used provider"),
+ subtitle: L10n.tr(
+ "settings.display.menu_bar.highest_usage.subtitle",
+ fallback: "Menu bar auto-shows the provider closest to its rate limit."),
binding: self.$settings.menuBarShowsHighestUsage)
.disabled(!self.settings.mergeIcons)
.opacity(self.settings.mergeIcons ? 1 : 0.5)
PreferenceToggleRow(
- title: "Menu bar shows percent",
- subtitle: "Replace critter bars with provider branding icons and a percentage.",
+ title: L10n.tr(
+ "settings.display.menu_bar.brand_percent.title",
+ fallback: "Menu bar shows percent"),
+ subtitle: L10n.tr(
+ "settings.display.menu_bar.brand_percent.subtitle",
+ fallback: "Replace critter bars with provider branding icons and a percentage."),
binding: self.$settings.menuBarShowsBrandIconWithPercent)
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
- Text("Display mode")
+ Text(L10n.tr("settings.display.menu_bar.mode.title", fallback: "Display mode"))
.font(.body)
- Text("Choose what to show in the menu bar (Pace shows usage vs. expected).")
+ Text(L10n.tr(
+ "settings.display.menu_bar.mode.subtitle",
+ fallback: "Choose what to show in the menu bar (Pace shows usage vs. expected)."))
.font(.footnote)
.foregroundStyle(.tertiary)
}
Spacer()
- Picker("Display mode", selection: self.$settings.menuBarDisplayMode) {
+ Picker(
+ L10n.tr("settings.display.menu_bar.mode.title", fallback: "Display mode"),
+ selection: self.$settings.menuBarDisplayMode)
+ {
ForEach(MenuBarDisplayMode.allCases) { mode in
Text(mode.label).tag(mode)
}
@@ -57,25 +77,41 @@ struct DisplayPane: View {
Divider()
SettingsSection(contentSpacing: 12) {
- Text("Menu content")
+ Text(L10n.tr("settings.display.menu_content.section", fallback: "Menu content"))
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
PreferenceToggleRow(
- title: "Show usage as used",
- subtitle: "Progress bars fill as you consume quota (instead of showing remaining).",
+ title: L10n.tr(
+ "settings.display.menu_content.usage_as_used.title",
+ fallback: "Show usage as used"),
+ subtitle: L10n.tr(
+ "settings.display.menu_content.usage_as_used.subtitle",
+ fallback: "Progress bars fill as you consume quota (instead of showing remaining)."),
binding: self.$settings.usageBarsShowUsed)
PreferenceToggleRow(
- title: "Show reset time as clock",
- subtitle: "Display reset times as absolute clock values instead of countdowns.",
+ title: L10n.tr(
+ "settings.display.menu_content.reset_clock.title",
+ fallback: "Show reset time as clock"),
+ subtitle: L10n.tr(
+ "settings.display.menu_content.reset_clock.subtitle",
+ fallback: "Display reset times as absolute clock values instead of countdowns."),
binding: self.$settings.resetTimesShowAbsolute)
PreferenceToggleRow(
- title: "Show credits + extra usage",
- subtitle: "Show Codex Credits and Claude Extra usage sections in the menu.",
+ title: L10n.tr(
+ "settings.display.menu_content.optional_usage.title",
+ fallback: "Show credits + extra usage"),
+ subtitle: L10n.tr(
+ "settings.display.menu_content.optional_usage.subtitle",
+ fallback: "Show Codex Credits and Claude Extra usage sections in the menu."),
binding: self.$settings.showOptionalCreditsAndExtraUsage)
PreferenceToggleRow(
- title: "Show all token accounts",
- subtitle: "Stack token accounts in the menu (otherwise show an account switcher bar).",
+ title: L10n.tr(
+ "settings.display.menu_content.all_token_accounts.title",
+ fallback: "Show all token accounts"),
+ subtitle: L10n.tr(
+ "settings.display.menu_content.all_token_accounts.subtitle",
+ fallback: "Stack token accounts in the menu (otherwise show an account switcher bar)."),
binding: self.$settings.showAllTokenAccountsInMenu)
}
}
diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift
index 39a95a55f..e7199eada 100644
--- a/Sources/CodexBar/PreferencesGeneralPane.swift
+++ b/Sources/CodexBar/PreferencesGeneralPane.swift
@@ -11,20 +11,112 @@ struct GeneralPane: View {
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: 16) {
SettingsSection(contentSpacing: 12) {
- Text("System")
+ Text(L10n.tr("settings.general.system.section", fallback: "System"))
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
PreferenceToggleRow(
- title: "Start at Login",
- subtitle: "Automatically opens CodexBar when you start your Mac.",
+ title: L10n.tr("settings.general.system.start_at_login.title", fallback: "Start at Login"),
+ subtitle: L10n.tr(
+ "settings.general.system.start_at_login.subtitle",
+ fallback: "Automatically opens CodexBar when you start your Mac."),
binding: self.$settings.launchAtLogin)
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .top, spacing: 12) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(L10n.tr("settings.general.language.title", fallback: "Language"))
+ .font(.body)
+ Text(L10n.tr(
+ "settings.general.language.subtitle",
+ fallback: "Choose app display language."))
+ .font(.footnote)
+ .foregroundStyle(.tertiary)
+ }
+ Spacer()
+ Picker("", selection: self.$settings.appLanguage) {
+ ForEach(AppLanguageOption.allCases) { option in
+ Text(option.label).tag(option)
+ }
+ }
+ .labelsHidden()
+ .pickerStyle(.menu)
+ .frame(maxWidth: 200)
+ }
+ Text(L10n.tr(
+ "settings.general.language.restart_hint",
+ fallback: "Language changes apply after restart."))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ HStack {
+ Spacer()
+ Button(L10n.tr("settings.general.language.apply_restart", fallback: "Apply & Restart")) {
+ self.restartApp()
+ }
+ .buttonStyle(.bordered)
+ }
+ }
+ }
+
+ Divider()
+
+ SettingsSection(contentSpacing: 12) {
+ Text(L10n.tr("settings.general.cliproxy.section", fallback: "CLIProxyAPI"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ VStack(alignment: .leading, spacing: 10) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(L10n.tr("settings.general.cliproxy.url.title", fallback: "Base URL"))
+ .font(.body)
+ Text(L10n.tr(
+ "settings.general.cliproxy.url.subtitle",
+ fallback: "Global default for providers using API source (for example Codex)."))
+ .font(.footnote)
+ .foregroundStyle(.tertiary)
+ TextField(
+ L10n.tr(
+ "settings.general.cliproxy.url.placeholder",
+ fallback: "http://127.0.0.1:8317"),
+ text: self.$settings.cliProxyGlobalBaseURL)
+ .textFieldStyle(.roundedBorder)
+ .font(.footnote)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(L10n.tr("settings.general.cliproxy.key.title", fallback: "Management Key"))
+ .font(.body)
+ SecureField(
+ L10n.tr(
+ "settings.general.cliproxy.key.placeholder",
+ fallback: "Paste management key…"),
+ text: self.$settings.cliProxyGlobalManagementKey)
+ .textFieldStyle(.roundedBorder)
+ .font(.footnote)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(L10n.tr("settings.general.cliproxy.auth_index.title", fallback: "auth_index (optional)"))
+ .font(.body)
+ Text(L10n.tr(
+ "settings.general.cliproxy.auth_index.subtitle",
+ fallback: "Optional. Set a specific auth file; leave empty to aggregate all matching auth entries."))
+ .font(.footnote)
+ .foregroundStyle(.tertiary)
+ TextField(
+ L10n.tr(
+ "settings.general.cliproxy.auth_index.placeholder",
+ fallback: "Leave empty to load all matching auth entries"),
+ text: self.$settings.cliProxyGlobalAuthIndex)
+ .textFieldStyle(.roundedBorder)
+ .font(.footnote)
+ }
+ }
}
Divider()
SettingsSection(contentSpacing: 12) {
- Text("Usage")
+ Text(L10n.tr("settings.general.usage.section", fallback: "Usage"))
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
@@ -32,18 +124,22 @@ struct GeneralPane: View {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Toggle(isOn: self.$settings.costUsageEnabled) {
- Text("Show cost summary")
+ Text(L10n.tr("settings.general.usage.cost_summary.title", fallback: "Show cost summary"))
.font(.body)
}
.toggleStyle(.checkbox)
- Text("Reads local usage logs. Shows today + last 30 days cost in the menu.")
+ Text(L10n.tr(
+ "settings.general.usage.cost_summary.subtitle",
+ fallback: "Reads local usage logs. Shows today + last 30 days cost in the menu."))
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
if self.settings.costUsageEnabled {
- Text("Auto-refresh: hourly · Timeout: 10m")
+ Text(L10n.tr(
+ "settings.general.usage.cost_summary.refresh_hint",
+ fallback: "Auto-refresh: hourly · Timeout: 10m"))
.font(.footnote)
.foregroundStyle(.tertiary)
@@ -57,21 +153,26 @@ struct GeneralPane: View {
Divider()
SettingsSection(contentSpacing: 12) {
- Text("Automation")
+ Text(L10n.tr("settings.general.automation.section", fallback: "Automation"))
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
- Text("Refresh cadence")
+ Text(L10n.tr("settings.general.automation.refresh_cadence.title", fallback: "Refresh cadence"))
.font(.body)
- Text("How often CodexBar polls providers in the background.")
+ Text(L10n.tr(
+ "settings.general.automation.refresh_cadence.subtitle",
+ fallback: "How often CodexBar polls providers in the background."))
.font(.footnote)
.foregroundStyle(.tertiary)
}
Spacer()
- Picker("Refresh cadence", selection: self.$settings.refreshFrequency) {
+ Picker(
+ L10n.tr("settings.general.automation.refresh_cadence.title", fallback: "Refresh cadence"),
+ selection: self.$settings.refreshFrequency)
+ {
ForEach(RefreshFrequency.allCases) { option in
Text(option.label).tag(option)
}
@@ -81,20 +182,26 @@ struct GeneralPane: View {
.frame(maxWidth: 200)
}
if self.settings.refreshFrequency == .manual {
- Text("Auto-refresh is off; use the menu's Refresh command.")
+ Text(L10n.tr(
+ "settings.general.automation.refresh_cadence.manual_hint",
+ fallback: "Auto-refresh is off; use the menu's Refresh command."))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
PreferenceToggleRow(
- title: "Check provider status",
- subtitle: "Polls OpenAI/Claude status pages and Google Workspace for " +
- "Gemini/Antigravity, surfacing incidents in the icon and menu.",
+ title: L10n.tr("settings.general.automation.check_status.title", fallback: "Check provider status"),
+ subtitle: L10n.tr(
+ "settings.general.automation.check_status.subtitle",
+ fallback: "Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu."),
binding: self.$settings.statusChecksEnabled)
PreferenceToggleRow(
- title: "Session quota notifications",
- subtitle: "Notifies when the 5-hour session quota hits 0% and when it becomes " +
- "available again.",
+ title: L10n.tr(
+ "settings.general.automation.session_quota.title",
+ fallback: "Session quota notifications"),
+ subtitle: L10n.tr(
+ "settings.general.automation.session_quota.subtitle",
+ fallback: "Notifies when the 5-hour session quota hits 0% and when it becomes available again."),
binding: self.$settings.sessionQuotaNotificationsEnabled)
}
@@ -103,7 +210,7 @@ struct GeneralPane: View {
SettingsSection(contentSpacing: 12) {
HStack {
Spacer()
- Button("Quit CodexBar") { NSApp.terminate(nil) }
+ Button(L10n.tr("settings.general.quit", fallback: "Quit CodexBar")) { NSApp.terminate(nil) }
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
@@ -119,7 +226,8 @@ struct GeneralPane: View {
let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
guard provider == .claude || provider == .codex else {
- return Text("\(name): unsupported")
+ let format = L10n.tr("settings.general.usage.cost_status.unsupported", fallback: "%@: unsupported")
+ return Text(String(format: format, locale: .current, name))
.font(.footnote)
.foregroundStyle(.tertiary)
}
@@ -133,14 +241,16 @@ struct GeneralPane: View {
formatter.unitsStyle = .abbreviated
return formatter.string(from: seconds).map { " (\($0))" } ?? ""
}()
- return Text("\(name): fetching…\(elapsed)")
+ let format = L10n.tr("settings.general.usage.cost_status.fetching", fallback: "%@: fetching…%@")
+ return Text(String(format: format, locale: .current, name, elapsed))
.font(.footnote)
.foregroundStyle(.tertiary)
}
if let snapshot = self.store.tokenSnapshot(for: provider) {
let updated = UsageFormatter.updatedString(from: snapshot.updatedAt)
let cost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—"
- return Text("\(name): \(updated) · 30d \(cost)")
+ let format = L10n.tr("settings.general.usage.cost_status.snapshot", fallback: "%@: %@ · 30d %@")
+ return Text(String(format: format, locale: .current, name, updated, cost))
.font(.footnote)
.foregroundStyle(.tertiary)
}
@@ -154,12 +264,25 @@ struct GeneralPane: View {
let rel = RelativeDateTimeFormatter()
rel.unitsStyle = .abbreviated
let when = rel.localizedString(for: lastAttempt, relativeTo: Date())
- return Text("\(name): last attempt \(when)")
+ let format = L10n.tr("settings.general.usage.cost_status.last_attempt", fallback: "%@: last attempt %@")
+ return Text(String(format: format, locale: .current, name, when))
.font(.footnote)
.foregroundStyle(.tertiary)
}
- return Text("\(name): no data yet")
+ let format = L10n.tr("settings.general.usage.cost_status.no_data", fallback: "%@: no data yet")
+ return Text(String(format: format, locale: .current, name))
.font(.footnote)
.foregroundStyle(.tertiary)
}
+
+ private func restartApp() {
+ let bundleURL = Bundle.main.bundleURL
+ let configuration = NSWorkspace.OpenConfiguration()
+ configuration.activates = true
+ NSWorkspace.shared.openApplication(at: bundleURL, configuration: configuration) { _, _ in
+ Task { @MainActor in
+ NSApp.terminate(nil)
+ }
+ }
+ }
}
diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift
index f8b843240..ad89f0891 100644
--- a/Sources/CodexBar/PreferencesProviderDetailView.swift
+++ b/Sources/CodexBar/PreferencesProviderDetailView.swift
@@ -38,14 +38,19 @@ struct ProviderDetailView: View {
if let errorDisplay {
ProviderErrorView(
- title: "Last \(self.store.metadata(for: self.provider).displayName) fetch failed:",
+ title: String(
+ format: L10n.tr(
+ "settings.providers.error.last_fetch_failed",
+ fallback: "Last %@ fetch failed:"),
+ locale: .current,
+ self.store.metadata(for: self.provider).displayName),
display: errorDisplay,
isExpanded: self.$isErrorExpanded,
onCopy: { self.onCopyError(errorDisplay.full) })
}
if self.hasSettings {
- ProviderSettingsSection(title: "Settings") {
+ ProviderSettingsSection(title: L10n.tr("settings.providers.section.settings", fallback: "Settings")) {
ForEach(self.settingsPickers) { picker in
ProviderSettingsPickerRowView(picker: picker)
}
@@ -61,7 +66,7 @@ struct ProviderDetailView: View {
}
if !self.settingsToggles.isEmpty {
- ProviderSettingsSection(title: "Options") {
+ ProviderSettingsSection(title: L10n.tr("settings.providers.section.options", fallback: "Options")) {
ForEach(self.settingsToggles) { toggle in
ProviderSettingsToggleRowView(toggle: toggle)
}
@@ -82,26 +87,31 @@ struct ProviderDetailView: View {
}
private var detailLabelWidth: CGFloat {
- var infoLabels = ["State", "Source", "Version", "Updated"]
+ var infoLabels = [
+ L10n.tr("settings.providers.detail.label.state", fallback: "State"),
+ L10n.tr("settings.providers.detail.label.source", fallback: "Source"),
+ L10n.tr("settings.providers.detail.label.version", fallback: "Version"),
+ L10n.tr("settings.providers.detail.label.updated", fallback: "Updated"),
+ ]
if self.store.status(for: self.provider) != nil {
- infoLabels.append("Status")
+ infoLabels.append(L10n.tr("settings.providers.detail.label.status", fallback: "Status"))
}
if !self.model.email.isEmpty {
- infoLabels.append("Account")
+ infoLabels.append(L10n.tr("settings.providers.detail.label.account", fallback: "Account"))
}
if let plan = self.model.planText, !plan.isEmpty {
- infoLabels.append("Plan")
+ infoLabels.append(L10n.tr("settings.providers.detail.label.plan", fallback: "Plan"))
}
var metricLabels = self.model.metrics.map(\.title)
if self.model.creditsText != nil {
- metricLabels.append("Credits")
+ metricLabels.append(L10n.tr("settings.providers.detail.label.credits", fallback: "Credits"))
}
if let providerCost = self.model.providerCost {
metricLabels.append(providerCost.title)
}
if self.model.tokenUsage != nil {
- metricLabels.append("Cost")
+ metricLabels.append(L10n.tr("settings.providers.detail.label.cost", fallback: "Cost"))
}
let infoWidth = ProviderSettingsMetrics.labelWidth(
@@ -147,7 +157,7 @@ private struct ProviderDetailHeaderView: View {
}
.buttonStyle(.bordered)
.controlSize(.small)
- .help("Refresh")
+ .help(L10n.tr("settings.providers.detail.help.refresh", fallback: "Refresh"))
Toggle("", isOn: self.$isEnabled)
.labelsHidden()
@@ -207,31 +217,52 @@ private struct ProviderDetailInfoGrid: View {
var body: some View {
let status = self.store.status(for: self.provider)
let source = self.store.sourceLabel(for: self.provider)
- let version = self.store.version(for: self.provider) ?? "not detected"
+ let version = self.store.version(for: self.provider)
+ ?? L10n.tr("settings.providers.detail.version.not_detected", fallback: "not detected")
let updated = self.updatedText
let email = self.model.email
let plan = self.model.planText ?? ""
- let enabledText = self.isEnabled ? "Enabled" : "Disabled"
+ let enabledText = self.isEnabled
+ ? L10n.tr("settings.providers.detail.state.enabled", fallback: "Enabled")
+ : L10n.tr("settings.providers.detail.state.disabled", fallback: "Disabled")
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) {
- ProviderDetailInfoRow(label: "State", value: enabledText, labelWidth: self.labelWidth)
- ProviderDetailInfoRow(label: "Source", value: source, labelWidth: self.labelWidth)
- ProviderDetailInfoRow(label: "Version", value: version, labelWidth: self.labelWidth)
- ProviderDetailInfoRow(label: "Updated", value: updated, labelWidth: self.labelWidth)
+ ProviderDetailInfoRow(
+ label: L10n.tr("settings.providers.detail.label.state", fallback: "State"),
+ value: enabledText,
+ labelWidth: self.labelWidth)
+ ProviderDetailInfoRow(
+ label: L10n.tr("settings.providers.detail.label.source", fallback: "Source"),
+ value: source,
+ labelWidth: self.labelWidth)
+ ProviderDetailInfoRow(
+ label: L10n.tr("settings.providers.detail.label.version", fallback: "Version"),
+ value: version,
+ labelWidth: self.labelWidth)
+ ProviderDetailInfoRow(
+ label: L10n.tr("settings.providers.detail.label.updated", fallback: "Updated"),
+ value: updated,
+ labelWidth: self.labelWidth)
if let status {
ProviderDetailInfoRow(
- label: "Status",
+ label: L10n.tr("settings.providers.detail.label.status", fallback: "Status"),
value: status.description ?? status.indicator.label,
labelWidth: self.labelWidth)
}
if !email.isEmpty {
- ProviderDetailInfoRow(label: "Account", value: email, labelWidth: self.labelWidth)
+ ProviderDetailInfoRow(
+ label: L10n.tr("settings.providers.detail.label.account", fallback: "Account"),
+ value: email,
+ labelWidth: self.labelWidth)
}
if !plan.isEmpty {
- ProviderDetailInfoRow(label: "Plan", value: plan, labelWidth: self.labelWidth)
+ ProviderDetailInfoRow(
+ label: L10n.tr("settings.providers.detail.label.plan", fallback: "Plan"),
+ value: plan,
+ labelWidth: self.labelWidth)
}
}
.font(.footnote)
@@ -243,9 +274,9 @@ private struct ProviderDetailInfoGrid: View {
return UsageFormatter.updatedString(from: updated)
}
if self.store.refreshingProviders.contains(self.provider) {
- return "Refreshing"
+ return L10n.tr("settings.providers.detail.updated.refreshing", fallback: "Refreshing")
}
- return "Not fetched yet"
+ return L10n.tr("settings.providers.detail.updated.not_fetched_yet", fallback: "Not fetched yet")
}
}
@@ -273,7 +304,7 @@ struct ProviderMetricsInlineView: View {
var body: some View {
ProviderSettingsSection(
- title: "Usage",
+ title: L10n.tr("settings.providers.section.usage", fallback: "Usage"),
spacing: 8,
verticalPadding: 6,
horizontalPadding: 0)
@@ -294,7 +325,7 @@ struct ProviderMetricsInlineView: View {
if let credits = self.model.creditsText {
ProviderMetricInlineTextRow(
- title: "Credits",
+ title: L10n.tr("settings.providers.detail.label.credits", fallback: "Credits"),
value: credits,
labelWidth: self.labelWidth)
}
@@ -308,7 +339,7 @@ struct ProviderMetricsInlineView: View {
if let tokenUsage = self.model.tokenUsage {
ProviderMetricInlineTextRow(
- title: "Cost",
+ title: L10n.tr("settings.providers.detail.label.cost", fallback: "Cost"),
value: tokenUsage.sessionLine,
labelWidth: self.labelWidth)
ProviderMetricInlineTextRow(
@@ -322,9 +353,12 @@ struct ProviderMetricsInlineView: View {
private var placeholderText: String {
if !self.isEnabled {
- return "Disabled — no recent data"
+ return L10n.tr(
+ "settings.providers.metrics.placeholder.disabled_no_data",
+ fallback: "Disabled — no recent data")
}
- return self.model.placeholder ?? "No usage yet"
+ return self.model.placeholder ??
+ L10n.tr("settings.providers.metrics.placeholder.no_usage", fallback: "No usage yet")
}
}
@@ -433,11 +467,14 @@ private struct ProviderMetricInlineCostRow: View {
UsageProgressBar(
percent: self.section.percentUsed,
tint: self.progressColor,
- accessibilityLabel: "Usage used")
+ accessibilityLabel: L10n.tr("settings.providers.cost.accessibility.usage_used", fallback: "Usage used"))
.frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity)
HStack(alignment: .firstTextBaseline, spacing: 8) {
- Text(String(format: "%.0f%% used", self.section.percentUsed))
+ Text(String(
+ format: L10n.tr("settings.providers.cost.percent_used", fallback: "%.0f%% used"),
+ locale: .current,
+ self.section.percentUsed))
.font(.footnote)
.foregroundStyle(.secondary)
.monospacedDigit()
diff --git a/Sources/CodexBar/PreferencesProviderErrorView.swift b/Sources/CodexBar/PreferencesProviderErrorView.swift
index 55d45fcbb..613a975d2 100644
--- a/Sources/CodexBar/PreferencesProviderErrorView.swift
+++ b/Sources/CodexBar/PreferencesProviderErrorView.swift
@@ -1,3 +1,4 @@
+import CodexBarCore
import SwiftUI
struct ProviderErrorDisplay: Sendable {
@@ -26,7 +27,7 @@ struct ProviderErrorView: View {
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
- .help("Copy error")
+ .help(L10n.tr("settings.providers.error.copy", fallback: "Copy error"))
}
Text(self.display.preview)
@@ -36,7 +37,12 @@ struct ProviderErrorView: View {
.fixedSize(horizontal: false, vertical: true)
if self.display.preview != self.display.full {
- Button(self.isExpanded ? "Hide details" : "Show details") { self.isExpanded.toggle() }
+ Button(self.isExpanded
+ ? L10n.tr("settings.providers.error.hide_details", fallback: "Hide details")
+ : L10n.tr("settings.providers.error.show_details", fallback: "Show details"))
+ {
+ self.isExpanded.toggle()
+ }
.buttonStyle(.link)
.font(.footnote)
}
diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift
index 5d7abde9a..d1bf61873 100644
--- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift
+++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift
@@ -1,3 +1,4 @@
+import CodexBarCore
import SwiftUI
struct ProviderSettingsSection: View {
@@ -218,7 +219,7 @@ struct ProviderSettingsTokenAccountsRowView: View {
let accounts = self.descriptor.accounts()
if accounts.isEmpty {
- Text("No token accounts yet.")
+ Text(L10n.tr("settings.providers.token_accounts.empty", fallback: "No token accounts yet."))
.font(.footnote)
.foregroundStyle(.secondary)
} else {
@@ -235,7 +236,10 @@ struct ProviderSettingsTokenAccountsRowView: View {
.pickerStyle(.menu)
.controlSize(.small)
- Button("Remove selected account") {
+ Button(L10n.tr(
+ "settings.providers.token_accounts.remove_selected",
+ fallback: "Remove selected account"))
+ {
let account = accounts[selectedIndex]
self.descriptor.removeAccount(account.id)
}
@@ -244,13 +248,15 @@ struct ProviderSettingsTokenAccountsRowView: View {
}
HStack(spacing: 8) {
- TextField("Label", text: self.$newLabel)
+ TextField(
+ L10n.tr("settings.providers.token_accounts.label_placeholder", fallback: "Label"),
+ text: self.$newLabel)
.textFieldStyle(.roundedBorder)
.font(.footnote)
SecureField(self.descriptor.placeholder, text: self.$newToken)
.textFieldStyle(.roundedBorder)
.font(.footnote)
- Button("Add") {
+ Button(L10n.tr("settings.providers.token_accounts.add", fallback: "Add")) {
let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines)
let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines)
guard !label.isEmpty, !token.isEmpty else { return }
@@ -265,12 +271,12 @@ struct ProviderSettingsTokenAccountsRowView: View {
}
HStack(spacing: 10) {
- Button("Open token file") {
+ Button(L10n.tr("settings.providers.token_accounts.open_token_file", fallback: "Open token file")) {
self.descriptor.openConfigFile()
}
.buttonStyle(.link)
.controlSize(.small)
- Button("Reload") {
+ Button(L10n.tr("settings.providers.token_accounts.reload", fallback: "Reload")) {
self.descriptor.reloadFromDisk()
}
.buttonStyle(.link)
diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift
index ee34cb3e7..c23a202dc 100644
--- a/Sources/CodexBar/PreferencesProviderSidebarView.swift
+++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift
@@ -62,7 +62,7 @@ private struct ProviderSidebarRowView: View {
.contentShape(Rectangle())
.padding(.vertical, 4)
.padding(.horizontal, 2)
- .help("Drag to reorder")
+ .help(L10n.tr("settings.providers.sidebar.reorder.help", fallback: "Drag to reorder"))
.onDrag {
self.draggingProvider = self.provider
return NSItemProvider(object: self.provider.rawValue as NSString)
@@ -106,12 +106,13 @@ private struct ProviderSidebarRowView: View {
private var statusText: String {
guard !self.isEnabled else { return self.subtitle }
let lines = self.subtitle.split(separator: "\n", omittingEmptySubsequences: false)
+ let format = L10n.tr("settings.providers.sidebar.status.disabled_prefix", fallback: "Disabled — %@")
if lines.count >= 2 {
let first = lines[0]
let rest = lines.dropFirst().joined(separator: "\n")
- return "Disabled — \(first)\n\(rest)"
+ return String(format: format, locale: .current, "\(first)\n\(rest)")
}
- return "Disabled — \(self.subtitle)"
+ return String(format: format, locale: .current, self.subtitle)
}
}
@@ -135,7 +136,7 @@ private struct ProviderSidebarReorderHandle: View {
width: ProviderSettingsMetrics.reorderHandleSize,
height: ProviderSettingsMetrics.reorderHandleSize)
.foregroundStyle(.tertiary)
- .accessibilityLabel("Reorder")
+ .accessibilityLabel(L10n.tr("settings.providers.sidebar.accessibility.reorder", fallback: "Reorder"))
}
}
diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift
index 379487ace..13bc8f53e 100644
--- a/Sources/CodexBar/PreferencesProvidersPane.swift
+++ b/Sources/CodexBar/PreferencesProvidersPane.swift
@@ -48,7 +48,7 @@ struct ProvidersPane: View {
}
})
} else {
- Text("Select a provider")
+ Text(L10n.tr("settings.providers.select_provider", fallback: "Select a provider"))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
@@ -76,7 +76,9 @@ struct ProvidersPane: View {
active.onConfirm()
self.activeConfirmation = nil
}
- Button("Cancel", role: .cancel) { self.activeConfirmation = nil }
+ Button(L10n.tr("settings.providers.alert.cancel", fallback: "Cancel"), role: .cancel) {
+ self.activeConfirmation = nil
+ }
}
},
message: {
@@ -113,9 +115,9 @@ struct ProvidersPane: View {
let relative = snapshot.updatedAt.relativeDescription()
usageText = relative
} else if self.store.isStale(provider: provider) {
- usageText = "last fetch failed"
+ usageText = L10n.tr("settings.providers.subtitle.last_fetch_failed", fallback: "last fetch failed")
} else {
- usageText = "usage not fetched yet"
+ usageText = L10n.tr("settings.providers.subtitle.not_fetched_yet", fallback: "usage not fetched yet")
}
let presentationContext = ProviderPresentationContext(
@@ -256,24 +258,41 @@ struct ProvidersPane: View {
if provider == .zai { return nil }
let metadata = self.store.metadata(for: provider)
let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider)
+ let primaryFormat = L10n.tr(
+ "settings.providers.menu_bar_metric.option.primary_with_label",
+ fallback: "Primary (%@)")
+ let secondaryFormat = L10n.tr(
+ "settings.providers.menu_bar_metric.option.secondary_with_label",
+ fallback: "Secondary (%@)")
var options: [ProviderSettingsPickerOption] = [
- ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"),
+ ProviderSettingsPickerOption(
+ id: MenuBarMetricPreference.automatic.rawValue,
+ title: MenuBarMetricPreference.automatic.label),
ProviderSettingsPickerOption(
id: MenuBarMetricPreference.primary.rawValue,
- title: "Primary (\(metadata.sessionLabel))"),
+ title: String(format: primaryFormat, locale: .current, metadata.sessionLabel)),
ProviderSettingsPickerOption(
id: MenuBarMetricPreference.secondary.rawValue,
- title: "Secondary (\(metadata.weeklyLabel))"),
+ title: String(format: secondaryFormat, locale: .current, metadata.weeklyLabel)),
]
if supportsAverage {
+ let averageFormat = L10n.tr(
+ "settings.providers.menu_bar_metric.option.average_with_labels",
+ fallback: "Average (%@ + %@)")
options.append(ProviderSettingsPickerOption(
id: MenuBarMetricPreference.average.rawValue,
- title: "Average (\(metadata.sessionLabel) + \(metadata.weeklyLabel))"))
+ title: String(
+ format: averageFormat,
+ locale: .current,
+ metadata.sessionLabel,
+ metadata.weeklyLabel)))
}
return ProviderSettingsPickerDescriptor(
id: "menuBarMetric",
- title: "Menu bar metric",
- subtitle: "Choose which window drives the menu bar percent.",
+ title: L10n.tr("settings.providers.menu_bar_metric.title", fallback: "Menu bar metric"),
+ subtitle: L10n.tr(
+ "settings.providers.menu_bar_metric.subtitle",
+ fallback: "Choose which window drives the menu bar percent."),
binding: Binding(
get: { self.settings.menuBarMetricPreference(for: provider).rawValue },
set: { rawValue in
@@ -320,6 +339,7 @@ struct ProvidersPane: View {
let input = UsageMenuCardView.Model.Input(
provider: provider,
metadata: metadata,
+ sourceLabel: self.store.sourceLabel(for: provider),
snapshot: snapshot,
credits: credits,
creditsError: creditsError,
diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift
index 39413302c..2952419b9 100644
--- a/Sources/CodexBar/PreferencesView.swift
+++ b/Sources/CodexBar/PreferencesView.swift
@@ -1,4 +1,5 @@
import AppKit
+import CodexBarCore
import SwiftUI
enum PreferencesTab: String, Hashable {
@@ -34,28 +35,52 @@ struct PreferencesView: View {
var body: some View {
TabView(selection: self.$selection.tab) {
GeneralPane(settings: self.settings, store: self.store)
- .tabItem { Label("General", systemImage: "gearshape") }
+ .tabItem {
+ Label(
+ L10n.tr("settings.preferences.tab.general", fallback: "General"),
+ systemImage: "gearshape")
+ }
.tag(PreferencesTab.general)
ProvidersPane(settings: self.settings, store: self.store)
- .tabItem { Label("Providers", systemImage: "square.grid.2x2") }
+ .tabItem {
+ Label(
+ L10n.tr("settings.preferences.tab.providers", fallback: "Providers"),
+ systemImage: "square.grid.2x2")
+ }
.tag(PreferencesTab.providers)
DisplayPane(settings: self.settings)
- .tabItem { Label("Display", systemImage: "eye") }
+ .tabItem {
+ Label(
+ L10n.tr("settings.preferences.tab.display", fallback: "Display"),
+ systemImage: "eye")
+ }
.tag(PreferencesTab.display)
AdvancedPane(settings: self.settings)
- .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") }
+ .tabItem {
+ Label(
+ L10n.tr("settings.preferences.tab.advanced", fallback: "Advanced"),
+ systemImage: "slider.horizontal.3")
+ }
.tag(PreferencesTab.advanced)
AboutPane(updater: self.updater)
- .tabItem { Label("About", systemImage: "info.circle") }
+ .tabItem {
+ Label(
+ L10n.tr("settings.preferences.tab.about", fallback: "About"),
+ systemImage: "info.circle")
+ }
.tag(PreferencesTab.about)
if self.settings.debugMenuEnabled {
DebugPane(settings: self.settings, store: self.store)
- .tabItem { Label("Debug", systemImage: "ladybug") }
+ .tabItem {
+ Label(
+ L10n.tr("settings.preferences.tab.debug", fallback: "Debug"),
+ systemImage: "ladybug")
+ }
.tag(PreferencesTab.debug)
}
}
diff --git a/Sources/CodexBar/ProviderSwitcherButtons.swift b/Sources/CodexBar/ProviderSwitcherButtons.swift
index 05ce53c53..6fd47f71d 100644
--- a/Sources/CodexBar/ProviderSwitcherButtons.swift
+++ b/Sources/CodexBar/ProviderSwitcherButtons.swift
@@ -66,6 +66,13 @@ final class InlineIconToggleButton: NSButton {
self.titleField.textColor = color
}
+ func setIconSize(_ size: CGFloat) {
+ guard self.iconSizeConstraints.count == 2 else { return }
+ self.iconSizeConstraints[0].constant = size
+ self.iconSizeConstraints[1].constant = size
+ if !self.isConfiguring { self.invalidateIntrinsicContentSize() }
+ }
+
func setTitleFontSize(_ size: CGFloat) {
self.titleField.font = NSFont.systemFont(ofSize: size)
}
@@ -109,7 +116,7 @@ final class InlineIconToggleButton: NSButton {
self.controlSize = .small
self.wantsLayer = true
- self.iconView.imageScaling = .scaleNone
+ self.iconView.imageScaling = .scaleProportionallyUpOrDown
self.iconView.translatesAutoresizingMaskIntoConstraints = false
self.titleField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
self.titleField.alignment = .left
@@ -195,6 +202,13 @@ final class StackedToggleButton: NSButton {
self.titleField.textColor = color
}
+ func setIconSize(_ size: CGFloat) {
+ guard self.iconSizeConstraints.count == 2 else { return }
+ self.iconSizeConstraints[0].constant = size
+ self.iconSizeConstraints[1].constant = size
+ if !self.isConfiguring { self.invalidateIntrinsicContentSize() }
+ }
+
func setTitleFontSize(_ size: CGFloat) {
self.titleField.font = NSFont.systemFont(ofSize: size)
}
@@ -238,7 +252,7 @@ final class StackedToggleButton: NSButton {
self.controlSize = .small
self.wantsLayer = true
- self.iconView.imageScaling = .scaleNone
+ self.iconView.imageScaling = .scaleProportionallyUpOrDown
self.iconView.translatesAutoresizingMaskIntoConstraints = false
self.titleField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize - 2)
self.titleField.alignment = .center
diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityProxyProviderImplementation.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityProxyProviderImplementation.swift
new file mode 100644
index 000000000..7fb601c06
--- /dev/null
+++ b/Sources/CodexBar/Providers/Antigravity/AntigravityProxyProviderImplementation.swift
@@ -0,0 +1,28 @@
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct AntigravityProxyProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .antigravityproxy
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.cliProxyGlobalBaseURL
+ _ = settings.cliProxyGlobalManagementKey
+ _ = settings.cliProxyGlobalAuthIndex
+ _ = settings.codexCLIProxyBaseURL
+ _ = settings.codexCLIProxyManagementKey
+ _ = settings.codexCLIProxyAuthIndex
+ }
+
+ @MainActor
+ func defaultSourceLabel(context _: ProviderSourceLabelContext) -> String? {
+ "cliproxy-api"
+ }
+
+ @MainActor
+ func sourceMode(context _: ProviderSourceModeContext) -> ProviderSourceMode {
+ .api
+ }
+}
diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift
index 35baa270d..921460c98 100644
--- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift
@@ -11,7 +11,8 @@ struct CodexProviderImplementation: ProviderImplementation {
@MainActor
func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
ProviderPresentation { context in
- context.store.version(for: context.provider) ?? "not detected"
+ context.store.version(for: context.provider)
+ ?? L10n.tr("provider.codex.version.not_detected", fallback: "not detected")
}
}
@@ -20,6 +21,9 @@ struct CodexProviderImplementation: ProviderImplementation {
_ = settings.codexUsageDataSource
_ = settings.codexCookieSource
_ = settings.codexCookieHeader
+ _ = settings.codexCLIProxyBaseURL
+ _ = settings.codexCLIProxyManagementKey
+ _ = settings.codexCLIProxyAuthIndex
}
@MainActor
@@ -49,6 +53,7 @@ struct CodexProviderImplementation: ProviderImplementation {
switch context.settings.codexUsageDataSource {
case .auto: .auto
case .oauth: .oauth
+ case .api: .auto
case .cli: .cli
}
}
@@ -73,8 +78,10 @@ struct CodexProviderImplementation: ProviderImplementation {
return [
ProviderSettingsToggleDescriptor(
id: "codex-openai-web-extras",
- title: "OpenAI web extras",
- subtitle: "Show usage breakdown, credits history, and code review via chatgpt.com.",
+ title: L10n.tr("provider.codex.toggle.openai_web_extras.title", fallback: "OpenAI web extras"),
+ subtitle: L10n.tr(
+ "provider.codex.toggle.openai_web_extras.subtitle",
+ fallback: "Show usage breakdown, credits history, and code review via chatgpt.com."),
binding: extrasBinding,
statusText: nil,
actions: [],
@@ -98,7 +105,7 @@ struct CodexProviderImplementation: ProviderImplementation {
context.settings.codexCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
})
- let usageOptions = CodexUsageDataSource.allCases.map {
+ let usageOptions = CodexUsageDataSource.allCases.filter { $0 != .api }.map {
ProviderSettingsPickerOption(id: $0.rawValue, title: $0.displayName)
}
let cookieOptions = ProviderCookieSourceUI.options(
@@ -109,16 +116,24 @@ struct CodexProviderImplementation: ProviderImplementation {
ProviderCookieSourceUI.subtitle(
source: context.settings.codexCookieSource,
keychainDisabled: context.settings.debugDisableKeychainAccess,
- auto: "Automatic imports browser cookies for dashboard extras.",
- manual: "Paste a Cookie header from a chatgpt.com request.",
- off: "Disable OpenAI dashboard cookie usage.")
+ auto: L10n.tr(
+ "provider.codex.picker.cookie_source.auto",
+ fallback: "Automatic imports browser cookies for dashboard extras."),
+ manual: L10n.tr(
+ "provider.codex.picker.cookie_source.manual",
+ fallback: "Paste a Cookie header from a chatgpt.com request."),
+ off: L10n.tr(
+ "provider.codex.picker.cookie_source.off",
+ fallback: "Disable OpenAI dashboard cookie usage."))
}
return [
ProviderSettingsPickerDescriptor(
id: "codex-usage-source",
- title: "Usage source",
- subtitle: "Auto falls back to the next source if the preferred one fails.",
+ title: L10n.tr("provider.codex.picker.usage_source.title", fallback: "Usage source"),
+ subtitle: L10n.tr(
+ "provider.codex.picker.usage_source.subtitle",
+ fallback: "Auto falls back to the next source if the preferred one fails."),
binding: usageBinding,
options: usageOptions,
isVisible: nil,
@@ -130,8 +145,10 @@ struct CodexProviderImplementation: ProviderImplementation {
}),
ProviderSettingsPickerDescriptor(
id: "codex-cookie-source",
- title: "OpenAI cookies",
- subtitle: "Automatic imports browser cookies for dashboard extras.",
+ title: L10n.tr("provider.codex.picker.cookie_source.title", fallback: "OpenAI cookies"),
+ subtitle: L10n.tr(
+ "provider.codex.picker.cookie_source.subtitle",
+ fallback: "Automatic imports browser cookies for dashboard extras."),
dynamicSubtitle: cookieSubtitle,
binding: cookieBinding,
options: cookieOptions,
@@ -140,7 +157,8 @@ struct CodexProviderImplementation: ProviderImplementation {
trailingText: {
guard let entry = CookieHeaderCache.load(provider: .codex) else { return nil }
let when = entry.storedAt.relativeDescription()
- return "Cached: \(entry.sourceLabel) • \(when)"
+ let format = L10n.tr("provider.codex.cookie.cached", fallback: "Cached: %@ • %@")
+ return String(format: format, locale: .current, entry.sourceLabel, when)
}),
]
}
@@ -153,7 +171,7 @@ struct CodexProviderImplementation: ProviderImplementation {
title: "",
subtitle: "",
kind: .secure,
- placeholder: "Cookie: …",
+ placeholder: L10n.tr("provider.codex.field.cookie_header.placeholder", fallback: "Cookie: …"),
binding: context.stringBinding(\.codexCookieHeader),
actions: [],
isVisible: {
@@ -168,13 +186,19 @@ struct CodexProviderImplementation: ProviderImplementation {
guard context.settings.showOptionalCreditsAndExtraUsage,
context.metadata.supportsCredits
else { return }
+ let isCLIProxySource = context.store.sourceLabel(for: .codex)
+ .localizedCaseInsensitiveContains("cliproxy-api")
if let credits = context.store.credits {
- entries.append(.text("Credits: \(UsageFormatter.creditsString(from: credits.remaining))", .primary))
+ let creditsValue = UsageFormatter.creditsString(from: credits.remaining)
+ let creditsFormat = L10n.tr("provider.codex.menu.credits", fallback: "Credits: %@")
+ entries.append(.text(String(format: creditsFormat, locale: .current, creditsValue), .primary))
if let latest = credits.events.first {
- entries.append(.text("Last spend: \(UsageFormatter.creditEventSummary(latest))", .secondary))
+ let spendValue = UsageFormatter.creditEventSummary(latest)
+ let spendFormat = L10n.tr("provider.codex.menu.last_spend", fallback: "Last spend: %@")
+ entries.append(.text(String(format: spendFormat, locale: .current, spendValue), .secondary))
}
- } else {
+ } else if !isCLIProxySource {
let hint = context.store.lastCreditsError ?? context.metadata.creditsHint
entries.append(.text(hint, .secondary))
}
diff --git a/Sources/CodexBar/Providers/Codex/CodexProxyProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProxyProviderImplementation.swift
new file mode 100644
index 000000000..1b128d4c5
--- /dev/null
+++ b/Sources/CodexBar/Providers/Codex/CodexProxyProviderImplementation.swift
@@ -0,0 +1,28 @@
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct CodexProxyProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .codexproxy
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.cliProxyGlobalBaseURL
+ _ = settings.cliProxyGlobalManagementKey
+ _ = settings.cliProxyGlobalAuthIndex
+ _ = settings.codexCLIProxyBaseURL
+ _ = settings.codexCLIProxyManagementKey
+ _ = settings.codexCLIProxyAuthIndex
+ }
+
+ @MainActor
+ func defaultSourceLabel(context _: ProviderSourceLabelContext) -> String? {
+ "cliproxy-api"
+ }
+
+ @MainActor
+ func sourceMode(context _: ProviderSourceModeContext) -> ProviderSourceMode {
+ .api
+ }
+}
diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift
index 335dbf411..5113062c6 100644
--- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift
+++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift
@@ -11,6 +11,7 @@ extension SettingsStore {
let source: ProviderSourceMode? = switch newValue {
case .auto: .auto
case .oauth: .oauth
+ case .api: .api
case .cli: .cli
}
self.updateProviderConfig(provider: .codex) { entry in
@@ -44,15 +45,69 @@ extension SettingsStore {
}
}
+ var codexCLIProxyBaseURL: String {
+ get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedAPIBaseURL ?? "" }
+ set {
+ self.updateProviderConfig(provider: .codex) { entry in
+ entry.apiBaseURL = self.normalizedConfigValue(newValue)
+ }
+ self.logProviderModeChange(provider: .codex, field: "apiBaseURL", value: newValue)
+ }
+ }
+
+ var codexCLIProxyManagementKey: String {
+ get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedAPIKey ?? "" }
+ set {
+ self.updateProviderConfig(provider: .codex) { entry in
+ entry.apiKey = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .codex, field: "apiKey", value: newValue)
+ }
+ }
+
+ var codexCLIProxyAuthIndex: String {
+ get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedAPIAuthIndex ?? "" }
+ set {
+ self.updateProviderConfig(provider: .codex) { entry in
+ entry.apiAuthIndex = self.normalizedConfigValue(newValue)
+ }
+ self.logProviderModeChange(provider: .codex, field: "apiAuthIndex", value: newValue)
+ }
+ }
+
func ensureCodexCookieLoaded() {}
}
extension SettingsStore {
func codexSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.CodexProviderSettings {
- ProviderSettingsSnapshot.CodexProviderSettings(
+ let resolvedBaseURL: String = {
+ let globalValue = self.cliProxyGlobalBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !globalValue.isEmpty { return globalValue }
+ let providerValue = self.codexCLIProxyBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !providerValue.isEmpty { return providerValue }
+ return globalValue
+ }()
+ let resolvedManagementKey: String = {
+ let globalValue = self.cliProxyGlobalManagementKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !globalValue.isEmpty { return globalValue }
+ let providerValue = self.codexCLIProxyManagementKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !providerValue.isEmpty { return providerValue }
+ return globalValue
+ }()
+ let resolvedAuthIndex: String = {
+ let globalValue = self.cliProxyGlobalAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !globalValue.isEmpty { return globalValue }
+ let providerValue = self.codexCLIProxyAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !providerValue.isEmpty { return providerValue }
+ return globalValue
+ }()
+ return ProviderSettingsSnapshot.CodexProviderSettings(
usageDataSource: self.codexUsageDataSource,
cookieSource: self.codexSnapshotCookieSource(tokenOverride: tokenOverride),
- manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride))
+ manualCookieHeader: self.codexSnapshotCookieHeader(tokenOverride: tokenOverride),
+ cliProxyBaseURL: resolvedBaseURL,
+ cliProxyManagementKey: resolvedManagementKey,
+ cliProxyAuthIndex: resolvedAuthIndex)
}
private static func codexUsageDataSource(from source: ProviderSourceMode?) -> CodexUsageDataSource {
@@ -94,4 +149,29 @@ extension SettingsStore {
if self.tokenAccounts(for: .codex).isEmpty { return fallback }
return .manual
}
+
+ func migrateLegacyCodexCLIProxyDefaultsIfNeeded() {
+ guard let entry = self.configSnapshot.providerConfig(for: .codex) else { return }
+
+ let legacyBaseURL = entry.sanitizedAPIBaseURL?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let legacyManagementKey = entry.sanitizedAPIKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let legacyAuthIndex = entry.sanitizedAPIAuthIndex?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+
+ let globalBaseURL = self.cliProxyGlobalBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
+ let shouldAdoptLegacyBaseURL = globalBaseURL.isEmpty ||
+ (globalBaseURL == CodexCLIProxySettings.defaultBaseURL && !legacyBaseURL.isEmpty && legacyBaseURL != globalBaseURL)
+ if shouldAdoptLegacyBaseURL, !legacyBaseURL.isEmpty {
+ self.cliProxyGlobalBaseURL = legacyBaseURL
+ }
+
+ let globalManagementKey = self.cliProxyGlobalManagementKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ if globalManagementKey.isEmpty, !legacyManagementKey.isEmpty {
+ self.cliProxyGlobalManagementKey = legacyManagementKey
+ }
+
+ let globalAuthIndex = self.cliProxyGlobalAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines)
+ if globalAuthIndex.isEmpty, !legacyAuthIndex.isEmpty {
+ self.cliProxyGlobalAuthIndex = legacyAuthIndex
+ }
+ }
}
diff --git a/Sources/CodexBar/Providers/Gemini/GeminiProxyProviderImplementation.swift b/Sources/CodexBar/Providers/Gemini/GeminiProxyProviderImplementation.swift
new file mode 100644
index 000000000..658262867
--- /dev/null
+++ b/Sources/CodexBar/Providers/Gemini/GeminiProxyProviderImplementation.swift
@@ -0,0 +1,28 @@
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderImplementationRegistration
+struct GeminiProxyProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .geminiproxy
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.cliProxyGlobalBaseURL
+ _ = settings.cliProxyGlobalManagementKey
+ _ = settings.cliProxyGlobalAuthIndex
+ _ = settings.codexCLIProxyBaseURL
+ _ = settings.codexCLIProxyManagementKey
+ _ = settings.codexCLIProxyAuthIndex
+ }
+
+ @MainActor
+ func defaultSourceLabel(context _: ProviderSourceLabelContext) -> String? {
+ "cliproxy-api"
+ }
+
+ @MainActor
+ func sourceMode(context _: ProviderSourceModeContext) -> ProviderSourceMode {
+ .api
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index f6a9b2a3b..0334d0c93 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -13,6 +13,9 @@ enum ProviderImplementationRegistry {
private static func makeImplementation(for provider: UsageProvider) -> (any ProviderImplementation) {
switch provider {
case .codex: CodexProviderImplementation()
+ case .codexproxy: CodexProxyProviderImplementation()
+ case .geminiproxy: GeminiProxyProviderImplementation()
+ case .antigravityproxy: AntigravityProxyProviderImplementation()
case .claude: ClaudeProviderImplementation()
case .cursor: CursorProviderImplementation()
case .opencode: OpenCodeProviderImplementation()
diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift
index 0e06b99fc..d053df457 100644
--- a/Sources/CodexBar/SettingsStore+Defaults.swift
+++ b/Sources/CodexBar/SettingsStore+Defaults.swift
@@ -274,6 +274,47 @@ extension SettingsStore {
}
}
+ var appLanguage: AppLanguageOption {
+ get { AppLanguageOption(rawValue: self.defaultsState.appLanguageRaw) ?? .system }
+ set {
+ self.defaultsState.appLanguageRaw = newValue.rawValue
+ self.userDefaults.set(newValue.rawValue, forKey: "appLanguageCode")
+ switch newValue {
+ case .system:
+ self.userDefaults.removeObject(forKey: "AppleLanguages")
+ case .english, .simplifiedChinese:
+ self.userDefaults.set([newValue.rawValue], forKey: "AppleLanguages")
+ }
+ }
+ }
+
+ var cliProxyGlobalBaseURL: String {
+ get { self.defaultsState.cliProxyGlobalBaseURL }
+ set {
+ let normalized = self.normalizedConfigValue(newValue) ?? ""
+ self.defaultsState.cliProxyGlobalBaseURL = normalized
+ self.userDefaults.set(normalized, forKey: "cliProxyGlobalBaseURL")
+ }
+ }
+
+ var cliProxyGlobalManagementKey: String {
+ get { self.defaultsState.cliProxyGlobalManagementKey }
+ set {
+ let normalized = self.normalizedConfigValue(newValue) ?? ""
+ self.defaultsState.cliProxyGlobalManagementKey = normalized
+ self.userDefaults.set(normalized, forKey: "cliProxyGlobalManagementKey")
+ }
+ }
+
+ var cliProxyGlobalAuthIndex: String {
+ get { self.defaultsState.cliProxyGlobalAuthIndex }
+ set {
+ let normalized = self.normalizedConfigValue(newValue) ?? ""
+ self.defaultsState.cliProxyGlobalAuthIndex = normalized
+ self.userDefaults.set(normalized, forKey: "cliProxyGlobalAuthIndex")
+ }
+ }
+
var debugLoadingPattern: LoadingPattern? {
get { self.debugLoadingPatternRaw.flatMap(LoadingPattern.init(rawValue:)) }
set { self.debugLoadingPatternRaw = newValue?.rawValue }
diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift
index e140de0ac..100b1aeda 100644
--- a/Sources/CodexBar/SettingsStore.swift
+++ b/Sources/CodexBar/SettingsStore.swift
@@ -28,12 +28,18 @@ enum RefreshFrequency: String, CaseIterable, Identifiable {
var label: String {
switch self {
- case .manual: "Manual"
- case .oneMinute: "1 min"
- case .twoMinutes: "2 min"
- case .fiveMinutes: "5 min"
- case .fifteenMinutes: "15 min"
- case .thirtyMinutes: "30 min"
+ case .manual:
+ L10n.tr("settings.general.refresh_frequency.manual", fallback: "Manual")
+ case .oneMinute:
+ L10n.tr("settings.general.refresh_frequency.one_minute", fallback: "1 min")
+ case .twoMinutes:
+ L10n.tr("settings.general.refresh_frequency.two_minutes", fallback: "2 min")
+ case .fiveMinutes:
+ L10n.tr("settings.general.refresh_frequency.five_minutes", fallback: "5 min")
+ case .fifteenMinutes:
+ L10n.tr("settings.general.refresh_frequency.fifteen_minutes", fallback: "15 min")
+ case .thirtyMinutes:
+ L10n.tr("settings.general.refresh_frequency.thirty_minutes", fallback: "30 min")
}
}
}
@@ -50,10 +56,35 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable {
var label: String {
switch self {
- case .automatic: "Automatic"
- case .primary: "Primary"
- case .secondary: "Secondary"
- case .average: "Average"
+ case .automatic:
+ L10n.tr("settings.providers.menu_bar_metric.option.automatic", fallback: "Automatic")
+ case .primary:
+ L10n.tr("settings.providers.menu_bar_metric.option.primary", fallback: "Primary")
+ case .secondary:
+ L10n.tr("settings.providers.menu_bar_metric.option.secondary", fallback: "Secondary")
+ case .average:
+ L10n.tr("settings.providers.menu_bar_metric.option.average", fallback: "Average")
+ }
+ }
+}
+
+enum AppLanguageOption: String, CaseIterable, Identifiable {
+ case system
+ case english = "en"
+ case simplifiedChinese = "zh-Hans"
+
+ var id: String {
+ self.rawValue
+ }
+
+ var label: String {
+ switch self {
+ case .system:
+ return L10n.tr("settings.general.language.option.system", fallback: "System")
+ case .english:
+ return L10n.tr("settings.general.language.option.english", fallback: "English")
+ case .simplifiedChinese:
+ return L10n.tr("settings.general.language.option.zh_hans", fallback: "简体中文")
}
}
}
@@ -139,7 +170,11 @@ final class SettingsStore {
self.config = config
self.configLoading = true
self.defaultsState = Self.loadDefaultsState(userDefaults: userDefaults)
+ if AppLanguageOption(rawValue: self.defaultsState.appLanguageRaw) == .system {
+ self.userDefaults.removeObject(forKey: "AppleLanguages")
+ }
self.updateProviderState(config: config)
+ self.migrateLegacyCodexCLIProxyDefaultsIfNeeded()
self.configLoading = false
CodexBarLog.setFileLoggingEnabled(self.debugFileLoggingEnabled)
userDefaults.removeObject(forKey: "showCodexUsage")
@@ -215,6 +250,10 @@ extension SettingsStore {
let switcherShowsIcons = userDefaults.object(forKey: "switcherShowsIcons") as? Bool ?? true
let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider")
let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false
+ let appLanguageRaw = userDefaults.string(forKey: "appLanguageCode") ?? AppLanguageOption.system.rawValue
+ let cliProxyGlobalBaseURL = userDefaults.string(forKey: "cliProxyGlobalBaseURL") ?? ""
+ let cliProxyGlobalManagementKey = userDefaults.string(forKey: "cliProxyGlobalManagementKey") ?? ""
+ let cliProxyGlobalAuthIndex = userDefaults.string(forKey: "cliProxyGlobalAuthIndex") ?? ""
return SettingsDefaultsState(
refreshFrequency: refreshFrequency,
@@ -244,7 +283,11 @@ extension SettingsStore {
mergeIcons: mergeIcons,
switcherShowsIcons: switcherShowsIcons,
selectedMenuProviderRaw: selectedMenuProviderRaw,
- providerDetectionCompleted: providerDetectionCompleted)
+ providerDetectionCompleted: providerDetectionCompleted,
+ appLanguageRaw: appLanguageRaw,
+ cliProxyGlobalBaseURL: cliProxyGlobalBaseURL,
+ cliProxyGlobalManagementKey: cliProxyGlobalManagementKey,
+ cliProxyGlobalAuthIndex: cliProxyGlobalAuthIndex)
}
}
diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift
index 9d8e833ba..632b01417 100644
--- a/Sources/CodexBar/SettingsStoreState.swift
+++ b/Sources/CodexBar/SettingsStoreState.swift
@@ -29,4 +29,8 @@ struct SettingsDefaultsState: Sendable {
var switcherShowsIcons: Bool
var selectedMenuProviderRaw: String?
var providerDetectionCompleted: Bool
+ var appLanguageRaw: String
+ var cliProxyGlobalBaseURL: String
+ var cliProxyGlobalManagementKey: String
+ var cliProxyGlobalAuthIndex: String
}
diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift
index a86e51444..18def1dd6 100644
--- a/Sources/CodexBar/StatusItemController+Actions.swift
+++ b/Sources/CodexBar/StatusItemController+Actions.swift
@@ -32,7 +32,11 @@ extension StatusItemController {
let meta = self.store.metadata(for: provider)
// For Claude, route subscription users to claude.ai/settings/usage instead of console billing
- let urlString: String? = if provider == .claude, self.store.isClaudeSubscription() {
+ let urlString: String? = if self.isCLIProxyDashboardProvider(provider),
+ let cliProxyDashboardURL = self.cliProxyUsageDashboardURL()
+ {
+ cliProxyDashboardURL.absoluteString
+ } else if provider == .claude, self.store.isClaudeSubscription() {
meta.subscriptionDashboardURL ?? meta.dashboardURL
} else {
meta.dashboardURL
@@ -70,6 +74,25 @@ extension StatusItemController {
return url.absoluteString
}
+ private func isCLIProxyDashboardProvider(_ provider: UsageProvider) -> Bool {
+ provider == .codexproxy || provider == .geminiproxy || provider == .antigravityproxy
+ }
+
+ private func cliProxyUsageDashboardURL() -> URL? {
+ let providerSettings = self.settings.codexSettingsSnapshot(tokenOverride: nil)
+ guard let cliProxySettings = CodexCLIProxySettings.resolve(
+ providerSettings: providerSettings,
+ environment: ProcessInfo.processInfo.environment)
+ else {
+ return nil
+ }
+
+ let dashboardURL = cliProxySettings.baseURL.appendingPathComponent("management.html", isDirectory: false)
+ guard var components = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) else { return dashboardURL }
+ components.fragment = "/usage"
+ return components.url ?? dashboardURL
+ }
+
@objc func openStatusPage() {
let preferred = self.lastMenuProvider
?? (self.store.isEnabled(.codex) ? .codex : self.store.enabledProviders().first)
diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift
index 2fb70778e..ac2a7cf29 100644
--- a/Sources/CodexBar/StatusItemController+Animation.swift
+++ b/Sources/CodexBar/StatusItemController+Animation.swift
@@ -187,7 +187,6 @@ extension StatusItemController {
let style = self.store.iconStyle
let showUsed = self.settings.usageBarsShowUsed
- let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent
let primaryProvider = self.primaryProviderForUnifiedIcon()
let snapshot = self.store.snapshot(for: primaryProvider)
@@ -233,15 +232,6 @@ extension StatusItemController {
return .none
}()
- if showBrandPercent,
- let brand = ProviderBrandIcon.image(for: primaryProvider)
- {
- let displayText = self.menuBarDisplayText(for: primaryProvider, snapshot: snapshot)
- self.setButtonImage(brand, for: button)
- self.setButtonTitle(displayText, for: button)
- return
- }
-
self.setButtonTitle(nil, for: button)
if let morphProgress {
let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style)
@@ -267,16 +257,6 @@ extension StatusItemController {
// IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the
// user setting we pass either "percent left" or "percent used".
let showUsed = self.settings.usageBarsShowUsed
- let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent
-
- if showBrandPercent,
- let brand = ProviderBrandIcon.image(for: provider)
- {
- let displayText = self.menuBarDisplayText(for: provider, snapshot: snapshot)
- self.setButtonImage(brand, for: button)
- self.setButtonTitle(displayText, for: button)
- return
- }
var primary = showUsed ? snapshot?.primary?.usedPercent : snapshot?.primary?.remainingPercent
var weekly = showUsed ? snapshot?.secondary?.usedPercent : snapshot?.secondary?.remainingPercent
var credits: Double? = provider == .codex ? self.store.credits?.remaining : nil
diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift
index 2167b2914..a68cf13e4 100644
--- a/Sources/CodexBar/StatusItemController+Menu.swift
+++ b/Sources/CodexBar/StatusItemController+Menu.swift
@@ -279,6 +279,47 @@ extension StatusItemController {
private func addMenuCards(to menu: NSMenu, context: MenuCardContext) -> Bool {
if let tokenAccountDisplay = context.tokenAccountDisplay, tokenAccountDisplay.showAll {
let accountSnapshots = tokenAccountDisplay.snapshots
+ let shouldShowAggregateCard = self.isCLIProxyMultiAuthDisplay(
+ provider: context.currentProvider,
+ display: tokenAccountDisplay)
+ if shouldShowAggregateCard, let aggregateModel = self.menuCardModel(for: context.selectedProvider) {
+ menu.addItem(self.makeMenuCardItem(
+ UsageMenuCardView(model: aggregateModel, width: context.menuWidth),
+ id: "menuCard-aggregate",
+ width: context.menuWidth))
+ if !accountSnapshots.isEmpty {
+ menu.addItem(.separator())
+ }
+ }
+ if shouldShowAggregateCard {
+ let entries = self.codexCLIProxyCompactEntries(
+ from: accountSnapshots,
+ provider: context.currentProvider)
+ if !entries.isEmpty {
+ let providerName = self.store.metadata(for: context.currentProvider).displayName
+ let compactView = CodexCLIProxyAuthCompactGridView(
+ providerDisplayName: providerName,
+ entries: entries)
+ menu.addItem(self.makeMenuCardItem(
+ compactView,
+ id: "menuCard-auth-grid",
+ width: context.menuWidth))
+ }
+ if let inlineCostHistoryItem = self.makeCostHistoryInlineItem(
+ provider: context.currentProvider,
+ width: context.menuWidth)
+ {
+ if !entries.isEmpty {
+ menu.addItem(.separator())
+ }
+ menu.addItem(inlineCostHistoryItem)
+ menu.addItem(.separator())
+ } else if !entries.isEmpty {
+ menu.addItem(.separator())
+ }
+ return false
+ }
+
let cards = accountSnapshots.isEmpty
? []
: accountSnapshots.compactMap { accountSnapshot in
@@ -287,7 +328,8 @@ extension StatusItemController {
snapshotOverride: accountSnapshot.snapshot,
errorOverride: accountSnapshot.error)
}
- if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) {
+
+ if cards.isEmpty, !shouldShowAggregateCard, let model = self.menuCardModel(for: context.selectedProvider) {
menu.addItem(self.makeMenuCardItem(
UsageMenuCardView(model: model, width: context.menuWidth),
id: "menuCard",
@@ -484,6 +526,20 @@ extension StatusItemController {
}
private func tokenAccountMenuDisplay(for provider: UsageProvider) -> TokenAccountMenuDisplay? {
+ if self.isCLIProxyMultiAuthProvider(provider),
+ let snapshots = self.store.accountSnapshots[provider],
+ snapshots.count > 1,
+ self.store.sourceLabel(for: provider).localizedCaseInsensitiveContains("cliproxy-api")
+ {
+ return TokenAccountMenuDisplay(
+ provider: provider,
+ accounts: snapshots.map(\.account),
+ snapshots: snapshots,
+ activeIndex: 0,
+ showAll: true,
+ showSwitcher: false)
+ }
+
guard TokenAccountSupportCatalog.support(for: provider) != nil else { return nil }
let accounts = self.settings.tokenAccounts(for: provider)
guard accounts.count > 1 else { return nil }
@@ -499,6 +555,50 @@ extension StatusItemController {
showSwitcher: !showAll)
}
+ private func isCLIProxyMultiAuthProvider(_ provider: UsageProvider) -> Bool {
+ provider == .codex || provider == .codexproxy || provider == .geminiproxy || provider == .antigravityproxy
+ }
+
+ private func isCLIProxyMultiAuthDisplay(
+ provider: UsageProvider,
+ display: TokenAccountMenuDisplay) -> Bool
+ {
+ self.isCLIProxyMultiAuthProvider(provider) &&
+ display.showAll &&
+ self.store.sourceLabel(for: provider).localizedCaseInsensitiveContains("cliproxy-api")
+ }
+
+ private func codexCLIProxyCompactEntries(
+ from snapshots: [TokenAccountUsageSnapshot],
+ provider: UsageProvider) -> [CodexCLIProxyAuthCompactGridView.Entry]
+ {
+ snapshots.map { snapshot in
+ let primary = self.percent(for: snapshot.snapshot?.primary)
+ let secondary = self.percent(for: snapshot.snapshot?.secondary)
+ let label = snapshot.account.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let accountTitle: String
+ if label.isEmpty {
+ accountTitle = snapshot.snapshot?.accountEmail(for: provider) ?? provider.rawValue
+ } else {
+ accountTitle = label
+ }
+ return CodexCLIProxyAuthCompactGridView.Entry(
+ id: snapshot.id,
+ accountTitle: accountTitle,
+ primaryPercent: primary,
+ secondaryPercent: secondary,
+ hasError: snapshot.error != nil)
+ }
+ }
+
+ private func percent(for window: RateWindow?) -> Double? {
+ guard let window else { return nil }
+ if self.settings.usageBarsShowUsed {
+ return max(0, min(100, window.usedPercent))
+ }
+ return max(0, min(100, window.remainingPercent))
+ }
+
private func menuNeedsRefresh(_ menu: NSMenu) -> Bool {
let key = ObjectIdentifier(menu)
return self.menuVersions[key] != self.menuContentVersion
@@ -731,12 +831,25 @@ extension StatusItemController {
topPadding: sectionSpacing,
bottomPadding: bottomPadding,
width: width)
- let costSubmenu = webItems.hasCostHistory ? self.makeCostHistorySubmenu(provider: provider) : nil
menu.addItem(self.makeMenuCardItem(
costView,
id: "menuCardCost",
- width: width,
- submenu: costSubmenu))
+ width: width))
+
+ if let inlineCostHistoryItem = self.makeCostHistoryInlineItem(provider: provider, width: width) {
+ menu.addItem(.separator())
+ menu.addItem(inlineCostHistoryItem)
+ } else if webItems.hasCostHistory {
+ let costSubmenu = self.makeCostHistorySubmenu(provider: provider)
+ if costSubmenu != nil {
+ // Fallback for non-rendering mode: still expose chart through submenu.
+ if let lastItem = menu.items.last {
+ lastItem.submenu = costSubmenu
+ lastItem.target = self
+ lastItem.action = #selector(self.menuCardNoOp(_:))
+ }
+ }
+ }
}
}
@@ -904,8 +1017,139 @@ extension StatusItemController {
}
}
+ private struct CodexCLIProxyAuthCompactGridView: View {
+ struct Entry: Identifiable {
+ let id: UUID
+ let accountTitle: String
+ let primaryPercent: Double?
+ let secondaryPercent: Double?
+ let hasError: Bool
+ }
+
+ let providerDisplayName: String
+ let entries: [Entry]
+ @Environment(\.menuItemHighlighted) private var isHighlighted
+
+ private var columns: [GridItem] {
+ [
+ GridItem(.flexible(minimum: 120), spacing: 8),
+ GridItem(.flexible(minimum: 120), spacing: 8),
+ ]
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ let titleFormat = L10n.tr(
+ "menu.cliproxy.auth_grid.title",
+ fallback: "%@ auth entries (%d)")
+ Text(String(format: titleFormat, locale: .current, self.providerDisplayName, self.entries.count))
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+
+ LazyVGrid(columns: self.columns, spacing: 8) {
+ ForEach(self.entries) { entry in
+ AccountCell(
+ entry: entry,
+ isHighlighted: self.isHighlighted)
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ }
+ }
+
+ private struct AccountCell: View {
+ let entry: CodexCLIProxyAuthCompactGridView.Entry
+ let isHighlighted: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(self.entry.accountTitle)
+ .font(.caption.weight(.medium))
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted))
+
+ HStack(spacing: 16) {
+ RingBadge(
+ percent: self.entry.primaryPercent,
+ isError: self.entry.hasError,
+ tint: Color(nsColor: NSColor.systemTeal),
+ isHighlighted: self.isHighlighted)
+ .frame(maxWidth: .infinity)
+ .aspectRatio(1, contentMode: .fit)
+ RingBadge(
+ percent: self.entry.secondaryPercent,
+ isError: self.entry.hasError,
+ tint: Color(nsColor: NSColor.systemIndigo),
+ isHighlighted: self.isHighlighted)
+ .frame(maxWidth: .infinity)
+ .aspectRatio(1, contentMode: .fit)
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 6)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(MenuHighlightStyle.progressTrack(self.isHighlighted)))
+ }
+ }
+
+ private struct RingBadge: View {
+ let percent: Double?
+ let isError: Bool
+ let tint: Color
+ let isHighlighted: Bool
+
+ private var normalizedPercent: Double {
+ guard let percent else { return 0 }
+ return max(0, min(100, percent))
+ }
+
+ var body: some View {
+ GeometryReader { proxy in
+ let diameter = min(proxy.size.width, proxy.size.height)
+ let lineWidth = max(3, diameter * 0.11)
+ let fontSize = max(10, diameter * 0.32)
+
+ ZStack {
+ Circle()
+ .stroke(MenuHighlightStyle.progressTrack(self.isHighlighted), lineWidth: lineWidth)
+ Circle()
+ .trim(from: 0, to: self.normalizedPercent / 100)
+ .stroke(
+ MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint),
+ style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))
+ .rotationEffect(.degrees(-90))
+
+ if self.isError {
+ Image(systemName: "xmark")
+ .font(.system(size: fontSize, weight: .bold))
+ .foregroundStyle(MenuHighlightStyle.error(self.isHighlighted))
+ } else if self.percent == nil {
+ Text("—")
+ .font(.system(size: fontSize, weight: .semibold))
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ } else {
+ Text("\(Int(self.normalizedPercent.rounded()))")
+ .font(.system(size: fontSize, weight: .semibold))
+ .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted))
+ }
+ }
+ .frame(width: diameter, height: diameter)
+ .position(x: proxy.size.width / 2, y: proxy.size.height / 2)
+ }
+ .aspectRatio(1, contentMode: .fit)
+ }
+ }
+
private func makeBuyCreditsItem() -> NSMenuItem {
- let item = NSMenuItem(title: "Buy Credits...", action: #selector(self.openCreditsPurchase), keyEquivalent: "")
+ let item = NSMenuItem(
+ title: L10n.tr("menu.action.buy_credits", fallback: "Buy Credits..."),
+ action: #selector(self.openCreditsPurchase),
+ keyEquivalent: "")
item.target = self
if let image = NSImage(systemSymbolName: "plus.circle", accessibilityDescription: nil) {
image.isTemplate = true
@@ -918,7 +1162,7 @@ extension StatusItemController {
@discardableResult
private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool {
guard let submenu = self.makeCreditsHistorySubmenu() else { return false }
- let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "")
+ let item = NSMenuItem(title: L10n.tr("menu.action.credits_history", fallback: "Credits history"), action: nil, keyEquivalent: "")
item.isEnabled = true
item.submenu = submenu
menu.addItem(item)
@@ -928,7 +1172,7 @@ extension StatusItemController {
@discardableResult
private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool {
guard let submenu = self.makeUsageBreakdownSubmenu() else { return false }
- let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "")
+ let item = NSMenuItem(title: L10n.tr("menu.action.usage_breakdown", fallback: "Usage breakdown"), action: nil, keyEquivalent: "")
item.isEnabled = true
item.submenu = submenu
menu.addItem(item)
@@ -1060,7 +1304,9 @@ extension StatusItemController {
}
private func makeCostHistorySubmenu(provider: UsageProvider) -> NSMenu? {
- guard provider == .codex || provider == .claude || provider == .vertexai else { return nil }
+ guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else {
+ return nil
+ }
let width = Self.menuCardBaseWidth
guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil }
guard !tokenSnapshot.daily.isEmpty else { return nil }
@@ -1096,6 +1342,25 @@ extension StatusItemController {
return submenu
}
+ private func makeCostHistoryInlineItem(provider: UsageProvider, width: CGFloat) -> NSMenuItem? {
+ guard Self.menuCardRenderingEnabled else { return nil }
+ guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else {
+ return nil
+ }
+ guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil }
+ guard !tokenSnapshot.daily.isEmpty else { return nil }
+
+ let chartView = CostHistoryChartMenuView(
+ provider: provider,
+ daily: tokenSnapshot.daily,
+ totalCostUSD: tokenSnapshot.last30DaysCostUSD,
+ width: width)
+ return self.makeMenuCardItem(
+ chartView,
+ id: "menuCardCostHistoryInline",
+ width: width)
+ }
+
private func isHostedSubviewMenu(_ menu: NSMenu) -> Bool {
let ids: Set = [
"usageBreakdownChart",
@@ -1154,6 +1419,13 @@ extension StatusItemController {
dashboardError = self.store.lastOpenAIDashboardError
tokenSnapshot = self.store.tokenSnapshot(for: target)
tokenError = self.store.tokenError(for: target)
+ } else if target == .codexproxy, snapshotOverride == nil {
+ credits = nil
+ creditsError = nil
+ dashboard = nil
+ dashboardError = nil
+ tokenSnapshot = self.store.tokenSnapshot(for: target)
+ tokenError = self.store.tokenError(for: target)
} else if target == .claude || target == .vertexai, snapshotOverride == nil {
credits = nil
creditsError = nil
@@ -1173,6 +1445,7 @@ extension StatusItemController {
let input = UsageMenuCardView.Model.Input(
provider: target,
metadata: metadata,
+ sourceLabel: self.store.sourceLabel(for: target),
snapshot: snapshot,
credits: credits,
creditsError: creditsError,
diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift
index 98dcb693d..4d64affeb 100644
--- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift
+++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift
@@ -100,6 +100,11 @@ final class ProviderSwitcherView: NSView {
count: layoutCount,
outerPadding: outerPadding,
minimumGap: minimumGap)
+ let iconPointSize = Self.switcherIconPointSize(
+ stackedIcons: self.stackedIcons,
+ rowCount: self.rowCount,
+ segmentCount: self.segments.count,
+ maxAllowedSegmentWidth: maxAllowedSegmentWidth)
func makeButton(index: Int, segment: Segment) -> NSButton {
let button: NSButton
@@ -113,6 +118,7 @@ final class ProviderSwitcherView: NSView {
if self.rowCount >= 4 {
stacked.setTitleFontSize(NSFont.smallSystemFontSize - 3)
}
+ stacked.setIconSize(iconPointSize)
button = stacked
} else if self.showsIcons {
let inline = InlineIconToggleButton(
@@ -120,6 +126,7 @@ final class ProviderSwitcherView: NSView {
image: segment.image,
target: self,
action: #selector(self.handleSelection(_:)))
+ inline.setIconSize(iconPointSize)
button = inline
} else {
button = PaddedToggleButton(
@@ -151,7 +158,7 @@ final class ProviderSwitcherView: NSView {
button.wantsLayer = true
button.layer?.cornerRadius = 6
button.state = (selected == segment.provider) ? .on : .off
- button.toolTip = nil
+ button.toolTip = ProviderDescriptorRegistry.descriptor(for: segment.provider).metadata.displayName
button.translatesAutoresizingMaskIntoConstraints = false
self.buttons.append(button)
return button
@@ -457,6 +464,21 @@ final class ProviderSwitcherView: NSView {
return rows
}
+ private static func switcherIconPointSize(
+ stackedIcons: Bool,
+ rowCount: Int,
+ segmentCount: Int,
+ maxAllowedSegmentWidth: CGFloat) -> CGFloat
+ {
+ if !stackedIcons {
+ return maxAllowedSegmentWidth < 72 ? 14 : 16
+ }
+ if rowCount >= 4 || maxAllowedSegmentWidth < 44 { return 11 }
+ if rowCount >= 3 || maxAllowedSegmentWidth < 50 { return 12 }
+ if segmentCount >= 7 || maxAllowedSegmentWidth < 58 { return 13 }
+ return 14
+ }
+
private static func switcherOuterPadding(for width: CGFloat, count: Int, minimumGap: CGFloat) -> CGFloat {
// Align with the card's left/right content grid when possible.
let preferred: CGFloat = 16
@@ -754,7 +776,16 @@ final class ProviderSwitcherView: NSView {
}
private static func switcherTitle(for provider: UsageProvider) -> String {
- ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
+ switch provider {
+ case .codexproxy:
+ "CodexProxy"
+ case .geminiproxy:
+ "GemProxy"
+ case .antigravityproxy:
+ "AGProxy"
+ default:
+ ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
+ }
}
}
diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift
index 0963b8a63..c342d7001 100644
--- a/Sources/CodexBar/UsageStore+Refresh.swift
+++ b/Sources/CodexBar/UsageStore+Refresh.swift
@@ -2,6 +2,12 @@ import CodexBarCore
import Foundation
extension UsageStore {
+ private enum CLIProxyMultiAuthRefreshState {
+ case notHandled
+ case success
+ case failure(Error)
+ }
+
/// Force refresh Augment session (called from UI button)
func forceRefreshAugmentSession() async {
await self.performRuntimeAction(.forceSessionRefresh, for: .augment)
@@ -33,13 +39,28 @@ extension UsageStore {
defer { self.refreshingProviders.remove(provider) }
let tokenAccounts = self.tokenAccounts(for: provider)
- if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) {
+ let shouldFetchAllTokenAccounts = self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts)
+ if shouldFetchAllTokenAccounts {
await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts)
return
- } else {
- _ = await MainActor.run {
- self.accountSnapshots.removeValue(forKey: provider)
+ }
+
+ let cliProxyMultiAuthState = await self.refreshCLIProxyMultiAuthIfNeeded(provider: provider)
+ switch cliProxyMultiAuthState {
+ case .notHandled:
+ break
+ case .success:
+ if let runtime = self.providerRuntimes[provider] {
+ let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self)
+ runtime.providerDidRefresh(context: context, provider: provider)
+ }
+ return
+ case let .failure(error):
+ if let runtime = self.providerRuntimes[provider] {
+ let context = ProviderRuntimeContext(provider: provider, settings: self.settings, store: self)
+ runtime.providerDidFail(context: context, provider: provider, error: error)
}
+ return
}
let outcome = await spec.fetch()
@@ -70,6 +91,13 @@ extension UsageStore {
self.handleSessionQuotaTransition(provider: provider, snapshot: scoped)
self.snapshots[provider] = scoped
self.lastSourceLabels[provider] = result.sourceLabel
+ if !shouldFetchAllTokenAccounts {
+ self.accountSnapshots.removeValue(forKey: provider)
+ }
+ if provider == .codex {
+ self.credits = result.credits
+ self.lastCreditsError = nil
+ }
self.errors[provider] = nil
self.failureGates[provider]?.recordSuccess()
}
@@ -84,6 +112,9 @@ extension UsageStore {
let shouldSurface =
self.failureGates[provider]?
.shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true
+ if !shouldFetchAllTokenAccounts {
+ self.accountSnapshots.removeValue(forKey: provider)
+ }
if shouldSurface {
self.errors[provider] = error.localizedDescription
self.snapshots.removeValue(forKey: provider)
@@ -98,4 +129,289 @@ extension UsageStore {
}
}
}
+
+ private func refreshCLIProxyMultiAuthIfNeeded(provider: UsageProvider) async -> CLIProxyMultiAuthRefreshState {
+ guard self.supportsCLIProxyMultiAuth(provider: provider) else { return .notHandled }
+ if provider == .codex, self.sourceMode(for: .codex) != .api {
+ return .notHandled
+ }
+
+ let settingsSnapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil)
+ let env = ProviderRegistry.makeEnvironment(
+ base: ProcessInfo.processInfo.environment,
+ provider: provider,
+ settings: self.settings,
+ tokenOverride: nil)
+
+ guard let proxySettings = CodexCLIProxySettings.resolve(
+ providerSettings: settingsSnapshot.codex,
+ environment: env)
+ else {
+ return .notHandled
+ }
+
+ guard proxySettings.authIndex == nil else { return .notHandled }
+
+ let client = CodexCLIProxyManagementClient(settings: proxySettings)
+ let auths: [CodexCLIProxyResolvedAuth]
+ do {
+ auths = try await self.listCLIProxyAuths(provider: provider, client: client)
+ } catch {
+ return .notHandled
+ }
+
+ guard auths.count > 1 else { return .notHandled }
+
+ var accountSnapshots: [TokenAccountUsageSnapshot] = []
+ accountSnapshots.reserveCapacity(auths.count)
+
+ var successfulUsageSnapshots: [UsageSnapshot] = []
+ successfulUsageSnapshots.reserveCapacity(auths.count)
+
+ var creditBalances: [Double] = []
+ creditBalances.reserveCapacity(auths.count)
+
+ var firstError: Error?
+ for auth in auths {
+ let account = self.codexCLIProxyAccount(for: auth)
+ do {
+ let fetchResult = try await self.cliProxyFetchResult(provider: provider, auth: auth, client: client)
+ let mapped = fetchResult.snapshot
+ let labeled = self.applyAccountLabel(mapped, provider: provider, account: account)
+ successfulUsageSnapshots.append(labeled)
+ if let credits = fetchResult.credits {
+ creditBalances.append(credits.remaining)
+ }
+ accountSnapshots.append(TokenAccountUsageSnapshot(
+ account: account,
+ snapshot: labeled,
+ error: nil,
+ sourceLabel: "cliproxy-api"))
+ } catch {
+ if firstError == nil { firstError = error }
+ accountSnapshots.append(TokenAccountUsageSnapshot(
+ account: account,
+ snapshot: nil,
+ error: error.localizedDescription,
+ sourceLabel: "cliproxy-api"))
+ }
+ }
+
+ let aggregatedCredits: CreditsSnapshot? = if creditBalances.isEmpty {
+ nil
+ } else {
+ CreditsSnapshot(remaining: creditBalances.reduce(0, +), events: [], updatedAt: Date())
+ }
+
+ if let aggregate = self.aggregateCodexCLIProxySnapshot(
+ successfulUsageSnapshots,
+ provider: provider,
+ totalAuthCount: auths.count)
+ {
+ await MainActor.run {
+ self.handleSessionQuotaTransition(provider: provider, snapshot: aggregate)
+ self.snapshots[provider] = aggregate
+ self.accountSnapshots[provider] = accountSnapshots
+ self.lastSourceLabels[provider] = "cliproxy-api"
+ self.lastFetchAttempts[provider] = []
+ self.errors[provider] = nil
+ if provider == .codex {
+ self.credits = aggregatedCredits
+ self.lastCreditsError = nil
+ }
+ self.failureGates[provider]?.recordSuccess()
+ }
+ return .success
+ }
+
+ let resolvedError = firstError ?? self.cliProxyMissingAuthError(for: provider, authIndex: nil)
+ await MainActor.run {
+ self.snapshots.removeValue(forKey: provider)
+ self.accountSnapshots[provider] = accountSnapshots
+ self.lastSourceLabels[provider] = "cliproxy-api"
+ self.lastFetchAttempts[provider] = []
+ self.errors[provider] = resolvedError.localizedDescription
+ if provider == .codex {
+ self.credits = nil
+ self.lastCreditsError = nil
+ }
+ }
+ return .failure(resolvedError)
+ }
+
+ private func supportsCLIProxyMultiAuth(provider: UsageProvider) -> Bool {
+ provider == .codex || provider == .codexproxy || provider == .geminiproxy || provider == .antigravityproxy
+ }
+
+ private func listCLIProxyAuths(
+ provider: UsageProvider,
+ client: CodexCLIProxyManagementClient) async throws -> [CodexCLIProxyResolvedAuth]
+ {
+ switch provider {
+ case .codex, .codexproxy:
+ return try await client.listCodexAuths()
+ case .geminiproxy:
+ return try await client.listGeminiAuths()
+ case .antigravityproxy:
+ return try await client.listAntigravityAuths()
+ default:
+ return []
+ }
+ }
+
+ private func cliProxyMissingAuthError(for provider: UsageProvider, authIndex: String?) -> CodexCLIProxyError {
+ switch provider {
+ case .codex, .codexproxy:
+ return .missingCodexAuth(authIndex)
+ case .geminiproxy:
+ return .missingProviderAuth(provider: "Gemini", authIndex: authIndex)
+ case .antigravityproxy:
+ return .missingProviderAuth(provider: "Antigravity", authIndex: authIndex)
+ default:
+ return .missingCodexAuth(authIndex)
+ }
+ }
+
+ private func cliProxyFetchResult(
+ provider: UsageProvider,
+ auth: CodexCLIProxyResolvedAuth,
+ client: CodexCLIProxyManagementClient) async throws -> (snapshot: UsageSnapshot, credits: CreditsSnapshot?)
+ {
+ switch provider {
+ case .codex, .codexproxy:
+ let usage = try await client.fetchCodexUsage(auth: auth)
+ return (
+ snapshot: self.codexUsageSnapshot(from: usage, auth: auth, provider: provider),
+ credits: provider == .codex ? self.codexCreditsSnapshot(from: usage) : nil
+ )
+ case .geminiproxy:
+ let quota = try await client.fetchGeminiQuota(auth: auth)
+ return (
+ snapshot: CLIProxyGeminiQuotaSnapshotMapper.usageSnapshot(
+ from: quota,
+ auth: auth,
+ provider: .geminiproxy),
+ credits: nil
+ )
+ case .antigravityproxy:
+ let quota = try await client.fetchAntigravityQuota(auth: auth)
+ return (
+ snapshot: CLIProxyGeminiQuotaSnapshotMapper.usageSnapshot(
+ from: quota,
+ auth: auth,
+ provider: .antigravityproxy),
+ credits: nil
+ )
+ default:
+ throw self.cliProxyMissingAuthError(for: provider, authIndex: auth.authIndex)
+ }
+ }
+
+ private func codexCLIProxyAccount(for auth: CodexCLIProxyResolvedAuth) -> ProviderTokenAccount {
+ ProviderTokenAccount(
+ id: UUID(),
+ label: self.codexCLIProxyAccountLabel(auth),
+ token: "",
+ addedAt: 0,
+ lastUsed: nil)
+ }
+
+ private func codexCLIProxyAccountLabel(_ auth: CodexCLIProxyResolvedAuth) -> String {
+ if let email = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines), !email.isEmpty {
+ return email
+ }
+ return auth.authIndex
+ }
+
+ private func aggregateCodexCLIProxySnapshot(
+ _ snapshots: [UsageSnapshot],
+ provider: UsageProvider,
+ totalAuthCount: Int) -> UsageSnapshot?
+ {
+ guard !snapshots.isEmpty else { return nil }
+
+ let primary = self.aggregateWindow(snapshots.compactMap(\.primary))
+ let secondary = self.aggregateWindow(snapshots.compactMap(\.secondary))
+ let tertiary = self.aggregateWindow(snapshots.compactMap(\.tertiary))
+
+ let loginMethods = Set(
+ snapshots.compactMap { snapshot in
+ snapshot.loginMethod(for: provider)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }.filter { !$0.isEmpty })
+ let loginMethod = loginMethods.count == 1 ? loginMethods.first : nil
+
+ let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
+ let accountLabelFormat = L10n.tr(
+ "provider.cliproxy.aggregate.account_label",
+ fallback: "All %@ auth entries (%d)")
+ let accountLabel = String(format: accountLabelFormat, locale: .current, providerName, totalAuthCount)
+ let identity = ProviderIdentitySnapshot(
+ providerID: provider,
+ accountEmail: accountLabel,
+ accountOrganization: nil,
+ loginMethod: loginMethod)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: tertiary,
+ updatedAt: Date(),
+ identity: identity)
+ }
+
+ private func aggregateWindow(_ windows: [RateWindow]) -> RateWindow? {
+ guard !windows.isEmpty else { return nil }
+ let usedPercent = windows.map(\.usedPercent).reduce(0, +) / Double(windows.count)
+ let windowMinutes = windows.compactMap(\.windowMinutes).max()
+ let resetsAt = windows.compactMap(\.resetsAt).min()
+ let resetDescription = resetsAt.map { UsageFormatter.resetDescription(from: $0) }
+ return RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: windowMinutes,
+ resetsAt: resetsAt,
+ resetDescription: resetDescription)
+ }
+
+ private func codexUsageSnapshot(
+ from usage: CodexUsageResponse,
+ auth: CodexCLIProxyResolvedAuth,
+ provider: UsageProvider) -> UsageSnapshot
+ {
+ let primary = self.codexRateWindow(from: usage.rateLimit?.primaryWindow)
+ ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil)
+ let secondary = self.codexRateWindow(from: usage.rateLimit?.secondaryWindow)
+ let resolvedPlan = usage.planType?.rawValue
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let fallbackPlan = auth.planType?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let loginMethod = (resolvedPlan?.isEmpty == false) ? resolvedPlan : fallbackPlan
+ let normalizedEmail = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let identity = ProviderIdentitySnapshot(
+ providerID: provider,
+ accountEmail: normalizedEmail?.isEmpty == true ? nil : normalizedEmail,
+ accountOrganization: nil,
+ loginMethod: loginMethod?.isEmpty == true ? nil : loginMethod)
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: nil,
+ updatedAt: Date(),
+ identity: identity)
+ .scoped(to: provider)
+ }
+
+ private func codexRateWindow(from window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? {
+ guard let window else { return nil }
+ let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt))
+ return RateWindow(
+ usedPercent: Double(window.usedPercent),
+ windowMinutes: window.limitWindowSeconds / 60,
+ resetsAt: resetDate,
+ resetDescription: UsageFormatter.resetDescription(from: resetDate))
+ }
+
+ private func codexCreditsSnapshot(from usage: CodexUsageResponse) -> CreditsSnapshot? {
+ guard let credits = usage.credits, credits.hasCredits, let balance = credits.balance else { return nil }
+ return CreditsSnapshot(remaining: balance, events: [], updatedAt: Date())
+ }
}
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 6b3672ed4..b24267b93 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -607,6 +607,15 @@ final class UsageStore {
private func refreshCreditsIfNeeded() async {
guard self.isEnabled(.codex) else { return }
+ if self.sourceMode(for: .codex) == .api {
+ await MainActor.run {
+ self.credits = nil
+ self.lastCreditsError = nil
+ self.lastCreditsSnapshot = nil
+ self.creditsFailureStreak = 0
+ }
+ return
+ }
do {
let credits = try await self.codexFetcher.loadLatestCredits(
keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive)
@@ -1140,6 +1149,20 @@ extension UsageStore {
let raw = await self.codexFetcher.debugRawRateLimits()
await MainActor.run { self.probeLogs[.codex] = raw }
return raw
+ case .codexproxy:
+ let text = "CLIProxy Codex uses management API; use CodexBarCLI --provider codexproxy --source api for raw checks."
+ await MainActor.run { self.probeLogs[.codexproxy] = text }
+ return text
+ case .geminiproxy:
+ let text =
+ "CLIProxy Gemini uses management API; use CodexBarCLI --provider geminiproxy --source api for raw checks."
+ await MainActor.run { self.probeLogs[.geminiproxy] = text }
+ return text
+ case .antigravityproxy:
+ let text =
+ "CLIProxy Antigravity uses management API; use CodexBarCLI --provider antigravityproxy --source api for raw checks."
+ await MainActor.run { self.probeLogs[.antigravityproxy] = text }
+ return text
case .claude:
let text = await self.debugClaudeLog(
claudeWebExtrasEnabled: claudeWebExtrasEnabled,
@@ -1505,7 +1528,7 @@ extension UsageStore {
}
private func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async {
- guard provider == .codex || provider == .claude || provider == .vertexai else {
+ guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else {
self.tokenSnapshots.removeValue(forKey: provider)
self.tokenErrors[provider] = nil
self.tokenFailureGates[provider]?.reset()
diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift
index d0ab5dfe6..12d9f0357 100644
--- a/Sources/CodexBarCLI/CLIUsageCommand.swift
+++ b/Sources/CodexBarCLI/CLIUsageCommand.swift
@@ -170,6 +170,16 @@ extension CodexBarCLI {
tokenContext: TokenAccountCLIContext,
command: UsageCommandContext) async -> UsageCommandOutput
{
+ if provider == .codex,
+ let output = await Self.fetchCodexCLIProxyUsageOutputsIfNeeded(
+ provider: provider,
+ status: status,
+ tokenContext: tokenContext,
+ command: command)
+ {
+ return output
+ }
+
let accounts: [ProviderTokenAccount]
do {
accounts = try tokenContext.resolvedAccounts(for: provider)
@@ -189,7 +199,8 @@ extension CodexBarCLI {
account: account,
status: status,
tokenContext: tokenContext,
- command: command)
+ command: command,
+ environmentOverride: [:])
output.merge(result)
}
return output
@@ -227,13 +238,17 @@ extension CodexBarCLI {
account: ProviderTokenAccount?,
status: ProviderStatusPayload?,
tokenContext: TokenAccountCLIContext,
- command: UsageCommandContext) async -> UsageCommandOutput
+ command: UsageCommandContext,
+ environmentOverride: [String: String]) async -> UsageCommandOutput
{
var output = UsageCommandOutput()
- let env = tokenContext.environment(
+ var env = tokenContext.environment(
base: ProcessInfo.processInfo.environment,
provider: provider,
account: account)
+ for (key, value) in environmentOverride {
+ env[key] = value
+ }
let settings = tokenContext.settingsSnapshot(for: provider, account: account)
let configSource = tokenContext.preferredSourceMode(for: provider)
let baseSource = command.sourceModeOverride ?? configSource
@@ -350,6 +365,67 @@ extension CodexBarCLI {
return output
}
+ private static func fetchCodexCLIProxyUsageOutputsIfNeeded(
+ provider: UsageProvider,
+ status: ProviderStatusPayload?,
+ tokenContext: TokenAccountCLIContext,
+ command: UsageCommandContext) async -> UsageCommandOutput?
+ {
+ let configSource = tokenContext.preferredSourceMode(for: provider)
+ let baseSource = command.sourceModeOverride ?? configSource
+ let sourceMode = tokenContext.effectiveSourceMode(base: baseSource, provider: provider, account: nil)
+ guard sourceMode == .api else { return nil }
+
+ let baseEnv = tokenContext.environment(
+ base: ProcessInfo.processInfo.environment,
+ provider: provider,
+ account: nil)
+ let settings = tokenContext.settingsSnapshot(for: provider, account: nil)
+ guard let proxySettings = CodexCLIProxySettings.resolve(
+ providerSettings: settings?.codex,
+ environment: baseEnv)
+ else {
+ return nil
+ }
+ guard proxySettings.authIndex == nil else { return nil }
+
+ let client = CodexCLIProxyManagementClient(settings: proxySettings)
+ let auths: [CodexCLIProxyResolvedAuth]
+ do {
+ auths = try await client.listCodexAuths()
+ } catch {
+ return nil
+ }
+ guard auths.count > 1 else { return nil }
+
+ var output = UsageCommandOutput()
+ for auth in auths {
+ let label = Self.codexCLIProxyAccountLabel(auth)
+ let account = ProviderTokenAccount(
+ id: UUID(),
+ label: label,
+ token: "",
+ addedAt: 0,
+ lastUsed: nil)
+ let result = await Self.fetchUsageOutput(
+ provider: provider,
+ account: account,
+ status: status,
+ tokenContext: tokenContext,
+ command: command,
+ environmentOverride: [CodexCLIProxySettings.environmentAuthIndexKey: auth.authIndex])
+ output.merge(result)
+ }
+ return output
+ }
+
+ private static func codexCLIProxyAccountLabel(_ auth: CodexCLIProxyResolvedAuth) -> String {
+ if let email = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines), !email.isEmpty {
+ return email
+ }
+ return auth.authIndex
+ }
+
private static func fetchAntigravityPlanInfoIfNeeded(
provider: UsageProvider,
command: UsageCommandContext) async -> AntigravityPlanInfoSummary?
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index 4809cfb06..e631ae754 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -82,11 +82,24 @@ struct TokenAccountCLIContext {
switch provider {
case .codex:
+ let codexSource = self.resolveCodexUsageDataSource(config)
return self.makeSnapshot(
codex: ProviderSettingsSnapshot.CodexProviderSettings(
- usageDataSource: .auto,
+ usageDataSource: codexSource,
cookieSource: cookieSource,
- manualCookieHeader: cookieHeader))
+ manualCookieHeader: cookieHeader,
+ cliProxyBaseURL: config?.sanitizedAPIBaseURL,
+ cliProxyManagementKey: config?.sanitizedAPIKey,
+ cliProxyAuthIndex: config?.sanitizedAPIAuthIndex))
+ case .codexproxy:
+ return self.makeSnapshot(
+ codex: ProviderSettingsSnapshot.CodexProviderSettings(
+ usageDataSource: .api,
+ cookieSource: .off,
+ manualCookieHeader: nil,
+ cliProxyBaseURL: config?.sanitizedAPIBaseURL,
+ cliProxyManagementKey: config?.sanitizedAPIKey,
+ cliProxyAuthIndex: config?.sanitizedAPIAuthIndex))
case .claude:
let claudeSource: ClaudeUsageDataSource = if provider == .claude,
let account,
@@ -147,7 +160,7 @@ struct TokenAccountCLIContext {
return self.makeSnapshot(
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
ideBasePath: nil))
- case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic:
+ case .geminiproxy, .antigravityproxy, .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic:
return nil
}
}
@@ -179,6 +192,20 @@ struct TokenAccountCLIContext {
jetbrains: jetbrains)
}
+ private func resolveCodexUsageDataSource(_ config: ProviderConfig?) -> CodexUsageDataSource {
+ guard let source = config?.source else { return .auto }
+ switch source {
+ case .auto, .web:
+ return .auto
+ case .api:
+ return .api
+ case .cli:
+ return .cli
+ case .oauth:
+ return .oauth
+ }
+ }
+
func environment(
base: [String: String],
provider: UsageProvider,
diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift
index c4bbbc2cc..a7c3182c9 100644
--- a/Sources/CodexBarCore/Config/CodexBarConfig.swift
+++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift
@@ -77,6 +77,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable {
public var enabled: Bool?
public var source: ProviderSourceMode?
public var apiKey: String?
+ public var apiBaseURL: String?
+ public var apiAuthIndex: String?
public var cookieHeader: String?
public var cookieSource: ProviderCookieSource?
public var region: String?
@@ -88,6 +90,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable {
enabled: Bool? = nil,
source: ProviderSourceMode? = nil,
apiKey: String? = nil,
+ apiBaseURL: String? = nil,
+ apiAuthIndex: String? = nil,
cookieHeader: String? = nil,
cookieSource: ProviderCookieSource? = nil,
region: String? = nil,
@@ -98,6 +102,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable {
self.enabled = enabled
self.source = source
self.apiKey = apiKey
+ self.apiBaseURL = apiBaseURL
+ self.apiAuthIndex = apiAuthIndex
self.cookieHeader = cookieHeader
self.cookieSource = cookieSource
self.region = region
@@ -109,6 +115,14 @@ public struct ProviderConfig: Codable, Sendable, Identifiable {
Self.clean(self.apiKey)
}
+ public var sanitizedAPIBaseURL: String? {
+ Self.clean(self.apiBaseURL)
+ }
+
+ public var sanitizedAPIAuthIndex: String? {
+ Self.clean(self.apiAuthIndex)
+ }
+
public var sanitizedCookieHeader: String? {
Self.clean(self.cookieHeader)
}
diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift
index d435a28f6..7c9e7a1c7 100644
--- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift
+++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift
@@ -167,6 +167,30 @@ public enum CodexBarConfigValidator {
message: "workspaceID is set but only opencode supports workspaceID."))
}
+ if let apiBaseURL = entry.apiBaseURL,
+ !apiBaseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ provider != .codex
+ {
+ issues.append(CodexBarConfigIssue(
+ severity: .warning,
+ provider: provider,
+ field: "apiBaseURL",
+ code: "api_base_url_unused",
+ message: "apiBaseURL is set but only codex currently uses it."))
+ }
+
+ if let apiAuthIndex = entry.apiAuthIndex,
+ !apiAuthIndex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ provider != .codex
+ {
+ issues.append(CodexBarConfigIssue(
+ severity: .warning,
+ provider: provider,
+ field: "apiAuthIndex",
+ code: "api_auth_index_unused",
+ message: "apiAuthIndex is set but only codex currently uses it."))
+ }
+
if let tokenAccounts = entry.tokenAccounts, !tokenAccounts.accounts.isEmpty,
TokenAccountSupportCatalog.support(for: provider) == nil
{
diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift
index 2243d5218..52824dd07 100644
--- a/Sources/CodexBarCore/CostUsageFetcher.swift
+++ b/Sources/CodexBarCore/CostUsageFetcher.swift
@@ -26,18 +26,19 @@ public struct CostUsageFetcher: Sendable {
forceRefresh: Bool = false,
allowVertexClaudeFallback: Bool = false) async throws -> CostUsageTokenSnapshot
{
- guard provider == .codex || provider == .claude || provider == .vertexai else {
+ guard provider == .codex || provider == .codexproxy || provider == .claude || provider == .vertexai else {
throw CostUsageError.unsupportedProvider(provider)
}
+ let scannedProvider: UsageProvider = (provider == .codexproxy) ? .codex : provider
let until = now
// Rolling window: last 30 days (inclusive). Use -29 for inclusive boundaries.
let since = Calendar.current.date(byAdding: .day, value: -29, to: now) ?? now
var options = CostUsageScanner.Options()
- if provider == .vertexai {
+ if scannedProvider == .vertexai {
options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly
- } else if provider == .claude {
+ } else if scannedProvider == .claude {
options.claudeLogProviderFilter = .excludeVertexAI
}
if forceRefresh {
@@ -45,13 +46,13 @@ public struct CostUsageFetcher: Sendable {
options.forceRescan = true
}
var daily = CostUsageScanner.loadDailyReport(
- provider: provider,
+ provider: scannedProvider,
since: since,
until: until,
now: now,
options: options)
- if provider == .vertexai,
+ if scannedProvider == .vertexai,
!allowVertexClaudeFallback,
options.claudeLogProviderFilter == .vertexAIOnly,
daily.data.isEmpty
@@ -59,7 +60,7 @@ public struct CostUsageFetcher: Sendable {
var fallback = options
fallback.claudeLogProviderFilter = .all
daily = CostUsageScanner.loadDailyReport(
- provider: provider,
+ provider: scannedProvider,
since: since,
until: until,
now: now,
diff --git a/Sources/CodexBarCore/Localization.swift b/Sources/CodexBarCore/Localization.swift
new file mode 100644
index 000000000..6d7d14a3a
--- /dev/null
+++ b/Sources/CodexBarCore/Localization.swift
@@ -0,0 +1,80 @@
+import Foundation
+
+public enum L10n {
+ private static let appLanguageKey = "appLanguageCode"
+ private static let appleLanguagesKey = "AppleLanguages"
+
+ public static func tr(_ key: String, fallback: String) -> String {
+ let bundle = self.localizedBundle()
+ return NSLocalizedString(
+ key,
+ tableName: "Localizable",
+ bundle: bundle,
+ value: fallback,
+ comment: "")
+ }
+
+ private static func localizedBundle() -> Bundle {
+ let selected = UserDefaults.standard.string(forKey: Self.appLanguageKey)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let usesSystemLanguage = selected.isEmpty || selected == "system"
+
+ let preferences = usesSystemLanguage
+ ? self.systemLanguagePreferences()
+ : self.languageCandidates(for: selected)
+
+ guard let bundle = self.bundle(matching: preferences) else { return .module }
+ return bundle
+ }
+
+ private static func systemLanguagePreferences() -> [String] {
+ if let explicit = UserDefaults.standard.array(forKey: Self.appleLanguagesKey) as? [String],
+ !explicit.isEmpty
+ {
+ return explicit
+ }
+ let preferred = Locale.preferredLanguages
+ if !preferred.isEmpty { return preferred }
+ return [Locale.current.identifier]
+ }
+
+ private static func languageCandidates(for raw: String) -> [String] {
+ let normalized = raw.replacingOccurrences(of: "_", with: "-")
+ var candidates: [String] = [raw, raw.lowercased(), normalized, normalized.lowercased()]
+ if normalized.contains("-") {
+ let parts = normalized.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: true)
+ if let base = parts.first {
+ candidates.append(String(base))
+ candidates.append(String(base).lowercased())
+ }
+ }
+
+ var seen: Set = []
+ return candidates.filter { seen.insert($0).inserted && !$0.isEmpty }
+ }
+
+ private static func bundle(matching preferences: [String]) -> Bundle? {
+ let available = Bundle.module.localizations.filter { $0 != "Base" }
+ guard !available.isEmpty else { return nil }
+
+ let preferred = Bundle.preferredLocalizations(from: available, forPreferences: preferences)
+ for language in preferred {
+ if let path = Bundle.module.path(forResource: language, ofType: "lproj"),
+ let bundle = Bundle(path: path)
+ {
+ return bundle
+ }
+ }
+
+ for language in preferences {
+ for candidate in self.languageCandidates(for: language) {
+ if let path = Bundle.module.path(forResource: candidate, ofType: "lproj"),
+ let bundle = Bundle(path: path)
+ {
+ return bundle
+ }
+ }
+ }
+ return nil
+ }
+}
diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift
index 4a8d9441d..249cc3023 100644
--- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift
+++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift
@@ -12,9 +12,12 @@ public struct OpenAIDashboardFetcher {
public var errorDescription: String? {
switch self {
case .loginRequired:
- "OpenAI web access requires login."
+ return L10n.tr("error.codex.openai_web.login_required", fallback: "OpenAI web access requires login.")
case let .noDashboardData(body):
- "OpenAI dashboard data not found. Body sample: \(body.prefix(200))"
+ let format = L10n.tr(
+ "error.codex.openai_web.no_data_with_body",
+ fallback: "OpenAI dashboard data not found. Body sample: %@")
+ return String(format: format, locale: .current, String(body.prefix(200)))
}
}
}
@@ -471,9 +474,12 @@ public struct OpenAIDashboardFetcher {
public var errorDescription: String? {
switch self {
case .loginRequired:
- "OpenAI web access requires login."
+ return L10n.tr("error.codex.openai_web.login_required", fallback: "OpenAI web access requires login.")
case let .noDashboardData(body):
- "OpenAI dashboard data not found. Body sample: \(body.prefix(200))"
+ let format = L10n.tr(
+ "error.codex.openai_web.no_data_with_body",
+ fallback: "OpenAI dashboard data not found. Body sample: %@")
+ return String(format: format, locale: .current, String(body.prefix(200)))
}
}
}
@@ -486,7 +492,10 @@ public struct OpenAIDashboardFetcher {
debugDumpHTML _: Bool = false,
timeout _: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot
{
- throw FetchError.noDashboardData(body: "OpenAI web dashboard fetch is only supported on macOS.")
+ throw FetchError.noDashboardData(
+ body: L10n.tr(
+ "error.codex.openai_web.unsupported_platform",
+ fallback: "OpenAI web dashboard fetch is only supported on macOS."))
}
}
#endif
diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProxyProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProxyProviderDescriptor.swift
new file mode 100644
index 000000000..9376c7a14
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProxyProviderDescriptor.swift
@@ -0,0 +1,77 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum AntigravityProxyProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .antigravityproxy,
+ metadata: ProviderMetadata(
+ id: .antigravityproxy,
+ displayName: "CLIProxy Antigravity",
+ sessionLabel: "Pro",
+ weeklyLabel: "Flash",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show CLIProxy Antigravity usage",
+ cliName: "antigravity-proxy",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ dashboardURL: "http://127.0.0.1:8317/management.html#/usage",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .antigravity,
+ iconResourceName: "ProviderIcon-antigravity",
+ color: ProviderColor(red: 96 / 255, green: 186 / 255, blue: 126 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Antigravity cost summary is not supported for CLIProxy source." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [AntigravityCLIProxyFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "antigravity-proxy",
+ aliases: ["cliproxy-antigravity"],
+ versionDetector: nil))
+ }
+}
+
+private struct AntigravityCLIProxyFetchStrategy: ProviderFetchStrategy {
+ let id: String = "antigravityproxy.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ CodexCLIProxySettings.resolve(
+ providerSettings: context.settings?.codex,
+ environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let settings = CodexCLIProxySettings.resolve(
+ providerSettings: context.settings?.codex,
+ environment: context.env)
+ else {
+ throw CodexCLIProxyError.missingManagementKey
+ }
+
+ let client = CodexCLIProxyManagementClient(settings: settings)
+ let auth = try await client.resolveAntigravityAuth()
+ let quota = try await client.fetchAntigravityQuota(auth: auth)
+ let snapshot = CLIProxyGeminiQuotaSnapshotMapper.usageSnapshot(
+ from: quota,
+ auth: auth,
+ provider: .antigravityproxy)
+
+ return self.makeResult(
+ usage: snapshot,
+ sourceLabel: "cliproxy-api")
+ }
+
+ func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool {
+ context.sourceMode == .auto
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift
new file mode 100644
index 000000000..babf0c0e8
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxyManagementClient.swift
@@ -0,0 +1,545 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public enum CodexCLIProxyError: LocalizedError, Sendable {
+ case invalidBaseURL
+ case missingManagementKey
+ case invalidResponse
+ case managementRequestFailed(Int, String?)
+ case missingCodexAuth(String?)
+ case missingProviderAuth(provider: String, authIndex: String?)
+ case apiCallFailed(Int, String?)
+ case decodeFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .invalidBaseURL:
+ return L10n.tr("error.codex.cliproxy.invalid_base_url", fallback: "CLIProxyAPI base URL is invalid.")
+ case .missingManagementKey:
+ return L10n.tr(
+ "error.codex.cliproxy.missing_management_key",
+ fallback: "CLIProxy management key is missing. Please set it in Settings > General > CLIProxyAPI.")
+ case .invalidResponse:
+ return L10n.tr("error.codex.cliproxy.invalid_response", fallback: "CLIProxyAPI returned an invalid response.")
+ case let .managementRequestFailed(status, message):
+ if let message, !message.isEmpty {
+ let format = L10n.tr(
+ "error.codex.cliproxy.management_failed_with_message",
+ fallback: "CLIProxyAPI management API failed (%d): %@")
+ return String(format: format, locale: .current, status, message)
+ }
+ let format = L10n.tr(
+ "error.codex.cliproxy.management_failed",
+ fallback: "CLIProxyAPI management API failed (%d).")
+ return String(format: format, locale: .current, status)
+ case let .missingCodexAuth(authIndex):
+ if let authIndex, !authIndex.isEmpty {
+ let format = L10n.tr(
+ "error.codex.cliproxy.missing_auth_with_index",
+ fallback: "CLIProxyAPI did not find Codex auth_index %@.")
+ return String(format: format, locale: .current, authIndex)
+ }
+ return L10n.tr(
+ "error.codex.cliproxy.missing_auth",
+ fallback: "CLIProxyAPI has no available Codex auth entry.")
+ case let .missingProviderAuth(provider, authIndex):
+ if let authIndex, !authIndex.isEmpty {
+ let format = L10n.tr(
+ "error.codex.cliproxy.missing_provider_auth_with_index",
+ fallback: "CLIProxyAPI did not find %@ auth_index %@.")
+ return String(format: format, locale: .current, provider, authIndex)
+ }
+ let format = L10n.tr(
+ "error.codex.cliproxy.missing_provider_auth",
+ fallback: "CLIProxyAPI has no available %@ auth entry.")
+ return String(format: format, locale: .current, provider)
+ case let .apiCallFailed(status, message):
+ if let message, !message.isEmpty {
+ let format = L10n.tr(
+ "error.codex.cliproxy.api_call_failed_with_message",
+ fallback: "CLIProxyAPI api-call failed (%d): %@")
+ return String(format: format, locale: .current, status, message)
+ }
+ let format = L10n.tr(
+ "error.codex.cliproxy.api_call_failed",
+ fallback: "CLIProxyAPI api-call failed (%d).")
+ return String(format: format, locale: .current, status)
+ case let .decodeFailed(message):
+ let format = L10n.tr(
+ "error.codex.cliproxy.decode_failed",
+ fallback: "Failed to decode CLIProxyAPI response: %@")
+ return String(format: format, locale: .current, message)
+ }
+ }
+}
+
+public struct CodexCLIProxyResolvedAuth: Sendable {
+ public let authIndex: String
+ public let email: String?
+ public let chatGPTAccountID: String?
+ public let planType: String?
+}
+
+public struct CLIProxyGeminiQuotaBucket: Sendable {
+ public let modelID: String
+ public let remainingFraction: Double
+ public let resetTime: Date?
+
+ public init(modelID: String, remainingFraction: Double, resetTime: Date?) {
+ self.modelID = modelID
+ self.remainingFraction = remainingFraction
+ self.resetTime = resetTime
+ }
+}
+
+public struct CLIProxyGeminiQuotaResponse: Sendable {
+ public let buckets: [CLIProxyGeminiQuotaBucket]
+
+ public init(buckets: [CLIProxyGeminiQuotaBucket]) {
+ self.buckets = buckets
+ }
+}
+
+private enum CLIProxyAuthProvider: Sendable {
+ case codex
+ case gemini
+ case antigravity
+
+ var displayName: String {
+ switch self {
+ case .codex: "Codex"
+ case .gemini: "Gemini"
+ case .antigravity: "Antigravity"
+ }
+ }
+
+ var providerValues: Set {
+ switch self {
+ case .codex: ["codex"]
+ case .gemini: ["gemini-cli", "gemini"]
+ case .antigravity: ["antigravity"]
+ }
+ }
+
+ var typeValues: Set {
+ switch self {
+ case .codex: ["codex"]
+ case .gemini: ["gemini-cli", "gemini"]
+ case .antigravity: ["antigravity"]
+ }
+ }
+
+ func matches(provider: String?, type: String?) -> Bool {
+ let normalizedProvider = provider?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ let normalizedType = type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return self.providerValues.contains(normalizedProvider ?? "")
+ || self.typeValues.contains(normalizedType ?? "")
+ }
+
+ func missingAuthError(authIndex: String?) -> CodexCLIProxyError {
+ switch self {
+ case .codex:
+ return .missingCodexAuth(authIndex)
+ case .gemini, .antigravity:
+ return .missingProviderAuth(provider: self.displayName, authIndex: authIndex)
+ }
+ }
+}
+
+public struct CodexCLIProxyManagementClient: Sendable {
+ private let settings: CodexCLIProxySettings
+ private let session: URLSession
+ private static let geminiQuotaURL = "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota"
+ private static let geminiLoadCodeAssistURL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"
+ private static let geminiFallbackProjectID = "just-well-nxk81"
+ private static let geminiHeaders = [
+ "Authorization": "Bearer $TOKEN$",
+ "Content-Type": "application/json",
+ "User-Agent": "google-api-nodejs-client/9.15.1",
+ "X-Goog-Api-Client": "gl-node/22.17.0",
+ "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
+ ]
+
+ public init(settings: CodexCLIProxySettings, session: URLSession = .shared) {
+ self.settings = settings
+ self.session = session
+ }
+
+ public func resolveCodexAuth() async throws -> CodexCLIProxyResolvedAuth {
+ try await self.resolveAuth(for: .codex)
+ }
+
+ public func listCodexAuths() async throws -> [CodexCLIProxyResolvedAuth] {
+ try await self.listAuths(for: .codex)
+ }
+
+ public func resolveGeminiAuth() async throws -> CodexCLIProxyResolvedAuth {
+ try await self.resolveAuth(for: .gemini)
+ }
+
+ public func listGeminiAuths() async throws -> [CodexCLIProxyResolvedAuth] {
+ try await self.listAuths(for: .gemini)
+ }
+
+ public func resolveAntigravityAuth() async throws -> CodexCLIProxyResolvedAuth {
+ try await self.resolveAuth(for: .antigravity)
+ }
+
+ public func listAntigravityAuths() async throws -> [CodexCLIProxyResolvedAuth] {
+ try await self.listAuths(for: .antigravity)
+ }
+
+ public func fetchGeminiQuota(auth: CodexCLIProxyResolvedAuth) async throws -> CLIProxyGeminiQuotaResponse {
+ try await self.fetchGeminiLikeQuota(auth: auth)
+ }
+
+ public func fetchAntigravityQuota(auth: CodexCLIProxyResolvedAuth) async throws -> CLIProxyGeminiQuotaResponse {
+ try await self.fetchGeminiLikeQuota(auth: auth)
+ }
+
+ public func fetchCodexUsage(auth: CodexCLIProxyResolvedAuth) async throws -> CodexUsageResponse {
+ let usageURL = "https://chatgpt.com/backend-api/wham/usage"
+ var headers = [
+ "Authorization": "Bearer $TOKEN$",
+ "Accept": "application/json",
+ "User-Agent": "CodexBar",
+ ]
+ if let accountID = auth.chatGPTAccountID, !accountID.isEmpty {
+ headers["ChatGPT-Account-Id"] = accountID
+ }
+
+ let body = APICallRequest(
+ authIndex: auth.authIndex,
+ method: "GET",
+ url: usageURL,
+ header: headers,
+ data: nil)
+ let callResponse = try await self.post(path: "/api-call", body: body)
+
+ let statusCode = callResponse.statusCode
+ guard (200...299).contains(statusCode) else {
+ throw CodexCLIProxyError.apiCallFailed(statusCode, callResponse.compactBody)
+ }
+
+ guard let bodyString = callResponse.body else {
+ throw CodexCLIProxyError.invalidResponse
+ }
+ let payload = Data(bodyString.utf8)
+ do {
+ return try JSONDecoder().decode(CodexUsageResponse.self, from: payload)
+ } catch {
+ throw CodexCLIProxyError.decodeFailed(error.localizedDescription)
+ }
+ }
+
+ private func resolveAuth(for provider: CLIProxyAuthProvider) async throws -> CodexCLIProxyResolvedAuth {
+ let auths = try await self.listAuths(for: provider)
+
+ if let preferred = self.settings.authIndex?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !preferred.isEmpty
+ {
+ guard let selected = auths.first(where: { $0.authIndex == preferred }) else {
+ throw provider.missingAuthError(authIndex: preferred)
+ }
+ return selected
+ }
+
+ guard let selected = auths.first else {
+ throw provider.missingAuthError(authIndex: nil)
+ }
+ return selected
+ }
+
+ private func listAuths(for provider: CLIProxyAuthProvider) async throws -> [CodexCLIProxyResolvedAuth] {
+ let response = try await self.fetchAuthFiles()
+ let auths = response.files.filter { provider.matches(provider: $0.provider, type: $0.type) }
+
+ let enabledAuths = auths.filter { !($0.disabled ?? false) }
+ let pool = enabledAuths.isEmpty ? auths : enabledAuths
+ let mapped = pool.compactMap { auth -> CodexCLIProxyResolvedAuth? in
+ let resolved = self.mapResolvedAuth(auth)
+ guard !resolved.authIndex.isEmpty else { return nil }
+ return resolved
+ }
+ return mapped.sorted { left, right in
+ let l = left.email?.lowercased() ?? left.authIndex.lowercased()
+ let r = right.email?.lowercased() ?? right.authIndex.lowercased()
+ return l < r
+ }
+ }
+
+ private func fetchGeminiLikeQuota(auth: CodexCLIProxyResolvedAuth) async throws -> CLIProxyGeminiQuotaResponse {
+ let projectID = await self.resolveGeminiProjectID(auth: auth) ?? Self.geminiFallbackProjectID
+ let payload = try await self.fetchGeminiLikeQuota(auth: auth, projectID: projectID)
+ if !payload.buckets.isEmpty { return payload }
+ if projectID != Self.geminiFallbackProjectID {
+ return try await self.fetchGeminiLikeQuota(auth: auth, projectID: Self.geminiFallbackProjectID)
+ }
+ return payload
+ }
+
+ private func fetchGeminiLikeQuota(
+ auth: CodexCLIProxyResolvedAuth,
+ projectID: String) async throws -> CLIProxyGeminiQuotaResponse
+ {
+ let bodyPayload = GeminiQuotaRequestPayload(project: projectID)
+ let requestData = try JSONEncoder().encode(bodyPayload)
+ guard let requestString = String(data: requestData, encoding: .utf8) else {
+ throw CodexCLIProxyError.invalidResponse
+ }
+
+ let body = APICallRequest(
+ authIndex: auth.authIndex,
+ method: "POST",
+ url: Self.geminiQuotaURL,
+ header: Self.geminiHeaders,
+ data: requestString)
+ let callResponse = try await self.post(path: "/api-call", body: body)
+ let statusCode = callResponse.statusCode
+ guard (200...299).contains(statusCode) else {
+ throw CodexCLIProxyError.apiCallFailed(statusCode, callResponse.compactBody)
+ }
+ guard let bodyString = callResponse.body else {
+ throw CodexCLIProxyError.invalidResponse
+ }
+
+ let responseData = Data(bodyString.utf8)
+ do {
+ let decoded = try JSONDecoder().decode(GeminiQuotaResponsePayload.self, from: responseData)
+ let buckets = decoded.buckets.compactMap { bucket -> CLIProxyGeminiQuotaBucket? in
+ guard let modelID = bucket.modelID?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !modelID.isEmpty,
+ let remainingFraction = bucket.remainingFraction
+ else {
+ return nil
+ }
+ return CLIProxyGeminiQuotaBucket(
+ modelID: modelID,
+ remainingFraction: remainingFraction,
+ resetTime: self.parseGeminiResetDate(bucket.resetTime))
+ }
+ return CLIProxyGeminiQuotaResponse(buckets: buckets)
+ } catch {
+ throw CodexCLIProxyError.decodeFailed(error.localizedDescription)
+ }
+ }
+
+ private func resolveGeminiProjectID(auth: CodexCLIProxyResolvedAuth) async -> String? {
+ let body = APICallRequest(
+ authIndex: auth.authIndex,
+ method: "POST",
+ url: Self.geminiLoadCodeAssistURL,
+ header: Self.geminiHeaders,
+ data: "{}")
+
+ guard let response = try? await self.post(path: "/api-call", body: body),
+ (200 ... 299).contains(response.statusCode),
+ let bodyString = response.body,
+ let data = bodyString.data(using: .utf8),
+ let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else {
+ return nil
+ }
+
+ if let project = raw["cloudaicompanionProject"] as? String {
+ let normalized = project.trimmingCharacters(in: .whitespacesAndNewlines)
+ return normalized.isEmpty ? nil : normalized
+ }
+
+ if let project = raw["cloudaicompanionProject"] as? [String: Any] {
+ if let id = project["id"] as? String {
+ let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !normalized.isEmpty { return normalized }
+ }
+ if let projectID = project["projectId"] as? String {
+ let normalized = projectID.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !normalized.isEmpty { return normalized }
+ }
+ }
+
+ return nil
+ }
+
+ private func parseGeminiResetDate(_ raw: String?) -> Date? {
+ guard let raw else { return nil }
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: raw) { return date }
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.date(from: raw)
+ }
+
+ private func fetchAuthFiles() async throws -> AuthFilesResponse {
+ let (data, statusCode) = try await self.get(path: "/auth-files")
+ guard (200...299).contains(statusCode) else {
+ let message = String(data: data, encoding: .utf8)
+ throw CodexCLIProxyError.managementRequestFailed(statusCode, message)
+ }
+ do {
+ return try JSONDecoder().decode(AuthFilesResponse.self, from: data)
+ } catch {
+ throw CodexCLIProxyError.decodeFailed(error.localizedDescription)
+ }
+ }
+
+ private func get(path: String) async throws -> (Data, Int) {
+ let request = try self.makeRequest(path: path, method: "GET", body: nil)
+ let (data, response) = try await self.session.data(for: request)
+ guard let http = response as? HTTPURLResponse else {
+ throw CodexCLIProxyError.invalidResponse
+ }
+ return (data, http.statusCode)
+ }
+
+ private func post(path: String, body: T) async throws -> APICallResponse {
+ let requestBody = try JSONEncoder().encode(body)
+ let request = try self.makeRequest(path: path, method: "POST", body: requestBody)
+ let (data, response) = try await self.session.data(for: request)
+ guard let http = response as? HTTPURLResponse else {
+ throw CodexCLIProxyError.invalidResponse
+ }
+ guard (200...299).contains(http.statusCode) else {
+ let message = String(data: data, encoding: .utf8)
+ throw CodexCLIProxyError.managementRequestFailed(http.statusCode, message)
+ }
+ do {
+ return try JSONDecoder().decode(APICallResponse.self, from: data)
+ } catch {
+ throw CodexCLIProxyError.decodeFailed(error.localizedDescription)
+ }
+ }
+
+ private func makeRequest(path: String, method: String, body: Data?) throws -> URLRequest {
+ guard let base = self.managementURL(path: path) else {
+ throw CodexCLIProxyError.invalidBaseURL
+ }
+ var request = URLRequest(url: base)
+ request.httpMethod = method
+ request.timeoutInterval = 30
+ request.setValue("Bearer \(self.settings.managementKey)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ if let body {
+ request.httpBody = body
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ }
+ return request
+ }
+
+ private func managementURL(path: String) -> URL? {
+ let trimmedPath = path.hasPrefix("/") ? String(path.dropFirst()) : path
+ let resolvedBaseURL = self.resolvedManagementBaseURL()
+ return resolvedBaseURL?.appendingPathComponent(trimmedPath)
+ }
+
+ private func resolvedManagementBaseURL() -> URL? {
+ let base = self.settings.baseURL
+ var normalized = base
+ let path = normalized.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ if path.lowercased().hasSuffix("v0/management") {
+ return normalized
+ }
+ normalized.appendPathComponent("v0", isDirectory: false)
+ normalized.appendPathComponent("management", isDirectory: false)
+ return normalized
+ }
+
+ private func mapResolvedAuth(_ auth: AuthFileEntry) -> CodexCLIProxyResolvedAuth {
+ let authIndex = auth.authIndex?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return CodexCLIProxyResolvedAuth(
+ authIndex: authIndex,
+ email: auth.email,
+ chatGPTAccountID: auth.idToken?.chatGPTAccountID,
+ planType: auth.idToken?.planType)
+ }
+}
+
+private struct AuthFilesResponse: Decodable {
+ let files: [AuthFileEntry]
+}
+
+private struct AuthFileEntry: Decodable {
+ let authIndex: String?
+ let type: String?
+ let provider: String?
+ let email: String?
+ let disabled: Bool?
+ let idToken: IDTokenClaims?
+
+ enum CodingKeys: String, CodingKey {
+ case authIndex = "auth_index"
+ case type
+ case provider
+ case email
+ case disabled
+ case idToken = "id_token"
+ }
+}
+
+private struct IDTokenClaims: Decodable {
+ let chatGPTAccountID: String?
+ let planType: String?
+
+ enum CodingKeys: String, CodingKey {
+ case chatGPTAccountID = "chatgpt_account_id"
+ case planType = "plan_type"
+ }
+}
+
+private struct APICallRequest: Encodable {
+ let authIndex: String
+ let method: String
+ let url: String
+ let header: [String: String]
+ let data: String?
+
+ enum CodingKeys: String, CodingKey {
+ case authIndex = "auth_index"
+ case method
+ case url
+ case header
+ case data
+ }
+}
+
+private struct APICallResponse: Decodable {
+ let statusCode: Int
+ let header: [String: [String]]?
+ let body: String?
+
+ enum CodingKeys: String, CodingKey {
+ case statusCode = "status_code"
+ case header
+ case body
+ }
+
+ var compactBody: String? {
+ guard let body else { return nil }
+ let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ return trimmed.count > 320 ? String(trimmed.prefix(320)) + "…" : trimmed
+ }
+}
+
+private struct GeminiQuotaRequestPayload: Encodable {
+ let project: String
+}
+
+private struct GeminiQuotaResponsePayload: Decodable {
+ let buckets: [GeminiQuotaBucketPayload]
+}
+
+private struct GeminiQuotaBucketPayload: Decodable {
+ let remainingFraction: Double?
+ let resetTime: String?
+ let modelID: String?
+ let tokenType: String?
+
+ enum CodingKeys: String, CodingKey {
+ case remainingFraction
+ case resetTime
+ case modelID = "modelId"
+ case tokenType
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLIProxySettings.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxySettings.swift
new file mode 100644
index 000000000..978c91495
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Codex/CodexCLIProxySettings.swift
@@ -0,0 +1,63 @@
+import Foundation
+
+public struct CodexCLIProxySettings: Sendable {
+ public static let defaultBaseURL = "http://127.0.0.1:8317"
+ public static let environmentBaseURLKey = "CODEX_CLIPROXY_BASE_URL"
+ public static let environmentManagementKeyKey = "CODEX_CLIPROXY_MANAGEMENT_KEY"
+ public static let environmentAuthIndexKey = "CODEX_CLIPROXY_AUTH_INDEX"
+
+ public let baseURL: URL
+ public let managementKey: String
+ public let authIndex: String?
+
+ public init(baseURL: URL, managementKey: String, authIndex: String?) {
+ self.baseURL = baseURL
+ self.managementKey = managementKey
+ self.authIndex = authIndex
+ }
+
+ public static func resolve(
+ providerSettings: ProviderSettingsSnapshot.CodexProviderSettings?,
+ environment: [String: String]) -> CodexCLIProxySettings?
+ {
+ let managementKey = self.cleaned(providerSettings?.cliProxyManagementKey)
+ ?? self.cleaned(environment[Self.environmentManagementKeyKey])
+ guard let managementKey else { return nil }
+
+ let rawBaseURL = self.cleaned(providerSettings?.cliProxyBaseURL)
+ ?? self.cleaned(environment[Self.environmentBaseURLKey])
+ ?? Self.defaultBaseURL
+ guard let baseURL = self.normalizedURL(rawBaseURL) else { return nil }
+
+ let authIndex = self.cleaned(providerSettings?.cliProxyAuthIndex)
+ ?? self.cleaned(environment[Self.environmentAuthIndexKey])
+
+ return CodexCLIProxySettings(baseURL: baseURL, managementKey: managementKey, authIndex: authIndex)
+ }
+
+ public static func normalizedURL(_ raw: String) -> URL? {
+ let value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !value.isEmpty else { return nil }
+
+ if let url = URL(string: value), url.scheme != nil {
+ return url
+ }
+ return URL(string: "http://\(value)")
+ }
+
+ private static func cleaned(_ raw: String?) -> String? {
+ guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+ return nil
+ }
+
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+
+ value = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? nil : value
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift
index 12b68cd22..a9358c00e 100644
--- a/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift
@@ -10,13 +10,15 @@ public enum CodexProviderDescriptor {
metadata: ProviderMetadata(
id: .codex,
displayName: "Codex",
- sessionLabel: "Session",
- weeklyLabel: "Weekly",
+ sessionLabel: L10n.tr("provider.codex.metadata.session_label", fallback: "Session"),
+ weeklyLabel: L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly"),
opusLabel: nil,
supportsOpus: false,
supportsCredits: true,
- creditsHint: "Credits unavailable; keep Codex running to refresh.",
- toggleTitle: "Show Codex usage",
+ creditsHint: L10n.tr(
+ "provider.codex.metadata.credits_hint",
+ fallback: "Credits unavailable; keep Codex running to refresh."),
+ toggleTitle: L10n.tr("provider.codex.metadata.toggle_title", fallback: "Show Codex usage"),
cliName: "codex",
defaultEnabled: true,
isPrimaryProvider: true,
@@ -49,12 +51,12 @@ public enum CodexProviderDescriptor {
switch context.sourceMode {
case .oauth:
return [oauth]
+ case .api:
+ return [web, cli]
case .web:
return [web]
case .cli:
return [cli]
- case .api:
- return []
case .auto:
return [web, cli]
}
@@ -62,12 +64,12 @@ public enum CodexProviderDescriptor {
switch context.sourceMode {
case .oauth:
return [oauth]
+ case .api:
+ return [oauth, cli]
case .cli:
return [cli]
case .web:
return [web]
- case .api:
- return []
case .auto:
return [oauth, cli]
}
@@ -84,7 +86,10 @@ public enum CodexProviderDescriptor {
} ?? "\(home)/.codex"
let sessions = "\(base)/sessions"
let archived = "\(base)/archived_sessions"
- return "No Codex sessions found in \(sessions) or \(archived)."
+ let format = L10n.tr(
+ "provider.codex.no_data_message",
+ fallback: "No Codex sessions found in %@ or %@.")
+ return String(format: format, locale: .current, sessions, archived)
}
public static func resolveUsageStrategy(
@@ -151,8 +156,11 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy {
accountId: credentials.accountId)
return self.makeResult(
- usage: Self.mapUsage(usage, credentials: credentials),
- credits: Self.mapCredits(usage.credits),
+ usage: CodexUsageSnapshotMapper.usageSnapshot(
+ from: usage,
+ accountEmail: Self.resolveAccountEmail(from: credentials),
+ fallbackLoginMethod: Self.resolvePlan(response: usage, credentials: credentials)),
+ credits: CodexUsageSnapshotMapper.creditsSnapshot(from: usage.credits),
sourceLabel: "oauth")
}
@@ -161,40 +169,6 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy {
return true
}
- private static func mapUsage(_ response: CodexUsageResponse, credentials: CodexOAuthCredentials) -> UsageSnapshot {
- let primary = Self.makeWindow(response.rateLimit?.primaryWindow)
- let secondary = Self.makeWindow(response.rateLimit?.secondaryWindow)
-
- let identity = ProviderIdentitySnapshot(
- providerID: .codex,
- accountEmail: Self.resolveAccountEmail(from: credentials),
- accountOrganization: nil,
- loginMethod: Self.resolvePlan(response: response, credentials: credentials))
-
- return UsageSnapshot(
- primary: primary ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
- secondary: secondary,
- tertiary: nil,
- updatedAt: Date(),
- identity: identity)
- }
-
- private static func mapCredits(_ credits: CodexUsageResponse.CreditDetails?) -> CreditsSnapshot? {
- guard let credits, let balance = credits.balance else { return nil }
- return CreditsSnapshot(remaining: balance, events: [], updatedAt: Date())
- }
-
- private static func makeWindow(_ window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? {
- guard let window else { return nil }
- let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt))
- let resetDescription = UsageFormatter.resetDescription(from: resetDate)
- return RateWindow(
- usedPercent: Double(window.usedPercent),
- windowMinutes: window.limitWindowSeconds / 60,
- resetsAt: resetDate,
- resetDescription: resetDescription)
- }
-
private static func resolveAccountEmail(from credentials: CodexOAuthCredentials) -> String? {
guard let idToken = credentials.idToken,
let payload = UsageFetcher.parseJWT(idToken)
@@ -220,11 +194,51 @@ struct CodexOAuthFetchStrategy: ProviderFetchStrategy {
}
}
+struct CodexCLIProxyFetchStrategy: ProviderFetchStrategy {
+ let id: String = "codex.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ if context.sourceMode == .api { return true }
+ return CodexCLIProxySettings.resolve(
+ providerSettings: context.settings?.codex,
+ environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let settings = CodexCLIProxySettings.resolve(
+ providerSettings: context.settings?.codex,
+ environment: context.env)
+ else {
+ throw CodexCLIProxyError.missingManagementKey
+ }
+
+ let client = CodexCLIProxyManagementClient(settings: settings)
+ let auth = try await client.resolveCodexAuth()
+ let usage = try await client.fetchCodexUsage(auth: auth)
+
+ return self.makeResult(
+ usage: CodexUsageSnapshotMapper.usageSnapshot(
+ from: usage,
+ accountEmail: auth.email,
+ fallbackLoginMethod: auth.planType),
+ credits: CodexUsageSnapshotMapper.creditsSnapshot(from: usage.credits),
+ sourceLabel: "cliproxy-api")
+ }
+
+ func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool {
+ context.sourceMode == .auto
+ }
+}
+
#if DEBUG
extension CodexOAuthFetchStrategy {
static func _mapUsageForTesting(_ data: Data, credentials: CodexOAuthCredentials) throws -> UsageSnapshot {
let usage = try JSONDecoder().decode(CodexUsageResponse.self, from: data)
- return Self.mapUsage(usage, credentials: credentials)
+ return CodexUsageSnapshotMapper.usageSnapshot(
+ from: usage,
+ accountEmail: Self.resolveAccountEmail(from: credentials),
+ fallbackLoginMethod: Self.resolvePlan(response: usage, credentials: credentials))
}
}
#endif
diff --git a/Sources/CodexBarCore/Providers/Codex/CodexProxyProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Codex/CodexProxyProviderDescriptor.swift
new file mode 100644
index 000000000..f0cc44e4b
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Codex/CodexProxyProviderDescriptor.swift
@@ -0,0 +1,78 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum CodexProxyProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .codexproxy,
+ metadata: ProviderMetadata(
+ id: .codexproxy,
+ displayName: "CLIProxy Codex",
+ sessionLabel: L10n.tr("provider.codex.metadata.session_label", fallback: "Session"),
+ weeklyLabel: L10n.tr("provider.codex.metadata.weekly_label", fallback: "Weekly"),
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show CLIProxy Codex usage",
+ cliName: "codex-proxy",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ dashboardURL: "http://127.0.0.1:8317/management.html#/usage",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .codex,
+ iconResourceName: "ProviderIcon-codex",
+ color: ProviderColor(red: 73 / 255, green: 163 / 255, blue: 176 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: true,
+ noDataMessage: { "No Codex sessions found in local logs for CLIProxy Codex." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [CodexProxyCLIProxyFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "codex-proxy",
+ aliases: ["cliproxy-codex"],
+ versionDetector: nil))
+ }
+}
+
+private struct CodexProxyCLIProxyFetchStrategy: ProviderFetchStrategy {
+ let id: String = "codexproxy.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ CodexCLIProxySettings.resolve(
+ providerSettings: context.settings?.codex,
+ environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let settings = CodexCLIProxySettings.resolve(
+ providerSettings: context.settings?.codex,
+ environment: context.env)
+ else {
+ throw CodexCLIProxyError.missingManagementKey
+ }
+
+ let client = CodexCLIProxyManagementClient(settings: settings)
+ let auth = try await client.resolveCodexAuth()
+ let usage = try await client.fetchCodexUsage(auth: auth)
+ let snapshot = CodexUsageSnapshotMapper.usageSnapshot(
+ from: usage,
+ accountEmail: auth.email,
+ fallbackLoginMethod: auth.planType)
+ .scoped(to: .codexproxy)
+
+ return self.makeResult(
+ usage: snapshot,
+ sourceLabel: "cliproxy-api")
+ }
+
+ func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool {
+ context.sourceMode == .auto
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift
index ff644cf2b..08442c525 100644
--- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift
+++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift
@@ -34,13 +34,18 @@ public enum CodexStatusProbeError: LocalizedError, Sendable {
public var errorDescription: String? {
switch self {
case .codexNotInstalled:
- "Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart."
+ return L10n.tr(
+ "error.codex.status.missing_cli",
+ fallback: "Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart.")
case .parseFailed:
- "Could not parse Codex status; will retry shortly."
+ return L10n.tr(
+ "error.codex.status.parse_failed",
+ fallback: "Could not parse Codex status; will retry shortly.")
case .timedOut:
- "Codex status probe timed out."
+ return L10n.tr("error.codex.status.timed_out", fallback: "Codex status probe timed out.")
case let .updateRequired(msg):
- "Codex CLI update needed: \(msg)"
+ let format = L10n.tr("error.codex.status.update_required", fallback: "Codex CLI update needed: %@")
+ return String(format: format, locale: .current, msg)
}
}
}
@@ -93,7 +98,9 @@ public struct CodexStatusProbe {
}
if self.containsUpdatePrompt(clean) {
throw CodexStatusProbeError.updateRequired(
- "Run `bun install -g @openai/codex` to continue (update prompt blocking /status).")
+ L10n.tr(
+ "error.codex.status.update_required_action",
+ fallback: "Run `bun install -g @openai/codex` to continue (update prompt blocking /status)."))
}
let credits = TextParsing.firstNumber(pattern: #"Credits:\s*([0-9][0-9.,]*)"#, text: clean)
// Pull reset info from the same lines that contain the percentages.
diff --git a/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift b/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift
index b3bb16cd0..910c6b754 100644
--- a/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift
+++ b/Sources/CodexBarCore/Providers/Codex/CodexUsageDataSource.swift
@@ -3,6 +3,7 @@ import Foundation
public enum CodexUsageDataSource: String, CaseIterable, Identifiable, Sendable {
case auto
case oauth
+ case api
case cli
public var id: String {
@@ -11,9 +12,14 @@ public enum CodexUsageDataSource: String, CaseIterable, Identifiable, Sendable {
public var displayName: String {
switch self {
- case .auto: "Auto"
- case .oauth: "OAuth API"
- case .cli: "CLI (RPC/PTY)"
+ case .auto:
+ L10n.tr("provider.codex.source.auto", fallback: "Auto")
+ case .oauth:
+ L10n.tr("provider.codex.source.oauth", fallback: "OAuth API")
+ case .api:
+ L10n.tr("provider.codex.source.api", fallback: "CLIProxyAPI")
+ case .cli:
+ L10n.tr("provider.codex.source.cli", fallback: "CLI (RPC/PTY)")
}
}
@@ -23,6 +29,8 @@ public enum CodexUsageDataSource: String, CaseIterable, Identifiable, Sendable {
"auto"
case .oauth:
"oauth"
+ case .api:
+ "cliproxy-api"
case .cli:
"cli"
}
diff --git a/Sources/CodexBarCore/Providers/Codex/CodexUsageSnapshotMapper.swift b/Sources/CodexBarCore/Providers/Codex/CodexUsageSnapshotMapper.swift
new file mode 100644
index 000000000..19fbb9352
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Codex/CodexUsageSnapshotMapper.swift
@@ -0,0 +1,46 @@
+import Foundation
+
+enum CodexUsageSnapshotMapper {
+ static func usageSnapshot(
+ from response: CodexUsageResponse,
+ accountEmail: String?,
+ fallbackLoginMethod: String?) -> UsageSnapshot
+ {
+ let primary = self.makeWindow(response.rateLimit?.primaryWindow)
+ let secondary = self.makeWindow(response.rateLimit?.secondaryWindow)
+
+ let resolvedPlan = response.planType?.rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ let fallbackPlan = fallbackLoginMethod?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let loginMethod = (resolvedPlan?.isEmpty == false) ? resolvedPlan : fallbackPlan
+ let normalizedEmail = accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .codex,
+ accountEmail: normalizedEmail?.isEmpty == true ? nil : normalizedEmail,
+ accountOrganization: nil,
+ loginMethod: loginMethod?.isEmpty == true ? nil : loginMethod)
+
+ return UsageSnapshot(
+ primary: primary ?? RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: secondary,
+ tertiary: nil,
+ updatedAt: Date(),
+ identity: identity)
+ }
+
+ static func creditsSnapshot(from credits: CodexUsageResponse.CreditDetails?) -> CreditsSnapshot? {
+ guard let credits, let balance = credits.balance else { return nil }
+ return CreditsSnapshot(remaining: balance, events: [], updatedAt: Date())
+ }
+
+ private static func makeWindow(_ window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? {
+ guard let window else { return nil }
+ let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt))
+ let resetDescription = UsageFormatter.resetDescription(from: resetDate)
+ return RateWindow(
+ usedPercent: Double(window.usedPercent),
+ windowMinutes: window.limitWindowSeconds / 60,
+ resetsAt: resetDate,
+ resetDescription: resetDescription)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Gemini/CLIProxyGeminiQuotaSnapshotMapper.swift b/Sources/CodexBarCore/Providers/Gemini/CLIProxyGeminiQuotaSnapshotMapper.swift
new file mode 100644
index 000000000..45cb2248c
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Gemini/CLIProxyGeminiQuotaSnapshotMapper.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+public enum CLIProxyGeminiQuotaSnapshotMapper {
+ public static func usageSnapshot(
+ from response: CLIProxyGeminiQuotaResponse,
+ auth: CodexCLIProxyResolvedAuth,
+ provider: UsageProvider) -> UsageSnapshot
+ {
+ let modelBuckets = self.reduceByModel(response.buckets)
+ let proBucket = self.lowestBucket(matching: "pro", from: modelBuckets)
+ let flashBucket = self.lowestBucket(matching: "flash", from: modelBuckets)
+ let fallbackBucket = modelBuckets.min(by: { $0.remainingFraction < $1.remainingFraction })
+
+ let primary = self.makeWindow(proBucket ?? fallbackBucket)
+ ?? RateWindow(usedPercent: 0, windowMinutes: 1440, resetsAt: nil, resetDescription: nil)
+ let secondary = self.makeWindow(flashBucket)
+
+ let normalizedEmail = auth.email?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let identity = ProviderIdentitySnapshot(
+ providerID: provider,
+ accountEmail: normalizedEmail?.isEmpty == true ? nil : normalizedEmail,
+ accountOrganization: nil,
+ loginMethod: nil)
+
+ return UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: nil,
+ updatedAt: Date(),
+ identity: identity)
+ .scoped(to: provider)
+ }
+
+ private static func reduceByModel(_ buckets: [CLIProxyGeminiQuotaBucket]) -> [CLIProxyGeminiQuotaBucket] {
+ var byModel: [String: CLIProxyGeminiQuotaBucket] = [:]
+ for bucket in buckets {
+ guard !bucket.modelID.isEmpty else { continue }
+ if let existing = byModel[bucket.modelID], existing.remainingFraction <= bucket.remainingFraction {
+ continue
+ }
+ byModel[bucket.modelID] = bucket
+ }
+ return byModel.values.sorted { $0.modelID < $1.modelID }
+ }
+
+ private static func lowestBucket(
+ matching token: String,
+ from buckets: [CLIProxyGeminiQuotaBucket]) -> CLIProxyGeminiQuotaBucket?
+ {
+ buckets
+ .filter { $0.modelID.localizedCaseInsensitiveContains(token) }
+ .min(by: { $0.remainingFraction < $1.remainingFraction })
+ }
+
+ private static func makeWindow(_ bucket: CLIProxyGeminiQuotaBucket?) -> RateWindow? {
+ guard let bucket else { return nil }
+ let usedPercent = max(0, min(100, (1 - bucket.remainingFraction) * 100))
+ let resetDescription = bucket.resetTime.map { UsageFormatter.resetDescription(from: $0) }
+ return RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: 1440,
+ resetsAt: bucket.resetTime,
+ resetDescription: resetDescription)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProxyProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProxyProviderDescriptor.swift
new file mode 100644
index 000000000..e5ca42c8a
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProxyProviderDescriptor.swift
@@ -0,0 +1,77 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum GeminiProxyProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .geminiproxy,
+ metadata: ProviderMetadata(
+ id: .geminiproxy,
+ displayName: "CLIProxy Gemini",
+ sessionLabel: "Pro",
+ weeklyLabel: "Flash",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show CLIProxy Gemini usage",
+ cliName: "gemini-proxy",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ dashboardURL: "http://127.0.0.1:8317/management.html#/usage",
+ statusPageURL: nil),
+ branding: ProviderBranding(
+ iconStyle: .gemini,
+ iconResourceName: "ProviderIcon-gemini",
+ color: ProviderColor(red: 171 / 255, green: 135 / 255, blue: 234 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Gemini cost summary is not supported for CLIProxy source." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .api],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [GeminiCLIProxyFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "gemini-proxy",
+ aliases: ["cliproxy-gemini"],
+ versionDetector: nil))
+ }
+}
+
+private struct GeminiCLIProxyFetchStrategy: ProviderFetchStrategy {
+ let id: String = "geminiproxy.api"
+ let kind: ProviderFetchKind = .apiToken
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ CodexCLIProxySettings.resolve(
+ providerSettings: context.settings?.codex,
+ environment: context.env) != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let settings = CodexCLIProxySettings.resolve(
+ providerSettings: context.settings?.codex,
+ environment: context.env)
+ else {
+ throw CodexCLIProxyError.missingManagementKey
+ }
+
+ let client = CodexCLIProxyManagementClient(settings: settings)
+ let auth = try await client.resolveGeminiAuth()
+ let quota = try await client.fetchGeminiQuota(auth: auth)
+ let snapshot = CLIProxyGeminiQuotaSnapshotMapper.usageSnapshot(
+ from: quota,
+ auth: auth,
+ provider: .geminiproxy)
+
+ return self.makeResult(
+ usage: snapshot,
+ sourceLabel: "cliproxy-api")
+ }
+
+ func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool {
+ context.sourceMode == .auto
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 6aff83695..09d01e8fc 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -54,6 +54,9 @@ public enum ProviderDescriptorRegistry {
private static let store = Store()
private static let descriptorsByID: [UsageProvider: ProviderDescriptor] = [
.codex: CodexProviderDescriptor.descriptor,
+ .codexproxy: CodexProxyProviderDescriptor.descriptor,
+ .geminiproxy: GeminiProxyProviderDescriptor.descriptor,
+ .antigravityproxy: AntigravityProxyProviderDescriptor.descriptor,
.claude: ClaudeProviderDescriptor.descriptor,
.cursor: CursorProviderDescriptor.descriptor,
.opencode: OpenCodeProviderDescriptor.descriptor,
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index f83cb9fd1..bb40303da 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -38,15 +38,24 @@ public struct ProviderSettingsSnapshot: Sendable {
public let usageDataSource: CodexUsageDataSource
public let cookieSource: ProviderCookieSource
public let manualCookieHeader: String?
+ public let cliProxyBaseURL: String?
+ public let cliProxyManagementKey: String?
+ public let cliProxyAuthIndex: String?
public init(
usageDataSource: CodexUsageDataSource,
cookieSource: ProviderCookieSource,
- manualCookieHeader: String?)
+ manualCookieHeader: String?,
+ cliProxyBaseURL: String?,
+ cliProxyManagementKey: String?,
+ cliProxyAuthIndex: String?)
{
self.usageDataSource = usageDataSource
self.cookieSource = cookieSource
self.manualCookieHeader = manualCookieHeader
+ self.cliProxyBaseURL = cliProxyBaseURL
+ self.cliProxyManagementKey = cliProxyManagementKey
+ self.cliProxyAuthIndex = cliProxyAuthIndex
}
}
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index a267fb953..af4693a4a 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -4,6 +4,9 @@ import SweetCookieKit
// swiftformat:disable sortDeclarations
public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case codex
+ case codexproxy
+ case geminiproxy
+ case antigravityproxy
case claude
case cursor
case opencode
diff --git a/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings
new file mode 100644
index 000000000..5d371dc1f
--- /dev/null
+++ b/Sources/CodexBarCore/Resources/en.lproj/Localizable.strings
@@ -0,0 +1,261 @@
+"provider.codex.source.auto" = "Auto";
+"provider.codex.source.oauth" = "OAuth API";
+"provider.codex.source.api" = "CLIProxyAPI";
+"provider.codex.source.cli" = "CLI (RPC/PTY)";
+
+"provider.codex.version.not_detected" = "not detected";
+"provider.codex.cookie.cached" = "Cached: %@ • %@";
+"provider.codex.menu.credits" = "Credits: %@";
+"provider.codex.menu.last_spend" = "Last spend: %@";
+
+"provider.codex.metadata.session_label" = "Session";
+"provider.codex.metadata.weekly_label" = "Weekly";
+"provider.codex.metadata.credits_hint" = "Credits unavailable; keep Codex running to refresh.";
+"provider.codex.metadata.toggle_title" = "Show Codex usage";
+"provider.codex.no_data_message" = "No Codex sessions found in %@ or %@.";
+
+"provider.codex.toggle.openai_web_extras.title" = "OpenAI web extras";
+"provider.codex.toggle.openai_web_extras.subtitle" = "Show usage breakdown, credits history, and code review via chatgpt.com.";
+
+"provider.codex.picker.usage_source.title" = "Usage source";
+"provider.codex.picker.usage_source.subtitle" = "Auto falls back to the next source if the preferred one fails.";
+
+"provider.codex.picker.cookie_source.title" = "OpenAI cookies";
+"provider.codex.picker.cookie_source.subtitle" = "Automatic imports browser cookies for dashboard extras.";
+"provider.codex.picker.cookie_source.auto" = "Automatic imports browser cookies for dashboard extras.";
+"provider.codex.picker.cookie_source.manual" = "Paste a Cookie header from a chatgpt.com request.";
+"provider.codex.picker.cookie_source.off" = "Disable OpenAI dashboard cookie usage.";
+
+"provider.codex.field.cliproxy_url.title" = "CLIProxyAPI URL";
+"provider.codex.field.cliproxy_url.subtitle" = "Management API base URL (defaults to http://127.0.0.1:8317).";
+"provider.codex.field.cliproxy_url.placeholder" = "http://127.0.0.1:8317";
+"provider.codex.field.cliproxy_management_key.title" = "CLIProxy management key";
+"provider.codex.field.cliproxy_management_key.subtitle" = "Sent as Authorization Bearer token to /v0/management/* endpoints.";
+"provider.codex.field.cliproxy_management_key.placeholder" = "Paste management key…";
+"provider.codex.field.cliproxy_auth_index.title" = "CLIProxy auth_index (optional)";
+"provider.codex.field.cliproxy_auth_index.subtitle" = "Leave empty for automatic Codex auth selection.";
+"provider.codex.field.cliproxy_auth_index.placeholder" = "Optional auth_index";
+"provider.codex.field.cookie_header.placeholder" = "Cookie: …";
+
+"error.codex.cliproxy.invalid_base_url" = "CLIProxyAPI base URL is invalid.";
+"error.codex.cliproxy.missing_management_key" = "CLIProxy management key is missing. Please set it in Settings > General > CLIProxyAPI.";
+"error.codex.cliproxy.invalid_response" = "CLIProxyAPI returned an invalid response.";
+"error.codex.cliproxy.management_failed" = "CLIProxyAPI management API failed (%d).";
+"error.codex.cliproxy.management_failed_with_message" = "CLIProxyAPI management API failed (%d): %@";
+"error.codex.cliproxy.missing_auth" = "CLIProxyAPI has no available Codex auth entry.";
+"error.codex.cliproxy.missing_auth_with_index" = "CLIProxyAPI did not find Codex auth_index %@.";
+"error.codex.cliproxy.missing_provider_auth" = "CLIProxyAPI has no available %@ auth entry.";
+"error.codex.cliproxy.missing_provider_auth_with_index" = "CLIProxyAPI did not find %@ auth_index %@.";
+"error.codex.cliproxy.api_call_failed" = "CLIProxyAPI api-call failed (%d).";
+"error.codex.cliproxy.api_call_failed_with_message" = "CLIProxyAPI api-call failed (%d): %@";
+"error.codex.cliproxy.decode_failed" = "Failed to decode CLIProxyAPI response: %@";
+
+"error.codex.status.missing_cli" = "Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart.";
+"error.codex.status.parse_failed" = "Could not parse Codex status; will retry shortly.";
+"error.codex.status.timed_out" = "Codex status probe timed out.";
+"error.codex.status.update_required" = "Codex CLI update needed: %@";
+"error.codex.status.update_required_action" = "Run `bun install -g @openai/codex` to continue (update prompt blocking /status).";
+
+"error.codex.rpc.start_failed" = "Codex not running. Try running a Codex command first. (%@)";
+"error.codex.rpc.request_failed" = "Codex connection failed: %@";
+"error.codex.rpc.malformed" = "Codex returned invalid data: %@";
+"error.codex.rpc.chatgpt_auth_required" = "ChatGPT authentication required to read rate limits.";
+
+"error.codex.openai_web.login_required" = "OpenAI web access requires login.";
+"error.codex.openai_web.no_data_with_body" = "OpenAI dashboard data not found. Body sample: %@";
+"error.codex.openai_web.unsupported_platform" = "OpenAI web dashboard fetch is only supported on macOS.";
+
+"menu.action.switch_account" = "Switch Account...";
+"menu.action.add_account" = "Add Account...";
+"menu.action.usage_dashboard" = "Usage Dashboard";
+"menu.action.status_page" = "Status Page";
+"menu.action.install_update" = "Update ready, restart now?";
+"menu.action.settings" = "Settings...";
+"menu.action.about" = "About CodexBar";
+"menu.action.quit" = "Quit";
+"menu.action.buy_credits" = "Buy Credits...";
+"menu.action.credits_history" = "Credits history";
+"menu.action.usage_breakdown" = "Usage breakdown";
+"menu.codex.cliproxy.auth_grid.title" = "Codex auth entries (%d)";
+"menu.cliproxy.auth_grid.title" = "%@ auth entries (%d)";
+
+"menu.card.percent.left" = "left";
+"menu.card.percent.used" = "used";
+"menu.card.accessibility.usage_remaining" = "Usage remaining";
+"menu.card.accessibility.usage_used" = "Usage used";
+"menu.card.accessibility.credits_remaining" = "Credits remaining";
+"menu.card.credits.title" = "Credits";
+"menu.card.cost.title" = "Cost";
+"menu.card.tokens.unit" = "%@ tokens";
+"menu.card.cost.today_with_tokens" = "Today: %@ · %@ tokens";
+"menu.card.cost.today" = "Today: %@";
+"menu.card.cost.last_30_days_with_tokens" = "Last 30 days: %@ · %@ tokens";
+"menu.card.cost.last_30_days" = "Last 30 days: %@";
+"menu.card.provider_cost.quota_usage" = "Quota usage";
+"menu.card.provider_cost.extra_usage" = "Extra usage";
+"menu.card.provider_cost.this_month" = "This month";
+
+"window.buy_credits.title" = "Buy Credits";
+
+"settings.general.language.title" = "Language";
+"settings.general.language.subtitle" = "Choose app display language.";
+"settings.general.language.restart_hint" = "Language changes apply after restart.";
+"settings.general.language.apply_restart" = "Apply & Restart";
+"settings.general.language.option.system" = "System";
+"settings.general.language.option.english" = "English";
+"settings.general.language.option.zh_hans" = "Simplified Chinese";
+
+"settings.general.cliproxy.section" = "CLIProxyAPI";
+"settings.general.cliproxy.url.title" = "Base URL";
+"settings.general.cliproxy.url.subtitle" = "Global default for providers using API source (for example Codex).";
+"settings.general.cliproxy.url.placeholder" = "http://127.0.0.1:8317";
+"settings.general.cliproxy.key.title" = "Management Key";
+"settings.general.cliproxy.key.placeholder" = "Paste management key…";
+"settings.general.cliproxy.auth_index.title" = "auth_index (optional)";
+"settings.general.cliproxy.auth_index.subtitle" = "Optional. Set a specific auth file; leave empty to aggregate all matching auth entries.";
+"settings.general.cliproxy.auth_index.placeholder" = "Leave empty to load all matching auth entries";
+
+"provider.codex.cliproxy.aggregate.account_label" = "All Codex auth entries (%d)";
+"provider.cliproxy.aggregate.account_label" = "All %@ auth entries (%d)";
+
+"settings.general.system.section" = "System";
+"settings.general.system.start_at_login.title" = "Start at Login";
+"settings.general.system.start_at_login.subtitle" = "Automatically opens CodexBar when you start your Mac.";
+"settings.general.usage.section" = "Usage";
+"settings.general.usage.cost_summary.title" = "Show cost summary";
+"settings.general.usage.cost_summary.subtitle" = "Reads local usage logs. Shows today + last 30 days cost in the menu.";
+"settings.general.usage.cost_summary.refresh_hint" = "Auto-refresh: hourly · Timeout: 10m";
+"settings.general.automation.section" = "Automation";
+"settings.general.automation.refresh_cadence.title" = "Refresh cadence";
+"settings.general.automation.refresh_cadence.subtitle" = "How often CodexBar polls providers in the background.";
+"settings.general.automation.refresh_cadence.manual_hint" = "Auto-refresh is off; use the menu's Refresh command.";
+"settings.general.automation.check_status.title" = "Check provider status";
+"settings.general.automation.check_status.subtitle" = "Polls OpenAI/Claude status pages and Google Workspace for Gemini/Antigravity, surfacing incidents in the icon and menu.";
+"settings.general.automation.session_quota.title" = "Session quota notifications";
+"settings.general.automation.session_quota.subtitle" = "Notifies when the 5-hour session quota hits 0% and when it becomes available again.";
+"settings.general.quit" = "Quit CodexBar";
+"settings.general.usage.cost_status.unsupported" = "%@: unsupported";
+"settings.general.usage.cost_status.fetching" = "%@: fetching…%@";
+"settings.general.usage.cost_status.snapshot" = "%@: %@ · 30d %@";
+"settings.general.usage.cost_status.last_attempt" = "%@: last attempt %@";
+"settings.general.usage.cost_status.no_data" = "%@: no data yet";
+
+"settings.general.refresh_frequency.manual" = "Manual";
+"settings.general.refresh_frequency.one_minute" = "1 min";
+"settings.general.refresh_frequency.two_minutes" = "2 min";
+"settings.general.refresh_frequency.five_minutes" = "5 min";
+"settings.general.refresh_frequency.fifteen_minutes" = "15 min";
+"settings.general.refresh_frequency.thirty_minutes" = "30 min";
+
+"settings.providers.menu_bar_metric.title" = "Menu bar metric";
+"settings.providers.menu_bar_metric.subtitle" = "Choose which window drives the menu bar percent.";
+"settings.providers.menu_bar_metric.option.automatic" = "Automatic";
+"settings.providers.menu_bar_metric.option.primary" = "Primary";
+"settings.providers.menu_bar_metric.option.secondary" = "Secondary";
+"settings.providers.menu_bar_metric.option.average" = "Average";
+"settings.providers.menu_bar_metric.option.primary_with_label" = "Primary (%@)";
+"settings.providers.menu_bar_metric.option.secondary_with_label" = "Secondary (%@)";
+"settings.providers.menu_bar_metric.option.average_with_labels" = "Average (%@ + %@)";
+
+"settings.preferences.tab.general" = "General";
+"settings.preferences.tab.providers" = "Providers";
+"settings.preferences.tab.display" = "Display";
+"settings.preferences.tab.advanced" = "Advanced";
+"settings.preferences.tab.about" = "About";
+"settings.preferences.tab.debug" = "Debug";
+
+"settings.display.menu_bar.section" = "Menu bar";
+"settings.display.menu_bar.merge_icons.title" = "Merge Icons";
+"settings.display.menu_bar.merge_icons.subtitle" = "Use a single menu bar icon with a provider switcher.";
+"settings.display.menu_bar.switcher_icons.title" = "Switcher shows icons";
+"settings.display.menu_bar.switcher_icons.subtitle" = "Show provider icons in the switcher (otherwise show a weekly progress line).";
+"settings.display.menu_bar.highest_usage.title" = "Show most-used provider";
+"settings.display.menu_bar.highest_usage.subtitle" = "Menu bar auto-shows the provider closest to its rate limit.";
+"settings.display.menu_bar.brand_percent.title" = "Menu bar shows percent";
+"settings.display.menu_bar.brand_percent.subtitle" = "Replace critter bars with provider branding icons and a percentage.";
+"settings.display.menu_bar.mode.title" = "Display mode";
+"settings.display.menu_bar.mode.subtitle" = "Choose what to show in the menu bar (Pace shows usage vs. expected).";
+"settings.display.menu_content.section" = "Menu content";
+"settings.display.menu_content.usage_as_used.title" = "Show usage as used";
+"settings.display.menu_content.usage_as_used.subtitle" = "Progress bars fill as you consume quota (instead of showing remaining).";
+"settings.display.menu_content.reset_clock.title" = "Show reset time as clock";
+"settings.display.menu_content.reset_clock.subtitle" = "Display reset times as absolute clock values instead of countdowns.";
+"settings.display.menu_content.optional_usage.title" = "Show credits + extra usage";
+"settings.display.menu_content.optional_usage.subtitle" = "Show Codex Credits and Claude Extra usage sections in the menu.";
+"settings.display.menu_content.all_token_accounts.title" = "Show all token accounts";
+"settings.display.menu_content.all_token_accounts.subtitle" = "Stack token accounts in the menu (otherwise show an account switcher bar).";
+
+"settings.advanced.keyboard.section" = "Keyboard shortcut";
+"settings.advanced.keyboard.open_menu.title" = "Open menu";
+"settings.advanced.keyboard.open_menu.subtitle" = "Trigger the menu bar menu from anywhere.";
+"settings.advanced.cli.install" = "Install CLI";
+"settings.advanced.cli.install.subtitle" = "Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar.";
+"settings.advanced.debug.title" = "Show Debug Settings";
+"settings.advanced.debug.subtitle" = "Expose troubleshooting tools in the Debug tab.";
+"settings.advanced.surprise.title" = "Surprise me";
+"settings.advanced.surprise.subtitle" = "Check if you like your agents having some fun up there.";
+"settings.advanced.privacy.hide_personal_info.title" = "Hide personal information";
+"settings.advanced.privacy.hide_personal_info.subtitle" = "Obscure email addresses in the menu bar and menu UI.";
+"settings.advanced.keychain.title" = "Keychain access";
+"settings.advanced.keychain.caption" = "Disable all Keychain reads and writes. Browser cookie import is unavailable; paste Cookie headers manually in Providers.";
+"settings.advanced.keychain.disable.title" = "Disable Keychain access";
+"settings.advanced.keychain.disable.subtitle" = "Prevents any Keychain access while enabled.";
+"settings.advanced.cli.status.helper_not_found" = "CodexBarCLI not found in app bundle.";
+"settings.advanced.cli.status.no_write_access" = "No write access: %@";
+"settings.advanced.cli.status.installed" = "Installed: %@";
+"settings.advanced.cli.status.exists" = "Exists: %@";
+"settings.advanced.cli.status.failed" = "Failed: %@";
+"settings.advanced.cli.status.no_writable_dirs" = "No writable bin dirs found.";
+
+"settings.about.version" = "Version %@";
+"settings.about.build" = "Built %@";
+"settings.about.tagline" = "May your tokens never run out—keep agent limits in view.";
+"settings.about.link.github" = "GitHub";
+"settings.about.link.website" = "Website";
+"settings.about.link.twitter" = "Twitter";
+"settings.about.link.email" = "Email";
+"settings.about.updates.auto_check" = "Check for updates automatically";
+"settings.about.updates.channel" = "Update Channel";
+"settings.about.updates.check_now" = "Check for Updates…";
+"settings.about.updates.unavailable" = "Updates unavailable in this build.";
+"settings.about.copyright" = "© 2025 Peter Steinberger. MIT License.";
+
+"settings.providers.select_provider" = "Select a provider";
+"settings.providers.alert.cancel" = "Cancel";
+"settings.providers.subtitle.last_fetch_failed" = "last fetch failed";
+"settings.providers.subtitle.not_fetched_yet" = "usage not fetched yet";
+"settings.providers.error.last_fetch_failed" = "Last %@ fetch failed:";
+"settings.providers.error.copy" = "Copy error";
+"settings.providers.error.hide_details" = "Hide details";
+"settings.providers.error.show_details" = "Show details";
+"settings.providers.section.settings" = "Settings";
+"settings.providers.section.options" = "Options";
+"settings.providers.section.usage" = "Usage";
+"settings.providers.detail.label.state" = "State";
+"settings.providers.detail.label.source" = "Source";
+"settings.providers.detail.label.version" = "Version";
+"settings.providers.detail.label.updated" = "Updated";
+"settings.providers.detail.label.status" = "Status";
+"settings.providers.detail.label.account" = "Account";
+"settings.providers.detail.label.plan" = "Plan";
+"settings.providers.detail.label.credits" = "Credits";
+"settings.providers.detail.label.cost" = "Cost";
+"settings.providers.detail.help.refresh" = "Refresh";
+"settings.providers.detail.version.not_detected" = "not detected";
+"settings.providers.detail.state.enabled" = "Enabled";
+"settings.providers.detail.state.disabled" = "Disabled";
+"settings.providers.detail.updated.refreshing" = "Refreshing";
+"settings.providers.detail.updated.not_fetched_yet" = "Not fetched yet";
+"settings.providers.metrics.placeholder.disabled_no_data" = "Disabled — no recent data";
+"settings.providers.metrics.placeholder.no_usage" = "No usage yet";
+"settings.providers.cost.accessibility.usage_used" = "Usage used";
+"settings.providers.cost.percent_used" = "%.0f%% used";
+"settings.providers.token_accounts.empty" = "No token accounts yet.";
+"settings.providers.token_accounts.remove_selected" = "Remove selected account";
+"settings.providers.token_accounts.label_placeholder" = "Label";
+"settings.providers.token_accounts.add" = "Add";
+"settings.providers.token_accounts.open_token_file" = "Open token file";
+"settings.providers.token_accounts.reload" = "Reload";
+"settings.providers.sidebar.reorder.help" = "Drag to reorder";
+"settings.providers.sidebar.status.disabled_prefix" = "Disabled — %@";
+"settings.providers.sidebar.accessibility.reorder" = "Reorder";
diff --git a/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings
new file mode 100644
index 000000000..79e8931b5
--- /dev/null
+++ b/Sources/CodexBarCore/Resources/zh-Hans.lproj/Localizable.strings
@@ -0,0 +1,261 @@
+"provider.codex.source.auto" = "自动";
+"provider.codex.source.oauth" = "OAuth 接口";
+"provider.codex.source.api" = "CLIProxyAPI";
+"provider.codex.source.cli" = "CLI(RPC/PTY)";
+
+"provider.codex.version.not_detected" = "未检测到";
+"provider.codex.cookie.cached" = "已缓存:%@ • %@";
+"provider.codex.menu.credits" = "积分:%@";
+"provider.codex.menu.last_spend" = "最近消费:%@";
+
+"provider.codex.metadata.session_label" = "5 小时使用上限";
+"provider.codex.metadata.weekly_label" = "每周使用上限";
+"provider.codex.metadata.credits_hint" = "积分暂不可用;保持 Codex 运行后会自动刷新。";
+"provider.codex.metadata.toggle_title" = "显示 Codex 用量";
+"provider.codex.no_data_message" = "在 %@ 或 %@ 中未找到 Codex 会话。";
+
+"provider.codex.toggle.openai_web_extras.title" = "OpenAI 网页扩展";
+"provider.codex.toggle.openai_web_extras.subtitle" = "在 chatgpt.com 显示用量拆分、积分历史和代码审查信息。";
+
+"provider.codex.picker.usage_source.title" = "用量来源";
+"provider.codex.picker.usage_source.subtitle" = "自动模式会在首选来源失败时回退到下一个来源。";
+
+"provider.codex.picker.cookie_source.title" = "OpenAI Cookies";
+"provider.codex.picker.cookie_source.subtitle" = "自动模式会导入浏览器 Cookies 以获取仪表盘扩展数据。";
+"provider.codex.picker.cookie_source.auto" = "自动模式会导入浏览器 Cookies 以获取仪表盘扩展数据。";
+"provider.codex.picker.cookie_source.manual" = "粘贴 chatgpt.com 请求中的 Cookie 头。";
+"provider.codex.picker.cookie_source.off" = "禁用 OpenAI 仪表盘 Cookie。";
+
+"provider.codex.field.cliproxy_url.title" = "CLIProxyAPI 地址";
+"provider.codex.field.cliproxy_url.subtitle" = "管理 API 基础地址(默认 http://127.0.0.1:8317)。";
+"provider.codex.field.cliproxy_url.placeholder" = "http://127.0.0.1:8317";
+"provider.codex.field.cliproxy_management_key.title" = "CLIProxy 管理密钥";
+"provider.codex.field.cliproxy_management_key.subtitle" = "以 Authorization Bearer 形式发送到 /v0/management/* 接口。";
+"provider.codex.field.cliproxy_management_key.placeholder" = "粘贴管理密钥…";
+"provider.codex.field.cliproxy_auth_index.title" = "CLIProxy auth_index(可选)";
+"provider.codex.field.cliproxy_auth_index.subtitle" = "留空时自动选择 Codex 认证条目。";
+"provider.codex.field.cliproxy_auth_index.placeholder" = "可选 auth_index";
+"provider.codex.field.cookie_header.placeholder" = "Cookie:…";
+
+"error.codex.cliproxy.invalid_base_url" = "CLIProxyAPI 地址无效。";
+"error.codex.cliproxy.missing_management_key" = "缺少 CLIProxy 管理密钥。请到 设置 > General > CLIProxyAPI 中填写。";
+"error.codex.cliproxy.invalid_response" = "CLIProxyAPI 返回了无效响应。";
+"error.codex.cliproxy.management_failed" = "CLIProxyAPI 管理接口请求失败(%d)。";
+"error.codex.cliproxy.management_failed_with_message" = "CLIProxyAPI 管理接口请求失败(%d):%@";
+"error.codex.cliproxy.missing_auth" = "CLIProxyAPI 中没有可用的 Codex 认证条目。";
+"error.codex.cliproxy.missing_auth_with_index" = "CLIProxyAPI 未找到 Codex auth_index %@。";
+"error.codex.cliproxy.missing_provider_auth" = "CLIProxyAPI 中没有可用的 %@ 认证条目。";
+"error.codex.cliproxy.missing_provider_auth_with_index" = "CLIProxyAPI 未找到 %@ auth_index %@。";
+"error.codex.cliproxy.api_call_failed" = "CLIProxyAPI api-call 请求失败(%d)。";
+"error.codex.cliproxy.api_call_failed_with_message" = "CLIProxyAPI api-call 请求失败(%d):%@";
+"error.codex.cliproxy.decode_failed" = "解析 CLIProxyAPI 响应失败:%@";
+
+"error.codex.status.missing_cli" = "未检测到 Codex CLI。请执行 `npm i -g @openai/codex`(或 bun 安装)后重试。";
+"error.codex.status.parse_failed" = "无法解析 Codex 状态,将稍后重试。";
+"error.codex.status.timed_out" = "Codex 状态探测超时。";
+"error.codex.status.update_required" = "需要更新 Codex CLI:%@";
+"error.codex.status.update_required_action" = "请执行 `bun install -g @openai/codex` 后继续(更新提示会阻塞 /status)。";
+
+"error.codex.rpc.start_failed" = "Codex 未运行。请先运行一次 Codex 命令。(%@)";
+"error.codex.rpc.request_failed" = "Codex 连接失败:%@";
+"error.codex.rpc.malformed" = "Codex 返回了无效数据:%@";
+"error.codex.rpc.chatgpt_auth_required" = "需要 ChatGPT 登录才能读取限额。";
+
+"error.codex.openai_web.login_required" = "OpenAI 网页访问需要登录。";
+"error.codex.openai_web.no_data_with_body" = "未找到 OpenAI 仪表盘数据。返回内容示例:%@";
+"error.codex.openai_web.unsupported_platform" = "OpenAI 网页仪表盘仅支持在 macOS 上抓取。";
+
+"menu.action.switch_account" = "切换账号...";
+"menu.action.add_account" = "添加账号...";
+"menu.action.usage_dashboard" = "用量仪表盘";
+"menu.action.status_page" = "状态页";
+"menu.action.install_update" = "发现更新,立即重启?";
+"menu.action.settings" = "设置...";
+"menu.action.about" = "关于 CodexBar";
+"menu.action.quit" = "退出";
+"menu.action.buy_credits" = "购买积分...";
+"menu.action.credits_history" = "积分历史";
+"menu.action.usage_breakdown" = "用量拆分";
+"menu.codex.cliproxy.auth_grid.title" = "Codex 认证条目(%d 个)";
+"menu.cliproxy.auth_grid.title" = "%@ 认证条目(%d 个)";
+
+"menu.card.percent.left" = "剩余";
+"menu.card.percent.used" = "已用";
+"menu.card.accessibility.usage_remaining" = "用量剩余";
+"menu.card.accessibility.usage_used" = "用量已用";
+"menu.card.accessibility.credits_remaining" = "积分剩余";
+"menu.card.credits.title" = "积分";
+"menu.card.cost.title" = "花费";
+"menu.card.tokens.unit" = "%@ tokens";
+"menu.card.cost.today_with_tokens" = "今天:%@ · %@ tokens";
+"menu.card.cost.today" = "今天:%@";
+"menu.card.cost.last_30_days_with_tokens" = "近 30 天:%@ · %@ tokens";
+"menu.card.cost.last_30_days" = "近 30 天:%@";
+"menu.card.provider_cost.quota_usage" = "配额使用";
+"menu.card.provider_cost.extra_usage" = "额外用量";
+"menu.card.provider_cost.this_month" = "本月";
+
+"window.buy_credits.title" = "购买积分";
+
+"settings.general.language.title" = "语言";
+"settings.general.language.subtitle" = "选择应用界面语言。";
+"settings.general.language.restart_hint" = "语言变更在重启后生效。";
+"settings.general.language.apply_restart" = "应用并重启";
+"settings.general.language.option.system" = "跟随系统";
+"settings.general.language.option.english" = "English";
+"settings.general.language.option.zh_hans" = "简体中文";
+
+"settings.general.cliproxy.section" = "CLIProxyAPI";
+"settings.general.cliproxy.url.title" = "基础地址";
+"settings.general.cliproxy.url.subtitle" = "作为使用 API 源的 Provider(例如 Codex)的全局默认值。";
+"settings.general.cliproxy.url.placeholder" = "http://127.0.0.1:8317";
+"settings.general.cliproxy.key.title" = "管理密钥";
+"settings.general.cliproxy.key.placeholder" = "粘贴 management key…";
+"settings.general.cliproxy.auth_index.title" = "auth_index(可选)";
+"settings.general.cliproxy.auth_index.subtitle" = "可选。指定某个认证文件;留空则聚合所有匹配的认证条目。";
+"settings.general.cliproxy.auth_index.placeholder" = "留空将加载全部匹配的认证条目";
+
+"provider.codex.cliproxy.aggregate.account_label" = "全部 Codex 认证条目(%d 个)";
+"provider.cliproxy.aggregate.account_label" = "全部 %@ 认证条目(%d 个)";
+
+"settings.general.system.section" = "系统";
+"settings.general.system.start_at_login.title" = "开机启动";
+"settings.general.system.start_at_login.subtitle" = "在 Mac 启动时自动打开 CodexBar。";
+"settings.general.usage.section" = "用量";
+"settings.general.usage.cost_summary.title" = "显示花费摘要";
+"settings.general.usage.cost_summary.subtitle" = "读取本地 usage 日志,在菜单中显示今天和近 30 天花费。";
+"settings.general.usage.cost_summary.refresh_hint" = "自动刷新:每小时 · 超时:10 分钟";
+"settings.general.automation.section" = "自动化";
+"settings.general.automation.refresh_cadence.title" = "刷新频率";
+"settings.general.automation.refresh_cadence.subtitle" = "CodexBar 在后台轮询各 Provider 的频率。";
+"settings.general.automation.refresh_cadence.manual_hint" = "已关闭自动刷新;可使用菜单中的 Refresh 手动刷新。";
+"settings.general.automation.check_status.title" = "检查服务状态";
+"settings.general.automation.check_status.subtitle" = "轮询 OpenAI/Claude 状态页和 Gemini/Antigravity 对应的 Google Workspace 状态,并在图标与菜单中提示故障。";
+"settings.general.automation.session_quota.title" = "会话配额通知";
+"settings.general.automation.session_quota.subtitle" = "当 5 小时会话额度降至 0% 或恢复可用时发送通知。";
+"settings.general.quit" = "退出 CodexBar";
+"settings.general.usage.cost_status.unsupported" = "%@:不支持";
+"settings.general.usage.cost_status.fetching" = "%@:获取中…%@";
+"settings.general.usage.cost_status.snapshot" = "%@:%@ · 30 天 %@";
+"settings.general.usage.cost_status.last_attempt" = "%@:上次尝试 %@";
+"settings.general.usage.cost_status.no_data" = "%@:暂无数据";
+
+"settings.general.refresh_frequency.manual" = "手动";
+"settings.general.refresh_frequency.one_minute" = "1 分钟";
+"settings.general.refresh_frequency.two_minutes" = "2 分钟";
+"settings.general.refresh_frequency.five_minutes" = "5 分钟";
+"settings.general.refresh_frequency.fifteen_minutes" = "15 分钟";
+"settings.general.refresh_frequency.thirty_minutes" = "30 分钟";
+
+"settings.providers.menu_bar_metric.title" = "菜单栏指标";
+"settings.providers.menu_bar_metric.subtitle" = "选择菜单栏百分比依据的窗口。";
+"settings.providers.menu_bar_metric.option.automatic" = "自动";
+"settings.providers.menu_bar_metric.option.primary" = "主窗口";
+"settings.providers.menu_bar_metric.option.secondary" = "次窗口";
+"settings.providers.menu_bar_metric.option.average" = "平均";
+"settings.providers.menu_bar_metric.option.primary_with_label" = "主窗口(%@)";
+"settings.providers.menu_bar_metric.option.secondary_with_label" = "次窗口(%@)";
+"settings.providers.menu_bar_metric.option.average_with_labels" = "平均(%@ + %@)";
+
+"settings.preferences.tab.general" = "通用";
+"settings.preferences.tab.providers" = "提供方";
+"settings.preferences.tab.display" = "显示";
+"settings.preferences.tab.advanced" = "高级";
+"settings.preferences.tab.about" = "关于";
+"settings.preferences.tab.debug" = "调试";
+
+"settings.display.menu_bar.section" = "菜单栏";
+"settings.display.menu_bar.merge_icons.title" = "合并图标";
+"settings.display.menu_bar.merge_icons.subtitle" = "使用单个菜单栏图标,并通过切换器切换 Provider。";
+"settings.display.menu_bar.switcher_icons.title" = "切换器显示图标";
+"settings.display.menu_bar.switcher_icons.subtitle" = "在切换器中显示 Provider 图标(否则显示周用量进度线)。";
+"settings.display.menu_bar.highest_usage.title" = "显示最高使用率 Provider";
+"settings.display.menu_bar.highest_usage.subtitle" = "菜单栏会自动显示最接近限额的 Provider。";
+"settings.display.menu_bar.brand_percent.title" = "菜单栏显示百分比";
+"settings.display.menu_bar.brand_percent.subtitle" = "使用 Provider 品牌图标和百分比替代小动物条。";
+"settings.display.menu_bar.mode.title" = "显示模式";
+"settings.display.menu_bar.mode.subtitle" = "选择菜单栏显示内容(Pace 显示当前用量相对预期节奏)。";
+"settings.display.menu_content.section" = "菜单内容";
+"settings.display.menu_content.usage_as_used.title" = "按已用显示用量";
+"settings.display.menu_content.usage_as_used.subtitle" = "进度条随配额消耗而填充(而不是显示剩余)。";
+"settings.display.menu_content.reset_clock.title" = "重置时间显示为时刻";
+"settings.display.menu_content.reset_clock.subtitle" = "将重置时间显示为绝对时刻而非倒计时。";
+"settings.display.menu_content.optional_usage.title" = "显示积分与额外用量";
+"settings.display.menu_content.optional_usage.subtitle" = "在菜单中显示 Codex 积分与 Claude 额外用量区块。";
+"settings.display.menu_content.all_token_accounts.title" = "显示全部 token 账户";
+"settings.display.menu_content.all_token_accounts.subtitle" = "在菜单中堆叠所有 token 账户(否则显示账户切换条)。";
+
+"settings.advanced.keyboard.section" = "快捷键";
+"settings.advanced.keyboard.open_menu.title" = "打开菜单";
+"settings.advanced.keyboard.open_menu.subtitle" = "在任何位置触发菜单栏菜单。";
+"settings.advanced.cli.install" = "安装 CLI";
+"settings.advanced.cli.install.subtitle" = "将 CodexBarCLI 软链接到 /usr/local/bin 和 /opt/homebrew/bin,并命名为 codexbar。";
+"settings.advanced.debug.title" = "显示调试设置";
+"settings.advanced.debug.subtitle" = "在“调试”标签页显示排障工具。";
+"settings.advanced.surprise.title" = "来点惊喜";
+"settings.advanced.surprise.subtitle" = "看看你是否喜欢你的 agents 在上面玩点花样。";
+"settings.advanced.privacy.hide_personal_info.title" = "隐藏个人信息";
+"settings.advanced.privacy.hide_personal_info.subtitle" = "在菜单栏和菜单 UI 中遮蔽邮箱地址。";
+"settings.advanced.keychain.title" = "钥匙串访问";
+"settings.advanced.keychain.caption" = "禁用所有钥匙串读写。浏览器 Cookie 导入将不可用;请在 Providers 中手动粘贴 Cookie 头。";
+"settings.advanced.keychain.disable.title" = "禁用钥匙串访问";
+"settings.advanced.keychain.disable.subtitle" = "启用后将阻止任何钥匙串访问。";
+"settings.advanced.cli.status.helper_not_found" = "在应用包中未找到 CodexBarCLI。";
+"settings.advanced.cli.status.no_write_access" = "无写权限:%@";
+"settings.advanced.cli.status.installed" = "已安装:%@";
+"settings.advanced.cli.status.exists" = "已存在:%@";
+"settings.advanced.cli.status.failed" = "失败:%@";
+"settings.advanced.cli.status.no_writable_dirs" = "未找到可写的 bin 目录。";
+
+"settings.about.version" = "版本 %@";
+"settings.about.build" = "构建于 %@";
+"settings.about.tagline" = "愿你的 tokens 永不见底——让 agent 限额始终可见。";
+"settings.about.link.github" = "GitHub";
+"settings.about.link.website" = "网站";
+"settings.about.link.twitter" = "Twitter";
+"settings.about.link.email" = "邮箱";
+"settings.about.updates.auto_check" = "自动检查更新";
+"settings.about.updates.channel" = "更新通道";
+"settings.about.updates.check_now" = "检查更新…";
+"settings.about.updates.unavailable" = "当前构建不可用更新功能。";
+"settings.about.copyright" = "© 2025 Peter Steinberger. MIT 许可证。";
+
+"settings.providers.select_provider" = "请选择一个 Provider";
+"settings.providers.alert.cancel" = "取消";
+"settings.providers.subtitle.last_fetch_failed" = "上次获取失败";
+"settings.providers.subtitle.not_fetched_yet" = "尚未获取用量";
+"settings.providers.error.last_fetch_failed" = "%@ 上次获取失败:";
+"settings.providers.error.copy" = "复制错误";
+"settings.providers.error.hide_details" = "隐藏详情";
+"settings.providers.error.show_details" = "显示详情";
+"settings.providers.section.settings" = "设置";
+"settings.providers.section.options" = "选项";
+"settings.providers.section.usage" = "用量";
+"settings.providers.detail.label.state" = "状态";
+"settings.providers.detail.label.source" = "来源";
+"settings.providers.detail.label.version" = "版本";
+"settings.providers.detail.label.updated" = "更新时间";
+"settings.providers.detail.label.status" = "服务状态";
+"settings.providers.detail.label.account" = "账户";
+"settings.providers.detail.label.plan" = "计划";
+"settings.providers.detail.label.credits" = "积分";
+"settings.providers.detail.label.cost" = "花费";
+"settings.providers.detail.help.refresh" = "刷新";
+"settings.providers.detail.version.not_detected" = "未检测到";
+"settings.providers.detail.state.enabled" = "已启用";
+"settings.providers.detail.state.disabled" = "已禁用";
+"settings.providers.detail.updated.refreshing" = "刷新中";
+"settings.providers.detail.updated.not_fetched_yet" = "尚未获取";
+"settings.providers.metrics.placeholder.disabled_no_data" = "已禁用 — 暂无最近数据";
+"settings.providers.metrics.placeholder.no_usage" = "暂无用量";
+"settings.providers.cost.accessibility.usage_used" = "已使用用量";
+"settings.providers.cost.percent_used" = "已使用 %.0f%%";
+"settings.providers.token_accounts.empty" = "暂无 token 账户。";
+"settings.providers.token_accounts.remove_selected" = "移除当前账户";
+"settings.providers.token_accounts.label_placeholder" = "标签";
+"settings.providers.token_accounts.add" = "添加";
+"settings.providers.token_accounts.open_token_file" = "打开 token 文件";
+"settings.providers.token_accounts.reload" = "重新加载";
+"settings.providers.sidebar.reorder.help" = "拖拽以排序";
+"settings.providers.sidebar.status.disabled_prefix" = "已禁用 — %@";
+"settings.providers.sidebar.accessibility.reorder" = "排序";
diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift
index ca300ea9f..7760a33b7 100644
--- a/Sources/CodexBarCore/UsageFetcher.swift
+++ b/Sources/CodexBarCore/UsageFetcher.swift
@@ -282,13 +282,34 @@ private enum RPCWireError: Error, LocalizedError {
var errorDescription: String? {
switch self {
case let .startFailed(message):
- "Codex not running. Try running a Codex command first. (\(message))"
+ let format = L10n.tr(
+ "error.codex.rpc.start_failed",
+ fallback: "Codex not running. Try running a Codex command first. (%@)")
+ return String(format: format, locale: .current, Self.localizedMessage(message))
case let .requestFailed(message):
- "Codex connection failed: \(message)"
+ let format = L10n.tr(
+ "error.codex.rpc.request_failed",
+ fallback: "Codex connection failed: %@")
+ return String(format: format, locale: .current, Self.localizedMessage(message))
case let .malformed(message):
- "Codex returned invalid data: \(message)"
+ let format = L10n.tr(
+ "error.codex.rpc.malformed",
+ fallback: "Codex returned invalid data: %@")
+ return String(format: format, locale: .current, Self.localizedMessage(message))
}
}
+
+ private static func localizedMessage(_ message: String) -> String {
+ let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return message }
+ let normalized = trimmed.lowercased()
+ if normalized.contains("chatgpt authentication required to read rate limits") {
+ return L10n.tr(
+ "error.codex.rpc.chatgpt_auth_required",
+ fallback: "ChatGPT authentication required to read rate limits.")
+ }
+ return trimmed
+ }
}
/// RPC helper used on background tasks; safe because we confine it to the owning task.
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index d47d7d557..d9c22a30c 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -61,6 +61,12 @@ enum CostUsageScanner {
switch provider {
case .codex:
return self.loadCodexDaily(range: range, now: now, options: options)
+ case .codexproxy:
+ return self.loadCodexDaily(range: range, now: now, options: options)
+ case .geminiproxy:
+ return CostUsageDailyReport(data: [], summary: nil)
+ case .antigravityproxy:
+ return CostUsageDailyReport(data: [], summary: nil)
case .claude:
return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options)
case .zai:
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 1634611ee..d7763a977 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -42,6 +42,9 @@ enum ProviderChoice: String, AppEnum {
init?(provider: UsageProvider) {
switch provider {
case .codex: self = .codex
+ case .codexproxy: return nil // CLIProxy Codex not yet supported in widgets
+ case .geminiproxy: return nil // CLIProxy Gemini not yet supported in widgets
+ case .antigravityproxy: return nil // CLIProxy Antigravity not yet supported in widgets
case .claude: self = .claude
case .gemini: self = .gemini
case .antigravity: self = .antigravity
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index ed39b4506..d298e746d 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -258,6 +258,9 @@ private struct ProviderSwitchChip: View {
private var shortLabel: String {
switch self.provider {
case .codex: "Codex"
+ case .codexproxy: "CdxProxy"
+ case .geminiproxy: "GemProxy"
+ case .antigravityproxy: "AntiProxy"
case .claude: "Claude"
case .gemini: "Gemini"
case .antigravity: "Anti"
@@ -571,6 +574,12 @@ enum WidgetColors {
switch provider {
case .codex:
Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255)
+ case .codexproxy:
+ Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255)
+ case .geminiproxy:
+ Color(red: 171 / 255, green: 135 / 255, blue: 234 / 255)
+ case .antigravityproxy:
+ Color(red: 96 / 255, green: 186 / 255, blue: 126 / 255)
case .claude:
Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255)
case .gemini:
diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift
index a735f6a19..c68d832ff 100644
--- a/Tests/CodexBarTests/MenuCardModelTests.swift
+++ b/Tests/CodexBarTests/MenuCardModelTests.swift
@@ -42,6 +42,7 @@ struct MenuCardModelTests {
let model = UsageMenuCardView.Model.make(.init(
provider: .codex,
metadata: metadata,
+ sourceLabel: nil,
snapshot: updatedSnap,
credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now),
creditsError: nil,
@@ -103,6 +104,7 @@ struct MenuCardModelTests {
let model = UsageMenuCardView.Model.make(.init(
provider: .codex,
metadata: metadata,
+ sourceLabel: nil,
snapshot: snapshot,
credits: nil,
creditsError: nil,
@@ -153,6 +155,7 @@ struct MenuCardModelTests {
let model = UsageMenuCardView.Model.make(.init(
provider: .codex,
metadata: metadata,
+ sourceLabel: nil,
snapshot: snapshot,
credits: nil,
creditsError: nil,
@@ -195,6 +198,7 @@ struct MenuCardModelTests {
let model = UsageMenuCardView.Model.make(.init(
provider: .claude,
metadata: metadata,
+ sourceLabel: nil,
snapshot: snapshot,
credits: nil,
creditsError: nil,
@@ -223,6 +227,7 @@ struct MenuCardModelTests {
let model = UsageMenuCardView.Model.make(.init(
provider: .codex,
metadata: metadata,
+ sourceLabel: nil,
snapshot: nil,
credits: nil,
creditsError: nil,
@@ -264,6 +269,7 @@ struct MenuCardModelTests {
let model = UsageMenuCardView.Model.make(.init(
provider: .codex,
metadata: metadata,
+ sourceLabel: nil,
snapshot: snapshot,
credits: nil,
creditsError: nil,
@@ -291,6 +297,7 @@ struct MenuCardModelTests {
let model = UsageMenuCardView.Model.make(.init(
provider: .claude,
metadata: metadata,
+ sourceLabel: nil,
snapshot: nil,
credits: nil,
creditsError: nil,
@@ -331,6 +338,7 @@ struct MenuCardModelTests {
let model = UsageMenuCardView.Model.make(.init(
provider: .codex,
metadata: metadata,
+ sourceLabel: nil,
snapshot: snapshot,
credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now),
creditsError: nil,
@@ -371,6 +379,7 @@ struct MenuCardModelTests {
let model = UsageMenuCardView.Model.make(.init(
provider: .claude,
metadata: metadata,
+ sourceLabel: nil,
snapshot: snapshot,
credits: nil,
creditsError: nil,
@@ -410,6 +419,7 @@ struct MenuCardModelTests {
let model = UsageMenuCardView.Model.make(.init(
provider: .codex,
metadata: metadata,
+ sourceLabel: nil,
snapshot: snapshot,
credits: nil,
creditsError: nil,
diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
index 9e05c7462..9c2d035bd 100644
--- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
+++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
@@ -123,6 +123,50 @@ struct ProviderSettingsDescriptorTests {
#expect(pickers.contains(where: { $0.id == "codex-cookie-source" }))
}
+ @Test
+ func codexHidesCLIProxyFieldsWhenAPISelected() throws {
+ let suite = "ProviderSettingsDescriptorTests-codex-cliproxy"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
+ let configStore = testConfigStore(suiteName: suite)
+ let settings = SettingsStore(
+ userDefaults: defaults,
+ configStore: configStore,
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.codexUsageDataSource = .api
+
+ let store = UsageStore(
+ fetcher: UsageFetcher(environment: [:]),
+ browserDetection: BrowserDetection(cacheTTL: 0),
+ settings: settings)
+
+ let context = ProviderSettingsContext(
+ provider: .codex,
+ settings: settings,
+ store: store,
+ boolBinding: { keyPath in
+ Binding(
+ get: { settings[keyPath: keyPath] },
+ set: { settings[keyPath: keyPath] = $0 })
+ },
+ stringBinding: { keyPath in
+ Binding(
+ get: { settings[keyPath: keyPath] },
+ set: { settings[keyPath: keyPath] = $0 })
+ },
+ statusText: { _ in nil },
+ setStatusText: { _, _ in },
+ lastAppActiveRunAt: { _ in nil },
+ setLastAppActiveRunAt: { _, _ in },
+ requestConfirmation: { _ in })
+
+ let fields = CodexProviderImplementation().settingsFields(context: context)
+ #expect(fields.contains(where: { $0.id == "codex-cliproxy-base-url" }) == false)
+ #expect(fields.contains(where: { $0.id == "codex-cliproxy-management-key" }) == false)
+ #expect(fields.contains(where: { $0.id == "codex-cliproxy-auth-index" }) == false)
+ }
+
@Test
func claudeExposesUsageAndCookiePickers() throws {
let suite = "ProviderSettingsDescriptorTests-claude"
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index fad99a763..93f68f547 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -143,6 +143,36 @@ struct SettingsStoreTests {
#expect(store.codexUsageDataSource == .auto)
}
+ @Test
+ func persistsCodexCLIProxySettingsAcrossInstances() throws {
+ let suite = "SettingsStoreTests-codex-cliproxy"
+ let defaultsA = try #require(UserDefaults(suiteName: suite))
+ defaultsA.removePersistentDomain(forName: suite)
+ let configStore = testConfigStore(suiteName: suite)
+
+ let storeA = SettingsStore(
+ userDefaults: defaultsA,
+ configStore: configStore,
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ storeA.codexUsageDataSource = .api
+ storeA.codexCLIProxyBaseURL = "http://127.0.0.1:8317"
+ storeA.codexCLIProxyManagementKey = "test-management-key"
+ storeA.codexCLIProxyAuthIndex = "auth-index-123"
+
+ let defaultsB = try #require(UserDefaults(suiteName: suite))
+ let storeB = SettingsStore(
+ userDefaults: defaultsB,
+ configStore: configStore,
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+
+ #expect(storeB.codexUsageDataSource == .api)
+ #expect(storeB.codexCLIProxyBaseURL == "http://127.0.0.1:8317")
+ #expect(storeB.codexCLIProxyManagementKey == "test-management-key")
+ #expect(storeB.codexCLIProxyAuthIndex == "auth-index-123")
+ }
+
@Test
@MainActor
func applyExternalConfigDoesNotBroadcast() throws {
diff --git a/docs/codex.md b/docs/codex.md
index 9b23ed49c..509a5e112 100644
--- a/docs/codex.md
+++ b/docs/codex.md
@@ -1,5 +1,5 @@
---
-summary: "Codex provider data sources: OpenAI web dashboard, Codex CLI RPC/PTY, credits, and local cost usage."
+summary: "Codex provider data sources: OAuth API, CLIProxyAPI management API, OpenAI web dashboard, CLI RPC/PTY, and local cost usage."
read_when:
- Debugging Codex usage/credits parsing
- Updating OpenAI dashboard scraping or cookie import
@@ -9,7 +9,7 @@ read_when:
# Codex provider
-Codex has four usage data paths (OAuth API, web dashboard, CLI RPC, CLI PTY) plus a local cost-usage scanner.
+Codex has five usage data paths (OAuth API, CLIProxyAPI management API, web dashboard, CLI RPC, CLI PTY) plus a local cost-usage scanner.
The OAuth API is the default app source when credentials are available; web access is optional for dashboard extras.
## Data sources + fallback order
@@ -21,7 +21,7 @@ The OAuth API is the default app source when credentials are available; web acce
`primary + openai-web`.
Usage source picker:
-- Preferences → Providers → Codex → Usage source (Auto/OAuth/CLI).
+- Preferences → Providers → Codex → Usage source (Auto/OAuth/CLIProxyAPI/CLI).
### CLI default selection (`--source auto`)
1) OpenAI web dashboard (when available).