diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 2fb70778e..e448e327d 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -343,12 +343,30 @@ extension StatusItemController { } func menuBarDisplayText(for provider: UsageProvider, snapshot: UsageSnapshot?) -> String? { - MenuBarDisplayText.displayText( + let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot) + let displayText = MenuBarDisplayText.displayText( mode: self.settings.menuBarDisplayMode, provider: provider, - percentWindow: self.menuBarPercentWindow(for: provider, snapshot: snapshot), + percentWindow: percentWindow, paceWindow: snapshot?.secondary, showUsed: self.settings.usageBarsShowUsed) + + let sessionExhausted = (snapshot?.primary?.remainingPercent ?? 100) <= 0 + let weeklyExhausted = (snapshot?.secondary?.remainingPercent ?? 100) <= 0 + + if provider == .codex, + self.settings.menuBarDisplayMode == .percent, + !self.settings.usageBarsShowUsed, + (sessionExhausted || weeklyExhausted), + let creditsRemaining = self.store.credits?.remaining, + creditsRemaining > 0 + { + return UsageFormatter + .creditsString(from: creditsRemaining) + .replacingOccurrences(of: " left", with: "") + } + + return displayText } private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index 64ef3d8e4..9134f15d5 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -267,4 +267,96 @@ struct StatusItemAnimationTests { #expect(pace == nil) #expect(both == nil) } + + @Test + func menuBarDisplayTextUsesCreditsWhenCodexWeeklyIsExhausted() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-credits-fallback"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.secondary, for: .codex) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, 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()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let remainingCredits = (snapshot.primary?.usedPercent ?? 0) * 4.5 + (snapshot.secondary?.usedPercent ?? 0) / 10 + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + store.credits = CreditsSnapshot(remaining: remainingCredits, events: [], updatedAt: Date()) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + let expected = UsageFormatter + .creditsString(from: remainingCredits) + .replacingOccurrences(of: " left", with: "") + + #expect(displayText == expected) + } + + @Test + func menuBarDisplayTextUsesCreditsWhenCodexSessionIsExhausted() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-credits-fallback-session"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primary, for: .codex) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, 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()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let remainingCredits = (snapshot.primary?.usedPercent ?? 0) - (snapshot.secondary?.usedPercent ?? 0) / 2 + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + store.credits = CreditsSnapshot(remaining: remainingCredits, events: [], updatedAt: Date()) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + let expected = UsageFormatter + .creditsString(from: remainingCredits) + .replacingOccurrences(of: " left", with: "") + + #expect(displayText == expected) + } }