diff --git a/CHANGELOG.md b/CHANGELOG.md index 7784b45aab..14b4c8e4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixed - Codex menu: hide error-only optional Credits and OpenAI web setup diagnostics while keeping them visible in provider Settings. +- Codex quotas: show the session quota as unavailable while an exhausted weekly limit is still binding, including menu-bar icons and widgets. Thanks @Yuxin-Qiao! - Codex cost history: reuse cached aggregate pricing and one pricing catalog across daily and project reports, carry fresh cache state across launches, and treat unpriced models as migrated, avoiding repeated row scans, filesystem work, and duplicate background scans on large local histories. - Kimi: show the five-hour rate limit before the weekly quota while preserving existing menu-bar metric preferences. Thanks @Zihao-Qi! diff --git a/Sources/CodexBar/IconRemainingResolver.swift b/Sources/CodexBar/IconRemainingResolver.swift index 83adcf0584..49fd27b773 100644 --- a/Sources/CodexBar/IconRemainingResolver.swift +++ b/Sources/CodexBar/IconRemainingResolver.swift @@ -1,4 +1,5 @@ import CodexBarCore +import Foundation enum IconRemainingResolver { private static let visibleZeroPercent = 0.0001 @@ -7,7 +8,7 @@ enum IconRemainingResolver { private static let sessionWindowMinutes = 5 * 60 private static let weeklyWindowMinutes = 7 * 24 * 60 - private static func codexProjection(snapshot: UsageSnapshot) -> CodexConsumerProjection { + private static func codexProjection(snapshot: UsageSnapshot, now: Date) -> CodexConsumerProjection { CodexConsumerProjection.make( surface: .menuBar, context: CodexConsumerProjection.Context( @@ -19,12 +20,12 @@ enum IconRemainingResolver { rawDashboardError: nil, dashboardAttachmentAuthorized: false, dashboardRequiresLogin: false, - now: snapshot.updatedAt)) + now: now)) } - private static func codexVisibleWindows(snapshot: UsageSnapshot) -> [RateWindow] { - let projection = self.codexProjection(snapshot: snapshot) - return projection.visibleRateLanes.compactMap { projection.rateWindow(for: $0) } + private static func codexVisibleWindows(snapshot: UsageSnapshot, now: Date) -> [RateWindow] { + let projection = self.codexProjection(snapshot: snapshot, now: now) + return projection.visibleRateLanes.compactMap { projection.menuBarSelectableRateWindow(for: $0) } } private static func antigravityQuotaSummaryWindows( @@ -67,7 +68,8 @@ enum IconRemainingResolver { static func resolvedWindows( snapshot: UsageSnapshot, style: IconStyle, - secondaryOverrideWindowID: String? = nil) + secondaryOverrideWindowID: String? = nil, + now: Date = Date()) -> (primary: RateWindow?, secondary: RateWindow?) { if style == .perplexity { @@ -82,7 +84,7 @@ enum IconRemainingResolver { ?? (primary: nil, secondary: nil) } if style == .codex { - let windows = self.codexVisibleWindows(snapshot: snapshot) + let windows = self.codexVisibleWindows(snapshot: snapshot, now: now) return ( primary: windows.first, secondary: windows.dropFirst().first) @@ -103,13 +105,15 @@ enum IconRemainingResolver { static func resolvedRemaining( snapshot: UsageSnapshot, style: IconStyle, - secondaryOverrideWindowID: String? = nil) + secondaryOverrideWindowID: String? = nil, + now: Date = Date()) -> (primary: Double?, secondary: Double?) { let windows = self.resolvedWindows( snapshot: snapshot, style: style, - secondaryOverrideWindowID: secondaryOverrideWindowID) + secondaryOverrideWindowID: secondaryOverrideWindowID, + now: now) return ( primary: windows.primary?.remainingPercent, secondary: windows.secondary?.remainingPercent) @@ -120,13 +124,15 @@ enum IconRemainingResolver { style: IconStyle, showUsed: Bool, renderingStyle: IconStyle? = nil, - secondaryOverrideWindowID: String? = nil) + secondaryOverrideWindowID: String? = nil, + now: Date = Date()) -> (primary: Double?, secondary: Double?) { let windows = Self.resolvedWindows( snapshot: snapshot, style: style, - secondaryOverrideWindowID: secondaryOverrideWindowID) + secondaryOverrideWindowID: secondaryOverrideWindowID, + now: now) var percents = ( primary: showUsed ? windows.primary?.usedPercent : windows.primary?.remainingPercent, secondary: showUsed ? windows.secondary?.usedPercent : windows.secondary?.remainingPercent) diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index 3d9b5feb29..df12a92f44 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -231,19 +231,23 @@ struct StatusIconView: View { } private var icon: NSImage { + let now = Date() let snapshot = self.store.snapshot(for: self.provider) let remaining = snapshot.map { - IconRemainingResolver.resolvedRemaining(snapshot: $0, style: self.store.style(for: self.provider)) + IconRemainingResolver.resolvedRemaining( + snapshot: $0, + style: self.store.style(for: self.provider), + now: now) } let creditsProjection = self.store.codexConsumerProjectionIfNeeded( for: self.provider, surface: .menuBar, snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) let creditsRemaining = creditsProjection?.menuBarFallback == .creditsBalance ? self.store.codexMenuBarCreditsRemaining( snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) : nil return IconRenderer.makeIcon( primaryRemaining: remaining?.primary, diff --git a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift index 527b836826..ff2429c78d 100644 --- a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift +++ b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift @@ -238,6 +238,7 @@ struct CodexConsumerProjection { private let rateWindowsByLane: [RateLane: RateWindow] private let codeReviewRemainingPercent: Double? private let codeReviewLimit: RateWindow? + private let evaluationTime: Date static func make(surface: Surface, context: Context) -> CodexConsumerProjection { let allowsLiveAdjuncts = surface != .overrideCard @@ -290,20 +291,64 @@ struct CodexConsumerProjection { credits: creditsProjection, menuBarFallback: self.menuBarFallback( creditsRemaining: creditsProjection?.remaining, - rateWindowsByLane: rateWindowsByLane), + rateWindowsByLane: rateWindowsByLane, + evaluationTime: context.now), userFacingErrors: userFacingErrors, canShowBuyCredits: canShowBuyCredits, hasUsageBreakdown: hasUsageBreakdown, hasCreditsHistory: hasCreditsHistory, rateWindowsByLane: rateWindowsByLane, codeReviewRemainingPercent: dashboardVisibility == .attached ? dashboard?.codeReviewRemainingPercent : nil, - codeReviewLimit: dashboardVisibility == .attached ? dashboard?.codeReviewLimit : nil) + codeReviewLimit: dashboardVisibility == .attached ? dashboard?.codeReviewLimit : nil, + evaluationTime: context.now) } func rateWindow(for lane: RateLane) -> RateWindow? { + guard let window = self.rateWindowsByLane[lane] else { return nil } + switch lane { + case .session: + return Self.sessionDisplayWindow( + session: window, + weekly: self.rateWindowsByLane[.weekly], + evaluationTime: self.evaluationTime) + case .weekly: + return window + } + } + + func sourceRateWindow(for lane: RateLane) -> RateWindow? { self.rateWindowsByLane[lane] } + func menuBarSelectableRateWindow(for lane: RateLane) -> RateWindow? { + guard let window = self.rateWindow(for: lane) else { return nil } + guard window.remainingPercent <= 0, + let resetAt = window.resetsAt, + resetAt <= self.evaluationTime + else { + return window + } + return nil + } + + var nextMenuBarStateChangeAt: Date? { + self.rateWindowsByLane.values.compactMap { window in + guard window.remainingPercent <= 0, + let resetAt = window.resetsAt, + resetAt > self.evaluationTime + else { + return nil + } + return resetAt + }.min() + } + + var hasBindingWeeklyCap: Bool { + Self.weeklyCapsSession( + weekly: self.rateWindowsByLane[.weekly], + evaluationTime: self.evaluationTime) + } + func remainingPercent(for metric: SupplementalMetric) -> Double? { switch metric { case .codeReview: @@ -399,18 +444,72 @@ struct CodexConsumerProjection { return (lane, window) } + /// When Codex's weekly lane is exhausted, it is the binding cap: session quota cannot be used until + /// the weekly window resets, even if the API still reports room in the 5-hour bucket. + private static func weeklyCapsSession(weekly: RateWindow?, evaluationTime: Date) -> Bool { + guard let weekly else { return false } + guard weekly.remainingPercent <= 0 else { return false } + return weekly.resetsAt.map { $0 > evaluationTime } ?? true + } + + private static func sessionDisplayWindow( + session: RateWindow, + weekly: RateWindow?, + evaluationTime: Date) -> RateWindow + { + guard self.weeklyCapsSession(weekly: weekly, evaluationTime: evaluationTime) else { + return session + } + let reset = self.bindingReset( + session: session, + weekly: weekly, + evaluationTime: evaluationTime) + return RateWindow( + usedPercent: max(session.usedPercent, 100), + windowMinutes: session.windowMinutes, + resetsAt: reset.date, + resetDescription: reset.description, + nextRegenPercent: session.nextRegenPercent, + isSyntheticPlaceholder: session.isSyntheticPlaceholder) + } + + private static func bindingReset( + session: RateWindow, + weekly: RateWindow?, + evaluationTime: Date) -> (date: Date?, description: String?) + { + guard let weekly else { return (nil, nil) } + let sessionIsExhausted = session.remainingPercent <= 0 && + (session.resetsAt.map { $0 > evaluationTime } ?? true) + guard sessionIsExhausted else { + return (weekly.resetsAt, weekly.resetDescription) + } + guard let sessionReset = session.resetsAt, let weeklyReset = weekly.resetsAt else { + return (nil, nil) + } + if sessionReset > weeklyReset { + return (sessionReset, session.resetDescription) + } + return (weeklyReset, weekly.resetDescription) + } + private static func menuBarFallback( creditsRemaining: Double?, - rateWindowsByLane: [RateLane: RateWindow]) -> MenuBarFallback + rateWindowsByLane: [RateLane: RateWindow], + evaluationTime: Date) -> MenuBarFallback { guard let creditsRemaining, creditsRemaining > 0 else { return .none } - let hasExhaustedLane = rateWindowsByLane.values.contains { $0.remainingPercent <= 0 } + let hasExhaustedLane = rateWindowsByLane.values.contains { + $0.remainingPercent <= 0 && ($0.resetsAt.map { $0 > evaluationTime } ?? true) + } let hasNoRateWindows = rateWindowsByLane.isEmpty return (hasExhaustedLane || hasNoRateWindows) ? .creditsBalance : .none } var hasExhaustedRateLane: Bool { - self.rateWindowsByLane.values.contains { $0.remainingPercent <= 0 } + self.rateWindowsByLane.values.contains { + $0.remainingPercent <= 0 && ($0.resetsAt.map { $0 > self.evaluationTime } ?? true) + } } } @@ -459,4 +558,37 @@ extension UsageStore { guard projection.menuBarFallback == .creditsBalance else { return nil } return projection.credits?.remaining } + + func codexMenuBarMetricWindow(snapshot: UsageSnapshot, now: Date = Date()) -> RateWindow? { + let projection = self.codexConsumerProjection( + surface: .menuBar, + snapshotOverride: snapshot, + now: now) + let windows = projection.visibleRateLanes.compactMap { + projection.menuBarSelectableRateWindow(for: $0) + } + let first = windows.first + let second = windows.dropFirst().first + + switch self.settings.menuBarMetricPreference(for: .codex, snapshot: snapshot) { + case .secondary, .tertiary: + return second ?? first + case .extraUsage: + return first + case .average: + guard self.settings.menuBarMetricSupportsAverage(for: .codex), + let primary = first, + let secondary = second + else { + return first + } + let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 + return RateWindow( + usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + case .primaryAndSecondary: + return windows.prefix(2).max(by: { $0.usedPercent < $1.usedPercent }) + case .automatic, .primary, .monthlyPlan: + return first + } + } } diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index c73fb2c060..a42f0f23aa 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -615,7 +615,11 @@ extension StatusItemController { supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider)) } - func menuBarCreditsRemainingForIcon(provider: UsageProvider, snapshot: UsageSnapshot?) -> Double? { + func menuBarCreditsRemainingForIcon( + provider: UsageProvider, + snapshot: UsageSnapshot?, + now: Date = Date()) -> Double? + { // Derive the menu-bar credits fallback from the same Codex projection path the rendered // icon and menu use (`codexConsumerProjection` -> `menuBarFallback`), instead of a // hand-rolled rate-window predicate. The projection is pure value composition over @@ -626,7 +630,7 @@ extension StatusItemController { guard provider == .codex else { return nil } return self.store.codexMenuBarCreditsRemaining( snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) } func quotaWarningFlashActive(provider: UsageProvider, now: Date = Date()) -> Bool { @@ -720,7 +724,11 @@ extension StatusItemController { return hasImage && !value.isEmpty ? " \(value)" : value } - func menuBarDisplayText(for provider: UsageProvider, snapshot: UsageSnapshot?) -> String? { + func menuBarDisplayText( + for provider: UsageProvider, + snapshot: UsageSnapshot?, + now: Date = .init()) -> String? + { let mode = self.settings.menuBarDisplayMode if provider == .openrouter, self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .automatic, @@ -789,8 +797,7 @@ extension StatusItemController { return spend } - let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot) - let now = Date() + let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot, now: now) let codexProjection = self.store.codexConsumerProjectionIfNeeded( for: provider, surface: .menuBar, @@ -1055,10 +1062,10 @@ extension StatusItemController { return value.isEmpty ? nil : value } - private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) + private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?, now: Date) -> RateWindow? { - self.menuBarMetricWindow(for: provider, snapshot: snapshot) + self.menuBarMetricWindow(for: provider, snapshot: snapshot, now: now) } /// Resolves the session (5h) and weekly (7d) lanes for the combined "Session + Weekly" menu-bar @@ -1083,8 +1090,11 @@ extension StatusItemController { return nil } let session = Self.combinedSessionLane(snapshot: snapshot, projection: projection) - let weekly = projection?.rateWindow(for: .weekly) - ?? Self.rateWindow(in: snapshot, matchingCadenceMinutes: Self.weeklyWindowMinutes) + let weekly: RateWindow? = if let projection { + projection.menuBarSelectableRateWindow(for: .weekly) + } else { + Self.rateWindow(in: snapshot, matchingCadenceMinutes: Self.weeklyWindowMinutes) + } return (session, weekly) } @@ -1097,8 +1107,8 @@ extension StatusItemController { snapshot: UsageSnapshot?, projection: CodexConsumerProjection?) -> RateWindow? { - if let projected = projection?.rateWindow(for: .session) { - return projected + if let projection { + return projection.menuBarSelectableRateWindow(for: .session) } guard let session = Self.rateWindow(in: snapshot, matchingCadenceMinutes: Self.sessionWindowMinutes) else { return nil } @@ -1120,7 +1130,7 @@ extension StatusItemController { percentWindow: RateWindow?) -> RateWindow? { if let projection { - return projection.rateWindow(for: .weekly) + return projection.menuBarSelectableRateWindow(for: .weekly) } if provider == .abacus { return snapshot?.primary diff --git a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift index 50820ec0a1..b82631d31b 100644 --- a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift +++ b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift @@ -8,21 +8,32 @@ extension StatusItemController { self.menuBarCountdownRefreshTask?.cancel() self.menuBarCountdownRefreshTask = nil - guard self.settings.menuBarShowsBrandIconWithPercent, - self.settings.menuBarDisplayMode == .resetTime, - self.settings.resetTimeDisplayStyle == .countdown - else { - return + var delays: [TimeInterval] = [] + let providers = self.menuBarRefreshProviders() + if self.settings.menuBarShowsBrandIconWithPercent, + self.settings.menuBarDisplayMode == .resetTime, + self.settings.resetTimeDisplayStyle == .countdown + { + let resetDates = providers.compactMap { provider in + self.menuBarMetricWindow( + for: provider, + snapshot: self.store.snapshot(for: provider), + now: now)?.resetsAt + } + if let delay = Self.menuBarCountdownRefreshDelay(resetDates: resetDates, now: now) { + delays.append(delay) + } } - let resetDates = self.menuBarCountdownProviders().compactMap { provider in - self.menuBarMetricWindow( - for: provider, - snapshot: self.store.snapshot(for: provider))?.resetsAt - } - guard let delay = Self.menuBarCountdownRefreshDelay(resetDates: resetDates, now: now) else { - return + if self.menuBarObservesCodexReset(providers: providers) { + let projection = self.store.codexConsumerProjection(surface: .menuBar, now: now) + if let resetAt = projection.nextMenuBarStateChangeAt { + delays.append(max( + Self.menuBarCountdownRefreshEpsilon, + resetAt.timeIntervalSince(now) + Self.menuBarCountdownRefreshEpsilon)) + } } + guard let delay = delays.min() else { return } self.menuBarCountdownRefreshTask = Task { @MainActor [weak self] in do { @@ -52,13 +63,22 @@ extension StatusItemController { }.min() } - private func menuBarCountdownProviders() -> [UsageProvider] { + private func menuBarRefreshProviders() -> [UsageProvider] { if self.shouldMergeIcons { return [self.primaryProviderForUnifiedIcon()] } return UsageProvider.allCases.filter(self.isVisible) } + private func menuBarObservesCodexReset(providers: [UsageProvider]) -> Bool { + if providers.contains(.codex) { return true } + guard self.shouldMergeIcons, self.settings.menuBarShowsHighestUsage else { return false } + let activeProviders = self.store.enabledProvidersForDisplay() + return self.settings.resolvedMergedOverviewProviders( + activeProviders: activeProviders, + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit).contains(.codex) + } + #if DEBUG func _test_isMenuBarCountdownRefreshScheduled() -> Bool { self.menuBarCountdownRefreshTask != nil diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 8cb48e2a41..c14fa62f35 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1366,12 +1366,14 @@ extension StatusItemController { let snapshot = self.store.snapshot(for: provider) let showUsed = self.settings.usageBarsShowUsed let style = self.store.style(for: provider) + let now = Date() let resolved = snapshot.map { IconRemainingResolver.resolvedPercents( snapshot: $0, style: style, showUsed: showUsed, - secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0)) + secondaryOverrideWindowID: self.settings.copilotIconSecondaryWindowOverrideID(snapshot: $0), + now: now) } let primary = resolved?.primary let weekly = resolved?.secondary @@ -1379,11 +1381,11 @@ extension StatusItemController { for: provider, surface: .menuBar, snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) let credits = creditsProjection?.menuBarFallback == .creditsBalance ? self.store.codexMenuBarCreditsRemaining( snapshotOverride: snapshot, - now: snapshot?.updatedAt ?? Date()) + now: now) : nil let stale = self.store.isStale(provider: provider) let indicator = self.store.statusIndicator(for: provider) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index fd194c6827..d923515ec0 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -335,9 +335,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin case waitingBrowser } - func menuBarMetricWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { + func menuBarMetricWindow(for provider: UsageProvider, snapshot: UsageSnapshot?, now: Date = Date()) -> RateWindow? { if provider == .codex { - return self.codexMenuBarMetricWindow(snapshot: snapshot) + return self.codexMenuBarMetricWindow(snapshot: snapshot, now: now) } return MenuBarMetricWindowResolver.rateWindow( preference: self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot), @@ -346,45 +346,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider)) } - private func codexMenuBarMetricWindow(snapshot: UsageSnapshot?) -> RateWindow? { + private func codexMenuBarMetricWindow(snapshot: UsageSnapshot?, now: Date) -> RateWindow? { guard let snapshot else { return nil } - let projection = CodexConsumerProjection.make( - surface: .menuBar, - context: CodexConsumerProjection.Context( - snapshot: snapshot, - rawUsageError: nil, - liveCredits: self.store.credits, - rawCreditsError: self.store.lastCreditsError, - liveDashboard: self.store.openAIDashboard, - rawDashboardError: self.store.lastOpenAIDashboardError, - dashboardAttachmentAuthorized: self.store.openAIDashboardAttachmentAuthorized, - dashboardRequiresLogin: self.store.openAIDashboardRequiresLogin, - now: snapshot.updatedAt)) - let lanes = projection.visibleRateLanes - let first = lanes.first.flatMap { projection.rateWindow(for: $0) } - let second = lanes.dropFirst().first.flatMap { projection.rateWindow(for: $0) } - let preference = self.settings.menuBarMetricPreference(for: .codex, snapshot: snapshot) - - switch preference { - case .secondary, .tertiary: - return second ?? first - case .extraUsage: - return first - case .average: - guard self.settings.menuBarMetricSupportsAverage(for: .codex), - let primary = first, - let secondary = second - else { - return first - } - let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2 - return RateWindow( - usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) - case .primaryAndSecondary: - return [first, second].compactMap(\.self).max(by: { $0.usedPercent < $1.usedPercent }) - case .automatic, .primary, .monthlyPlan: - return first - } + return self.store.codexMenuBarMetricWindow(snapshot: snapshot, now: now) } init( diff --git a/Sources/CodexBar/UsageStore+HighestUsage.swift b/Sources/CodexBar/UsageStore+HighestUsage.swift index 5b8429ec34..f629158a76 100644 --- a/Sources/CodexBar/UsageStore+HighestUsage.swift +++ b/Sources/CodexBar/UsageStore+HighestUsage.swift @@ -5,7 +5,7 @@ import Foundation extension UsageStore { /// Returns the enabled candidate provider with the highest usage percentage (closest to rate limit). /// Excludes providers that are fully rate-limited. - func providerWithHighestUsage(candidateProviders: [UsageProvider]? = nil) + func providerWithHighestUsage(candidateProviders: [UsageProvider]? = nil, now: Date = Date()) -> (provider: UsageProvider, usedPercent: Double)? { let candidateSet = candidateProviders.map(Set.init) @@ -14,14 +14,19 @@ extension UsageStore { where candidateSet?.contains(provider) ?? true { guard let snapshot = self.snapshots[provider] else { continue } - guard let window = self.menuBarMetricWindowForHighestUsage(provider: provider, snapshot: snapshot) else { + guard let window = self.menuBarMetricWindowForHighestUsage( + provider: provider, + snapshot: snapshot, + now: now) + else { continue } let percent = window.usedPercent guard !self.shouldExcludeFromHighestUsage( provider: provider, snapshot: snapshot, - metricPercent: percent) + metricPercent: percent, + now: now) else { continue } @@ -32,11 +37,18 @@ extension UsageStore { return highest } - private func menuBarMetricWindowForHighestUsage(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? { + private func menuBarMetricWindowForHighestUsage( + provider: UsageProvider, + snapshot: UsageSnapshot, + now: Date) -> RateWindow? + { let effectivePreference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) if provider == .antigravity, effectivePreference == .automatic { return Self.mostConstrainedAntigravityQuotaSummaryWindow(snapshot: snapshot) } + if provider == .codex { + return self.codexMenuBarMetricWindow(snapshot: snapshot, now: now) + } return MenuBarMetricWindowResolver.rateWindow( preference: effectivePreference, provider: provider, @@ -47,12 +59,21 @@ extension UsageStore { private func shouldExcludeFromHighestUsage( provider: UsageProvider, snapshot: UsageSnapshot, - metricPercent: Double) + metricPercent: Double, + now: Date) -> Bool { let effectivePreference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) guard metricPercent >= 100 else { return false } if provider == .codex || provider == .claude, effectivePreference == .primaryAndSecondary { + if provider == .codex, + self.codexConsumerProjection( + surface: .menuBar, + snapshotOverride: snapshot, + now: now).hasBindingWeeklyCap + { + return true + } // A Claude spend-limit-only snapshot has no real session/weekly lanes; the metric resolves to // the spend-limit window, so reaching here (metricPercent >= 100) means the spend limit itself // is exhausted. Mirror that resolver fallback and exclude, instead of inspecting the raw 0% diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index b7269ab37a..ee385c2a73 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -26,18 +26,19 @@ extension UsageStore { } private func makeWidgetSnapshot() -> WidgetSnapshot { + let now = Date() let enabledProviders = self.enabledProviders() let entries = UsageProvider.allCases.compactMap { provider in - self.makeWidgetEntry(for: provider) + self.makeWidgetEntry(for: provider, now: now) } return WidgetSnapshot( entries: entries, enabledProviders: enabledProviders, usageBarsShowUsed: self.settings.usageBarsShowUsed, - generatedAt: Date()) + generatedAt: now) } - private func makeWidgetEntry(for provider: UsageProvider) -> WidgetSnapshot.ProviderEntry? { + private func makeWidgetEntry(for provider: UsageProvider, now: Date) -> WidgetSnapshot.ProviderEntry? { let snapshot = self.snapshots[provider] let storedTokenSnapshot = self.tokenSnapshots[provider] guard snapshot != nil || (provider == .claude && storedTokenSnapshot != nil) else { return nil } @@ -52,7 +53,7 @@ extension UsageStore { } ?? [] let tokenUsage = Self.widgetTokenUsageSummary(from: tokenSnapshot, provider: provider) - let usageRows = snapshot.map { self.widgetUsageRows(provider: provider, snapshot: $0) } ?? [] + let usageRows = snapshot.map { self.widgetUsageRows(provider: provider, snapshot: $0, now: now) } ?? [] let creditsRemaining: Double? let codeReviewRemaining: Double? @@ -60,7 +61,7 @@ extension UsageStore { let projection = self.codexConsumerProjection( surface: .widget, snapshotOverride: snapshot, - now: snapshot.updatedAt) + now: now) let displayOnlyExtrasHidden = projection.dashboardVisibility == .displayOnly creditsRemaining = displayOnlyExtrasHidden ? nil : projection.credits?.remaining codeReviewRemaining = displayOnlyExtrasHidden ? nil : projection.remainingPercent(for: .codeReview) @@ -78,7 +79,7 @@ extension UsageStore { return WidgetSnapshot.ProviderEntry( provider: provider, - updatedAt: snapshot?.updatedAt ?? tokenSnapshot?.updatedAt ?? Date(), + updatedAt: snapshot?.updatedAt ?? tokenSnapshot?.updatedAt ?? now, primary: snapshot?.primary, secondary: snapshot?.secondary, tertiary: snapshot?.tertiary, @@ -111,16 +112,17 @@ extension UsageStore { private func widgetUsageRows( provider: UsageProvider, - snapshot: UsageSnapshot) -> [WidgetSnapshot.WidgetUsageRowSnapshot] + snapshot: UsageSnapshot, + now: Date) -> [WidgetSnapshot.WidgetUsageRowSnapshot] { let metadata = ProviderDefaults.metadata[provider] if provider == .codex { let projection = self.codexConsumerProjection( surface: .widget, snapshotOverride: snapshot, - now: snapshot.updatedAt) + now: now) return projection.visibleRateLanes.compactMap { lane in - guard let window = projection.rateWindow(for: lane) else { return nil } + guard let window = projection.sourceRateWindow(for: lane) else { return nil } let title = switch lane { case .session: metadata?.sessionLabel ?? "Session" @@ -130,7 +132,8 @@ extension UsageStore { return WidgetSnapshot.WidgetUsageRowSnapshot( id: lane.rawValue, title: title, - percentLeft: window.remainingPercent) + percentLeft: window.remainingPercent, + window: window) } } if provider == .antigravity, diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index 686b65debf..5f68d59ce8 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -5,11 +5,13 @@ public struct WidgetSnapshot: Codable, Sendable { public let id: String public let title: String public let percentLeft: Double? + public let window: RateWindow? - public init(id: String, title: String, percentLeft: Double?) { + public init(id: String, title: String, percentLeft: Double?, window: RateWindow? = nil) { self.id = id self.title = title self.percentLeft = percentLeft + self.window = window } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index d9eae2b97b..df74b23d45 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -228,8 +228,9 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { { let provider = configuration.provider.provider let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() - let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot) - let refresh = Date().addingTimeInterval(30 * 60) + let now = Date() + let entry = CodexBarWidgetEntry(date: now, provider: provider, snapshot: snapshot) + let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: snapshot, provider: provider, now: now) return Timeline(entries: [entry], policy: .after(refresh)) } } @@ -251,7 +252,10 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider { func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { let entry = self.makeEntry() - let refresh = Date().addingTimeInterval(30 * 60) + let refresh = BurnDownRefreshSchedule.nextRefresh( + snapshot: entry.snapshot, + provider: entry.provider, + now: entry.date) completion(Timeline(entries: [entry], policy: .after(refresh))) } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index f1560262e2..471b7f4367 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -614,12 +614,36 @@ struct WidgetUsageRow: Identifiable, Equatable { return limit } - static func rows(for entry: WidgetSnapshot.ProviderEntry, limit: Int? = nil) -> [WidgetUsageRow] { + static func rows( + for entry: WidgetSnapshot.ProviderEntry, + limit: Int? = nil, + now: Date = Date()) -> [WidgetUsageRow] + { let rows: [WidgetUsageRow] if let usageRows = entry.usageRows { - rows = usageRows.map { row in - WidgetUsageRow(id: row.id, title: row.title, percentLeft: row.percentLeft) + let resolvedSnapshots = usageRows.map { row in + guard row.window == nil, + let window = self.legacyCodexRateWindow(for: row.id, entry: entry) + else { + return row + } + return WidgetSnapshot.WidgetUsageRowSnapshot( + id: row.id, + title: row.title, + percentLeft: row.percentLeft, + window: window) } + let sourceRows = resolvedSnapshots.map { row in + WidgetUsageRow( + id: row.id, + title: row.title, + percentLeft: row.window?.remainingPercent ?? row.percentLeft) + } + rows = self.applyingCodexWeeklyCap( + sourceRows, + snapshots: resolvedSnapshots, + provider: entry.provider, + now: now) } else { let metadata = ProviderDefaults.metadata[entry.provider] var defaultRows = [ @@ -672,6 +696,45 @@ struct WidgetUsageRow: Identifiable, Equatable { return Array(rows.prefix(max(0, limit))) } + private static func applyingCodexWeeklyCap( + _ rows: [WidgetUsageRow], + snapshots: [WidgetSnapshot.WidgetUsageRowSnapshot], + provider: UsageProvider, + now: Date) -> [WidgetUsageRow] + { + guard provider == .codex, + let weekly = snapshots.first(where: { $0.id == "weekly" })?.window, + weekly.remainingPercent <= 0, + weekly.resetsAt.map({ $0 > now }) ?? true + else { + return rows + } + return rows.map { row in + guard row.id == "session" else { return row } + return WidgetUsageRow(id: row.id, title: row.title, percentLeft: 0) + } + } + + private static func legacyCodexRateWindow( + for rowID: String, + entry: WidgetSnapshot.ProviderEntry) -> RateWindow? + { + guard entry.provider == .codex else { return nil } + let candidates = [(entry.primary, "session"), (entry.secondary, "weekly")] + for (window, fallbackID) in candidates { + guard let window else { continue } + let classifiedID = switch window.windowMinutes { + case 300: "session" + case 10080: "weekly" + default: fallbackID + } + if classifiedID == rowID { + return window + } + } + return nil + } + static func compactTokenUsage( for entry: WidgetSnapshot.ProviderEntry) -> WidgetSnapshot.TokenUsageSummary? { diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index 9bc1cc1934..e21ab1ff47 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -555,6 +555,50 @@ struct CodexBarWidgetProviderTests { #expect(rows == [WidgetUsageRow(id: "weekly", title: "Weekly", percentLeft: 75)]) } + @Test + func `codex widget session cap lifts at weekly reset without a new snapshot`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyReset = now.addingTimeInterval(3600) + let sessionWindow = RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil) + let weeklyWindow = RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil) + let entry = WidgetSnapshot.ProviderEntry( + provider: .codex, + updatedAt: now.addingTimeInterval(-7200), + primary: sessionWindow, + secondary: weeklyWindow, + tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "session", + title: "Session", + percentLeft: 99, + window: sessionWindow), + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "weekly", + title: "Weekly", + percentLeft: 0, + window: weeklyWindow), + ], + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let capped = WidgetUsageRow.rows(for: entry, now: now) + let reset = WidgetUsageRow.rows(for: entry, now: weeklyReset) + + #expect(capped.map(\.percentLeft) == [0, 0]) + #expect(reset.map(\.percentLeft) == [99, 0]) + } + @Test func `legacy widget usage rows use antigravity grouped slots`() { let now = Date(timeIntervalSince1970: 1_700_000_000) diff --git a/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift b/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift index 6218335245..31194288bb 100644 --- a/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift +++ b/Tests/CodexBarTests/CodexCombinedMetricHighestUsageTests.swift @@ -26,6 +26,68 @@ struct CodexCombinedMetricHighestUsageTests { #expect(highest?.usedPercent == 91) } + @Test + func `combined codex metric ignores expired weekly lane when ranking highest usage`() { + let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-expired-weekly-ranking") + let now = Date(timeIntervalSince1970: 1_800_000_000) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: now, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .claude) + + let highest = store.providerWithHighestUsage(now: now) + #expect(highest?.provider == .claude) + #expect(highest?.usedPercent == 80) + } + + @Test + func `combined codex metric excludes an actively binding weekly cap`() { + let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-binding-weekly-cap") + let now = Date(timeIntervalSince1970: 1_800_000_000) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + updatedAt: now), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .claude) + + let highest = store.providerWithHighestUsage(now: now) + #expect(highest?.provider == .claude) + #expect(highest?.usedPercent == 80) + } + @Test func `combined codex metric stays eligible when only one lane is exhausted`() { let store = self.makeStore(suiteName: "CodexCombinedMetricHighestUsageTests-one-exhausted") diff --git a/Tests/CodexBarTests/CodexConsumerProjectionTests.swift b/Tests/CodexBarTests/CodexConsumerProjectionTests.swift index 63410b7310..03babc0dd1 100644 --- a/Tests/CodexBarTests/CodexConsumerProjectionTests.swift +++ b/Tests/CodexBarTests/CodexConsumerProjectionTests.swift @@ -231,6 +231,245 @@ struct CodexConsumerProjectionTests { #expect(projection.credits?.remaining == 92239) } + @Test + func `exhausted weekly lane caps session display until weekly reset`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-caps-session") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(4 * 24 * 3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 157, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + let weekly = try #require(projection.rateWindow(for: .weekly)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == weeklyReset) + #expect(weekly.remainingPercent == 0) + #expect(weekly.resetsAt == weeklyReset) + #expect(projection.planUtilizationLanes.first?.window.usedPercent == 1) + } + + @Test + func `exhausted weekly lane retargets session reset when session is also exhausted`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-caps-both-exhausted") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(42 * 60) + let weeklyReset = now.addingTimeInterval(4 * 24 * 3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 157, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == weeklyReset) + #expect(session.resetsAt != sessionReset) + } + + @Test + func `both exhausted lanes use the later session reset`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-session-reset-binds-later") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyReset = now.addingTimeInterval(60 * 60) + let sessionReset = now.addingTimeInterval(4 * 60 * 60) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: "session reset"), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: "weekly reset"), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == sessionReset) + #expect(session.resetDescription == "session reset") + } + + @Test + func `both exhausted lanes keep effective reset unknown when session reset is unknown`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-session-reset-unknown") + let now = Date(timeIntervalSince1970: 1_800_000_000) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: "weekly reset"), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == nil) + #expect(session.resetDescription == nil) + } + + @Test + func `exhausted weekly lane leaves session reset unknown when weekly reset is unknown`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-caps-unknown-reset") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(42 * 60) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: "in 42m"), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 0) + #expect(session.resetsAt == nil) + #expect(session.resetDescription == nil) + } + + @Test + func `weekly cap lifts after weekly reset even with stale snapshot timestamp`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-cap-stale-snapshot") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let snapshotCapturedAt = now.addingTimeInterval(-2 * 3600) + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(-3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: snapshotCapturedAt), + provider: .codex) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now) + + let projection = store.codexConsumerProjection(surface: .menuBar, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 99) + #expect(session.resetsAt == sessionReset) + #expect(projection.menuBarFallback == .none) + } + + @Test + func `weekly cap does not alter session display when weekly has reset`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-reset-session-uncapped") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(-3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 99) + #expect(session.resetsAt == sessionReset) + } + + @Test + func `weekly cap lifts at the weekly reset boundary`() throws { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-weekly-reset-boundary") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3 * 3600) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-3600)), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + let session = try #require(projection.rateWindow(for: .session)) + + #expect(session.remainingPercent == 99) + #expect(session.resetsAt == sessionReset) + } + private func makeStore(suite: String) -> UsageStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift b/Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift new file mode 100644 index 0000000000..97ad743689 --- /dev/null +++ b/Tests/CodexBarTests/CodexLegacyWidgetSnapshotTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +@testable import CodexBarCore +@testable import CodexBarWidget + +struct CodexLegacyWidgetSnapshotTests { + @Test + func `codex widget caps legacy decoded rows without window metadata`() throws { + let json = """ + { + "entries": [ + { + "provider": "codex", + "updatedAt": "2027-01-15T08:00:00Z", + "primary": { + "usedPercent": 1, + "windowMinutes": 300, + "resetsAt": "2027-01-15T09:00:00Z", + "resetDescription": null + }, + "secondary": { + "usedPercent": 100, + "windowMinutes": 10080, + "resetsAt": "2027-01-15T10:00:00Z", + "resetDescription": null + }, + "tertiary": null, + "usageRows": [ + { "id": "session", "title": "Session", "percentLeft": 99 }, + { "id": "weekly", "title": "Weekly", "percentLeft": 0 } + ], + "creditsRemaining": null, + "codeReviewRemainingPercent": null, + "tokenUsage": null, + "dailyUsage": [] + } + ], + "enabledProviders": ["codex"], + "generatedAt": "2027-01-15T08:00:00Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(WidgetSnapshot.self, from: Data(json.utf8)) + let entry = try #require(snapshot.entries.first) + let now = try #require(ISO8601DateFormatter().date(from: "2027-01-15T08:30:00Z")) + + let rows = WidgetUsageRow.rows(for: entry, now: now) + + #expect(entry.usageRows?.allSatisfy { $0.window == nil } == true) + #expect(rows.map(\.id) == ["session", "weekly"]) + #expect(rows.map(\.percentLeft) == [0, 0]) + } +} diff --git a/Tests/CodexBarTests/CodexWeeklyCapSurfaceTests.swift b/Tests/CodexBarTests/CodexWeeklyCapSurfaceTests.swift new file mode 100644 index 0000000000..9f46cb6e63 --- /dev/null +++ b/Tests/CodexBarTests/CodexWeeklyCapSurfaceTests.swift @@ -0,0 +1,219 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct CodexWeeklyCapSurfaceTests { + @Test + func `menu card session metric shows weekly cap and reset`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let weeklyReset = now.addingTimeInterval(4 * 24 * 60 * 60) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-2 * 60 * 60)) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + 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 session = try #require(model.metrics.first { $0.id == "primary" }) + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(session.percent == 0) + #expect(session.resetText == weekly.resetText) + #expect(session.resetText != nil) + } + + @Test + func `primary menu bar metric and credits follow binding weekly reset`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "CodexWeeklyCapSurfaceTests-menu-bar"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.setMenuBarMetricPreference(.primary, for: .codex) + + if let codexMeta = ProviderRegistry.shared.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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyReset = now.addingTimeInterval(3600) + let sessionReset = now.addingTimeInterval(1800) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now) + + let capped = controller.menuBarMetricWindow(for: .codex, snapshot: snapshot, now: now) + let reset = controller.menuBarMetricWindow(for: .codex, snapshot: snapshot, now: weeklyReset) + let cappedCredits = controller.menuBarCreditsRemainingForIcon( + provider: .codex, + snapshot: snapshot, + now: now) + let resetCredits = controller.menuBarCreditsRemainingForIcon( + provider: .codex, + snapshot: snapshot, + now: weeklyReset) + + #expect(capped?.remainingPercent == 0) + #expect(capped?.resetsAt == weeklyReset) + #expect(reset?.remainingPercent == 99) + #expect(reset?.resetsAt == sessionReset) + #expect(cappedCredits == 80) + #expect(resetCredits == nil) + } + + @Test + func `combined menu bar modes ignore exhausted weekly lane after its reset`() throws { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "CodexWeeklyCapSurfaceTests-combined-reset"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.usageBarsShowUsed = false + settings.resetTimesShowAbsolute = false + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + + if let codexMeta = ProviderRegistry.shared.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: .system) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let selected = try #require(controller.menuBarMetricWindow(for: .codex, snapshot: snapshot, now: now)) + #expect(selected.remainingPercent == 99) + #expect(selected.resetsAt == sessionReset) + + settings.menuBarDisplayMode = .percent + #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "5h 99%") + settings.menuBarDisplayMode = .pace + #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "99%") + settings.menuBarDisplayMode = .both + #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "99%") + settings.menuBarDisplayMode = .resetTime + #expect(controller.menuBarDisplayText(for: .codex, snapshot: snapshot, now: now) == "↻ in 1h") + + settings.setMenuBarMetricPreference(.primary, for: .codex) + settings.menuBarDisplayMode = .percent + let expiredSessionSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)) + store._setSnapshotForTesting(expiredSessionSnapshot, provider: .codex) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now) + + let resetPrimary = try #require(controller.menuBarMetricWindow( + for: .codex, + snapshot: expiredSessionSnapshot, + now: now)) + let resetIcon = IconRemainingResolver.resolvedRemaining( + snapshot: expiredSessionSnapshot, + style: .codex, + now: now) + #expect(resetPrimary.remainingPercent == 60) + #expect(controller.menuBarDisplayText(for: .codex, snapshot: expiredSessionSnapshot, now: now) == "60%") + #expect(resetIcon.primary == 60) + #expect(resetIcon.secondary == nil) + #expect(store.codexConsumerProjection(surface: .menuBar, now: now).menuBarFallback == .none) + } +} diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index 0d7f76db1d..7502ab9155 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -636,6 +636,35 @@ struct CodexBarTests { #expect(remaining.secondary == nil) } + @Test + func `codex icon caps session only until exhausted weekly lane resets`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let weeklyReset = now.addingTimeInterval(3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)) + + let capped = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: .codex, now: now) + let reset = IconRemainingResolver.resolvedRemaining( + snapshot: snapshot, + style: .codex, + now: weeklyReset) + + #expect(capped.primary == 0) + #expect(capped.secondary == 0) + #expect(reset.primary == 99) + #expect(reset.secondary == nil) + } + @Test func `status overlays cut halos through the quota bar and keep glyphs visible`() throws { let plain = IconRenderer.makeIcon( diff --git a/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift b/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift index e17b9508db..8596778c13 100644 --- a/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift +++ b/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift @@ -33,7 +33,7 @@ struct MenuBarCountdownRefreshTests { } @Test - func `status item schedules countdown refresh only for countdown reset dates`() { + func `status item schedules countdown and exhausted lane refreshes`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "MenuBarCountdownRefreshTests-scheduling"), zaiTokenStore: NoopZaiTokenStore(), @@ -78,6 +78,58 @@ struct MenuBarCountdownRefreshTests { controller.updateIcons() #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(90), + resetDescription: nil), + updatedAt: now), + provider: .codex) + controller.updateIcons() + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(-1), + resetDescription: nil), + updatedAt: now), + provider: .codex) + controller.updateIcons() + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(90), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + updatedAt: now), + provider: .codex) + controller.updateIcons() + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + settings.resetTimesShowAbsolute = false store._setSnapshotForTesting(nil, provider: .codex) controller.updateIcons() @@ -99,4 +151,69 @@ struct MenuBarCountdownRefreshTests { controller.prepareForAppShutdown() #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) } + + @Test + func `merged highest usage observes reset for noncurrent Codex candidate`() throws { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "MenuBarCountdownRefreshTests-merged-highest"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.menuBarShowsHighestUsage = true + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = .percent + settings.resetTimesShowAbsolute = true + + let registry = ProviderRegistry.shared + try settings.setProviderEnabled( + provider: .codex, + metadata: #require(registry.metadata[.codex]), + enabled: true) + try settings.setProviderEnabled( + provider: .claude, + metadata: #require(registry.metadata[.claude]), + enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(90), + resetDescription: nil), + updatedAt: now), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + provider: .claude) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + controller.updateIcons() + #expect(controller.primaryProviderForUnifiedIcon() == .claude) + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + } } diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift index d4210ac541..d6b7743578 100644 --- a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -14,7 +14,10 @@ struct StatusItemIconObservationSignatureTests { settings.statusChecksEnabled = true settings.refreshFrequency = .manual settings.menuBarShowsBrandIconWithPercent = false + settings.menuBarShowsHighestUsage = false settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = false + settings.selectedMenuProvider = .codex let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -88,6 +91,7 @@ struct StatusItemIconObservationSignatureTests { let registry = ProviderRegistry.shared let claudeMetadata = try #require(registry.metadata[.claude]) settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + settings.selectedMenuProvider = .codex store._setSnapshotForTesting( Self.makeSnapshot(provider: .claude, email: "claude@example.com"), provider: .claude) diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift index 37908c2267..fbdf8124ea 100644 --- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -5,6 +5,53 @@ import Testing @MainActor struct UsageStoreWidgetSnapshotTests { + @Test + func `widget snapshot preserves raw Codex windows for timeline projection`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-codex-weekly-cap" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 1, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + updatedAt: now.addingTimeInterval(-7200)), + provider: .codex) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "codex-weekly-cap-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .codex }) + #expect(entry.usageRows?.map(\.id) == ["session", "weekly"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [99, 0]) + #expect(entry.usageRows?.first?.window?.usedPercent == 1) + #expect(entry.usageRows?.last?.window?.resetsAt == now.addingTimeInterval(3600)) + } + @Test func `widget snapshot includes Kimi monthly quota`() async throws { let suite = "UsageStoreWidgetSnapshotTests-kimi-monthly" diff --git a/docs/screenshots/codex-upstream-weekly-exhausted-session-misleading.png b/docs/screenshots/codex-upstream-weekly-exhausted-session-misleading.png new file mode 100644 index 0000000000..cb8d287ec3 Binary files /dev/null and b/docs/screenshots/codex-upstream-weekly-exhausted-session-misleading.png differ diff --git a/docs/screenshots/codexbar-weekly-caps-session-fix.png b/docs/screenshots/codexbar-weekly-caps-session-fix.png new file mode 100644 index 0000000000..e3b6326920 Binary files /dev/null and b/docs/screenshots/codexbar-weekly-caps-session-fix.png differ