From d0fc33a7c62a2f78b469c97cb1ae6fdd760d1402 Mon Sep 17 00:00:00 2001 From: nrosa Date: Tue, 4 Aug 2026 15:21:25 -0500 Subject: [PATCH] Fix Codex credit usage in menu bar --- CHANGELOG.md | 1 + Sources/CodexBar/MenuBarLayoutEditor.swift | 6 +++ Sources/CodexBar/MenuBarLayoutRenderer.swift | 11 +++-- .../Codex/CodexConsumerProjection.swift | 5 ++ .../StatusItemController+MenuBarLayout.swift | 17 +++++++ .../MenuBarLayoutRendererTests.swift | 49 ++++++++++++++++--- ...StatusItemAnimationCodexCreditsTests.swift | 46 +++++++++++++++++ 7 files changed, 126 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db1f876803..5bc644494d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ ### Fixed - Overview: stop the infinite menu flicker when hovering between provider chart submenus with Agent Sessions enabled — no-op session rescans no longer invalidate menus, submenu hovers no longer trigger rescans, and session updates defer tracked-parent rebuilds like other data refreshes (#2652). Thanks @qazi0! - Widgets: bound widget-snapshot file I/O with a defensive timeout and skip further container access once it wedges, so a blocked macOS 26 app-group open() can no longer beachball the app or hang the widget (#2267 follow-up). +- Codex: show the credit-limit percentage in automatic menu-bar layouts when session and weekly usage are unavailable. - Command Code: parse and display 5-hour and weekly rolling limits alongside monthly credits and reset times (#2466). Thanks @derekszen! - OpenCode Go: include Zen balance in CLI usage reads without waiting beyond five seconds (#2583). Thanks @Yuxin-Qiao! - Usage & Spend: keep validated Codex totals visible while the local scanner catches up, with refresh indicators in the dashboard and menu cost rows (#2397). Thanks @hhh2210! diff --git a/Sources/CodexBar/MenuBarLayoutEditor.swift b/Sources/CodexBar/MenuBarLayoutEditor.swift index b32acb0951..9498d4c7dd 100644 --- a/Sources/CodexBar/MenuBarLayoutEditor.swift +++ b/Sources/CodexBar/MenuBarLayoutEditor.swift @@ -691,6 +691,11 @@ private struct MenuBarLayoutPreview: View { scopedWeekly: MenuBarLayoutRenderWindow(scopedNamed?.window), scopedWeeklyTitle: scopedNamed?.title, automatic: MenuBarLayoutRenderWindow(automatic), + automaticFallbackUsedPercent: self.store.codexConsumerProjectionIfNeeded( + for: provider, + surface: .menuBar, + snapshotOverride: snapshot, + now: now)?.menuBarFallbackUsedPercent, sessionPace: self.store.menuBarLayoutPaceText(provider: provider, window: session, now: now), weeklyPace: self.store.menuBarLayoutPaceText(provider: provider, window: weekly, now: now), automaticPace: self.store.menuBarLayoutPaceText(provider: provider, window: automatic, now: now), @@ -734,6 +739,7 @@ private struct MenuBarLayoutPreview: View { scopedWeekly: MenuBarLayoutRenderWindow(scopedWeekly), scopedWeeklyTitle: "Fable only", automatic: MenuBarLayoutRenderWindow(session), + automaticFallbackUsedPercent: nil, sessionPace: samplePace(session), weeklyPace: samplePace(weekly), automaticPace: samplePace(session), diff --git a/Sources/CodexBar/MenuBarLayoutRenderer.swift b/Sources/CodexBar/MenuBarLayoutRenderer.swift index 18c4e859fd..d4608613b3 100644 --- a/Sources/CodexBar/MenuBarLayoutRenderer.swift +++ b/Sources/CodexBar/MenuBarLayoutRenderer.swift @@ -32,6 +32,9 @@ struct MenuBarLayoutRenderData: Hashable { /// `.scopedWeekly` token with the real model rather than assuming Fable. let scopedWeeklyTitle: String? let automatic: MenuBarLayoutRenderWindow? + /// Used percentage for a non-rate-window quota that can stand in for the automatic metric. + /// Codex credit limits use this when the account exposes credits but no session/weekly lanes. + let automaticFallbackUsedPercent: Double? /// Signed pace deltas per window, already formatted (`+11%`, `-8%`, `0%`). Pace needs the store's /// historical dataset and work-day setting, so it is resolved upstream like `runsOut` rather than /// derived from the render windows here. @@ -239,7 +242,9 @@ final class MenuBarLayoutRenderer { attributes: style.attributes) case let .percent(window): let rateWindow = Self.window(window, data: data) - let percent = rateWindow.map { options.showUsed ? $0.usedPercent : $0.remainingPercent } + let fallbackUsedPercent = window == .automatic ? data.automaticFallbackUsedPercent : nil + let percent = fallbackUsedPercent.map { options.showUsed ? $0 : max(0, 100 - $0) } + ?? rateWindow.map { options.showUsed ? $0.usedPercent : $0.remainingPercent } let value = percent.map(UsageFormatter.percentString) ?? Self.missingValue let prefix: String let accessibilityPrefix: String @@ -269,13 +274,13 @@ final class MenuBarLayoutRenderer { accessibilityPrefix: Self.paceAccessibilityPrefix(window), attributes: style.attributes) case .usageBar: - guard let window = data.automatic else { + guard let usedPercent = data.automaticFallbackUsedPercent ?? data.automatic?.usedPercent else { return self.textToken( self.missingValue, accessibilityText: L("Usage bar unavailable"), attributes: style.attributes) } - let displayedPercent = options.showUsed ? window.usedPercent : window.remainingPercent + let displayedPercent = options.showUsed ? usedPercent : max(0, 100 - usedPercent) let filled = Int((displayedPercent.clamped(to: 0...100) / 100 * 3).rounded()) let value = String(repeating: "▮", count: filled) + String(repeating: "▯", count: 3 - filled) return self.textToken( diff --git a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift index be1104d035..47f958c5d4 100644 --- a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift +++ b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift @@ -569,6 +569,11 @@ struct CodexConsumerProjection { $0.remainingPercent <= 0 && ($0.resetsAt.map { $0 > self.evaluationTime } ?? true) } } + + var menuBarFallbackUsedPercent: Double? { + guard self.menuBarFallback == .creditsBalance else { return nil } + return self.credits?.snapshot?.codexCreditLimit?.usedPercent + } } extension UsageStore { diff --git a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift index eefaab9a2d..a5c0104b1d 100644 --- a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift +++ b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift @@ -74,6 +74,10 @@ extension StatusItemController { scopedWeekly: MenuBarLayoutRenderWindow(scopedNamed?.window), scopedWeeklyTitle: scopedNamed?.title, automatic: MenuBarLayoutRenderWindow(windows.automatic), + automaticFallbackUsedPercent: self.menuBarLayoutAutomaticFallbackUsedPercent( + provider: provider, + snapshot: snapshot, + now: now), sessionPace: self.store.menuBarLayoutPaceText(provider: provider, window: windows.session, now: now), weeklyPace: self.store.menuBarLayoutPaceText(provider: provider, window: windows.weekly, now: now), automaticPace: self.store.menuBarLayoutPaceText( @@ -85,6 +89,19 @@ extension StatusItemController { cost30d: costStrings.last30Days) } + func menuBarLayoutAutomaticFallbackUsedPercent( + provider: UsageProvider, + snapshot: UsageSnapshot?, + now: Date) + -> Double? + { + self.store.codexConsumerProjectionIfNeeded( + for: provider, + surface: .menuBar, + snapshotOverride: snapshot, + now: now)?.menuBarFallbackUsedPercent + } + func menuBarLayoutAccountLabel(provider: UsageProvider, snapshot: UsageSnapshot?) -> String? { let rawAccountLabel = snapshot?.accountEmail(for: provider)? .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift index 279db7fef5..8b93aeb696 100644 --- a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift +++ b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift @@ -94,6 +94,7 @@ struct MenuBarLayoutRendererTests { scopedWeekly: nil, scopedWeeklyTitle: nil, automatic: nil, + automaticFallbackUsedPercent: nil, sessionPace: nil, weeklyPace: nil, automaticPace: nil, @@ -159,6 +160,7 @@ struct MenuBarLayoutRendererTests { scopedWeekly: nil, scopedWeeklyTitle: nil, automatic: nil, + automaticFallbackUsedPercent: nil, // Pace is suppressed below 3% of window elapsed; the percent token must survive that. sessionPace: nil, weeklyPace: nil, @@ -294,6 +296,33 @@ struct MenuBarLayoutRendererTests { #expect(output.attributedTitle.string == "▮▮▮") } + @Test + func `automatic percentage uses quota fallback in either display direction`() { + let renderer = MenuBarLayoutRenderer() + let data = self.data(automaticUsedPercent: 100, automaticFallbackUsedPercent: 3) + let layout = MenuBarLayout(lines: [[ + .percent(window: .automatic), + .separatorDot, + .percent(window: .session), + ]]) + + let used = renderer.render(layout: layout, data: data, icon: nil, options: self.options()) + let remaining = renderer.render( + layout: layout, + data: data, + icon: nil, + options: MenuBarLayoutRenderOptions( + size: .regular, + highContrast: false, + showUsed: false, + appearanceName: "aqua", + isDebugApp: false, + now: self.now)) + + #expect(used.attributedTitle.string == "3%\u{2009}·\u{2009}5h 25%") + #expect(remaining.attributedTitle.string == "97%\u{2009}·\u{2009}5h 75%") + } + @Test func `absolute reset falls back to provider text`() { let renderer = MenuBarLayoutRenderer() @@ -311,6 +340,7 @@ struct MenuBarLayoutRendererTests { scopedWeekly: nil, scopedWeeklyTitle: nil, automatic: textOnlyWindow, + automaticFallbackUsedPercent: nil, sessionPace: nil, weeklyPace: nil, automaticPace: nil, @@ -351,7 +381,11 @@ struct MenuBarLayoutRendererTests { .attribute(.foregroundColor, at: textIndex, effectiveRange: nil) as? NSColor == .labelColor) } - private func data(automaticUsedPercent: Double = 50) -> MenuBarLayoutRenderData { + private func data( + automaticUsedPercent: Double? = 50, + automaticFallbackUsedPercent: Double? = nil) + -> MenuBarLayoutRenderData + { MenuBarLayoutRenderData( iconKey: "codex", providerName: "Codex", @@ -372,11 +406,14 @@ struct MenuBarLayoutRendererTests { resetsAt: self.now.addingTimeInterval(24 * 60 * 60), resetDescription: nil)), scopedWeeklyTitle: "Fable only", - automatic: MenuBarLayoutRenderWindow(RateWindow( - usedPercent: automaticUsedPercent, - windowMinutes: 300, - resetsAt: self.now.addingTimeInterval(2 * 60 * 60), - resetDescription: nil)), + automatic: automaticUsedPercent.flatMap { + MenuBarLayoutRenderWindow(RateWindow( + usedPercent: $0, + windowMinutes: 300, + resetsAt: self.now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil)) + }, + automaticFallbackUsedPercent: automaticFallbackUsedPercent, sessionPace: "-8%", weeklyPace: "+11%", automaticPace: "0%", diff --git a/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift b/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift index 25dc34b444..27d28f4283 100644 --- a/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift @@ -6,6 +6,52 @@ import Testing @Suite(.serialized) @MainActor struct StatusItemAnimationCodexCreditsTests { + @Test + func `codex automatic percentage uses credit limit when usage is missing`() { + let settings = testSettingsStore(suiteName: "StatusItemAnimationTests-credit-limit-layout") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarLayout = .defaultLayout + settings.usageBarsShowUsed = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let now = Date() + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(nil, provider: .codex) + store.credits = CreditsSnapshot( + remaining: 41556.4, + events: [], + updatedAt: now, + codexCreditLimit: CodexCreditLimitSnapshot( + used: 1193.6, + limit: 42750, + remainingPercent: 97.208, + resetsAt: nil, + updatedAt: now)) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + controller.applyIcon(for: .codex, phase: nil) + + let title = controller.statusItems[.codex]?.button?.attributedTitle.string + #expect(title?.contains("3%") == true) + #expect(title?.contains("–") == false) + } + @Test func `codex icon keeps credits only rendering when usage is missing`() { let settings = testSettingsStore(suiteName: "StatusItemAnimationTests-credits-only-icon")