diff --git a/.gitignore b/.gitignore
index d44f986f6..0de0767b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,10 @@ debug_*.swift
# Misc
.DS_Store
.vscode/
+
+# Cursor / SpecStory local state (never commit)
+.cursor/hooks/state/
+.specstory/
.codex/environments/
.swiftpm-cache/
diff --git a/AGENTS.md b/AGENTS.md
index a4c8e630a..0a5563d62 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -40,3 +40,11 @@
- Keep provider data siloed: when rendering usage or account info for a provider (Claude vs Codex), never display identity/plan fields sourced from a different provider.***
- Claude CLI status line is custom + user-configurable; never rely on it for usage parsing.
- Cookie imports: default Chrome-only when possible to avoid other browser prompts; override via browser list when needed.
+
+## Learned User Preferences
+- When extending provider usage models (e.g. MiniMax), mirror existing field and UI patterns; add new fields using the same conventions as neighboring code.
+
+## Learned Workspace Facts
+- MiniMax Coding Plan `model_remains` weekly fields may arrive as both zeros, or with only one of `current_weekly_total_count` / `current_weekly_usage_count` present and zero. CodexBar treats “at least one weekly key present and both sides numerically zero when missing counts as zero” as no weekly cap. Interval window lines that are 0/0 placeholders are suppressed in the menu card so they are not mistaken for weekly limits.
+- `swift build -c release` only refreshes the `.build/.../CodexBar` binary. The launchable root `CodexBar.app` is recreated by `Scripts/package_app.sh` or `Scripts/compile_and_run.sh`; if UI behavior looks stale, compare the bundle `CodexGitCommit` in `Contents/Info.plist` with `git rev-parse --short HEAD`.
+- MiniMax menu usage is rendered inside one hosted `NSMenuItem`, so height limiting, scrolling, and section collapsing must happen inside that card to keep the bottom app-level menu items visible. Current MiniMax behavior: collapse state is keyed by section title, 5+ row sections default to collapsed, and Preferences mirrors the sections with scrolling only (no collapse).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bddd111e3..8f40e7b6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,36 +1,77 @@
# Changelog
-## 0.21 — Unreleased
+## 0.23 — Unreleased
+
+### Changes
+- Claude: show Designs and Daily Routines usage bars from live Claude OAuth/Web quota data, and restore the Web-mode Sonnet bar (#740). Thanks @AISupplyGuy!
+- Codex: add GPT-5.5 and GPT-5.5 Pro pricing so local cost scanning recognizes the new models.
+- Cursor: add an Extra usage menu bar metric for on-demand budgets (#789). Thanks @huiye98!
+- Mistral: add provider support with monthly spend tracking, browser-cookie import, manual cookies, and CLI/token-account support (#607). Thanks @welcoMattic!
+- Usage: add an opt-in confetti celebration when weekly limits reset after active use (#785). Thanks @zats!
+
+### Fixes
+- Codex: ignore invalid zero-minute subscription history so the utilization submenu no longer shows duplicate Session tabs.
+- Codex: clean up cached CLI status probes during app shutdown so `codex -s read-only` workers are not orphaned after restart.
+- Menu: keep merged-menu cards, switcher rows, wrapped status text, and hosted chart submenus aligned with the real AppKit menu width so menus no longer grow oversized or show narrower chart submenus after width changes. Thanks @ngutman!
+- Widgets: package App Intents metadata for the widget extension and use configuration defaults so configurable widgets load correctly in WidgetKit (#783). Thanks @ngutman and @vincentyangch!
+
+## 0.22 — 2026-04-21
+
+### Highlights
+- Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry.
+- Synthetic: parse live quota payloads for five-hour, weekly, and search limits, including continuous reset/regeneration details (#732). Thanks @baanish!
+- Antigravity: restore account/quota probing across newer localhost endpoint/token layouts and retry paths (#727). Thanks @icey-zhang!
+- Menu: add standard shortcuts for Refresh, Settings, and Quit while the status menu is open (#737). Thanks @anirudhvee!
+- Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman!
+
+### Providers & Usage
+- Synthetic: parse live five-hour, weekly, and search quota payloads, including continuous reset/regeneration details (#732). Thanks @baanish!
+- Antigravity: restore localhost probing with async TLS challenge handling, extension-token fallback, and best-effort port selection (#727). Thanks @icey-zhang!
+- Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael!
+- Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe!
+- Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang!
+- Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry. @ratulsarna
+
+### Menu & Settings
+- Menu: show and handle standard shortcuts for Refresh (⌘R), Settings (⌘,), and Quit (⌘Q) while the status menu is open (#737). Thanks @anirudhvee!
+- Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman!
+- Settings: fix provider-sidebar clipping on macOS Tahoe and resize the Preferences window when switching tabs (#580). Thanks @chadneal!
+
+### Fixes
+- Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98!
+
+## 0.21 — 2026-04-18
### Highlights
- Abacus AI: add a new provider for ChatLLM and RouteLLM credit tracking with browser-cookie import, manual-cookie support, and monthly pace rendering. Thanks @ChrisGVE!
-- Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation!
-- z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation.
-- Claude: add Opus 4.7 pricing so local cost scanning and cost breakdowns recognize the new model. Thanks @knivram!
- Codex: recognize the new Pro $100 plan in OAuth, OpenAI web, menu, and CLI rendering, and preserve CLI fallback when partial OAuth payloads lose the 5-hour session lane (#691, #709). Thanks @ImLukeF!
+- Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane!
- Codex: fix local cost scanner overcounting and cross-day undercounting across forked sessions, cold-cache refreshes, and sessions-root changes (#698). Thanks @xx205!
-- Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han!
+- z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation.
+- Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation!
+- Antigravity: restore account and quota probing across newer localhost endpoint/token layouts and API-level retry failures (#693, fixes #692). Thanks @anirudhvee!
- Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm!
- Battery / refresh: cut menu redraw churn, skip background work for unavailable providers, and reuse cached OpenAI web views more efficiently (#708).
-- Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane!
+- Claude: add Opus 4.7 pricing so local cost scanning and cost breakdowns recognize the new model. Thanks @knivram!
+- Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han!
### Providers & Usage
- Abacus AI: add provider support for ChatLLM and RouteLLM monthly compute-credit tracking with cookie import, manual cookie headers, timeout/browser-detection threading, optional billing fallback, and hardened cached-session retry behavior. Thanks @ChrisGVE!
+- Codex: render the new Pro $100 plan consistently across OAuth, OpenAI web, menu, and CLI surfaces, tolerate newer Codex OAuth payload variants like `prolite`, and only fall back to the CLI in auto mode when OAuth decode damage actually drops the session lane (#691, #709).
+- Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable.
+- Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205!
- z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation.
- Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663).
-- Claude: add Opus 4.7 pricing so local cost usage and breakdowns price the new model correctly. Thanks @knivram!
-- Antigravity: scope insecure localhost trust handling to `127.0.0.1` / `localhost`, keep localhost requests cancellable, and restore local quota/account probing on builds that previously failed TLS challenge handling (#693, fixes #692). Thanks @anirudhvee!
+- Antigravity: try both language-server and extension-server endpoint/token combinations, retry after API-level errors, scope insecure localhost trust handling to loopback hosts, and restore local quota/account probing on newer Antigravity builds (#693, fixes #692). Thanks @anirudhvee!
- Antigravity: prefer `userTier.name` over generic plan info when rendering the account plan so Google AI Ultra and similar tiers show their real subscription name, while still falling back cleanly when the tier label is absent or blank (#303). Thanks @zacklavin11!
-- Codex: render the new Pro $100 plan consistently across OAuth, OpenAI web, menu, and CLI surfaces, tolerate newer Codex OAuth payload variants like `prolite`, and only fall back to the CLI in auto mode when OAuth decode damage actually drops the session lane (#691, #709).
- Ollama: recognize `__Secure-session` cookies during manual cookie entry and browser-cookie import so authenticated usage fetching continues to work with the newer cookie name (#707). Thanks @anirudhvee!
- OpenCode: enable weekly pace visualization for the app and CLI so weekly bars show reserve percentage, expected-usage markers, and "Lasts until reset" details like Codex and Claude (#639). Thanks @Zachary!
-- Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205!
- Refresh pipeline: skip background work for unavailable providers, clear stale cached state, and show explicit unavailable messages (#708).
-- Claude: broaden CLI binary lookup to native installer paths (#731). Thanks @dingtang2008!
- Codex: support Microsoft Edge in browser-cookie import for the Codex provider while keeping the contributor branch untouched in the superseding integration path (#694). Thanks @Astro-Han!
- OpenCode / OpenCode Go: treat serialized `_server` auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500.
-- Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable.
- OpenAI web: keep cached WebViews across same-account refreshes and clean them up only when accounts or providers go stale (#708).
+- Claude: add Opus 4.7 pricing so local cost usage and breakdowns price the new model correctly. Thanks @knivram!
+- Claude: broaden CLI binary lookup to native installer paths (#731). Thanks @dingtang2008!
### Menu & Settings
- Menu bar: fix missing icons on affected macOS 26 systems by replacing RenderBox-triggering material/offscreen SwiftUI effects in the provider sidebar and highlighted progress bar (#677). Thanks @andrzejchm!
diff --git a/Package.resolved b/Package.resolved
index f84c0c217..2dd5f9581 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "74bd6f3ab6e0b0cb0c2cddb00f2167c2ab0a1c00cd54ffc1a2899c7ef8c56367",
+ "originHash" : "6e0bbde3ad4d9af0981adadaf1f109eb154e54018d06dbe966616f09c3898482",
"pins" : [
{
"identity" : "commander",
@@ -24,8 +24,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
- "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6",
- "version" : "2.8.1"
+ "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261",
+ "version" : "2.9.1"
}
},
{
@@ -42,8 +42,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log",
"state" : {
- "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181",
- "version" : "1.9.1"
+ "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256",
+ "version" : "1.12.0"
}
},
{
@@ -54,6 +54,14 @@
"revision" : "0687f71944021d616d34d922343dcef086855920",
"version" : "600.0.1"
}
+ },
+ {
+ "identity" : "vortex",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/zats/Vortex",
+ "state" : {
+ "revision" : "ef5392088d4aeb255c4eee83157dbdafcd31bf07"
+ }
}
],
"version" : 3
diff --git a/Package.swift b/Package.swift
index 0ea2821a0..e8e769f33 100644
--- a/Package.swift
+++ b/Package.swift
@@ -17,11 +17,12 @@ let package = Package(
.macOS(.v14),
],
dependencies: [
- .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
+ .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1"),
.package(url: "https://github.com/steipete/Commander", from: "0.2.1"),
- .package(url: "https://github.com/apple/swift-log", from: "1.9.1"),
+ .package(url: "https://github.com/apple/swift-log", from: "1.12.0"),
.package(url: "https://github.com/apple/swift-syntax", from: "600.0.1"),
.package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.4.0"),
+ .package(url: "https://github.com/zats/Vortex", revision: "ef5392088d4aeb255c4eee83157dbdafcd31bf07"),
sweetCookieKitDependency,
],
targets: {
@@ -82,6 +83,7 @@ let package = Package(
dependencies: [
.product(name: "Sparkle", package: "Sparkle"),
.product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"),
+ .product(name: "Vortex", package: "Vortex"),
"CodexBarMacroSupport",
"CodexBarCore",
],
diff --git a/Scripts/compile_and_run.sh b/Scripts/compile_and_run.sh
index 865f2a797..12a39996b 100755
--- a/Scripts/compile_and_run.sh
+++ b/Scripts/compile_and_run.sh
@@ -29,6 +29,33 @@ delete_keychain_service_items() {
done
}
+# Ensure Swift >= 5.5 (required for --arch flag in swift build)
+ensure_swift_version() {
+ local swift_output
+ local swift_ver
+ swift_output=$(swift --version 2>&1 || true)
+ if [[ "$swift_output" =~ (Apple[[:space:]]+)?Swift[[:space:]]+version[[:space:]]+([0-9]+)\.([0-9]+)(\.[0-9]+)? ]]; then
+ swift_ver="${BASH_REMATCH[2]}.${BASH_REMATCH[3]}${BASH_REMATCH[4]}"
+ else
+ fail "Swift >= 5.5 required (found ${swift_output:-none}). Install Xcode or update swiftly."
+ fi
+ local major minor
+ major=$(echo "$swift_ver" | cut -d. -f1)
+ minor=$(echo "$swift_ver" | cut -d. -f2)
+ if [[ "${major:-0}" -ge 6 ]] || { [[ "${major:-0}" -eq 5 ]] && [[ "${minor:-0}" -ge 5 ]]; }; then
+ return 0
+ fi
+ # Try Xcode toolchain
+ local xcrun_swift
+ xcrun_swift=$(xcrun --find swift 2>/dev/null || true)
+ if [[ -n "$xcrun_swift" && -x "$xcrun_swift" ]]; then
+ log "WARN: PATH swift is v${swift_ver}; switching to Xcode toolchain at $(dirname "$xcrun_swift")"
+ export PATH="$(dirname "$xcrun_swift"):$PATH"
+ return 0
+ fi
+ fail "Swift >= 5.5 required (found ${swift_ver:-none}). Install Xcode or update swiftly."
+}
+
has_signing_identity() {
local identity="${1:-}"
if [[ -z "${identity}" ]]; then
@@ -173,6 +200,7 @@ for arg in "$@"; do
esac
done
+ensure_swift_version
resolve_signing_mode
if [[ "${CLEAR_ADHOC_KEYCHAIN}" == "1" && "${SIGNING_MODE}" != "adhoc" ]]; then
fail "--clear-adhoc-keychain is only supported when using adhoc signing."
diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh
index 4a8a6de02..ac8842dab 100755
--- a/Scripts/package_app.sh
+++ b/Scripts/package_app.sh
@@ -98,6 +98,86 @@ path.write_text(text)
PY
}
+generate_widget_appintents_metadata() {
+ local widget_resources_dir="$1"
+ local xcode_conf
+ local host_arch
+ local derived_dir
+ local build_dir
+ local object_dir
+ local source_file_list
+ local const_values_list
+ local dependency_metadata
+ local static_dependency_metadata
+ local appintents_tool
+ local sdk_root
+ local swiftc_path
+ local toolchain_dir
+ local xcode_version
+
+ xcode_conf="Release"
+ if [[ "$LOWER_CONF" == "debug" ]]; then
+ xcode_conf="Debug"
+ fi
+
+ host_arch=$(uname -m)
+ derived_dir="$ROOT/.build/xcode-widget-metadata-${LOWER_CONF}"
+ build_dir="$derived_dir/Build/Intermediates.noindex/CodexBar.build/${xcode_conf}/CodexBarWidget.build"
+ object_dir="$build_dir/Objects-normal/${host_arch}"
+ source_file_list="$object_dir/CodexBarWidget.SwiftFileList"
+ const_values_list="$object_dir/CodexBarWidget.SwiftConstValuesFileList"
+ dependency_metadata="$build_dir/CodexBarWidget.DependencyMetadataFileList"
+ static_dependency_metadata="$build_dir/CodexBarWidget.DependencyStaticMetadataFileList"
+
+ appintents_tool=$(xcrun --find appintentsmetadataprocessor)
+ sdk_root=$(xcrun --sdk macosx --show-sdk-path)
+ swiftc_path=$(xcrun --find swiftc)
+ toolchain_dir=$(dirname "$(dirname "$(dirname "$swiftc_path")")")
+ xcode_version=$(xcodebuild -version | awk '/Build version/ { print $3 }')
+
+ rm -rf "$derived_dir"
+ xcodebuild \
+ -workspace "$ROOT/.swiftpm/xcode/package.xcworkspace" \
+ -scheme CodexBarWidget \
+ -configuration "$xcode_conf" \
+ -destination "platform=macOS,arch=${host_arch}" \
+ -derivedDataPath "$derived_dir" \
+ build >/dev/null
+
+ if [[ ! -f "$source_file_list" ]]; then
+ echo "ERROR: Missing App Intents metadata inputs for CodexBarWidget." >&2
+ exit 1
+ fi
+
+ find "$object_dir" -name '*.swiftconstvalues' | sort > "$const_values_list"
+ if [[ ! -s "$const_values_list" ]]; then
+ echo "ERROR: Missing App Intents const-values outputs for CodexBarWidget." >&2
+ exit 1
+ fi
+ rm -rf "$widget_resources_dir/Metadata.appintents"
+ mkdir -p "$widget_resources_dir"
+
+ "$appintents_tool" \
+ --output "$widget_resources_dir" \
+ --toolchain-dir "$toolchain_dir" \
+ --module-name CodexBarWidget \
+ --sdk-root "$sdk_root" \
+ --xcode-version "$xcode_version" \
+ --platform-family macOS \
+ --deployment-target 14.0 \
+ --target-triple "${host_arch}-apple-macos14.0" \
+ --source-file-list "$source_file_list" \
+ --swift-const-vals-list "$const_values_list" \
+ --metadata-file-list "$dependency_metadata" \
+ --static-metadata-file-list "$static_dependency_metadata" \
+ --force >/dev/null
+
+ if [[ ! -f "$widget_resources_dir/Metadata.appintents/extract.actionsdata" ]]; then
+ echo "ERROR: Failed to generate App Intents metadata for CodexBarWidget." >&2
+ exit 1
+ fi
+}
+
KEYBOARD_SHORTCUTS_UTIL="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift"
if [[ ! -f "$KEYBOARD_SHORTCUTS_UTIL" ]]; then
swift build -c "$CONF" --arch "${ARCH_LIST[0]}"
@@ -134,9 +214,10 @@ if [[ "$SIGNING_MODE" == "adhoc" ]]; then
AUTO_CHECKS=false
fi
WIDGET_BUNDLE_ID="${BUNDLE_ID}.widget"
-APP_GROUP_ID="group.com.steipete.codexbar"
+APP_TEAM_ID="${APP_TEAM_ID:-Y5PE65HELJ}"
+APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar"
if [[ "$BUNDLE_ID" == *".debug"* ]]; then
- APP_GROUP_ID="group.com.steipete.codexbar.debug"
+ APP_GROUP_ID="${APP_TEAM_ID}.com.steipete.codexbar.debug"
fi
ENTITLEMENTS_DIR="$ROOT/.build/entitlements"
APP_ENTITLEMENTS="${ENTITLEMENTS_DIR}/CodexBar.entitlements"
@@ -197,6 +278,7 @@ cat > "$APP/Contents/Info.plist" <SUEnableAutomaticChecks<${AUTO_CHECKS}/>
CodexBuildTimestamp${BUILD_TIMESTAMP}CodexGitCommit${GIT_COMMIT}
+ CodexBarTeamID${APP_TEAM_ID}
PLIST
@@ -292,6 +374,7 @@ if [[ -n "$(resolve_binary_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then
CFBundleShortVersionString${MARKETING_VERSION}CFBundleVersion${BUILD_NUMBER}LSMinimumSystemVersion14.0
+ CodexBarTeamID${APP_TEAM_ID}NSExtensionNSExtensionPointIdentifiercom.apple.widgetkit-extension
@@ -301,6 +384,7 @@ if [[ -n "$(resolve_binary_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then
PLIST
install_binary "CodexBarWidget" "$WIDGET_APP/Contents/MacOS/CodexBarWidget"
+ generate_widget_appintents_metadata "$WIDGET_APP/Contents/Resources"
fi
# Embed Sparkle.framework
if [[ -d ".build/$CONF/Sparkle.framework" ]]; then
diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift
index cb7fb5be4..ccaad6969 100644
--- a/Sources/CodexBar/CodexbarApp.swift
+++ b/Sources/CodexBar/CodexbarApp.swift
@@ -278,6 +278,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
let updaterController: UpdaterProviding = makeUpdaterController()
+ private let confettiOverlayController = ScreenConfettiOverlayController()
+ private let confettiLogger = CodexBarLog.logger(LogCategories.confetti)
private var statusController: StatusItemControlling?
private var store: UsageStore?
private var settings: SettingsStore?
@@ -285,6 +287,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var preferencesSelection: PreferencesSelection?
private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator?
private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator?
+ private var hasInstalledWeeklyLimitResetObserver = false
func configure(_ dependencies: Dependencies) {
self.store = dependencies.store
@@ -307,12 +310,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self?.statusController?.openMenuFromShortcut()
}
}
+ if !self.hasInstalledWeeklyLimitResetObserver {
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(self.handleWeeklyLimitResetNotification(_:)),
+ name: .codexbarWeeklyLimitReset,
+ object: nil)
+ self.hasInstalledWeeklyLimitResetObserver = true
+ }
}
func applicationWillTerminate(_ notification: Notification) {
+ self.confettiOverlayController.dismiss()
TTYCommandRunner.terminateActiveProcessesForAppShutdown()
}
+ @objc private func handleWeeklyLimitResetNotification(_ notification: Notification) {
+ guard let event = notification.object as? WeeklyLimitResetEvent else { return }
+ guard self.settings?.confettiOnWeeklyLimitResetsEnabled == true else { return }
+ let origin = self.statusController?.celebrationOriginPoint(for: event.provider)
+ self.confettiLogger.info(
+ "Triggering confetti",
+ metadata: [
+ "provider": event.provider.rawValue,
+ "accountIdentifier": event.accountIdentifier,
+ "originKnown": origin == nil ? "0" : "1",
+ ])
+ self.confettiOverlayController.play(originInScreen: origin)
+ }
+
/// Use the classic (non-Liquid Glass) app icon on macOS versions before 26.
private func configureAppIconForMacOSVersion() {
if #unavailable(macOS 26) {
@@ -382,4 +408,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
fallbackManagedCodexAccountCoordinator,
fallbackCodexAccountPromotionCoordinator)
}
+
+ deinit {
+ NotificationCenter.default.removeObserver(self)
+ }
}
diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift
index 9857f39a1..f013d636e 100644
--- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift
+++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift
@@ -17,6 +17,8 @@ enum MenuBarMetricWindowResolver {
{
guard let snapshot else { return nil }
switch preference {
+ case .extraUsage:
+ return Self.extraUsageWindow(snapshot: snapshot)
case .tertiary:
return Self.window(in: snapshot, following: Self.tertiaryOrder(for: provider))
case .primary:
@@ -141,4 +143,14 @@ enum MenuBarMetricWindowResolver {
guard !windows.isEmpty else { return nil }
return windows.max(by: { $0.usedPercent < $1.usedPercent })
}
+
+ private static func extraUsageWindow(snapshot: UsageSnapshot?) -> RateWindow? {
+ guard let cost = snapshot?.providerCost, cost.limit > 0 else { return nil }
+ let usedPercent = max(0, min(100, (cost.used / cost.limit) * 100))
+ return RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: nil,
+ resetsAt: cost.resetsAt,
+ resetDescription: nil)
+ }
}
diff --git a/Sources/CodexBar/MenuCardClickToCopy.swift b/Sources/CodexBar/MenuCardClickToCopy.swift
new file mode 100644
index 000000000..73451d12e
--- /dev/null
+++ b/Sources/CodexBar/MenuCardClickToCopy.swift
@@ -0,0 +1,42 @@
+import AppKit
+import SwiftUI
+
+// MARK: - Copy-on-click overlay
+
+struct ClickToCopyOverlay: NSViewRepresentable {
+ let copyText: String
+
+ func makeNSView(context: Context) -> ClickToCopyView {
+ ClickToCopyView(copyText: self.copyText)
+ }
+
+ func updateNSView(_ nsView: ClickToCopyView, context: Context) {
+ nsView.copyText = self.copyText
+ }
+}
+
+final class ClickToCopyView: NSView {
+ var copyText: String
+
+ init(copyText: String) {
+ self.copyText = copyText
+ super.init(frame: .zero)
+ self.wantsLayer = false
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
+ true
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ _ = event
+ let pb = NSPasteboard.general
+ pb.clearContents()
+ pb.setString(self.copyText, forType: .string)
+ }
+}
diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift
index 4d9c05127..81e24a181 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -88,6 +88,22 @@ struct UsageMenuCardView: View {
let spendLine: String
}
+ /// Grouped Token Plan rows (`model_remains[]`) for MiniMax menu card.
+ struct MiniMaxSection {
+ let title: String
+ let rows: [MiniMaxRow]
+ }
+
+ struct MiniMaxRow: Identifiable, Equatable {
+ let id: String
+ let title: String
+ let percent: Double?
+ let percentStyle: PercentStyle
+ let resetText: String?
+ let detailText: String?
+ let secondaryLine: String?
+ }
+
let provider: UsageProvider
let providerName: String
let email: String
@@ -95,6 +111,8 @@ struct UsageMenuCardView: View {
let subtitleStyle: SubtitleStyle
let planText: String?
let metrics: [Metric]
+ /// Non-nil only for MiniMax when `model_remains` has more than one row or weekly detail.
+ let minimaxSections: [MiniMaxSection]?
let usageNotes: [String]
let creditsText: String?
let creditsRemaining: Double?
@@ -108,13 +126,12 @@ struct UsageMenuCardView: View {
let model: Model
let width: CGFloat
+ let onMiniMaxLayoutChange: (() -> Void)?
+ let miniMaxVisibleScreenHeight: CGFloat?
@Environment(\.menuItemHighlighted) private var isHighlighted
static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String {
- if provider == .openrouter, metric.id == "primary" {
- return "API key limit"
- }
- return metric.title
+ provider == .openrouter && metric.id == "primary" ? "API key limit" : metric.title
}
var body: some View {
@@ -125,7 +142,8 @@ struct UsageMenuCardView: View {
Divider()
}
- if self.model.metrics.isEmpty {
+ let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false
+ if self.model.metrics.isEmpty, !hasMiniMaxSections {
if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
} else if let placeholder = self.model.placeholder {
@@ -134,22 +152,53 @@ struct UsageMenuCardView: View {
.font(.subheadline)
}
} else {
- let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty
+ let hasUsage = !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || hasMiniMaxSections
let hasCredits = self.model.creditsText != nil
let hasProviderCost = self.model.providerCost != nil
let hasCost = self.model.tokenUsage != nil || hasProviderCost
VStack(alignment: .leading, spacing: 12) {
if hasUsage {
- VStack(alignment: .leading, spacing: 12) {
- ForEach(self.model.metrics, id: \.id) { metric in
- MetricRow(
- metric: metric,
- title: Self.popupMetricTitle(provider: self.model.provider, metric: metric),
- progressColor: self.model.progressColor)
- }
- if !self.model.usageNotes.isEmpty {
- UsageNotesContent(notes: self.model.usageNotes)
+ Group {
+ if hasMiniMaxSections {
+ MiniMaxCappedScrollView(
+ maxHeight: MiniMaxUILayoutMetrics
+ .menuUsageScrollMaxHeight(visibleScreenHeight: self.miniMaxVisibleScreenHeight))
+ {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(self.model.metrics, id: \.id) { metric in
+ MetricRow(
+ metric: metric,
+ title: Self.popupMetricTitle(
+ provider: self.model.provider,
+ metric: metric),
+ progressColor: self.model.progressColor)
+ }
+ if !self.model.usageNotes.isEmpty {
+ UsageNotesContent(notes: self.model.usageNotes)
+ }
+ if let sections = self.model.minimaxSections, !sections.isEmpty {
+ MiniMaxTokenPlanSectionsView(
+ sections: sections,
+ progressColor: self.model.progressColor,
+ onLayoutChange: self.onMiniMaxLayoutChange)
+ }
+ }
+ }
+ } else {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(self.model.metrics, id: \.id) { metric in
+ MetricRow(
+ metric: metric,
+ title: Self.popupMetricTitle(
+ provider: self.model.provider,
+ metric: metric),
+ progressColor: self.model.progressColor)
+ }
+ if !self.model.usageNotes.isEmpty {
+ UsageNotesContent(notes: self.model.usageNotes)
+ }
+ }
}
}
}
@@ -216,7 +265,8 @@ struct UsageMenuCardView: View {
private var hasDetails: Bool {
!self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil ||
self.model.tokenUsage != nil ||
- self.model.providerCost != nil
+ self.model.providerCost != nil ||
+ (self.model.minimaxSections?.isEmpty == false)
}
}
@@ -456,28 +506,22 @@ struct UsageMenuCardUsageSectionView: View {
let showBottomDivider: Bool
let bottomPadding: CGFloat
let width: CGFloat
+ let onMiniMaxLayoutChange: (() -> Void)?
+ let miniMaxVisibleScreenHeight: CGFloat?
@Environment(\.menuItemHighlighted) private var isHighlighted
var body: some View {
+ let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false
VStack(alignment: .leading, spacing: 12) {
- if self.model.metrics.isEmpty {
- if !self.model.usageNotes.isEmpty {
- UsageNotesContent(notes: self.model.usageNotes)
- } else if let placeholder = self.model.placeholder {
- Text(placeholder)
- .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
- .font(.subheadline)
+ if hasMiniMaxSections {
+ MiniMaxCappedScrollView(
+ maxHeight: MiniMaxUILayoutMetrics
+ .menuUsageScrollMaxHeight(visibleScreenHeight: self.miniMaxVisibleScreenHeight))
+ {
+ self.usageContent(hasMiniMaxSections: hasMiniMaxSections)
}
} else {
- ForEach(self.model.metrics, id: \.id) { metric in
- MetricRow(
- metric: metric,
- title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric),
- progressColor: self.model.progressColor)
- }
- if !self.model.usageNotes.isEmpty {
- UsageNotesContent(notes: self.model.usageNotes)
- }
+ self.usageContent(hasMiniMaxSections: hasMiniMaxSections)
}
if self.showBottomDivider {
Divider()
@@ -488,6 +532,35 @@ struct UsageMenuCardUsageSectionView: View {
.padding(.bottom, self.bottomPadding)
.frame(width: self.width, alignment: .leading)
}
+
+ @ViewBuilder
+ private func usageContent(hasMiniMaxSections: Bool) -> some View {
+ if self.model.metrics.isEmpty, !hasMiniMaxSections {
+ if !self.model.usageNotes.isEmpty {
+ UsageNotesContent(notes: self.model.usageNotes)
+ } else if let placeholder = self.model.placeholder {
+ Text(placeholder)
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ .font(.subheadline)
+ }
+ } else {
+ ForEach(self.model.metrics, id: \.id) { metric in
+ MetricRow(
+ metric: metric,
+ title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric),
+ progressColor: self.model.progressColor)
+ }
+ if !self.model.usageNotes.isEmpty {
+ UsageNotesContent(notes: self.model.usageNotes)
+ }
+ }
+ if let sections = self.model.minimaxSections, !sections.isEmpty {
+ MiniMaxTokenPlanSectionsView(
+ sections: sections,
+ progressColor: self.model.progressColor,
+ onLayoutChange: self.onMiniMaxLayoutChange)
+ }
+ }
}
struct UsageMenuCardCreditsSectionView: View {
@@ -527,16 +600,13 @@ private struct CreditsBarContent: View {
let hintCopyText: String?
let progressColor: Color
@Environment(\.menuItemHighlighted) private var isHighlighted
-
private var percentLeft: Double? {
guard let creditsRemaining else { return nil }
- let percent = (creditsRemaining / Self.fullScaleTokens) * 100
- return min(100, max(0, percent))
+ return min(100, max(0, (creditsRemaining / Self.fullScaleTokens) * 100))
}
private var scaleText: String {
- let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens))
- return "\(scale) tokens"
+ "\(UsageFormatter.tokenCountString(Int(Self.fullScaleTokens))) tokens"
}
var body: some View {
@@ -753,6 +823,7 @@ extension UsageMenuCardView.Model {
lastError: input.lastError)
let redacted = Self.redactedText(input: input, subtitle: subtitle)
let placeholder = input.snapshot == nil && !input.isRefreshing && input.lastError == nil ? "No usage yet" : nil
+ let minimaxSections = Self.miniMaxSections(input: input)
return UsageMenuCardView.Model(
provider: input.provider,
@@ -762,6 +833,7 @@ extension UsageMenuCardView.Model {
subtitleStyle: subtitle.style,
planText: planText,
metrics: metrics,
+ minimaxSections: minimaxSections,
usageNotes: usageNotes,
creditsText: creditsText,
creditsRemaining: input.credits?.remaining,
@@ -965,18 +1037,6 @@ extension UsageMenuCardView.Model {
percentStyle: percentStyle,
zaiTimeDetail: zaiTimeDetail))
}
- if input.provider == .kilo,
- metrics.contains(where: { $0.id == "primary" }),
- metrics.contains(where: { $0.id == "secondary" })
- {
- metrics.sort { lhs, rhs in
- let kiloOrder: [String: Int] = [
- "secondary": 0,
- "primary": 1,
- ]
- return (kiloOrder[lhs.id] ?? Int.max) < (kiloOrder[rhs.id] ?? Int.max)
- }
- }
if input.metadata.supportsOpus, let opus = snapshot.tertiary {
var tertiaryDetailText: String?
if input.provider == .alibaba,
@@ -1004,6 +1064,39 @@ extension UsageMenuCardView.Model {
pacePercent: nil,
paceOnTop: true))
}
+ if let extraRateWindows = snapshot.extraRateWindows {
+ metrics.append(contentsOf: extraRateWindows.map { namedWindow in
+ Metric(
+ id: namedWindow.id,
+ title: namedWindow.title,
+ percent: Self.clamped(
+ input.usageBarsShowUsed
+ ? namedWindow.window.usedPercent
+ : namedWindow.window.remainingPercent),
+ percentStyle: percentStyle,
+ resetText: Self.resetText(
+ for: namedWindow.window,
+ style: input.resetTimeDisplayStyle,
+ now: input.now),
+ detailText: nil,
+ detailLeftText: nil,
+ detailRightText: nil,
+ pacePercent: nil,
+ paceOnTop: true)
+ })
+ }
+ if input.provider == .kilo,
+ metrics.contains(where: { $0.id == "primary" }),
+ metrics.contains(where: { $0.id == "secondary" })
+ {
+ metrics.sort { lhs, rhs in
+ let kiloOrder: [String: Int] = [
+ "secondary": 0,
+ "primary": 1,
+ ]
+ return (kiloOrder[lhs.id] ?? Int.max) < (kiloOrder[rhs.id] ?? Int.max)
+ }
+ }
if let codexProjection = input.codexProjection,
codexProjection.supplementalMetrics.contains(.codeReview),
@@ -1048,7 +1141,7 @@ extension UsageMenuCardView.Model {
{
primaryDetailText = detail
}
- if input.provider == .alibaba,
+ if input.provider == .alibaba || input.provider == .mistral,
let detail = primary.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
@@ -1085,6 +1178,18 @@ extension UsageMenuCardView.Model {
}
}
}
+ if input.provider == .synthetic,
+ let regen = Self.syntheticRollingRegenDetail(
+ window: primary,
+ now: input.now,
+ showUsed: input.usageBarsShowUsed)
+ {
+ primaryResetText = regen.resetText
+ primaryDetailLeft = regen.pace.leftLabel
+ primaryDetailRight = regen.pace.rightLabel
+ primaryPacePercent = regen.pace.pacePercent
+ primaryPaceOnTop = regen.pace.paceOnTop
+ }
return Metric(
id: "primary",
title: input.metadata.sessionLabel,
@@ -1105,7 +1210,7 @@ extension UsageMenuCardView.Model {
percentStyle: PercentStyle,
zaiTimeDetail: String?) -> Metric
{
- let paceDetail = Self.weeklyPaceDetail(
+ var paceDetail = Self.weeklyPaceDetail(
window: weekly,
now: input.now,
pace: input.weeklyPace,
@@ -1141,6 +1246,16 @@ extension UsageMenuCardView.Model {
{
weeklyResetText = detail
}
+ if input.provider == .synthetic,
+ let regen = Self.syntheticRegenDetail(
+ weekly: weekly,
+ cost: input.snapshot?.providerCost,
+ now: input.now,
+ showUsed: input.usageBarsShowUsed)
+ {
+ weeklyResetText = regen.resetText
+ paceDetail = regen.pace
+ }
return Metric(
id: "secondary",
title: input.metadata.weeklyLabel,
@@ -1314,6 +1429,69 @@ extension UsageMenuCardView.Model {
paceOnTop: paceOnTop)
}
+ private static func syntheticRegenDetail(
+ weekly: RateWindow,
+ cost: ProviderCostSnapshot?,
+ now: Date,
+ showUsed: Bool) -> (resetText: String, pace: PaceDetail)?
+ {
+ guard let cost,
+ cost.limit > 0,
+ let nextRegenAmount = cost.nextRegenAmount,
+ nextRegenAmount > 0,
+ let resetsAt = weekly.resetsAt
+ else { return nil }
+
+ let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now)
+ let resetText = "Regenerates \(countdown)"
+
+ let nextRegenPercent = (nextRegenAmount / cost.limit) * 100
+ let afterNextRegenRemaining = min(100, weekly.remainingPercent + nextRegenPercent)
+ let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining
+ let suffix = showUsed ? "used after next regen" : "after next regen"
+ let ticksToFull = max(0, cost.used) / nextRegenAmount
+ let left = String(format: "%.0f%% %@", afterNextRegen, suffix)
+ let right = if ticksToFull <= 0.1 {
+ "Near full"
+ } else if ticksToFull < 1.5 {
+ "Full in ~1 regen"
+ } else {
+ String(format: "Full in ~%.0f regens", ceil(ticksToFull))
+ }
+ return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true))
+ }
+
+ private static func syntheticRollingRegenDetail(
+ window: RateWindow,
+ now: Date,
+ showUsed: Bool) -> (resetText: String, pace: PaceDetail)?
+ {
+ guard let resetsAt = window.resetsAt,
+ let nextRegenPercent = window.nextRegenPercent,
+ nextRegenPercent > 0
+ else { return nil }
+
+ let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now)
+ let resetText = "Regenerates \(countdown)"
+
+ let afterNextRegenRemaining = min(100, window.remainingPercent + nextRegenPercent)
+ let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining
+ let suffix = showUsed ? "used after next regen" : "after next regen"
+ let left = String(format: "%.0f%% %@", afterNextRegen, suffix)
+
+ let missingPercent = max(0, window.usedPercent)
+ let ticksToFull = missingPercent / nextRegenPercent
+ let right = if ticksToFull <= 0.1 {
+ "Near full"
+ } else if ticksToFull < 1.5 {
+ "Full in ~1 regen"
+ } else {
+ String(format: "Full in ~%.0f regens", ceil(ticksToFull))
+ }
+
+ return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true))
+ }
+
private static func creditsLine(
metadata: ProviderMetadata,
credits: CreditsSnapshot?,
@@ -1378,6 +1556,7 @@ extension UsageMenuCardView.Model {
{
guard let cost else { return nil }
guard cost.limit > 0 else { return nil }
+ guard provider != .synthetic else { return nil }
let used: String
let limit: String
@@ -1402,7 +1581,7 @@ extension UsageMenuCardView.Model {
spendLine: "\(periodLabel): \(used) / \(limit)")
}
- private static func clamped(_ value: Double) -> Double {
+ static func clamped(_ value: Double) -> Double {
min(100, max(0, value))
}
@@ -1419,43 +1598,3 @@ extension UsageMenuCardView.Model {
UsageFormatter.resetLine(for: window, style: style, now: now)
}
}
-
-// MARK: - Copy-on-click overlay
-
-private struct ClickToCopyOverlay: NSViewRepresentable {
- let copyText: String
-
- func makeNSView(context: Context) -> ClickToCopyView {
- ClickToCopyView(copyText: self.copyText)
- }
-
- func updateNSView(_ nsView: ClickToCopyView, context: Context) {
- nsView.copyText = self.copyText
- }
-}
-
-private final class ClickToCopyView: NSView {
- var copyText: String
-
- init(copyText: String) {
- self.copyText = copyText
- super.init(frame: .zero)
- self.wantsLayer = false
- }
-
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
- true
- }
-
- override func mouseDown(with event: NSEvent) {
- _ = event
- let pb = NSPasteboard.general
- pb.clearContents()
- pb.setString(self.copyText, forType: .string)
- }
-}
diff --git a/Sources/CodexBar/MiniMaxMenuCardViews.swift b/Sources/CodexBar/MiniMaxMenuCardViews.swift
new file mode 100644
index 000000000..e9c3a9375
--- /dev/null
+++ b/Sources/CodexBar/MiniMaxMenuCardViews.swift
@@ -0,0 +1,133 @@
+import CodexBarCore
+import SwiftUI
+
+struct MiniMaxCappedScrollView: View {
+ let maxHeight: CGFloat
+ @ViewBuilder let content: () -> Content
+
+ var body: some View {
+ ScrollView {
+ self.content()
+ }
+ .frame(maxHeight: self.maxHeight, alignment: .top)
+ }
+}
+
+struct MiniMaxTokenPlanSectionsView: View {
+ let sections: [UsageMenuCardView.Model.MiniMaxSection]
+ let progressColor: Color
+ let onLayoutChange: (() -> Void)?
+ @Environment(\.menuItemHighlighted) private var isHighlighted
+ @Bindable private var collapseStore = MiniMaxSectionCollapseStore.shared
+
+ init(
+ sections: [UsageMenuCardView.Model.MiniMaxSection],
+ progressColor: Color,
+ onLayoutChange: (() -> Void)? = nil)
+ {
+ self.sections = sections
+ self.progressColor = progressColor
+ self.onLayoutChange = onLayoutChange
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ ForEach(self.sections, id: \.title) { section in
+ let rowCount = section.rows.count
+ let collapsed = self.collapseStore.isCollapsed(sectionTitle: section.title, rowCount: rowCount)
+ VStack(alignment: .leading, spacing: 8) {
+ Button {
+ self.collapseStore.toggle(sectionTitle: section.title, rowCount: rowCount)
+ if let onLayoutChange = self.onLayoutChange {
+ Task { @MainActor in
+ await Task.yield()
+ onLayoutChange()
+ }
+ }
+ } label: {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(section.title)
+ .font(.subheadline)
+ .fontWeight(.semibold)
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ Spacer(minLength: 8)
+ if collapsed {
+ Text("\(rowCount) items")
+ .font(.caption2)
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ .monospacedDigit()
+ }
+ Image(systemName: collapsed ? "chevron.right" : "chevron.down")
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(collapsed ? "展开 \(section.title)" : "折叠 \(section.title)")
+
+ if !collapsed {
+ ForEach(section.rows) { row in
+ MiniMaxTokenPlanRowView(row: row, progressColor: self.progressColor)
+ }
+ }
+ }
+ }
+ }
+ .padding(.top, 4)
+ }
+}
+
+struct MiniMaxTokenPlanRowView: View {
+ let row: UsageMenuCardView.Model.MiniMaxRow
+ let progressColor: Color
+ @Environment(\.menuItemHighlighted) private var isHighlighted
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(self.row.title)
+ .font(.footnote)
+ .fontWeight(.medium)
+ if let statusText = self.row.detailText, statusText.isEmpty == false {
+ Text(statusText)
+ .font(.caption2)
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ }
+ if let percent = self.row.percent {
+ UsageProgressBar(
+ percent: percent,
+ tint: self.progressColor,
+ accessibilityLabel: self.row.percentStyle.accessibilityLabel)
+ HStack(alignment: .firstTextBaseline) {
+ Text(String(format: "%.0f%% %@", percent, self.row.percentStyle.labelSuffix))
+ .font(.caption2)
+ Spacer()
+ if let reset = self.row.resetText {
+ Text(reset)
+ .font(.caption2)
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ .lineLimit(2)
+ }
+ }
+ } else if let reset = self.row.resetText {
+ HStack(alignment: .firstTextBaseline) {
+ Text("Usage unavailable")
+ .font(.caption2)
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ Spacer()
+ Text(reset)
+ .font(.caption2)
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ .lineLimit(2)
+ }
+ }
+ if let secondary = self.row.secondaryLine, !secondary.isEmpty {
+ Text(secondary)
+ .font(.caption2)
+ .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
+ .lineLimit(3)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+}
diff --git a/Sources/CodexBar/MiniMaxSectionCollapseStore.swift b/Sources/CodexBar/MiniMaxSectionCollapseStore.swift
new file mode 100644
index 000000000..6126ccad6
--- /dev/null
+++ b/Sources/CodexBar/MiniMaxSectionCollapseStore.swift
@@ -0,0 +1,33 @@
+import Foundation
+import Observation
+
+/// 进程内保存 MiniMax 菜单卡各分组折叠状态;`sectionTitle` 为 key,未覆盖时使用「行数 ≥ 5 默认折叠」。
+@MainActor
+@Observable
+final class MiniMaxSectionCollapseStore {
+ static let shared = MiniMaxSectionCollapseStore()
+
+ private var overrides: [String: Bool] = [:]
+
+ private init() {}
+
+ /// - Parameters:
+ /// - sectionTitle: 分组标题(与 `MiniMaxSection.title` 一致)。
+ /// - rowCount: 该分组内行数;≥ `MiniMaxUILayoutMetrics.collapseThreshold` 时默认折叠。
+ func isCollapsed(sectionTitle: String, rowCount: Int) -> Bool {
+ if let stored = self.overrides[sectionTitle] {
+ return stored
+ }
+ return rowCount >= MiniMaxUILayoutMetrics.collapseThreshold
+ }
+
+ func toggle(sectionTitle: String, rowCount: Int) {
+ let current = self.isCollapsed(sectionTitle: sectionTitle, rowCount: rowCount)
+ self.overrides[sectionTitle] = !current
+ }
+
+ /// 单测重置覆盖,避免用例互相污染。
+ func resetOverridesForTesting() {
+ self.overrides.removeAll()
+ }
+}
diff --git a/Sources/CodexBar/MiniMaxUILayoutMetrics.swift b/Sources/CodexBar/MiniMaxUILayoutMetrics.swift
new file mode 100644
index 000000000..66536c0c9
--- /dev/null
+++ b/Sources/CodexBar/MiniMaxUILayoutMetrics.swift
@@ -0,0 +1,24 @@
+import AppKit
+
+enum MiniMaxUILayoutMetrics {
+ static let collapseThreshold = 5
+ static let settingsEmbeddedScrollThreshold = 6
+ static let settingsEmbeddedScrollMaxHeight: CGFloat = 360
+ static let settingsTitleWidthReference = "code-plan-search"
+ static let menuScrollFallbackHeight: CGFloat = 560
+
+ static func menuUsageScrollMaxHeight(visibleScreenHeight: CGFloat?) -> CGFloat {
+ guard let height = visibleScreenHeight else {
+ return self.menuScrollFallbackHeight
+ }
+ return min(640, max(320, height - 310))
+ }
+
+ static func preferredMenuUsageHeight(contentHeight: CGFloat, visibleScreenHeight: CGFloat?) -> CGFloat {
+ min(max(1, ceil(contentHeight)), self.menuUsageScrollMaxHeight(visibleScreenHeight: visibleScreenHeight))
+ }
+
+ static func settingsTitleWidthCap(font: NSFont = ProviderSettingsMetrics.metricLabelFont()) -> CGFloat {
+ ProviderSettingsMetrics.labelWidth(for: [self.settingsTitleWidthReference], font: font)
+ }
+}
diff --git a/Sources/CodexBar/Notifications+CodexBar.swift b/Sources/CodexBar/Notifications+CodexBar.swift
index 8c0456276..354c63bf1 100644
--- a/Sources/CodexBar/Notifications+CodexBar.swift
+++ b/Sources/CodexBar/Notifications+CodexBar.swift
@@ -1,7 +1,24 @@
+import CodexBarCore
import Foundation
extension Notification.Name {
static let codexbarOpenSettings = Notification.Name("codexbarOpenSettings")
static let codexbarDebugBlinkNow = Notification.Name("codexbarDebugBlinkNow")
+ static let codexbarWeeklyLimitReset = Notification.Name("codexbarWeeklyLimitReset")
static let codexbarProviderConfigDidChange = Notification.Name("codexbarProviderConfigDidChange")
}
+
+@MainActor
+final class WeeklyLimitResetEvent: NSObject {
+ let provider: UsageProvider
+ let accountIdentifier: String
+ let accountLabel: String?
+ let usedPercent: Double
+
+ init(provider: UsageProvider, accountIdentifier: String, accountLabel: String?, usedPercent: Double) {
+ self.provider = provider
+ self.accountIdentifier = accountIdentifier
+ self.accountLabel = accountLabel
+ self.usedPercent = usedPercent
+ }
+}
diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift
index c82b3740c..7112b50ce 100644
--- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift
+++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift
@@ -185,6 +185,7 @@ struct PlanUtilizationHistoryChartMenuView: View {
return histories
.filter { history in
guard !history.entries.isEmpty else { return false }
+ guard history.windowMinutes > 0 else { return false }
guard let allowedNames else { return true }
return allowedNames.contains(history.name)
}
diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift
index 7fd3e5e89..9bb5f6bc7 100644
--- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift
+++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift
@@ -129,7 +129,9 @@ struct PlanUtilizationHistoryStore {
for provider in UsageProvider.allCases {
let fileURL = self.providerFileURL(for: provider)
let buckets = providers[provider] ?? PlanUtilizationHistoryBuckets()
- guard !buckets.isEmpty else {
+ let unscoped = Self.sortedHistories(buckets.unscoped)
+ let accounts = Self.sortedAccounts(buckets.accounts)
+ guard !unscoped.isEmpty || !accounts.isEmpty else {
try? FileManager.default.removeItem(at: fileURL)
continue
}
@@ -137,8 +139,8 @@ struct PlanUtilizationHistoryStore {
let payload = ProviderHistoryDocument(
version: Self.providerSchemaVersion,
preferredAccountKey: buckets.preferredAccountKey,
- unscoped: Self.sortedHistories(buckets.unscoped),
- accounts: Self.sortedAccounts(buckets.accounts))
+ unscoped: unscoped,
+ accounts: accounts)
let data = try encoder.encode(payload)
try data.write(to: fileURL, options: Data.WritingOptions.atomic)
}
@@ -209,7 +211,7 @@ struct PlanUtilizationHistoryStore {
}
private static func sortedHistories(_ histories: [PlanUtilizationSeriesHistory]) -> [PlanUtilizationSeriesHistory] {
- histories.sorted { lhs, rhs in
+ self.sanitizedHistories(histories).sorted { lhs, rhs in
if lhs.windowMinutes != rhs.windowMinutes {
return lhs.windowMinutes < rhs.windowMinutes
}
@@ -217,6 +219,13 @@ struct PlanUtilizationHistoryStore {
}
}
+ private static func sanitizedHistories(_ histories: [PlanUtilizationSeriesHistory])
+ -> [PlanUtilizationSeriesHistory] {
+ histories.filter { history in
+ history.windowMinutes > 0 && !history.entries.isEmpty
+ }
+ }
+
private static func defaultDirectoryURL() -> URL? {
guard let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift
index 1db4897f2..9b901c9d8 100644
--- a/Sources/CodexBar/PreferencesAdvancedPane.swift
+++ b/Sources/CodexBar/PreferencesAdvancedPane.swift
@@ -64,6 +64,10 @@ struct AdvancedPane: View {
title: "Surprise me",
subtitle: "Check if you like your agents having some fun up there.",
binding: self.$settings.randomBlinkEnabled)
+ PreferenceToggleRow(
+ title: "Weekly limit confetti",
+ subtitle: "Play full-screen confetti when weekly usage resets.",
+ binding: self.$settings.confettiOnWeeklyLimitResetsEnabled)
}
Divider()
diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift
index 5ecff079d..65610ccac 100644
--- a/Sources/CodexBar/PreferencesProviderDetailView.swift
+++ b/Sources/CodexBar/PreferencesProviderDetailView.swift
@@ -349,13 +349,14 @@ struct ProviderMetricsInlineView: View {
let hasCredits = self.model.creditsText != nil
let hasProviderCost = self.model.providerCost != nil
let hasTokenUsage = self.model.tokenUsage != nil
+ let hasMiniMaxSections = self.model.minimaxSections?.isEmpty == false
ProviderSettingsSection(
title: "Usage",
spacing: 8,
verticalPadding: 6,
horizontalPadding: 0)
{
- if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage {
+ if !hasMetrics, !hasUsageNotes, !hasProviderCost, !hasCredits, !hasTokenUsage, !hasMiniMaxSections {
Text(self.placeholderText)
.font(.footnote)
.foregroundStyle(.secondary)
@@ -375,6 +376,13 @@ struct ProviderMetricsInlineView: View {
alignsWithMetricContent: hasMetrics)
}
+ if let sections = self.model.minimaxSections, !sections.isEmpty {
+ ProviderMiniMaxSectionsInlineView(
+ sections: sections,
+ progressColor: self.model.progressColor,
+ labelWidth: self.labelWidth)
+ }
+
if let credits = self.model.creditsText {
ProviderMetricInlineTextRow(
title: "Credits",
@@ -563,3 +571,119 @@ private struct ProviderMetricInlineCostRow: View {
.padding(.vertical, 2)
}
}
+
+// MARK: - MiniMax `model_remains[]`(设置页镜像菜单卡分组,无折叠)
+
+private struct ProviderMiniMaxSectionsInlineView: View {
+ let sections: [UsageMenuCardView.Model.MiniMaxSection]
+ let progressColor: Color
+ let labelWidth: CGFloat
+
+ private var totalRowCount: Int {
+ self.sections.reduce(0) { $0 + $1.rows.count }
+ }
+
+ var body: some View {
+ Group {
+ if self.totalRowCount >= MiniMaxUILayoutMetrics.settingsEmbeddedScrollThreshold {
+ ScrollView {
+ self.sectionStack
+ }
+ .frame(maxHeight: MiniMaxUILayoutMetrics.settingsEmbeddedScrollMaxHeight)
+ .background {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(Color.primary.opacity(0.06))
+ }
+ } else {
+ self.sectionStack
+ }
+ }
+ }
+
+ private var sectionStack: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ ForEach(self.sections, id: \.title) { section in
+ VStack(alignment: .leading, spacing: 8) {
+ Text(section.title)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+ ForEach(section.rows) { row in
+ ProviderMiniMaxRowInlineView(
+ row: row,
+ progressColor: self.progressColor,
+ labelWidth: self.labelWidth)
+ }
+ }
+ }
+ }
+ .padding(.top, 4)
+ }
+}
+
+private struct ProviderMiniMaxRowInlineView: View {
+ let row: UsageMenuCardView.Model.MiniMaxRow
+ let progressColor: Color
+ let labelWidth: CGFloat
+
+ private static var titleWidthCap: CGFloat {
+ MiniMaxUILayoutMetrics.settingsTitleWidthCap()
+ }
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 10) {
+ Text(self.row.title)
+ .font(.subheadline.weight(.semibold))
+ .multilineTextAlignment(.leading)
+ .fixedSize(horizontal: false, vertical: true)
+ .help(self.row.title)
+ .frame(width: Self.titleWidthCap, alignment: .leading)
+
+ VStack(alignment: .leading, spacing: 4) {
+ if let detail = self.row.detailText, !detail.isEmpty {
+ Text(detail)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ if let percent = self.row.percent {
+ UsageProgressBar(
+ percent: percent,
+ tint: self.progressColor,
+ accessibilityLabel: self.row.percentStyle.accessibilityLabel)
+ .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity)
+
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(String(format: "%.0f%% %@", percent, self.row.percentStyle.labelSuffix))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ Spacer(minLength: 8)
+ if let resetText = self.row.resetText, !resetText.isEmpty {
+ Text(resetText)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ } else if let resetText = self.row.resetText, !resetText.isEmpty {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text("Usage unavailable")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ Spacer(minLength: 8)
+ Text(resetText)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ if let secondary = self.row.secondaryLine, !secondary.isEmpty {
+ Text(secondary)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift
index 414f41c55..514c7e3ce 100644
--- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift
+++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift
@@ -196,6 +196,13 @@ struct ProviderSettingsFieldRowView: View {
}
}
}
+
+ if let footer = self.field.footerText, !footer.isEmpty {
+ Text(footer)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
}
}
}
diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift
index 32a1b5b47..559c5329e 100644
--- a/Sources/CodexBar/PreferencesProviderSidebarView.swift
+++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift
@@ -13,26 +13,36 @@ struct ProviderSidebarListView: View {
@State private var draggingProvider: UsageProvider?
var body: some View {
- List(selection: self.$selection) {
- ForEach(self.providers, id: \.self) { provider in
- ProviderSidebarRowView(
- provider: provider,
- store: self.store,
- isEnabled: self.isEnabled(provider),
- subtitle: self.subtitle(provider),
- draggingProvider: self.$draggingProvider)
- .tag(provider)
- .onDrop(
- of: [UTType.plainText],
- delegate: ProviderSidebarDropDelegate(
- item: provider,
- providers: self.providers,
- dragging: self.$draggingProvider,
- moveProviders: self.moveProviders))
+ ScrollView {
+ VStack(spacing: 0) {
+ ForEach(self.providers, id: \.self) { provider in
+ ProviderSidebarRowView(
+ provider: provider,
+ store: self.store,
+ isEnabled: self.isEnabled(provider),
+ subtitle: self.subtitle(provider),
+ draggingProvider: self.$draggingProvider)
+ .padding(.horizontal, 8)
+ .background(
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(
+ self.selection == provider
+ ? Color(nsColor: .selectedContentBackgroundColor)
+ : Color.clear)
+ .padding(.horizontal, 4))
+ .contentShape(Rectangle())
+ .onTapGesture { self.selection = provider }
+ .onDrop(
+ of: [UTType.plainText],
+ delegate: ProviderSidebarDropDelegate(
+ item: provider,
+ providers: self.providers,
+ dragging: self.$draggingProvider,
+ moveProviders: self.moveProviders))
+ }
}
+ .padding(.vertical, 4)
}
- .listStyle(.sidebar)
- .scrollContentBackground(.hidden)
.background(
RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor).opacity(0.8)))
diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift
index 7e39ee0e2..a5738086c 100644
--- a/Sources/CodexBar/PreferencesProvidersPane.swift
+++ b/Sources/CodexBar/PreferencesProvidersPane.swift
@@ -465,6 +465,7 @@ struct ProvidersPane: View {
let snapshot = self.store.snapshot(for: provider)
let supportsAverage = self.settings.menuBarMetricSupportsAverage(for: provider)
let supportsTertiary = self.settings.menuBarMetricSupportsTertiary(for: provider, snapshot: snapshot)
+ let supportsExtraUsage = self.settings.menuBarMetricSupportsExtraUsage(for: provider, snapshot: snapshot)
var metricOptions: [ProviderSettingsPickerOption] = [
ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"),
ProviderSettingsPickerOption(
@@ -480,6 +481,11 @@ struct ProvidersPane: View {
id: MenuBarMetricPreference.tertiary.rawValue,
title: "Tertiary (\(tertiaryTitle))"))
}
+ if supportsExtraUsage {
+ metricOptions.append(ProviderSettingsPickerOption(
+ id: MenuBarMetricPreference.extraUsage.rawValue,
+ title: MenuBarMetricPreference.extraUsage.label))
+ }
if supportsAverage {
metricOptions.append(ProviderSettingsPickerOption(
id: MenuBarMetricPreference.average.rawValue,
diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift
index 14a541695..408a83d6d 100644
--- a/Sources/CodexBar/PreferencesView.swift
+++ b/Sources/CodexBar/PreferencesView.swift
@@ -1,7 +1,7 @@
import AppKit
import SwiftUI
-enum PreferencesTab: String, Hashable {
+enum PreferencesTab: String, CaseIterable, Hashable {
case general
case providers
case display
@@ -13,6 +13,17 @@ enum PreferencesTab: String, Hashable {
static let providersWidth: CGFloat = 720
static let windowHeight: CGFloat = 580
+ var title: String {
+ switch self {
+ case .general: "General"
+ case .providers: "Providers"
+ case .display: "Display"
+ case .advanced: "Advanced"
+ case .about: "About"
+ case .debug: "Debug"
+ }
+ }
+
var preferredWidth: CGFloat {
self == .providers ? PreferencesTab.providersWidth : PreferencesTab.defaultWidth
}
@@ -110,6 +121,24 @@ struct PreferencesView: View {
} else {
change()
}
+ Self.resizeSettingsWindow(width: tab.preferredWidth, height: tab.preferredHeight, animate: animate)
+ }
+
+ private static let settingsWindowIdentifier = "com_apple_SwiftUI_Settings_window"
+ private static let knownTabTitles = Set(PreferencesTab.allCases.map(\.title))
+
+ private static func resizeSettingsWindow(width: CGFloat, height: CGFloat, animate: Bool) {
+ guard let window = NSApp.windows.first(where: {
+ $0.identifier?.rawValue == settingsWindowIdentifier
+ || knownTabTitles.contains($0.title)
+ }) else { return }
+ let toolbarHeight = window.frame.height - window.contentLayoutRect.height
+ guard toolbarHeight > 0 else { return }
+ let newSize = NSSize(width: width, height: height + toolbarHeight)
+ var frame = window.frame
+ frame.origin.y += frame.size.height - newSize.height
+ frame.size = newSize
+ window.setFrame(frame, display: true, animate: animate)
}
private func ensureValidTabSelection() {
diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift
index 9f0613c33..764e418c4 100644
--- a/Sources/CodexBar/ProviderRegistry.swift
+++ b/Sources/CodexBar/ProviderRegistry.swift
@@ -23,7 +23,8 @@ struct ProviderRegistry {
metadata: [UsageProvider: ProviderMetadata],
codexFetcher: UsageFetcher,
claudeFetcher: any ClaudeUsageFetching,
- browserDetection: BrowserDetection) -> [UsageProvider: ProviderSpec]
+ browserDetection: BrowserDetection,
+ environmentBase: [String: String] = ProcessInfo.processInfo.environment) -> [UsageProvider: ProviderSpec]
{
var specs: [UsageProvider: ProviderSpec] = [:]
specs.reserveCapacity(UsageProvider.allCases.count)
@@ -41,7 +42,7 @@ struct ProviderRegistry {
?? .auto
let snapshot = Self.makeSettingsSnapshot(settings: settings, tokenOverride: nil)
let env = Self.makeEnvironment(
- base: ProcessInfo.processInfo.environment,
+ base: environmentBase,
provider: provider,
settings: settings,
tokenOverride: nil)
diff --git a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift
index 55275ae61..1170f2236 100644
--- a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift
+++ b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift
@@ -30,7 +30,7 @@ struct CopilotLoginFlow {
return // Cancelled
}
- if let url = URL(string: code.verificationUri) {
+ if let url = URL(string: code.verificationURLToOpen) {
NSWorkspace.shared.open(url)
}
diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift
index 986d81f2f..6d400417c 100644
--- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift
+++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift
@@ -31,6 +31,7 @@ struct CopilotProviderImplementation: ProviderImplementation {
id: "copilot-api-token",
title: "GitHub Login",
subtitle: "Requires authentication via GitHub Device Flow.",
+ footerText: "The device code is copied to your clipboard. Paste it into the GitHub page with ⌘V.",
kind: .secure,
placeholder: "Sign in via button below",
binding: context.stringBinding(\.copilotAPIToken),
diff --git a/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift
new file mode 100644
index 000000000..1949d2ed0
--- /dev/null
+++ b/Sources/CodexBar/Providers/Mistral/MistralProviderImplementation.swift
@@ -0,0 +1,106 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+import SwiftUI
+
+@ProviderImplementationRegistration
+struct MistralProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .mistral
+
+ @MainActor
+ func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
+ ProviderPresentation { _ in "web" }
+ }
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.mistralCookieSource
+ _ = settings.mistralCookieHeader
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .mistral(context.settings.mistralSettingsSnapshot(tokenOverride: context.tokenOverride))
+ }
+
+ @MainActor
+ func tokenAccountsVisibility(context: ProviderSettingsContext, support: TokenAccountSupport) -> Bool {
+ guard support.requiresManualCookieSource else { return true }
+ if !context.settings.tokenAccounts(for: context.provider).isEmpty { return true }
+ return context.settings.mistralCookieSource == .manual
+ }
+
+ @MainActor
+ func applyTokenAccountCookieSource(settings: SettingsStore) {
+ if settings.mistralCookieSource != .manual {
+ settings.mistralCookieSource = .manual
+ }
+ }
+
+ @MainActor
+ func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
+ let cookieBinding = Binding(
+ get: { context.settings.mistralCookieSource.rawValue },
+ set: { raw in
+ context.settings.mistralCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
+ })
+ let cookieOptions = ProviderCookieSourceUI.options(
+ allowsOff: false,
+ keychainDisabled: context.settings.debugDisableKeychainAccess)
+
+ let cookieSubtitle: () -> String? = {
+ ProviderCookieSourceUI.subtitle(
+ source: context.settings.mistralCookieSource,
+ keychainDisabled: context.settings.debugDisableKeychainAccess,
+ auto: "Automatic imports browser cookies from admin.mistral.ai.",
+ manual: "Paste a Cookie header captured from the billing page.",
+ off: "Mistral cookies are disabled.")
+ }
+
+ return [
+ ProviderSettingsPickerDescriptor(
+ id: "mistral-cookie-source",
+ title: "Cookie source",
+ subtitle: "Automatic imports browser cookies from admin.mistral.ai.",
+ dynamicSubtitle: cookieSubtitle,
+ binding: cookieBinding,
+ options: cookieOptions,
+ isVisible: nil,
+ onChange: nil,
+ trailingText: {
+ guard let entry = CookieHeaderCache.load(provider: .mistral) else { return nil }
+ let when = entry.storedAt.relativeDescription()
+ return "Cached: \(entry.sourceLabel) • \(when)"
+ }),
+ ]
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "mistral-cookie-header",
+ title: "Cookie header",
+ subtitle: "Paste the Cookie header from a request to admin.mistral.ai. "
+ + "Must contain an ory_session_* cookie.",
+ kind: .secure,
+ placeholder: "ory_session_…=…; csrftoken=…",
+ binding: context.stringBinding(\.mistralCookieHeader),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "mistral-open-console",
+ title: "Open Mistral Admin",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://admin.mistral.ai/organization/usage") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: { context.settings.mistralCookieSource == .manual },
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift b/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift
new file mode 100644
index 000000000..e99485517
--- /dev/null
+++ b/Sources/CodexBar/Providers/Mistral/MistralSettingsStore.swift
@@ -0,0 +1,64 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var mistralCookieHeader: String {
+ get { self.configSnapshot.providerConfig(for: .mistral)?.sanitizedCookieHeader ?? "" }
+ set {
+ self.updateProviderConfig(provider: .mistral) { entry in
+ entry.cookieHeader = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .mistral, field: "cookieHeader", value: newValue)
+ }
+ }
+
+ var mistralCookieSource: ProviderCookieSource {
+ get { self.resolvedCookieSource(provider: .mistral, fallback: .auto) }
+ set {
+ self.updateProviderConfig(provider: .mistral) { entry in
+ entry.cookieSource = newValue
+ }
+ self.logProviderModeChange(provider: .mistral, field: "cookieSource", value: newValue.rawValue)
+ }
+ }
+
+ func ensureMistralCookieLoaded() {}
+}
+
+extension SettingsStore {
+ func mistralSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot
+ .MistralProviderSettings
+ {
+ ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: self.mistralSnapshotCookieSource(tokenOverride: tokenOverride),
+ manualCookieHeader: self.mistralSnapshotCookieHeader(tokenOverride: tokenOverride))
+ }
+
+ private func mistralSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String {
+ let fallback = self.mistralCookieHeader
+ guard let support = TokenAccountSupportCatalog.support(for: .mistral),
+ case .cookieHeader = support.injection
+ else {
+ return fallback
+ }
+ guard let account = ProviderTokenAccountSelection.selectedAccount(
+ provider: .mistral,
+ settings: self,
+ override: tokenOverride)
+ else {
+ return fallback
+ }
+ return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support)
+ }
+
+ private func mistralSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource {
+ let fallback = self.mistralCookieSource
+ guard let support = TokenAccountSupportCatalog.support(for: .mistral),
+ support.requiresManualCookieSource
+ else {
+ return fallback
+ }
+ if self.tokenAccounts(for: .mistral).isEmpty { return fallback }
+ return .manual
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 6dd8c45c4..1b4950ec3 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -39,6 +39,7 @@ enum ProviderImplementationRegistry {
case .warp: WarpProviderImplementation()
case .perplexity: PerplexityProviderImplementation()
case .abacus: AbacusProviderImplementation()
+ case .mistral: MistralProviderImplementation()
}
}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift
index d5a85b8f7..7b408d8da 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift
@@ -76,6 +76,7 @@ struct ProviderSettingsFieldDescriptor: Identifiable {
let id: String
let title: String
let subtitle: String
+ var footerText: String?
let kind: Kind
let placeholder: String?
let binding: Binding
diff --git a/Sources/CodexBar/Resources/ProviderIcon-mistral.svg b/Sources/CodexBar/Resources/ProviderIcon-mistral.svg
new file mode 100644
index 000000000..c946b5225
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-mistral.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/CodexBar/ScreenConfettiOverlayController.swift b/Sources/CodexBar/ScreenConfettiOverlayController.swift
new file mode 100644
index 000000000..acacceb5e
--- /dev/null
+++ b/Sources/CodexBar/ScreenConfettiOverlayController.swift
@@ -0,0 +1,290 @@
+import AppKit
+import CodexBarCore
+import SwiftUI
+import Vortex
+
+@MainActor
+final class ScreenConfettiOverlayController {
+ private static let overlayLifetime: TimeInterval = 5
+
+ private let logger = CodexBarLog.logger(LogCategories.confetti)
+ private var windows: [NSWindow] = []
+ private var dismissalTask: Task?
+
+ func play(originInScreen origin: CGPoint?) {
+ guard self.windows.isEmpty else {
+ self.logger.debug("Ignoring confetti trigger while overlay is already active")
+ return
+ }
+
+ let screens = NSScreen.screens
+ guard !screens.isEmpty else {
+ self.logger.error("Cannot present confetti overlay because no screens were found")
+ return
+ }
+
+ let palette = Self.randomPalette()
+ self.windows = screens.map { screen in
+ let frame = screen.frame
+ let localOrigin = Self.localOrigin(in: frame, from: origin)
+ let contentView = ScreenConfettiOverlayView(origin: localOrigin, colors: palette)
+ .allowsHitTesting(false)
+ let hostingView = NSHostingView(rootView: contentView)
+ hostingView.wantsLayer = true
+ hostingView.layer?.backgroundColor = NSColor.clear.cgColor
+
+ let window = ClickThroughOverlayPanel(
+ contentRect: frame,
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false,
+ screen: screen)
+ window.contentView = hostingView
+ window.level = .statusBar
+ window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle, .stationary]
+ window.backgroundColor = .clear
+ window.isOpaque = false
+ window.hasShadow = false
+ window.ignoresMouseEvents = true
+ window.acceptsMouseMovedEvents = false
+ window.isMovable = false
+ window.isReleasedWhenClosed = false
+ window.canHide = false
+ window.hidesOnDeactivate = false
+ window.becomesKeyOnlyIfNeeded = false
+ window.isExcludedFromWindowsMenu = true
+ window.setFrame(frame, display: false)
+ return window
+ }
+
+ self.logger.info(
+ "Presenting confetti overlay",
+ metadata: [
+ "screenCount": "\(self.windows.count)",
+ "originKnown": origin == nil ? "0" : "1",
+ ])
+
+ for window in self.windows {
+ window.orderFrontRegardless()
+ }
+
+ self.dismissalTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(for: .seconds(Self.overlayLifetime))
+ self?.dismiss()
+ }
+ }
+
+ func dismiss() {
+ self.dismissalTask?.cancel()
+ self.dismissalTask = nil
+
+ guard !self.windows.isEmpty else { return }
+ for window in self.windows {
+ window.orderOut(nil)
+ window.close()
+ }
+ self.windows.removeAll(keepingCapacity: true)
+ }
+
+ private static func localOrigin(in screenFrame: CGRect, from globalOrigin: CGPoint?) -> CGPoint {
+ let fallback = CGPoint(x: screenFrame.maxX - 28, y: screenFrame.maxY - 8)
+ let resolved: CGPoint = if let globalOrigin, screenFrame.contains(globalOrigin) {
+ globalOrigin
+ } else {
+ fallback
+ }
+
+ let insetFrame = screenFrame.insetBy(dx: 8, dy: 8)
+ return CGPoint(
+ x: min(max(resolved.x, insetFrame.minX), insetFrame.maxX) - screenFrame.minX,
+ y: min(max(resolved.y, insetFrame.minY), insetFrame.maxY) - screenFrame.minY)
+ }
+
+ private static func randomPalette() -> [Color] {
+ let hue = Double.random(in: 0...1)
+ let hueOffsets = [0.0, 0.08, 0.16, 0.5, 0.66, 0.83]
+ return hueOffsets.map { offset in
+ Color(
+ hue: (hue + offset).truncatingRemainder(dividingBy: 1),
+ saturation: Double.random(in: 0.55...0.95),
+ brightness: Double.random(in: 0.85...1))
+ }
+ }
+}
+
+private final class ClickThroughOverlayPanel: NSPanel {
+ override var canBecomeKey: Bool {
+ false
+ }
+
+ override var canBecomeMain: Bool {
+ false
+ }
+
+ override var acceptsFirstResponder: Bool {
+ false
+ }
+}
+
+private struct ScreenConfettiOverlayView: View {
+ private static let clockwiseRotationAngles: [Double] = [270, 234, 198, 162, 126, 90]
+ private static let counterclockwiseRotationAngles: [Double] = [90, 126, 162, 198, 234, 270]
+
+ let origin: CGPoint
+ let colors: [Color]
+
+ @Environment(\.self) private var environment
+ @State private var visiblePhaseCount = 0
+
+ var body: some View {
+ GeometryReader { proxy in
+ let clockwiseAngles = Array(Self.clockwiseRotationAngles.prefix(self.visiblePhaseCount).enumerated())
+ let counterclockwiseAngles = Array(
+ Self.counterclockwiseRotationAngles.prefix(self.visiblePhaseCount).enumerated())
+ ZStack {
+ ForEach(clockwiseAngles, id: \.offset) { index, angle in
+ VortexView(self.makeFireworkConfettiSystem(
+ in: proxy.size,
+ launchAngle: angle,
+ phaseIndex: index,
+ lateralOffset: -12))
+ {
+ RoundedRectangle(cornerRadius: 2, style: .continuous)
+ .fill(.white)
+ .frame(width: 10, height: 20)
+ .tag("confetti-bar")
+
+ Circle()
+ .fill(.white)
+ .frame(width: 9, height: 9)
+ .tag("confetti-dot")
+
+ Capsule(style: .continuous)
+ .fill(.white)
+ .frame(width: 8, height: 16)
+ .rotationEffect(.degrees(30))
+ .tag("confetti-pill")
+
+ Circle()
+ .fill(.white)
+ .frame(width: 6, height: 6)
+ .blur(radius: 1)
+ .tag("confetti-tracer")
+ }
+ }
+
+ ForEach(counterclockwiseAngles, id: \.offset) { index, angle in
+ VortexView(self.makeFireworkConfettiSystem(
+ in: proxy.size,
+ launchAngle: angle,
+ phaseIndex: index,
+ lateralOffset: 12))
+ {
+ RoundedRectangle(cornerRadius: 2, style: .continuous)
+ .fill(.white)
+ .frame(width: 10, height: 20)
+ .tag("confetti-bar")
+
+ Circle()
+ .fill(.white)
+ .frame(width: 9, height: 9)
+ .tag("confetti-dot")
+
+ Capsule(style: .continuous)
+ .fill(.white)
+ .frame(width: 8, height: 16)
+ .rotationEffect(.degrees(30))
+ .tag("confetti-pill")
+
+ Circle()
+ .fill(.white)
+ .frame(width: 6, height: 6)
+ .blur(radius: 1)
+ .tag("confetti-tracer")
+ }
+ }
+ }
+ .ignoresSafeArea()
+ .allowsHitTesting(false)
+ .task {
+ self.visiblePhaseCount = 1
+ for phaseCount in 2...Self.clockwiseRotationAngles.count {
+ try? await Task.sleep(for: .milliseconds(60))
+ self.visiblePhaseCount = phaseCount
+ }
+ }
+ }
+ }
+
+ private func makeFireworkConfettiSystem(
+ in size: CGSize,
+ launchAngle: Double,
+ phaseIndex: Int,
+ lateralOffset: CGFloat)
+ -> VortexSystem
+ {
+ let canvasOrigin = self.canvasOrigin(in: size, lateralOffset: lateralOffset)
+ let normalizedX = size.width > 0 ? canvasOrigin.x / size.width : 1
+ let normalizedY = size.height > 0 ? canvasOrigin.y / size.height : 0
+ let resolvedColors = self.colors.map { color -> VortexSystem.Color in
+ let components = color.resolve(in: self.environment)
+ return VortexSystem.Color(
+ red: Double(components.red),
+ green: Double(components.green),
+ blue: Double(components.blue),
+ opacity: Double(components.opacity))
+ }
+
+ let explosion = VortexSystem(
+ tags: ["confetti-bar", "confetti-dot", "confetti-pill"],
+ spawnOccasion: .onDeath,
+ shape: .point,
+ birthRate: 24000,
+ emissionLimit: 42,
+ emissionDuration: 0.08,
+ idleDuration: 10,
+ lifespan: 4.2,
+ speed: 0.72,
+ speedVariation: 0.44,
+ angleRange: .degrees(360),
+ acceleration: [0, 0.32],
+ dampingFactor: 0.18,
+ angularSpeed: [0, 0, 3],
+ angularSpeedVariation: [2, 2, 14],
+ colors: .random(resolvedColors),
+ size: 0.74,
+ sizeVariation: 0.26,
+ sizeMultiplierAtDeath: 0.94,
+ stretchFactor: 0.82)
+
+ return VortexSystem(
+ tags: ["confetti-tracer"],
+ secondarySystems: [explosion],
+ position: [normalizedX, normalizedY],
+ shape: .point,
+ birthRate: 18,
+ emissionLimit: 4,
+ emissionDuration: 0.22,
+ idleDuration: 10,
+ lifespan: 0.58 + (Double(phaseIndex) * 0.03),
+ speed: 1.36 + (Double(phaseIndex) * 0.04),
+ speedVariation: 0.12,
+ angle: .degrees(launchAngle),
+ angleRange: .degrees(12),
+ acceleration: [0, 0.12],
+ dampingFactor: 0.06,
+ angularSpeed: [0, 0, 6],
+ angularSpeedVariation: [1, 1, 8],
+ colors: .single(.white),
+ size: 0.34,
+ sizeVariation: 0.08,
+ sizeMultiplierAtDeath: 0.4,
+ stretchFactor: 1.3)
+ }
+
+ private func canvasOrigin(in size: CGSize, lateralOffset: CGFloat = 0) -> CGPoint {
+ CGPoint(
+ x: min(max(self.origin.x + lateralOffset, 0), size.width),
+ y: min(max(size.height - self.origin.y + 18, 0), size.height))
+ }
+}
diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift
index 84aebdd9f..ab32e5797 100644
--- a/Sources/CodexBar/SettingsStore+Defaults.swift
+++ b/Sources/CodexBar/SettingsStore+Defaults.swift
@@ -192,6 +192,14 @@ extension SettingsStore {
}
}
+ var confettiOnWeeklyLimitResetsEnabled: Bool {
+ get { self.defaultsState.confettiOnWeeklyLimitResetsEnabled }
+ set {
+ self.defaultsState.confettiOnWeeklyLimitResetsEnabled = newValue
+ self.userDefaults.set(newValue, forKey: "confettiOnWeeklyLimitResetsEnabled")
+ }
+ }
+
var menuBarShowsHighestUsage: Bool {
get { self.defaultsState.menuBarShowsHighestUsage }
set {
diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift
index 5ac7f16f6..73786c47f 100644
--- a/Sources/CodexBar/SettingsStore+MenuObservation.swift
+++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift
@@ -22,6 +22,7 @@ extension SettingsStore {
_ = self.costUsageEnabled
_ = self.hidePersonalInfo
_ = self.randomBlinkEnabled
+ _ = self.confettiOnWeeklyLimitResetsEnabled
_ = self.claudeOAuthKeychainPromptMode
_ = self.claudeOAuthKeychainReadStrategy
_ = self.claudeWebExtrasEnabled
diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift
index fa62c073f..f4b136e86 100644
--- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift
+++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift
@@ -9,7 +9,7 @@ extension SettingsStore {
switch preference {
case .automatic, .primary:
return preference
- case .secondary, .average, .tertiary:
+ case .secondary, .average, .tertiary, .extraUsage:
return .automatic
}
}
@@ -21,6 +21,9 @@ extension SettingsStore {
if preference == .tertiary, !self.menuBarMetricSupportsTertiary(for: provider) {
return .automatic
}
+ if preference == .extraUsage, !self.menuBarMetricSupportsExtraUsage(for: provider) {
+ return .automatic
+ }
return preference
}
@@ -29,7 +32,7 @@ extension SettingsStore {
switch preference {
case .automatic, .primary:
self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue
- case .secondary, .average, .tertiary:
+ case .secondary, .average, .tertiary, .extraUsage:
self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue
}
return
@@ -38,6 +41,10 @@ extension SettingsStore {
self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue
return
}
+ if preference == .extraUsage, !self.menuBarMetricSupportsExtraUsage(for: provider) {
+ self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.automatic.rawValue
+ return
+ }
self.menuBarMetricPreferencesRaw[provider.rawValue] = preference.rawValue
}
@@ -56,6 +63,16 @@ extension SettingsStore {
return self.menuBarMetricSupportsTertiary(for: provider)
}
+ func menuBarMetricSupportsExtraUsage(for provider: UsageProvider) -> Bool {
+ provider == .cursor
+ }
+
+ func menuBarMetricSupportsExtraUsage(for provider: UsageProvider, snapshot: UsageSnapshot?) -> Bool {
+ guard self.menuBarMetricSupportsExtraUsage(for: provider) else { return false }
+ guard let cost = snapshot?.providerCost else { return false }
+ return cost.limit > 0
+ }
+
func menuBarMetricPreference(for provider: UsageProvider, snapshot: UsageSnapshot?) -> MenuBarMetricPreference {
let preference = self.menuBarMetricPreference(for: provider)
if preference == .tertiary,
@@ -63,6 +80,11 @@ extension SettingsStore {
{
return .automatic
}
+ if preference == .extraUsage,
+ !self.menuBarMetricSupportsExtraUsage(for: provider, snapshot: snapshot)
+ {
+ return .automatic
+ }
return preference
}
diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift
index 68ba707aa..b006f6d0e 100644
--- a/Sources/CodexBar/SettingsStore.swift
+++ b/Sources/CodexBar/SettingsStore.swift
@@ -43,6 +43,7 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable {
case primary
case secondary
case tertiary
+ case extraUsage
case average
var id: String {
@@ -55,6 +56,7 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable {
case .primary: "Primary"
case .secondary: "Secondary"
case .tertiary: "Tertiary"
+ case .extraUsage: "Extra usage"
case .average: "Average"
}
}
@@ -63,7 +65,7 @@ enum MenuBarMetricPreference: String, CaseIterable, Identifiable {
@MainActor
@Observable
final class SettingsStore {
- static let sharedDefaults = UserDefaults(suiteName: "group.com.steipete.codexbar")
+ static let sharedDefaults = AppGroupSupport.sharedDefaults()
static let mergedOverviewProviderLimit = 3
static let isRunningTests: Bool = {
let env = ProcessInfo.processInfo.environment
@@ -124,6 +126,21 @@ final class SettingsStore {
copilotTokenStore: any CopilotTokenStoring = KeychainCopilotTokenStore(),
tokenAccountStore: any ProviderTokenAccountStoring = FileTokenAccountStore())
{
+ let appGroupID = AppGroupSupport.currentGroupID()
+ let appGroupMigration = AppGroupSupport.migrateLegacyDataIfNeeded(standardDefaults: userDefaults)
+ let sharedDefaultsAvailable = Self.sharedDefaults != nil
+ if !Self.isRunningTests {
+ CodexBarLog.logger(LogCategories.settings).info(
+ "App group resolved",
+ metadata: [
+ "groupID": appGroupID,
+ "sharedDefaultsAvailable": sharedDefaultsAvailable ? "1" : "0",
+ "migrationStatus": appGroupMigration.status.rawValue,
+ "migratedSnapshot": appGroupMigration.copiedSnapshot ? "1" : "0",
+ "migratedDefaults": "\(appGroupMigration.copiedDefaults)",
+ ])
+ }
+
let hasStoredOpenAIWebAccessPreference = userDefaults.object(forKey: "openAIWebAccessEnabled") != nil
let hadExistingConfig = (try? configStore.load()) != nil
let legacyStores = CodexBarConfigMigrator.LegacyStores(
@@ -240,6 +257,8 @@ extension SettingsStore {
let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false
let hidePersonalInfo = userDefaults.object(forKey: "hidePersonalInfo") as? Bool ?? false
let randomBlinkEnabled = userDefaults.object(forKey: "randomBlinkEnabled") as? Bool ?? false
+ let confettiOnWeeklyLimitResetsEnabled = userDefaults.object(
+ forKey: "confettiOnWeeklyLimitResetsEnabled") as? Bool ?? false
let menuBarShowsHighestUsage = userDefaults.object(forKey: "menuBarShowsHighestUsage") as? Bool ?? false
let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode")
let claudeOAuthKeychainReadStrategyRaw = userDefaults.string(forKey: "claudeOAuthKeychainReadStrategy")
@@ -284,6 +303,7 @@ extension SettingsStore {
costUsageEnabled: costUsageEnabled,
hidePersonalInfo: hidePersonalInfo,
randomBlinkEnabled: randomBlinkEnabled,
+ confettiOnWeeklyLimitResetsEnabled: confettiOnWeeklyLimitResetsEnabled,
menuBarShowsHighestUsage: menuBarShowsHighestUsage,
claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw,
claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw,
diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift
index 69e676032..a65fb45d5 100644
--- a/Sources/CodexBar/SettingsStoreState.swift
+++ b/Sources/CodexBar/SettingsStoreState.swift
@@ -21,6 +21,7 @@ struct SettingsDefaultsState {
var costUsageEnabled: Bool
var hidePersonalInfo: Bool
var randomBlinkEnabled: Bool
+ var confettiOnWeeklyLimitResetsEnabled: Bool
var menuBarShowsHighestUsage: Bool
var claudeOAuthKeychainPromptModeRaw: String?
var claudeOAuthKeychainReadStrategyRaw: String?
diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift
index b470e049c..e9fcf6f66 100644
--- a/Sources/CodexBar/StatusItemController+Actions.swift
+++ b/Sources/CodexBar/StatusItemController+Actions.swift
@@ -198,6 +198,26 @@ extension StatusItemController {
item.button?.performClick(nil)
}
+ func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? {
+ let item: NSStatusItem = if self.shouldMergeIcons {
+ self.statusItem
+ } else if let provider, let existing = self.statusItems[provider], existing.isVisible {
+ existing
+ } else {
+ self.lazyStatusItem(for: provider ?? .codex)
+ }
+
+ guard let button = item.button,
+ let window = button.window
+ else {
+ return nil
+ }
+
+ let buttonFrameInWindow = button.convert(button.bounds, to: nil)
+ let screenFrame = window.convertToScreen(buttonFrameInWindow)
+ return CGPoint(x: screenFrame.midX, y: screenFrame.midY)
+ }
+
private func openSettings(tab: PreferencesTab) {
DispatchQueue.main.async {
self.preferencesSelection.tab = tab
diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift
index 564dde609..bed72a824 100644
--- a/Sources/CodexBar/StatusItemController+Animation.swift
+++ b/Sources/CodexBar/StatusItemController+Animation.swift
@@ -405,7 +405,10 @@ extension StatusItemController {
// swiftlint:enable function_body_length
private func shouldSkipMergedIconRender(_ signature: String) -> Bool {
- guard self.shouldMergeIcons else { return false }
+ guard self.shouldMergeIcons else {
+ self.lastAppliedMergedIconRenderSignature = signature
+ return false
+ }
if self.lastAppliedMergedIconRenderSignature == signature {
return true
}
diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift
index 1676db9b1..c716636b2 100644
--- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift
+++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift
@@ -3,8 +3,6 @@ import CodexBarCore
import SwiftUI
extension StatusItemController {
- private static let hostedSubviewWidth: CGFloat = 310
-
func makeHostedSubviewPlaceholderMenu(chartID: String, provider: UsageProvider? = nil) -> NSMenu {
let submenu = NSMenu()
submenu.delegate = self
@@ -25,7 +23,7 @@ extension StatusItemController {
return
}
- let width = Self.hostedSubviewWidth
+ let width = self.renderedMenuWidth(for: menu.supermenu ?? menu)
menu.removeAllItems()
let didHydrate: Bool = switch chartID {
diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift
index 55ff40b7a..32f560f61 100644
--- a/Sources/CodexBar/StatusItemController+Menu.swift
+++ b/Sources/CodexBar/StatusItemController+Menu.swift
@@ -16,9 +16,48 @@ extension StatusItemController {
static let costHistoryChartID = "costHistoryChart"
static let usageHistoryChartID = "usageHistoryChart"
- private func menuCardWidth(for providers: [UsageProvider], menu: NSMenu? = nil) -> CGFloat {
- _ = menu
- return Self.menuCardBaseWidth
+ private func shortcut(for action: MenuDescriptor.MenuAction) -> (key: String, modifiers: NSEvent.ModifierFlags)? {
+ switch action {
+ case .refresh:
+ ("r", [.command])
+ case .settings:
+ (",", [.command])
+ case .quit:
+ ("q", [.command])
+ default:
+ nil
+ }
+ }
+
+ private func menuCardWidth(
+ for providers: [UsageProvider],
+ sections: [MenuDescriptor.Section]) -> CGFloat
+ {
+ _ = providers
+ let baselineWidth = Self.menuCardBaseWidth
+ return max(baselineWidth, self.measuredStandardMenuWidth(for: sections, baseWidth: baselineWidth))
+ }
+
+ private func measuredStandardMenuWidth(for sections: [MenuDescriptor.Section], baseWidth: CGFloat) -> CGFloat {
+ let measuringMenu = NSMenu()
+ measuringMenu.autoenablesItems = false
+ self.addActionableSections(sections, to: measuringMenu, width: baseWidth)
+ return ceil(measuringMenu.size.width)
+ }
+
+ func renderedMenuWidth(for menu: NSMenu) -> CGFloat {
+ let measuredWidth = ceil(menu.size.width)
+ return max(measuredWidth, Self.menuCardBaseWidth)
+ }
+
+ private func menuVisibleScreenHeight(for menu: NSMenu) -> CGFloat? {
+ if let viewHeight = menu.items.lazy
+ .compactMap({ $0.view?.window?.screen?.visibleFrame.height })
+ .first
+ {
+ return viewHeight
+ }
+ return self.statusItem.button?.window?.screen?.visibleFrame.height
}
func makeMenu() -> NSMenu {
@@ -66,9 +105,9 @@ extension StatusItemController {
if didRefresh {
self.populateMenu(menu, provider: provider)
self.markMenuFresh(menu)
- // Heights are already set during populateMenu, no need to remeasure
}
self.openMenus[ObjectIdentifier(menu)] = menu
+ self.refreshMenuCardHeights(in: menu)
// Only schedule refresh after menu is registered as open - refreshNow is called async
if Self.menuRefreshEnabled {
self.scheduleOpenMenuRefresh(for: menu)
@@ -114,7 +153,6 @@ extension StatusItemController {
} else {
switcherSelection?.provider ?? provider
}
- let menuWidth = self.menuCardWidth(for: enabledProviders, menu: menu)
let currentProvider = selectedProvider ?? enabledProviders.first ?? .codex
let codexAccountDisplay = isOverviewSelected ? nil : self.codexAccountMenuDisplay(for: currentProvider)
let tokenAccountDisplay = isOverviewSelected ? nil : self.tokenAccountMenuDisplay(for: currentProvider)
@@ -122,6 +160,16 @@ extension StatusItemController {
let openAIContext = self.openAIWebContext(
currentProvider: currentProvider,
showAllTokenAccounts: showAllTokenAccounts)
+ let descriptor = MenuDescriptor.build(
+ provider: selectedProvider,
+ store: self.store,
+ settings: self.settings,
+ account: self.account,
+ managedCodexAccountCoordinator: self.managedCodexAccountCoordinator,
+ codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator,
+ updateReady: self.updater.updateStatus.isUpdateReady,
+ includeContextualActions: !isOverviewSelected)
+ let menuWidth = self.menuCardWidth(for: enabledProviders, sections: descriptor.sections)
let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView }
let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView }
@@ -132,6 +180,10 @@ extension StatusItemController {
let tokenSwitcherCompatible = tokenAccountDisplay == nil && !hasTokenSwitcher
let codexSwitcherCompatible = codexAccountDisplay == self.lastCodexAccountMenuDisplay &&
((codexAccountDisplay == nil && !hasCodexSwitcher) || (codexAccountDisplay != nil && hasCodexSwitcher))
+ let reusableRowWidthsMatch = self.reusableFixedWidthRows(in: menu).allSatisfy { item in
+ guard let view = item.view else { return false }
+ return abs(view.frame.width - menuWidth) <= 0.5
+ }
let canSmartUpdate = self.shouldMergeIcons &&
enabledProviders.count > 1 &&
!isOverviewSelected &&
@@ -141,6 +193,7 @@ extension StatusItemController {
switcherOverviewAvailabilityMatches &&
tokenSwitcherCompatible &&
codexSwitcherCompatible &&
+ reusableRowWidthsMatch &&
!menu.items.isEmpty &&
menu.items.first?.view is ProviderSwitcherView
@@ -155,22 +208,12 @@ extension StatusItemController {
}
menu.removeAllItems()
-
- let descriptor = MenuDescriptor.build(
- provider: selectedProvider,
- store: self.store,
- settings: self.settings,
- account: self.account,
- managedCodexAccountCoordinator: self.managedCodexAccountCoordinator,
- codexAccountPromotionCoordinator: self.codexAccountPromotionCoordinator,
- updateReady: self.updater.updateStatus.isUpdateReady,
- includeContextualActions: !isOverviewSelected)
-
self.addProviderSwitcherIfNeeded(
to: menu,
enabledProviders: enabledProviders,
includesOverview: includesOverview,
- selection: switcherSelection ?? .provider(currentProvider))
+ selection: switcherSelection ?? .provider(currentProvider),
+ width: menuWidth)
// Track which providers the switcher was built with for smart update detection
if self.shouldMergeIcons, enabledProviders.count > 1 {
self.lastSwitcherProviders = enabledProviders
@@ -178,9 +221,9 @@ extension StatusItemController {
self.lastMergedSwitcherSelection = switcherSelection
self.lastSwitcherIncludesOverview = includesOverview
}
- self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay)
+ self.addCodexAccountSwitcherIfNeeded(to: menu, display: codexAccountDisplay, width: menuWidth)
self.lastCodexAccountMenuDisplay = codexAccountDisplay
- self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay)
+ self.addTokenAccountSwitcherIfNeeded(to: menu, display: tokenAccountDisplay, width: menuWidth)
let menuContext = MenuCardContext(
currentProvider: currentProvider,
selectedProvider: selectedProvider,
@@ -205,13 +248,36 @@ extension StatusItemController {
currentProvider: currentProvider,
context: openAIContext,
addedOpenAIWebItems: addedOpenAIWebItems)
- if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) {
+ if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider, width: menuWidth) {
menu.addItem(.separator())
}
}
self.addActionableSections(descriptor.sections, to: menu, width: menuWidth)
}
+ private func reusableFixedWidthRows(in menu: NSMenu) -> [NSMenuItem] {
+ guard !menu.items.isEmpty else { return [] }
+
+ var reusableRows: [NSMenuItem] = []
+ var index = 0
+ if menu.items.first?.view is ProviderSwitcherView {
+ reusableRows.append(menu.items[0])
+ index = 2
+ }
+ if menu.items.count > index,
+ menu.items[index].view is CodexAccountSwitcherView
+ {
+ reusableRows.append(menu.items[index])
+ index += 2
+ }
+ if menu.items.count > index,
+ menu.items[index].view is TokenAccountSwitcherView
+ {
+ reusableRows.append(menu.items[index])
+ }
+ return reusableRows
+ }
+
/// Smart update: only rebuild content sections when switching providers (keep the switcher intact).
private func updateMenuContent(
_ menu: NSMenu,
@@ -264,7 +330,7 @@ extension StatusItemController {
currentProvider: currentProvider,
context: openAIContext,
addedOpenAIWebItems: addedOpenAIWebItems)
- if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) {
+ if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider, width: menuWidth) {
menu.addItem(.separator())
}
self.addActionableSections(descriptor.sections, to: menu, width: menuWidth)
@@ -313,28 +379,30 @@ extension StatusItemController {
to menu: NSMenu,
enabledProviders: [UsageProvider],
includesOverview: Bool,
- selection: ProviderSwitcherSelection)
+ selection: ProviderSwitcherSelection,
+ width: CGFloat)
{
guard self.shouldMergeIcons, enabledProviders.count > 1 else { return }
let switcherItem = self.makeProviderSwitcherItem(
providers: enabledProviders,
includesOverview: includesOverview,
selected: selection,
- menu: menu)
+ menu: menu,
+ width: width)
menu.addItem(switcherItem)
menu.addItem(.separator())
}
- private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?) {
+ private func addTokenAccountSwitcherIfNeeded(to menu: NSMenu, display: TokenAccountMenuDisplay?, width: CGFloat) {
guard let display, display.showSwitcher else { return }
- let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu)
+ let switcherItem = self.makeTokenAccountSwitcherItem(display: display, menu: menu, width: width)
menu.addItem(switcherItem)
menu.addItem(.separator())
}
- private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?) {
+ private func addCodexAccountSwitcherIfNeeded(to menu: NSMenu, display: CodexAccountMenuDisplay?, width: CGFloat) {
guard let display else { return }
- let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu)
+ let switcherItem = self.makeCodexAccountSwitcherItem(display: display, menu: menu, width: width)
menu.addItem(switcherItem)
menu.addItem(.separator())
}
@@ -357,7 +425,11 @@ extension StatusItemController {
for (index, row) in rows.enumerated() {
let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)"
let item = self.makeMenuCardItem(
- OverviewMenuCardRowView(model: row.model, width: menuWidth),
+ OverviewMenuCardRowView(
+ model: row.model,
+ width: menuWidth,
+ onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu),
+ miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)),
id: identifier,
width: menuWidth,
onClick: { [weak self, weak menu] in
@@ -403,14 +475,22 @@ extension StatusItemController {
}
if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) {
menu.addItem(self.makeMenuCardItem(
- UsageMenuCardView(model: model, width: context.menuWidth),
+ UsageMenuCardView(
+ model: model,
+ width: context.menuWidth,
+ onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu),
+ miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)),
id: "menuCard",
width: context.menuWidth))
menu.addItem(.separator())
} else {
for (index, model) in cards.enumerated() {
menu.addItem(self.makeMenuCardItem(
- UsageMenuCardView(model: model, width: context.menuWidth),
+ UsageMenuCardView(
+ model: model,
+ width: context.menuWidth,
+ onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu),
+ miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)),
id: "menuCard-\(index)",
width: context.menuWidth))
if index < cards.count - 1 {
@@ -441,7 +521,11 @@ extension StatusItemController {
}
menu.addItem(self.makeMenuCardItem(
- UsageMenuCardView(model: model, width: context.menuWidth),
+ UsageMenuCardView(
+ model: model,
+ width: context.menuWidth,
+ onMiniMaxLayoutChange: self.makeMiniMaxLayoutRefreshAction(for: menu),
+ miniMaxVisibleScreenHeight: self.menuVisibleScreenHeight(for: menu)),
id: "menuCard",
width: context.menuWidth))
if context.openAIContext.canShowBuyCredits {
@@ -506,6 +590,10 @@ extension StatusItemController {
let item = NSMenuItem(title: title, action: selector, keyEquivalent: "")
item.target = self
item.representedObject = represented
+ if let shortcut = self.shortcut(for: action) {
+ item.keyEquivalent = shortcut.key
+ item.keyEquivalentModifierMask = shortcut.modifiers
+ }
if let iconName = action.systemImageName,
let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil)
{
@@ -561,7 +649,7 @@ extension StatusItemController {
}
private func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem {
- let item = NSMenuItem(title: text, action: nil, keyEquivalent: "")
+ let item = NSMenuItem(title: "", action: nil, keyEquivalent: "")
let view = self.makeWrappedSecondaryTextView(text: text)
let height = self.menuTextItemHeight(for: view, width: width)
view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height))
@@ -614,13 +702,14 @@ extension StatusItemController {
providers: [UsageProvider],
includesOverview: Bool,
selected: ProviderSwitcherSelection,
- menu: NSMenu) -> NSMenuItem
+ menu: NSMenu,
+ width: CGFloat) -> NSMenuItem
{
let view = ProviderSwitcherView(
providers: providers,
selected: selected,
includesOverview: includesOverview,
- width: self.menuCardWidth(for: providers, menu: menu),
+ width: width,
showsIcons: self.settings.switcherShowsIcons,
iconProvider: { [weak self] provider in
self?.switcherIcon(for: provider) ?? NSImage()
@@ -655,12 +744,13 @@ extension StatusItemController {
private func makeTokenAccountSwitcherItem(
display: TokenAccountMenuDisplay,
- menu: NSMenu) -> NSMenuItem
+ menu: NSMenu,
+ width: CGFloat) -> NSMenuItem
{
let view = TokenAccountSwitcherView(
accounts: display.accounts,
selectedIndex: display.activeIndex,
- width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu),
+ width: width,
onSelect: { [weak self, weak menu] index in
guard let self, let menu else { return }
self.settings.setActiveTokenAccountIndex(index, for: display.provider)
@@ -681,12 +771,13 @@ extension StatusItemController {
private func makeCodexAccountSwitcherItem(
display: CodexAccountMenuDisplay,
- menu: NSMenu) -> NSMenuItem
+ menu: NSMenu,
+ width: CGFloat) -> NSMenuItem
{
let view = CodexAccountSwitcherView(
accounts: display.accounts,
selectedAccountID: display.activeVisibleAccountID,
- width: self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu),
+ width: width,
onSelect: { [weak self, weak menu] visibleAccountID in
guard let self else { return }
self.handleCodexVisibleAccountSelection(visibleAccountID, menu: menu)
@@ -803,7 +894,7 @@ extension StatusItemController {
let provider = self.menuProvider(for: menu)
self.populateMenu(menu, provider: provider)
self.markMenuFresh(menu)
- // Heights are already set during populateMenu, no need to remeasure
+ self.refreshMenuCardHeights(in: menu)
}
}
}
@@ -842,6 +933,7 @@ extension StatusItemController {
guard self.openMenus[ObjectIdentifier(menu)] != nil else { return }
self.populateMenu(menu, provider: provider)
self.markMenuFresh(menu)
+ self.refreshMenuCardHeights(in: menu)
self.applyIcon(phase: nil)
#if DEBUG
self._test_openMenuRebuildObserver?(menu)
@@ -904,18 +996,16 @@ extension StatusItemController {
}
private func refreshMenuCardHeights(in menu: NSMenu) {
- // Re-measure the menu card height right before display to avoid stale/incorrect sizing when content
- // changes (e.g. dashboard error lines causing wrapping).
- let cardItems = menu.items.filter { item in
- (item.representedObject as? String)?.hasPrefix("menuCard") == true
- }
- for item in cardItems {
- guard let view = item.view else { continue }
- let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu)
- let height = self.menuCardHeight(for: view, width: width)
- view.frame = NSRect(
- origin: .zero,
- size: NSSize(width: width, height: height))
+ // Re-measure card/overview rows right before display to avoid stale sizing when wrapped text changes.
+ let width = self.renderedMenuWidth(for: menu)
+ for item in menu.items {
+ let represented = item.representedObject as? String
+ let isOverviewRow = represented?.hasPrefix(Self.overviewRowIdentifierPrefix) == true
+ let isMenuCard = represented?.hasPrefix("menuCard") == true
+ guard isOverviewRow || isMenuCard, let view = item.view else { continue }
+ // Use instance-based remeasure here so dynamic collapse/expand state can shrink as well as grow.
+ let height = self.remeasuredMenuCardHeight(for: view, width: width)
+ view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height))
}
}
@@ -1010,7 +1100,9 @@ extension StatusItemController {
model: model,
showBottomDivider: false,
bottomPadding: usageBottomPadding,
- width: width)
+ width: width,
+ onMiniMaxLayoutChange: nil,
+ miniMaxVisibleScreenHeight: nil)
let usageSubmenu = self.makeUsageSubmenu(
provider: provider,
snapshot: self.store.snapshot(for: provider),
@@ -1270,8 +1362,7 @@ extension StatusItemController {
}
private func refreshHostedSubviewHeights(in menu: NSMenu) {
- let enabledProviders = self.store.enabledProvidersForDisplay()
- let width = self.menuCardWidth(for: enabledProviders, menu: menu)
+ let width = self.renderedMenuWidth(for: menu)
for item in menu.items {
guard let view = item.view else { continue }
@@ -1282,6 +1373,26 @@ extension StatusItemController {
}
}
+ private func remeasuredMenuCardHeight(for view: NSView, width: CGFloat) -> CGFloat {
+ let basePadding: CGFloat = 6
+ let descenderSafety: CGFloat = 1
+
+ view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1))
+ view.layoutSubtreeIfNeeded()
+ let fitted = view.fittingSize
+ return max(1, ceil(fitted.height + basePadding + descenderSafety))
+ }
+
+ private func makeMiniMaxLayoutRefreshAction(for menu: NSMenu) -> () -> Void {
+ { [weak self, weak menu] in
+ Task { @MainActor [weak self, weak menu] in
+ guard let self, let menu else { return }
+ await Task.yield()
+ self.rebuildOpenMenuIfStillVisible(menu, provider: self.menuProvider(for: menu))
+ }
+ }
+ }
+
func menuCardModel(
for provider: UsageProvider?,
snapshotOverride: UsageSnapshot? = nil,
diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift
index 418d4e9d2..83238535a 100644
--- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift
+++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift
@@ -123,9 +123,26 @@ final class MenuCardItemHostingView: NSHostingView, Menu
@objc private func handlePrimaryClick(_ recognizer: NSClickGestureRecognizer) {
guard recognizer.state == .ended else { return }
+ let location = recognizer.location(in: self)
+ if let hitView = self.hitTest(location),
+ self.shouldSuppressRowSelection(for: hitView)
+ {
+ return
+ }
self.onClick?()
}
+ private func shouldSuppressRowSelection(for hitView: NSView) -> Bool {
+ var current: NSView? = hitView
+ while let view = current, view !== self {
+ if view is NSButton || view is NSControl {
+ return true
+ }
+ current = view.superview
+ }
+ return false
+ }
+
func measuredHeight(width: CGFloat) -> CGFloat {
let controller = NSHostingController(rootView: self.rootView)
let measured = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift
index 3e8c6c3cc..caf57de1a 100644
--- a/Sources/CodexBar/StatusItemController+MenuTypes.swift
+++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift
@@ -16,6 +16,20 @@ extension ProviderSwitcherSelection {
struct OverviewMenuCardRowView: View {
let model: UsageMenuCardView.Model
let width: CGFloat
+ let onMiniMaxLayoutChange: (() -> Void)?
+ let miniMaxVisibleScreenHeight: CGFloat?
+
+ init(
+ model: UsageMenuCardView.Model,
+ width: CGFloat,
+ onMiniMaxLayoutChange: (() -> Void)? = nil,
+ miniMaxVisibleScreenHeight: CGFloat? = nil)
+ {
+ self.model = model
+ self.width = width
+ self.onMiniMaxLayoutChange = onMiniMaxLayoutChange
+ self.miniMaxVisibleScreenHeight = miniMaxVisibleScreenHeight
+ }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -28,14 +42,17 @@ struct OverviewMenuCardRowView: View {
model: self.model,
showBottomDivider: false,
bottomPadding: 6,
- width: self.width)
+ width: self.width,
+ onMiniMaxLayoutChange: self.onMiniMaxLayoutChange,
+ miniMaxVisibleScreenHeight: self.miniMaxVisibleScreenHeight)
}
}
.frame(width: self.width, alignment: .leading)
}
private var hasUsageBlock: Bool {
- !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil
+ !self.model.metrics.isEmpty || !self.model.usageNotes.isEmpty || self.model.placeholder != nil ||
+ (self.model.minimaxSections?.isEmpty == false)
}
}
diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift
index a2d5b9600..6cfed4206 100644
--- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift
+++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift
@@ -10,9 +10,8 @@ private final class UsageHistoryMenuHostingView: NSHostingView Bool {
+ func addUsageHistoryMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool {
guard let submenu = self.makeUsageHistorySubmenu(provider: provider) else { return false }
- let width: CGFloat = 310
let item = self.makeMenuCardItem(
HStack(spacing: 0) {
Text("Subscription Utilization")
diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift
index b77865f96..210fd8530 100644
--- a/Sources/CodexBar/StatusItemController.swift
+++ b/Sources/CodexBar/StatusItemController.swift
@@ -9,6 +9,13 @@ import SwiftUI
@MainActor
protocol StatusItemControlling: AnyObject {
func openMenuFromShortcut()
+ func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint?
+}
+
+extension StatusItemControlling {
+ func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? {
+ nil
+ }
}
@MainActor
@@ -185,6 +192,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
switch preference {
case .secondary, .tertiary:
return second ?? first
+ case .extraUsage:
+ return first
case .average:
guard self.settings.menuBarMetricSupportsAverage(for: .codex),
let primary = first,
@@ -238,8 +247,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
// Status items for individual providers are now created lazily in updateVisibility()
super.init()
self.wireBindings()
- self.updateIcons()
self.updateVisibility()
+ self.updateIcons()
NotificationCenter.default.addObserver(
self,
selector: #selector(self.handleDebugReplayNotification(_:)),
diff --git a/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift
new file mode 100644
index 000000000..264953f9a
--- /dev/null
+++ b/Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift
@@ -0,0 +1,143 @@
+import CodexBarCore
+import Foundation
+import SwiftUI
+
+extension UsageMenuCardView.Model {
+ static func miniMaxSections(input: Input) -> [MiniMaxSection]? {
+ guard input.provider == .minimax,
+ let models = input.snapshot?.minimaxUsage?.models,
+ !models.isEmpty
+ else {
+ return nil
+ }
+ let hasWeeklyDetail = models.contains { $0.weeklyTotal != nil || $0.weeklyRemaining != nil }
+ guard models.count > 1 || hasWeeklyDetail else {
+ return nil
+ }
+
+ let fiveHour = models.filter { if case .fiveHour = $0.window { return true }; return false }
+ let daily = models.filter { if case .daily = $0.window { return true }; return false }
+ let weeklyOnly = models.filter { if case .weekly = $0.window { return true }; return false }
+ let other = models.filter { if case .other = $0.window { return true }; return false }
+
+ var sections: [MiniMaxSection] = []
+ if !fiveHour.isEmpty {
+ sections.append(MiniMaxSection(
+ title: "5-hour window",
+ rows: fiveHour.map { Self.miniMaxRow(model: $0, input: input) }))
+ }
+ if !daily.isEmpty {
+ sections.append(MiniMaxSection(
+ title: "Daily quota",
+ rows: daily.map { Self.miniMaxRow(model: $0, input: input) }))
+ }
+ if !weeklyOnly.isEmpty {
+ sections.append(MiniMaxSection(
+ title: "Weekly quota",
+ rows: weeklyOnly.map { Self.miniMaxRow(model: $0, input: input) }))
+ }
+ if !other.isEmpty {
+ sections.append(MiniMaxSection(
+ title: "Other windows",
+ rows: other.map { Self.miniMaxRow(model: $0, input: input) }))
+ }
+ return sections.isEmpty ? nil : sections
+ }
+
+ static func miniMaxRow(model: MiniMaxModelUsage, input: Input) -> MiniMaxRow {
+ let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left
+ let used = model.usedPercent
+ let barPercent = used.map { percentStyle == .used ? $0 : (100 - $0) }
+ let resetText: String? = if let at = model.resetsAt {
+ UsageFormatter.resetLine(
+ for: RateWindow(
+ usedPercent: used ?? 0,
+ windowMinutes: model.windowMinutes,
+ resetsAt: at,
+ resetDescription: nil),
+ style: input.resetTimeDisplayStyle,
+ now: input.now)
+ } else {
+ nil
+ }
+ let detailText = Self.miniMaxDetailLine(model: model)
+ let secondaryLine = Self.miniMaxWeeklySecondaryLine(model: model, input: input)
+ return MiniMaxRow(
+ id: model.identifier,
+ title: model.displayName,
+ percent: barPercent.map { Self.clamped($0) },
+ percentStyle: percentStyle,
+ resetText: resetText,
+ detailText: detailText,
+ secondaryLine: secondaryLine)
+ }
+
+ static func miniMaxDetailLine(model: MiniMaxModelUsage) -> String? {
+ guard let total = model.availablePrompts else { return nil }
+
+ // 与 MiniMaxUsageFetcher 一致:仅当同时有 total+remaining(或解析出的 current)时才推导已用量;
+ // remaining 缺省时不得假定为 0,否则会显示成「用尽」且与省略的 current_interval_usage_count 矛盾。
+ let used: Int? = if let current = model.currentPrompts {
+ current
+ } else if let remaining = model.remainingPrompts {
+ max(0, total - remaining)
+ } else {
+ nil
+ }
+
+ let remaining = model.remainingPrompts
+ // 区间额度占位 0/0 时不展示误导性用量(与周限 0/0 同类问题)。
+ if total == 0, (used ?? 0) == 0, remaining == nil || remaining == 0 {
+ return nil
+ }
+
+ let totalStr = UsageFormatter.tokenCountString(total)
+ guard let used else {
+ return "—/\(totalStr)"
+ }
+
+ let usedStr = UsageFormatter.tokenCountString(used)
+ if let remaining {
+ let remStr = UsageFormatter.tokenCountString(remaining)
+ return "\(usedStr)/\(totalStr) (\(remStr) remaining)"
+ }
+ return "\(usedStr)/\(totalStr)"
+ }
+
+ static func miniMaxWeeklySecondaryLine(model: MiniMaxModelUsage, input: Input) -> String? {
+ guard model.weeklyTotal != nil || model.weeklyRemaining != nil else { return nil }
+ // 与解析层一致:任一侧为 0、另一侧缺省时按 0 计;全零即无周限,不展示误导性周限行。
+ if (model.weeklyTotal ?? 0) == 0, (model.weeklyRemaining ?? 0) == 0 { return nil }
+ let total = model.weeklyTotal
+ let used = model.weeklyUsed
+ let remaining = model.weeklyRemaining
+ let usedStr = used.map { UsageFormatter.tokenCountString($0) } ?? "—"
+ let totalStr = total.map { UsageFormatter.tokenCountString($0) } ?? "—"
+ let pctStr = if let p = model.weeklyUsedPercent {
+ String(format: "%.1f%%", p)
+ } else {
+ "—"
+ }
+ let weeklyReset: String? = if let at = model.weeklyResetsAt {
+ UsageFormatter.resetLine(
+ for: RateWindow(
+ usedPercent: model.weeklyUsedPercent ?? 0,
+ windowMinutes: 7 * 24 * 60,
+ resetsAt: at,
+ resetDescription: nil),
+ style: input.resetTimeDisplayStyle,
+ now: input.now)
+ } else {
+ nil
+ }
+ let remStr = remaining.map { UsageFormatter.tokenCountString($0) }
+ var line = "↳ Weekly \(usedStr)/\(totalStr) (\(pctStr) used)"
+ if let remStr {
+ line += " · \(remStr) remaining"
+ }
+ if let weeklyReset {
+ line += " · \(weeklyReset)"
+ }
+ return line
+ }
+}
diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift
index 743f405e2..9c3c759e9 100644
--- a/Sources/CodexBar/UsageStore+Accessors.swift
+++ b/Sources/CodexBar/UsageStore+Accessors.swift
@@ -81,7 +81,7 @@ extension UsageStore {
return self.codexFetcher.loadAccountInfo()
}
let env = ProviderRegistry.makeEnvironment(
- base: ProcessInfo.processInfo.environment,
+ base: self.environmentBase,
provider: .codex,
settings: self.settings,
tokenOverride: nil)
diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift
index 08a15e4a6..53b6e2016 100644
--- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift
+++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift
@@ -921,6 +921,7 @@ extension UsageStore {
result = try await importer.importBestCookies(
intoAccountEmail: normalizedTarget,
allowAnyAccount: allowAnyAccount,
+ preferCachedCookieHeader: !force,
cacheScope: cacheScope,
logger: log)
case .off:
diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift
index d45b54276..10eca18b0 100644
--- a/Sources/CodexBar/UsageStore+PlanUtilization.swift
+++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift
@@ -2,6 +2,15 @@ import CodexBarCore
import Foundation
extension UsageStore {
+ private nonisolated static let weeklyLimitResetThreshold = 1.0
+ private nonisolated static let weeklyLimitResetDetectorDefaultsKey = "weeklyLimitResetDetectorStates"
+ private nonisolated static let weeklyWindowMinutes = 7 * 24 * 60
+
+ struct WeeklyLimitResetDetectorState: Codable, Equatable {
+ let wasAboveThreshold: Bool
+ let lastObservedAt: Date
+ }
+
func supportsPlanUtilizationHistory(for provider: UsageProvider) -> Bool {
switch provider {
case .codex, .claude:
@@ -65,6 +74,22 @@ extension UsageStore {
now: Date = Date())
async
{
+ let samples = self.planUtilizationSeriesSamples(provider: provider, snapshot: snapshot, capturedAt: now)
+ guard !samples.isEmpty else { return }
+
+ let detectorAccountKey = self.planUtilizationAccountKey(
+ for: provider,
+ snapshot: snapshot,
+ preferredAccount: account)
+ await MainActor.run {
+ self.postWeeklyLimitResetCelebrationIfNeeded(
+ provider: provider,
+ account: account,
+ snapshot: snapshot,
+ accountKey: detectorAccountKey,
+ samples: samples)
+ }
+
guard self.supportsPlanUtilizationHistory(for: provider) else { return }
guard !self.shouldDeferClaudePlanUtilizationHistory(provider: provider) else { return }
@@ -80,7 +105,6 @@ extension UsageStore {
shouldAdoptUnscopedHistory: shouldAdoptUnscopedHistory,
providerBuckets: &providerBuckets)
let histories = providerBuckets.histories(for: accountKey)
- let samples = self.planUtilizationSeriesSamples(provider: provider, snapshot: snapshot, capturedAt: now)
guard let updatedHistories = Self.updatedPlanUtilizationHistories(
existingHistories: histories,
@@ -195,6 +219,62 @@ extension UsageStore {
return max(0, min(100, value))
}
+ private func postWeeklyLimitResetCelebrationIfNeeded(
+ provider: UsageProvider,
+ account: ProviderTokenAccount?,
+ snapshot: UsageSnapshot,
+ accountKey: String?,
+ samples: [PlanUtilizationSeriesSample])
+ {
+ guard let weeklySample = samples.last(where: { $0.name == .weekly }) else { return }
+
+ let accountIdentifier = self.weeklyLimitResetAccountIdentifier(
+ provider: provider,
+ account: account,
+ snapshot: snapshot,
+ accountKey: accountKey)
+ let detectorKey = Self.weeklyLimitResetDetectorStateKey(
+ provider: provider,
+ accountIdentifier: accountIdentifier)
+ let currentUsed = weeklySample.entry.usedPercent
+ let currentObservedAt = weeklySample.entry.capturedAt
+ let wasAboveThreshold = currentUsed > Self.weeklyLimitResetThreshold
+ if let existingState = self.weeklyLimitResetDetectorStates[detectorKey],
+ currentObservedAt <= existingState.lastObservedAt
+ {
+ return
+ }
+
+ let shouldPost = self.weeklyLimitResetDetectorStates[detectorKey]?.wasAboveThreshold == true
+ && !wasAboveThreshold
+ self.weeklyLimitResetDetectorStates[detectorKey] = WeeklyLimitResetDetectorState(
+ wasAboveThreshold: wasAboveThreshold,
+ lastObservedAt: currentObservedAt)
+ self.persistWeeklyLimitResetDetectorStates()
+
+ guard shouldPost else { return }
+ let accountLabel = self.weeklyLimitResetAccountLabel(
+ provider: provider,
+ account: account,
+ snapshot: snapshot)
+ let event = WeeklyLimitResetEvent(
+ provider: provider,
+ accountIdentifier: accountIdentifier,
+ accountLabel: accountLabel,
+ usedPercent: currentUsed)
+
+ CodexBarLog.logger(LogCategories.confetti).info(
+ "Weekly limit reset",
+ metadata: [
+ "provider": provider.rawValue,
+ "accountIdentifier": accountIdentifier,
+ "accountLabel": accountLabel ?? "",
+ "usedPercent": String(format: "%.2f", currentUsed),
+ "observedAt": String(format: "%.0f", currentObservedAt.timeIntervalSince1970),
+ ])
+ NotificationCenter.default.post(name: .codexbarWeeklyLimitReset, object: event)
+ }
+
private func planUtilizationSeriesSamples(
provider: UsageProvider,
snapshot: UsageSnapshot,
@@ -206,6 +286,7 @@ extension UsageStore {
guard let name,
let window,
let windowMinutes = window.windowMinutes,
+ windowMinutes > 0,
let usedPercent = Self.clampedPercent(window.usedPercent)
else {
return
@@ -235,7 +316,10 @@ extension UsageStore {
appendWindow(snapshot.secondary, name: .weekly)
appendWindow(snapshot.tertiary, name: .opus)
default:
- break
+ for window in [snapshot.primary, snapshot.secondary, snapshot.tertiary] {
+ guard let window, window.windowMinutes == Self.weeklyWindowMinutes else { continue }
+ appendWindow(window, name: .weekly)
+ }
}
return samplesByKey.values.sorted { lhs, rhs in
@@ -426,6 +510,63 @@ extension UsageStore {
provider == .claude && self.shouldHidePlanUtilizationMenuItem(for: .claude)
}
+ private func weeklyLimitResetAccountIdentifier(
+ provider: UsageProvider,
+ account: ProviderTokenAccount?,
+ snapshot: UsageSnapshot,
+ accountKey: String?) -> String
+ {
+ let identity = snapshot.identity(for: provider)
+ return account?.id.uuidString.lowercased()
+ ?? accountKey
+ ?? identity?.accountEmail
+ ?? identity?.accountOrganization
+ ?? provider.rawValue
+ }
+
+ private func weeklyLimitResetAccountLabel(
+ provider: UsageProvider,
+ account: ProviderTokenAccount?,
+ snapshot: UsageSnapshot) -> String?
+ {
+ let identity = snapshot.identity(for: provider)
+ return account?.label
+ ?? identity?.accountEmail
+ ?? identity?.accountOrganization
+ }
+
+ private nonisolated static func weeklyLimitResetDetectorStateKey(
+ provider: UsageProvider,
+ accountIdentifier: String) -> String
+ {
+ "\(provider.rawValue):\(accountIdentifier)"
+ }
+
+ nonisolated static func loadWeeklyLimitResetDetectorStates(from userDefaults: UserDefaults)
+ -> [String: WeeklyLimitResetDetectorState]
+ {
+ guard let data = userDefaults.data(forKey: self.weeklyLimitResetDetectorDefaultsKey) else { return [:] }
+ do {
+ return try JSONDecoder().decode([String: WeeklyLimitResetDetectorState].self, from: data)
+ } catch {
+ CodexBarLog.logger(LogCategories.confetti).error(
+ "Failed to decode weekly limit reset detector state",
+ metadata: ["error": String(describing: error)])
+ return [:]
+ }
+ }
+
+ private func persistWeeklyLimitResetDetectorStates() {
+ do {
+ let data = try JSONEncoder().encode(self.weeklyLimitResetDetectorStates)
+ self.settings.userDefaults.set(data, forKey: Self.weeklyLimitResetDetectorDefaultsKey)
+ } catch {
+ CodexBarLog.logger(LogCategories.confetti).error(
+ "Failed to encode weekly limit reset detector state",
+ metadata: ["error": String(describing: error)])
+ }
+ }
+
private func resolvePlanUtilizationAccountKey(
provider: UsageProvider,
snapshot: UsageSnapshot?,
diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift
index 4940eeec5..7a00c1236 100644
--- a/Sources/CodexBar/UsageStore+TokenAccounts.swift
+++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift
@@ -99,7 +99,7 @@ extension UsageStore {
let sourceMode = self.sourceMode(for: provider)
let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: override)
let env = ProviderRegistry.makeEnvironment(
- base: ProcessInfo.processInfo.environment,
+ base: self.environmentBase,
provider: provider,
settings: self.settings,
tokenOverride: override)
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index d2af04b21..3704feeda 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -179,6 +179,7 @@ final class UsageStore {
@ObservationIgnored let browserDetection: BrowserDetection
@ObservationIgnored private let registry: ProviderRegistry
@ObservationIgnored let settings: SettingsStore
+ @ObservationIgnored let environmentBase: [String: String]
@ObservationIgnored private let sessionQuotaNotifier: any SessionQuotaNotifying
@ObservationIgnored private let sessionQuotaLogger = CodexBarLog.logger(LogCategories.sessionQuota)
@ObservationIgnored let openAIWebLogger = CodexBarLog.logger(LogCategories.openAIWeb)
@@ -204,6 +205,7 @@ final class UsageStore {
@ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:]
@ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:]
@ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:]
+ @ObservationIgnored var weeklyLimitResetDetectorStates: [String: WeeklyLimitResetDetectorState] = [:]
@ObservationIgnored private var hasCompletedInitialRefresh: Bool = false
@ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60
@ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60
@@ -220,7 +222,8 @@ final class UsageStore {
historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(),
planUtilizationHistoryStore: PlanUtilizationHistoryStore = .defaultAppSupport(),
sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(),
- startupBehavior: StartupBehavior = .automatic)
+ startupBehavior: StartupBehavior = .automatic,
+ environmentBase: [String: String] = ProcessInfo.processInfo.environment)
{
self.codexFetcher = fetcher
self.browserDetection = browserDetection
@@ -228,6 +231,7 @@ final class UsageStore {
self.costUsageFetcher = costUsageFetcher
self.settings = settings
self.registry = registry
+ self.environmentBase = environmentBase
self.historicalUsageHistoryStore = historicalUsageHistoryStore
self.planUtilizationHistoryStore = planUtilizationHistoryStore
self.sessionQuotaNotifier = sessionQuotaNotifier
@@ -247,11 +251,13 @@ final class UsageStore {
metadata: self.providerMetadata,
codexFetcher: fetcher,
claudeFetcher: self.claudeFetcher,
- browserDetection: browserDetection)
+ browserDetection: browserDetection,
+ environmentBase: environmentBase)
self.providerRuntimes = Dictionary(uniqueKeysWithValues: ProviderCatalog.all.compactMap { implementation in
implementation.makeRuntime().map { (implementation.id, $0) }
})
self.planUtilizationHistory = planUtilizationHistoryStore.load()
+ self.weeklyLimitResetDetectorStates = Self.loadWeeklyLimitResetDetectorStates(from: settings.userDefaults)
self.logStartupState()
self.bindSettings()
self.pathDebugInfo = PathDebugSnapshot(
@@ -419,7 +425,7 @@ final class UsageStore {
// Otherwise providers (notably token-account-backed API providers) can fetch successfully but be
// hidden from the menu because their credentials are not in ProcessInfo's environment.
let environment = ProviderRegistry.makeEnvironment(
- base: ProcessInfo.processInfo.environment,
+ base: self.environmentBase,
provider: provider,
settings: self.settings,
tokenOverride: nil)
@@ -777,7 +783,7 @@ extension UsageStore {
let ampCookieHeader = self.settings.ampCookieHeader
let ollamaCookieSource = self.settings.ollamaCookieSource
let ollamaCookieHeader = self.settings.ollamaCookieHeader
- let processEnvironment = ProcessInfo.processInfo.environment
+ let processEnvironment = self.environmentBase
let openRouterConfigToken = self.settings.providerConfig(for: .openrouter)?.sanitizedAPIKey
let openRouterHasConfigToken = !(openRouterConfigToken?.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
@@ -876,7 +882,7 @@ extension UsageStore {
let source = resolution?.source.rawValue ?? "none"
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
- .kimik2, .jetbrains, .perplexity, .abacus:
+ .kimik2, .jetbrains, .perplexity, .abacus, .mistral:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
@@ -898,7 +904,7 @@ extension UsageStore {
let sourceMode = self.sourceMode(for: .claude)
let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil)
let environment = ProviderRegistry.makeEnvironment(
- base: ProcessInfo.processInfo.environment,
+ base: self.environmentBase,
provider: .claude,
settings: self.settings,
tokenOverride: nil)
diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift
index fe42751cb..160d0ef13 100644
--- a/Sources/CodexBarCLI/CLIIO.swift
+++ b/Sources/CodexBarCLI/CLIIO.swift
@@ -12,7 +12,7 @@ extension CodexBarCLI {
}
static func printVersion() -> Never {
- if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
+ if let version = currentVersion() {
print("CodexBar \(version)")
} else {
print("CodexBar")
@@ -21,7 +21,7 @@ extension CodexBarCLI {
}
static func printHelp(for command: String?) -> Never {
- let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
+ let version = self.currentVersion() ?? "unknown"
switch command {
case "usage":
print(Self.usageHelp(version: version))
@@ -35,6 +35,39 @@ extension CodexBarCLI {
Self.platformExit(0)
}
+ static func currentVersion(
+ bundle: Bundle = .main,
+ executablePath: String? = CommandLine.arguments.first) -> String?
+ {
+ if let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String {
+ return version
+ }
+ guard let executablePath, !executablePath.isEmpty else { return nil }
+
+ let executableURL = URL(fileURLWithPath: executablePath).resolvingSymlinksInPath()
+ return Self.containingAppVersion(for: executableURL)
+ }
+
+ static func containingAppVersion(for executableURL: URL) -> String? {
+ var currentURL = executableURL.deletingLastPathComponent()
+ let fileManager = FileManager.default
+
+ while currentURL.path != currentURL.deletingLastPathComponent().path {
+ if currentURL.pathExtension == "app" {
+ let infoURL = currentURL
+ .appendingPathComponent("Contents")
+ .appendingPathComponent("Info.plist")
+ guard let data = fileManager.contents(atPath: infoURL.path),
+ let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]
+ else { return nil }
+ return plist["CFBundleShortVersionString"] as? String
+ }
+ currentURL.deleteLastPathComponent()
+ }
+
+ return nil
+ }
+
static func platformExit(_ code: Int32) -> Never {
#if canImport(Darwin)
Darwin.exit(code)
diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift
index 211cb9d52..e22260c8d 100644
--- a/Sources/CodexBarCLI/CLIRenderer.swift
+++ b/Sources/CodexBarCLI/CLIRenderer.swift
@@ -199,7 +199,7 @@ enum CLIRenderer {
now: Date,
lines: inout [String])
{
- if provider == .warp || provider == .kilo {
+ if provider == .warp || provider == .kilo || provider == .mistral {
if let reset = self.resetLineForDetailBackedWindow(window: window, style: context.resetStyle, now: now) {
lines.append(self.subtleLine(reset, useColor: context.useColor))
}
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index e43073e82..5d4f9cba2 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -193,6 +193,13 @@ struct TokenAccountCLIContext {
abacus: ProviderSettingsSnapshot.AbacusProviderSettings(
cookieSource: cookieSource,
manualCookieHeader: cookieHeader))
+ case .mistral:
+ let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config)
+ let cookieSource = self.cookieSource(provider: provider, account: account, config: config)
+ return self.makeSnapshot(
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: cookieSource,
+ manualCookieHeader: cookieHeader))
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
return nil
}
@@ -215,7 +222,8 @@ struct TokenAccountCLIContext {
ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil,
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil,
perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil,
- abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot
+ abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil,
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot.make(
codex: codex,
@@ -234,7 +242,8 @@ struct TokenAccountCLIContext {
ollama: ollama,
jetbrains: jetbrains,
perplexity: perplexity,
- abacus: abacus)
+ abacus: abacus,
+ mistral: mistral)
}
private func makeCodexSettingsSnapshot(account: ProviderTokenAccount?) ->
diff --git a/Sources/CodexBarCore/AppGroupSupport.swift b/Sources/CodexBarCore/AppGroupSupport.swift
new file mode 100644
index 000000000..b1b346d34
--- /dev/null
+++ b/Sources/CodexBarCore/AppGroupSupport.swift
@@ -0,0 +1,247 @@
+import Foundation
+#if os(macOS)
+import Security
+#endif
+
+public enum AppGroupSupport {
+ public static let defaultTeamID = "Y5PE65HELJ"
+ public static let teamIDInfoKey = "CodexBarTeamID"
+ public static let legacyReleaseGroupID = "group.com.steipete.codexbar"
+ public static let legacyDebugGroupID = "group.com.steipete.codexbar.debug"
+ public static let widgetSnapshotFilename = "widget-snapshot.json"
+ public static let migrationVersion = 1
+ public static let migrationVersionKey = "appGroupMigrationVersion"
+ private static let sharedDefaultsMigrationKeys = [
+ "debugDisableKeychainAccess",
+ "widgetSelectedProvider",
+ ]
+
+ public struct MigrationResult: Sendable {
+ public enum Status: String, Sendable {
+ case alreadyCompleted
+ case targetUnavailable
+ case noChangesNeeded
+ case migrated
+ }
+
+ public let status: Status
+ public let copiedSnapshot: Bool
+ public let copiedDefaults: Int
+
+ public init(status: Status, copiedSnapshot: Bool = false, copiedDefaults: Int = 0) {
+ self.status = status
+ self.copiedSnapshot = copiedSnapshot
+ self.copiedDefaults = copiedDefaults
+ }
+ }
+
+ public static func currentGroupID(for bundleID: String? = Bundle.main.bundleIdentifier) -> String {
+ self.currentGroupID(teamID: self.resolvedTeamID(), bundleID: bundleID)
+ }
+
+ static func currentGroupID(teamID: String, bundleID: String?) -> String {
+ let base = "\(teamID).com.steipete.codexbar"
+ return self.isDebugBundleID(bundleID) ? "\(base).debug" : base
+ }
+
+ public static func resolvedTeamID(bundle: Bundle = .main) -> String {
+ self.resolvedTeamID(
+ infoDictionaryOverride: bundle.infoDictionary,
+ bundleURLOverride: bundle.bundleURL)
+ }
+
+ static func resolvedTeamID(
+ infoDictionaryOverride: [String: Any]?,
+ bundleURLOverride: URL?) -> String
+ {
+ if let teamID = self.codeSignatureTeamID(bundleURL: bundleURLOverride) {
+ return teamID
+ }
+ if let teamID = infoDictionaryOverride?[self.teamIDInfoKey] as? String,
+ !teamID.isEmpty
+ {
+ return teamID
+ }
+ return self.defaultTeamID
+ }
+
+ public static func legacyGroupID(for bundleID: String? = Bundle.main.bundleIdentifier) -> String {
+ self.isDebugBundleID(bundleID) ? self.legacyDebugGroupID : self.legacyReleaseGroupID
+ }
+
+ public static func sharedDefaults(
+ bundleID: String? = Bundle.main.bundleIdentifier,
+ fileManager: FileManager = .default)
+ -> UserDefaults?
+ {
+ guard self.currentContainerURL(bundleID: bundleID, fileManager: fileManager) != nil else { return nil }
+ return UserDefaults(suiteName: self.currentGroupID(for: bundleID))
+ }
+
+ public static func currentContainerURL(
+ bundleID: String? = Bundle.main.bundleIdentifier,
+ fileManager: FileManager = .default)
+ -> URL?
+ {
+ #if os(macOS)
+ fileManager.containerURL(forSecurityApplicationGroupIdentifier: self.currentGroupID(for: bundleID))
+ #else
+ nil
+ #endif
+ }
+
+ public static func snapshotURL(
+ bundleID: String? = Bundle.main.bundleIdentifier,
+ fileManager: FileManager = .default,
+ homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser)
+ -> URL
+ {
+ if let container = self.currentContainerURL(bundleID: bundleID, fileManager: fileManager) {
+ return container.appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false)
+ }
+
+ let directory = self.localFallbackDirectory(fileManager: fileManager, homeDirectory: homeDirectory)
+ return directory.appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false)
+ }
+
+ public static func localFallbackDirectory(
+ fileManager: FileManager = .default,
+ homeDirectory _: URL = FileManager.default.homeDirectoryForCurrentUser)
+ -> URL
+ {
+ let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+ ?? fileManager.temporaryDirectory
+ let directory = base.appendingPathComponent("CodexBar", isDirectory: true)
+ try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+ return directory
+ }
+
+ public static func legacyContainerCandidateURL(
+ bundleID: String? = Bundle.main.bundleIdentifier,
+ homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser)
+ -> URL
+ {
+ homeDirectory
+ .appendingPathComponent("Library", isDirectory: true)
+ .appendingPathComponent("Group Containers", isDirectory: true)
+ .appendingPathComponent(self.legacyGroupID(for: bundleID), isDirectory: true)
+ }
+
+ public static func migrateLegacyDataIfNeeded(
+ bundleID: String? = Bundle.main.bundleIdentifier,
+ standardDefaults: UserDefaults = .standard,
+ fileManager: FileManager = .default,
+ homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
+ currentDefaultsOverride: UserDefaults? = nil,
+ legacyDefaultsOverride: UserDefaults? = nil,
+ currentSnapshotURLOverride: URL? = nil,
+ legacySnapshotURLOverride: URL? = nil)
+ -> MigrationResult
+ {
+ if standardDefaults.integer(forKey: self.migrationVersionKey) >= self.migrationVersion {
+ return MigrationResult(status: .alreadyCompleted)
+ }
+
+ guard let currentDefaults = currentDefaultsOverride ?? self.sharedDefaults(
+ bundleID: bundleID,
+ fileManager: fileManager)
+ else {
+ return MigrationResult(status: .targetUnavailable)
+ }
+
+ let legacyDefaults = legacyDefaultsOverride ?? UserDefaults(suiteName: self.legacyGroupID(for: bundleID))
+ let currentSnapshotURL = currentSnapshotURLOverride
+ ?? self.currentContainerURL(bundleID: bundleID, fileManager: fileManager)?
+ .appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false)
+ let legacySnapshotURL = legacySnapshotURLOverride
+ ?? self.legacyContainerCandidateURL(bundleID: bundleID, homeDirectory: homeDirectory)
+ .appendingPathComponent(self.widgetSnapshotFilename, isDirectory: false)
+
+ let copiedSnapshot = {
+ guard let currentSnapshotURL else { return false }
+ guard !fileManager.fileExists(atPath: currentSnapshotURL.path),
+ fileManager.fileExists(atPath: legacySnapshotURL.path)
+ else {
+ return false
+ }
+ do {
+ try fileManager.createDirectory(
+ at: currentSnapshotURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true)
+ try fileManager.copyItem(at: legacySnapshotURL, to: currentSnapshotURL)
+ return true
+ } catch {
+ return false
+ }
+ }()
+
+ let copiedDefaults = self.copyLegacySharedDefaults(
+ from: legacyDefaults,
+ to: currentDefaults)
+
+ let result = if copiedSnapshot || copiedDefaults > 0 {
+ MigrationResult(
+ status: .migrated,
+ copiedSnapshot: copiedSnapshot,
+ copiedDefaults: copiedDefaults)
+ } else {
+ MigrationResult(status: .noChangesNeeded)
+ }
+
+ standardDefaults.set(self.migrationVersion, forKey: self.migrationVersionKey)
+ return result
+ }
+
+ private static func copyLegacySharedDefaults(
+ from legacyDefaults: UserDefaults?,
+ to currentDefaults: UserDefaults) -> Int
+ {
+ guard let legacyDefaults else { return 0 }
+
+ var copied = 0
+ for key in self.sharedDefaultsMigrationKeys {
+ guard currentDefaults.object(forKey: key) == nil,
+ let legacyValue = legacyDefaults.object(forKey: key)
+ else {
+ continue
+ }
+ currentDefaults.set(legacyValue, forKey: key)
+ copied += 1
+ }
+ return copied
+ }
+
+ private static func isDebugBundleID(_ bundleID: String?) -> Bool {
+ guard let bundleID, !bundleID.isEmpty else { return false }
+ return bundleID.contains(".debug")
+ }
+
+ private static func codeSignatureTeamID(bundleURL: URL?) -> String? {
+ #if os(macOS)
+ guard let bundleURL else { return nil }
+
+ var staticCode: SecStaticCode?
+ guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess,
+ let code = staticCode
+ else {
+ return nil
+ }
+
+ var infoCF: CFDictionary?
+ guard SecCodeCopySigningInformation(
+ code,
+ SecCSFlags(rawValue: kSecCSSigningInformation),
+ &infoCF) == errSecSuccess,
+ let info = infoCF as? [String: Any],
+ let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String,
+ !teamID.isEmpty
+ else {
+ return nil
+ }
+ return teamID
+ #else
+ _ = bundleURL
+ return nil
+ #endif
+ }
+}
diff --git a/Sources/CodexBarCore/CookieHeaderCache.swift b/Sources/CodexBarCore/CookieHeaderCache.swift
index 0d24cde93..dae36ebf2 100644
--- a/Sources/CodexBarCore/CookieHeaderCache.swift
+++ b/Sources/CodexBarCore/CookieHeaderCache.swift
@@ -36,6 +36,9 @@ public enum CookieHeaderCache {
case let .found(entry):
self.log.debug("Cookie cache hit", metadata: ["provider": provider.rawValue])
return entry
+ case .temporarilyUnavailable:
+ self.log.debug("Cookie cache temporarily unavailable", metadata: ["provider": provider.rawValue])
+ return nil
case .invalid:
self.log.warning("Cookie cache invalid; clearing", metadata: ["provider": provider.rawValue])
KeychainCacheStore.clear(key: key)
diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift
index d55bff859..7608ef11b 100644
--- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift
+++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift
@@ -175,6 +175,19 @@ public struct TTYCommandRunner {
}
}
+ @discardableResult
+ static func registerActiveProcessForAppShutdown(pid: pid_t, binary: String) -> Bool {
+ TTYCommandRunnerActiveProcessRegistry.register(pid: pid, binary: binary)
+ }
+
+ static func updateActiveProcessGroupForAppShutdown(pid: pid_t, processGroup: pid_t?) {
+ TTYCommandRunnerActiveProcessRegistry.updateProcessGroup(pid: pid, processGroup: processGroup)
+ }
+
+ static func unregisterActiveProcessForAppShutdown(pid: pid_t) {
+ TTYCommandRunnerActiveProcessRegistry.unregister(pid: pid)
+ }
+
private static func resolveShutdownTargets(
_ targets: [(pid: pid_t, binary: String, processGroup: pid_t?)],
hostProcessGroup: pid_t,
diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift
index 548a4add4..86451a75d 100644
--- a/Sources/CodexBarCore/KeychainAccessGate.swift
+++ b/Sources/CodexBarCore/KeychainAccessGate.swift
@@ -5,7 +5,6 @@ import SweetCookieKit
public enum KeychainAccessGate {
private static let flagKey = "debugDisableKeychainAccess"
- private static let appGroupID = "group.com.steipete.codexbar"
@TaskLocal private static var taskOverrideValue: Bool?
private nonisolated(unsafe) static var overrideValue: Bool?
@@ -19,9 +18,7 @@ public enum KeychainAccessGate {
#endif
if let overrideValue { return overrideValue }
if UserDefaults.standard.bool(forKey: Self.flagKey) { return true }
- if let shared = UserDefaults(suiteName: Self.appGroupID),
- shared.bool(forKey: Self.flagKey)
- {
+ if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) {
return true
}
return false
diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift
index ebe5c45ae..ad5b9db37 100644
--- a/Sources/CodexBarCore/KeychainCacheStore.swift
+++ b/Sources/CodexBarCore/KeychainCacheStore.swift
@@ -21,6 +21,7 @@ public enum KeychainCacheStore {
public enum LoadResult {
case found(Entry)
case missing
+ case temporarilyUnavailable
case invalid
}
@@ -29,6 +30,9 @@ public enum KeychainCacheStore {
private static let cacheLabel = "CodexBar Cache"
private nonisolated(unsafe) static var globalServiceOverride: String?
@TaskLocal private static var serviceOverride: String?
+ #if DEBUG && os(macOS)
+ @TaskLocal private static var loadFailureStatusOverride: OSStatus?
+ #endif
private static let testStoreLock = NSLock()
private struct TestStoreKey: Hashable {
let service: String
@@ -42,17 +46,23 @@ public enum KeychainCacheStore {
key: Key,
as type: Entry.Type = Entry.self) -> LoadResult
{
+ #if DEBUG && os(macOS)
+ if let status = self.loadFailureStatusOverride {
+ return self.loadResultForKeychainReadFailure(status: status, key: key)
+ }
+ #endif
if let testResult = loadFromTestStore(key: key, as: type) {
return testResult
}
#if os(macOS)
- let query: [String: Any] = [
+ var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: self.serviceName,
kSecAttrAccount as String: key.account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
+ KeychainNoUIQuery.apply(to: &query)
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
@@ -68,11 +78,8 @@ public enum KeychainCacheStore {
return .invalid
}
return .found(decoded)
- case errSecItemNotFound:
- return .missing
default:
- self.log.error("Keychain cache read failed (\(key.account)): \(status)")
- return .invalid
+ return self.loadResultForKeychainReadFailure(status: status, key: key)
}
#else
return .missing
@@ -172,6 +179,17 @@ public enum KeychainCacheStore {
self.serviceOverride
}
+ #if DEBUG && os(macOS)
+ public static func withLoadFailureStatusOverrideForTesting(
+ _ status: OSStatus?,
+ operation: () throws -> T) rethrows -> T
+ {
+ try self.$loadFailureStatusOverride.withValue(status) {
+ try operation()
+ }
+ }
+ #endif
+
static func setTestStoreForTesting(_ enabled: Bool) {
self.testStoreLock.lock()
defer { self.testStoreLock.unlock() }
@@ -204,6 +222,25 @@ public enum KeychainCacheStore {
return decoder
}
+ #if os(macOS)
+ static func loadResultForKeychainReadFailure(
+ status: OSStatus,
+ key: Key) -> LoadResult
+ {
+ switch status {
+ case errSecItemNotFound:
+ return .missing
+ case errSecInteractionNotAllowed:
+ // Keychain is temporarily locked, e.g. immediately after wake from sleep.
+ self.log.info("Keychain cache temporarily locked (\(key.account)), will retry on next access")
+ return .temporarilyUnavailable
+ default:
+ self.log.error("Keychain cache read failed (\(key.account)): \(status)")
+ return .invalid
+ }
+ }
+ #endif
+
private static func loadFromTestStore(
key: Key,
as type: Entry.Type) -> LoadResult?
diff --git a/Sources/CodexBarCore/Logging/CompositeLogHandler.swift b/Sources/CodexBarCore/Logging/CompositeLogHandler.swift
index f86aebc1c..51f989ea1 100644
--- a/Sources/CodexBarCore/Logging/CompositeLogHandler.swift
+++ b/Sources/CodexBarCore/Logging/CompositeLogHandler.swift
@@ -33,31 +33,8 @@ struct CompositeLogHandler: LogHandler {
}
}
- // swiftlint:disable:next function_parameter_count
- func log(
- level: Logger.Level,
- message: Logger.Message,
- metadata: Logger.Metadata?,
- source: String,
- file: String,
- function: String,
- line: UInt)
- {
- self.primary.log(
- level: level,
- message: message,
- metadata: metadata,
- source: source,
- file: file,
- function: function,
- line: line)
- self.secondary.log(
- level: level,
- message: message,
- metadata: metadata,
- source: source,
- file: file,
- function: function,
- line: line)
+ func log(event: LogEvent) {
+ self.primary.log(event: event)
+ self.secondary.log(event: event)
}
}
diff --git a/Sources/CodexBarCore/Logging/FileLogHandler.swift b/Sources/CodexBarCore/Logging/FileLogHandler.swift
index effe9ff5c..e43211626 100644
--- a/Sources/CodexBarCore/Logging/FileLogHandler.swift
+++ b/Sources/CodexBarCore/Logging/FileLogHandler.swift
@@ -103,19 +103,10 @@ struct FileLogHandler: LogHandler {
set { self.metadata[metadataKey] = newValue }
}
- // swiftlint:disable:next function_parameter_count
- func log(
- level: Logger.Level,
- message: Logger.Message,
- metadata: Logger.Metadata?,
- source: String,
- file: String,
- function: String,
- line: UInt)
- {
+ func log(event: LogEvent) {
let ts = Self.timestamp()
var combined = self.metadata
- if let metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) }
+ if let metadata = event.metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) }
var metaText = ""
if !combined.isEmpty {
let pairs = combined
@@ -128,12 +119,12 @@ struct FileLogHandler: LogHandler {
.joined(separator: " ")
metaText = " \(pairs)"
}
- let safeMessage = LogRedactor.redact("\(message)")
- let lineText = "[\(ts)] [\(level.rawValue.uppercased())] \(self.label): \(safeMessage)\(metaText)\n"
- _ = source
- _ = file
- _ = function
- _ = line
+ let safeMessage = LogRedactor.redact("\(event.message)")
+ let lineText = "[\(ts)] [\(event.level.rawValue.uppercased())] \(self.label): \(safeMessage)\(metaText)\n"
+ _ = event.source
+ _ = event.file
+ _ = event.function
+ _ = event.line
self.sink.write(lineText)
}
diff --git a/Sources/CodexBarCore/Logging/JSONStderrLogHandler.swift b/Sources/CodexBarCore/Logging/JSONStderrLogHandler.swift
index 6c02e745e..4d5c5d394 100644
--- a/Sources/CodexBarCore/Logging/JSONStderrLogHandler.swift
+++ b/Sources/CodexBarCore/Logging/JSONStderrLogHandler.swift
@@ -25,29 +25,20 @@ struct JSONStderrLogHandler: LogHandler {
set { self.metadata[metadataKey] = newValue }
}
- // swiftlint:disable:next function_parameter_count
- func log(
- level: Logger.Level,
- message: Logger.Message,
- metadata: Logger.Metadata?,
- source: String,
- file: String,
- function: String,
- line: UInt)
- {
+ func log(event: LogEvent) {
let ts = Date()
var combined = self.metadata
- if let metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) }
+ if let metadata = event.metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) }
let payload = JSONLogLine(
timestamp: ts,
- level: level.rawValue,
+ level: event.level.rawValue,
label: self.label,
- message: message.description,
- source: source,
- file: file,
- function: function,
- line: line,
+ message: event.message.description,
+ source: event.source,
+ file: event.file,
+ function: event.function,
+ line: event.line,
metadata: combined.isEmpty ? nil : combined.mapValues(\.description))
guard let data = try? self.encoder.encode(payload),
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index 90a7175d2..fba33c0ce 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -14,6 +14,7 @@ public enum LogCategories {
public static let codexRPC = "codex-rpc"
public static let configMigration = "config-migration"
public static let configStore = "config-store"
+ public static let confetti = "confetti"
public static let cookieCache = "cookie-cache"
public static let cookieHeaderStore = "cookie-header-store"
public static let copilotTokenStore = "copilot-token-store"
diff --git a/Sources/CodexBarCore/Logging/OSLogLogHandler.swift b/Sources/CodexBarCore/Logging/OSLogLogHandler.swift
index 3a1b28754..be0dc2509 100644
--- a/Sources/CodexBarCore/Logging/OSLogLogHandler.swift
+++ b/Sources/CodexBarCore/Logging/OSLogLogHandler.swift
@@ -23,24 +23,15 @@ struct OSLogLogHandler: LogHandler {
set { self.metadata[metadataKey] = newValue }
}
- // swiftlint:disable:next function_parameter_count
- func log(
- level: Logging.Logger.Level,
- message: Logging.Logger.Message,
- metadata: Logging.Logger.Metadata?,
- source: String,
- file: String,
- function: String,
- line: UInt)
- {
+ func log(event: LogEvent) {
let msg = Self.decorate(
- message: message.description,
+ message: event.message.description,
label: self.label,
subsystem: self.subsystem,
metadata: self.metadata,
- extraMetadata: metadata)
+ extraMetadata: event.metadata)
- switch level {
+ switch event.level {
case .trace:
self.logger.debug("\(msg, privacy: .public)")
case .debug:
diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift
index 200193f62..bf36e53cf 100644
--- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift
+++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift
@@ -105,6 +105,7 @@ public struct OpenAIDashboardBrowserCookieImporter {
public func importBestCookies(
intoAccountEmail targetEmail: String?,
allowAnyAccount: Bool = false,
+ preferCachedCookieHeader: Bool = true,
cacheScope: CookieHeaderCache.Scope? = nil,
logger: ((String) -> Void)? = nil) async throws -> ImportResult
{
@@ -130,27 +131,31 @@ public struct OpenAIDashboardBrowserCookieImporter {
var diagnostics = ImportDiagnostics()
- if let cached = CookieHeaderCache.load(provider: .codex, scope: cacheScope),
- !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- {
- log("Using cached cookie header from \(cached.sourceLabel)")
- do {
- return try await self.importManualCookies(
- cookieHeader: cached.cookieHeader,
- intoAccountEmail: context.targetEmail,
- allowAnyAccount: context.allowAnyAccount,
- cacheScope: cacheScope,
- logger: log)
- } catch let error as ImportError {
- switch error {
- case .manualCookieHeaderInvalid, .noMatchingAccount, .dashboardStillRequiresLogin:
- CookieHeaderCache.clear(provider: .codex, scope: cacheScope)
- default:
+ if preferCachedCookieHeader {
+ if let cached = CookieHeaderCache.load(provider: .codex, scope: cacheScope),
+ !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ {
+ log("Using cached cookie header from \(cached.sourceLabel)")
+ do {
+ return try await self.importManualCookies(
+ cookieHeader: cached.cookieHeader,
+ intoAccountEmail: context.targetEmail,
+ allowAnyAccount: context.allowAnyAccount,
+ cacheScope: cacheScope,
+ logger: log)
+ } catch let error as ImportError {
+ switch error {
+ case .manualCookieHeaderInvalid, .noMatchingAccount, .dashboardStillRequiresLogin:
+ CookieHeaderCache.clear(provider: .codex, scope: cacheScope)
+ default:
+ throw error
+ }
+ } catch {
throw error
}
- } catch {
- throw error
}
+ } else {
+ log("Skipping cached cookie header; forcing fresh browser import")
}
// Filter to cookie-eligible browsers to avoid unnecessary keychain prompts
@@ -606,15 +611,11 @@ public struct OpenAIDashboardBrowserCookieImporter {
// Validate against the persistent store (login + email sync).
do {
- defer {
- // The probe is only a validation step. Start the real dashboard scrape with a
- // fresh WKWebView instead of reusing the probe instance.
- OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent)
- }
let probe = try await OpenAIDashboardFetcher().probeUsagePage(
websiteDataStore: persistent,
logger: logger,
- timeout: 20)
+ timeout: 20,
+ preserveLoadedPageForReuse: true)
let signed = probe.signedInEmail?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
let matches = signed?.lowercased() == targetEmail.lowercased()
logger("Persistent session signed in as: \(signed ?? "unknown")")
@@ -630,8 +631,12 @@ public struct OpenAIDashboardBrowserCookieImporter {
signedInEmail: signed,
matchesCodexEmail: matches)
} catch OpenAIDashboardFetcher.FetchError.loginRequired {
+ OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent)
logger("Selected \(candidate.label) but dashboard still requires login.")
throw ImportError.dashboardStillRequiresLogin
+ } catch {
+ OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent)
+ throw error
}
}
@@ -644,15 +649,11 @@ public struct OpenAIDashboardBrowserCookieImporter {
await self.setCookies(candidate.cookies, into: persistent)
do {
- defer {
- // The probe is only a validation step. Start the real dashboard scrape with a
- // fresh WKWebView instead of reusing the probe instance.
- OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent)
- }
let probe = try await OpenAIDashboardFetcher().probeUsagePage(
websiteDataStore: persistent,
logger: logger,
- timeout: 20)
+ timeout: 20,
+ preserveLoadedPageForReuse: true)
let signed = probe.signedInEmail?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
logger("Persistent session signed in as: \(signed ?? "unknown")")
return ImportResult(
@@ -661,8 +662,12 @@ public struct OpenAIDashboardBrowserCookieImporter {
signedInEmail: signed,
matchesCodexEmail: false)
} catch OpenAIDashboardFetcher.FetchError.loginRequired {
+ OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent)
logger("Selected \(candidate.label) but dashboard still requires login.")
throw ImportError.dashboardStillRequiresLogin
+ } catch {
+ OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: persistent)
+ throw error
}
}
@@ -816,6 +821,7 @@ public struct OpenAIDashboardBrowserCookieImporter {
public func importBestCookies(
intoAccountEmail _: String?,
allowAnyAccount _: Bool = false,
+ preferCachedCookieHeader _: Bool = true,
cacheScope _: CookieHeaderCache.Scope? = nil,
logger _: ((String) -> Void)? = nil) async throws -> ImportResult
{
diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift
index fe654bfab..71ab76628 100644
--- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift
+++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift
@@ -19,7 +19,7 @@ public struct OpenAIDashboardFetcher {
}
}
- private let usageURL = URL(string: "https://chatgpt.com/codex/settings/usage")!
+ private let usageURL = URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")!
public init() {}
@@ -97,6 +97,22 @@ public struct OpenAIDashboardFetcher {
}
}
+ nonisolated static func shouldPreserveLoadedPageAfterProbe(_ result: ProbeResult) -> Bool {
+ guard !result.loginRequired, !result.workspacePicker, !result.cloudflareInterstitial else {
+ return false
+ }
+
+ guard self.isUsageRoute(result.href) else { return false }
+
+ guard let signedInEmail = result.signedInEmail?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !signedInEmail.isEmpty
+ else {
+ return false
+ }
+
+ return true
+ }
+
public func loadLatestDashboard(
accountEmail: String?,
logger: ((String) -> Void)? = nil,
@@ -342,13 +358,15 @@ public struct OpenAIDashboardFetcher {
public func probeUsagePage(
websiteDataStore: WKWebsiteDataStore,
logger: ((String) -> Void)? = nil,
- timeout: TimeInterval = 30) async throws -> ProbeResult
+ timeout: TimeInterval = 30,
+ preserveLoadedPageForReuse: Bool = false) async throws -> ProbeResult
{
let deadline = Self.deadline(startingAt: Date(), timeout: timeout)
let lease = try await self.makeWebView(
websiteDataStore: websiteDataStore,
logger: logger,
- timeout: Self.remainingTimeout(until: deadline))
+ timeout: Self.remainingTimeout(until: deadline),
+ preserveLoadedPageOnRelease: preserveLoadedPageForReuse)
defer { lease.release() }
let webView = lease.webView
let log = lease.log
@@ -410,23 +428,28 @@ public struct OpenAIDashboardFetcher {
continue
}
- return ProbeResult(
+ let result = ProbeResult(
href: scrape.href,
loginRequired: scrape.loginRequired,
workspacePicker: scrape.workspacePicker,
cloudflareInterstitial: scrape.cloudflareInterstitial,
signedInEmail: normalizedEmail,
bodyText: scrape.bodyText)
+ lease.setPreserveLoadedPageOnRelease(
+ preserveLoadedPageForReuse && Self.shouldPreserveLoadedPageAfterProbe(result))
+ return result
}
log("Probe timed out (href=\(lastHref ?? "nil"))")
- return ProbeResult(
+ let result = ProbeResult(
href: lastHref,
loginRequired: false,
workspacePicker: false,
cloudflareInterstitial: false,
signedInEmail: nil,
bodyText: lastBody)
+ lease.setPreserveLoadedPageOnRelease(false)
+ return result
}
// MARK: - JS scrape
@@ -530,13 +553,15 @@ public struct OpenAIDashboardFetcher {
private func makeWebView(
websiteDataStore: WKWebsiteDataStore,
logger: ((String) -> Void)?,
- timeout: TimeInterval) async throws -> OpenAIDashboardWebViewLease
+ timeout: TimeInterval,
+ preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease
{
try await OpenAIDashboardWebViewCache.shared.acquire(
websiteDataStore: websiteDataStore,
usageURL: self.usageURL,
logger: logger,
- navigationTimeout: timeout)
+ navigationTimeout: timeout,
+ preserveLoadedPageOnRelease: preserveLoadedPageOnRelease)
}
nonisolated static func sanitizedTimeout(_ timeout: TimeInterval) -> TimeInterval {
@@ -556,7 +581,10 @@ public struct OpenAIDashboardFetcher {
guard let href, !href.isEmpty else { return false }
let path = (URL(string: href)?.path ?? href)
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
- return path.hasSuffix("codex/settings/usage") || path.hasSuffix("codex/cloud/settings/usage")
+ return path.hasSuffix("codex/settings/usage")
+ || path.hasSuffix("codex/cloud/settings/usage")
+ || path.hasSuffix("codex/settings/analytics")
+ || path.hasSuffix("codex/cloud/settings/analytics")
}
private static func writeDebugArtifacts(html: String, bodyText: String?, logger: (String) -> Void) {
diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift
index 397e41138..1a90d2816 100644
--- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift
+++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift
@@ -6,6 +6,7 @@ import WebKit
struct OpenAIDashboardWebViewLease {
let webView: WKWebView
let log: (String) -> Void
+ let setPreserveLoadedPageOnRelease: (Bool) -> Void
let release: () -> Void
}
@@ -14,17 +15,67 @@ final class OpenAIDashboardWebViewCache {
static let shared = OpenAIDashboardWebViewCache()
fileprivate static let log = CodexBarLog.logger(LogCategories.openAIWebview)
+ private final class ReleaseState {
+ var preserveLoadedPageOnRelease: Bool
+
+ init(preserveLoadedPageOnRelease: Bool) {
+ self.preserveLoadedPageOnRelease = preserveLoadedPageOnRelease
+ }
+ }
+
+ private struct AcquireOptions {
+ let allowTimeoutRetry: Bool
+ let preserveLoadedPageOnRelease: Bool
+ }
+
private final class Entry {
let webView: WKWebView
let host: OffscreenWebViewHost
var lastUsedAt: Date
var isBusy: Bool
-
- init(webView: WKWebView, host: OffscreenWebViewHost, lastUsedAt: Date, isBusy: Bool) {
+ var preservedPageExpiresAt: Date?
+ var preservedPageExpiryTask: Task?
+
+ init(
+ webView: WKWebView,
+ host: OffscreenWebViewHost,
+ lastUsedAt: Date,
+ isBusy: Bool,
+ preservedPageExpiresAt: Date? = nil)
+ {
self.webView = webView
self.host = host
self.lastUsedAt = lastUsedAt
self.isBusy = isBusy
+ self.preservedPageExpiresAt = preservedPageExpiresAt
+ }
+
+ func armPreservedPage(until expiry: Date) {
+ self.preservedPageExpiresAt = expiry
+ }
+
+ func setPreservedPageExpiryTask(_ task: Task?) {
+ self.preservedPageExpiryTask?.cancel()
+ self.preservedPageExpiryTask = task
+ }
+
+ func clearPreservedPage() {
+ self.preservedPageExpiresAt = nil
+ self.preservedPageExpiryTask?.cancel()
+ self.preservedPageExpiryTask = nil
+ }
+
+ func consumePreservedPageReuseIfAvailable(now: Date) -> Bool {
+ guard let preservedPageExpiresAt else { return false }
+ self.preservedPageExpiresAt = nil
+ self.preservedPageExpiryTask?.cancel()
+ self.preservedPageExpiryTask = nil
+ return preservedPageExpiresAt > now
+ }
+
+ func hasExpiredPreservedPage(now: Date) -> Bool {
+ guard let preservedPageExpiresAt else { return false }
+ return preservedPageExpiresAt <= now
}
}
@@ -32,19 +83,41 @@ final class OpenAIDashboardWebViewCache {
/// Keep the WebView alive only long enough for immediate retries/menu reopens.
/// Long-lived hidden ChatGPT tabs still consume noticeable energy on some setups.
private let idleTimeout: TimeInterval = 60
+ /// Reuse the validated analytics page only for the immediate next handoff.
+ private let preservedPageHandoffTimeout: TimeInterval = 5
private let blankURL = URL(string: "about:blank")!
-
- private func releaseCachedEntry(_ entry: Entry) {
+ private let reusablePageResetScript = """
+ (() => {
+ try {
+ delete window.__codexbarDidScrollToCredits;
+ delete window.__codexbarUsageBreakdownJSON;
+ delete window.__codexbarUsageBreakdownDebug;
+ return true;
+ } catch {
+ return false;
+ }
+ })();
+ """
+
+ private func releaseCachedEntry(_ entry: Entry, preserveLoadedPage: Bool) {
entry.isBusy = false
entry.lastUsedAt = Date()
- self.prepareCachedWebViewForIdle(entry.webView, host: entry.host)
+ self.updatePreservedPageState(for: entry, preserveLoadedPage: preserveLoadedPage)
+ self.prepareCachedWebViewForIdle(
+ entry.webView,
+ host: entry.host,
+ preserveLoadedPage: preserveLoadedPage)
self.prune(now: Date())
}
- private func releaseNewEntry(_ entry: Entry, webView: WKWebView) {
+ private func releaseNewEntry(_ entry: Entry, webView: WKWebView, preserveLoadedPage: Bool) {
entry.isBusy = false
entry.lastUsedAt = Date()
- self.prepareCachedWebViewForIdle(webView, host: entry.host)
+ self.updatePreservedPageState(for: entry, preserveLoadedPage: preserveLoadedPage)
+ self.prepareCachedWebViewForIdle(
+ webView,
+ host: entry.host,
+ preserveLoadedPage: preserveLoadedPage)
self.prune(now: Date())
}
@@ -71,6 +144,31 @@ final class OpenAIDashboardWebViewCache {
self.idleTimeout
}
+ var preservedPageHandoffTimeoutForTesting: TimeInterval {
+ self.preservedPageHandoffTimeout
+ }
+
+ func hasPreservedPageForTesting(for websiteDataStore: WKWebsiteDataStore) -> Bool {
+ let key = ObjectIdentifier(websiteDataStore)
+ return self.entries[key]?.preservedPageExpiresAt != nil
+ }
+
+ func markPreservedPageForTesting(
+ websiteDataStore: WKWebsiteDataStore,
+ expiresAt: Date = .init().addingTimeInterval(5))
+ {
+ let key = ObjectIdentifier(websiteDataStore)
+ guard let entry = self.entries[key] else { return }
+ entry.armPreservedPage(until: expiresAt)
+ self.schedulePreservedPageExpiry(for: key, entry: entry, expiresAt: expiresAt)
+ }
+
+ func consumePreservedPageForTesting(websiteDataStore: WKWebsiteDataStore, now: Date = Date()) -> Bool {
+ let key = ObjectIdentifier(websiteDataStore)
+ guard let entry = self.entries[key] else { return false }
+ return entry.consumePreservedPageReuseIfAvailable(now: now)
+ }
+
/// Seed a cached entry without navigating a real page (for test stability).
@discardableResult
func cacheEntryForTesting(
@@ -97,17 +195,23 @@ final class OpenAIDashboardWebViewCache {
/// Clear all cached entries (for test isolation).
func clearAllForTesting() {
for (_, entry) in self.entries {
+ entry.clearPreservedPage()
entry.host.close()
}
self.entries.removeAll()
}
+
+ func resetReusablePageStateForTesting(_ webView: WKWebView) async -> Bool {
+ await self.resetReusablePageState(webView)
+ }
#endif
func acquire(
websiteDataStore: WKWebsiteDataStore,
usageURL: URL,
logger: ((String) -> Void)?,
- navigationTimeout: TimeInterval = 15) async throws -> OpenAIDashboardWebViewLease
+ navigationTimeout: TimeInterval = 15,
+ preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease
{
let deadline = Date().addingTimeInterval(max(navigationTimeout, 1))
return try await self.acquire(
@@ -115,7 +219,9 @@ final class OpenAIDashboardWebViewCache {
usageURL: usageURL,
logger: logger,
deadline: deadline,
- allowTimeoutRetry: true)
+ options: .init(
+ allowTimeoutRetry: true,
+ preserveLoadedPageOnRelease: preserveLoadedPageOnRelease))
}
private func acquire(
@@ -123,7 +229,7 @@ final class OpenAIDashboardWebViewCache {
usageURL: URL,
logger: ((String) -> Void)?,
deadline: Date,
- allowTimeoutRetry: Bool) async throws -> OpenAIDashboardWebViewLease
+ options: AcquireOptions) async throws -> OpenAIDashboardWebViewLease
{
let now = Date()
self.prune(now: now)
@@ -140,9 +246,13 @@ final class OpenAIDashboardWebViewCache {
let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore)
host.show()
do {
- try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout)
+ try await self.prepareWebView(
+ webView,
+ usageURL: usageURL,
+ timeout: remainingTimeout,
+ canReuseLoadedPage: false)
} catch {
- if allowTimeoutRetry, Self.isPrepareTimeout(error) {
+ if options.allowTimeoutRetry, Self.isPrepareTimeout(error) {
host.close()
log("Temporary OpenAI WebView timed out; retrying with a fresh WebView.")
return try await self.acquireTemporaryWebView(
@@ -157,18 +267,26 @@ final class OpenAIDashboardWebViewCache {
return OpenAIDashboardWebViewLease(
webView: webView,
log: log,
+ setPreserveLoadedPageOnRelease: { _ in },
release: { host.close() })
}
entry.isBusy = true
entry.lastUsedAt = now
+ let canReuseLoadedPage = entry.consumePreservedPageReuseIfAvailable(now: now)
+ let releaseState = ReleaseState(preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease)
entry.host.show()
do {
- try await self.prepareWebView(entry.webView, usageURL: usageURL, timeout: remainingTimeout)
+ try await self.prepareWebView(
+ entry.webView,
+ usageURL: usageURL,
+ timeout: remainingTimeout,
+ canReuseLoadedPage: canReuseLoadedPage)
} catch {
- if allowTimeoutRetry, Self.isPrepareTimeout(error) {
+ if options.allowTimeoutRetry, Self.isPrepareTimeout(error) {
entry.isBusy = false
entry.lastUsedAt = Date()
+ entry.clearPreservedPage()
entry.host.close()
self.entries.removeValue(forKey: key)
log("Cached OpenAI WebView timed out; recreating it.")
@@ -177,10 +295,13 @@ final class OpenAIDashboardWebViewCache {
usageURL: usageURL,
logger: logger,
deadline: deadline,
- allowTimeoutRetry: false)
+ options: .init(
+ allowTimeoutRetry: false,
+ preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease))
}
entry.isBusy = false
entry.lastUsedAt = Date()
+ entry.clearPreservedPage()
entry.host.close()
self.entries.removeValue(forKey: key)
Self.log.warning("OpenAI webview prepare failed")
@@ -190,9 +311,14 @@ final class OpenAIDashboardWebViewCache {
return OpenAIDashboardWebViewLease(
webView: entry.webView,
log: log,
+ setPreserveLoadedPageOnRelease: { preserveLoadedPageOnRelease in
+ releaseState.preserveLoadedPageOnRelease = preserveLoadedPageOnRelease
+ },
release: { [weak self, weak entry] in
guard let self, let entry else { return }
- self.releaseCachedEntry(entry)
+ self.releaseCachedEntry(
+ entry,
+ preserveLoadedPage: releaseState.preserveLoadedPageOnRelease)
})
}
@@ -200,11 +326,16 @@ final class OpenAIDashboardWebViewCache {
let entry = Entry(webView: webView, host: host, lastUsedAt: now, isBusy: true)
self.entries[key] = entry
host.show()
+ let releaseState = ReleaseState(preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease)
do {
- try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout)
+ try await self.prepareWebView(
+ webView,
+ usageURL: usageURL,
+ timeout: remainingTimeout,
+ canReuseLoadedPage: false)
} catch {
- if allowTimeoutRetry, Self.isPrepareTimeout(error) {
+ if options.allowTimeoutRetry, Self.isPrepareTimeout(error) {
self.entries.removeValue(forKey: key)
host.close()
log("OpenAI WebView timed out during prepare; retrying once.")
@@ -213,7 +344,9 @@ final class OpenAIDashboardWebViewCache {
usageURL: usageURL,
logger: logger,
deadline: deadline,
- allowTimeoutRetry: false)
+ options: .init(
+ allowTimeoutRetry: false,
+ preserveLoadedPageOnRelease: options.preserveLoadedPageOnRelease))
}
self.entries.removeValue(forKey: key)
host.close()
@@ -224,15 +357,22 @@ final class OpenAIDashboardWebViewCache {
return OpenAIDashboardWebViewLease(
webView: webView,
log: log,
+ setPreserveLoadedPageOnRelease: { preserveLoadedPageOnRelease in
+ releaseState.preserveLoadedPageOnRelease = preserveLoadedPageOnRelease
+ },
release: { [weak self, weak entry] in
guard let self, let entry else { return }
- self.releaseNewEntry(entry, webView: webView)
+ self.releaseNewEntry(
+ entry,
+ webView: webView,
+ preserveLoadedPage: releaseState.preserveLoadedPageOnRelease)
})
}
func evict(websiteDataStore: WKWebsiteDataStore) {
let key = ObjectIdentifier(websiteDataStore)
guard let entry = self.entries.removeValue(forKey: key) else { return }
+ entry.clearPreservedPage()
Self.log.debug("OpenAI webview evicted")
entry.host.close()
}
@@ -241,6 +381,7 @@ final class OpenAIDashboardWebViewCache {
let existing = self.entries
self.entries.removeAll()
for (_, entry) in existing {
+ entry.clearPreservedPage()
entry.host.close()
}
if !existing.isEmpty {
@@ -248,17 +389,35 @@ final class OpenAIDashboardWebViewCache {
}
}
- private func prepareCachedWebViewForIdle(_ webView: WKWebView, host: OffscreenWebViewHost) {
+ private func prepareCachedWebViewForIdle(
+ _ webView: WKWebView,
+ host: OffscreenWebViewHost,
+ preserveLoadedPage: Bool)
+ {
+ webView.navigationDelegate = nil
+ webView.codexNavigationDelegate = nil
+ if preserveLoadedPage {
+ host.hide()
+ return
+ }
+
// Detach the heavyweight ChatGPT SPA as soon as a scrape completes. Keeping the WebView object around
// still helps with immediate reuse, but letting chatgpt.com remain the active document is too expensive.
webView.stopLoading()
- webView.navigationDelegate = nil
- webView.codexNavigationDelegate = nil
_ = webView.load(URLRequest(url: self.blankURL))
host.hide()
}
private func prune(now: Date) {
+ for entry in self.entries.values where !entry.isBusy && entry.hasExpiredPreservedPage(now: now) {
+ entry.clearPreservedPage()
+ self.prepareCachedWebViewForIdle(
+ entry.webView,
+ host: entry.host,
+ preserveLoadedPage: false)
+ Self.log.debug("OpenAI webview preserved page expired")
+ }
+
let expired = self.entries.filter { _, entry in
!entry.isBusy && now.timeIntervalSince(entry.lastUsedAt) > self.idleTimeout
}
@@ -281,7 +440,12 @@ final class OpenAIDashboardWebViewCache {
return (webView, host)
}
- private func prepareWebView(_ webView: WKWebView, usageURL: URL, timeout: TimeInterval) async throws {
+ private func prepareWebView(
+ _ webView: WKWebView,
+ usageURL: URL,
+ timeout: TimeInterval,
+ canReuseLoadedPage: Bool) async throws
+ {
#if DEBUG
if usageURL.absoluteString == "about:blank" {
_ = webView.loadHTMLString("", baseURL: nil)
@@ -289,6 +453,17 @@ final class OpenAIDashboardWebViewCache {
}
#endif
+ if canReuseLoadedPage,
+ let currentURL = webView.url?.absoluteString,
+ OpenAIDashboardFetcher.isUsageRoute(currentURL)
+ {
+ if await self.resetReusablePageState(webView) {
+ return
+ }
+
+ Self.log.debug("OpenAI preserved page reset failed; reloading usage URL")
+ }
+
try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in
let delegate = NavigationDelegate { result in
cont.resume(with: result)
@@ -310,7 +485,11 @@ final class OpenAIDashboardWebViewCache {
let (webView, host) = self.makeWebView(websiteDataStore: websiteDataStore)
host.show()
do {
- try await self.prepareWebView(webView, usageURL: usageURL, timeout: remainingTimeout)
+ try await self.prepareWebView(
+ webView,
+ usageURL: usageURL,
+ timeout: remainingTimeout,
+ canReuseLoadedPage: false)
} catch {
host.close()
throw error
@@ -318,6 +497,7 @@ final class OpenAIDashboardWebViewCache {
return OpenAIDashboardWebViewLease(
webView: webView,
log: log,
+ setPreserveLoadedPageOnRelease: { _ in },
release: { host.close() })
}
@@ -325,6 +505,60 @@ final class OpenAIDashboardWebViewCache {
let nsError = error as NSError
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
}
+
+ private func updatePreservedPageState(for entry: Entry, preserveLoadedPage: Bool) {
+ if preserveLoadedPage {
+ let expiresAt = Date().addingTimeInterval(self.preservedPageHandoffTimeout)
+ entry.armPreservedPage(until: expiresAt)
+ if let key = self.entries.first(where: { $0.value === entry })?.key {
+ self.schedulePreservedPageExpiry(for: key, entry: entry, expiresAt: expiresAt)
+ }
+ } else {
+ entry.clearPreservedPage()
+ }
+ }
+
+ private func schedulePreservedPageExpiry(
+ for key: ObjectIdentifier,
+ entry: Entry,
+ expiresAt: Date)
+ {
+ let delay = max(0, expiresAt.timeIntervalSinceNow)
+ let task = Task { @MainActor [weak self] in
+ try? await Task.sleep(for: .seconds(delay))
+ guard !Task.isCancelled else { return }
+ self?.expirePreservedPageIfNeeded(for: key, expectedExpiry: expiresAt)
+ }
+ entry.setPreservedPageExpiryTask(task)
+ }
+
+ private func expirePreservedPageIfNeeded(for key: ObjectIdentifier, expectedExpiry: Date) {
+ guard let entry = self.entries[key],
+ !entry.isBusy,
+ let preservedPageExpiresAt = entry.preservedPageExpiresAt,
+ preservedPageExpiresAt == expectedExpiry,
+ preservedPageExpiresAt <= Date()
+ else {
+ return
+ }
+
+ entry.clearPreservedPage()
+ self.prepareCachedWebViewForIdle(
+ entry.webView,
+ host: entry.host,
+ preserveLoadedPage: false)
+ Self.log.debug("OpenAI webview preserved page expired")
+ self.prune(now: Date())
+ }
+
+ private func resetReusablePageState(_ webView: WKWebView) async -> Bool {
+ do {
+ let any = try await webView.evaluateJavaScript(self.reusablePageResetScript)
+ return (any as? Bool) ?? true
+ } catch {
+ return false
+ }
+ }
}
@MainActor
diff --git a/Sources/CodexBarCore/ProviderCostSnapshot.swift b/Sources/CodexBarCore/ProviderCostSnapshot.swift
index c3d25af49..7941fb540 100644
--- a/Sources/CodexBarCore/ProviderCostSnapshot.swift
+++ b/Sources/CodexBarCore/ProviderCostSnapshot.swift
@@ -9,6 +9,8 @@ public struct ProviderCostSnapshot: Equatable, Codable, Sendable {
public let period: String?
/// Optional renewal/reset timestamp for the period.
public let resetsAt: Date?
+ /// Optional amount restored on the next regeneration tick for providers with rolling credit recovery.
+ public let nextRegenAmount: Double?
public let updatedAt: Date
public init(
@@ -17,6 +19,7 @@ public struct ProviderCostSnapshot: Equatable, Codable, Sendable {
currencyCode: String,
period: String? = nil,
resetsAt: Date? = nil,
+ nextRegenAmount: Double? = nil,
updatedAt: Date)
{
self.used = used
@@ -24,6 +27,7 @@ public struct ProviderCostSnapshot: Equatable, Codable, Sendable {
self.currencyCode = currencyCode
self.period = period
self.resetsAt = resetsAt
+ self.nextRegenAmount = nextRegenAmount
self.updatedAt = updatedAt
}
}
diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift
index 1e7e5b772..c3d94e2ec 100644
--- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift
+++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanAPIRegion.swift
@@ -47,7 +47,7 @@ public enum AlibabaCodingPlanAPIRegion: String, CaseIterable, Sendable {
case .international:
"MODELSTUDIO_ALIBABACLOUD"
case .chinaMainland:
- "BAILIAN_CONSOLE"
+ "BAILIAN_ALIYUN"
}
}
@@ -79,7 +79,7 @@ public enum AlibabaCodingPlanAPIRegion: String, CaseIterable, Sendable {
case .international:
"https://bailian-singapore-cs.alibabacloud.com"
case .chinaMainland:
- "https://bailian-beijing-cs.aliyuncs.com"
+ "https://bailian-cs.console.aliyun.com"
}
}
diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift
index ea354697a..9d8f9e27b 100644
--- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift
+++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift
@@ -13,6 +13,7 @@ public enum AlibabaCodingPlanCookieImporter {
private static let cookieClient = BrowserCookieClient()
private static let cookieDomains = [
"bailian-singapore-cs.alibabacloud.com",
+ "bailian-cs.console.aliyun.com",
"bailian-beijing-cs.aliyuncs.com",
"modelstudio.console.alibabacloud.com",
"bailian.console.aliyun.com",
diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift
index 359029d91..22f2600a0 100644
--- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift
+++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift
@@ -422,8 +422,8 @@ public struct AntigravityStatusProbe: Sendable {
let commandLine: String
}
- struct AntigravityConnectionEndpoint: Sendable, Equatable {
- enum Source: String, Sendable {
+ struct AntigravityConnectionEndpoint: Equatable {
+ enum Source: String {
case languageServer = "language-server"
case extensionServer = "extension-server"
}
@@ -665,9 +665,41 @@ public struct AntigravityStatusProbe: Sendable {
let ok = await testConnectivity(endpoint, timeout)
if ok { return endpoint }
}
+ if let fallback = fallbackProbeEndpoint(candidateEndpoints) {
+ self.log.debug("Port probe fell back to best-effort endpoint", metadata: [
+ "source": fallback.source.rawValue,
+ "scheme": fallback.scheme,
+ "port": "\(fallback.port)",
+ ])
+ return fallback
+ }
throw AntigravityStatusProbeError.portDetectionFailed("no working API port found")
}
+ static func fallbackProbePort(ports: [Int], extensionPort: Int?) -> Int? {
+ if let nonExtension = ports.first(where: { $0 != extensionPort }) {
+ return nonExtension
+ }
+ if let extensionPort {
+ return extensionPort
+ }
+ return ports.first
+ }
+
+ static func isReachableProbeError(_ error: Error) -> Bool {
+ guard case let AntigravityStatusProbeError.apiError(message) = error else { return false }
+ return message.hasPrefix("HTTP ")
+ }
+
+ private static func fallbackProbeEndpoint(
+ _ endpoints: [AntigravityConnectionEndpoint]) -> AntigravityConnectionEndpoint?
+ {
+ if let languageServerEndpoint = endpoints.first(where: { $0.source == .languageServer }) {
+ return languageServerEndpoint
+ }
+ return endpoints.first
+ }
+
private static func testEndpointConnectivity(
_ endpoint: AntigravityConnectionEndpoint,
timeout: TimeInterval) async -> Bool
@@ -680,6 +712,15 @@ public struct AntigravityStatusProbe: Sendable {
context: RequestContext(endpoints: [endpoint], timeout: timeout))
return true
} catch {
+ if self.isReachableProbeError(error) {
+ self.log.debug("Port probe received HTTP response; treating endpoint as reachable", metadata: [
+ "source": endpoint.source.rawValue,
+ "scheme": endpoint.scheme,
+ "port": "\(endpoint.port)",
+ "error": error.localizedDescription,
+ ])
+ return true
+ }
self.log.debug("Port probe failed", metadata: [
"source": endpoint.source.rawValue,
"scheme": endpoint.scheme,
@@ -896,13 +937,9 @@ private final class LocalhostSessionDelegate: NSObject {
extension LocalhostSessionDelegate: URLSessionDelegate {
func urlSession(
_ session: URLSession,
- didReceive challenge: URLAuthenticationChallenge,
- completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
+ didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?)
{
- let result = self.challengeResult(challenge)
- Task { @MainActor in
- completionHandler(result.disposition, result.credential)
- }
+ self.challengeResult(challenge)
}
}
@@ -910,13 +947,9 @@ extension LocalhostSessionDelegate: URLSessionTaskDelegate {
func urlSession(
_ session: URLSession,
task: URLSessionTask,
- didReceive challenge: URLAuthenticationChallenge,
- completionHandler: @escaping @MainActor @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
+ didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?)
{
- let result = self.challengeResult(challenge)
- Task { @MainActor in
- completionHandler(result.disposition, result.credential)
- }
+ self.challengeResult(challenge)
}
}
diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift
index 9ae1bc052..b96908e11 100644
--- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift
+++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift
@@ -298,9 +298,21 @@ actor ClaudeCLISession {
}
let pid = proc.processIdentifier
+ guard TTYCommandRunner.registerActiveProcessForAppShutdown(
+ pid: pid,
+ binary: URL(fileURLWithPath: binary).lastPathComponent)
+ else {
+ proc.terminate()
+ kill(pid, SIGKILL)
+ try? primaryHandle.close()
+ try? secondaryHandle.close()
+ throw SessionError.launchFailed("App shutdown in progress")
+ }
+
var processGroup: pid_t?
if setpgid(pid, pid) == 0 {
processGroup = pid
+ TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: pid, processGroup: processGroup)
}
self.process = proc
@@ -354,6 +366,7 @@ actor ClaudeCLISession {
}
kill(proc.processIdentifier, SIGKILL)
}
+ TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: proc.processIdentifier)
}
self.process = nil
diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift
index 35dfdc274..6388be800 100644
--- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift
+++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift
@@ -207,6 +207,7 @@ public enum ClaudeOAuthCredentialsStore {
var lastError: Error?
var expiredRecord: ClaudeOAuthCredentialRecord?
+ var cacheTemporarilyUnavailable = false
switch KeychainCacheStore.load(key: ClaudeOAuthCredentialsStore.cacheKey, as: CacheEntry.self) {
case let .found(entry):
@@ -239,6 +240,8 @@ public enum ClaudeOAuthCredentialsStore {
}
case .invalid:
KeychainCacheStore.clear(key: ClaudeOAuthCredentialsStore.cacheKey)
+ case .temporarilyUnavailable:
+ cacheTemporarilyUnavailable = true
case .missing:
break
}
@@ -259,7 +262,9 @@ public enum ClaudeOAuthCredentialsStore {
owner: .claudeCLI,
source: .memoryCache),
timestamp: Date())
- ClaudeOAuthCredentialsStore.saveToCacheKeychain(fileData, owner: .claudeCLI)
+ if !cacheTemporarilyUnavailable {
+ ClaudeOAuthCredentialsStore.saveToCacheKeychain(fileData, owner: .claudeCLI)
+ }
return record
}
} catch let error as ClaudeOAuthCredentialsError {
@@ -274,7 +279,8 @@ public enum ClaudeOAuthCredentialsStore {
if allowClaudeKeychainRepairWithoutPrompt, !allowKeychainPrompt {
if let repaired = recovery.repairFromClaudeKeychainWithoutPromptIfAllowed(
now: Date(),
- respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes)
+ respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes,
+ allowCacheKeychainWrite: !cacheTemporarilyUnavailable)
{
return repaired
}
@@ -283,6 +289,7 @@ public enum ClaudeOAuthCredentialsStore {
if let prompted = self.loadFromClaudeKeychainWithPromptIfAllowed(
allowKeychainPrompt: allowKeychainPrompt,
respectKeychainPromptCooldown: respectKeychainPromptCooldown,
+ allowCacheKeychainWrite: !cacheTemporarilyUnavailable,
lastError: &lastError)
{
return prompted
@@ -299,6 +306,7 @@ public enum ClaudeOAuthCredentialsStore {
private func loadFromClaudeKeychainWithPromptIfAllowed(
allowKeychainPrompt: Bool,
respectKeychainPromptCooldown: Bool,
+ allowCacheKeychainWrite: Bool,
lastError: inout Error?) -> ClaudeOAuthCredentialRecord?
{
let shouldApplyPromptCooldown =
@@ -355,7 +363,9 @@ public enum ClaudeOAuthCredentialsStore {
owner: .claudeCLI,
source: .memoryCache),
timestamp: Date())
- ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI)
+ if allowCacheKeychainWrite {
+ ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI)
+ }
return record
}
@@ -404,7 +414,9 @@ public enum ClaudeOAuthCredentialsStore {
owner: .claudeCLI,
source: .memoryCache),
timestamp: Date())
- ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI)
+ if allowCacheKeychainWrite {
+ ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI)
+ }
return record
} catch let error as ClaudeOAuthCredentialsError {
if case .notFound = error {
@@ -423,24 +435,28 @@ public enum ClaudeOAuthCredentialsStore {
let current = ClaudeOAuthCredentialsStore.currentFileFingerprint()
let stored = ClaudeOAuthCredentialsStore.loadFileFingerprint()
guard current != stored else { return false }
- ClaudeOAuthCredentialsStore.saveFileFingerprint(current)
ClaudeOAuthCredentialsStore.log.info("Claude OAuth credentials file changed; invalidating cache")
ClaudeOAuthCredentialsStore.writeMemoryCache(record: nil, timestamp: nil)
var shouldClearKeychainCache = false
+ var shouldSaveFileFingerprint = true
if let current {
if let modifiedAtMs = current.modifiedAtMs {
let modifiedAt = Date(timeIntervalSince1970: TimeInterval(Double(modifiedAtMs) / 1000.0))
- if case let .found(entry) = KeychainCacheStore.load(
+ switch KeychainCacheStore.load(
key: ClaudeOAuthCredentialsStore.cacheKey,
as: CacheEntry.self)
{
+ case let .found(entry):
if entry.storedAt < modifiedAt {
shouldClearKeychainCache = true
}
- } else {
+ case .missing, .invalid:
shouldClearKeychainCache = true
+ case .temporarilyUnavailable:
+ shouldClearKeychainCache = false
+ shouldSaveFileFingerprint = false
}
} else {
shouldClearKeychainCache = true
@@ -452,6 +468,9 @@ public enum ClaudeOAuthCredentialsStore {
if shouldClearKeychainCache {
ClaudeOAuthCredentialsStore.clearCacheKeychain()
}
+ if shouldSaveFileFingerprint {
+ ClaudeOAuthCredentialsStore.saveFileFingerprint(current)
+ }
return true
}
}
@@ -507,6 +526,8 @@ public enum ClaudeOAuthCredentialsStore {
owner: entry.owner ?? .claudeCLI,
source: .cacheKeychain)
return isRefreshableOrValid(record)
+ case .temporarilyUnavailable:
+ return true
default:
break
}
@@ -697,7 +718,8 @@ public enum ClaudeOAuthCredentialsStore {
func repairFromClaudeKeychainWithoutPromptIfAllowed(
now: Date,
- respectKeychainPromptCooldown: Bool) -> ClaudeOAuthCredentialRecord?
+ respectKeychainPromptCooldown: Bool,
+ allowCacheKeychainWrite: Bool = true) -> ClaudeOAuthCredentialRecord?
{
#if os(macOS)
let mode = ClaudeOAuthKeychainPromptPreference.current()
@@ -735,7 +757,9 @@ public enum ClaudeOAuthCredentialsStore {
owner: .claudeCLI,
source: .memoryCache),
timestamp: now)
- ClaudeOAuthCredentialsStore.saveToCacheKeychain(securityData, owner: .claudeCLI)
+ if allowCacheKeychainWrite {
+ ClaudeOAuthCredentialsStore.saveToCacheKeychain(securityData, owner: .claudeCLI)
+ }
ClaudeOAuthCredentialsStore.log.info(
"Claude keychain credentials loaded without prompt; syncing OAuth cache",
@@ -773,7 +797,9 @@ public enum ClaudeOAuthCredentialsStore {
owner: .claudeCLI,
source: .memoryCache),
timestamp: now)
- ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI)
+ if allowCacheKeychainWrite {
+ ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI)
+ }
ClaudeOAuthCredentialsStore.log.info(
"Claude keychain credentials loaded without prompt; syncing OAuth cache",
diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift
index 22003fb23..33e8677e7 100644
--- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift
@@ -114,17 +114,96 @@ struct OAuthUsageResponse: Decodable {
let sevenDayOAuthApps: OAuthUsageWindow?
let sevenDayOpus: OAuthUsageWindow?
let sevenDaySonnet: OAuthUsageWindow?
+ let sevenDayDesign: OAuthUsageWindow?
+ let sevenDayRoutines: OAuthUsageWindow?
+ let sevenDayDesignSourceKey: String?
+ let sevenDayRoutinesSourceKey: String?
let iguanaNecktie: OAuthUsageWindow?
let extraUsage: OAuthExtraUsage?
- enum CodingKeys: String, CodingKey {
- case fiveHour = "five_hour"
- case sevenDay = "seven_day"
- case sevenDayOAuthApps = "seven_day_oauth_apps"
- case sevenDayOpus = "seven_day_opus"
- case sevenDaySonnet = "seven_day_sonnet"
- case iguanaNecktie = "iguana_necktie"
- case extraUsage = "extra_usage"
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: DynamicCodingKey.self)
+ self.fiveHour = Self.decodeWindow(in: container, keys: ["five_hour"])
+ self.sevenDay = Self.decodeWindow(in: container, keys: ["seven_day"])
+ self.sevenDayOAuthApps = Self.decodeWindow(in: container, keys: ["seven_day_oauth_apps"])
+ self.sevenDayOpus = Self.decodeWindow(in: container, keys: ["seven_day_opus"])
+ self.sevenDaySonnet = Self.decodeWindow(in: container, keys: ["seven_day_sonnet"])
+ let design = Self.decodeWindowWithSource(in: container, keys: [
+ "seven_day_design",
+ "seven_day_claude_design",
+ "claude_design",
+ "design",
+ "seven_day_omelette",
+ "omelette",
+ "omelette_promotional",
+ ])
+ self.sevenDayDesign = design.window
+ self.sevenDayDesignSourceKey = design.sourceKey
+ let routines = Self.decodeWindowWithSource(in: container, keys: [
+ "seven_day_routines",
+ "seven_day_claude_routines",
+ "claude_routines",
+ "routines",
+ "routine",
+ "seven_day_cowork",
+ "cowork",
+ ])
+ self.sevenDayRoutines = routines.window
+ self.sevenDayRoutinesSourceKey = routines.sourceKey
+ self.iguanaNecktie = Self.decodeWindow(in: container, keys: ["iguana_necktie"])
+ self.extraUsage = Self.decodeValue(in: container, keys: ["extra_usage"])
+ }
+
+ private static func decodeWindow(
+ in container: KeyedDecodingContainer,
+ keys: [String]) -> OAuthUsageWindow?
+ {
+ self.decodeValue(in: container, keys: keys)
+ }
+
+ private static func decodeWindowWithSource(
+ in container: KeyedDecodingContainer,
+ keys: [String]) -> (window: OAuthUsageWindow?, sourceKey: String?)
+ {
+ var firstNullKey: String?
+ for keyName in keys {
+ guard let key = DynamicCodingKey(stringValue: keyName) else { continue }
+ guard container.contains(key) else { continue }
+ if let value = try? container.decodeIfPresent(OAuthUsageWindow.self, forKey: key) {
+ return (value, keyName)
+ }
+ if firstNullKey == nil {
+ firstNullKey = keyName
+ }
+ }
+ return (nil, firstNullKey)
+ }
+
+ private static func decodeValue(
+ in container: KeyedDecodingContainer,
+ keys: [String]) -> T?
+ {
+ for keyName in keys {
+ guard let key = DynamicCodingKey(stringValue: keyName) else { continue }
+ if let value = try? container.decodeIfPresent(T.self, forKey: key) {
+ return value
+ }
+ }
+ return nil
+ }
+}
+
+private struct DynamicCodingKey: CodingKey {
+ let stringValue: String
+ let intValue: Int?
+
+ init?(stringValue: String) {
+ self.stringValue = stringValue
+ self.intValue = nil
+ }
+
+ init?(intValue: Int) {
+ nil
}
}
diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift
index 50a54a301..20db61312 100644
--- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift
@@ -303,6 +303,7 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy {
primary: usage.primary,
secondary: usage.secondary,
tertiary: usage.opus,
+ extraRateWindows: usage.extraRateWindows.isEmpty ? nil : usage.extraRateWindows,
providerCost: usage.providerCost,
updatedAt: usage.updatedAt,
identity: identity)
diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
index dd4848a53..82d919865 100644
--- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
@@ -10,6 +10,7 @@ public struct ClaudeUsageSnapshot: Sendable {
public let primary: RateWindow
public let secondary: RateWindow?
public let opus: RateWindow?
+ public let extraRateWindows: [NamedRateWindow]
public let providerCost: ProviderCostSnapshot?
public let updatedAt: Date
public let accountEmail: String?
@@ -21,6 +22,7 @@ public struct ClaudeUsageSnapshot: Sendable {
primary: RateWindow,
secondary: RateWindow?,
opus: RateWindow?,
+ extraRateWindows: [NamedRateWindow] = [],
providerCost: ProviderCostSnapshot? = nil,
updatedAt: Date,
accountEmail: String?,
@@ -31,6 +33,7 @@ public struct ClaudeUsageSnapshot: Sendable {
self.primary = primary
self.secondary = secondary
self.opus = opus
+ self.extraRateWindows = extraRateWindows
self.providerCost = providerCost
self.updatedAt = updatedAt
self.accountEmail = accountEmail
@@ -841,6 +844,7 @@ extension ClaudeUsageFetcher {
let modelSpecific = makeWindow(
usage.sevenDaySonnet ?? usage.sevenDayOpus,
windowMinutes: 7 * 24 * 60)
+ let extraRateWindows = Self.oauthExtraRateWindows(from: usage)
let loginMethod = ClaudePlan.oauthLoginMethod(rateLimitTier: credentials.rateLimitTier)
let providerCost = Self.oauthExtraUsageCost(usage.extraUsage, loginMethod: loginMethod)
@@ -849,6 +853,7 @@ extension ClaudeUsageFetcher {
primary: primary,
secondary: weekly,
opus: modelSpecific,
+ extraRateWindows: extraRateWindows,
providerCost: providerCost,
updatedAt: Date(),
accountEmail: nil,
@@ -887,6 +892,50 @@ extension ClaudeUsageFetcher {
(used: used / 100.0, limit: limit / 100.0)
}
+ private static func oauthExtraRateWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] {
+ let definitions: [(id: String, title: String, window: OAuthUsageWindow?, sourceKey: String?)] = [
+ (
+ id: "claude-design",
+ title: "Designs",
+ window: usage.sevenDayDesign,
+ sourceKey: usage.sevenDayDesignSourceKey),
+ (
+ id: "claude-routines",
+ title: "Daily Routines",
+ window: usage.sevenDayRoutines,
+ sourceKey: usage.sevenDayRoutinesSourceKey),
+ ]
+ if let designKey = usage.sevenDayDesignSourceKey {
+ Self.log.debug("Claude OAuth extra usage key matched: design=\(designKey)")
+ }
+ if let routinesKey = usage.sevenDayRoutinesSourceKey {
+ Self.log.debug("Claude OAuth extra usage key matched: routines=\(routinesKey)")
+ }
+ return definitions.compactMap { definition in
+ let utilization: Double
+ let resetDate: Date?
+ if let window = definition.window, let parsedUtilization = window.utilization {
+ utilization = parsedUtilization
+ resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt)
+ } else if definition.sourceKey != nil {
+ // Keep product bars visible when the API returns a known key with null payload.
+ utilization = 0
+ resetDate = nil
+ } else {
+ return nil
+ }
+ let resetDescription = resetDate.map(Self.formatResetDate)
+ return NamedRateWindow(
+ id: definition.id,
+ title: definition.title,
+ window: RateWindow(
+ usedPercent: utilization,
+ windowMinutes: Self.weeklyWindowMinutes,
+ resetsAt: resetDate,
+ resetDescription: resetDescription))
+ }
+ }
+
// MARK: - Web API path (uses browser cookies)
private func loadViaWebAPI() async throws -> ClaudeUsageSnapshot {
@@ -927,6 +976,7 @@ extension ClaudeUsageFetcher {
primary: primary,
secondary: secondary,
opus: opus,
+ extraRateWindows: webData.extraRateWindows,
providerCost: webData.extraUsageCost,
updatedAt: Date(),
accountEmail: webData.accountEmail,
@@ -986,6 +1036,7 @@ extension ClaudeUsageFetcher {
primary: primary,
secondary: weekly,
opus: opus,
+ extraRateWindows: [],
providerCost: nil,
updatedAt: Date(),
accountEmail: snap.accountEmail,
@@ -1009,13 +1060,17 @@ extension ClaudeUsageFetcher {
Self.log.debug(msg)
}
}
- // Only merge cost extras; keep identity fields from the primary data source.
- if snapshot.providerCost == nil, let extra = webData.extraUsageCost {
+ // Only merge usage/cost extras; keep identity fields from the primary data source.
+ let mergedExtraRateWindows = snapshot.extraRateWindows.isEmpty ? webData.extraRateWindows : snapshot
+ .extraRateWindows
+ let mergedProviderCost = snapshot.providerCost ?? webData.extraUsageCost
+ if mergedProviderCost != snapshot.providerCost || mergedExtraRateWindows != snapshot.extraRateWindows {
return ClaudeUsageSnapshot(
primary: snapshot.primary,
secondary: snapshot.secondary,
opus: snapshot.opus,
- providerCost: extra,
+ extraRateWindows: mergedExtraRateWindows,
+ providerCost: mergedProviderCost,
updatedAt: snapshot.updatedAt,
accountEmail: snapshot.accountEmail,
accountOrganization: snapshot.accountOrganization,
diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift
index c350d30c4..6fb3f41ea 100644
--- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift
+++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift
@@ -84,6 +84,7 @@ public enum ClaudeWebAPIFetcher {
public let weeklyPercentUsed: Double?
public let weeklyResetsAt: Date?
public let opusPercentUsed: Double?
+ public let extraRateWindows: [NamedRateWindow]
public let extraUsageCost: ProviderCostSnapshot?
public let accountOrganization: String?
public let accountEmail: String?
@@ -95,6 +96,7 @@ public enum ClaudeWebAPIFetcher {
weeklyPercentUsed: Double?,
weeklyResetsAt: Date?,
opusPercentUsed: Double?,
+ extraRateWindows: [NamedRateWindow],
extraUsageCost: ProviderCostSnapshot?,
accountOrganization: String?,
accountEmail: String?,
@@ -105,6 +107,7 @@ public enum ClaudeWebAPIFetcher {
self.weeklyPercentUsed = weeklyPercentUsed
self.weeklyResetsAt = weeklyResetsAt
self.opusPercentUsed = opusPercentUsed
+ self.extraRateWindows = extraRateWindows
self.extraUsageCost = extraUsageCost
self.accountOrganization = accountOrganization
self.accountEmail = accountEmail
@@ -195,6 +198,7 @@ public enum ClaudeWebAPIFetcher {
weeklyPercentUsed: usage.weeklyPercentUsed,
weeklyResetsAt: usage.weeklyResetsAt,
opusPercentUsed: usage.opusPercentUsed,
+ extraRateWindows: usage.extraRateWindows,
extraUsageCost: extra,
accountOrganization: usage.accountOrganization,
accountEmail: usage.accountEmail,
@@ -207,6 +211,7 @@ public enum ClaudeWebAPIFetcher {
weeklyPercentUsed: usage.weeklyPercentUsed,
weeklyResetsAt: usage.weeklyResetsAt,
opusPercentUsed: usage.opusPercentUsed,
+ extraRateWindows: usage.extraRateWindows,
extraUsageCost: usage.extraUsageCost,
accountOrganization: usage.accountOrganization,
accountEmail: account.email,
@@ -219,6 +224,7 @@ public enum ClaudeWebAPIFetcher {
weeklyPercentUsed: usage.weeklyPercentUsed,
weeklyResetsAt: usage.weeklyResetsAt,
opusPercentUsed: usage.opusPercentUsed,
+ extraRateWindows: usage.extraRateWindows,
extraUsageCost: usage.extraUsageCost,
accountOrganization: name,
accountEmail: usage.accountEmail,
@@ -439,7 +445,7 @@ public enum ClaudeWebAPIFetcher {
switch httpResponse.statusCode {
case 200:
- return try self.parseUsageResponse(data)
+ return try self.parseUsageResponse(data, logger: logger)
case 401, 403:
throw FetchError.unauthorized
default:
@@ -447,7 +453,7 @@ public enum ClaudeWebAPIFetcher {
}
}
- private static func parseUsageResponse(_ data: Data) throws -> WebUsageData {
+ private static func parseUsageResponse(_ data: Data, logger: ((String) -> Void)? = nil) throws -> WebUsageData {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw FetchError.invalidResponse
}
@@ -480,12 +486,19 @@ public enum ClaudeWebAPIFetcher {
}
}
- // Parse seven_day_opus (Opus-specific weekly) usage
+ // Parse seven_day_sonnet (preferred) / seven_day_opus usage
var opusPercent: Double?
- if let sevenDayOpus = json["seven_day_opus"] as? [String: Any] {
- if let utilization = sevenDayOpus["utilization"] as? Int {
- opusPercent = Double(utilization)
- }
+ if let sevenDaySonnet = json["seven_day_sonnet"] as? [String: Any] {
+ opusPercent = Self.percentValue(from: sevenDaySonnet["utilization"])
+ } else if let sevenDayOpus = json["seven_day_opus"] as? [String: Any] {
+ opusPercent = Self.percentValue(from: sevenDayOpus["utilization"])
+ }
+ let extraRateParse = ClaudeWebExtraRateWindowParser.parse(from: json)
+ if let sourceKey = extraRateParse.sourceKeys["claude-design"] {
+ logger?("Usage API extra window key matched: design=\(sourceKey)")
+ }
+ if let sourceKey = extraRateParse.sourceKeys["claude-routines"] {
+ logger?("Usage API extra window key matched: routines=\(sourceKey)")
}
return WebUsageData(
@@ -494,12 +507,23 @@ public enum ClaudeWebAPIFetcher {
weeklyPercentUsed: weeklyPercent,
weeklyResetsAt: weeklyResets,
opusPercentUsed: opusPercent,
+ extraRateWindows: extraRateParse.windows,
extraUsageCost: nil,
accountOrganization: nil,
accountEmail: nil,
loginMethod: nil)
}
+ private static func percentValue(from value: Any?) -> Double? {
+ if let intValue = value as? Int {
+ return Double(intValue)
+ }
+ if let doubleValue = value as? Double {
+ return doubleValue
+ }
+ return nil
+ }
+
// MARK: - Extra usage cost (Claude "Extra")
private struct OverageSpendLimitResponse: Decodable {
diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift
new file mode 100644
index 000000000..b1df6343c
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift
@@ -0,0 +1,118 @@
+import Foundation
+
+enum ClaudeWebExtraRateWindowParser {
+ private static let definitions: [(id: String, title: String, keys: [String])] = [
+ (
+ id: "claude-design",
+ title: "Designs",
+ keys: [
+ "seven_day_design",
+ "seven_day_claude_design",
+ "claude_design",
+ "design",
+ "seven_day_omelette",
+ "omelette",
+ "omelette_promotional",
+ ]),
+ (
+ id: "claude-routines",
+ title: "Daily Routines",
+ keys: [
+ "seven_day_routines",
+ "seven_day_claude_routines",
+ "claude_routines",
+ "routines",
+ "routine",
+ "seven_day_cowork",
+ "cowork",
+ ]),
+ ]
+
+ static func parse(from json: [String: Any]) -> (windows: [NamedRateWindow], sourceKeys: [String: String]) {
+ var windows: [NamedRateWindow] = []
+ var sourceKeys: [String: String] = [:]
+ windows.reserveCapacity(Self.definitions.count)
+
+ for definition in Self.definitions {
+ if let foundWindow = Self.firstUsageWindow(in: json, keys: definition.keys) {
+ let rawWindow = foundWindow.window
+ guard let utilization = Self.percentValue(from: rawWindow["utilization"]) else { continue }
+ let resetsAt = (rawWindow["resets_at"] as? String).flatMap(Self.parseISO8601Date)
+ windows.append(Self.namedWindow(
+ id: definition.id,
+ title: definition.title,
+ usedPercent: utilization,
+ resetsAt: resetsAt))
+ sourceKeys[definition.id] = foundWindow.sourceKey
+ continue
+ }
+
+ // Some accounts expose the key with null payloads (for example `seven_day_cowork: null`).
+ // Preserve the bar in that case with a 0% window so the product section remains visible.
+ if let key = Self.firstUsageKey(in: json, keys: definition.keys) {
+ windows.append(Self.namedWindow(
+ id: definition.id,
+ title: definition.title,
+ usedPercent: 0,
+ resetsAt: nil))
+ sourceKeys[definition.id] = key
+ }
+ }
+ return (windows, sourceKeys)
+ }
+
+ private static func namedWindow(
+ id: String,
+ title: String,
+ usedPercent: Double,
+ resetsAt: Date?) -> NamedRateWindow
+ {
+ NamedRateWindow(
+ id: id,
+ title: title,
+ window: RateWindow(
+ usedPercent: usedPercent,
+ windowMinutes: 7 * 24 * 60,
+ resetsAt: resetsAt,
+ resetDescription: nil))
+ }
+
+ private static func firstUsageWindow(
+ in json: [String: Any],
+ keys: [String]) -> (window: [String: Any], sourceKey: String)?
+ {
+ for key in keys {
+ if let window = json[key] as? [String: Any] {
+ return (window, key)
+ }
+ }
+ return nil
+ }
+
+ private static func firstUsageKey(in json: [String: Any], keys: [String]) -> String? {
+ for key in keys where json.keys.contains(key) {
+ return key
+ }
+ return nil
+ }
+
+ private static func percentValue(from value: Any?) -> Double? {
+ if let intValue = value as? Int {
+ return Double(intValue)
+ }
+ if let doubleValue = value as? Double {
+ return doubleValue
+ }
+ return nil
+ }
+
+ private static func parseISO8601Date(_ string: String) -> Date? {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: string) {
+ return date
+ }
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.date(from: string)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift b/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift
index 8a6c811e8..2238fc0b2 100644
--- a/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift
+++ b/Sources/CodexBarCore/Providers/Codex/CodexCLISession.swift
@@ -300,9 +300,21 @@ actor CodexCLISession {
}
let pid = proc.processIdentifier
+ guard TTYCommandRunner.registerActiveProcessForAppShutdown(
+ pid: pid,
+ binary: resolvedURL.lastPathComponent)
+ else {
+ proc.terminate()
+ kill(pid, SIGKILL)
+ try? primaryHandle.close()
+ try? secondaryHandle.close()
+ throw SessionError.launchFailed("App shutdown in progress")
+ }
+
var processGroup: pid_t?
if setpgid(pid, pid) == 0 {
processGroup = pid
+ TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: pid, processGroup: processGroup)
}
self.process = proc
@@ -341,6 +353,7 @@ actor CodexCLISession {
}
kill(proc.processIdentifier, SIGKILL)
}
+ TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: proc.processIdentifier)
}
self.process = nil
diff --git a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift
index 9f7b6b93e..b613a54e4 100644
--- a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift
+++ b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift
@@ -150,15 +150,43 @@ extension CodexWebDashboardStrategy {
let log: @MainActor (String) -> Void = { line in
logger.append(line)
}
- let result = try await Self.fetchOpenAIWebDashboard(
- context: context,
- options: options,
- browserDetection: browserDetection,
- logger: log)
- return try Self.makeAuthorizedDashboardResult(
- dashboard: result.dashboard,
- context: context,
- routingTargetEmail: result.routingTargetEmail)
+ do {
+ let result = try await Self.fetchOpenAIWebDashboard(
+ context: context,
+ options: options,
+ browserDetection: browserDetection,
+ preferCachedCookieHeader: true,
+ logger: log)
+ return try Self.makeAuthorizedDashboardResult(
+ dashboard: result.dashboard,
+ context: context,
+ routingTargetEmail: result.routingTargetEmail)
+ } catch {
+ guard Self.shouldRetryWithFreshBrowserImport(after: error) else {
+ throw error
+ }
+ log("Retrying OpenAI web dashboard with a fresh browser cookie import.")
+ let result = try await Self.fetchOpenAIWebDashboard(
+ context: context,
+ options: options,
+ browserDetection: browserDetection,
+ preferCachedCookieHeader: false,
+ logger: log)
+ return try Self.makeAuthorizedDashboardResult(
+ dashboard: result.dashboard,
+ context: context,
+ routingTargetEmail: result.routingTargetEmail)
+ }
+ }
+
+ nonisolated static func shouldRetryWithFreshBrowserImport(after error: Error) -> Bool {
+ if error is OpenAIWebCodexError {
+ return error as? OpenAIWebCodexError == .missingUsage
+ }
+ if case OpenAIDashboardFetcher.FetchError.noDashboardData = error {
+ return true
+ }
+ return false
}
@MainActor
@@ -222,6 +250,7 @@ extension CodexWebDashboardStrategy {
context: ProviderFetchContext,
options: OpenAIWebOptions,
browserDetection: BrowserDetection,
+ preferCachedCookieHeader: Bool,
logger: @MainActor @escaping (String) -> Void) async throws -> OpenAIWebDashboardFetchResult
{
let auth = context.fetcher.loadAuthBackedCodexAccount()
@@ -232,6 +261,7 @@ extension CodexWebDashboardStrategy {
.importBestCookies(
intoAccountEmail: routingTargetEmail,
allowAnyAccount: allowAnyAccount,
+ preferCachedCookieHeader: preferCachedCookieHeader,
logger: logger)
let effectiveEmail = routingTargetEmail ?? importResult.signedInEmail?
.trimmingCharacters(in: .whitespacesAndNewlines)
diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift
index 094ad9f2e..ff778adf8 100644
--- a/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift
+++ b/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift
@@ -11,13 +11,19 @@ public struct CopilotDeviceFlow: Sendable {
public let deviceCode: String
public let userCode: String
public let verificationUri: String
+ public let verificationUriComplete: String?
public let expiresIn: Int
public let interval: Int
+ public var verificationURLToOpen: String {
+ self.verificationUriComplete ?? self.verificationUri
+ }
+
enum CodingKeys: String, CodingKey {
case deviceCode = "device_code"
case userCode = "user_code"
case verificationUri = "verification_uri"
+ case verificationUriComplete = "verification_uri_complete"
case expiresIn = "expires_in"
case interval
}
diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift
index b94a35115..4dee402a2 100644
--- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift
+++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift
@@ -407,7 +407,7 @@ public struct CursorStatusSnapshot: Sendable {
used: resolvedOnDemandUsed,
limit: resolvedOnDemandLimit ?? 0,
currencyCode: "USD",
- period: "monthly",
+ period: "Monthly",
resetsAt: self.billingCycleEnd,
updatedAt: Date())
} else {
diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift
index d978630aa..7ec634f59 100644
--- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift
+++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift
@@ -475,20 +475,280 @@ public struct GeminiStatusProbe: Sendable {
}
// Resolve symlinks to find the actual installation
- let fm = FileManager.default
- var realPath = geminiPath
- if let resolved = try? fm.destinationOfSymbolicLink(atPath: geminiPath) {
- if resolved.hasPrefix("/") {
- realPath = resolved
- } else {
- realPath = (geminiPath as NSString).deletingLastPathComponent + "/" + resolved
+ let resolvedGeminiPath = URL(fileURLWithPath: geminiPath).resolvingSymlinksInPath().path
+
+ // Try the legacy layouts first — they're cheap file reads and cover the common cases
+ // (Homebrew, npm/bun sibling, Nix) without spawning subprocesses or walking the tree.
+ if let credentials = Self.extractOAuthCredentialsFromLegacyPaths(realGeminiPath: resolvedGeminiPath) {
+ return credentials
+ }
+
+ // For fnm-managed installs, ask fnm where the package lives
+ if Self.isLikelyFnmManagedPath(geminiPath) || Self.isLikelyFnmManagedPath(resolvedGeminiPath),
+ let fnmPath = TTYCommandRunner.which("fnm"),
+ let packageRoot = Self.resolveGeminiPackageRootViaFnm(fnmPath: fnmPath, environment: env),
+ let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot)
+ {
+ return credentials
+ }
+
+ // Fall back to walking up the directory tree from the binary
+ if let packageRoot = Self.findGeminiPackageRoot(startingAt: resolvedGeminiPath),
+ let credentials = Self.extractOAuthCredentials(fromGeminiPackageRoot: packageRoot)
+ {
+ return credentials
+ }
+
+ return nil
+ }
+
+ private static func isLikelyFnmManagedPath(_ path: String) -> Bool {
+ let normalized = path.replacingOccurrences(of: "\\", with: "/")
+ return normalized.contains("/fnm_multishells/")
+ || (normalized.contains("/node-versions/") && normalized.contains("/fnm/"))
+ }
+
+ private static func resolveGeminiPackageRootViaFnm(
+ fnmPath: String,
+ environment: [String: String]) -> String?
+ {
+ guard let currentVersion = runProcess(
+ executable: fnmPath,
+ arguments: ["current"],
+ environment: environment,
+ timeout: 2.0),
+ !currentVersion.isEmpty
+ else {
+ return nil
+ }
+
+ // Prefer npm root -g because require.resolve searches from the current
+ // working directory and often fails for globally-installed packages.
+ if let npmRoot = runProcess(
+ executable: fnmPath,
+ arguments: [
+ "exec",
+ "--using",
+ currentVersion,
+ "npm",
+ "root",
+ "-g",
+ ],
+ environment: environment,
+ timeout: 4.0),
+ !npmRoot.isEmpty
+ {
+ let packageRoot = "\(npmRoot)/@google/gemini-cli"
+ let packageJSONPath = "\(packageRoot)/package.json"
+ if FileManager.default.fileExists(atPath: packageJSONPath) {
+ return packageRoot
+ }
+ }
+
+ // Fallback for non-npm global installations.
+ if let packageJSONPath = runProcess(
+ executable: fnmPath,
+ arguments: [
+ "exec",
+ "--using",
+ currentVersion,
+ "node",
+ "-p",
+ "require.resolve('@google/gemini-cli/package.json')",
+ ],
+ environment: environment,
+ timeout: 4.0),
+ !packageJSONPath.isEmpty
+ {
+ return (packageJSONPath as NSString).deletingLastPathComponent
+ }
+
+ return nil
+ }
+
+ private static func runProcess(
+ executable: String,
+ arguments: [String],
+ environment: [String: String],
+ timeout: TimeInterval) -> String?
+ {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: executable)
+ process.arguments = arguments
+
+ var mergedEnvironment = environment
+ mergedEnvironment["PATH"] = PathBuilder.effectivePATH(
+ purposes: [.tty, .nodeTooling],
+ env: environment,
+ loginPATH: LoginShellPathCache.shared.current)
+ process.environment = mergedEnvironment
+
+ let stdout = Pipe()
+ process.standardOutput = stdout
+ process.standardError = Pipe()
+ process.standardInput = nil
+
+ let exitSemaphore = DispatchSemaphore(value: 0)
+ process.terminationHandler = { _ in
+ exitSemaphore.signal()
+ }
+
+ do {
+ try process.run()
+ } catch {
+ return nil
+ }
+
+ let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success
+ if !didExit {
+ if process.isRunning {
+ process.terminate()
+ _ = exitSemaphore.wait(timeout: .now() + 0.5)
+ }
+ return nil
+ }
+
+ let data = stdout.fileHandleForReading.readDataToEndOfFile()
+ guard process.terminationStatus == 0,
+ let output = String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !output.isEmpty
+ else {
+ return nil
+ }
+
+ return output.components(separatedBy: .newlines).first?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private static func findGeminiPackageRoot(startingAt path: String) -> String? {
+ let fileManager = FileManager.default
+ var currentURL = URL(fileURLWithPath: path).standardizedFileURL
+
+ var isDirectory: ObjCBool = false
+ if !fileManager.fileExists(atPath: currentURL.path, isDirectory: &isDirectory) || !isDirectory.boolValue {
+ currentURL.deleteLastPathComponent()
+ }
+
+ // Bound the walk so an unrelated Gemini install elsewhere on the host
+ // (e.g. a global npm/brew install unrelated to the resolved binary) can't
+ // contaminate discovery started from the actual binary path.
+ let maxAscents = 8
+ for _ in 0...maxAscents {
+ let packageJSONURL = currentURL.appendingPathComponent("package.json")
+ if let data = try? Data(contentsOf: packageJSONURL),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ json["name"] as? String == "@google/gemini-cli"
+ {
+ return currentURL.path
+ }
+
+ // Also check for a global Node installation layout:
+ // /lib/node_modules/@google/gemini-cli/package.json
+ let globalPackageJSONURL = currentURL
+ .appendingPathComponent("lib")
+ .appendingPathComponent("node_modules")
+ .appendingPathComponent("@google")
+ .appendingPathComponent("gemini-cli")
+ .appendingPathComponent("package.json")
+ if let data = try? Data(contentsOf: globalPackageJSONURL),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ json["name"] as? String == "@google/gemini-cli"
+ {
+ return globalPackageJSONURL.deletingLastPathComponent().path
+ }
+
+ let parentURL = currentURL.deletingLastPathComponent()
+ if parentURL.path == currentURL.path {
+ return nil
+ }
+ currentURL = parentURL
+ }
+
+ return nil
+ }
+
+ private static func extractOAuthCredentials(fromGeminiPackageRoot packageRoot: String) -> OAuthClientCredentials? {
+ // Check the standard distributed file first, then any sibling core package
+ let oauthFile = "dist/src/code_assist/oauth2.js"
+ let candidatePaths = [
+ "\(packageRoot)/\(oauthFile)",
+ "\(packageRoot)/node_modules/@google/gemini-cli-core/\(oauthFile)",
+ ]
+
+ for path in candidatePaths {
+ if let content = try? String(contentsOfFile: path, encoding: .utf8),
+ let credentials = Self.parseOAuthCredentials(from: content)
+ {
+ return credentials
+ }
+ }
+
+ return Self.extractOAuthCredentialsFromBundle(packageRoot: packageRoot)
+ }
+
+ private static func extractOAuthCredentialsFromBundle(packageRoot: String) -> OAuthClientCredentials? {
+ let bundleRoot = URL(fileURLWithPath: packageRoot).appendingPathComponent("bundle", isDirectory: true)
+ let entryURL = bundleRoot.appendingPathComponent("gemini.js")
+
+ guard FileManager.default.fileExists(atPath: entryURL.path) else {
+ return nil
+ }
+
+ var pendingURLs = [entryURL]
+ var visitedPaths = Set()
+
+ while !pendingURLs.isEmpty {
+ let currentURL = pendingURLs.removeFirst()
+ let standardizedPath = currentURL.standardizedFileURL.path
+ guard visitedPaths.insert(standardizedPath).inserted,
+ let content = try? String(contentsOf: currentURL, encoding: .utf8)
+ else {
+ continue
+ }
+
+ if let credentials = Self.parseOAuthCredentials(from: content) {
+ return credentials
+ }
+
+ let imports = Self.extractRelativeJavaScriptImports(from: content)
+ for importPath in imports {
+ let nextURL = URL(fileURLWithPath: importPath, relativeTo: currentURL.deletingLastPathComponent())
+ .standardizedFileURL
+ guard nextURL.path.hasPrefix(bundleRoot.path) else { continue }
+ pendingURLs.append(nextURL)
}
}
- // Navigate from bin/gemini to the oauth2.js file
- // Homebrew path: .../libexec/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js
- // Bun/npm path: .../node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js (sibling package)
- let binDir = (realPath as NSString).deletingLastPathComponent
+ return nil
+ }
+
+ private static func extractRelativeJavaScriptImports(from content: String) -> [String] {
+ let patterns = [
+ #"(?:import|export)\s+(?:[^;]*?\s+from\s+)?[\"'](\./[^\"']+\.js)[\"']"#,
+ #"import\(\s*[\"'](\./[^\"']+\.js)[\"']\s*\)"#,
+ ]
+
+ var discoveredPaths: [String] = []
+ var seen = Set()
+ let fullRange = NSRange(content.startIndex..., in: content)
+
+ for pattern in patterns {
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
+ for match in regex.matches(in: content, range: fullRange) {
+ guard let range = Range(match.range(at: 1), in: content) else { continue }
+ let path = String(content[range])
+ if seen.insert(path).inserted {
+ discoveredPaths.append(path)
+ }
+ }
+ }
+
+ return discoveredPaths
+ }
+
+ private static func extractOAuthCredentialsFromLegacyPaths(realGeminiPath: String) -> OAuthClientCredentials? {
+ let binDir = (realGeminiPath as NSString).deletingLastPathComponent
let baseDir = (binDir as NSString).deletingLastPathComponent
let oauthSubpath =
@@ -509,8 +769,10 @@ public struct GeminiStatusProbe: Sendable {
]
for path in possiblePaths {
- if let content = try? String(contentsOfFile: path, encoding: .utf8) {
- return self.parseOAuthCredentials(from: content)
+ if let content = try? String(contentsOfFile: path, encoding: .utf8),
+ let credentials = Self.parseOAuthCredentials(from: content)
+ {
+ return credentials
}
}
diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift
new file mode 100644
index 000000000..63df7701e
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+/// One quota row from `coding_plan/remains` `model_remains[]` (Token Plan).
+public struct MiniMaxModelUsage: Sendable, Equatable {
+ public enum WindowKind: Sendable, Equatable {
+ case fiveHour
+ case daily
+ case weekly
+ case other(minutes: Int?)
+ }
+
+ public let identifier: String
+ public let displayName: String
+ public let availablePrompts: Int?
+ public let currentPrompts: Int?
+ public let remainingPrompts: Int?
+ public let windowMinutes: Int?
+ public let usedPercent: Double?
+ public let resetsAt: Date?
+ public let weeklyTotal: Int?
+ public let weeklyUsed: Int?
+ public let weeklyRemaining: Int?
+ public let weeklyUsedPercent: Double?
+ public let weeklyResetsAt: Date?
+ public let window: WindowKind
+
+ public init(
+ identifier: String,
+ displayName: String,
+ availablePrompts: Int?,
+ currentPrompts: Int?,
+ remainingPrompts: Int?,
+ windowMinutes: Int?,
+ usedPercent: Double?,
+ resetsAt: Date?,
+ weeklyTotal: Int?,
+ weeklyUsed: Int?,
+ weeklyRemaining: Int?,
+ weeklyUsedPercent: Double?,
+ weeklyResetsAt: Date?,
+ window: WindowKind)
+ {
+ self.identifier = identifier
+ self.displayName = displayName
+ self.availablePrompts = availablePrompts
+ self.currentPrompts = currentPrompts
+ self.remainingPrompts = remainingPrompts
+ self.windowMinutes = windowMinutes
+ self.usedPercent = usedPercent
+ self.resetsAt = resetsAt
+ self.weeklyTotal = weeklyTotal
+ self.weeklyUsed = weeklyUsed
+ self.weeklyRemaining = weeklyRemaining
+ self.weeklyUsedPercent = weeklyUsedPercent
+ self.weeklyResetsAt = weeklyResetsAt
+ self.window = window
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift
index 1b78131d5..95662158b 100644
--- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift
+++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift
@@ -393,6 +393,15 @@ struct MiniMaxModelRemains: Decodable {
let startTime: Int?
let endTime: Int?
let remainsTime: Int?
+ let currentWeeklyTotalCount: Int?
+ let currentWeeklyUsageCount: Int?
+ let weeklyEndTime: Int?
+ let weeklyRemainsTime: Int?
+ let modelName: String?
+ let modelType: String?
+ let modelId: String?
+ let modelTitle: String?
+ let displayName: String?
private enum CodingKeys: String, CodingKey {
case currentIntervalTotalCount = "current_interval_total_count"
@@ -400,6 +409,16 @@ struct MiniMaxModelRemains: Decodable {
case startTime = "start_time"
case endTime = "end_time"
case remainsTime = "remains_time"
+ case currentWeeklyTotalCount = "current_weekly_total_count"
+ case currentWeeklyUsageCount = "current_weekly_usage_count"
+ case weeklyEndTime = "weekly_end_time"
+ case weeklyRemainsTime = "weekly_remains_time"
+ case modelName = "model_name"
+ case modelType = "model_type"
+ case modelId = "model_id"
+ case modelTitle = "model_title"
+ case displayName = "name"
+ case title
}
init(from decoder: Decoder) throws {
@@ -409,6 +428,19 @@ struct MiniMaxModelRemains: Decodable {
self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime)
self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime)
self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime)
+ self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount)
+ self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount)
+ self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime)
+ self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime)
+ self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName)
+ self.modelType = try container.decodeIfPresent(String.self, forKey: .modelType)
+ self.modelId = try container.decodeIfPresent(String.self, forKey: .modelId)
+ self.modelTitle = try container.decodeIfPresent(String.self, forKey: .modelTitle)
+ if let name = try container.decodeIfPresent(String.self, forKey: .displayName) {
+ self.displayName = name
+ } else {
+ self.displayName = try container.decodeIfPresent(String.self, forKey: .title)
+ }
}
}
@@ -487,7 +519,8 @@ enum MiniMaxUsageParser {
windowMinutes: available?.windowMinutes,
usedPercent: usedPercent,
resetsAt: resetsAt,
- updatedAt: now)
+ updatedAt: now,
+ models: [])
}
static func parseCodingPlanRemains(
@@ -504,7 +537,8 @@ enum MiniMaxUsageParser {
throw MiniMaxUsageError.apiError(message)
}
- guard let first = payload.data.modelRemains.first else {
+ let rows = payload.data.modelRemains
+ guard let first = rows.first else {
throw MiniMaxUsageError.parseFailed("Missing coding plan data.")
}
@@ -533,6 +567,23 @@ enum MiniMaxUsageParser {
nil
}
+ let baseIdentifiers = rows.enumerated().map { index, row in
+ self.modelIdentifierBase(row: row, index: index)
+ }
+ var seenIdentifierCounts: [String: Int] = [:]
+ let identifiers = baseIdentifiers.map { baseIdentifier in
+ let seen = seenIdentifierCounts[baseIdentifier, default: 0]
+ seenIdentifierCounts[baseIdentifier] = seen + 1
+ return seen == 0 ? baseIdentifier : "\(baseIdentifier)#\(seen)"
+ }
+
+ let models = rows.enumerated().map { index, row in
+ self.buildModelUsage(
+ row: row,
+ identifier: identifiers[index],
+ now: now)
+ }
+
return MiniMaxUsageSnapshot(
planName: planName,
availablePrompts: total,
@@ -541,7 +592,129 @@ enum MiniMaxUsageParser {
windowMinutes: windowMinutes,
usedPercent: usedPercent,
resetsAt: resetsAt,
- updatedAt: now)
+ updatedAt: now,
+ models: models)
+ }
+
+ private static func buildModelUsage(
+ row: MiniMaxModelRemains,
+ identifier: String,
+ now: Date) -> MiniMaxModelUsage
+ {
+ let total = row.currentIntervalTotalCount
+ let remaining = row.currentIntervalUsageCount
+ let usedPercent = self.usedPercent(total: total, remaining: remaining)
+ let startDate = self.dateFromEpoch(row.startTime)
+ let endDate = self.dateFromEpoch(row.endTime)
+ let windowMinutes = self.windowMinutes(start: startDate, end: endDate)
+ let resetsAt = self.resetsAt(end: endDate, remains: row.remainsTime, now: now)
+
+ let currentPrompts: Int? = if let total, let remaining {
+ max(0, total - remaining)
+ } else {
+ nil
+ }
+
+ // API 在无周限套餐上可能返回周限占位 0(仅 total、仅 remaining、或两者均为 0)。双 nil 表示未提供周限字段,不归一化。
+ let rawWeeklyTotal = row.currentWeeklyTotalCount
+ let rawWeeklyRemaining = row.currentWeeklyUsageCount
+ let hasAnyWeeklyField = rawWeeklyTotal != nil || rawWeeklyRemaining != nil
+ let noWeeklyCap = hasAnyWeeklyField
+ && (rawWeeklyTotal ?? 0) == 0
+ && (rawWeeklyRemaining ?? 0) == 0
+ let weeklyTotal: Int? = noWeeklyCap ? nil : rawWeeklyTotal
+ let weeklyRemaining: Int? = noWeeklyCap ? nil : rawWeeklyRemaining
+ let weeklyUsed: Int? = if let weeklyTotal, let weeklyRemaining {
+ max(0, weeklyTotal - weeklyRemaining)
+ } else {
+ nil
+ }
+ let weeklyUsedPercent = self.usedPercent(total: weeklyTotal, remaining: weeklyRemaining)
+ let weeklyEndDate = self.dateFromEpoch(row.weeklyEndTime)
+ let weeklyResetsAt: Date? = if noWeeklyCap {
+ nil
+ } else {
+ self.resetsAt(end: weeklyEndDate, remains: row.weeklyRemainsTime, now: now)
+ }
+
+ let displayName = self.modelDisplayName(row: row, identifier: identifier)
+ let windowKind = self.classifyWindowKind(
+ windowMinutes: windowMinutes,
+ start: startDate,
+ end: endDate,
+ hasWeeklyQuota: weeklyTotal != nil || weeklyRemaining != nil,
+ hasIntervalQuota: total != nil || remaining != nil)
+
+ return MiniMaxModelUsage(
+ identifier: identifier,
+ displayName: displayName,
+ availablePrompts: total,
+ currentPrompts: currentPrompts,
+ remainingPrompts: remaining,
+ windowMinutes: windowMinutes,
+ usedPercent: usedPercent,
+ resetsAt: resetsAt,
+ weeklyTotal: weeklyTotal,
+ weeklyUsed: weeklyUsed,
+ weeklyRemaining: weeklyRemaining,
+ weeklyUsedPercent: weeklyUsedPercent,
+ weeklyResetsAt: weeklyResetsAt,
+ window: windowKind)
+ }
+
+ private static func modelIdentifierBase(row: MiniMaxModelRemains, index: Int) -> String {
+ let primary = [
+ row.modelId,
+ row.modelName,
+ row.modelType,
+ row.modelTitle,
+ row.displayName,
+ ]
+ for candidate in primary {
+ let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if !trimmed.isEmpty { return trimmed }
+ }
+ return "model-\(index + 1)"
+ }
+
+ private static func modelDisplayName(row: MiniMaxModelRemains, identifier: String) -> String {
+ let candidates = [
+ row.modelName,
+ row.displayName,
+ row.modelTitle,
+ row.modelType,
+ row.modelId,
+ ]
+ for candidate in candidates {
+ let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if !trimmed.isEmpty { return trimmed }
+ }
+ return identifier
+ }
+
+ private static func classifyWindowKind(
+ windowMinutes: Int?,
+ start: Date?,
+ end: Date?,
+ hasWeeklyQuota: Bool,
+ hasIntervalQuota: Bool) -> MiniMaxModelUsage.WindowKind
+ {
+ if let windowMinutes, windowMinutes == 300 {
+ return .fiveHour
+ }
+ if let windowMinutes, windowMinutes == 24 * 60 {
+ return .daily
+ }
+ if let start, let end {
+ let mins = Int(end.timeIntervalSince(start) / 60)
+ if mins >= 1380, mins <= 1500 {
+ return .daily
+ }
+ }
+ if hasWeeklyQuota, !hasIntervalQuota {
+ return .weekly
+ }
+ return .other(minutes: windowMinutes)
}
private static func usedPercent(total: Int?, remaining: Int?) -> Double? {
diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift
index 09ed671e2..2e5732f77 100644
--- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift
@@ -9,6 +9,8 @@ public struct MiniMaxUsageSnapshot: Sendable {
public let usedPercent: Double?
public let resetsAt: Date?
public let updatedAt: Date
+ /// All rows from `model_remains` (first row mirrors the scalar fields above).
+ public let models: [MiniMaxModelUsage]
public init(
planName: String?,
@@ -18,7 +20,8 @@ public struct MiniMaxUsageSnapshot: Sendable {
windowMinutes: Int?,
usedPercent: Double?,
resetsAt: Date?,
- updatedAt: Date)
+ updatedAt: Date,
+ models: [MiniMaxModelUsage] = [])
{
self.planName = planName
self.availablePrompts = availablePrompts
@@ -28,6 +31,7 @@ public struct MiniMaxUsageSnapshot: Sendable {
self.usedPercent = usedPercent
self.resetsAt = resetsAt
self.updatedAt = updatedAt
+ self.models = models
}
}
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift
new file mode 100644
index 000000000..0bd65cb44
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift
@@ -0,0 +1,101 @@
+import Foundation
+
+#if os(macOS)
+import SweetCookieKit
+
+private let mistralCookieImportOrder: BrowserCookieImportOrder =
+ ProviderDefaults.metadata[.mistral]?.browserCookieOrder ?? Browser.defaultImportOrder
+
+public enum MistralCookieImporter {
+ private static let cookieClient = BrowserCookieClient()
+ private static let cookieDomains = ["mistral.ai", "admin.mistral.ai", "auth.mistral.ai"]
+
+ public struct SessionInfo: Sendable {
+ public let cookies: [HTTPCookie]
+ public let sourceLabel: String
+
+ public init(cookies: [HTTPCookie], sourceLabel: String) {
+ self.cookies = cookies
+ self.sourceLabel = sourceLabel
+ }
+
+ public var cookieHeader: String {
+ self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
+ }
+
+ /// Extracts the CSRF token from the `csrftoken` cookie for the `X-CSRFTOKEN` header.
+ public var csrfToken: String? {
+ self.cookies.first { $0.name == "csrftoken" }?.value
+ }
+ }
+
+ /// Returns `true` if any cookie name starts with `ory_session_` (the Ory Kratos session cookie).
+ private static func hasSessionCookie(_ cookies: [HTTPCookie]) -> Bool {
+ cookies.contains { $0.name.hasPrefix("ory_session_") }
+ }
+
+ public static func importSession(
+ browserDetection: BrowserDetection,
+ preferredBrowsers: [Browser] = [.chrome],
+ logger: ((String) -> Void)? = nil) throws -> SessionInfo
+ {
+ let log: (String) -> Void = { msg in logger?("[mistral-cookie] \(msg)") }
+ let installedBrowsers = preferredBrowsers.isEmpty
+ ? mistralCookieImportOrder.cookieImportCandidates(using: browserDetection)
+ : preferredBrowsers.cookieImportCandidates(using: browserDetection)
+
+ for browserSource in installedBrowsers {
+ do {
+ let query = BrowserCookieQuery(domains: self.cookieDomains)
+ let sources = try Self.cookieClient.records(
+ matching: query,
+ in: browserSource,
+ logger: log)
+ for source in sources where !source.records.isEmpty {
+ let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
+ if !httpCookies.isEmpty {
+ guard Self.hasSessionCookie(httpCookies) else {
+ log("Skipping \(source.label) cookies: missing ory_session_* cookie")
+ continue
+ }
+ log("Found \(httpCookies.count) Mistral cookies in \(source.label)")
+ return SessionInfo(cookies: httpCookies, sourceLabel: source.label)
+ }
+ }
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)")
+ }
+ }
+
+ throw MistralCookieImportError.noCookies
+ }
+
+ public static func hasSession(
+ browserDetection: BrowserDetection,
+ preferredBrowsers: [Browser] = [.chrome],
+ logger: ((String) -> Void)? = nil) -> Bool
+ {
+ do {
+ _ = try self.importSession(
+ browserDetection: browserDetection,
+ preferredBrowsers: preferredBrowsers,
+ logger: logger)
+ return true
+ } catch {
+ return false
+ }
+ }
+}
+
+enum MistralCookieImportError: LocalizedError {
+ case noCookies
+
+ var errorDescription: String? {
+ switch self {
+ case .noCookies:
+ "No Mistral session cookies found in browsers."
+ }
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift b/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift
new file mode 100644
index 000000000..1c8ab4ae4
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift
@@ -0,0 +1,35 @@
+import Foundation
+
+public enum MistralUsageError: LocalizedError, Sendable {
+ case missingCookie
+ case invalidCredentials
+ case apiError(String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCookie:
+ "No Mistral session cookies found in browsers."
+ case .invalidCredentials:
+ "Mistral session expired or invalid (HTTP 401/403)."
+ case let .apiError(detail):
+ "Mistral API error: \(detail)"
+ case let .parseFailed(detail):
+ "Failed to parse Mistral billing response: \(detail)"
+ }
+ }
+}
+
+enum MistralSettingsError: LocalizedError {
+ case missingCookie
+ case invalidCookie
+
+ var errorDescription: String? {
+ switch self {
+ case .missingCookie:
+ "No Mistral session cookies found in browsers."
+ case .invalidCookie:
+ "Mistral cookie header is invalid or missing ory_session cookie."
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift
new file mode 100644
index 000000000..cdd81427e
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift
@@ -0,0 +1,124 @@
+import Foundation
+
+// MARK: - API Response Models
+
+/// Top-level response from `GET https://admin.mistral.ai/api/billing/v2/usage`.
+struct MistralBillingResponse: Codable {
+ let completion: MistralModelUsageCategory?
+ let ocr: MistralModelUsageCategory?
+ let connectors: MistralModelUsageCategory?
+ let librariesApi: MistralLibrariesUsageCategory?
+ let fineTuning: MistralFineTuningCategory?
+ let audio: MistralModelUsageCategory?
+ let vibeUsage: Double?
+ let date: String?
+ let previousMonth: String?
+ let nextMonth: String?
+ let startDate: String?
+ let endDate: String?
+ let currency: String?
+ let currencySymbol: String?
+ let prices: [MistralPrice]?
+
+ enum CodingKeys: String, CodingKey {
+ case completion, ocr, connectors, audio, date, currency, prices
+ case librariesApi = "libraries_api"
+ case fineTuning = "fine_tuning"
+ case vibeUsage = "vibe_usage"
+ case previousMonth = "previous_month"
+ case nextMonth = "next_month"
+ case startDate = "start_date"
+ case endDate = "end_date"
+ case currencySymbol = "currency_symbol"
+ }
+}
+
+struct MistralModelUsageCategory: Codable {
+ let models: [String: MistralModelUsageData]?
+}
+
+struct MistralLibrariesUsageCategory: Codable {
+ let pages: MistralModelUsageCategory?
+ let tokens: MistralModelUsageCategory?
+}
+
+struct MistralFineTuningCategory: Codable {
+ let training: [String: MistralModelUsageData]?
+ let storage: [String: MistralModelUsageData]?
+}
+
+struct MistralModelUsageData: Codable {
+ let input: [MistralUsageEntry]?
+ let output: [MistralUsageEntry]?
+ let cached: [MistralUsageEntry]?
+}
+
+struct MistralUsageEntry: Codable {
+ let usageType: String?
+ let eventType: String?
+ let billingMetric: String?
+ let billingDisplayName: String?
+ let billingGroup: String?
+ let timestamp: String?
+ let value: Int?
+ let valuePaid: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case timestamp, value
+ case usageType = "usage_type"
+ case eventType = "event_type"
+ case billingMetric = "billing_metric"
+ case billingDisplayName = "billing_display_name"
+ case billingGroup = "billing_group"
+ case valuePaid = "value_paid"
+ }
+}
+
+struct MistralPrice: Codable {
+ let eventType: String?
+ let billingMetric: String?
+ let billingGroup: String?
+ let price: String?
+
+ enum CodingKeys: String, CodingKey {
+ case price
+ case eventType = "event_type"
+ case billingMetric = "billing_metric"
+ case billingGroup = "billing_group"
+ }
+}
+
+// MARK: - Intermediate Snapshot
+
+public struct MistralUsageSnapshot: Sendable {
+ public let totalCost: Double
+ public let currency: String
+ public let currencySymbol: String
+ public let totalInputTokens: Int
+ public let totalOutputTokens: Int
+ public let totalCachedTokens: Int
+ public let modelCount: Int
+ public let startDate: Date?
+ public let endDate: Date?
+ public let updatedAt: Date
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ let resetDate = self.endDate.map { Calendar.current.date(byAdding: .second, value: 1, to: $0) ?? $0 }
+ let costDescription = if self.totalCost > 0 {
+ "\(self.currencySymbol)\(String(format: "%.4f", self.totalCost)) this month"
+ } else {
+ "No usage this month"
+ }
+ let primary = RateWindow(
+ usedPercent: 0,
+ windowMinutes: nil,
+ resetsAt: resetDate,
+ resetDescription: costDescription)
+ return UsageSnapshot(
+ primary: primary,
+ secondary: nil,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: nil)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift
new file mode 100644
index 000000000..914a2c5e8
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift
@@ -0,0 +1,121 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum MistralProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .mistral,
+ metadata: ProviderMetadata(
+ id: .mistral,
+ displayName: "Mistral",
+ sessionLabel: "Monthly",
+ weeklyLabel: "",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Mistral usage",
+ cliName: "mistral",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder,
+ dashboardURL: "https://admin.mistral.ai/organization/usage",
+ statusPageURL: nil,
+ statusLinkURL: "https://status.mistral.ai"),
+ branding: ProviderBranding(
+ iconStyle: .mistral,
+ iconResourceName: "ProviderIcon-mistral",
+ color: ProviderColor(red: 255 / 255, green: 80 / 255, blue: 15 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Mistral cost summary is not yet supported." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .web],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MistralWebFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "mistral",
+ aliases: ["mistral-ai"],
+ versionDetector: nil))
+ }
+}
+
+struct MistralWebFetchStrategy: ProviderFetchStrategy {
+ let id: String = "mistral.web"
+ let kind: ProviderFetchKind = .web
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ guard context.settings?.mistral?.cookieSource != .off else { return false }
+ return true
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let cookieSource = context.settings?.mistral?.cookieSource ?? .auto
+ do {
+ let (cookieHeader, csrfToken) = try Self.resolveCookieHeader(context: context, allowCached: true)
+ let snapshot = try await MistralUsageFetcher.fetchUsage(
+ cookieHeader: cookieHeader,
+ csrfToken: csrfToken,
+ timeout: context.webTimeout)
+ return self.makeResult(
+ usage: snapshot.toUsageSnapshot(),
+ sourceLabel: "web")
+ } catch MistralUsageError.invalidCredentials where cookieSource != .manual {
+ #if os(macOS)
+ CookieHeaderCache.clear(provider: .mistral)
+ let (cookieHeader, csrfToken) = try Self.resolveCookieHeader(context: context, allowCached: false)
+ let snapshot = try await MistralUsageFetcher.fetchUsage(
+ cookieHeader: cookieHeader,
+ csrfToken: csrfToken,
+ timeout: context.webTimeout)
+ return self.makeResult(
+ usage: snapshot.toUsageSnapshot(),
+ sourceLabel: "web")
+ #else
+ throw MistralUsageError.invalidCredentials
+ #endif
+ }
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+
+ private static func resolveCookieHeader(
+ context: ProviderFetchContext,
+ allowCached: Bool) throws -> (cookieHeader: String, csrfToken: String?)
+ {
+ if let settings = context.settings?.mistral, settings.cookieSource == .manual {
+ if let header = CookieHeaderNormalizer.normalize(settings.manualCookieHeader) {
+ let pairs = CookieHeaderNormalizer.pairs(from: header)
+ let hasSessionCookie = pairs.contains { $0.name.hasPrefix("ory_session_") }
+ if hasSessionCookie {
+ let csrfToken = pairs.first { $0.name == "csrftoken" }?.value
+ return (header, csrfToken)
+ }
+ }
+ throw MistralSettingsError.invalidCookie
+ }
+
+ #if os(macOS)
+ if allowCached,
+ let cached = CookieHeaderCache.load(provider: .mistral),
+ !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ {
+ let pairs = CookieHeaderNormalizer.pairs(from: cached.cookieHeader)
+ let csrfToken = pairs.first { $0.name == "csrftoken" }?.value
+ return (cached.cookieHeader, csrfToken)
+ }
+ let session = try MistralCookieImporter.importSession(browserDetection: context.browserDetection)
+ CookieHeaderCache.store(
+ provider: .mistral,
+ cookieHeader: session.cookieHeader,
+ sourceLabel: session.sourceLabel)
+ return (session.cookieHeader, session.csrfToken)
+ #else
+ throw MistralSettingsError.missingCookie
+ #endif
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift
new file mode 100644
index 000000000..1e4925444
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift
@@ -0,0 +1,197 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public enum MistralUsageFetcher {
+ private static let baseURL = URL(string: "https://admin.mistral.ai")!
+
+ public static func fetchUsage(
+ cookieHeader: String,
+ csrfToken: String?,
+ timeout: TimeInterval = 15) async throws -> MistralUsageSnapshot
+ {
+ let now = Date()
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: "UTC")!
+ let month = calendar.component(.month, from: now)
+ let year = calendar.component(.year, from: now)
+
+ let usagePath = self.baseURL.appendingPathComponent("/api/billing/v2/usage")
+ var components = URLComponents(url: usagePath, resolvingAgainstBaseURL: false)!
+ components.queryItems = [
+ URLQueryItem(name: "month", value: "\(month)"),
+ URLQueryItem(name: "year", value: "\(year)"),
+ ]
+ guard let url = components.url else {
+ throw MistralUsageError.apiError("Failed to construct URL")
+ }
+
+ var request = URLRequest(url: url, timeoutInterval: timeout)
+ request.setValue("*/*", forHTTPHeaderField: "Accept")
+ request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
+ request.setValue("https://admin.mistral.ai/organization/usage", forHTTPHeaderField: "Referer")
+ request.setValue("https://admin.mistral.ai", forHTTPHeaderField: "Origin")
+ if let csrfToken {
+ request.setValue(csrfToken, forHTTPHeaderField: "X-CSRFTOKEN")
+ }
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw MistralUsageError.apiError("Invalid response type")
+ }
+
+ switch httpResponse.statusCode {
+ case 200:
+ break
+ case 401, 403:
+ throw MistralUsageError.invalidCredentials
+ default:
+ let body = String(data: data.prefix(200), encoding: .utf8) ?? ""
+ throw MistralUsageError.apiError("HTTP \(httpResponse.statusCode): \(body)")
+ }
+
+ return try Self.parseResponse(data: data, updatedAt: now)
+ }
+
+ static func parseResponse(data: Data, updatedAt: Date) throws -> MistralUsageSnapshot {
+ let decoder = JSONDecoder()
+ let billing: MistralBillingResponse
+ do {
+ billing = try decoder.decode(MistralBillingResponse.self, from: data)
+ } catch {
+ throw MistralUsageError.parseFailed(error.localizedDescription)
+ }
+
+ let prices = Self.buildPriceIndex(billing.prices ?? [])
+ var totalCost: Double = 0
+ var totalInput = 0
+ var totalOutput = 0
+ var totalCached = 0
+ var modelCount = 0
+
+ // Aggregate completion tokens
+ if let models = billing.completion?.models {
+ for (_, modelData) in models {
+ modelCount += 1
+ let (input, output, cached, cost) = Self.aggregateModel(modelData, prices: prices)
+ totalInput += input
+ totalOutput += output
+ totalCached += cached
+ totalCost += cost
+ }
+ }
+
+ // Aggregate OCR, connectors, audio if present
+ for category in [billing.ocr, billing.connectors, billing.audio] {
+ if let models = category?.models {
+ for (_, modelData) in models {
+ let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices)
+ totalCost += cost
+ }
+ }
+ }
+
+ // Aggregate libraries_api (pages + tokens)
+ for category in [billing.librariesApi?.pages, billing.librariesApi?.tokens] {
+ if let models = category?.models {
+ for (_, modelData) in models {
+ let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices)
+ totalCost += cost
+ }
+ }
+ }
+
+ // Aggregate fine_tuning (training + storage)
+ for models in [billing.fineTuning?.training, billing.fineTuning?.storage] {
+ if let models {
+ for (_, modelData) in models {
+ let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices)
+ totalCost += cost
+ }
+ }
+ }
+
+ let currency = billing.currency ?? "EUR"
+ let currencySymbol = billing.currencySymbol ?? "€"
+
+ let startDate = billing.startDate.flatMap { Self.parseDate($0) }
+ let endDate = billing.endDate.flatMap { Self.parseDate($0) }
+
+ return MistralUsageSnapshot(
+ totalCost: totalCost,
+ currency: currency,
+ currencySymbol: currencySymbol,
+ totalInputTokens: totalInput,
+ totalOutputTokens: totalOutput,
+ totalCachedTokens: totalCached,
+ modelCount: modelCount,
+ startDate: startDate,
+ endDate: endDate,
+ updatedAt: updatedAt)
+ }
+
+ // MARK: - Private Helpers
+
+ private static func buildPriceIndex(_ prices: [MistralPrice]) -> [String: Double] {
+ var index: [String: Double] = [:]
+ for price in prices {
+ guard let metric = price.billingMetric,
+ let group = price.billingGroup,
+ let priceStr = price.price,
+ let value = Double(priceStr)
+ else { continue }
+ let key = "\(metric)::\(group)"
+ index[key] = value
+ }
+ return index
+ }
+
+ private static func aggregateModel(
+ _ data: MistralModelUsageData,
+ prices: [String: Double]) -> (input: Int, output: Int, cached: Int, cost: Double)
+ {
+ var totalInput = 0
+ var totalOutput = 0
+ var totalCached = 0
+ var totalCost: Double = 0
+
+ for entry in data.input ?? [] {
+ let tokens = entry.valuePaid ?? entry.value ?? 0
+ totalInput += tokens
+ if let metric = entry.billingMetric, let group = entry.billingGroup {
+ let pricePerToken = prices["\(metric)::\(group)"] ?? 0
+ totalCost += Double(tokens) * pricePerToken
+ }
+ }
+
+ for entry in data.output ?? [] {
+ let tokens = entry.valuePaid ?? entry.value ?? 0
+ totalOutput += tokens
+ if let metric = entry.billingMetric, let group = entry.billingGroup {
+ let pricePerToken = prices["\(metric)::\(group)"] ?? 0
+ totalCost += Double(tokens) * pricePerToken
+ }
+ }
+
+ for entry in data.cached ?? [] {
+ let tokens = entry.valuePaid ?? entry.value ?? 0
+ totalCached += tokens
+ if let metric = entry.billingMetric, let group = entry.billingGroup {
+ let pricePerToken = prices["\(metric)::\(group)"] ?? 0
+ totalCost += Double(tokens) * pricePerToken
+ }
+ }
+
+ return (totalInput, totalOutput, totalCached, totalCost)
+ }
+
+ private static func parseDate(_ string: String) -> Date? {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = formatter.date(from: string) { return date }
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.date(from: string)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 6fb994efc..d98204b8e 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -79,6 +79,7 @@ public enum ProviderDescriptorRegistry {
.warp: WarpProviderDescriptor.descriptor,
.perplexity: PerplexityProviderDescriptor.descriptor,
.abacus: AbacusProviderDescriptor.descriptor,
+ .mistral: MistralProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
for provider in UsageProvider.allCases {
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index 63aa6221c..2692c4920 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -21,7 +21,8 @@ public struct ProviderSettingsSnapshot: Sendable {
ollama: OllamaProviderSettings? = nil,
jetbrains: JetBrainsProviderSettings? = nil,
perplexity: PerplexityProviderSettings? = nil,
- abacus: AbacusProviderSettings? = nil) -> ProviderSettingsSnapshot
+ abacus: AbacusProviderSettings? = nil,
+ mistral: MistralProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot(
debugMenuEnabled: debugMenuEnabled,
@@ -43,7 +44,8 @@ public struct ProviderSettingsSnapshot: Sendable {
ollama: ollama,
jetbrains: jetbrains,
perplexity: perplexity,
- abacus: abacus)
+ abacus: abacus,
+ mistral: mistral)
}
public struct CodexProviderSettings: Sendable {
@@ -244,6 +246,16 @@ public struct ProviderSettingsSnapshot: Sendable {
}
}
+ public struct MistralProviderSettings: Sendable {
+ public let cookieSource: ProviderCookieSource
+ public let manualCookieHeader: String?
+
+ public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) {
+ self.cookieSource = cookieSource
+ self.manualCookieHeader = manualCookieHeader
+ }
+ }
+
public let debugMenuEnabled: Bool
public let debugKeepCLISessionsAlive: Bool
public let codex: CodexProviderSettings?
@@ -264,6 +276,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let jetbrains: JetBrainsProviderSettings?
public let perplexity: PerplexityProviderSettings?
public let abacus: AbacusProviderSettings?
+ public let mistral: MistralProviderSettings?
public var jetbrainsIDEBasePath: String? {
self.jetbrains?.ideBasePath
@@ -289,7 +302,8 @@ public struct ProviderSettingsSnapshot: Sendable {
ollama: OllamaProviderSettings?,
jetbrains: JetBrainsProviderSettings? = nil,
perplexity: PerplexityProviderSettings? = nil,
- abacus: AbacusProviderSettings? = nil)
+ abacus: AbacusProviderSettings? = nil,
+ mistral: MistralProviderSettings? = nil)
{
self.debugMenuEnabled = debugMenuEnabled
self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive
@@ -311,6 +325,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.jetbrains = jetbrains
self.perplexity = perplexity
self.abacus = abacus
+ self.mistral = mistral
}
}
@@ -333,6 +348,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings)
case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings)
case abacus(ProviderSettingsSnapshot.AbacusProviderSettings)
+ case mistral(ProviderSettingsSnapshot.MistralProviderSettings)
}
public struct ProviderSettingsSnapshotBuilder: Sendable {
@@ -356,6 +372,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings?
public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings?
public var abacus: ProviderSettingsSnapshot.AbacusProviderSettings?
+ public var mistral: ProviderSettingsSnapshot.MistralProviderSettings?
public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) {
self.debugMenuEnabled = debugMenuEnabled
@@ -382,6 +399,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .jetbrains(value): self.jetbrains = value
case let .perplexity(value): self.perplexity = value
case let .abacus(value): self.abacus = value
+ case let .mistral(value): self.mistral = value
}
}
@@ -406,6 +424,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
ollama: self.ollama,
jetbrains: self.jetbrains,
perplexity: self.perplexity,
- abacus: self.abacus)
+ abacus: self.abacus,
+ mistral: self.mistral)
}
}
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 83dade054..22493fa0f 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -29,6 +29,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case openrouter
case perplexity
case abacus
+ case mistral
}
// swiftformat:enable sortDeclarations
@@ -60,6 +61,7 @@ public enum IconStyle: Sendable, CaseIterable {
case openrouter
case perplexity
case abacus
+ case mistral
case combined
}
diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift
index 550ab9190..b0501408d 100644
--- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift
@@ -10,12 +10,12 @@ public enum SyntheticProviderDescriptor {
metadata: ProviderMetadata(
id: .synthetic,
displayName: "Synthetic",
- sessionLabel: "Quota",
- weeklyLabel: "Usage",
- opusLabel: nil,
- supportsOpus: false,
+ sessionLabel: "Five-hour quota",
+ weeklyLabel: "Weekly tokens",
+ opusLabel: "Search hourly",
+ supportsOpus: true,
supportsCredits: false,
- creditsHint: "",
+ creditsHint: "Weekly token quota regenerates continuously.",
toggleTitle: "Show Synthetic usage",
cliName: "synthetic",
defaultEnabled: false,
diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift
index 198c42c25..f3e2edff8 100644
--- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift
+++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift
@@ -9,29 +9,45 @@ public struct SyntheticQuotaEntry: Sendable {
public let windowMinutes: Int?
public let resetsAt: Date?
public let resetDescription: String?
+ public let nextRegenPercent: Double?
+ public let cost: ProviderCostSnapshot?
public init(
label: String?,
usedPercent: Double,
windowMinutes: Int?,
resetsAt: Date?,
- resetDescription: String?)
+ resetDescription: String?,
+ nextRegenPercent: Double? = nil,
+ cost: ProviderCostSnapshot? = nil)
{
self.label = label
self.usedPercent = usedPercent
self.windowMinutes = windowMinutes
self.resetsAt = resetsAt
self.resetDescription = resetDescription
+ self.nextRegenPercent = nextRegenPercent
+ self.cost = cost
}
}
public struct SyntheticUsageSnapshot: Sendable {
public let quotas: [SyntheticQuotaEntry]
+ /// Slot-identified lanes for the known Synthetic response shape: [rolling-5h, weekly, search-hourly].
+ /// When set, `toUsageSnapshot` maps slot 0 → primary, slot 1 → secondary, slot 2 → tertiary,
+ /// so a missing lane stays nil instead of promoting the next lane into the wrong UI label.
+ public let slottedQuotas: [SyntheticQuotaEntry?]?
public let planName: String?
public let updatedAt: Date
- public init(quotas: [SyntheticQuotaEntry], planName: String?, updatedAt: Date) {
+ public init(
+ quotas: [SyntheticQuotaEntry],
+ slottedQuotas: [SyntheticQuotaEntry?]? = nil,
+ planName: String?,
+ updatedAt: Date)
+ {
self.quotas = quotas
+ self.slottedQuotas = slottedQuotas
self.planName = planName
self.updatedAt = updatedAt
}
@@ -39,11 +55,13 @@ public struct SyntheticUsageSnapshot: Sendable {
extension SyntheticUsageSnapshot {
public func toUsageSnapshot() -> UsageSnapshot {
- let primaryEntry = self.quotas.first
- let secondaryEntry = self.quotas.dropFirst().first
+ let slots = self.slottedQuotas
+ ?? [self.quotas.first, self.quotas.dropFirst().first, self.quotas.dropFirst(2).first]
+ let entries: [SyntheticQuotaEntry?] = (0..<3).map { slots.indices.contains($0) ? slots[$0] : nil }
- let primary = primaryEntry.map(Self.rateWindow(for:))
- let secondary = secondaryEntry.map(Self.rateWindow(for:))
+ let primary = entries[0].map(Self.rateWindow(for:))
+ let secondary = entries[1].map(Self.rateWindow(for:))
+ let tertiary = entries[2].map(Self.rateWindow(for:))
let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines)
let loginMethod = (planName?.isEmpty ?? true) ? nil : planName
@@ -56,8 +74,8 @@ extension SyntheticUsageSnapshot {
return UsageSnapshot(
primary: primary,
secondary: secondary,
- tertiary: nil,
- providerCost: nil,
+ tertiary: tertiary,
+ providerCost: self.quotas.first(where: { $0.cost != nil })?.cost,
updatedAt: self.updatedAt,
identity: identity)
}
@@ -67,7 +85,8 @@ extension SyntheticUsageSnapshot {
usedPercent: quota.usedPercent,
windowMinutes: quota.windowMinutes,
resetsAt: quota.resetsAt,
- resetDescription: quota.resetDescription)
+ resetDescription: quota.resetDescription,
+ nextRegenPercent: quota.nextRegenPercent)
}
}
@@ -147,20 +166,46 @@ enum SyntheticUsageParser {
}()
let planName = self.planName(from: root)
- let quotaObjects = self.quotaObjects(from: root)
- let quotas = quotaObjects.compactMap { self.parseQuota($0) }
+ if let slots = self.prioritizedQuotaSlots(from: root) {
+ let slotted: [SyntheticQuotaEntry?] = slots.map { $0.flatMap(self.parseQuota) }
+ let flat = slotted.compactMap(\.self)
+ guard !flat.isEmpty else {
+ throw SyntheticUsageError.parseFailed("Missing quota data.")
+ }
+ return SyntheticUsageSnapshot(
+ quotas: flat,
+ slottedQuotas: slotted,
+ planName: planName,
+ updatedAt: now)
+ }
+
+ let quotas = self.fallbackQuotaObjects(from: root).compactMap(self.parseQuota)
guard !quotas.isEmpty else {
throw SyntheticUsageError.parseFailed("Missing quota data.")
}
-
return SyntheticUsageSnapshot(
quotas: quotas,
planName: planName,
updatedAt: now)
}
- private static func quotaObjects(from root: [String: Any]) -> [[String: Any]] {
+ /// Returns slot-positional quota payloads `[rolling-5h, weekly, search-hourly]` when the known Synthetic
+ /// response shape is detected. Missing lanes stay nil in their slot so downstream code doesn't shift
+ /// labels. Returns nil if none of the known keys appear, so the fallback path runs.
+ private static func prioritizedQuotaSlots(from root: [String: Any]) -> [[String: Any]?]? {
+ let dataDict = root["data"] as? [String: Any]
+ let rolling = self.namedQuota(root["rollingFiveHourLimit"], label: "Rolling five-hour limit")
+ ?? self.namedQuota(dataDict?["rollingFiveHourLimit"], label: "Rolling five-hour limit")
+ let weekly = self.namedQuota(root["weeklyTokenLimit"], label: "Weekly token limit")
+ ?? self.namedQuota(dataDict?["weeklyTokenLimit"], label: "Weekly token limit")
+ let searchHourly = self.namedQuota((root["search"] as? [String: Any])?["hourly"], label: "Search hourly")
+ ?? self.namedQuota((dataDict?["search"] as? [String: Any])?["hourly"], label: "Search hourly")
+ let slots: [[String: Any]?] = [rolling, weekly, searchHourly]
+ return slots.contains(where: { $0 != nil }) ? slots : nil
+ }
+
+ private static func fallbackQuotaObjects(from root: [String: Any]) -> [[String: Any]] {
let dataDict = root["data"] as? [String: Any]
let candidates: [Any?] = [
root["quotas"],
@@ -179,14 +224,8 @@ enum SyntheticUsageParser {
]
for candidate in candidates {
- if let array = candidate as? [[String: Any]] { return array }
- if let array = candidate as? [Any] {
- let dicts = array.compactMap { $0 as? [String: Any] }
- if !dicts.isEmpty { return dicts }
- }
- if let dict = candidate as? [String: Any], self.isQuotaPayload(dict) {
- return [dict]
- }
+ let quotas = self.extractQuotaObjects(from: candidate)
+ if !quotas.isEmpty { return quotas }
}
return []
}
@@ -239,14 +278,22 @@ enum SyntheticUsageParser {
let windowMinutes = windowMinutes(from: payload)
let resetsAt = self.firstDate(in: payload, keys: self.resetKeys)
+ // Leave resetDescription nil when resetsAt is set so the UI rebuilds the countdown each render
+ // against the current clock instead of freezing a stale "in Xm" string at parse time.
let resetDescription = resetsAt == nil ? self.windowDescription(minutes: windowMinutes) : nil
+ let cost = self.providerCost(from: payload, usedPercent: clamped, resetsAt: resetsAt)
+ let nextRegenPercent = self.normalizedPercent(
+ self.firstDouble(in: payload, keys: Self.tickPercentKeys))
+
return SyntheticQuotaEntry(
label: label,
usedPercent: clamped,
windowMinutes: windowMinutes,
resetsAt: resetsAt,
- resetDescription: resetDescription)
+ resetDescription: resetDescription,
+ nextRegenPercent: nextRegenPercent,
+ cost: cost)
}
private static func isQuotaPayload(_ payload: [String: Any]) -> Bool {
@@ -271,9 +318,78 @@ enum SyntheticUsageParser {
if let seconds = self.firstDouble(in: payload, keys: windowSecondsKeys) {
return Int((seconds / 60).rounded())
}
+ if let text = self.firstString(in: payload, keys: windowStringKeys) {
+ return self.windowMinutes(fromText: text)
+ }
return nil
}
+ private static func namedQuota(_ candidate: Any?, label: String) -> [String: Any]? {
+ guard var payload = candidate as? [String: Any], self.isQuotaPayload(payload) else { return nil }
+ if payload["label"] == nil, payload["name"] == nil {
+ payload["label"] = label
+ }
+ return payload
+ }
+
+ private static func extractQuotaObjects(from candidate: Any?) -> [[String: Any]] {
+ switch candidate {
+ case let array as [[String: Any]]:
+ var nestedQuotas: [[String: Any]] = []
+ for entry in array {
+ if self.isQuotaPayload(entry) {
+ nestedQuotas.append(entry)
+ } else {
+ nestedQuotas.append(contentsOf: self.extractQuotaObjects(from: entry))
+ }
+ }
+ return nestedQuotas
+ case let array as [Any]:
+ return array.flatMap { self.extractQuotaObjects(from: $0) }
+ case let dict as [String: Any]:
+ if self.isQuotaPayload(dict) {
+ return [dict]
+ }
+ var nestedQuotas: [[String: Any]] = []
+ for key in dict.keys.sorted() {
+ nestedQuotas.append(contentsOf: self.extractQuotaObjects(from: dict[key]))
+ }
+ return nestedQuotas
+ default:
+ return []
+ }
+ }
+
+ /// Parses durations like `"5hr"`, `"30min"`, `"2 days"`. Suffixes are sorted longest-first so
+ /// multi-letter units always win over their single-letter aliases — no ordering surprises if a
+ /// future unit shares a trailing letter with another.
+ static func windowMinutes(fromText text: String) -> Int? {
+ let normalized = text
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ .replacingOccurrences(of: " ", with: "")
+ guard !normalized.isEmpty else { return nil }
+
+ for (suffix, multiplier) in Self.windowSuffixMultipliers {
+ guard normalized.hasSuffix(suffix) else { continue }
+ let valueText = String(normalized.dropLast(suffix.count))
+ guard let value = Double(valueText), value > 0 else { return nil }
+ return Int((value * multiplier).rounded())
+ }
+ return nil
+ }
+
+ private static let windowSuffixMultipliers: [(suffix: String, multiplier: Double)] = {
+ let raw: [(String, Double)] = [
+ ("minutes", 1), ("minute", 1), ("mins", 1), ("min", 1), ("m", 1),
+ ("hours", 60), ("hour", 60), ("hrs", 60), ("hr", 60), ("h", 60),
+ ("days", 24 * 60), ("day", 24 * 60), ("d", 24 * 60),
+ ]
+ return raw
+ .sorted { $0.0.count > $1.0.count }
+ .map { (suffix: $0.0, multiplier: $0.1) }
+ }()
+
private static func windowDescription(minutes: Int?) -> String? {
guard let minutes, minutes > 0 else { return nil }
let dayMinutes = 24 * 60
@@ -288,6 +404,57 @@ enum SyntheticUsageParser {
return "\(minutes) minute\(minutes == 1 ? "" : "s") window"
}
+ private static func providerCost(
+ from payload: [String: Any],
+ usedPercent: Double,
+ resetsAt: Date?) -> ProviderCostSnapshot?
+ {
+ guard let limit = self.firstCurrency(in: payload, keys: self.costLimitKeys) else { return nil }
+
+ let remaining = self.firstCurrency(in: payload, keys: self.costRemainingKeys)
+ let usedFromPayload = self.firstCurrency(in: payload, keys: self.costUsedKeys)
+ let nextRegenAmount = self.firstCurrency(in: payload, keys: self.regenAmountKeys)
+ let used = if let usedFromPayload {
+ usedFromPayload
+ } else if let remaining {
+ max(0, limit - remaining)
+ } else {
+ (usedPercent.clamped(to: 0...100) / 100) * limit
+ }
+
+ return ProviderCostSnapshot(
+ used: used,
+ limit: limit,
+ currencyCode: "USD",
+ period: "Weekly",
+ resetsAt: resetsAt,
+ nextRegenAmount: nextRegenAmount,
+ updatedAt: Date())
+ }
+
+ private static func firstCurrency(in payload: [String: Any], keys: [String]) -> Double? {
+ for key in keys {
+ guard let value = payload[key] else { continue }
+ if let text = value as? String,
+ let parsed = self.parseCurrency(text)
+ {
+ return parsed
+ }
+ if let number = self.doubleValue(value) {
+ return number
+ }
+ }
+ return nil
+ }
+
+ private static func parseCurrency(_ text: String) -> Double? {
+ let cleaned = text
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: "$", with: "")
+ .replacingOccurrences(of: ",", with: "")
+ return Double(cleaned)
+ }
+
private static func normalizedPercent(_ value: Double?) -> Double? {
guard let value else { return nil }
if value <= 1 { return value * 100 }
@@ -423,6 +590,13 @@ enum SyntheticUsageParser {
private static let limitKeys = [
"limit",
+ "messageLimit",
+ "message_limit",
+ "messages",
+ "maxRequests",
+ "max_requests",
+ "requestLimit",
+ "request_limit",
"quota",
"max",
"total",
@@ -433,6 +607,10 @@ enum SyntheticUsageParser {
private static let usedKeys = [
"used",
"usage",
+ "usedMessages",
+ "used_messages",
+ "messagesUsed",
+ "messages_used",
"requests",
"requestCount",
"request_count",
@@ -456,6 +634,10 @@ enum SyntheticUsageParser {
"renew_at",
"renewsAt",
"renews_at",
+ "nextTickAt",
+ "next_tick_at",
+ "nextRegenAt",
+ "next_regen_at",
"periodEnd",
"period_end",
"expiresAt",
@@ -464,6 +646,33 @@ enum SyntheticUsageParser {
"end_at",
]
+ private static let regenAmountKeys = [
+ "nextRegenCredits",
+ "next_regen_credits",
+ ]
+
+ private static let tickPercentKeys = [
+ "tickPercent",
+ "tick_percent",
+ "nextTickPercent",
+ "next_tick_percent",
+ ]
+
+ private static let costLimitKeys = [
+ "maxCredits",
+ "max_credits",
+ ]
+
+ private static let costRemainingKeys = [
+ "remainingCredits",
+ "remaining_credits",
+ ]
+
+ private static let costUsedKeys = [
+ "usedCredits",
+ "used_credits",
+ ]
+
private static let windowMinutesKeys = [
"windowMinutes",
"window_minutes",
@@ -491,6 +700,15 @@ enum SyntheticUsageParser {
"periodSeconds",
"period_seconds",
]
+
+ private static let windowStringKeys = [
+ "window",
+ "windowLabel",
+ "window_label",
+ "period",
+ "periodLabel",
+ "period_label",
+ ]
}
public enum SyntheticUsageError: LocalizedError, Sendable {
diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
index a13d28a80..893be0d5a 100644
--- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
+++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
@@ -72,5 +72,12 @@ extension TokenAccountSupportCatalog {
injection: .cookieHeader,
requiresManualCookieSource: true,
cookieName: nil),
+ .mistral: TokenAccountSupport(
+ title: "Session tokens",
+ subtitle: "Store multiple Mistral Cookie headers.",
+ placeholder: "Cookie: …",
+ injection: .cookieHeader,
+ requiresManualCookieSource: true,
+ cookieName: nil),
]
}
diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift
index 90360ca60..22ebd19c7 100644
--- a/Sources/CodexBarCore/UsageFetcher.swift
+++ b/Sources/CodexBarCore/UsageFetcher.swift
@@ -6,12 +6,21 @@ public struct RateWindow: Codable, Equatable, Sendable {
public let resetsAt: Date?
/// Optional textual reset description (used by Claude CLI UI scrape).
public let resetDescription: String?
+ /// Optional percent restored on the next regeneration tick for providers with rolling recovery.
+ public let nextRegenPercent: Double?
- public init(usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, resetDescription: String?) {
+ public init(
+ usedPercent: Double,
+ windowMinutes: Int?,
+ resetsAt: Date?,
+ resetDescription: String?,
+ nextRegenPercent: Double? = nil)
+ {
self.usedPercent = usedPercent
self.windowMinutes = windowMinutes
self.resetsAt = resetsAt
self.resetDescription = resetDescription
+ self.nextRegenPercent = nextRegenPercent
}
public var remainingPercent: Double {
@@ -19,6 +28,18 @@ public struct RateWindow: Codable, Equatable, Sendable {
}
}
+public struct NamedRateWindow: Codable, Equatable, Sendable {
+ public let id: String
+ public let title: String
+ public let window: RateWindow
+
+ public init(id: String, title: String, window: RateWindow) {
+ self.id = id
+ self.title = title
+ self.window = window
+ }
+}
+
public struct ProviderIdentitySnapshot: Codable, Sendable {
public let providerID: UsageProvider?
public let accountEmail: String?
@@ -51,6 +72,7 @@ public struct UsageSnapshot: Codable, Sendable {
public let primary: RateWindow?
public let secondary: RateWindow?
public let tertiary: RateWindow?
+ public let extraRateWindows: [NamedRateWindow]?
public let providerCost: ProviderCostSnapshot?
public let zaiUsage: ZaiUsageSnapshot?
public let minimaxUsage: MiniMaxUsageSnapshot?
@@ -63,6 +85,7 @@ public struct UsageSnapshot: Codable, Sendable {
case primary
case secondary
case tertiary
+ case extraRateWindows
case providerCost
case openRouterUsage
case updatedAt
@@ -76,6 +99,7 @@ public struct UsageSnapshot: Codable, Sendable {
primary: RateWindow?,
secondary: RateWindow?,
tertiary: RateWindow? = nil,
+ extraRateWindows: [NamedRateWindow]? = nil,
providerCost: ProviderCostSnapshot? = nil,
zaiUsage: ZaiUsageSnapshot? = nil,
minimaxUsage: MiniMaxUsageSnapshot? = nil,
@@ -87,6 +111,7 @@ public struct UsageSnapshot: Codable, Sendable {
self.primary = primary
self.secondary = secondary
self.tertiary = tertiary
+ self.extraRateWindows = extraRateWindows
self.providerCost = providerCost
self.zaiUsage = zaiUsage
self.minimaxUsage = minimaxUsage
@@ -101,6 +126,7 @@ public struct UsageSnapshot: Codable, Sendable {
self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary)
self.secondary = try container.decodeIfPresent(RateWindow.self, forKey: .secondary)
self.tertiary = try container.decodeIfPresent(RateWindow.self, forKey: .tertiary)
+ self.extraRateWindows = try container.decodeIfPresent([NamedRateWindow].self, forKey: .extraRateWindows)
self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost)
self.zaiUsage = nil // Not persisted, fetched fresh each time
self.minimaxUsage = nil // Not persisted, fetched fresh each time
@@ -131,6 +157,7 @@ public struct UsageSnapshot: Codable, Sendable {
try container.encode(self.primary, forKey: .primary)
try container.encode(self.secondary, forKey: .secondary)
try container.encode(self.tertiary, forKey: .tertiary)
+ try container.encodeIfPresent(self.extraRateWindows, forKey: .extraRateWindows)
try container.encodeIfPresent(self.providerCost, forKey: .providerCost)
try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage)
try container.encode(self.updatedAt, forKey: .updatedAt)
@@ -215,6 +242,7 @@ public struct UsageSnapshot: Codable, Sendable {
primary: self.primary,
secondary: self.secondary,
tertiary: self.tertiary,
+ extraRateWindows: self.extraRateWindows,
providerCost: self.providerCost,
zaiUsage: self.zaiUsage,
minimaxUsage: self.minimaxUsage,
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift
index 1055e104a..3f7979604 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift
@@ -112,6 +112,16 @@ enum CostUsagePricing {
outputCostPerToken: 1.8e-4,
cacheReadInputCostPerToken: nil,
displayLabel: nil),
+ "gpt-5.5": CodexPricing(
+ inputCostPerToken: 5e-6,
+ outputCostPerToken: 3e-5,
+ cacheReadInputCostPerToken: 5e-7,
+ displayLabel: nil),
+ "gpt-5.5-pro": CodexPricing(
+ inputCostPerToken: 3e-5,
+ outputCostPerToken: 1.8e-4,
+ cacheReadInputCostPerToken: nil,
+ displayLabel: nil),
]
private static let claude: [String: ClaudePricing] = [
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index 17ddf1dba..69cc02db6 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -235,7 +235,8 @@ enum CostUsageScanner {
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot,
.minimax, .kilo, .kiro, .kimi,
- .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus:
+ .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus,
+ .mistral:
return emptyReport
}
}
diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift
index 0dc371f02..d87c4dcca 100644
--- a/Sources/CodexBarCore/WidgetSnapshot.swift
+++ b/Sources/CodexBarCore/WidgetSnapshot.swift
@@ -114,17 +114,16 @@ public struct WidgetSnapshot: Codable, Sendable {
}
public enum WidgetSnapshotStore {
- public static let appGroupID = "group.com.steipete.codexbar"
- private static let filename = "widget-snapshot.json"
+ private static let filename = AppGroupSupport.widgetSnapshotFilename
public static func load(bundleID: String? = Bundle.main.bundleIdentifier) -> WidgetSnapshot? {
- guard let url = self.snapshotURL(bundleID: bundleID) else { return nil }
+ let url = self.snapshotURL(bundleID: bundleID)
guard let data = try? Data(contentsOf: url) else { return nil }
return try? self.decoder.decode(WidgetSnapshot.self, from: data)
}
public static func save(_ snapshot: WidgetSnapshot, bundleID: String? = Bundle.main.bundleIdentifier) {
- guard let url = self.snapshotURL(bundleID: bundleID) else { return }
+ let url = self.snapshotURL(bundleID: bundleID)
do {
let data = try self.encoder.encode(snapshot)
try data.write(to: url, options: [.atomic])
@@ -133,32 +132,12 @@ public enum WidgetSnapshotStore {
}
}
- private static func snapshotURL(bundleID: String?) -> URL? {
- let fm = FileManager.default
- let groupID = self.groupID(for: bundleID)
- #if os(macOS)
- if let groupID, let container = fm.containerURL(forSecurityApplicationGroupIdentifier: groupID) {
- return container.appendingPathComponent(self.filename, isDirectory: false)
- }
- #endif
-
- let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
- ?? fm.temporaryDirectory
- let dir = base.appendingPathComponent("CodexBar", isDirectory: true)
- try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
- return dir.appendingPathComponent(self.filename, isDirectory: false)
+ private static func snapshotURL(bundleID: String?) -> URL {
+ AppGroupSupport.snapshotURL(bundleID: bundleID)
}
public static func appGroupID(for bundleID: String?) -> String? {
- self.groupID(for: bundleID)
- }
-
- private static func groupID(for bundleID: String?) -> String? {
- guard let bundleID, !bundleID.isEmpty else { return self.appGroupID }
- if bundleID.contains(".debug") {
- return "group.com.steipete.codexbar.debug"
- }
- return self.appGroupID
+ AppGroupSupport.currentGroupID(for: bundleID)
}
private static var encoder: JSONEncoder {
@@ -178,7 +157,7 @@ public enum WidgetSelectionStore {
private static let selectedProviderKey = "widgetSelectedProvider"
public static func loadSelectedProvider(bundleID: String? = Bundle.main.bundleIdentifier) -> UsageProvider? {
- guard let defaults = self.sharedDefaults(bundleID: bundleID) else { return nil }
+ let defaults = self.sharedDefaults(bundleID: bundleID)
guard let raw = defaults.string(forKey: self.selectedProviderKey) else { return nil }
return UsageProvider(rawValue: raw)
}
@@ -187,12 +166,11 @@ public enum WidgetSelectionStore {
_ provider: UsageProvider,
bundleID: String? = Bundle.main.bundleIdentifier)
{
- guard let defaults = self.sharedDefaults(bundleID: bundleID) else { return }
+ let defaults = self.sharedDefaults(bundleID: bundleID)
defaults.set(provider.rawValue, forKey: self.selectedProviderKey)
}
- private static func sharedDefaults(bundleID: String?) -> UserDefaults? {
- guard let groupID = WidgetSnapshotStore.appGroupID(for: bundleID) else { return nil }
- return UserDefaults(suiteName: groupID)
+ private static func sharedDefaults(bundleID: String?) -> UserDefaults {
+ AppGroupSupport.sharedDefaults(bundleID: bundleID) ?? .standard
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index 73055305f..ce60108de 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -77,6 +77,7 @@ enum ProviderChoice: String, AppEnum {
case .warp: return nil // Warp not yet supported in widgets
case .perplexity: return nil // Perplexity not yet supported in widgets
case .abacus: return nil // Abacus AI not yet supported in widgets
+ case .mistral: return nil // Mistral not yet supported in widgets
}
}
}
@@ -99,7 +100,7 @@ struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Provider"
static let description = IntentDescription("Select the provider to display in the widget.")
- @Parameter(title: "Provider")
+ @Parameter(title: "Provider", default: .codex)
var provider: ProviderChoice
init() {
@@ -131,10 +132,10 @@ struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Provider + Metric"
static let description = IntentDescription("Select the provider and metric to display.")
- @Parameter(title: "Provider")
+ @Parameter(title: "Provider", default: .codex)
var provider: ProviderChoice
- @Parameter(title: "Metric")
+ @Parameter(title: "Metric", default: .credits)
var metric: CompactMetric
init() {
@@ -184,7 +185,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider {
in context: Context) async -> Timeline
{
let provider = configuration.provider.provider
- let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()
+ let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()
let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot)
let refresh = Date().addingTimeInterval(30 * 60)
return Timeline(entries: [entry], policy: .after(refresh))
@@ -213,7 +214,7 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider {
}
private func makeEntry() -> CodexBarSwitcherEntry {
- let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()
+ let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()
let providers = self.availableProviders(from: snapshot)
let stored = WidgetSelectionStore.loadSelectedProvider()
let selected = providers.first { $0 == stored } ?? providers.first ?? .codex
@@ -250,10 +251,11 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider {
func snapshot(for configuration: CompactMetricSelectionIntent, in context: Context) async -> CodexBarCompactEntry {
let provider = configuration.provider.provider
+ let metric = configuration.metric
return CodexBarCompactEntry(
date: Date(),
provider: provider,
- metric: configuration.metric,
+ metric: metric,
snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot())
}
@@ -262,11 +264,12 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider {
in context: Context) async -> Timeline
{
let provider = configuration.provider.provider
- let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()
+ let metric = configuration.metric
+ let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()
let entry = CodexBarCompactEntry(
date: Date(),
provider: provider,
- metric: configuration.metric,
+ metric: metric,
snapshot: snapshot)
let refresh = Date().addingTimeInterval(30 * 60)
return Timeline(entries: [entry], policy: .after(refresh))
@@ -274,6 +277,10 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider {
}
enum WidgetPreviewData {
+ static func emptySnapshot() -> WidgetSnapshot {
+ WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date())
+ }
+
static func snapshot() -> WidgetSnapshot {
let primary = RateWindow(usedPercent: 35, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 4h")
let secondary = RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 3d")
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 4e03801b3..00c791aa8 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -283,6 +283,7 @@ private struct ProviderSwitchChip: View {
case .warp: "Warp"
case .perplexity: "Pplx"
case .abacus: "Abacus"
+ case .mistral: "Mistral"
}
}
}
@@ -644,6 +645,8 @@ enum WidgetColors {
Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal
case .abacus:
Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255)
+ case .mistral:
+ Color(red: 255 / 255, green: 80 / 255, blue: 15 / 255) // Mistral orange
}
}
}
diff --git a/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift
index 413b9661f..a013df8f4 100644
--- a/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift
+++ b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift
@@ -945,6 +945,7 @@ final class AlibabaConsoleSECTokenStubURLProtocol: URLProtocol {
"modelstudio.console.alibabacloud.com",
"bailian-singapore-cs.alibabacloud.com",
"bailian.console.aliyun.com",
+ "bailian-cs.console.aliyun.com",
"bailian-beijing-cs.aliyuncs.com",
].contains(host)
}
diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift
index f1d0e75b5..5e91fc46e 100644
--- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift
+++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift
@@ -745,4 +745,34 @@ struct AntigravityStatusProbeTests {
#expect(usage.accountEmail(for: .antigravity) == "test@example.com")
#expect(usage.loginMethod(for: .antigravity) == "Pro")
}
+
+ @Test
+ func `http probe errors still count as reachable`() {
+ #expect(
+ AntigravityStatusProbe.isReachableProbeError(
+ AntigravityStatusProbeError.apiError("HTTP 403: Forbidden")))
+ #expect(
+ AntigravityStatusProbe.isReachableProbeError(
+ AntigravityStatusProbeError.apiError("HTTP 404: Not Found")))
+ #expect(
+ !AntigravityStatusProbe.isReachableProbeError(
+ AntigravityStatusProbeError.apiError("Invalid response")))
+ #expect(!AntigravityStatusProbe.isReachableProbeError(AntigravityStatusProbeError.notRunning))
+ }
+
+ @Test
+ func `fallback probe port prefers non extension candidate`() {
+ #expect(
+ AntigravityStatusProbe.fallbackProbePort(
+ ports: [51170, 61775],
+ extensionPort: 61775) == 51170)
+ #expect(
+ AntigravityStatusProbe.fallbackProbePort(
+ ports: [61775],
+ extensionPort: 61775) == 61775)
+ #expect(
+ AntigravityStatusProbe.fallbackProbePort(
+ ports: [51170, 61775],
+ extensionPort: nil) == 51170)
+ }
}
diff --git a/Tests/CodexBarTests/AppGroupSupportTests.swift b/Tests/CodexBarTests/AppGroupSupportTests.swift
new file mode 100644
index 000000000..de5b1e8d6
--- /dev/null
+++ b/Tests/CodexBarTests/AppGroupSupportTests.swift
@@ -0,0 +1,132 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct AppGroupSupportTests {
+ @Test
+ func `app group identifiers use resolved team-prefixed release and debug variants`() {
+ #expect(
+ AppGroupSupport.currentGroupID(teamID: "Y5PE65HELJ", bundleID: "com.steipete.codexbar")
+ == "Y5PE65HELJ.com.steipete.codexbar")
+ #expect(
+ AppGroupSupport.currentGroupID(teamID: "ABCDE12345", bundleID: "com.steipete.codexbar.debug")
+ == "ABCDE12345.com.steipete.codexbar.debug")
+ #expect(
+ AppGroupSupport.legacyGroupID(for: "com.steipete.codexbar")
+ == "group.com.steipete.codexbar")
+ #expect(
+ AppGroupSupport.legacyGroupID(for: "com.steipete.codexbar.debug")
+ == "group.com.steipete.codexbar.debug")
+ }
+
+ @Test
+ func `resolved team id falls back to plist and then default`() {
+ #expect(
+ AppGroupSupport.resolvedTeamID(
+ infoDictionaryOverride: [AppGroupSupport.teamIDInfoKey: "ABCDE12345"],
+ bundleURLOverride: nil) == "ABCDE12345")
+ #expect(
+ AppGroupSupport.resolvedTeamID(
+ infoDictionaryOverride: nil,
+ bundleURLOverride: nil) == AppGroupSupport.defaultTeamID)
+ }
+
+ @Test
+ func `legacy migration copies snapshot once`() throws {
+ let fileManager = FileManager.default
+ let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? fileManager.removeItem(at: root) }
+
+ let standardSuite = "AppGroupSupportTests-standard-\(UUID().uuidString)"
+ let currentSuite = "AppGroupSupportTests-current-\(UUID().uuidString)"
+ let legacySuite = "AppGroupSupportTests-legacy-\(UUID().uuidString)"
+
+ let standardDefaults = try #require(UserDefaults(suiteName: standardSuite))
+ let currentDefaults = try #require(UserDefaults(suiteName: currentSuite))
+ let legacyDefaults = try #require(UserDefaults(suiteName: legacySuite))
+ standardDefaults.removePersistentDomain(forName: standardSuite)
+ currentDefaults.removePersistentDomain(forName: currentSuite)
+ legacyDefaults.removePersistentDomain(forName: legacySuite)
+
+ legacyDefaults.set(true, forKey: "debugDisableKeychainAccess")
+ legacyDefaults.set(UsageProvider.cursor.rawValue, forKey: "widgetSelectedProvider")
+
+ let legacySnapshotURL = root.appendingPathComponent(
+ "legacy/widget-snapshot.json",
+ isDirectory: false)
+ try fileManager.createDirectory(
+ at: legacySnapshotURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true)
+ try Data("legacy-snapshot".utf8).write(to: legacySnapshotURL)
+
+ let currentSnapshotURL = root.appendingPathComponent("current/widget-snapshot.json", isDirectory: false)
+ let result = AppGroupSupport.migrateLegacyDataIfNeeded(
+ bundleID: "com.steipete.codexbar",
+ standardDefaults: standardDefaults,
+ currentDefaultsOverride: currentDefaults,
+ legacyDefaultsOverride: legacyDefaults,
+ currentSnapshotURLOverride: currentSnapshotURL,
+ legacySnapshotURLOverride: legacySnapshotURL)
+
+ #expect(result.status == .migrated)
+ #expect(result.copiedSnapshot)
+ #expect(result.copiedDefaults == 2)
+ #expect(currentDefaults.bool(forKey: "debugDisableKeychainAccess"))
+ #expect(currentDefaults.string(forKey: "widgetSelectedProvider") == UsageProvider.cursor.rawValue)
+ #expect(fileManager.fileExists(atPath: currentSnapshotURL.path))
+ #expect(
+ standardDefaults.integer(forKey: AppGroupSupport.migrationVersionKey)
+ == AppGroupSupport.migrationVersion)
+
+ let secondResult = AppGroupSupport.migrateLegacyDataIfNeeded(
+ bundleID: "com.steipete.codexbar",
+ standardDefaults: standardDefaults,
+ currentDefaultsOverride: currentDefaults,
+ legacyDefaultsOverride: legacyDefaults,
+ currentSnapshotURLOverride: currentSnapshotURL,
+ legacySnapshotURLOverride: legacySnapshotURL)
+ #expect(secondResult.status == .alreadyCompleted)
+ }
+
+ @Test
+ func `legacy migration preserves existing target shared defaults`() throws {
+ let fileManager = FileManager.default
+ let root = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? fileManager.removeItem(at: root) }
+
+ let currentSnapshotURL = root.appendingPathComponent("current/widget-snapshot.json", isDirectory: false)
+ let legacySnapshotURL = root.appendingPathComponent("legacy/widget-snapshot.json", isDirectory: false)
+ // 隔离快照路径,避免在未覆盖时使用本机 Group Container 中的真实文件而误判为已迁移。
+
+ let standardSuite = "AppGroupSupportTests-standard-existing-\(UUID().uuidString)"
+ let currentSuite = "AppGroupSupportTests-current-existing-\(UUID().uuidString)"
+ let legacySuite = "AppGroupSupportTests-legacy-existing-\(UUID().uuidString)"
+
+ let standardDefaults = try #require(UserDefaults(suiteName: standardSuite))
+ let currentDefaults = try #require(UserDefaults(suiteName: currentSuite))
+ let legacyDefaults = try #require(UserDefaults(suiteName: legacySuite))
+ standardDefaults.removePersistentDomain(forName: standardSuite)
+ currentDefaults.removePersistentDomain(forName: currentSuite)
+ legacyDefaults.removePersistentDomain(forName: legacySuite)
+
+ currentDefaults.set(false, forKey: "debugDisableKeychainAccess")
+ currentDefaults.set(UsageProvider.codex.rawValue, forKey: "widgetSelectedProvider")
+ legacyDefaults.set(true, forKey: "debugDisableKeychainAccess")
+ legacyDefaults.set(UsageProvider.cursor.rawValue, forKey: "widgetSelectedProvider")
+
+ let result = AppGroupSupport.migrateLegacyDataIfNeeded(
+ bundleID: "com.steipete.codexbar",
+ standardDefaults: standardDefaults,
+ currentDefaultsOverride: currentDefaults,
+ legacyDefaultsOverride: legacyDefaults,
+ currentSnapshotURLOverride: currentSnapshotURL,
+ legacySnapshotURLOverride: legacySnapshotURL)
+
+ #expect(result.status == .noChangesNeeded)
+ #expect(result.copiedDefaults == 0)
+ #expect(!currentDefaults.bool(forKey: "debugDisableKeychainAccess"))
+ #expect(currentDefaults.string(forKey: "widgetSelectedProvider") == UsageProvider.codex.rawValue)
+ }
+}
diff --git a/Tests/CodexBarTests/CLIEntryTests.swift b/Tests/CodexBarTests/CLIEntryTests.swift
index daa8f174f..0aa92f786 100644
--- a/Tests/CodexBarTests/CLIEntryTests.swift
+++ b/Tests/CodexBarTests/CLIEntryTests.swift
@@ -44,6 +44,58 @@ struct CLIEntryTests {
#expect(header.contains("cli"))
}
+ @Test
+ func `CLI version falls back to containing app bundle`() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("codexbar-cli-version-\(UUID().uuidString)", isDirectory: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ let appURL = root.appendingPathComponent("CodexBar.app", isDirectory: true)
+ let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true)
+ let helpersURL = contentsURL.appendingPathComponent("Helpers", isDirectory: true)
+ try FileManager.default.createDirectory(at: helpersURL, withIntermediateDirectories: true)
+
+ let infoURL = contentsURL.appendingPathComponent("Info.plist")
+ let plist: [String: Any] = ["CFBundleShortVersionString": "9.8.7"]
+ let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
+ try data.write(to: infoURL)
+
+ let helperURL = helpersURL.appendingPathComponent("CodexBarCLI")
+ try Data().write(to: helperURL)
+
+ #expect(CodexBarCLI.containingAppVersion(for: helperURL) == "9.8.7")
+ }
+
+ @Test
+ func `CLI version follows symlinked helper`() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("codexbar-cli-version-symlink-\(UUID().uuidString)", isDirectory: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ let appURL = root.appendingPathComponent("CodexBar.app", isDirectory: true)
+ let emptyBundleURL = root.appendingPathComponent("Empty.bundle", isDirectory: true)
+ let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true)
+ let helpersURL = contentsURL.appendingPathComponent("Helpers", isDirectory: true)
+ let binURL = root.appendingPathComponent("bin", isDirectory: true)
+ try FileManager.default.createDirectory(at: helpersURL, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: binURL, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: emptyBundleURL, withIntermediateDirectories: true)
+
+ let infoURL = contentsURL.appendingPathComponent("Info.plist")
+ let plist: [String: Any] = ["CFBundleShortVersionString": "2.4.6"]
+ let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
+ try data.write(to: infoURL)
+
+ let helperURL = helpersURL.appendingPathComponent("CodexBarCLI")
+ try Data().write(to: helperURL)
+
+ let symlinkURL = binURL.appendingPathComponent("codexbar")
+ try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: helperURL)
+
+ let emptyBundle = try #require(Bundle(url: emptyBundleURL))
+ #expect(CodexBarCLI.currentVersion(bundle: emptyBundle, executablePath: symlinkURL.path) == "2.4.6")
+ }
+
@Test
func `render open AI web dashboard text includes summary`() {
let event = CreditEvent(
diff --git a/Tests/CodexBarTests/CLIWebFallbackTests.swift b/Tests/CodexBarTests/CLIWebFallbackTests.swift
index a3809c5b3..df93a6f0a 100644
--- a/Tests/CodexBarTests/CLIWebFallbackTests.swift
+++ b/Tests/CodexBarTests/CLIWebFallbackTests.swift
@@ -62,6 +62,16 @@ struct CLIWebFallbackTests {
context: context))
}
+ @Test
+ func `codex retries fresh browser import for missing usage and no data`() {
+ #expect(CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport(
+ after: OpenAIWebCodexError.missingUsage))
+ #expect(CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport(
+ after: OpenAIDashboardFetcher.FetchError.noDashboardData(body: "missing")))
+ #expect(!CodexWebDashboardStrategy.shouldRetryWithFreshBrowserImport(
+ after: OpenAIDashboardFetcher.FetchError.loginRequired))
+ }
+
@Test
func `codex display only falls back in auto`() {
let strategy = CodexWebDashboardStrategy()
diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift
index 1dc85d7c7..1e49708b0 100644
--- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift
@@ -693,50 +693,56 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests {
ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
}
- let tempDir = FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
- let fileURL = tempDir.appendingPathComponent("credentials.json")
- try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
- let securityData = self.makeCredentialsData(
- accessToken: "security-repair-no-fingerprint-probe",
- expiresAt: Date(timeIntervalSinceNow: 3600))
- let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
- let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
- modifiedAt: 456,
- createdAt: 455,
- persistentRefHash: "sentinel")
-
- let record = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
- .securityCLIExperimental,
- operation: {
- try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
- try ProviderInteractionContext.$current.withValue(.background) {
- try ClaudeOAuthCredentialsStore
- .withClaudeKeychainFingerprintStoreOverrideForTesting(
- fingerprintStore)
- {
- try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting(
- data: nil,
- fingerprint: sentinelFingerprint)
- {
- try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(
- .data(securityData))
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let securityData = self.makeCredentialsData(
+ accessToken: "security-repair-no-fingerprint-probe",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore()
+ let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint(
+ modifiedAt: 456,
+ createdAt: 455,
+ persistentRefHash: "sentinel")
+
+ let record = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting(
+ .securityCLIExperimental,
+ operation: {
+ try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) {
+ try ProviderInteractionContext.$current.withValue(.background) {
+ try ClaudeOAuthCredentialsStore
+ .withClaudeKeychainFingerprintStoreOverrideForTesting(
+ fingerprintStore)
{
- try ClaudeOAuthCredentialsStore.loadRecord(
- environment: [:],
- allowKeychainPrompt: false,
- respectKeychainPromptCooldown: true)
+ try ClaudeOAuthCredentialsStore
+ .withClaudeKeychainOverridesForTesting(
+ data: nil,
+ fingerprint: sentinelFingerprint)
+ {
+ try ClaudeOAuthCredentialsStore
+ .withSecurityCLIReadOverrideForTesting(
+ .data(securityData))
+ {
+ try ClaudeOAuthCredentialsStore.loadRecord(
+ environment: [:],
+ allowKeychainPrompt: false,
+ respectKeychainPromptCooldown: true)
+ }
+ }
}
- }
}
- }
- }
- })
+ }
+ })
- #expect(record.credentials.accessToken == "security-repair-no-fingerprint-probe")
- #expect(record.source == .claudeKeychain)
- #expect(fingerprintStore.fingerprint == nil)
+ #expect(record.credentials.accessToken == "security-repair-no-fingerprint-probe")
+ #expect(record.source == .claudeKeychain)
+ #expect(fingerprintStore.fingerprint == nil)
+ }
+ }
}
}
}
diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift
new file mode 100644
index 000000000..b9e0630f5
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift
@@ -0,0 +1,237 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite(.serialized)
+struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests {
+ private struct WrongCacheEntry: Codable {
+ let value: String
+ }
+
+ private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data {
+ let millis = Int(expiresAt.timeIntervalSince1970 * 1000)
+ let refreshField: String = {
+ guard let refreshToken else { return "" }
+ return ",\n \"refreshToken\": \"\(refreshToken)\""
+ }()
+ let json = """
+ {
+ "claudeAiOauth": {
+ "accessToken": "\(accessToken)",
+ "expiresAt": \(millis),
+ "scopes": ["user:profile"]\(refreshField)
+ }
+ }
+ """
+ return Data(json.utf8)
+ }
+
+ #if os(macOS)
+ @Test
+ func `credentials file invalidation preserves keychain cache when temporarily unavailable`() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let firstFile = self.makeCredentialsData(
+ accessToken: "first-file",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ try firstFile.write(to: fileURL)
+ #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
+
+ let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
+ let cachedData = self.makeCredentialsData(
+ accessToken: "cached-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ KeychainCacheStore.store(
+ key: cacheKey,
+ entry: ClaudeOAuthCredentialsStore.CacheEntry(
+ data: cachedData,
+ storedAt: Date(),
+ owner: .claudeCLI))
+ defer { KeychainCacheStore.clear(key: cacheKey) }
+
+ let updatedFile = self.makeCredentialsData(
+ accessToken: "updated-file-token-longer",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ try updatedFile.write(to: fileURL)
+
+ KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) {
+ #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
+ }
+
+ switch KeychainCacheStore.load(
+ key: cacheKey,
+ as: ClaudeOAuthCredentialsStore.CacheEntry.self)
+ {
+ case let .found(entry):
+ let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
+ #expect(parsed.accessToken == "cached-token")
+ case .missing, .temporarilyUnavailable, .invalid:
+ #expect(Bool(false), "Expected temporary unavailability not to clear Claude cache")
+ }
+
+ #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged())
+
+ switch KeychainCacheStore.load(
+ key: cacheKey,
+ as: ClaudeOAuthCredentialsStore.CacheEntry.self)
+ {
+ case .missing:
+ #expect(true)
+ case .found, .temporarilyUnavailable, .invalid:
+ #expect(Bool(false), "Expected pending invalidation to clear stale Claude cache")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func `temporary keychain cache unavailability does not overwrite cache from credentials file fallback`() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(true) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
+
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let fileData = self.makeCredentialsData(
+ accessToken: "file-fallback-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ try fileData.write(to: fileURL)
+
+ let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
+ let cachedData = self.makeCredentialsData(
+ accessToken: "cached-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ KeychainCacheStore.store(
+ key: cacheKey,
+ entry: ClaudeOAuthCredentialsStore.CacheEntry(
+ data: cachedData,
+ storedAt: Date(),
+ owner: .claudeCLI))
+ defer { KeychainCacheStore.clear(key: cacheKey) }
+
+ let loaded = try KeychainCacheStore.withLoadFailureStatusOverrideForTesting(
+ errSecInteractionNotAllowed)
+ {
+ try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
+ }
+ #expect(loaded.accessToken == "file-fallback-token")
+
+ switch KeychainCacheStore.load(
+ key: cacheKey,
+ as: ClaudeOAuthCredentialsStore.CacheEntry.self)
+ {
+ case let .found(entry):
+ let parsed = try ClaudeOAuthCredentials.parse(data: entry.data)
+ #expect(parsed.accessToken == "cached-token")
+ case .missing, .temporarilyUnavailable, .invalid:
+ #expect(Bool(false), "Expected file fallback not to overwrite unavailable cache")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ func `has cached credentials treats temporary keychain cache unavailability as present`() {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ KeychainCacheStore.withServiceOverrideForTesting(service) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ ClaudeOAuthCredentialsStore.invalidateCache()
+ let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
+ let cachedData = self.makeCredentialsData(
+ accessToken: "cached-token",
+ expiresAt: Date(timeIntervalSinceNow: 3600))
+ KeychainCacheStore.store(
+ key: cacheKey,
+ entry: ClaudeOAuthCredentialsStore.CacheEntry(data: cachedData, storedAt: Date()))
+ defer { KeychainCacheStore.clear(key: cacheKey) }
+
+ let hasCached = KeychainCacheStore.withLoadFailureStatusOverrideForTesting(
+ errSecInteractionNotAllowed)
+ {
+ ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: [:])
+ }
+
+ #expect(hasCached == true)
+ }
+ }
+ }
+ #endif
+
+ @Test
+ func `invalid keychain cache is cleared by load`() throws {
+ let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)"
+ try KeychainCacheStore.withServiceOverrideForTesting(service) {
+ try KeychainAccessGate.withTaskOverrideForTesting(true) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting()
+ defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() }
+
+ try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting {
+ try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting {
+ let tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ let fileURL = tempDir.appendingPathComponent("credentials.json")
+ try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) {
+ let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude)
+ KeychainCacheStore.store(key: cacheKey, entry: WrongCacheEntry(value: "wrong-shape"))
+
+ do {
+ _ = try ClaudeOAuthCredentialsStore.load(environment: [:], allowKeychainPrompt: false)
+ Issue.record("Expected ClaudeOAuthCredentialsError.notFound")
+ } catch let error as ClaudeOAuthCredentialsError {
+ guard case .notFound = error else {
+ Issue.record("Expected .notFound, got \(error)")
+ return
+ }
+ }
+
+ switch KeychainCacheStore.load(
+ key: cacheKey,
+ as: ClaudeOAuthCredentialsStore.CacheEntry.self)
+ {
+ case .missing:
+ #expect(true)
+ case .found, .temporarilyUnavailable, .invalid:
+ #expect(Bool(false), "Expected invalid Claude cache to be cleared")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift
index cb3541bc1..d62d74207 100644
--- a/Tests/CodexBarTests/ClaudeOAuthTests.swift
+++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift
@@ -81,6 +81,67 @@ struct ClaudeOAuthTests {
#expect(snap.loginMethod == "Claude Pro")
}
+ @Test
+ func `maps O auth design and routines usage windows`() throws {
+ let json = """
+ {
+ "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" },
+ "seven_day_design": { "utilization": 44, "resets_at": "2025-12-31T00:00:00.000Z" },
+ "seven_day_routines": { "utilization": 18, "resets_at": "2026-01-01T00:00:00.000Z" }
+ }
+ """
+ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
+ #expect(snap.extraRateWindows.count == 2)
+ #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.title == "Designs")
+ #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 44)
+ #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.title == "Daily Routines")
+ #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 18)
+ }
+
+ @Test
+ func `maps O auth omelette and cowork usage windows`() throws {
+ let json = """
+ {
+ "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" },
+ "seven_day_omelette": { "utilization": 29, "resets_at": "2025-12-31T00:00:00.000Z" },
+ "seven_day_cowork": { "utilization": 9, "resets_at": "2026-01-01T00:00:00.000Z" }
+ }
+ """
+ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
+ #expect(snap.extraRateWindows.count == 2)
+ #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 29)
+ #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 9)
+ }
+
+ @Test
+ func `maps O auth null cowork as zero routines window`() throws {
+ let json = """
+ {
+ "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" },
+ "seven_day_omelette": { "utilization": 29, "resets_at": "2025-12-31T00:00:00.000Z" },
+ "seven_day_cowork": null
+ }
+ """
+ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
+ #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0)
+ }
+
+ @Test
+ func `prefers populated alias over null alias in mixed payload`() throws {
+ let json = """
+ {
+ "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" },
+ "seven_day_design": null,
+ "seven_day_omelette": { "utilization": 37, "resets_at": "2025-12-31T00:00:00.000Z" },
+ "seven_day_routines": null,
+ "seven_day_cowork": { "utilization": 14, "resets_at": "2026-01-01T00:00:00.000Z" }
+ }
+ """
+ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
+ #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 37)
+ #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 14)
+ }
+
@Test
func `maps O auth extra usage`() throws {
// OAuth API returns values in cents (minor units), same as Web API.
diff --git a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift
new file mode 100644
index 000000000..324332297
--- /dev/null
+++ b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift
@@ -0,0 +1,48 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct ClaudeWebUsageExtraWindowTests {
+ @Test
+ func `parses claude web API sonnet usage response`() throws {
+ let json = """
+ {
+ "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" },
+ "seven_day_sonnet": { "utilization": 6, "resets_at": "2025-12-30T23:00:00.000Z" }
+ }
+ """
+ let data = Data(json.utf8)
+ let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data)
+ #expect(parsed.opusPercentUsed == 6)
+ }
+
+ @Test
+ func `parses claude web API omelette and cowork usage windows`() throws {
+ let json = """
+ {
+ "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" },
+ "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" },
+ "seven_day_cowork": { "utilization": 11, "resets_at": "2025-12-31T23:00:00.000Z" }
+ }
+ """
+ let data = Data(json.utf8)
+ let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data)
+ #expect(parsed.extraRateWindows.count == 2)
+ #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 26)
+ #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 11)
+ }
+
+ @Test
+ func `parses claude web API cowork null as zero routines window`() throws {
+ let json = """
+ {
+ "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" },
+ "seven_day_omelette": { "utilization": 26, "resets_at": "2025-12-30T23:00:00.000Z" },
+ "seven_day_cowork": null
+ }
+ """
+ let data = Data(json.utf8)
+ let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data)
+ #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0)
+ }
+}
diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift
index 71c6461de..e951d59db 100644
--- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift
+++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift
@@ -16,6 +16,13 @@ struct CodexBarWidgetProviderTests {
#expect(ProviderChoice.opencodego.provider == .opencodego)
}
+ @Test
+ func `supported providers fall back to codex when snapshot is empty`() {
+ let snapshot = WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date())
+
+ #expect(CodexBarSwitcherTimelineProvider.supportedProviders(from: snapshot) == [.codex])
+ }
+
@Test
func `supported providers keep alibaba when it is the only enabled provider`() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
@@ -97,4 +104,14 @@ struct CodexBarWidgetProviderTests {
#expect(rows == [WidgetUsageRow(id: "weekly", title: "Weekly", percentLeft: 75)])
}
+
+ @Test
+ func `widget configuration intents default to codex and credits`() {
+ let providerIntent = ProviderSelectionIntent()
+ let compactIntent = CompactMetricSelectionIntent()
+
+ #expect(providerIntent.provider == .codex)
+ #expect(compactIntent.provider == .codex)
+ #expect(compactIntent.metric == .credits)
+ }
}
diff --git a/Tests/CodexBarTests/CookieHeaderCacheTests.swift b/Tests/CodexBarTests/CookieHeaderCacheTests.swift
index 3a8a7b50d..de1247694 100644
--- a/Tests/CodexBarTests/CookieHeaderCacheTests.swift
+++ b/Tests/CodexBarTests/CookieHeaderCacheTests.swift
@@ -4,6 +4,10 @@ import Testing
@Suite(.serialized)
struct CookieHeaderCacheTests {
+ private struct WrongEntry: Codable {
+ let value: String
+ }
+
@Test
func `stores and loads entry`() {
KeychainCacheStore.setTestStoreForTesting(true)
@@ -105,4 +109,65 @@ struct CookieHeaderCacheTests {
let loadedAgain = CookieHeaderCache.load(provider: provider)
#expect(loadedAgain?.cookieHeader == "auth=legacy")
}
+
+ #if os(macOS)
+ @Test
+ func `temporary keychain unavailability returns nil without migrating legacy file`() {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ let legacyBase = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ CookieHeaderCache.setLegacyBaseURLOverrideForTesting(legacyBase)
+ defer { CookieHeaderCache.setLegacyBaseURLOverrideForTesting(nil) }
+
+ let provider: UsageProvider = .codex
+ let legacyURL = legacyBase.appendingPathComponent("\(provider.rawValue)-cookie.json")
+ CookieHeaderCache.store(
+ CookieHeaderCache.Entry(
+ cookieHeader: "auth=legacy",
+ storedAt: Date(timeIntervalSince1970: 0),
+ sourceLabel: "Legacy"),
+ to: legacyURL)
+ #expect(FileManager.default.fileExists(atPath: legacyURL.path) == true)
+
+ let loaded = KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) {
+ CookieHeaderCache.load(provider: provider)
+ }
+
+ #expect(loaded == nil)
+ #expect(FileManager.default.fileExists(atPath: legacyURL.path) == true)
+
+ switch KeychainCacheStore.load(key: .cookie(provider: provider), as: CookieHeaderCache.Entry.self) {
+ case .missing:
+ #expect(true)
+ case .found, .temporarilyUnavailable, .invalid:
+ #expect(Bool(false), "Expected temporary miss not to migrate legacy cache")
+ }
+ }
+ #endif
+
+ @Test
+ func `invalid keychain cache is cleared`() {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ let legacyBase = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ CookieHeaderCache.setLegacyBaseURLOverrideForTesting(legacyBase)
+ defer { CookieHeaderCache.setLegacyBaseURLOverrideForTesting(nil) }
+
+ let provider: UsageProvider = .codex
+ let key = KeychainCacheStore.Key.cookie(provider: provider)
+ KeychainCacheStore.store(key: key, entry: WrongEntry(value: "not-a-cookie-entry"))
+
+ #expect(CookieHeaderCache.load(provider: provider) == nil)
+
+ switch KeychainCacheStore.load(key: key, as: CookieHeaderCache.Entry.self) {
+ case .missing:
+ #expect(true)
+ case .found, .temporarilyUnavailable, .invalid:
+ #expect(Bool(false), "Expected invalid cookie cache to be cleared")
+ }
+ }
}
diff --git a/Tests/CodexBarTests/CopilotDeviceFlowTests.swift b/Tests/CodexBarTests/CopilotDeviceFlowTests.swift
new file mode 100644
index 000000000..7e0b1f20e
--- /dev/null
+++ b/Tests/CodexBarTests/CopilotDeviceFlowTests.swift
@@ -0,0 +1,42 @@
+import CodexBarCore
+import Foundation
+import Testing
+
+struct CopilotDeviceFlowTests {
+ @Test
+ func `prefers verification uri complete when available`() throws {
+ let response = try JSONDecoder().decode(
+ CopilotDeviceFlow.DeviceCodeResponse.self,
+ from: Data(
+ """
+ {
+ "device_code": "device-code",
+ "user_code": "ABCD-EFGH",
+ "verification_uri": "https://github.com/login/device",
+ "verification_uri_complete": "https://github.com/login/device?user_code=ABCD-EFGH",
+ "expires_in": 900,
+ "interval": 5
+ }
+ """.utf8))
+
+ #expect(response.verificationURLToOpen == "https://github.com/login/device?user_code=ABCD-EFGH")
+ }
+
+ @Test
+ func `falls back to verification uri when complete url missing`() throws {
+ let response = try JSONDecoder().decode(
+ CopilotDeviceFlow.DeviceCodeResponse.self,
+ from: Data(
+ """
+ {
+ "device_code": "device-code",
+ "user_code": "ABCD-EFGH",
+ "verification_uri": "https://github.com/login/device",
+ "expires_in": 900,
+ "interval": 5
+ }
+ """.utf8))
+
+ #expect(response.verificationURLToOpen == "https://github.com/login/device")
+ }
+}
diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift
index 4917dc120..20af0b54b 100644
--- a/Tests/CodexBarTests/CostUsagePricingTests.swift
+++ b/Tests/CodexBarTests/CostUsagePricingTests.swift
@@ -10,6 +10,8 @@ struct CostUsagePricingTests {
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-pro-2026-03-05") == "gpt-5.4-pro")
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-mini-2026-03-17") == "gpt-5.4-mini")
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.4-nano-2026-03-17") == "gpt-5.4-nano")
+ #expect(CostUsagePricing.normalizeCodexModel("gpt-5.5-2026-04-23") == "gpt-5.5")
+ #expect(CostUsagePricing.normalizeCodexModel("gpt-5.5-pro-2026-04-23") == "gpt-5.5-pro")
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-2026-03-05") == "gpt-5.3-codex")
#expect(CostUsagePricing.normalizeCodexModel("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark")
}
@@ -51,6 +53,28 @@ struct CostUsagePricingTests {
#expect(nano != nil)
}
+ @Test
+ func `codex cost supports gpt55`() {
+ let cost = CostUsagePricing.codexCostUSD(
+ model: "openai/gpt-5.5-2026-04-23",
+ inputTokens: 100,
+ cachedInputTokens: 10,
+ outputTokens: 5)
+
+ #expect(cost == 90 * 5e-6 + 10 * 5e-7 + 5 * 3e-5)
+ }
+
+ @Test
+ func `codex cost supports gpt55 pro`() {
+ let cost = CostUsagePricing.codexCostUSD(
+ model: "openai/gpt-5.5-pro-2026-04-23",
+ inputTokens: 100,
+ cachedInputTokens: 10,
+ outputTokens: 5)
+
+ #expect(cost == 100 * 3e-5 + 5 * 1.8e-4)
+ }
+
@Test
func `codex cost returns zero for research preview model`() {
let cost = CostUsagePricing.codexCostUSD(
diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift
index 2b02bbcbb..e34277f9f 100644
--- a/Tests/CodexBarTests/CursorStatusProbeTests.swift
+++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift
@@ -370,6 +370,7 @@ struct CursorStatusProbeTests {
#expect(usageSnapshot.providerCost != nil)
#expect(usageSnapshot.providerCost?.used == 0.0)
#expect(usageSnapshot.providerCost?.limit == 75.0)
+ #expect(usageSnapshot.providerCost?.period == "Monthly")
}
@Test
@@ -1045,7 +1046,9 @@ final class CursorStatusProbeStubURLProtocol: URLProtocol {
Self.lock.unlock()
do {
- let handler = try #require(handler)
+ guard let handler else {
+ throw URLError(.cancelled)
+ }
let (response, data) = try handler(self.request)
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
self.client?.urlProtocol(self, didLoad: data)
diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift
index f4a457fa8..921ebe1e4 100644
--- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift
+++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift
@@ -67,6 +67,13 @@ struct GeminiStatusProbeAPITests {
switch host {
case "oauth2.googleapis.com":
+ // Fail the refresh if the client_id did not come from the test stub.
+ // This guards against the probe accidentally extracting OAuth creds
+ // from an unrelated Gemini install on the developer's machine.
+ let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? ""
+ guard body.contains("client_id=test-client-id") else {
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data())
+ }
let json = GeminiAPITestHelpers.jsonData([
"access_token": "new-token",
"expires_in": 3600,
@@ -139,6 +146,13 @@ struct GeminiStatusProbeAPITests {
switch host {
case "oauth2.googleapis.com":
+ // Fail the refresh if the client_id did not come from the test stub.
+ // This guards against the probe accidentally extracting OAuth creds
+ // from an unrelated Gemini install on the developer's machine.
+ let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? ""
+ guard body.contains("client_id=test-client-id") else {
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data())
+ }
let json = GeminiAPITestHelpers.jsonData([
"access_token": "new-token",
"expires_in": 3600,
@@ -180,6 +194,116 @@ struct GeminiStatusProbeAPITests {
#expect(snapshot.accountPlan == "Paid")
}
+ @Test
+ func `refreshes expired token with fnm bundle layout`() async throws {
+ let env = try GeminiTestEnvironment()
+ defer { env.cleanup() }
+ try env.writeCredentials(
+ accessToken: "old-token",
+ refreshToken: "refresh-token",
+ expiry: Date().addingTimeInterval(-3600),
+ idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com"))
+
+ let binURL = try env.writeFakeGeminiCLI(layout: .fnmBundle)
+ // Match the real fnm layout: package root is inside the same multishell
+ // dir as the bin symlink target, under lib/node_modules/@google/gemini-cli.
+ let multishellRoot = binURL.deletingLastPathComponent().deletingLastPathComponent()
+ let packageJSONPath = multishellRoot
+ .appendingPathComponent("lib")
+ .appendingPathComponent("node_modules")
+ .appendingPathComponent("@google")
+ .appendingPathComponent("gemini-cli")
+ .appendingPathComponent("package.json")
+ let npmRoot = packageJSONPath
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .path
+ _ = try env.writeFakeFnm(npmRoot: npmRoot, geminiPackageJSONPath: packageJSONPath.path)
+
+ let previousPath = ProcessInfo.processInfo.environment["PATH"]
+ let fakeBinDir = env.homeURL.appendingPathComponent("bin").path
+ let pathValue = if let previousPath, !previousPath.isEmpty {
+ "\(fakeBinDir):\(binURL.deletingLastPathComponent().path):\(previousPath)"
+ } else {
+ "\(fakeBinDir):\(binURL.deletingLastPathComponent().path)"
+ }
+ setenv("PATH", pathValue, 1)
+
+ let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"]
+ setenv("GEMINI_CLI_PATH", binURL.path, 1)
+ defer {
+ if let previousPath {
+ setenv("PATH", previousPath, 1)
+ } else {
+ unsetenv("PATH")
+ }
+
+ if let previousGeminiPath {
+ setenv("GEMINI_CLI_PATH", previousGeminiPath, 1)
+ } else {
+ unsetenv("GEMINI_CLI_PATH")
+ }
+ }
+
+ let dataLoader = GeminiAPITestHelpers.dataLoader { request in
+ guard let url = request.url, let host = url.host else {
+ throw URLError(.badURL)
+ }
+
+ switch host {
+ case "oauth2.googleapis.com":
+ // Fail the refresh if the client_id did not come from the test stub.
+ // This guards against the probe accidentally extracting OAuth creds
+ // from an unrelated Gemini install on the developer's machine.
+ let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? ""
+ guard body.contains("client_id=test-client-id") else {
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data())
+ }
+ let json = GeminiAPITestHelpers.jsonData([
+ "access_token": "new-token",
+ "expires_in": 3600,
+ "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"),
+ ])
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json)
+ case "cloudresourcemanager.googleapis.com":
+ let json = GeminiAPITestHelpers.jsonData(["projects": []])
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json)
+ case "cloudcode-pa.googleapis.com":
+ if url.path == "/v1internal:loadCodeAssist" {
+ let auth = request.value(forHTTPHeaderField: "Authorization")
+ if auth != "Bearer new-token" {
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data())
+ }
+ return GeminiAPITestHelpers.response(
+ url: url.absoluteString,
+ status: 200,
+ body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse())
+ }
+ if url.path != "/v1internal:retrieveUserQuota" {
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data())
+ }
+ let auth = request.value(forHTTPHeaderField: "Authorization")
+ if auth != "Bearer new-token" {
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data())
+ }
+ return GeminiAPITestHelpers.response(
+ url: url.absoluteString,
+ status: 200,
+ body: GeminiAPITestHelpers.sampleQuotaResponse())
+ default:
+ return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data())
+ }
+ }
+
+ let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader)
+ let snapshot = try await probe.fetch()
+ #expect(snapshot.accountPlan == "Paid")
+
+ let updated = try env.readCredentials()
+ #expect(updated["access_token"] as? String == "new-token")
+ }
+
@Test
func `uses code assist project for quota`() async throws {
let env = try GeminiTestEnvironment()
diff --git a/Tests/CodexBarTests/GeminiTestEnvironment.swift b/Tests/CodexBarTests/GeminiTestEnvironment.swift
index 3d6b0b4bb..0a371003e 100644
--- a/Tests/CodexBarTests/GeminiTestEnvironment.swift
+++ b/Tests/CodexBarTests/GeminiTestEnvironment.swift
@@ -4,6 +4,7 @@ struct GeminiTestEnvironment {
enum GeminiCLILayout {
case npmNested
case nixShare
+ case fnmBundle
}
let homeURL: URL
@@ -57,9 +58,9 @@ struct GeminiTestEnvironment {
let binDir = base.appendingPathComponent("bin")
try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true)
- let oauthPath: URL = switch layout {
+ switch layout {
case .npmNested:
- base
+ let oauthPath = base
.appendingPathComponent("lib")
.appendingPathComponent("node_modules")
.appendingPathComponent("@google")
@@ -71,8 +72,28 @@ struct GeminiTestEnvironment {
.appendingPathComponent("src")
.appendingPathComponent("code_assist")
.appendingPathComponent("oauth2.js")
+
+ if includeOAuth {
+ try FileManager.default.createDirectory(
+ at: oauthPath.deletingLastPathComponent(),
+ withIntermediateDirectories: true)
+
+ let oauthContent = """
+ const OAUTH_CLIENT_ID = 'test-client-id';
+ const OAUTH_CLIENT_SECRET = 'test-client-secret';
+ """
+ try oauthContent.write(to: oauthPath, atomically: true, encoding: .utf8)
+ }
+
+ let geminiBinary = binDir.appendingPathComponent("gemini")
+ try "#!/bin/bash\nexit 0\n".write(to: geminiBinary, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o755],
+ ofItemAtPath: geminiBinary.path)
+ return geminiBinary
+
case .nixShare:
- base
+ let oauthPath = base
.appendingPathComponent("share")
.appendingPathComponent("gemini-cli")
.appendingPathComponent("node_modules")
@@ -82,25 +103,144 @@ struct GeminiTestEnvironment {
.appendingPathComponent("src")
.appendingPathComponent("code_assist")
.appendingPathComponent("oauth2.js")
- }
- if includeOAuth {
- try FileManager.default.createDirectory(
- at: oauthPath.deletingLastPathComponent(),
- withIntermediateDirectories: true)
+ if includeOAuth {
+ try FileManager.default.createDirectory(
+ at: oauthPath.deletingLastPathComponent(),
+ withIntermediateDirectories: true)
+
+ let oauthContent = """
+ const OAUTH_CLIENT_ID = 'test-client-id';
+ const OAUTH_CLIENT_SECRET = 'test-client-secret';
+ """
+ try oauthContent.write(to: oauthPath, atomically: true, encoding: .utf8)
+ }
- let oauthContent = """
- const OAUTH_CLIENT_ID = 'test-client-id';
- const OAUTH_CLIENT_SECRET = 'test-client-secret';
+ let geminiBinary = binDir.appendingPathComponent("gemini")
+ try "#!/bin/bash\nexit 0\n".write(to: geminiBinary, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o755],
+ ofItemAtPath: geminiBinary.path)
+ return geminiBinary
+
+ case .fnmBundle:
+ // Mirror a real fnm multishell layout: bin/gemini is a single relative
+ // symlink into the same multishell's lib/node_modules/@google/gemini-cli,
+ // which is a plain directory with the real package.json + bundle/*.js.
+ let multishellRoot = self.homeURL
+ .appendingPathComponent("Library")
+ .appendingPathComponent("Caches")
+ .appendingPathComponent("fnm_multishells")
+ .appendingPathComponent("12345_67890")
+ let binDir = multishellRoot.appendingPathComponent("bin")
+ let packageRoot = multishellRoot
+ .appendingPathComponent("lib")
+ .appendingPathComponent("node_modules")
+ .appendingPathComponent("@google")
+ .appendingPathComponent("gemini-cli")
+ let bundleDir = packageRoot.appendingPathComponent("bundle")
+ try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true)
+
+ let packageJSON = """
+ {
+ "name": "@google/gemini-cli"
+ }
"""
- try oauthContent.write(to: oauthPath, atomically: true, encoding: .utf8)
+ try packageJSON.write(
+ to: packageRoot.appendingPathComponent("package.json"),
+ atomically: true,
+ encoding: .utf8)
+
+ let chunkName = "chunk-TEST123.js"
+ let geminiEntry = bundleDir.appendingPathComponent("gemini.js")
+ let geminiContent = """
+ #!/usr/bin/env node
+ import { start } from "./\(chunkName)";
+ start();
+ """
+ try geminiContent.write(to: geminiEntry, atomically: true, encoding: .utf8)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o755],
+ ofItemAtPath: geminiEntry.path)
+
+ let chunkContent = if includeOAuth {
+ """
+ export const start = () => {};
+ const OAUTH_CLIENT_ID = 'test-client-id';
+ const OAUTH_CLIENT_SECRET = 'test-client-secret';
+ """
+ } else {
+ "export const start = () => {};\n"
+ }
+ try chunkContent.write(
+ to: bundleDir.appendingPathComponent(chunkName),
+ atomically: true,
+ encoding: .utf8)
+
+ // Relative symlink matching what fnm actually creates:
+ // fnm_multishells/XXX/bin/gemini -> ../lib/node_modules/@google/gemini-cli/bundle/gemini.js
+ // Use the path-based API so the target is stored as a literal relative
+ // string; the URL-based API resolves URL(fileURLWithPath: "../...") against
+ // the process CWD, which produces a bogus absolute target.
+ let geminiBinary = binDir.appendingPathComponent("gemini")
+ try FileManager.default.createSymbolicLink(
+ atPath: geminiBinary.path,
+ withDestinationPath: "../lib/node_modules/@google/gemini-cli/bundle/gemini.js")
+
+ return geminiBinary
}
+ }
+
+ func writeFakeFnm(
+ currentVersion: String = "v24.6.0",
+ npmRoot: String? = nil,
+ geminiPackageJSONPath: String) throws -> URL
+ {
+ let binDir = self.homeURL.appendingPathComponent("bin")
+ try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true)
+
+ let fnmPath = binDir.appendingPathComponent("fnm")
+ let script = if let npmRoot {
+ """
+ #!/bin/bash
+ if [ "$1" = "current" ]; then
+ printf '%s\n' "\(currentVersion)"
+ exit 0
+ fi
+
+ if [ "$1" = "exec" ] && [ "$4" = "npm" ] && [ "$5" = "root" ] && [ "$6" = "-g" ]; then
+ printf '%s\n' "\(npmRoot)"
+ exit 0
+ fi
- let geminiBinary = binDir.appendingPathComponent("gemini")
- try "#!/bin/bash\nexit 0\n".write(to: geminiBinary, atomically: true, encoding: .utf8)
+ if [ "$1" = "exec" ] && [ "$4" = "node" ]; then
+ printf '%s\n' "\(geminiPackageJSONPath)"
+ exit 0
+ fi
+
+ exit 1
+ """
+ } else {
+ """
+ #!/bin/bash
+ if [ "$1" = "current" ]; then
+ printf '%s\n' "\(currentVersion)"
+ exit 0
+ fi
+
+ if [ "$1" = "exec" ]; then
+ printf '%s\n' "\(geminiPackageJSONPath)"
+ exit 0
+ fi
+
+ exit 1
+ """
+ }
+ try script.write(to: fnmPath, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755],
- ofItemAtPath: geminiBinary.path)
- return geminiBinary
+ ofItemAtPath: fnmPath.path)
+ return fnmPath
}
}
diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift
index 0eb90d640..12145ea6a 100644
--- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift
+++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift
@@ -24,7 +24,7 @@ struct KeychainCacheStoreTests {
switch KeychainCacheStore.load(key: key, as: TestEntry.self) {
case let .found(loaded):
#expect(loaded == entry)
- case .missing, .invalid:
+ case .missing, .temporarilyUnavailable, .invalid:
#expect(Bool(false), "Expected keychain cache entry")
}
}
@@ -45,7 +45,7 @@ struct KeychainCacheStoreTests {
switch KeychainCacheStore.load(key: key, as: TestEntry.self) {
case let .found(loaded):
#expect(loaded == second)
- case .missing, .invalid:
+ case .missing, .temporarilyUnavailable, .invalid:
#expect(Bool(false), "Expected overwritten keychain cache entry")
}
}
@@ -64,8 +64,52 @@ struct KeychainCacheStoreTests {
switch KeychainCacheStore.load(key: key, as: TestEntry.self) {
case .missing:
#expect(true)
- case .found, .invalid:
+ case .found, .temporarilyUnavailable, .invalid:
#expect(Bool(false), "Expected keychain cache entry to be cleared")
}
}
+
+ #if os(macOS)
+ @Test
+ func `interaction not allowed is treated as temporarily unavailable`() {
+ let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString)
+ let result: KeychainCacheStore.LoadResult = KeychainCacheStore.loadResultForKeychainReadFailure(
+ status: errSecInteractionNotAllowed,
+ key: key)
+
+ switch result {
+ case .temporarilyUnavailable:
+ #expect(true)
+ case .found, .missing, .invalid:
+ #expect(Bool(false), "Expected temporary keychain lock to be retry-later")
+ }
+ }
+
+ @Test
+ func `load failure override bypasses test store without affecting store or clear`() {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+
+ let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString)
+ let entry = TestEntry(value: "stored", storedAt: Date(timeIntervalSince1970: 0))
+ KeychainCacheStore.store(key: key, entry: entry)
+ defer { KeychainCacheStore.clear(key: key) }
+
+ KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) {
+ switch KeychainCacheStore.load(key: key, as: TestEntry.self) {
+ case .temporarilyUnavailable:
+ #expect(true)
+ case .found, .missing, .invalid:
+ #expect(Bool(false), "Expected override to run before test store")
+ }
+ }
+
+ switch KeychainCacheStore.load(key: key, as: TestEntry.self) {
+ case let .found(loaded):
+ #expect(loaded == entry)
+ case .missing, .temporarilyUnavailable, .invalid:
+ #expect(Bool(false), "Expected override not to mutate test store")
+ }
+ }
+ #endif
}
diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift
index e72581e06..df37232dc 100644
--- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift
+++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift
@@ -20,4 +20,25 @@ struct MenuBarMetricWindowResolverTests {
#expect(window?.usedPercent == 92)
}
+
+ @Test
+ func `extra usage metric maps provider cost into a menu bar window`() {
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ providerCost: ProviderCostSnapshot(
+ used: 37.5,
+ limit: 150,
+ currencyCode: "USD",
+ updatedAt: Date()),
+ updatedAt: Date())
+
+ let window = MenuBarMetricWindowResolver.rateWindow(
+ preference: .extraUsage,
+ provider: .cursor,
+ snapshot: snapshot,
+ supportsAverage: false)
+
+ #expect(window?.usedPercent == 25)
+ }
}
diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift
index b4e3b7522..a0ee41663 100644
--- a/Tests/CodexBarTests/MenuCardModelTests.swift
+++ b/Tests/CodexBarTests/MenuCardModelTests.swift
@@ -124,6 +124,74 @@ struct MenuCardModelTests {
#expect(model.planText == "Max")
}
+ @Test
+ func `claude model includes design and routines bars when present`() throws {
+ let now = Date()
+ let identity = ProviderIdentitySnapshot(
+ providerID: .claude,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: "Max")
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 2,
+ windowMinutes: nil,
+ resetsAt: now.addingTimeInterval(3600),
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 8,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(7200),
+ resetDescription: nil),
+ tertiary: RateWindow(
+ usedPercent: 16,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(7800),
+ resetDescription: nil),
+ extraRateWindows: [
+ NamedRateWindow(
+ id: "claude-design",
+ title: "Designs",
+ window: RateWindow(
+ usedPercent: 31,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(8200),
+ resetDescription: nil)),
+ NamedRateWindow(
+ id: "claude-routines",
+ title: "Daily Routines",
+ window: RateWindow(
+ usedPercent: 7,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(9200),
+ resetDescription: nil)),
+ ],
+ updatedAt: now,
+ identity: identity)
+ let metadata = try #require(ProviderDefaults.metadata[.claude])
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .claude,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: "codex@example.com", plan: "plus"),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: false,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ #expect(model.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Designs", "Daily Routines"])
+ }
+
@Test
func `shows error subtitle when present`() throws {
let metadata = try #require(ProviderDefaults.metadata[.codex])
@@ -707,4 +775,50 @@ struct MenuCardModelTests {
#expect(primary.resetText == nil)
#expect(primary.detailText == "10/100 credits")
}
+
+ @Test
+ func `mistral model surfaces monthly cost as primary detail text`() throws {
+ let now = Date()
+ let resetsAt = now.addingTimeInterval(3 * 24 * 60 * 60)
+ let identity = ProviderIdentitySnapshot(
+ providerID: .mistral,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil)
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 0,
+ windowMinutes: nil,
+ resetsAt: resetsAt,
+ resetDescription: "€1.2345 this month"),
+ secondary: nil,
+ tertiary: nil,
+ updatedAt: now,
+ identity: identity)
+ let metadata = try #require(ProviderDefaults.metadata[.mistral])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .mistral,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: true,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ let primary = try #require(model.metrics.first)
+ #expect(primary.detailText == "€1.2345 this month")
+ #expect(primary.resetText?.hasPrefix("Resets") == true)
+ }
}
diff --git a/Tests/CodexBarTests/MiniMaxMenuCardTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift
new file mode 100644
index 000000000..dd7d2e7fb
--- /dev/null
+++ b/Tests/CodexBarTests/MiniMaxMenuCardTests.swift
@@ -0,0 +1,281 @@
+import CodexBarCore
+import Foundation
+import Testing
+@testable import CodexBar
+
+struct MiniMaxMenuCardTests {
+ @Test
+ func `minimax sections group fiveHour and daily rows`() throws {
+ let now = Date()
+ let models: [MiniMaxModelUsage] = [
+ MiniMaxModelUsage(
+ identifier: "text-gen",
+ displayName: "Text",
+ availablePrompts: 4500,
+ currentPrompts: 100,
+ remainingPrompts: 4400,
+ windowMinutes: 300,
+ usedPercent: 2.2,
+ resetsAt: nil,
+ weeklyTotal: nil,
+ weeklyUsed: nil,
+ weeklyRemaining: nil,
+ weeklyUsedPercent: nil,
+ weeklyResetsAt: nil,
+ window: .fiveHour),
+ MiniMaxModelUsage(
+ identifier: "image-01",
+ displayName: "image-01",
+ availablePrompts: 120,
+ currentPrompts: 0,
+ remainingPrompts: 120,
+ windowMinutes: 1440,
+ usedPercent: 0,
+ resetsAt: nil,
+ weeklyTotal: nil,
+ weeklyUsed: nil,
+ weeklyRemaining: nil,
+ weeklyUsedPercent: nil,
+ weeklyResetsAt: nil,
+ window: .daily),
+ ]
+ let minimax = MiniMaxUsageSnapshot(
+ planName: "Token Plan",
+ availablePrompts: 4500,
+ currentPrompts: 100,
+ remainingPrompts: 4400,
+ windowMinutes: 300,
+ usedPercent: 2.2,
+ resetsAt: nil,
+ updatedAt: now,
+ models: models)
+ let snapshot = minimax.toUsageSnapshot()
+ let metadata = try #require(ProviderDefaults.metadata[.minimax])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .minimax,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: true,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ let sections = try #require(model.minimaxSections)
+ #expect(sections.count == 2)
+ #expect(sections[0].title == "5-hour window")
+ #expect(sections[0].rows.count == 1)
+ #expect(sections[0].rows[0].title == "Text")
+ #expect(sections[1].title == "Daily quota")
+ #expect(sections[1].rows[0].title == "image-01")
+ }
+
+ @Test
+ func `minimax shows sections when single row has weekly quota`() throws {
+ let now = Date()
+ let models: [MiniMaxModelUsage] = [
+ MiniMaxModelUsage(
+ identifier: "speech-hd",
+ displayName: "Speech HD",
+ availablePrompts: 11000,
+ currentPrompts: 10995,
+ remainingPrompts: 5,
+ windowMinutes: 1440,
+ usedPercent: 99.95,
+ resetsAt: nil,
+ weeklyTotal: 77000,
+ weeklyUsed: 6354,
+ weeklyRemaining: 70646,
+ weeklyUsedPercent: 91.7,
+ weeklyResetsAt: nil,
+ window: .daily),
+ ]
+ let minimax = MiniMaxUsageSnapshot(
+ planName: "Token Plan",
+ availablePrompts: 11000,
+ currentPrompts: 10995,
+ remainingPrompts: 5,
+ windowMinutes: 1440,
+ usedPercent: 99.95,
+ resetsAt: nil,
+ updatedAt: now,
+ models: models)
+ let snapshot = minimax.toUsageSnapshot()
+ let metadata = try #require(ProviderDefaults.metadata[.minimax])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .minimax,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: false,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ let sections = try #require(model.minimaxSections)
+ #expect(sections.count == 1)
+ let row = try #require(sections.first?.rows.first)
+ let weekly = try #require(row.secondaryLine)
+ #expect(weekly.contains("Weekly"))
+ #expect(weekly.contains("91.7"))
+ // Remaining uses UsageFormatter.tokenCountString (e.g. 70646 → "71K"), not raw digits.
+ #expect(weekly.contains("remaining"))
+ }
+
+ @Test
+ func `minimax hides weekly secondary line when weekly quota is zero zero`() throws {
+ let now = Date()
+ let models: [MiniMaxModelUsage] = [
+ MiniMaxModelUsage(
+ identifier: "coding",
+ displayName: "Coding",
+ availablePrompts: 1000,
+ currentPrompts: 100,
+ remainingPrompts: 900,
+ windowMinutes: 300,
+ usedPercent: 10,
+ resetsAt: nil,
+ weeklyTotal: 0,
+ weeklyUsed: 0,
+ weeklyRemaining: 0,
+ weeklyUsedPercent: nil,
+ weeklyResetsAt: nil,
+ window: .fiveHour),
+ ]
+ let minimax = MiniMaxUsageSnapshot(
+ planName: "Plan",
+ availablePrompts: 1000,
+ currentPrompts: 100,
+ remainingPrompts: 900,
+ windowMinutes: 300,
+ usedPercent: 10,
+ resetsAt: nil,
+ updatedAt: now,
+ models: models)
+ let snapshot = minimax.toUsageSnapshot()
+ let metadata = try #require(ProviderDefaults.metadata[.minimax])
+
+ let model = UsageMenuCardView.Model.make(.init(
+ provider: .minimax,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: true,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+
+ let sections = try #require(model.minimaxSections)
+ let row = try #require(sections.first?.rows.first)
+ #expect(row.secondaryLine == nil)
+ }
+
+ @Test @MainActor
+ func `collapse store defaults collapsed when row count at least five`() {
+ let store = MiniMaxSectionCollapseStore.shared
+ store.resetOverridesForTesting()
+ #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold))
+ #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: 10))
+ #expect(!store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold - 1))
+ }
+
+ @Test @MainActor
+ func `collapse store toggle persists until reset`() {
+ let store = MiniMaxSectionCollapseStore.shared
+ store.resetOverridesForTesting()
+ #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold))
+ store.toggle(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold)
+ #expect(!store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold))
+ store.toggle(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold)
+ #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold))
+ store.resetOverridesForTesting()
+ #expect(store.isCollapsed(sectionTitle: "Daily quota", rowCount: MiniMaxUILayoutMetrics.collapseThreshold))
+ }
+
+ @Test @MainActor
+ func `collapse store user override beats default for small sections`() {
+ let store = MiniMaxSectionCollapseStore.shared
+ store.resetOverridesForTesting()
+ #expect(!store.isCollapsed(sectionTitle: "Other windows", rowCount: 2))
+ store.toggle(sectionTitle: "Other windows", rowCount: 2)
+ #expect(store.isCollapsed(sectionTitle: "Other windows", rowCount: 2))
+ }
+
+ @Test
+ func `minimax detail line does not infer full usage when interval usage count missing`() {
+ let row = MiniMaxModelUsage(
+ identifier: "m",
+ displayName: "M",
+ availablePrompts: 1000,
+ currentPrompts: nil,
+ remainingPrompts: nil,
+ windowMinutes: 300,
+ usedPercent: 12.0,
+ resetsAt: nil,
+ weeklyTotal: nil,
+ weeklyUsed: nil,
+ weeklyRemaining: nil,
+ weeklyUsedPercent: nil,
+ weeklyResetsAt: nil,
+ window: .fiveHour)
+ let line = UsageMenuCardView.Model.miniMaxDetailLine(model: row)
+ let totalStr = UsageFormatter.tokenCountString(1000)
+ #expect(line == "—/\(totalStr)")
+ }
+
+ @Test
+ func `minimax detail line derives used from remaining when current omitted`() {
+ let row = MiniMaxModelUsage(
+ identifier: "m",
+ displayName: "M",
+ availablePrompts: 1000,
+ currentPrompts: nil,
+ remainingPrompts: 250,
+ windowMinutes: 300,
+ usedPercent: 75.0,
+ resetsAt: nil,
+ weeklyTotal: nil,
+ weeklyUsed: nil,
+ weeklyRemaining: nil,
+ weeklyUsedPercent: nil,
+ weeklyResetsAt: nil,
+ window: .fiveHour)
+ let line = UsageMenuCardView.Model.miniMaxDetailLine(model: row)
+ let usedStr = UsageFormatter.tokenCountString(750)
+ let totalStr = UsageFormatter.tokenCountString(1000)
+ let remStr = UsageFormatter.tokenCountString(250)
+ #expect(line == "\(usedStr)/\(totalStr) (\(remStr) remaining)")
+ }
+}
diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift
index 7cf0524bc..96e189bdd 100644
--- a/Tests/CodexBarTests/MiniMaxProviderTests.swift
+++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift
@@ -113,6 +113,8 @@ struct MiniMaxUsageParserTests {
#expect(snapshot.windowMinutes == 300)
#expect(snapshot.usedPercent == 75)
#expect(snapshot.resetsAt == expectedReset)
+ #expect(snapshot.models.count == 1)
+ #expect(snapshot.models.first?.window == .fiveHour)
}
@Test
@@ -149,6 +151,134 @@ struct MiniMaxUsageParserTests {
#expect(snapshot.windowMinutes == 300)
#expect(abs((snapshot.usedPercent ?? 0) - expectedUsed) < 0.01)
#expect(snapshot.resetsAt == expectedReset)
+ #expect(snapshot.models.count == 1)
+ }
+
+ @Test
+ func `parses multiple model_remains rows and weekly fields`() throws {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let start5h = 1_700_000_000_000
+ let end5h = start5h + 5 * 60 * 60 * 1000
+ let dayStart = 1_700_000_000_000
+ let dayEnd = dayStart + 24 * 60 * 60 * 1000
+ let json = """
+ {
+ "base_resp": { "status_code": 0 },
+ "current_subscribe_title": "Token Plan",
+ "model_remains": [
+ {
+ "model_name": "text-gen",
+ "current_interval_total_count": 4500,
+ "current_interval_usage_count": 4381,
+ "start_time": \(start5h),
+ "end_time": \(end5h),
+ "remains_time": 240000
+ },
+ {
+ "model_name": "image-01",
+ "current_interval_total_count": 120,
+ "current_interval_usage_count": 120,
+ "start_time": \(dayStart),
+ "end_time": \(dayEnd),
+ "remains_time": 3600000
+ },
+ {
+ "model_name": "speech-hd",
+ "current_interval_total_count": 11000,
+ "current_interval_usage_count": 5,
+ "current_weekly_total_count": "77000",
+ "current_weekly_usage_count": "70646",
+ "start_time": \(dayStart),
+ "end_time": \(dayEnd),
+ "remains_time": 3600000
+ }
+ ]
+ }
+ """
+
+ let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now)
+
+ #expect(snapshot.planName == "Token Plan")
+ #expect(snapshot.availablePrompts == 4500)
+ #expect(snapshot.models.count == 3)
+
+ let text = snapshot.models.first { $0.identifier == "text-gen" }
+ #expect(text?.window == .fiveHour)
+ #expect(text?.currentPrompts == 119)
+
+ let image = snapshot.models.first { $0.identifier == "image-01" }
+ #expect(image?.window == .daily)
+ #expect(image?.usedPercent == 0)
+
+ let speech = snapshot.models.first { $0.identifier == "speech-hd" }
+ #expect(speech?.weeklyTotal == 77000)
+ #expect(speech?.weeklyRemaining == 70646)
+ #expect(speech?.weeklyUsed == 6354)
+ }
+
+ @Test
+ func `parses weekly zero zero as no weekly cap`() throws {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let start = 1_700_000_000_000
+ let end = start + 5 * 60 * 60 * 1000
+ let json = """
+ {
+ "base_resp": { "status_code": 0 },
+ "current_subscribe_title": "Unlimited Weekly",
+ "model_remains": [
+ {
+ "model_name": "coding-model",
+ "current_interval_total_count": 1000,
+ "current_interval_usage_count": 500,
+ "current_weekly_total_count": 0,
+ "current_weekly_usage_count": 0,
+ "weekly_end_time": \(start + 7 * 24 * 60 * 60 * 1000),
+ "weekly_remains_time": 3600000,
+ "start_time": \(start),
+ "end_time": \(end),
+ "remains_time": 240000
+ }
+ ]
+ }
+ """
+
+ let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now)
+ let row = try #require(snapshot.models.first { $0.identifier == "coding-model" })
+ #expect(row.weeklyTotal == nil)
+ #expect(row.weeklyRemaining == nil)
+ #expect(row.weeklyUsed == nil)
+ #expect(row.weeklyUsedPercent == nil)
+ #expect(row.weeklyResetsAt == nil)
+ }
+
+ @Test
+ func `parses weekly total zero with missing remaining as no weekly cap`() throws {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let start = 1_700_000_000_000
+ let end = start + 5 * 60 * 60 * 1000
+ let json = """
+ {
+ "base_resp": { "status_code": 0 },
+ "current_subscribe_title": "Plan",
+ "model_remains": [
+ {
+ "model_name": "m1",
+ "current_interval_total_count": 1000,
+ "current_interval_usage_count": 500,
+ "current_weekly_total_count": 0,
+ "start_time": \(start),
+ "end_time": \(end),
+ "remains_time": 240000
+ }
+ ]
+ }
+ """
+
+ let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now)
+ let row = try #require(snapshot.models.first)
+ #expect(row.weeklyTotal == nil)
+ #expect(row.weeklyRemaining == nil)
+ #expect(row.weeklyResetsAt == nil)
}
@Test
@@ -193,6 +323,7 @@ struct MiniMaxUsageParserTests {
#expect(snapshot.windowMinutes == 300)
#expect(snapshot.usedPercent == 75)
#expect(snapshot.resetsAt == expectedReset)
+ #expect(snapshot.models.count == 1)
}
@Test
diff --git a/Tests/CodexBarTests/MiniMaxUILayoutMetricsTests.swift b/Tests/CodexBarTests/MiniMaxUILayoutMetricsTests.swift
new file mode 100644
index 000000000..d9ad47fa3
--- /dev/null
+++ b/Tests/CodexBarTests/MiniMaxUILayoutMetricsTests.swift
@@ -0,0 +1,28 @@
+import Testing
+@testable import CodexBar
+
+struct MiniMaxUILayoutMetricsTests {
+ @Test
+ func `preferred menu usage height uses content height when under cap`() {
+ let height = MiniMaxUILayoutMetrics.preferredMenuUsageHeight(
+ contentHeight: 180,
+ visibleScreenHeight: 1000)
+ #expect(height == 180)
+ }
+
+ @Test
+ func `preferred menu usage height clamps to cap when content is taller`() {
+ let cap = MiniMaxUILayoutMetrics.menuUsageScrollMaxHeight(visibleScreenHeight: 900)
+ let height = MiniMaxUILayoutMetrics.preferredMenuUsageHeight(
+ contentHeight: cap + 240,
+ visibleScreenHeight: 900)
+ #expect(height == cap)
+ }
+
+ @Test
+ func `menu usage height falls back when screen height unavailable`() {
+ #expect(
+ MiniMaxUILayoutMetrics.menuUsageScrollMaxHeight(visibleScreenHeight: nil) ==
+ MiniMaxUILayoutMetrics.menuScrollFallbackHeight)
+ }
+}
diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift
new file mode 100644
index 000000000..ec1d6dd84
--- /dev/null
+++ b/Tests/CodexBarTests/MistralUsageParserTests.swift
@@ -0,0 +1,222 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct MistralUsageParserTests {
+ // swiftlint:disable line_length
+
+ private static let novemberResponseJSON = """
+ {"completion":{"models":{"mistral-large-latest::mistral-large-2411":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"input","timestamp":"2025-11-14","value":11121,"value_paid":11121}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"output","timestamp":"2025-11-14","value":1115,"value_paid":1115}]},"mistral-small-latest::mistral-small-2506":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-14","value":20,"value_paid":20},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-24","value":100,"value_paid":100}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-14","value":500,"value_paid":500},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-24","value":2482,"value_paid":2482}]}}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2025-11-01T00:00:00Z","previous_month":"2025-10","next_month":"2025-12","start_date":"2025-11-01T00:00:00Z","end_date":"2025-11-30T23:59:59.999Z","currency":"EUR","currency_symbol":"\\u20ac","prices":[{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"input","price":"0.0000017000"},{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"output","price":"0.0000051000"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"input","price":"8.50E-8"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"output","price":"2.550E-7"}]}
+ """
+
+ private static let emptyResponseJSON = """
+ {"completion":{"models":{}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2026-02-01T00:00:00Z","previous_month":"2026-01","next_month":"2026-03","start_date":"2026-02-01T00:00:00Z","end_date":"2026-02-28T23:59:59.999Z","currency":"EUR","currency_symbol":"\\u20ac","prices":[]}
+ """
+
+ // swiftlint:enable line_length
+
+ @Test
+ func `parses response with usage data and computes token totals`() throws {
+ let data = try #require(Self.novemberResponseJSON.data(using: .utf8))
+ let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date())
+
+ // mistral-large input: 11121, mistral-small input: 20+100=120
+ #expect(snapshot.totalInputTokens == 11121 + 120)
+ // mistral-large output: 1115, mistral-small output: 500+2482=2982
+ #expect(snapshot.totalOutputTokens == 1115 + 2982)
+ #expect(snapshot.totalCachedTokens == 0)
+ #expect(snapshot.modelCount == 2)
+ #expect(snapshot.currency == "EUR")
+ #expect(snapshot.currencySymbol == "€")
+ }
+
+ @Test
+ func `computes cost from tokens and prices`() throws {
+ let data = try #require(Self.novemberResponseJSON.data(using: .utf8))
+ let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date())
+
+ // mistral-large-2411 input: 11121 * 0.0000017 = 0.0189057
+ // mistral-large-2411 output: 1115 * 0.0000051 = 0.0056865
+ // mistral-small-2506 input: 120 * 0.000000085 = 0.0000102
+ // mistral-small-2506 output: 2982 * 0.000000255 = 0.00076041
+ let expectedCost = 0.0189057 + 0.0056865 + 0.0000102 + 0.00076041
+ #expect(abs(snapshot.totalCost - expectedCost) < 0.0001)
+ #expect(snapshot.totalCost > 0)
+ }
+
+ @Test
+ func `parses empty response with no usage`() throws {
+ let data = try #require(Self.emptyResponseJSON.data(using: .utf8))
+ let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date())
+
+ #expect(snapshot.totalInputTokens == 0)
+ #expect(snapshot.totalOutputTokens == 0)
+ #expect(snapshot.totalCost == 0)
+ #expect(snapshot.modelCount == 0)
+ #expect(snapshot.currency == "EUR")
+ }
+
+ @Test
+ func `parses dates from response`() throws {
+ let data = try #require(Self.novemberResponseJSON.data(using: .utf8))
+ let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date())
+
+ #expect(snapshot.startDate != nil)
+ #expect(snapshot.endDate != nil)
+
+ let calendar = Calendar.current
+ if let start = snapshot.startDate {
+ #expect(calendar.component(.month, from: start) == 11)
+ #expect(calendar.component(.year, from: start) == 2025)
+ }
+ }
+
+ @Test
+ func `throws parseFailed for invalid JSON`() {
+ let data = Data("not json".utf8)
+ #expect(throws: MistralUsageError.self) {
+ try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date())
+ }
+ }
+}
+
+struct MistralUsageSnapshotConversionTests {
+ @Test
+ func `converts cost into primary resetDescription so it surfaces as detail text`() {
+ let snapshot = MistralUsageSnapshot(
+ totalCost: 1.2345,
+ currency: "EUR",
+ currencySymbol: "€",
+ totalInputTokens: 10000,
+ totalOutputTokens: 5000,
+ totalCachedTokens: 0,
+ modelCount: 2,
+ startDate: nil,
+ endDate: Date(),
+ updatedAt: Date())
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary != nil)
+ #expect(usage.primary?.usedPercent == 0)
+ #expect(usage.primary?.resetDescription?.contains("€1.2345") == true)
+ // providerCost is intentionally nil: the menu card's providerCostSection requires
+ // limit > 0 to render a bar, and Mistral is pay-as-you-go with no quota. The cost
+ // is surfaced via primary.resetDescription (rendered as detail text in the card).
+ #expect(usage.providerCost == nil)
+ }
+
+ @Test
+ func `converts zero cost with no-usage description`() {
+ let snapshot = MistralUsageSnapshot(
+ totalCost: 0,
+ currency: "USD",
+ currencySymbol: "$",
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ totalCachedTokens: 0,
+ modelCount: 0,
+ startDate: nil,
+ endDate: nil,
+ updatedAt: Date())
+
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary?.resetDescription == "No usage this month")
+ }
+}
+
+struct MistralStrategyTests {
+ private struct StubClaudeFetcher: ClaudeUsageFetching {
+ func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
+ throw ClaudeUsageError.parseFailed("stub")
+ }
+
+ func debugRawProbe(model _: String) async -> String {
+ "stub"
+ }
+
+ func detectVersion() -> String? {
+ nil
+ }
+ }
+
+ private func makeContext(
+ sourceMode: ProviderSourceMode = .auto,
+ settings: ProviderSettingsSnapshot? = nil,
+ env: [String: String] = [:]) -> ProviderFetchContext
+ {
+ let browserDetection = BrowserDetection(cacheTTL: 0)
+ return ProviderFetchContext(
+ runtime: .cli,
+ sourceMode: sourceMode,
+ includeCredits: false,
+ webTimeout: 1,
+ webDebugDumpHTML: false,
+ verbose: false,
+ env: env,
+ settings: settings,
+ fetcher: UsageFetcher(environment: env),
+ claudeFetcher: StubClaudeFetcher(),
+ browserDetection: browserDetection)
+ }
+
+ @Test
+ func `strategy is unavailable when cookie source is off`() async {
+ let settings = ProviderSettingsSnapshot.make(
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: .off,
+ manualCookieHeader: nil))
+ let context = self.makeContext(settings: settings)
+ let strategy = MistralWebFetchStrategy()
+
+ let available = await strategy.isAvailable(context)
+ #expect(available == false)
+ }
+
+ @Test
+ func `strategy is available when cookie source is auto`() async {
+ let settings = ProviderSettingsSnapshot.make(
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil))
+ let context = self.makeContext(settings: settings)
+ let strategy = MistralWebFetchStrategy()
+
+ let available = await strategy.isAvailable(context)
+ #expect(available == true)
+ }
+
+ @Test
+ func `strategy is available when cookie source is manual`() async {
+ let settings = ProviderSettingsSnapshot.make(
+ mistral: ProviderSettingsSnapshot.MistralProviderSettings(
+ cookieSource: .manual,
+ manualCookieHeader: "ory_session_x=abc; csrftoken=xyz"))
+ let context = self.makeContext(settings: settings)
+ let strategy = MistralWebFetchStrategy()
+
+ let available = await strategy.isAvailable(context)
+ #expect(available == true)
+ }
+
+ @Test
+ func `strategy never falls back (single strategy provider)`() {
+ let strategy = MistralWebFetchStrategy()
+ let context = self.makeContext()
+ let shouldFallback = strategy.shouldFallback(
+ on: MistralUsageError.invalidCredentials,
+ context: context)
+ #expect(shouldFallback == false)
+ }
+
+ @Test
+ func `descriptor metadata is correct`() {
+ let descriptor = MistralProviderDescriptor.descriptor
+ #expect(descriptor.id == .mistral)
+ #expect(descriptor.metadata.displayName == "Mistral")
+ #expect(descriptor.metadata.cliName == "mistral")
+ #expect(descriptor.metadata.defaultEnabled == false)
+ #expect(descriptor.cli.name == "mistral")
+ #expect(descriptor.fetchPlan.sourceModes == [.auto, .web])
+ #expect(descriptor.branding.iconResourceName == "ProviderIcon-mistral")
+ }
+}
diff --git a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift
index 85713405a..b234a2c32 100644
--- a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift
+++ b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift
@@ -108,6 +108,32 @@ struct OpenAIDashboardFetcherCreditsWaitTests {
#expect(shouldWait == false)
}
+ @Test
+ func `probe handoff preserves page only after confirmed signed in email`() {
+ let result = OpenAIDashboardFetcher.ProbeResult(
+ href: "https://chatgpt.com/codex/cloud/settings/analytics#usage",
+ loginRequired: false,
+ workspacePicker: false,
+ cloudflareInterstitial: false,
+ signedInEmail: "user@example.com",
+ bodyText: "Credits remaining 42")
+
+ #expect(OpenAIDashboardFetcher.shouldPreserveLoadedPageAfterProbe(result))
+ }
+
+ @Test
+ func `probe handoff does not preserve timed out usage page without email`() {
+ let result = OpenAIDashboardFetcher.ProbeResult(
+ href: "https://chatgpt.com/codex/cloud/settings/analytics#usage",
+ loginRequired: false,
+ workspacePicker: false,
+ cloudflareInterstitial: false,
+ signedInEmail: nil,
+ bodyText: "Codex Analytics")
+
+ #expect(!OpenAIDashboardFetcher.shouldPreserveLoadedPageAfterProbe(result))
+ }
+
@Test
func `probe grace restarts after route reload resets readiness timestamps`() {
let now = Date()
@@ -167,10 +193,21 @@ struct OpenAIDashboardFetcherCreditsWaitTests {
#expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/usage"))
}
+ @Test
+ func `usage route matcher accepts analytics route`() {
+ #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics"))
+ }
+
+ @Test
+ func `usage route matcher accepts analytics usage hash route`() {
+ #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics#usage"))
+ }
+
@Test
func `usage route matcher accepts trailing slash variants`() {
#expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/settings/usage/"))
#expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/usage/"))
+ #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics/"))
}
@Test
diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift
index 13c31e625..0ee8e0e70 100644
--- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift
+++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift
@@ -3,11 +3,25 @@ import Testing
import WebKit
@testable import CodexBarCore
+@Suite(.serialized)
struct OpenAIDashboardNavigationDelegateTests {
final class DelegateBox: @unchecked Sendable {
var delegate: NavigationDelegate?
}
+ @MainActor
+ private func waitForResult(
+ _ result: @escaping () -> Result?,
+ timeout: TimeInterval = NavigationDelegate.postCommitSuccessDelay + 10.0) async -> Result?
+ {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if let result = result() { return result }
+ try? await Task.sleep(nanoseconds: 50_000_000)
+ }
+ return result()
+ }
+
@Test
func `ignores NSURLErrorCancelled`() {
let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)
@@ -56,10 +70,10 @@ struct OpenAIDashboardNavigationDelegateTests {
box.delegate?.webView(webView, didCommit: nil)
#expect(result == nil)
- try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000))
+ let completed = await self.waitForResult { result }
box.delegate = nil
- switch result {
+ switch completed {
case .success?:
#expect(Bool(true))
default:
@@ -79,10 +93,10 @@ struct OpenAIDashboardNavigationDelegateTests {
let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)
box.delegate?.webView(webView, didFail: nil, withError: timeout)
- try? await Task.sleep(nanoseconds: UInt64((NavigationDelegate.postCommitSuccessDelay + 0.1) * 1_000_000_000))
+ let completed = await self.waitForResult { result }
box.delegate = nil
- switch result {
+ switch completed {
case let .failure(error as NSError)?:
#expect(error.domain == NSURLErrorDomain)
#expect(error.code == NSURLErrorTimedOut)
diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift
index 84d500fb2..1b361879d 100644
--- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift
+++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift
@@ -138,6 +138,106 @@ struct OpenAIDashboardWebViewCacheTests {
cache.clearAllForTesting()
}
+ @Test
+ func `Preserved page handoff is consumed only once`() {
+ if self.shouldSkipOnCI() { return }
+ let cache = OpenAIDashboardWebViewCache()
+ let store = WKWebsiteDataStore.nonPersistent()
+ cache.cacheEntryForTesting(websiteDataStore: store)
+ cache.markPreservedPageForTesting(
+ websiteDataStore: store,
+ expiresAt: Date().addingTimeInterval(cache.preservedPageHandoffTimeoutForTesting))
+
+ #expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed")
+ #expect(cache.consumePreservedPageForTesting(websiteDataStore: store), "First acquire should reuse handoff")
+ #expect(
+ !cache.consumePreservedPageForTesting(websiteDataStore: store),
+ "Second acquire should not keep reusing preserved page")
+
+ cache.clearAllForTesting()
+ }
+
+ @Test
+ func `Expired preserved page is cleared before idle eviction`() {
+ if self.shouldSkipOnCI() { return }
+ let cache = OpenAIDashboardWebViewCache()
+ let store = WKWebsiteDataStore.nonPersistent()
+ cache.cacheEntryForTesting(websiteDataStore: store)
+ cache.markPreservedPageForTesting(
+ websiteDataStore: store,
+ expiresAt: Date().addingTimeInterval(1))
+
+ let afterExpiry = Date().addingTimeInterval(cache.preservedPageHandoffTimeoutForTesting + 1)
+ cache.pruneForTesting(now: afterExpiry)
+
+ #expect(!cache.hasPreservedPageForTesting(for: store), "Expired preserved page should be cleared")
+ #expect(cache.hasCachedEntry(for: store), "Entry should remain cached after page handoff expires")
+
+ cache.clearAllForTesting()
+ }
+
+ @Test
+ func `Preserved page expiry is scheduled without future cache activity`() async throws {
+ if self.shouldSkipOnCI() { return }
+ let cache = OpenAIDashboardWebViewCache()
+ let store = WKWebsiteDataStore.nonPersistent()
+ let webView = cache.cacheEntryForTesting(websiteDataStore: store)
+
+ _ = webView.loadHTMLString("alive", baseURL: nil)
+ try? await Task.sleep(for: .milliseconds(150))
+
+ cache.markPreservedPageForTesting(
+ websiteDataStore: store,
+ expiresAt: Date().addingTimeInterval(0.2))
+
+ #expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed")
+
+ try? await Task.sleep(for: .milliseconds(450))
+
+ let bodyText = try await webView.evaluateJavaScript(
+ "document.body ? String(document.body.innerText || '') : ''") as? String
+
+ #expect(!cache.hasPreservedPageForTesting(for: store), "Expected scheduled expiry to clear preserved page")
+ #expect(bodyText?.isEmpty == true, "Expected scheduled expiry to detach the preserved page to about:blank")
+
+ cache.clearAllForTesting()
+ }
+
+ @Test
+ func `Reused page reset clears one shot scraper globals`() async throws {
+ if self.shouldSkipOnCI() { return }
+ let cache = OpenAIDashboardWebViewCache()
+ let store = WKWebsiteDataStore.nonPersistent()
+ let url = try #require(URL(string: "about:blank"))
+
+ let lease = try await cache.acquire(
+ websiteDataStore: store,
+ usageURL: url,
+ logger: nil)
+
+ _ = try await lease.webView.evaluateJavaScript(
+ """
+ window.__codexbarDidScrollToCredits = true;
+ window.__codexbarUsageBreakdownJSON = '[{"day":"2026-04-19"}]';
+ window.__codexbarUsageBreakdownDebug = 'debug';
+ true;
+ """)
+
+ #expect(await cache.resetReusablePageStateForTesting(lease.webView))
+
+ let reset = try await lease.webView.evaluateJavaScript(
+ """
+ typeof window.__codexbarDidScrollToCredits === 'undefined' &&
+ typeof window.__codexbarUsageBreakdownJSON === 'undefined' &&
+ typeof window.__codexbarUsageBreakdownDebug === 'undefined'
+ """) as? Bool
+
+ #expect(reset == true, "Expected one-shot scraper globals to be cleared before reuse")
+
+ lease.release()
+ cache.clearAllForTesting()
+ }
+
// MARK: - Eviction Tests
@Test
@@ -188,8 +288,8 @@ struct OpenAIDashboardWebViewCacheTests {
cache.clearAllForTesting()
}
- @Test("Evict all should remove every cached WebView")
- func evictAllRemovesAllEntries() async throws {
+ @Test
+ func `Evict all should remove every cached WebView`() async throws {
if self.shouldSkipOnCI() { return }
let cache = OpenAIDashboardWebViewCache()
let store1 = WKWebsiteDataStore.nonPersistent()
diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift
index 67c7282cc..eead8bb84 100644
--- a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift
+++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift
@@ -3,8 +3,8 @@ import Testing
@testable import CodexBar
struct OpenAIWebRefreshGateTests {
- @Test("Battery saver keeps background OpenAI web refreshes off")
- func batterySaverDisablesBackgroundRefresh() {
+ @Test
+ func `Battery saver keeps background OpenAI web refreshes off`() {
let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init(
accessEnabled: true,
batterySaverEnabled: true,
@@ -13,8 +13,8 @@ struct OpenAIWebRefreshGateTests {
#expect(shouldRun == false)
}
- @Test("Disabling battery saver restores normal OpenAI web refreshes")
- func disabledBatterySaverAllowsBackgroundRefresh() {
+ @Test
+ func `Disabling battery saver restores normal OpenAI web refreshes`() {
let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init(
accessEnabled: true,
batterySaverEnabled: false,
@@ -23,8 +23,8 @@ struct OpenAIWebRefreshGateTests {
#expect(shouldRun == true)
}
- @Test("Manual refresh still forces OpenAI web refreshes with battery saver enabled")
- func manualRefreshBypassesBatterySaver() {
+ @Test
+ func `Manual refresh still forces OpenAI web refreshes with battery saver enabled`() {
let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init(
accessEnabled: true,
batterySaverEnabled: true,
@@ -33,22 +33,22 @@ struct OpenAIWebRefreshGateTests {
#expect(shouldRun == true)
}
- @Test("Battery saver stale-submenu refresh respects the cooldown")
- func batterySaverStaleRefreshDoesNotForce() {
+ @Test
+ func `Battery saver stale-submenu refresh respects the cooldown`() {
let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: true)
#expect(shouldForce == false)
}
- @Test("Normal stale-submenu refresh still forces when battery saver is off")
- func nonBatterySaverStaleRefreshForces() {
+ @Test
+ func `Normal stale-submenu refresh still forces when battery saver is off`() {
let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: false)
#expect(shouldForce == true)
}
- @Test("Recent successful dashboard refresh stays throttled")
- func recentSuccessSkipsRefresh() {
+ @Test
+ func `Recent successful dashboard refresh stays throttled`() {
let now = Date()
let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init(
@@ -63,8 +63,8 @@ struct OpenAIWebRefreshGateTests {
#expect(shouldSkip == true)
}
- @Test("Recent failed dashboard refresh also stays throttled")
- func recentFailureSkipsRefresh() {
+ @Test
+ func `Recent failed dashboard refresh also stays throttled`() {
let now = Date()
let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init(
@@ -79,8 +79,8 @@ struct OpenAIWebRefreshGateTests {
#expect(shouldSkip == true)
}
- @Test("Force refresh bypasses throttle after failures")
- func forceRefreshBypassesCooldown() {
+ @Test
+ func `Force refresh bypasses throttle after failures`() {
let now = Date()
let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init(
@@ -95,8 +95,8 @@ struct OpenAIWebRefreshGateTests {
#expect(shouldSkip == false)
}
- @Test("Account switches bypass the prior-attempt cooldown")
- func accountChangeBypassesCooldown() {
+ @Test
+ func `Account switches bypass the prior-attempt cooldown`() {
let now = Date()
let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init(
diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
index 99f8a9837..81a159b8e 100644
--- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
+++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift
@@ -125,7 +125,7 @@ struct ProviderSettingsDescriptorTests {
}
@Test
- func codexExposesOpenAIWebExtrasToggleAsDefaultOffOptIn() throws {
+ func `codex exposes open AI web extras toggle as default off opt in`() throws {
let suite = "ProviderSettingsDescriptorTests-codex-openai-toggle"
let defaults = try #require(UserDefaults(suiteName: suite))
defaults.removePersistentDomain(forName: suite)
diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift
index ee7e4e229..33a67187f 100644
--- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift
+++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift
@@ -62,6 +62,48 @@ struct ProvidersPaneCoverageTests {
#expect(tertiaryOption?.title == "Tertiary (API)")
}
+ @Test
+ func `cursor menu bar metric picker omits extra usage when on demand budget is missing`() {
+ let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-no-extra-usage-picker")
+ let store = Self.makeUsageStore(settings: settings)
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date()),
+ provider: .cursor)
+ let pane = ProvidersPane(settings: settings, store: store)
+
+ let picker = pane._test_menuBarMetricPicker(for: .cursor)
+ let ids = picker?.options.map(\.id) ?? []
+ #expect(!ids.contains(MenuBarMetricPreference.extraUsage.rawValue))
+ }
+
+ @Test
+ func `cursor menu bar metric picker includes extra usage when on demand budget is available`() {
+ let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-extra-usage-picker")
+ let store = Self.makeUsageStore(settings: settings)
+ store._setSnapshotForTesting(
+ UsageSnapshot(
+ primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ providerCost: ProviderCostSnapshot(
+ used: 15,
+ limit: 100,
+ currencyCode: "USD",
+ updatedAt: Date()),
+ updatedAt: Date()),
+ provider: .cursor)
+ let pane = ProvidersPane(settings: settings, store: store)
+
+ let picker = pane._test_menuBarMetricPicker(for: .cursor)
+ let ids = picker?.options.map(\.id) ?? []
+ #expect(ids.contains(MenuBarMetricPreference.extraUsage.rawValue))
+ let option = picker?.options.first { $0.id == MenuBarMetricPreference.extraUsage.rawValue }
+ #expect(option?.title == "Extra usage")
+ }
+
@Test
func `zai menu bar metric picker omits tertiary lane when snapshot has no 5-hour metric`() {
let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-no-tertiary-picker")
diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift
index 7ff18809c..4948cb31b 100644
--- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift
@@ -36,6 +36,11 @@ struct SettingsStoreAdditionalTests {
#expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic)
#expect(settings.menuBarMetricSupportsTertiary(for: .cursor, snapshot: nil) == false)
+ settings.setMenuBarMetricPreference(.extraUsage, for: .cursor)
+ #expect(settings.menuBarMetricPreference(for: .cursor) == .extraUsage)
+ #expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic)
+ #expect(settings.menuBarMetricSupportsExtraUsage(for: .cursor, snapshot: nil) == false)
+
settings.setMenuBarMetricPreference(.tertiary, for: .perplexity)
#expect(settings.menuBarMetricPreference(for: .perplexity) == .tertiary)
#expect(settings.menuBarMetricPreference(for: .perplexity, snapshot: nil) == .tertiary)
@@ -60,6 +65,9 @@ struct SettingsStoreAdditionalTests {
settings.setMenuBarMetricPreference(.tertiary, for: .openrouter)
#expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic)
+
+ settings.setMenuBarMetricPreference(.extraUsage, for: .openrouter)
+ #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic)
}
@Test
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index 53e67413c..9493cdb45 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -6,6 +6,7 @@ import Testing
@Suite(.serialized)
@MainActor
+// swiftlint:disable:next type_body_length
struct SettingsStoreTests {
private final class ObservationFlag: @unchecked Sendable {
private let lock = NSLock()
@@ -85,6 +86,31 @@ struct SettingsStoreTests {
#expect(storeB.refreshFrequency.seconds == 900)
}
+ @Test
+ func `weekly confetti setting defaults off and persists`() throws {
+ let suite = "SettingsStoreTests-weekly-confetti"
+ 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())
+
+ #expect(storeA.confettiOnWeeklyLimitResetsEnabled == false)
+ storeA.confettiOnWeeklyLimitResetsEnabled = true
+
+ let defaultsB = try #require(UserDefaults(suiteName: suite))
+ let storeB = SettingsStore(
+ userDefaults: defaultsB,
+ configStore: configStore,
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+
+ #expect(storeB.confettiOnWeeklyLimitResetsEnabled == true)
+ }
+
@Test
func `persists selected menu provider across instances`() throws {
let suite = "SettingsStoreTests-selectedMenuProvider"
@@ -649,7 +675,7 @@ struct SettingsStoreTests {
}
@Test
- func defaultsOpenAIWebAccessToDisabled() throws {
+ func `defaults open AI web access to disabled`() throws {
let suite = "SettingsStoreTests-openai-web"
let defaults = try #require(UserDefaults(suiteName: suite))
defaults.removePersistentDomain(forName: suite)
@@ -670,7 +696,7 @@ struct SettingsStoreTests {
}
@Test
- func infersOpenAIWebAccessEnabledForLegacyConfiguredCodexCookies() throws {
+ func `infers open AI web access enabled for legacy configured codex cookies`() throws {
let suite = "SettingsStoreTests-openai-web-legacy"
let defaults = try #require(UserDefaults(suiteName: suite))
defaults.removePersistentDomain(forName: suite)
@@ -695,7 +721,7 @@ struct SettingsStoreTests {
}
@Test
- func infersOpenAIWebAccessEnabledForLegacyCodexConfigWithImplicitAutoCookies() throws {
+ func `infers open AI web access enabled for legacy codex config with implicit auto cookies`() throws {
let suite = "SettingsStoreTests-openai-web-legacy-implicit-auto"
let defaults = try #require(UserDefaults(suiteName: suite))
defaults.removePersistentDomain(forName: suite)
@@ -720,7 +746,7 @@ struct SettingsStoreTests {
}
@Test
- func disablingOpenAIWebAccessTurnsCodexCookieSourceOff() throws {
+ func `disabling open AI web access turns codex cookie source off`() throws {
let suite = "SettingsStoreTests-openai-web-toggle"
let defaults = try #require(UserDefaults(suiteName: suite))
defaults.removePersistentDomain(forName: suite)
@@ -746,7 +772,7 @@ struct SettingsStoreTests {
}
@Test
- func openAIWebBatterySaverPersistsSeparatelyFromExtrasAvailability() throws {
+ func `open AI web battery saver persists separately from extras availability`() throws {
let suite = "SettingsStoreTests-openai-web-battery-saver"
let defaults = try #require(UserDefaults(suiteName: suite))
defaults.removePersistentDomain(forName: suite)
@@ -912,6 +938,7 @@ struct SettingsStoreTests {
.openrouter,
.perplexity,
.abacus,
+ .mistral,
])
// Move one provider; ensure it's persisted across instances.
diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift
index f7cb6047a..ef85a9715 100644
--- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift
+++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift
@@ -14,9 +14,13 @@ struct StatusItemAnimationSignatureTests {
}
@Test
- func `merged render signature changes when unified icon style changes`() {
+ func `merged render signature changes when unified icon style changes`() throws {
+ let suite = "StatusItemAnimationSignatureTests-merged-style-signature"
+ let defaults = try #require(UserDefaults(suiteName: suite))
+ defaults.removePersistentDomain(forName: suite)
let settings = SettingsStore(
- configStore: testConfigStore(suiteName: "StatusItemAnimationSignatureTests-merged-style-signature"),
+ userDefaults: defaults,
+ configStore: testConfigStore(suiteName: suite),
zaiTokenStore: NoopZaiTokenStore(),
syntheticTokenStore: NoopSyntheticTokenStore())
settings.statusChecksEnabled = false
@@ -57,9 +61,11 @@ struct StatusItemAnimationSignatureTests {
controller.applyIcon(phase: nil)
let combinedSignature = controller.lastAppliedMergedIconRenderSignature
- settings.syntheticAPIToken = ""
+ if let syntheticMeta = registry.metadata[.synthetic] {
+ settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: false)
+ }
- #expect(store.enabledProvidersForDisplay() == [.codex, .synthetic])
+ #expect(store.enabledProvidersForDisplay() == [.codex])
#expect(store.enabledProviders() == [.codex])
#expect(store.iconStyle == .codex)
controller.applyIcon(phase: nil)
diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift
index 2df1afef9..f6e2d2870 100644
--- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift
+++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift
@@ -1,3 +1,4 @@
+import AppKit
import CodexBarCore
import Foundation
import Testing
@@ -149,4 +150,19 @@ struct StatusItemControllerMenuTests {
snapshot: snapshot))
#expect(snapshot.primary?.usedPercent == 10)
}
+
+ @Test
+ @MainActor
+ func `menu card width stays at base width when menu accessories are present`() {
+ let shortcutMenu = NSMenu()
+ let refreshItem = NSMenuItem(title: "Refresh", action: nil, keyEquivalent: "r")
+ shortcutMenu.addItem(refreshItem)
+ #expect(ceil(shortcutMenu.size.width) < 310)
+
+ let submenuMenu = NSMenu()
+ let parentItem = NSMenuItem(title: "Session", action: nil, keyEquivalent: "")
+ parentItem.submenu = NSMenu(title: "Session")
+ submenuMenu.addItem(parentItem)
+ #expect(ceil(submenuMenu.size.width) < 310)
+ }
}
diff --git a/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift
new file mode 100644
index 000000000..34b9e3746
--- /dev/null
+++ b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift
@@ -0,0 +1,83 @@
+import AppKit
+import CodexBarCore
+import Testing
+@testable import CodexBar
+
+@Suite(.serialized)
+@MainActor
+struct StatusItemExtraUsageMetricTests {
+ private func makeStatusBarForTesting() -> NSStatusBar {
+ let env = ProcessInfo.processInfo.environment
+ if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" {
+ return .system
+ }
+ return NSStatusBar()
+ }
+
+ @Test
+ func `menu bar extra usage preference uses cursor on demand budget`() {
+ let (store, controller) = self.makeCursorController(suiteName: "StatusItemExtraUsageMetricTests-budget")
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ providerCost: ProviderCostSnapshot(
+ used: 15,
+ limit: 100,
+ currencyCode: "USD",
+ updatedAt: Date()),
+ updatedAt: Date())
+
+ store._setSnapshotForTesting(snapshot, provider: .cursor)
+ store._setErrorForTesting(nil, provider: .cursor)
+
+ let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot)
+
+ #expect(window?.usedPercent == 15)
+ }
+
+ @Test
+ func `menu bar extra usage preference falls back to automatic when cursor on demand budget is missing`() {
+ let (store, controller) = self.makeCursorController(suiteName: "StatusItemExtraUsageMetricTests-missing-budget")
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: nil,
+ providerCost: nil,
+ updatedAt: Date())
+
+ store._setSnapshotForTesting(snapshot, provider: .cursor)
+ store._setErrorForTesting(nil, provider: .cursor)
+
+ let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot)
+
+ #expect(window?.usedPercent == 72)
+ }
+
+ private func makeCursorController(suiteName: String) -> (UsageStore, StatusItemController) {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: suiteName),
+ zaiTokenStore: NoopZaiTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .cursor
+ settings.setMenuBarMetricPreference(.extraUsage, for: .cursor)
+
+ let registry = ProviderRegistry.shared
+ if let cursorMeta = registry.metadata[.cursor] {
+ settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+ return (store, controller)
+ }
+}
diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift
index c74ecf69c..0d0ec05d2 100644
--- a/Tests/CodexBarTests/StatusMenuTests.swift
+++ b/Tests/CodexBarTests/StatusMenuTests.swift
@@ -348,6 +348,61 @@ struct StatusMenuTests {
}
}
+ @Test
+ func `merged provider switch rebuilds stale width switcher rows`() {
+ self.disableMenuCardsForTesting()
+ let settings = self.makeSettings()
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .codex
+
+ let registry = ProviderRegistry.shared
+ for provider in UsageProvider.allCases {
+ guard let metadata = registry.metadata[provider] else { continue }
+ let shouldEnable = provider == .codex || provider == .claude
+ settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable)
+ }
+ let activeProviders: [UsageProvider] = [.codex, .claude]
+ _ = settings.setMergedOverviewProviderSelection(
+ provider: .codex,
+ isSelected: false,
+ activeProviders: activeProviders)
+ _ = settings.setMergedOverviewProviderSelection(
+ provider: .claude,
+ isSelected: false,
+ activeProviders: activeProviders)
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ let menu = controller.makeMenu()
+ controller.menuWillOpen(menu)
+
+ let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView
+ #expect(initialSwitcher != nil)
+ let initialSwitcherID = initialSwitcher.map(ObjectIdentifier.init)
+ initialSwitcher?.frame.size.width = 250
+
+ let nextProviderButton = self.switcherButtons(in: menu).first(where: { $0.state == .off })
+ #expect(nextProviderButton != nil)
+ nextProviderButton?.performClick(nil)
+
+ let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView
+ #expect(updatedSwitcher != nil)
+ if let initialSwitcherID, let updatedSwitcher {
+ #expect(initialSwitcherID != ObjectIdentifier(updatedSwitcher))
+ #expect(updatedSwitcher.frame.width == 310)
+ }
+ }
+
@Test
func `merged switcher includes overview tab when multiple providers enabled`() {
self.disableMenuCardsForTesting()
@@ -522,6 +577,21 @@ struct StatusMenuTests {
#expect(titles.contains("Settings..."))
#expect(titles.contains("About CodexBar"))
#expect(titles.contains("Quit"))
+
+ let refreshItem = menu.items.first { $0.title == "Refresh" }
+ #expect(refreshItem != nil)
+ #expect(refreshItem?.keyEquivalent == "r")
+ #expect(refreshItem?.keyEquivalentModifierMask == [.command])
+
+ let settingsItem = menu.items.first { $0.title == "Settings..." }
+ #expect(settingsItem != nil)
+ #expect(settingsItem?.keyEquivalent == ",")
+ #expect(settingsItem?.keyEquivalentModifierMask == [.command])
+
+ let quitItem = menu.items.first { $0.title == "Quit" }
+ #expect(quitItem != nil)
+ #expect(quitItem?.keyEquivalent == "q")
+ #expect(quitItem?.keyEquivalentModifierMask == [.command])
}
}
@@ -563,7 +633,7 @@ extension StatusMenuTests {
let statusItem = menu.items.first(where: { $0.toolTip == statusText })
#expect(statusItem != nil)
#expect(statusItem?.view != nil)
- #expect(statusItem?.title == statusText)
+ #expect(statusItem?.title.isEmpty == true)
#expect(statusItem?.view?.frame.width == 310)
}
@@ -653,7 +723,7 @@ extension StatusMenuTests {
}
@Test
- func hidesOpenAIWebSubmenusWhenOpenAIWebExtrasDisabled() {
+ func `hides open AI web submenus when open AI web extras disabled`() {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
@@ -702,7 +772,64 @@ extension StatusMenuTests {
}
@Test
- func showsOpenAIWebSubmenusWhenHistoryExists() throws {
+ func `hosted chart submenu matches widened parent menu width`() {
+ let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled
+ let previousMenuRefresh = StatusItemController.menuRefreshEnabled
+ StatusItemController.menuCardRenderingEnabled = true
+ StatusItemController.menuRefreshEnabled = false
+ defer {
+ StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering
+ StatusItemController.menuRefreshEnabled = previousMenuRefresh
+ }
+
+ let settings = self.makeSettings()
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1)
+ let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30)
+ store.openAIDashboard = OpenAIDashboardSnapshot(
+ signedInEmail: "user@example.com",
+ codeReviewRemainingPercent: 100,
+ creditEvents: [event],
+ dailyBreakdown: breakdown,
+ usageBreakdown: breakdown,
+ creditsPurchaseURL: nil,
+ updatedAt: Date())
+
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ let parentMenu = NSMenu()
+ parentMenu.autoenablesItems = false
+ let wideItem = NSMenuItem(title: String(repeating: "W", count: 60), action: nil, keyEquivalent: "")
+ parentMenu.addItem(wideItem)
+
+ let submenu = controller.makeHostedSubviewPlaceholderMenu(chartID: StatusItemController.usageBreakdownChartID)
+ let submenuItem = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "")
+ submenuItem.submenu = submenu
+ parentMenu.addItem(submenuItem)
+
+ let parentWidth = ceil(parentMenu.size.width)
+ #expect(parentWidth > 310)
+
+ controller.hydrateHostedSubviewMenuIfNeeded(submenu)
+
+ let chartItem = submenu.items.first
+ #expect(chartItem?.representedObject as? String == StatusItemController.usageBreakdownChartID)
+ #expect(chartItem?.view != nil)
+ #expect(abs((chartItem?.view?.frame.width ?? 0) - parentWidth) <= 0.5)
+ }
+
+ @Test
+ func `shows open AI web submenus when history exists`() throws {
self.disableMenuCardsForTesting()
let settings = SettingsStore(
configStore: testConfigStore(suiteName: "StatusMenuTests-history"),
diff --git a/Tests/CodexBarTests/SyntheticMenuCardTests.swift b/Tests/CodexBarTests/SyntheticMenuCardTests.swift
new file mode 100644
index 000000000..df7d00951
--- /dev/null
+++ b/Tests/CodexBarTests/SyntheticMenuCardTests.swift
@@ -0,0 +1,107 @@
+import CodexBarCore
+import Foundation
+import SwiftUI
+import Testing
+@testable import CodexBar
+
+struct SyntheticMenuCardTests {
+ private static func makeModel(
+ primary: RateWindow?,
+ secondary: RateWindow? = nil,
+ providerCost: ProviderCostSnapshot? = nil,
+ now: Date) throws -> UsageMenuCardView.Model
+ {
+ let identity = ProviderIdentitySnapshot(
+ providerID: .synthetic,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: nil)
+ let snapshot = UsageSnapshot(
+ primary: primary,
+ secondary: secondary,
+ tertiary: nil,
+ providerCost: providerCost,
+ updatedAt: now,
+ identity: identity)
+ let metadata = try #require(ProviderDefaults.metadata[.synthetic])
+ return UsageMenuCardView.Model.make(.init(
+ provider: .synthetic,
+ metadata: metadata,
+ snapshot: snapshot,
+ credits: nil,
+ creditsError: nil,
+ dashboard: nil,
+ dashboardError: nil,
+ tokenSnapshot: nil,
+ tokenError: nil,
+ account: AccountInfo(email: nil, plan: nil),
+ isRefreshing: false,
+ lastError: nil,
+ usageBarsShowUsed: false,
+ resetTimeDisplayStyle: .countdown,
+ tokenCostUsageEnabled: false,
+ showOptionalCreditsAndExtraUsage: true,
+ hidePersonalInfo: false,
+ now: now))
+ }
+
+ @Test
+ func `rolling regen text uses parsed tickPercent not hardcoded fallback`() throws {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let primary = RateWindow(
+ usedPercent: 50,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(900),
+ resetDescription: nil,
+ nextRegenPercent: 2)
+ let model = try Self.makeModel(primary: primary, now: now)
+ let metric = try #require(model.metrics.first)
+ // 50% used / 2% per tick = 25 ticks to full.
+ #expect(metric.detailRightText == "Full in ~25 regens")
+ #expect(metric.detailLeftText == "52% after next regen")
+ }
+
+ @Test
+ func `rolling regen omits Synthetic-specific text when tickPercent is missing`() throws {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let primary = RateWindow(
+ usedPercent: 50,
+ windowMinutes: 300,
+ resetsAt: now.addingTimeInterval(900),
+ resetDescription: nil,
+ nextRegenPercent: nil)
+ let model = try Self.makeModel(primary: primary, now: now)
+ let metric = try #require(model.metrics.first)
+ // Without nextRegenPercent we no longer assert a regen-specific label;
+ // the renderer must not fabricate ticks-to-full from a guessed rate.
+ #expect(metric.detailRightText?.contains("regen") != true)
+ }
+
+ @Test
+ func `weekly regen text near full reports both labels consistently`() throws {
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let secondary = RateWindow(
+ usedPercent: 1,
+ windowMinutes: 10080,
+ resetsAt: now.addingTimeInterval(3600),
+ resetDescription: nil)
+ let cost = ProviderCostSnapshot(
+ used: 0.36,
+ limit: 36,
+ currencyCode: "USD",
+ period: "Weekly",
+ resetsAt: now.addingTimeInterval(3600),
+ nextRegenAmount: 0.72,
+ updatedAt: now)
+ let model = try Self.makeModel(
+ primary: nil,
+ secondary: secondary,
+ providerCost: cost,
+ now: now)
+ let weekly = try #require(model.metrics.first(where: { $0.id == "secondary" }))
+ // used=$0.36 / nextRegen=$0.72 = 0.5 ticks → between 0.1 and 1.5 → "Full in ~1 regen".
+ #expect(weekly.detailRightText == "Full in ~1 regen")
+ // remaining 99% + 2% next regen caps at 100% → "100% after next regen".
+ #expect(weekly.detailLeftText == "100% after next regen")
+ }
+}
diff --git a/Tests/CodexBarTests/SyntheticProviderTests.swift b/Tests/CodexBarTests/SyntheticProviderTests.swift
index 32b63e481..abe9765b0 100644
--- a/Tests/CodexBarTests/SyntheticProviderTests.swift
+++ b/Tests/CodexBarTests/SyntheticProviderTests.swift
@@ -61,4 +61,167 @@ struct SyntheticUsageSnapshotTests {
#expect(usage.primary?.resetsAt == expectedReset)
#expect(usage.loginMethod(for: .synthetic) == nil)
}
+
+ @Test
+ func `parses nested subscription pack quota`() throws {
+ let json = """
+ {
+ "subscription": {
+ "packs": 2,
+ "rateLimit": {
+ "messages": 1000,
+ "requests": 250,
+ "period": "5hr",
+ "resetsAt": "2026-04-16T18:00:00Z"
+ }
+ }
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123))
+ let usage = snapshot.toUsageSnapshot()
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ let expectedReset = try #require(formatter.date(from: "2026-04-16T18:00:00Z"))
+
+ #expect(usage.primary?.usedPercent == 25)
+ #expect(usage.primary?.windowMinutes == 300)
+ #expect(usage.primary?.resetsAt == expectedReset)
+ }
+
+ @Test
+ func `parses live root level rolling and weekly quotas`() throws {
+ let json = """
+ {
+ "subscription": {
+ "limit": 750,
+ "requests": 0,
+ "renewsAt": "2026-04-17T08:35:49.493Z"
+ },
+ "weeklyTokenLimit": {
+ "nextRegenAt": "2026-04-17T05:19:30.000Z",
+ "percentRemaining": 98.05884722222223,
+ "maxCredits": "$36.00",
+ "remainingCredits": "$35.30",
+ "nextRegenCredits": "$0.72"
+ },
+ "rollingFiveHourLimit": {
+ "nextTickAt": "2026-04-17T03:44:11.000Z",
+ "tickPercent": 0.05,
+ "remaining": 750,
+ "max": 750,
+ "limited": false
+ },
+ "search": {
+ "hourly": {
+ "limit": 250,
+ "requests": 2,
+ "renewsAt": "2026-04-17T04:30:01.494Z"
+ }
+ }
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123))
+ let usage = snapshot.toUsageSnapshot()
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ let fractionalFormatter = ISO8601DateFormatter()
+ fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ let expectedPrimaryReset = try #require(formatter.date(from: "2026-04-17T03:44:11Z"))
+ let expectedSecondaryReset = try #require(formatter.date(from: "2026-04-17T05:19:30Z"))
+ let expectedTertiaryReset = try #require(fractionalFormatter.date(from: "2026-04-17T04:30:01.494Z"))
+
+ #expect(usage.primary?.usedPercent == 0)
+ #expect(usage.primary?.resetsAt == expectedPrimaryReset)
+ #expect(usage.primary?.resetDescription == nil)
+ #expect(abs((usage.secondary?.usedPercent ?? 0) - 1.9411527777777715) < 0.001)
+ #expect(usage.secondary?.resetsAt == expectedSecondaryReset)
+ #expect(usage.secondary?.resetDescription == nil)
+ #expect(usage.tertiary?.usedPercent == 0.8)
+ #expect(usage.tertiary?.resetsAt == expectedTertiaryReset)
+ #expect(usage.providerCost?.limit == 36)
+ #expect(abs((usage.providerCost?.used ?? 0) - 0.7) < 0.0001)
+ #expect(usage.providerCost?.nextRegenAmount == 0.72)
+ }
+
+ @Test
+ func `parses rolling lane tickPercent into primary nextRegenPercent`() throws {
+ let json = """
+ {
+ "rollingFiveHourLimit": {
+ "nextTickAt": "2026-04-17T03:44:11.000Z",
+ "tickPercent": 0.05,
+ "remaining": 750,
+ "max": 750,
+ "limited": false
+ }
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123))
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary?.nextRegenPercent == 5.0)
+ }
+
+ @Test
+ func `omits nextRegenPercent when rolling lane lacks tickPercent`() throws {
+ let json = """
+ {
+ "rollingFiveHourLimit": {
+ "nextTickAt": "2026-04-17T03:44:11.000Z",
+ "remaining": 750,
+ "max": 750
+ }
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123))
+ let usage = snapshot.toUsageSnapshot()
+ #expect(usage.primary?.nextRegenPercent == nil)
+ }
+
+ @Test
+ func `parses time string suffixes covering minutes hours and days`() {
+ #expect(SyntheticUsageParser.windowMinutes(fromText: "5min") == 5)
+ #expect(SyntheticUsageParser.windowMinutes(fromText: "5m") == 5)
+ #expect(SyntheticUsageParser.windowMinutes(fromText: "5hr") == 300)
+ #expect(SyntheticUsageParser.windowMinutes(fromText: "5h") == 300)
+ #expect(SyntheticUsageParser.windowMinutes(fromText: "5hours") == 300)
+ #expect(SyntheticUsageParser.windowMinutes(fromText: "2days") == 2880)
+ #expect(SyntheticUsageParser.windowMinutes(fromText: "2d") == 2880)
+ #expect(SyntheticUsageParser.windowMinutes(fromText: "1 hour") == 60)
+ #expect(SyntheticUsageParser.windowMinutes(fromText: "junk") == nil)
+ #expect(SyntheticUsageParser.windowMinutes(fromText: "") == nil)
+ }
+
+ @Test
+ func `preserves slot identity when rolling lane is missing`() throws {
+ let json = """
+ {
+ "weeklyTokenLimit": {
+ "nextRegenAt": "2026-04-17T05:19:30.000Z",
+ "percentRemaining": 98.0,
+ "maxCredits": "$36.00",
+ "remainingCredits": "$35.30",
+ "nextRegenCredits": "$0.72"
+ },
+ "search": {
+ "hourly": {
+ "limit": 250,
+ "requests": 2,
+ "renewsAt": "2026-04-17T04:30:01.494Z"
+ }
+ }
+ }
+ """
+ let data = try #require(json.data(using: .utf8))
+ let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123))
+ let usage = snapshot.toUsageSnapshot()
+
+ #expect(usage.primary == nil)
+ #expect(abs((usage.secondary?.usedPercent ?? 0) - 2.0) < 0.001)
+ #expect(usage.tertiary?.usedPercent == 0.8)
+ #expect(usage.providerCost?.limit == 36)
+ }
}
diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift
index 78c6419ea..2bfcebc85 100644
--- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift
+++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift
@@ -35,6 +35,19 @@ struct TTYCommandRunnerEnvTests {
#expect(TTYCommandRunner._test_trackedProcessCount() == 0)
}
+ @Test
+ func `cached CLI sessions share shutdown tracking`() {
+ TTYCommandRunner._test_resetTrackedProcesses()
+ defer { TTYCommandRunner._test_resetTrackedProcesses() }
+
+ #expect(TTYCommandRunner.registerActiveProcessForAppShutdown(pid: 3001, binary: "codex"))
+ TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: 3001, processGroup: 3001)
+ #expect(TTYCommandRunner._test_trackedProcessCount() == 1)
+
+ TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: 3001)
+ #expect(TTYCommandRunner._test_trackedProcessCount() == 0)
+ }
+
@Test
func `tracked process helpers ignore invalid PID`() {
TTYCommandRunner._test_resetTrackedProcesses()
diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift
index 9e9258447..b5710878a 100644
--- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift
+++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift
@@ -134,7 +134,7 @@ struct UsageStoreCoverageTests {
}
@Test
- func backgroundRefreshOnlyTracksEnabledProviders() throws {
+ func `background refresh only tracks enabled providers`() throws {
let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-refresh")
settings.refreshFrequency = .manual
settings.statusChecksEnabled = false
@@ -167,7 +167,7 @@ struct UsageStoreCoverageTests {
}
@Test
- func cleanupPreservesEnabledButUnavailableProviderState() throws {
+ func `cleanup preserves enabled but unavailable provider state`() throws {
let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-preserve-unavailable")
settings.refreshFrequency = .manual
settings.statusChecksEnabled = false
@@ -204,7 +204,7 @@ struct UsageStoreCoverageTests {
}
@Test
- func backgroundWorkExcludesEnabledButUnavailableProviders() throws {
+ func `background work excludes enabled but unavailable providers`() throws {
let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-unavailable")
settings.refreshFrequency = .manual
settings.statusChecksEnabled = false
@@ -229,7 +229,7 @@ struct UsageStoreCoverageTests {
}
@Test
- func visibleUnavailableProviderGetsExplicitUserFacingState() throws {
+ func `visible unavailable provider gets explicit user facing state`() throws {
let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-unavailable-message")
settings.refreshFrequency = .manual
settings.statusChecksEnabled = false
@@ -256,7 +256,7 @@ struct UsageStoreCoverageTests {
}
@Test
- func refreshClearsEnabledButUnavailableCachedState() async throws {
+ func `refresh clears enabled but unavailable cached state`() async throws {
let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-cleanup")
settings.refreshFrequency = .manual
settings.statusChecksEnabled = false
@@ -305,7 +305,7 @@ struct UsageStoreCoverageTests {
}
@Test
- func refreshClearsEnabledButUnavailableFailureState() async throws {
+ func `refresh clears enabled but unavailable failure state`() async throws {
let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-failure-cleanup")
settings.refreshFrequency = .manual
settings.statusChecksEnabled = false
@@ -340,7 +340,7 @@ struct UsageStoreCoverageTests {
}
@Test
- func unavailableProviderWithOnlyCachedStatusGetsSingleCleanupPass() async throws {
+ func `unavailable provider with only cached status gets single cleanup pass`() async throws {
let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-status-cleanup")
settings.refreshFrequency = .manual
settings.statusChecksEnabled = true
@@ -372,7 +372,7 @@ struct UsageStoreCoverageTests {
}
@Test
- func statusIndicatorsAndFailureGate() {
+ func `status indicators and failure gate`() {
#expect(!ProviderStatusIndicator.none.hasIssue)
#expect(ProviderStatusIndicator.maintenance.hasIssue)
#expect(ProviderStatusIndicator.unknown.label == "Status unknown")
@@ -423,7 +423,8 @@ struct UsageStoreCoverageTests {
UsageStore(
fetcher: UsageFetcher(environment: [:]),
browserDetection: BrowserDetection(cacheTTL: 0),
- settings: settings)
+ settings: settings,
+ environmentBase: [:])
}
}
diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift
index 17732675a..539786eb7 100644
--- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift
+++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift
@@ -3,6 +3,7 @@ import Foundation
import Testing
@testable import CodexBar
+// swiftlint:disable:next type_body_length
struct UsageStorePlanUtilizationTests {
@Test
func `coalesces changed usage within hour into single entry`() throws {
@@ -122,6 +123,9 @@ struct UsageStorePlanUtilizationTests {
@Test
func `native chart shows visible series tabs only`() {
let histories = [
+ planSeries(name: .session, windowMinutes: 0, entries: [
+ planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 90),
+ ]),
planSeries(name: .session, windowMinutes: 300, entries: [
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20),
]),
@@ -587,6 +591,39 @@ struct UsageStorePlanUtilizationTests {
#expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.resetsAt == secondaryReset)
}
+ @MainActor
+ @Test
+ func `record plan history skips invalid zero minute windows`() async {
+ let store = Self.makeStore()
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(
+ usedPercent: 25,
+ windowMinutes: 0,
+ resetsAt: Date(timeIntervalSince1970: 1_710_000_000),
+ resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 44,
+ windowMinutes: 10080,
+ resetsAt: Date(timeIntervalSince1970: 1_710_086_400),
+ resetDescription: nil),
+ updatedAt: Date(),
+ identity: ProviderIdentitySnapshot(
+ providerID: .codex,
+ accountEmail: "alice@example.com",
+ accountOrganization: nil,
+ loginMethod: "plus"))
+ store._setSnapshotForTesting(snapshot, provider: .codex)
+
+ await store.recordPlanUtilizationHistorySample(
+ provider: .codex,
+ snapshot: snapshot,
+ now: Date(timeIntervalSince1970: 1_700_000_000))
+
+ let histories = store.planUtilizationHistory(for: .codex)
+ #expect(findSeries(histories, name: .session, windowMinutes: 0) == nil)
+ #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 44)
+ }
+
@MainActor
@Test
func `record plan history keeps semantic codex lanes when durations drift`() async {
@@ -651,6 +688,194 @@ struct UsageStorePlanUtilizationTests {
#expect(findSeries(histories, name: .opus, windowMinutes: 10080)?.entries.last?.usedPercent == 30)
}
+ @MainActor
+ @Test
+ func `weekly quota celebration posts when weekly usage resets to zero`() async {
+ let store = Self.makeStore()
+ let accountLabel = "reset-zero@example.com"
+ let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel)
+ defer { recorder.invalidate() }
+
+ let before = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
+ identity: ProviderIdentitySnapshot(
+ providerID: .claude,
+ accountEmail: accountLabel,
+ accountOrganization: nil,
+ loginMethod: "max"))
+ let after = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date(timeIntervalSince1970: 1_700_003_600),
+ identity: ProviderIdentitySnapshot(
+ providerID: .claude,
+ accountEmail: accountLabel,
+ accountOrganization: nil,
+ loginMethod: "max"))
+
+ await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt)
+ await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt)
+
+ let events = recorder.events
+ #expect(events.count == 1)
+ #expect(events[0].provider == .claude)
+ #expect(events[0].accountLabel == accountLabel)
+ #expect(events[0].usedPercent == 0)
+ }
+
+ @MainActor
+ @Test
+ func `weekly quota celebration posts when reset lands mid hour without history split`() async {
+ let store = Self.makeStore()
+ let accountLabel = "mid-hour-reset@example.com"
+ let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel)
+ defer { recorder.invalidate() }
+
+ let before = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 40,
+ windowMinutes: 10080,
+ resetsAt: Date(timeIntervalSince1970: 1_700_100_000),
+ resetDescription: nil),
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
+ identity: ProviderIdentitySnapshot(
+ providerID: .claude,
+ accountEmail: accountLabel,
+ accountOrganization: nil,
+ loginMethod: "max"))
+ let after = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(
+ usedPercent: 0,
+ windowMinutes: 10080,
+ resetsAt: Date(timeIntervalSince1970: 1_700_100_030),
+ resetDescription: nil),
+ updatedAt: Date(timeIntervalSince1970: 1_700_001_800),
+ identity: ProviderIdentitySnapshot(
+ providerID: .claude,
+ accountEmail: accountLabel,
+ accountOrganization: nil,
+ loginMethod: "max"))
+
+ await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt)
+ await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: after, now: after.updatedAt)
+
+ let histories = store.planUtilizationHistory(for: .claude)
+ #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.count == 1)
+ #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 40)
+ let events = recorder.events
+ #expect(events.count == 1)
+ #expect(events[0].usedPercent == 0)
+ }
+
+ @MainActor
+ @Test
+ func `weekly quota celebration ignores first seen reset sample`() async {
+ let store = Self.makeStore()
+ let accountLabel = "first-seen-reset@example.com"
+ let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel)
+ defer { recorder.invalidate() }
+
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
+ identity: ProviderIdentitySnapshot(
+ providerID: .claude,
+ accountEmail: accountLabel,
+ accountOrganization: nil,
+ loginMethod: "max"))
+
+ await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: snapshot, now: snapshot.updatedAt)
+
+ #expect(recorder.events.isEmpty)
+ }
+
+ @MainActor
+ @Test
+ func `weekly quota celebration fires once across repeated low samples`() async {
+ let store = Self.makeStore()
+ let accountLabel = "repeated-low@example.com"
+ let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel)
+ defer { recorder.invalidate() }
+
+ let before = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 60, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
+ identity: ProviderIdentitySnapshot(
+ providerID: .claude,
+ accountEmail: accountLabel,
+ accountOrganization: nil,
+ loginMethod: "max"))
+ let firstLow = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 1, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date(timeIntervalSince1970: 1_700_001_800),
+ identity: ProviderIdentitySnapshot(
+ providerID: .claude,
+ accountEmail: accountLabel,
+ accountOrganization: nil,
+ loginMethod: "max"))
+ let secondLow = UsageSnapshot(
+ primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date(timeIntervalSince1970: 1_700_002_100),
+ identity: ProviderIdentitySnapshot(
+ providerID: .claude,
+ accountEmail: accountLabel,
+ accountOrganization: nil,
+ loginMethod: "max"))
+
+ await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt)
+ await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: firstLow, now: firstLow.updatedAt)
+ await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: secondLow, now: secondLow.updatedAt)
+
+ let events = recorder.events
+ #expect(events.count == 1)
+ #expect(events[0].usedPercent == 1)
+ }
+
+ @MainActor
+ @Test
+ func `weekly quota celebration posts for generic provider weekly lane`() async {
+ let store = Self.makeStore()
+ let accountLabel = "zai-reset-org"
+ let recorder = WeeklyLimitResetEventRecorder(provider: .zai, accountLabel: accountLabel)
+ defer { recorder.invalidate() }
+
+ let before = UsageSnapshot(
+ primary: RateWindow(usedPercent: 92, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
+ identity: ProviderIdentitySnapshot(
+ providerID: .zai,
+ accountEmail: nil,
+ accountOrganization: accountLabel,
+ loginMethod: "pro"))
+ let after = UsageSnapshot(
+ primary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date(timeIntervalSince1970: 1_700_003_600),
+ identity: ProviderIdentitySnapshot(
+ providerID: .zai,
+ accountEmail: nil,
+ accountOrganization: accountLabel,
+ loginMethod: "pro"))
+
+ await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt)
+ await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt)
+
+ let events = recorder.events
+ #expect(events.count == 1)
+ #expect(events[0].provider == .zai)
+ #expect(events[0].accountLabel == accountLabel)
+ #expect(events[0].usedPercent == 0)
+ }
+
@MainActor
@Test
func `concurrent plan history writes coalesce within single hour bucket per series`() async throws {
@@ -712,6 +937,64 @@ struct UsageStorePlanUtilizationTests {
#expect(loaded.isEmpty)
}
+ @Test
+ func `store drops invalid zero minute and empty histories when loading and saving`() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let directoryURL = root
+ .appendingPathComponent("com.steipete.codexbar", isDirectory: true)
+ .appendingPathComponent("history", isDirectory: true)
+ let providerURL = directoryURL.appendingPathComponent("codex.json")
+ let store = PlanUtilizationHistoryStore(directoryURL: directoryURL)
+ try FileManager.default.createDirectory(
+ at: directoryURL,
+ withIntermediateDirectories: true)
+
+ let validUnscoped = planSeries(name: .session, windowMinutes: 300, entries: [
+ planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 12),
+ ])
+ let validAccount = planSeries(name: .weekly, windowMinutes: 10080, entries: [
+ planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 64),
+ ])
+ let document = PersistedFixtureDocument(
+ version: 1,
+ preferredAccountKey: "alice",
+ unscoped: [
+ planSeries(name: .session, windowMinutes: 0, entries: [
+ planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 99),
+ ]),
+ planSeries(name: .weekly, windowMinutes: 10080, entries: []),
+ validUnscoped,
+ ],
+ accounts: [
+ "alice": [
+ planSeries(name: .session, windowMinutes: 0, entries: [
+ planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 88),
+ ]),
+ validAccount,
+ ],
+ "empty": [
+ planSeries(name: .weekly, windowMinutes: 10080, entries: []),
+ ],
+ ])
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ try encoder.encode(document).write(to: providerURL, options: Data.WritingOptions.atomic)
+
+ let loaded = store.load()
+ let loadedBuckets = try #require(loaded[.codex])
+ #expect(loadedBuckets.unscoped == [validUnscoped])
+ #expect(loadedBuckets.accounts == ["alice": [validAccount]])
+
+ store.save(loaded)
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ let rewritten = try decoder.decode(PersistedFixtureDocument.self, from: Data(contentsOf: providerURL))
+ #expect(rewritten.unscoped == [validUnscoped])
+ #expect(rewritten.accounts == ["alice": [validAccount]])
+ }
+
@Test
func `store round trips account buckets with series entries`() {
let root = FileManager.default.temporaryDirectory
@@ -746,6 +1029,13 @@ struct UsageStorePlanUtilizationTests {
}
extension UsageStorePlanUtilizationTests {
+ private struct PersistedFixtureDocument: Codable {
+ let version: Int
+ let preferredAccountKey: String?
+ let unscoped: [PlanUtilizationSeriesHistory]
+ let accounts: [String: [PlanUtilizationSeriesHistory]]
+ }
+
private struct FixtureDocument: Decodable {
let preferredAccountKey: String?
let unscoped: [PlanUtilizationSeriesHistory]
@@ -842,6 +1132,75 @@ func findSeries(
histories.first { $0.name == name && $0.windowMinutes == windowMinutes }
}
+private final class WeeklyLimitResetEventRecorder: @unchecked Sendable {
+ struct Event: Sendable {
+ let provider: UsageProvider
+ let accountLabel: String?
+ let usedPercent: Double
+ }
+
+ private let provider: UsageProvider
+ private let accountLabel: String?
+ private let lock = NSLock()
+ private var observedEvents: [Event] = []
+ private var token: NSObjectProtocol?
+
+ init(provider: UsageProvider, accountLabel: String?) {
+ self.provider = provider
+ self.accountLabel = accountLabel
+ self.token = NotificationCenter.default.addObserver(
+ forName: .codexbarWeeklyLimitReset,
+ object: nil,
+ queue: nil)
+ { [weak self] notification in
+ guard let self,
+ let event = notification.object as? WeeklyLimitResetEvent
+ else {
+ return
+ }
+
+ let recorded = MainActor.assumeIsolated { () -> Event? in
+ guard event.provider == self.provider,
+ event.accountLabel == self.accountLabel
+ else {
+ return nil
+ }
+ return Event(
+ provider: event.provider,
+ accountLabel: event.accountLabel,
+ usedPercent: event.usedPercent)
+ }
+ guard let recorded else { return }
+
+ self.lock.lock()
+ self.observedEvents.append(recorded)
+ self.lock.unlock()
+ }
+ }
+
+ var events: [Event] {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ return self.observedEvents
+ }
+
+ var count: Int {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ return self.observedEvents.count
+ }
+
+ func invalidate() {
+ guard let token else { return }
+ NotificationCenter.default.removeObserver(token)
+ self.token = nil
+ }
+
+ deinit {
+ self.invalidate()
+ }
+}
+
func formattedBoundary(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
diff --git a/appcast.xml b/appcast.xml
index 4a55cde5f..635565eac 100644
--- a/appcast.xml
+++ b/appcast.xml
@@ -3,110 +3,139 @@
CodexBar
- 0.20
- Wed, 08 Apr 2026 04:42:18 +0100
+ 0.22
+ Tue, 21 Apr 2026 01:12:52 +0100
https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml
- 55
- 0.20
+ 57
+ 0.2214.0
- CodexBar 0.20
+ CodexBar 0.22
Highlights
-
Codex: switch between system accounts/profiles without manually logging out and back in. @ratulsarna
-
Add Perplexity provider support with recurring, bonus, and purchased-credit tracking, Pro/Max plan detection, browser-cookie auto-import, and manual-cookie fallback (#449). Thanks @BeelixGit!
-
Add OpenCode Go as a separate provider with 5-hour, weekly, and monthly web usage tracking, widget integration, and browser-cookie support.
-
Claude: fix token and cost inflation caused by cross-file double counting of subagent JSONL logs, fix streaming chunk deduplication, and add claude-sonnet-4-6 pricing. Thanks @enzonaute for the investigation!
-
Cost history: merge supported pi session usage into Codex/Claude provider history (#653). Thanks @ngutman!
+
Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry.
+
Synthetic: parse live quota payloads for five-hour, weekly, and search limits, including continuous reset/regeneration details (#732). Thanks @baanish!
+
Antigravity: restore account/quota probing across newer localhost endpoint/token layouts and retry paths (#727). Thanks @icey-zhang!
+
Menu: add standard shortcuts for Refresh, Settings, and Quit while the status menu is open (#737). Thanks @anirudhvee!
+
Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman!
Providers & Usage
-
Perplexity: add recurring, bonus, and purchased-credit tracking; plan detection for Pro/Max; browser-cookie auto-import; and manual-cookie fallback (#449). Thanks @BeelixGit!
-
OpenCode Go: add a dedicated provider, parse live authenticated workspace Go usage from the web app, keep monthly optional and honor workspace env overrides.
-
Codex: add workspace attribution for account labels and same-email multi-workspace accounts.
-
Codex: reconcile live-system and managed accounts by canonical identity, preserve account-scoped usage/history/dashboard state, allow OAuth CLI fallback, and tighten OpenAI web ownership gating so quota and credits only attach to the matching account. Thanks @monterrr and @Rag30 for the initial effort and ideas!
-
Codex: normalize weekly-only rate limits across OAuth and CLI/RPC so free-plan accounts render as Weekly instead of a fake Session, preserve unknown single-window payloads in the primary lane, hide the empty Session lane in widgets, and accept weekly-only Codex CLI /status/RPC data without failing. @ratulsarna
-
Codex: refactor the provider end to end into clearer components and better division of responsibilities.
-
OpenCode: preserve product separation between Zen and Go, improve null/unsupported usage handling, and harden cookie/domain behavior for authenticated web fetches.
-
Cost history: merge supported pi session usage into Codex/Claude provider history (#653). Thanks @ngutman!
+
Synthetic: parse live five-hour, weekly, and search quota payloads, including continuous reset/regeneration details (#732). Thanks @baanish!
+
Antigravity: restore localhost probing with async TLS challenge handling, extension-token fallback, and best-effort port selection (#727). Thanks @icey-zhang!
+
Gemini: discover OAuth config in fnm/Homebrew/bundled CLI layouts so expired-token refresh keeps working (#723). Thanks @Leechael!
+
Copilot: open the complete device-login verification URL when available so the browser flow carries the user code (#739). Thanks @skhe!
+
Alibaba: update the China mainland Coding Plan endpoint and browser-cookie domain while keeping older domains as fallbacks (#712). Thanks @hezhongtang!
+
Codex: restore OpenAI web dashboard fetching on the new analytics route and tighten hidden WebView reuse/expiry. @ratulsarna
Menu & Settings
-
Codex: add UI for switching the system-level Codex account and promoting a managed account into the live system slot.
-
Codex: hide display-only OpenAI web extras in widgets and fix buy-credits / credits-only presentation regressions.
-
Claude: enable “Avoid Keychain prompts” by default, remove the experimental label, and preserve user-action cooldown clearing plus startup bootstrap when Security.framework fallback is still needed.
-
Fix alignment of menu chart hover coordinates on macOS. Thanks @cuidong233!
+
Menu: show and handle standard shortcuts for Refresh (⌘R), Settings (⌘,), and Quit (⌘Q) while the status menu is open (#737). Thanks @anirudhvee!
+
Widgets: migrate app-group sharing to the Team-ID-prefixed container and carry widget state across the move (#701). Thanks @ngutman!
+
Settings: fix provider-sidebar clipping on macOS Tahoe and resize the Preferences window when switching tabs (#580). Thanks @chadneal!
+
+
Fixes
+
+
Keychain cache: preserve cached credentials when macOS temporarily denies keychain UI after wake, avoiding repeated prompts (#594). Thanks @josepe98!
Add basic multi-account support to Codex. Thanks @monterrr and @Rag30 for the initial effort and ideas!
-
Add Perplexity provider with recurring, bonus, and purchased-credit tracking; plan detection (Pro/Max); and browser-cookie auto-import with manual-cookie fallback (#449). Thanks @BeelixGit!
+
Abacus AI: add a new provider for ChatLLM and RouteLLM credit tracking with browser-cookie import, manual-cookie support, and monthly pace rendering. Thanks @ChrisGVE!
+
Codex: recognize the new Pro $100 plan in OAuth, OpenAI web, menu, and CLI rendering, and preserve CLI fallback when partial OAuth payloads lose the 5-hour session lane (#691, #709). Thanks @ImLukeF!
+
Codex: make OpenAI web extras opt-in for fresh installs, preserve working legacy setups on upgrade, add an OpenAI web battery-saver toggle, and keep account-scoped dashboard state aligned during refreshes and account switches (#529). Thanks @cbrane!
+
Codex: fix local cost scanner overcounting and cross-day undercounting across forked sessions, cold-cache refreshes, and sessions-root changes (#698). Thanks @xx205!
+
z.ai: preserve weekly and 5-hour token quotas together, surface the 5-hour lane correctly across the menu/menu bar, and add regression coverage (#662). Thanks to @takumi3488 for the original fix and investigation.
+
Cursor: fix a crash in the usage fetch path and add regression coverage (#663). Thanks @anirudhvee for the report and validation!
+
Antigravity: restore account and quota probing across newer localhost endpoint/token layouts and API-level retry failures (#693, fixes #692). Thanks @anirudhvee!
+
Menu bar: fix missing icons on affected macOS 26 systems by avoiding RenderBox-triggering SwiftUI effects (#677). Thanks @andrzejchm!
+
Battery / refresh: cut menu redraw churn, skip background work for unavailable providers, and reuse cached OpenAI web views more efficiently (#708).
+
Claude: add Opus 4.7 pricing so local cost scanning and cost breakdowns recognize the new model. Thanks @knivram!
+
Codex: add Microsoft Edge as a browser-cookie import option for the Codex provider while preserving the contributor-branch workflow from the original PR (#694). Thanks @Astro-Han!
Providers & Usage
-
Add the foundation for multi-account support to Codex and basic UX for adding and switching accounts. @ratulsarna
-
Codex: normalize weekly-only rate limits across OAuth and CLI/RPC so free-plan accounts render as Weekly instead of a fake Session, preserve unknown single-window payloads in the primary lane, hide the empty Session lane in widgets, and accept weekly-only Codex CLI /status/RPC data without failing. @ratulsarna
-
Perplexity: add provider support with credit tracking for recurring (monthly), bonus (promotional), and purchased on-demand credits; plan detection (Pro/Max); and browser-cookie auto-import with manual-cookie fallback (#449). Thanks @BeelixGit!
+
Abacus AI: add provider support for ChatLLM and RouteLLM monthly compute-credit tracking with cookie import, manual cookie headers, timeout/browser-detection threading, optional billing fallback, and hardened cached-session retry behavior. Thanks @ChrisGVE!
+
Codex: render the new Pro $100 plan consistently across OAuth, OpenAI web, menu, and CLI surfaces, tolerate newer Codex OAuth payload variants like prolite, and only fall back to the CLI in auto mode when OAuth decode damage actually drops the session lane (#691, #709).
+
Codex: make OpenAI web extras opt-in by default, preserve legacy implicit-auto cookie setups during upgrade inference, add battery-saver gating for non-forced dashboard refreshes, and preserve provider/dashboard state for enabled providers that are temporarily unavailable.
+
Cost: tighten the local Codex cost scanner around fork inheritance, cold-cache discovery, incremental parsing, and sessions-root changes so replayed sessions no longer overcount or slip usage across day boundaries (#698). Thanks @xx205!
+
z.ai: preserve both weekly and 5-hour token quotas, keep the existing 2-limit behavior unchanged, and render the 5-hour quota as a tertiary row in provider snapshots and CLI/menu cards (#662). Credit to @takumi3488 for the original fix and investigation.
+
Cursor: fix the usage fetch path so failed or cancelled requests no longer crash, and add Linux build and regression test coverage fixes (#663).
+
Antigravity: try both language-server and extension-server endpoint/token combinations, retry after API-level errors, scope insecure localhost trust handling to loopback hosts, and restore local quota/account probing on newer Antigravity builds (#693, fixes #692). Thanks @anirudhvee!
+
Antigravity: prefer userTier.name over generic plan info when rendering the account plan so Google AI Ultra and similar tiers show their real subscription name, while still falling back cleanly when the tier label is absent or blank (#303). Thanks @zacklavin11!
+
Ollama: recognize __Secure-session cookies during manual cookie entry and browser-cookie import so authenticated usage fetching continues to work with the newer cookie name (#707). Thanks @anirudhvee!
+
OpenCode: enable weekly pace visualization for the app and CLI so weekly bars show reserve percentage, expected-usage markers, and "Lasts until reset" details like Codex and Claude (#639). Thanks @Zachary!
+
Refresh pipeline: skip background work for unavailable providers, clear stale cached state, and show explicit unavailable messages (#708).
+
Codex: support Microsoft Edge in browser-cookie import for the Codex provider while keeping the contributor branch untouched in the superseding integration path (#694). Thanks @Astro-Han!
+
OpenCode / OpenCode Go: treat serialized _server auth/account-context failures as invalid credentials so cached browser cookies are cleared and retried instead of surfacing a misleading HTTP 500.
+
OpenAI web: keep cached WebViews across same-account refreshes and clean them up only when accounts or providers go stale (#708).
+
Claude: add Opus 4.7 pricing so local cost usage and breakdowns price the new model correctly. Thanks @knivram!
Fix alignment of menu chart hover coordinates on macOS. Thanks @cuidong233!
+
Menu bar: fix missing icons on affected macOS 26 systems by replacing RenderBox-triggering material/offscreen SwiftUI effects in the provider sidebar and highlighted progress bar (#677). Thanks @andrzejchm!
+
z.ai: fix menu bar selection when both weekly and 5-hour quotas are present (#662).
+
Menu bar: avoid redundant merged-icon redraws and make hosted chart submenus load lazily without losing provider context (#708).
+
Merged menu: when Overview is selected, keep the merged menu bar icon aligned with the first Overview provider in configured order, even while that provider is still loading (#724). Thanks @anirudhvee!
+
Codex: add an OpenAI web battery-saver toggle, keep manual refresh available when battery saver is on, and hide OpenAI web submenus when web extras are disabled.
+
+
Development & Tooling
+
+
Diagnostics: add lightweight battery instrumentation for menu updates and refresh work (#708).
+
Build script: make CodexBar-owned ad-hoc keychain cleanup opt-in with --clear-adhoc-keychain, and extend the explicit reset path to clear both com.steipete.CodexBar and com.steipete.codexbar.cache. Thanks @magnaprog!
Add Alibaba Coding Plan provider with region-aware quota fetching, widget integration, and browser-cookie import defaults (#574).
-
Align Cursor usage with the dashboard's Total/Auto/API lanes. (#587). Thanks @Rag30!
-
Add subscription utilization history chart to the menu with DST-safe data point identification (#589). Thanks @maxceem!
-
Refactor the Claude provider end to end into clearer, better-tested components while preserving behavior (#494). @ratulsarna
-
Add reset time display for Codex code review limits (#581). Thanks @Q1CHENL!
-
Add per-model token counts to cost history (#546). Thanks @iam-brain!
-
Fix Antigravity model selection to use stable model-family matching for Claude, Gemini Pro, and Gemini Flash, and preserve fallback lane visibility in the menu bar and icon (#590). Thanks @skainguyen1412!
-
Add GPT-5.4 mini and nano pricing (#561). Thanks @iam-brain!
+
Codex: switch between system accounts/profiles without manually logging out and back in. @ratulsarna
+
Add Perplexity provider support with recurring, bonus, and purchased-credit tracking, Pro/Max plan detection, browser-cookie auto-import, and manual-cookie fallback (#449). Thanks @BeelixGit!
+
Add OpenCode Go as a separate provider with 5-hour, weekly, and monthly web usage tracking, widget integration, and browser-cookie support.
+
Claude: fix token and cost inflation caused by cross-file double counting of subagent JSONL logs, fix streaming chunk deduplication, and add claude-sonnet-4-6 pricing. Thanks @enzonaute for the investigation!
+
Cost history: merge supported pi session usage into Codex/Claude provider history (#653). Thanks @ngutman!
Providers & Usage
-
Alibaba: add Coding Plan provider support with region-aware web/API quota fetching, widget integration, and browser-cookie import defaults (#574).
-
Cursor: trust dashboard percent fields for Total/Auto/API usage, preserve on-demand remaining fallback views, and keep scanning imported browser-cookie candidates until a working Cursor session is found (#587, supersedes #579). Thanks @Rag30!
-
Claude: refactor the provider end to end into clearer components, with baseline docs and expanded tests to lock down behavior (#494).
-
Codex: show reset times for code review limits, including Core review reset parsing support (#581). Thanks @Q1CHENL!
-
Cost history: add per-model token counts so token usage is broken out by model (#546). Thanks @iam-brain!
-
Antigravity: replace label-order guessing with stable model-family selection for Claude, Gemini Pro, and Gemini Flash; fix mapping for Claude thinking models and placeholder model IDs; preserve fallback lane visibility in the menu bar and icon when only fallback lanes exist (#590). Thanks @skainguyen1412!
-
Kimi: tolerate API responses without resetTime so usage decoding no longer fails on sparse payloads.
-
Codex: add GPT-5.4 mini and nano pricing (#561). Thanks @iam-brain!
+
Perplexity: add recurring, bonus, and purchased-credit tracking; plan detection for Pro/Max; browser-cookie auto-import; and manual-cookie fallback (#449). Thanks @BeelixGit!
+
OpenCode Go: add a dedicated provider, parse live authenticated workspace Go usage from the web app, keep monthly optional and honor workspace env overrides.
+
Codex: add workspace attribution for account labels and same-email multi-workspace accounts.
+
Codex: reconcile live-system and managed accounts by canonical identity, preserve account-scoped usage/history/dashboard state, allow OAuth CLI fallback, and tighten OpenAI web ownership gating so quota and credits only attach to the matching account. Thanks @monterrr and @Rag30 for the initial effort and ideas!
+
Codex: normalize weekly-only rate limits across OAuth and CLI/RPC so free-plan accounts render as Weekly instead of a fake Session, preserve unknown single-window payloads in the primary lane, hide the empty Session lane in widgets, and accept weekly-only Codex CLI /status/RPC data without failing. @ratulsarna
+
Codex: refactor the provider end to end into clearer components and better division of responsibilities.
+
OpenCode: preserve product separation between Zen and Go, improve null/unsupported usage handling, and harden cookie/domain behavior for authenticated web fetches.
+
Cost history: merge supported pi session usage into Codex/Claude provider history (#653). Thanks @ngutman!
Menu & Settings
-
Menu: add subscription utilization history chart with DST-safe chart point identifiers and per-provider plan utilization tracking (#589). Thanks @maxceem!
-
Menu bar: in Both display mode, fall back to percent when pace data is unavailable so text stays visible for providers without pace metrics (#527). Thanks @Astro-Han!
-
Settings: persist the resolved refresh cadence default to UserDefaults on first launch and repair invalid stored values so the setting stays normalized across relaunches (#519). Thanks @Astro-Han!
-
Menu: wrap long status blurbs and preserve wrapped titles for multiline entries (#543). Thanks @zkforge!
+
Codex: add UI for switching the system-level Codex account and promoting a managed account into the live system slot.
+
Codex: hide display-only OpenAI web extras in widgets and fix buy-credits / credits-only presentation regressions.
+
Claude: enable “Avoid Keychain prompts” by default, remove the experimental label, and preserve user-action cooldown clearing plus startup bootstrap when Security.framework fallback is still needed.
+
Fix alignment of menu chart hover coordinates on macOS. Thanks @cuidong233!
]]>
-
+ 0.14.0
diff --git a/docs/minimax.md b/docs/minimax.md
index 6b7b7d647..2de11af52 100644
--- a/docs/minimax.md
+++ b/docs/minimax.md
@@ -13,33 +13,30 @@ either a Bearer API token or a session cookie header.
## Data sources + fallback order
-1) **API token** (preferred)
- - Set in Preferences → Providers → MiniMax (stored in `~/.codexbar/config.json`) or `MINIMAX_API_KEY`.
- - When present, MiniMax uses the API token and ignores cookies entirely.
-
-2) **Cached cookie header** (automatic, only when no API token)
- - Keychain cache: `com.steipete.codexbar.cache` (account `cookie.minimax`).
-
-3) **Browser cookie import** (automatic)
- - Cookie order from provider metadata (default: Safari → Chrome → Firefox).
- - Merges Chromium profile cookies across the primary + Network stores before attempting a request.
- - Tries each browser source until the Coding Plan API accepts the cookies.
- - Domain filters: `platform.minimax.io`, `minimax.io`.
-
-4) **Browser local storage access token** (Chromium-based)
- - Reads `access_token` (and related tokens) from Chromium local storage (LevelDB) to authorize the remains API.
- - If decoding fails, falls back to a text-entry scan for `minimax.io` keys/values and filters for MiniMax JWT claims.
- - Used automatically; no UI field.
- - Also extracts `GroupId` when present (appends query param).
-
-5) **Manual session cookie header** (optional override)
- - Stored in `~/.codexbar/config.json` via Preferences → Providers → MiniMax (Cookie source → Manual).
- - Accepts a raw `Cookie:` header or a full "Copy as cURL" string.
- - When a cURL string is pasted, MiniMax extracts the cookie header plus `Authorization: Bearer …` and
- `GroupId=…` for the remains API.
- - CLI/runtime env: `MINIMAX_COOKIE` or `MINIMAX_COOKIE_HEADER`.
+1. **API token** (preferred)
+ - Set in Preferences → Providers → MiniMax (stored in `~/.codexbar/config.json`) or `MINIMAX_API_KEY`.
+ - When present, MiniMax uses the API token and ignores cookies entirely.
+2. **Cached cookie header** (automatic, only when no API token)
+ - Keychain cache: `com.steipete.codexbar.cache` (account `cookie.minimax`).
+3. **Browser cookie import** (automatic)
+ - Cookie order from provider metadata (default: Safari → Chrome → Firefox).
+ - Merges Chromium profile cookies across the primary + Network stores before attempting a request.
+ - Tries each browser source until the Coding Plan API accepts the cookies.
+ - Domain filters: `platform.minimax.io`, `minimax.io`.
+4. **Browser local storage access token** (Chromium-based)
+ - Reads `access_token` (and related tokens) from Chromium local storage (LevelDB) to authorize the remains API.
+ - If decoding fails, falls back to a text-entry scan for `minimax.io` keys/values and filters for MiniMax JWT claims.
+ - Used automatically; no UI field.
+ - Also extracts `GroupId` when present (appends query param).
+5. **Manual session cookie header** (optional override)
+ - Stored in `~/.codexbar/config.json` via Preferences → Providers → MiniMax (Cookie source → Manual).
+ - Accepts a raw `Cookie:` header or a full "Copy as cURL" string.
+ - When a cURL string is pasted, MiniMax extracts the cookie header plus `Authorization: Bearer …` and
+ `GroupId=…` for the remains API.
+ - CLI/runtime env: `MINIMAX_COOKIE` or `MINIMAX_COOKIE_HEADER`.
## Endpoints
+
- API token endpoint: `https://api.minimax.io/v1/coding_plan/remains`
- Requires `Authorization: Bearer `.
- Global host (cookies): `https://platform.minimax.io`
@@ -57,30 +54,67 @@ either a Bearer API token or a session cookie header.
- `MINIMAX_REMAINS_URL=...` (full URL override)
## Cookie capture (optional override)
+
- Open the Coding Plan page and DevTools → Network.
- Select the request to `/v1/api/openplatform/coding_plan/remains`.
- Copy the `Cookie` request header (or use “Copy as cURL” and paste the whole line).
- Paste into Preferences → Providers → MiniMax only if automatic import fails.
## Notes
+
- Cookies alone often return status 1004 (“cookie is missing, log in again”); the remains API expects a Bearer token.
- MiniMax stores `access_token` in Chromium local storage (LevelDB). Some entries serialize the storage key without a scheme
- (ex: `minimax.io`), so origin matching must account for host-only keys.
+(ex: `minimax.io`), so origin matching must account for host-only keys.
- Raw JWT scan fallback remains as a safety net if Chromium key formats change.
- If local storage keys don’t decode (some Chrome builds), the MiniMax-specific text scan avoids a full raw-byte scan.
## Cookie file paths
+
- Safari: `~/Library/Cookies/Cookies.binarycookies`
- Chrome/Chromium forks: `~/Library/Application Support/Google/Chrome/*/Cookies`
- Firefox: `~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite`
## Snapshot mapping
+
- Primary: percent used from `model_remains` (used/total) or HTML "Available usage".
- Window: derived from `start_time`/`end_time` or HTML duration text.
- Reset: derived from `remains_time` (fallback to `end_time`) or HTML "Resets in …".
- Plan/tier: best-effort from response fields or HTML title.
+### Coding Plan multi-model (`model_remains[]`)
+
+- The remains API returns **one row per quota** (text, VLM, search, TTS HD, video, music, image, lyrics, coding-plan modules, etc.). CodexBar decodes **every** row into `MiniMaxUsageSnapshot.models` while keeping the **existing scalar fields** (`availablePrompts`, `usedPercent`, `resetsAt`, …) aligned with **`model_remains[0]`** for the menu bar icon / primary `UsageSnapshot`.
+- Field semantics match the existing parser: `current_interval_total_count` is the window cap, `current_interval_usage_count` is treated as **remaining** in this codebase, and **used = total − remaining** (same as before).
+- If interval counts are partially missing (for example, API omits `current_interval_usage_count`), the row keeps usage percent as **unknown** instead of coercing to `0% used` / `100% left`. Detail text can still show `—/total` when total is known.
+- `MiniMaxModelUsage.identifier` preserves unsuffixed model keys for normal rows (for stable lookups), and only appends `#n` when duplicate identifiers occur within the same payload so SwiftUI `ForEach` still has unique row IDs.
+- Optional **weekly** columns (e.g. TTS): `current_weekly_total_count` and `current_weekly_usage_count` (weekly **remaining**, same naming convention as the interval fields). When present, the menu card shows a secondary “↳ Weekly …” line under that row.
+- When weekly fields are **absent-or-zero in aggregate** (at least one key present, and both numeric values are 0 when treating missing as 0), CodexBar treats that as **no weekly cap**: weekly quota fields are cleared and no weekly usage line is shown (avoids misleading `0/0`, `0/—`, etc.).
+- Rows are grouped in the menu card by inferred window: **5-hour** (`windowMinutes == 300`), **daily** (~24h window), **weekly** (weekly-only rows), **other**.
+
+### Providers settings mirror (Preferences → Providers → MiniMax)
+
+- The Providers detail **Usage** section mirrors the same `model_remains[]` grouping as the menu card (**5-hour**, **Daily**, **Weekly**, **Other**), with the same per-row progress bar, `used/total (remaining)` detail line, reset text, and optional weekly secondary line.
+- Preferences does **not** reuse the menu card’s collapsible section headers; if the combined row count is **≥ 6**, the block is wrapped in an embedded `ScrollView` (max height ≈ 360 pt) so the window stays manageable.
+- MiniMax row titles in Preferences use a **separate fixed-width title column** instead of the global usage label width. The width is the rendered width of `code-plan-search`; longer model names **wrap onto multiple lines inside that fixed column** instead of tail-truncating, so the progress/detail column keeps a stable width without hiding the full name.
+
+### Menu-bar card layout (MiniMax-only)
+
+- When `minimaxSections` is present, the card wraps **metrics + usage notes + multi-model sections** in an internal vertical `ScrollView`. The scroll region first **measures the rendered content height** and then applies an explicit frame height of `min(actualContentHeight, min(640, max(320, menuScreenVisibleHeight − 310)))`. `menuScreenVisibleHeight` is resolved from the status-item/menu display screen (with fallback only when unavailable), so multi-monitor setups use the correct cap. This means the card **shrinks to fit** when collapsed/short and **scrolls only when content exceeds the cap**. The **header** (provider name / account / plan) stays **above** this scroll region so account context remains visible while scrolling.
+- Each grouped section (**5-hour window**, **Daily quota**, **Weekly quota**, **Other windows**) has a tappable header with a chevron. **Collapsed** headers show **`N items`** on the right. Default: **collapsed** when that section has **≥ 5** rows; **expanded** otherwise. The user’s toggle is stored in-process in `MiniMaxSectionCollapseStore` (keyed by section title); it resets on app quit.
+- Toggling a section invalidates and remeasures the hosting `NSMenuItem` view while the menu is open, so the MiniMax card **shrinks immediately when collapsing** and **grows immediately when expanding** instead of keeping the initial height.
+- This layout keeps the total `NSMenu` height bounded so app-level items below the card (e.g. Usage Dashboard, Refresh, Settings) remain reachable without relying on the menu’s own overflow chevrons.
+- In merged **Overview** mode, MiniMax section header taps (collapse/expand) are handled as in-card interactions and must not trigger the row-level provider-selection action.
+
## Key files
+
- `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift`
+- `Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelUsage.swift`
- `Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift`
- `Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift`
+- `Sources/CodexBar/MiniMaxUILayoutMetrics.swift`
+- `Sources/CodexBar/MiniMaxSectionCollapseStore.swift`
+- `Sources/CodexBar/MiniMaxMenuCardViews.swift` (分组折叠 + 行视图)
+- `Sources/CodexBar/UsageMenuCardViewModel+MiniMax.swift` (`model_remains[]` → 菜单模型)
+- `Sources/CodexBar/MenuCardView.swift`(MiniMax 卡片区滚动)
+- `Sources/CodexBar/PreferencesProviderDetailView.swift` (Providers → MiniMax usage mirror)
+
diff --git a/docs/providers.md b/docs/providers.md
index a82898e78..0fe26b2cf 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -1,5 +1,5 @@
---
-summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI)."
+summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI, Mistral)."
read_when:
- Adding or modifying provider fetch/parsing
- Adjusting provider labels, toggles, or metadata
@@ -40,6 +40,7 @@ until the session is invalid, to avoid repeated Keychain prompts.
| Ollama | Web settings page via browser cookies (`web`). |
| OpenRouter | API token (config, overrides env) → credits API (`api`). |
| Abacus AI | Browser cookies → compute points + billing API (`web`). |
+| Mistral | Console billing API via Ory Kratos session cookies (`web`). |
## Codex
- Web dashboard (optional, off by default): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies.
@@ -192,4 +193,15 @@ until the session is invalid, to avoid repeated Keychain prompts.
- Status: none yet.
- Details: `docs/abacus.md`.
+## Mistral
+- Session cookie (`ory_session_*`) from browser auto-import or manual `Cookie:` header.
+- CSRF token (`csrftoken` cookie) sent as `X-CSRFTOKEN` header.
+- Domain: `admin.mistral.ai`.
+- Billing endpoint: `GET https://admin.mistral.ai/api/billing/v2/usage?month=&year=`.
+- Returns monthly token usage per model (completion, OCR, audio, connectors, fine-tuning) with pricing.
+- Cost computed client-side from token counts × per-model prices included in the response.
+- Currency from response (typically EUR).
+- Resets at end of calendar month.
+- Status: `https://status.mistral.ai` (link only, no auto-polling).
+
See also: `docs/provider.md` for architecture notes.
diff --git a/version.env b/version.env
index b28d35c74..073e2844f 100644
--- a/version.env
+++ b/version.env
@@ -1,2 +1,2 @@
-MARKETING_VERSION=0.21
-BUILD_NUMBER=56
+MARKETING_VERSION=0.23
+BUILD_NUMBER=58