diff --git a/AGENTS.md b/AGENTS.md index 8db4d4681c..eaf70d7bc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ ## Testing Guidelines - Add/extend XCTest cases under `Tests/CodexBarTests/*Tests.swift` (`FeatureNameTests` with `test_caseDescription` methods). - Always run `swift test` (or `./Scripts/compile_and_run.sh`) before handoff; add fixtures for new parsing/formatting scenarios. -- After any code change, run `pnpm check` and fix all reported format/lint issues before handoff. +- After any code change, run `make check` and fix all reported format/lint issues before handoff. - macOS CI is brittle around headless AppKit status/menu tests. Prefer covering menu behavior through stable state/model seams (`MenuDescriptor`, `ProvidersPane`, `CodexAccountsSectionState`, etc.) instead of constructing live `NSStatusBar`/`NSMenu` flows unless the AppKit wiring itself is the thing under test. ## Commit & PR Guidelines diff --git a/CHANGELOG.md b/CHANGELOG.md index 307a72f704..8dfceb0503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Providers & Usage - Cost history: add an additive models.dev pricing metadata parser/cache pipeline for future provider-scoped cost lookups (#863). Thanks @iam-brain! +- Notifications: add opt-in quota warning notifications, warning markers, and provider-level thresholds for session and weekly quota windows (#852). Thanks @Alekstodo! - Venice: add API-key balance provider support with DIEM/USD balance display and token-account CLI wiring (#865). Thanks @clawSean! - Factory/Droid: add token-rate-limit billing windows, Core fallback buckets, and extra usage balance display (#878). Thanks @dantemoon1! - Usage pace: compute pace for any explicit reset window instead of a provider allowlist (#875). Thanks @ViperThanks! diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..da971da46a --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ +SHELL := /bin/bash + +.PHONY: build check docs-list format lint release restart start start-debug start-release stop test test-live test-tty + +start: + ./Scripts/compile_and_run.sh + +start-debug: + ./Scripts/compile_and_run.sh + +start-release: + ./Scripts/package_app.sh release + pkill -x CodexBar || pkill -f CodexBar.app || true + cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app + +restart: start + +stop: + pkill -x CodexBar || pkill -f CodexBar.app || true + +check lint: + ./Scripts/lint.sh lint + +format: + ./Scripts/lint.sh format + +docs-list: + node Scripts/docs-list.mjs + +build: + swift build + +test: + swift test + +test-tty: + swift test --filter TTYIntegrationTests + +test-live: + LIVE_TEST=1 swift test --filter LiveAccountTests + +release: + ./Scripts/package_app.sh release diff --git a/README.md b/README.md index 94784d4a4b..bab3895a00 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,8 @@ Dev loop: ```bash ./Scripts/compile_and_run.sh ./Scripts/compile_and_run.sh --test # also run swift test before packaging/relaunching -pnpm check # SwiftFormat + SwiftLint -pnpm docs:list # list docs with frontmatter summaries +make check # SwiftFormat + SwiftLint +make docs-list # list docs with frontmatter summaries ``` CLI install: diff --git a/Sources/CodexBar/AppNotifications.swift b/Sources/CodexBar/AppNotifications.swift index 6bd3bc55a5..56065dfb56 100644 --- a/Sources/CodexBar/AppNotifications.swift +++ b/Sources/CodexBar/AppNotifications.swift @@ -19,7 +19,13 @@ final class AppNotifications { _ = self.ensureAuthorizationTask() } - func post(idPrefix: String, title: String, body: String, badge: NSNumber? = nil) { + func post( + idPrefix: String, + title: String, + body: String, + badge: NSNumber? = nil, + soundEnabled: Bool = true) + { guard !Self.isRunningUnderTests else { return } let center = self.centerProvider() let logger = self.logger @@ -34,7 +40,7 @@ final class AppNotifications { let content = UNMutableNotificationContent() content.title = title content.body = body - content.sound = .default + content.sound = soundEnabled ? .default : nil content.badge = badge let request = UNNotificationRequest( diff --git a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift new file mode 100644 index 0000000000..89505ac5d2 --- /dev/null +++ b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift @@ -0,0 +1,21 @@ +import CodexBarCore + +extension CodexConsumerProjection.RateLane { + var quotaWarningWindow: QuotaWarningWindow { + switch self { + case .session: + .session + case .weekly: + .weekly + } + } +} + +extension UsageMenuCardView.Model { + static func warningMarkerPercents(thresholds: [Int]?, showUsed: Bool) -> [Double] { + guard let thresholds, !thresholds.isEmpty else { return [] } + return QuotaWarningThresholds.active(thresholds) + .map { showUsed ? 100 - Double($0) : Double($0) } + .filter { $0 > 0 && $0 < 100 } + } +} diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index e1be7854f9..4f41f91782 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -36,6 +36,7 @@ struct UsageMenuCardView: View { let detailRightText: String? let pacePercent: Double? let paceOnTop: Bool + let warningMarkerPercents: [Double] init( id: String, @@ -48,7 +49,8 @@ struct UsageMenuCardView: View { detailLeftText: String?, detailRightText: String?, pacePercent: Double?, - paceOnTop: Bool) + paceOnTop: Bool, + warningMarkerPercents: [Double] = []) { self.id = id self.title = title @@ -61,6 +63,7 @@ struct UsageMenuCardView: View { self.detailRightText = detailRightText self.pacePercent = pacePercent self.paceOnTop = paceOnTop + self.warningMarkerPercents = warningMarkerPercents } var percentLabel: String { @@ -373,7 +376,8 @@ private struct MetricRow: View { tint: self.progressColor, accessibilityLabel: self.metric.percentStyle.accessibilityLabel, pacePercent: self.metric.pacePercent, - paceOnTop: self.metric.paceOnTop) + paceOnTop: self.metric.paceOnTop, + warningMarkerPercents: self.metric.warningMarkerPercents) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline) { Text(self.metric.percentLabel) @@ -677,6 +681,7 @@ extension UsageMenuCardView.Model { let hidePersonalInfo: Bool let claudePeakHoursEnabled: Bool let weeklyPace: UsagePace? + let quotaWarningThresholds: [QuotaWarningWindow: [Int]] let now: Date init( @@ -702,6 +707,7 @@ extension UsageMenuCardView.Model { hidePersonalInfo: Bool, claudePeakHoursEnabled: Bool = true, weeklyPace: UsagePace? = nil, + quotaWarningThresholds: [QuotaWarningWindow: [Int]] = [:], now: Date) { self.provider = provider @@ -726,6 +732,7 @@ extension UsageMenuCardView.Model { self.hidePersonalInfo = hidePersonalInfo self.claudePeakHoursEnabled = claudePeakHoursEnabled self.weeklyPace = weeklyPace + self.quotaWarningThresholds = quotaWarningThresholds self.now = now } } @@ -1010,7 +1017,10 @@ extension UsageMenuCardView.Model { detailLeftText: nil, detailRightText: nil, pacePercent: nil, - paceOnTop: true)) + paceOnTop: true, + warningMarkerPercents: Self.warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: input.usageBarsShowUsed))) } if let extraRateWindows = snapshot.extraRateWindows { metrics.append(contentsOf: extraRateWindows.map { namedWindow in @@ -1175,7 +1185,10 @@ extension UsageMenuCardView.Model { detailLeftText: primaryDetailLeft, detailRightText: primaryDetailRight, pacePercent: primaryPacePercent, - paceOnTop: primaryPaceOnTop) + paceOnTop: primaryPaceOnTop, + warningMarkerPercents: Self.warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.session], + showUsed: input.usageBarsShowUsed)) } private static func secondaryMetric( @@ -1247,7 +1260,10 @@ extension UsageMenuCardView.Model { detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, - paceOnTop: paceDetail?.paceOnTop ?? true) + paceOnTop: paceDetail?.paceOnTop ?? true, + warningMarkerPercents: Self.warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: input.usageBarsShowUsed)) } private static func codexRateMetrics( @@ -1286,7 +1302,10 @@ extension UsageMenuCardView.Model { detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, - paceOnTop: paceDetail?.paceOnTop ?? true) + paceOnTop: paceDetail?.paceOnTop ?? true, + warningMarkerPercents: Self.warningMarkerPercents( + thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow], + showUsed: input.usageBarsShowUsed)) } } diff --git a/Sources/CodexBar/Notifications+CodexBar.swift b/Sources/CodexBar/Notifications+CodexBar.swift index 354c63bf16..4d8e4b2f9a 100644 --- a/Sources/CodexBar/Notifications+CodexBar.swift +++ b/Sources/CodexBar/Notifications+CodexBar.swift @@ -6,6 +6,7 @@ extension Notification.Name { static let codexbarDebugBlinkNow = Notification.Name("codexbarDebugBlinkNow") static let codexbarWeeklyLimitReset = Notification.Name("codexbarWeeklyLimitReset") static let codexbarProviderConfigDidChange = Notification.Name("codexbarProviderConfigDidChange") + static let codexbarQuotaWarningDidPost = Notification.Name("codexbarQuotaWarningDidPost") } @MainActor @@ -22,3 +23,18 @@ final class WeeklyLimitResetEvent: NSObject { self.usedPercent = usedPercent } } + +@MainActor +final class QuotaWarningPostedEvent: NSObject { + let provider: UsageProvider + let window: QuotaWarningWindow + let threshold: Int + let postedAt: Date + + init(provider: UsageProvider, window: QuotaWarningWindow, threshold: Int, postedAt: Date) { + self.provider = provider + self.window = window + self.threshold = threshold + self.postedAt = postedAt + } +} diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index e5bce6ffe1..328c2fed88 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -96,6 +96,13 @@ struct GeneralPane: View { subtitle: "Notifies when the 5-hour session quota hits 0% and when it becomes " + "available again.", binding: self.$settings.sessionQuotaNotificationsEnabled) + PreferenceToggleRow( + title: "Quota warning notifications", + subtitle: "Warns when session or weekly quota remaining crosses configured thresholds.", + binding: self.$settings.quotaWarningNotificationsEnabled) + if self.settings.quotaWarningNotificationsEnabled { + GlobalQuotaWarningSettingsView(settings: self.settings) + } } Divider() diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 5c930a7e0d..faac998129 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -125,6 +125,8 @@ struct ProviderDetailView: View { self.supplementarySettingsContent } + ProviderQuotaWarningSettingsView(provider: self.provider, settings: self.store.settings) + if !self.settingsToggles.isEmpty { ProviderSettingsSection(title: "Options") { ForEach(self.settingsToggles) { toggle in @@ -430,7 +432,8 @@ private struct ProviderMetricInlineRow: View { tint: self.progressColor, accessibilityLabel: self.metric.percentStyle.accessibilityLabel, pacePercent: self.metric.pacePercent, - paceOnTop: self.metric.paceOnTop) + paceOnTop: self.metric.paceOnTop, + warningMarkerPercents: self.metric.warningMarkerPercents) .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) HStack(alignment: .firstTextBaseline, spacing: 8) { diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index d46401fc3c..cb1d6d1760 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -605,10 +605,19 @@ struct ProvidersPane: View { hidePersonalInfo: self.settings.hidePersonalInfo, claudePeakHoursEnabled: self.settings.claudePeakHoursEnabled, weeklyPace: weeklyPace, + quotaWarningThresholds: [ + .session: self.quotaWarningMarkerThresholds(provider: provider, window: .session), + .weekly: self.quotaWarningMarkerThresholds(provider: provider, window: .weekly), + ], now: now) return UsageMenuCardView.Model.make(input) } + private func quotaWarningMarkerThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int] { + guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { return [] } + return self.settings.resolvedQuotaWarningThresholds(provider: provider, window: window) + } + private func refreshCodexProvider() async { await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refreshCodexAccountScopedState(allowDisabled: true) diff --git a/Sources/CodexBar/QuotaWarningSettingsViews.swift b/Sources/CodexBar/QuotaWarningSettingsViews.swift new file mode 100644 index 0000000000..bce2a9fc2f --- /dev/null +++ b/Sources/CodexBar/QuotaWarningSettingsViews.swift @@ -0,0 +1,231 @@ +import CodexBarCore +import SwiftUI + +@MainActor +struct GlobalQuotaWarningSettingsView: View { + @Bindable var settings: SettingsStore + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 16) { + Toggle(isOn: Binding( + get: { self.settings.quotaWarningWindowEnabled(.session) }, + set: { self.settings.setQuotaWarningWindowEnabled(.session, enabled: $0) })) + { + Text("Session") + .font(.footnote) + } + .toggleStyle(.checkbox) + + Toggle(isOn: Binding( + get: { self.settings.quotaWarningWindowEnabled(.weekly) }, + set: { self.settings.setQuotaWarningWindowEnabled(.weekly, enabled: $0) })) + { + Text("Weekly") + .font(.footnote) + } + .toggleStyle(.checkbox) + } + + QuotaWarningThresholdField( + title: "Warn at", + subtitle: "Remaining percentages for session and weekly windows unless a provider overrides them.", + thresholds: { self.settings.quotaWarningThresholds }, + setThresholds: { self.settings.quotaWarningThresholds = $0 }) + .disabled(!self.settings.quotaWarningWindowEnabled(.session) && !self.settings + .quotaWarningWindowEnabled(.weekly)) + .opacity(!self.settings.quotaWarningWindowEnabled(.session) && !self.settings + .quotaWarningWindowEnabled(.weekly) ? 0.55 : 1) + + Toggle(isOn: self.$settings.quotaWarningSoundEnabled) { + Text("Play notification sound") + .font(.footnote) + } + .toggleStyle(.checkbox) + } + .padding(.leading, 20) + } +} + +@MainActor +struct ProviderQuotaWarningSettingsView: View { + let provider: UsageProvider + @Bindable var settings: SettingsStore + + var body: some View { + ProviderSettingsSection(title: "Quota warnings") { + Text("Uses the global quota warning settings unless a window is customized here.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + self.windowRow(.session) + self.windowRow(.weekly) + } + } + + private func windowRow(_ window: QuotaWarningWindow) -> some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: Binding( + get: { self.settings.hasQuotaWarningOverride(provider: self.provider, window: window) }, + set: { isOn in + if isOn { + self.settings.setQuotaWarningOverride( + provider: self.provider, + window: window, + thresholds: self.settings.quotaWarningThresholds, + enabled: self.settings.quotaWarningWindowEnabled(window)) + } else { + self.settings.setQuotaWarningOverride( + provider: self.provider, + window: window, + thresholds: nil, + enabled: nil) + } + })) { + Text("Customize \(window.displayName) thresholds") + .font(.subheadline.weight(.semibold)) + } + .toggleStyle(.checkbox) + + if self.settings.hasQuotaWarningOverride(provider: self.provider, window: window) { + Toggle(isOn: Binding( + get: { self.settings.quotaWarningEnabled(provider: self.provider, window: window) }, + set: { + self.settings.setQuotaWarningWindowEnabled( + provider: self.provider, + window: window, + enabled: $0) + })) { + Text("Enable \(window.displayName) warnings") + .font(.footnote) + } + .toggleStyle(.checkbox) + .padding(.leading, 20) + + if self.settings.quotaWarningEnabled(provider: self.provider, window: window) { + QuotaWarningThresholdField( + title: "\(window.displayName.capitalized) warn at", + subtitle: "", + thresholds: { + self.settings.resolvedQuotaWarningThresholds(provider: self.provider, window: window) + }, + setThresholds: { + self.settings.setQuotaWarningThresholds( + provider: self.provider, + window: window, + thresholds: $0) + }) + .padding(.leading, 20) + } else { + Text("Off") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.leading, 20) + } + } else { + Text("Inherited: " + Self.thresholdText( + self.settings.quotaWarningThresholds, + enabled: self.settings.quotaWarningWindowEnabled(window))) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.leading, 20) + } + } + } + + private static func thresholdText(_ thresholds: [Int], enabled: Bool) -> String { + guard enabled else { return "Off" } + let text = QuotaWarningThresholds.active(thresholds).map { "\($0)%" }.joined(separator: ", ") + return text.isEmpty ? "depleted only" : text + } +} + +@MainActor +private struct QuotaWarningThresholdField: View { + let title: String + let subtitle: String + let thresholds: () -> [Int] + let setThresholds: ([Int]) -> Void + + @State private var upperText: String = "" + @State private var lowerText: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(self.title) + .font(.footnote.weight(.semibold)) + .frame(width: 110, alignment: .leading) + + Text("Upper") + .font(.footnote) + .foregroundStyle(.secondary) + + TextField("50", text: self.$upperText) + .textFieldStyle(.roundedBorder) + .font(.footnote) + .frame(width: 56) + .onChange(of: self.upperText) { _, value in + self.upperText = Self.filteredIntegerText(value) + } + .onSubmit { self.commit() } + + Text("Lower") + .font(.footnote) + .foregroundStyle(.secondary) + + TextField("20", text: self.$lowerText) + .textFieldStyle(.roundedBorder) + .font(.footnote) + .frame(width: 56) + .onChange(of: self.lowerText) { _, value in + self.lowerText = Self.filteredIntegerText(value) + } + .onSubmit { self.commit() } + + Button("Apply") { self.commit() } + .controlSize(.small) + } + + if !self.subtitle.isEmpty { + Text(self.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .onAppear { self.updateText(from: self.thresholds()) } + .onChange(of: self.thresholds()) { _, value in + self.updateText(from: value) + } + } + + private func commit() { + let sanitized = QuotaWarningThresholds.resolved( + upper: Self.integer(from: self.upperText), + lower: Self.integer(from: self.lowerText)) + self.updateText(from: sanitized) + self.setThresholds(sanitized) + } + + private func updateText(from thresholds: [Int]) { + let pair = Self.pair(from: thresholds) + self.upperText = pair.upper.map(String.init) ?? "" + self.lowerText = pair.lower.map(String.init) ?? "" + } + + private static func pair(from thresholds: [Int]) -> (upper: Int?, lower: Int?) { + let sanitized = QuotaWarningThresholds.sanitized(thresholds) + return (sanitized.first, sanitized.dropFirst().first) + } + + private static func integer(from text: String) -> Int? { + guard !text.isEmpty else { return nil } + return Int(text) + } + + private static func filteredIntegerText(_ text: String) -> String { + String(text.filter(\.isNumber).prefix(2)) + } +} diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 962b5a61fa..67d6d986da 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation @preconcurrency import UserNotifications @@ -8,6 +9,12 @@ enum SessionQuotaTransition: Equatable { case restored } +struct QuotaWarningEvent: Equatable, Sendable { + let window: QuotaWarningWindow + let threshold: Int + let currentRemaining: Double +} + enum SessionQuotaNotificationLogic { static let depletedThreshold: Double = 0.0001 @@ -29,9 +36,57 @@ enum SessionQuotaNotificationLogic { } } +enum QuotaWarningNotificationLogic { + static func notificationCopy( + providerName: String, + window: QuotaWarningWindow, + threshold: Int, + currentRemaining: Double) -> (title: String, body: String) + { + let windowLabel = window.displayName + let remainingText = Self.percentText(currentRemaining) + return ( + "\(providerName) \(windowLabel) quota low", + "\(remainingText) left. Reached your \(threshold)% \(windowLabel) warning threshold.") + } + + static func crossedThreshold( + previousRemaining: Double?, + currentRemaining: Double, + thresholds: [Int], + alreadyFired: Set) -> Int? + { + let sanitized = QuotaWarningThresholds.active(thresholds) + let eligible = sanitized.filter { threshold in + currentRemaining <= Double(threshold) && !alreadyFired.contains(threshold) + } + guard !eligible.isEmpty else { return nil } + + if let previousRemaining { + let crossed = eligible.filter { previousRemaining > Double($0) } + return crossed.min() + } + + return eligible.min() + } + + static func firedThresholdsAfterWarning(threshold: Int, thresholds: [Int]) -> Set { + Set(QuotaWarningThresholds.active(thresholds).filter { $0 >= threshold }) + } + + static func thresholdsToClear(currentRemaining: Double, alreadyFired: Set) -> Set { + Set(alreadyFired.filter { currentRemaining > Double($0) }) + } + + private static func percentText(_ value: Double) -> String { + "\(Int(min(100, max(0, value)).rounded()))%" + } +} + @MainActor protocol SessionQuotaNotifying: AnyObject { func post(transition: SessionQuotaTransition, provider: UsageProvider, badge: NSNumber?) + func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool) } @MainActor @@ -60,4 +115,27 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { self.logger.info("enqueuing", metadata: ["prefix": idPrefix]) AppNotifications.shared.post(idPrefix: idPrefix, title: title, body: body, badge: badge) } + + func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool = true) { + let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + let threshold = event.threshold + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: providerName, + window: event.window, + threshold: threshold, + currentRemaining: event.currentRemaining) + let idPrefix = "quota-warning-\(provider.rawValue)-\(event.window.rawValue)-\(threshold)" + self.logger.info("enqueuing", metadata: ["prefix": idPrefix]) + if soundEnabled { + (NSSound(named: "Glass") ?? NSSound(named: "Ping"))?.play() + } + NotificationCenter.default.post( + name: .codexbarQuotaWarningDidPost, + object: QuotaWarningPostedEvent( + provider: provider, + window: event.window, + threshold: threshold, + postedAt: Date())) + AppNotifications.shared.post(idPrefix: idPrefix, title: copy.title, body: copy.body, soundEnabled: false) + } } diff --git a/Sources/CodexBar/SettingsStore+Config.swift b/Sources/CodexBar/SettingsStore+Config.swift index 8195200e0f..578945ceba 100644 --- a/Sources/CodexBar/SettingsStore+Config.swift +++ b/Sources/CodexBar/SettingsStore+Config.swift @@ -6,6 +6,82 @@ extension SettingsStore { self.configSnapshot.providerConfig(for: provider) } + func quotaWarningConfig(for provider: UsageProvider) -> QuotaWarningConfig { + self.configSnapshot.providerConfig(for: provider)?.quotaWarnings ?? QuotaWarningConfig() + } + + func resolvedQuotaWarningThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int] { + self.quotaWarningConfig(for: provider).thresholds(for: window, global: self.quotaWarningThresholds) + } + + func quotaWarningEnabled(provider: UsageProvider, window: QuotaWarningWindow) -> Bool { + self.quotaWarningConfig(for: provider).isEnabled( + for: window, + global: self.quotaWarningWindowEnabled(window)) + } + + func hasQuotaWarningOverride(provider: UsageProvider, window: QuotaWarningWindow) -> Bool { + self.quotaWarningConfig(for: provider).hasOverride(for: window) + } + + func setQuotaWarningThresholds(provider: UsageProvider, window: QuotaWarningWindow, thresholds: [Int]?) { + self.updateProviderConfig(provider: provider) { entry in + var config = entry.quotaWarnings ?? QuotaWarningConfig() + switch window { + case .session: + var windowConfig = config.session ?? QuotaWarningWindowConfig() + windowConfig.thresholds = thresholds.map(QuotaWarningThresholds.sanitized) + config.session = windowConfig.hasOverride ? windowConfig : nil + case .weekly: + var windowConfig = config.weekly ?? QuotaWarningWindowConfig() + windowConfig.thresholds = thresholds.map(QuotaWarningThresholds.sanitized) + config.weekly = windowConfig.hasOverride ? windowConfig : nil + } + entry.quotaWarnings = config.isEmpty ? nil : config + } + } + + func setQuotaWarningOverride( + provider: UsageProvider, + window: QuotaWarningWindow, + thresholds: [Int]?, + enabled: Bool?) + { + self.updateProviderConfig(provider: provider) { entry in + var config = entry.quotaWarnings ?? QuotaWarningConfig() + switch window { + case .session: + var windowConfig = config.session ?? QuotaWarningWindowConfig() + windowConfig.thresholds = thresholds.map(QuotaWarningThresholds.sanitized) + windowConfig.enabled = enabled + config.session = windowConfig.hasOverride ? windowConfig : nil + case .weekly: + var windowConfig = config.weekly ?? QuotaWarningWindowConfig() + windowConfig.thresholds = thresholds.map(QuotaWarningThresholds.sanitized) + windowConfig.enabled = enabled + config.weekly = windowConfig.hasOverride ? windowConfig : nil + } + entry.quotaWarnings = config.isEmpty ? nil : config + } + } + + func setQuotaWarningWindowEnabled(provider: UsageProvider, window: QuotaWarningWindow, enabled: Bool?) { + self.updateProviderConfig(provider: provider) { entry in + var config = entry.quotaWarnings ?? QuotaWarningConfig() + switch window { + case .session: + var windowConfig = config.session ?? QuotaWarningWindowConfig() + windowConfig.enabled = enabled + config.session = windowConfig.hasOverride ? windowConfig : nil + case .weekly: + var windowConfig = config.weekly ?? QuotaWarningWindowConfig() + windowConfig.enabled = enabled + config.weekly = windowConfig.hasOverride ? windowConfig : nil + } + entry.quotaWarnings = config.isEmpty ? nil : config + } + } + var tokenAccountsByProvider: [UsageProvider: ProviderTokenAccountData] { get { Dictionary(uniqueKeysWithValues: self.configSnapshot.providers.compactMap { entry in diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 90581c1ced..3012953eff 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -103,6 +103,51 @@ extension SettingsStore { } } + var quotaWarningNotificationsEnabled: Bool { + get { self.defaultsState.quotaWarningNotificationsEnabled } + set { + self.defaultsState.quotaWarningNotificationsEnabled = newValue + self.userDefaults.set(newValue, forKey: "quotaWarningNotificationsEnabled") + } + } + + var quotaWarningThresholds: [Int] { + get { QuotaWarningThresholds.sanitized(self.defaultsState.quotaWarningThresholdsRaw) } + set { + let sanitized = QuotaWarningThresholds.sanitized(newValue) + self.defaultsState.quotaWarningThresholdsRaw = sanitized + self.userDefaults.set(sanitized, forKey: "quotaWarningThresholds") + } + } + + func quotaWarningWindowEnabled(_ window: QuotaWarningWindow) -> Bool { + switch window { + case .session: + self.defaultsState.quotaWarningSessionEnabled + case .weekly: + self.defaultsState.quotaWarningWeeklyEnabled + } + } + + func setQuotaWarningWindowEnabled(_ window: QuotaWarningWindow, enabled: Bool) { + switch window { + case .session: + self.defaultsState.quotaWarningSessionEnabled = enabled + self.userDefaults.set(enabled, forKey: "quotaWarningSessionEnabled") + case .weekly: + self.defaultsState.quotaWarningWeeklyEnabled = enabled + self.userDefaults.set(enabled, forKey: "quotaWarningWeeklyEnabled") + } + } + + var quotaWarningSoundEnabled: Bool { + get { self.defaultsState.quotaWarningSoundEnabled } + set { + self.defaultsState.quotaWarningSoundEnabled = newValue + self.userDefaults.set(newValue, forKey: "quotaWarningSoundEnabled") + } + } + var usageBarsShowUsed: Bool { get { self.defaultsState.usageBarsShowUsed } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 682117cab3..43739bdc3e 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -11,6 +11,11 @@ extension SettingsStore { _ = self.debugKeepCLISessionsAlive _ = self.statusChecksEnabled _ = self.sessionQuotaNotificationsEnabled + _ = self.quotaWarningNotificationsEnabled + _ = self.quotaWarningThresholds + _ = self.quotaWarningWindowEnabled(.session) + _ = self.quotaWarningWindowEnabled(.weekly) + _ = self.quotaWarningSoundEnabled _ = self.usageBarsShowUsed _ = self.resetTimesShowAbsolute _ = self.menuBarShowsBrandIconWithPercent diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 6306d8078e..3648c1ab0d 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -266,6 +266,7 @@ extension SettingsStore { if Self.isRunningTests, sessionQuotaDefault == nil { userDefaults.set(true, forKey: "sessionQuotaNotificationsEnabled") } + let quotaWarnings = Self.loadQuotaWarningDefaults(userDefaults: userDefaults) let usageBarsShowUsed = userDefaults.object(forKey: "usageBarsShowUsed") as? Bool ?? false let resetTimesShowAbsolute = userDefaults.object(forKey: "resetTimesShowAbsolute") as? Bool ?? false let menuBarShowsBrandIconWithPercent = userDefaults.object( @@ -334,6 +335,11 @@ extension SettingsStore { debugKeepCLISessionsAlive: debugKeepCLISessionsAlive, statusChecksEnabled: statusChecksEnabled, sessionQuotaNotificationsEnabled: sessionQuotaNotificationsEnabled, + quotaWarningNotificationsEnabled: quotaWarnings.notificationsEnabled, + quotaWarningThresholdsRaw: quotaWarnings.thresholdsRaw, + quotaWarningSessionEnabled: quotaWarnings.sessionEnabled, + quotaWarningWeeklyEnabled: quotaWarnings.weeklyEnabled, + quotaWarningSoundEnabled: quotaWarnings.soundEnabled, usageBarsShowUsed: usageBarsShowUsed, resetTimesShowAbsolute: resetTimesShowAbsolute, menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent, @@ -362,6 +368,48 @@ extension SettingsStore { selectedMenuProviderRaw: selectedMenuProviderRaw, providerDetectionCompleted: providerDetectionCompleted) } + + private struct LoadedQuotaWarningDefaults { + var notificationsEnabled: Bool + var thresholdsRaw: [Int] + var sessionEnabled: Bool + var weeklyEnabled: Bool + var soundEnabled: Bool + } + + private static func loadQuotaWarningDefaults(userDefaults: UserDefaults) -> LoadedQuotaWarningDefaults { + let notificationsEnabled = userDefaults.object(forKey: "quotaWarningNotificationsEnabled") as? Bool ?? false + let rawThresholds = userDefaults.array(forKey: "quotaWarningThresholds") as? [Int] + let thresholdsRaw = QuotaWarningThresholds.sanitized(rawThresholds ?? QuotaWarningThresholds.defaults) + if Self.isRunningTests, rawThresholds != thresholdsRaw { + userDefaults.set(thresholdsRaw, forKey: "quotaWarningThresholds") + } + + let sessionDefault = userDefaults.object(forKey: "quotaWarningSessionEnabled") as? Bool + let sessionEnabled = sessionDefault ?? true + if Self.isRunningTests, sessionDefault == nil { + userDefaults.set(true, forKey: "quotaWarningSessionEnabled") + } + + let weeklyDefault = userDefaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool + let weeklyEnabled = weeklyDefault ?? true + if Self.isRunningTests, weeklyDefault == nil { + userDefaults.set(true, forKey: "quotaWarningWeeklyEnabled") + } + + let soundDefault = userDefaults.object(forKey: "quotaWarningSoundEnabled") as? Bool + let soundEnabled = soundDefault ?? true + if Self.isRunningTests, soundDefault == nil { + userDefaults.set(true, forKey: "quotaWarningSoundEnabled") + } + + return LoadedQuotaWarningDefaults( + notificationsEnabled: notificationsEnabled, + thresholdsRaw: thresholdsRaw, + sessionEnabled: sessionEnabled, + weeklyEnabled: weeklyEnabled, + soundEnabled: soundEnabled) + } } extension SettingsStore { diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 8db7d29b79..0686963c3a 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -11,6 +11,11 @@ struct SettingsDefaultsState { var debugKeepCLISessionsAlive: Bool var statusChecksEnabled: Bool var sessionQuotaNotificationsEnabled: Bool + var quotaWarningNotificationsEnabled: Bool + var quotaWarningThresholdsRaw: [Int] + var quotaWarningSessionEnabled: Bool + var quotaWarningWeeklyEnabled: Bool + var quotaWarningSoundEnabled: Bool var usageBarsShowUsed: Bool var resetTimesShowAbsolute: Bool var menuBarShowsBrandIconWithPercent: Bool diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 507c54ead4..39cce2c064 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -232,6 +232,7 @@ extension StatusItemController { let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent let primaryProvider = self.primaryProviderForUnifiedIcon() let snapshot = self.store.snapshot(for: primaryProvider) + let warningFlash = self.quotaWarningFlashActive(provider: primaryProvider) // IconRenderer treats these values as a left-to-right "progress fill" percentage; depending on the // user setting we pass either "percent left" or "percent used". @@ -325,12 +326,13 @@ extension StatusItemController { "stale=\(stale ? "1" : "0")", "status=\(statusIndicator.rawValue)", "text=\(displayText ?? "nil")", + "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { return true } - self.setButtonImage(brand, for: button) + self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) self.setButtonTitle(displayText, for: button) return false } @@ -343,13 +345,14 @@ extension StatusItemController { "style=\(String(describing: style))", "morph=\(debugDouble(morphProgress))", "status=\(statusIndicator.rawValue)", + "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { return true } let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) - self.setButtonImage(image, for: button) + self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) } else { let signature = [ "mode=icon", @@ -363,6 +366,7 @@ extension StatusItemController { "blink=\(debugDouble(Double(blink)))", "wiggle=\(debugDouble(Double(wiggle)))", "tilt=\(debugDouble(Double(tilt)))", + "warningFlash=\(warningFlash ? "1" : "0")", "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { @@ -378,7 +382,7 @@ extension StatusItemController { wiggle: wiggle, tilt: tilt, statusIndicator: statusIndicator) - self.setButtonImage(image, for: button) + self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) } return false } @@ -403,12 +407,13 @@ extension StatusItemController { let showUsed = self.settings.usageBarsShowUsed let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent let style: IconStyle = self.store.style(for: provider) + let warningFlash = self.quotaWarningFlashActive(provider: provider) if showBrandPercent, let brand = ProviderBrandIcon.image(for: provider) { let displayText = self.menuBarDisplayText(for: provider, snapshot: snapshot) - self.setButtonImage(brand, for: button) + self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: brand) : brand, for: button) self.setButtonTitle(displayText, for: button) return } @@ -484,7 +489,7 @@ extension StatusItemController { let tilt = self.tiltAmount(for: provider) * .pi / 28 // limit to ~6.4° if let morphProgress { let image = IconRenderer.makeMorphIcon(progress: morphProgress, style: style) - self.setButtonImage(image, for: button) + self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) } else { self.setButtonTitle(nil, for: button) let image = IconRenderer.makeIcon( @@ -497,10 +502,33 @@ extension StatusItemController { wiggle: wiggle, tilt: tilt, statusIndicator: self.store.statusIndicator(for: provider)) - self.setButtonImage(image, for: button) + self.setButtonImage(warningFlash ? Self.quotaWarningFlashImage(base: image) : image, for: button) } } + func quotaWarningFlashActive(provider: UsageProvider, now: Date = Date()) -> Bool { + guard let until = self.quotaWarningFlashUntil[provider] else { return false } + if until > now { return true } + self.quotaWarningFlashUntil.removeValue(forKey: provider) + self.quotaWarningFlashTasks[provider]?.cancel() + self.quotaWarningFlashTasks.removeValue(forKey: provider) + return false + } + + static func quotaWarningFlashImage(base: NSImage) -> NSImage { + let image = NSImage(size: base.size) + image.lockFocus() + let rect = NSRect(origin: .zero, size: base.size) + NSColor.systemRed.withAlphaComponent(0.22).setFill() + NSBezierPath(roundedRect: rect.insetBy(dx: 1, dy: 1), xRadius: 4, yRadius: 4).fill() + base.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1) + NSColor.systemRed.withAlphaComponent(0.28).setFill() + NSBezierPath(rect: rect).fill() + image.unlockFocus() + image.isTemplate = false + return image + } + private func setButtonImage(_ image: NSImage, for button: NSStatusBarButton) { if button.image === image { return } button.image = image diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 327f26723e..79e848d059 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1594,10 +1594,19 @@ extension StatusItemController { hidePersonalInfo: self.settings.hidePersonalInfo, claudePeakHoursEnabled: self.settings.claudePeakHoursEnabled, weeklyPace: weeklyPace, + quotaWarningThresholds: [ + .session: self.quotaWarningMarkerThresholds(provider: target, window: .session), + .weekly: self.quotaWarningMarkerThresholds(provider: target, window: .weekly), + ], now: now) return UsageMenuCardView.Model.make(input) } + private func quotaWarningMarkerThresholds(provider: UsageProvider, window: QuotaWarningWindow) -> [Int] { + guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { return [] } + return self.settings.resolvedQuotaWarningThresholds(provider: provider, window: window) + } + @objc private func menuCardNoOp(_ sender: NSMenuItem) { _ = sender } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 2226265119..2bb64e7351 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -23,6 +23,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin static var menuCardRenderingEnabled = !SettingsStore.isRunningTests private static let defaultMenuRefreshEnabled = !SettingsStore.isRunningTests private(set) static var menuRefreshEnabled = !SettingsStore.isRunningTests + static let quotaWarningFlashDuration: TimeInterval = 60 #if DEBUG static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { self.menuRefreshEnabled = enabled @@ -110,6 +111,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var blinkAmounts: [UsageProvider: CGFloat] = [:] var wiggleAmounts: [UsageProvider: CGFloat] = [:] var tiltAmounts: [UsageProvider: CGFloat] = [:] + var quotaWarningFlashUntil: [UsageProvider: Date] = [:] + var quotaWarningFlashTasks: [UsageProvider: Task] = [:] var blinkForceUntil: Date? var loginPhase: LoginPhase = .idle { didSet { @@ -271,6 +274,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin selector: #selector(self.handleDebugBlinkNotification), name: .codexbarDebugBlinkNow, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.handleQuotaWarningPosted(_:)), + name: .codexbarQuotaWarningDidPost, + object: nil) if observeProviderConfigNotifications { NotificationCenter.default.addObserver( self, @@ -380,6 +388,31 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.handleProviderConfigChange(reason: "notification:\(reason)") } + @objc private func handleQuotaWarningPosted(_ notification: Notification) { + guard let event = notification.object as? QuotaWarningPostedEvent else { return } + self.startQuotaWarningFlash(provider: event.provider, postedAt: event.postedAt) + } + + func startQuotaWarningFlash(provider: UsageProvider, postedAt: Date = Date()) { + let until = postedAt.addingTimeInterval(Self.quotaWarningFlashDuration) + self.quotaWarningFlashUntil[provider] = until + self.quotaWarningFlashTasks[provider]?.cancel() + self.updateIcons() + self.quotaWarningFlashTasks[provider] = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.quotaWarningFlashDuration)) + await MainActor.run { [weak self] in + guard let self else { return } + if let currentUntil = self.quotaWarningFlashUntil[provider], + currentUntil <= Date() + { + self.quotaWarningFlashUntil.removeValue(forKey: provider) + self.quotaWarningFlashTasks.removeValue(forKey: provider) + self.updateIcons() + } + } + } + } + private func observeUpdaterChanges() { withObservationTracking { _ = self.updater.updateStatus.isUpdateReady diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index 58d95475e9..fbb344e329 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -17,6 +17,7 @@ struct UsageProgressBar: View { let accessibilityLabel: String let pacePercent: Double? let paceOnTop: Bool + let warningMarkerPercents: [Double] @Environment(\.menuItemHighlighted) private var isHighlighted @Environment(\.displayScale) private var displayScale @@ -25,13 +26,15 @@ struct UsageProgressBar: View { tint: Color, accessibilityLabel: String, pacePercent: Double? = nil, - paceOnTop: Bool = true) + paceOnTop: Bool = true, + warningMarkerPercents: [Double] = []) { self.percent = percent self.tint = tint self.accessibilityLabel = accessibilityLabel self.pacePercent = pacePercent self.paceOnTop = paceOnTop + self.warningMarkerPercents = warningMarkerPercents } private var clamped: Double { @@ -51,6 +54,9 @@ struct UsageProgressBar: View { let stripeInset = 1 / scale let tipOffset = paceWidth - tipWidth + (Self.paceStripeSpan(for: scale) / 2) + stripeInset let showTip = self.pacePercent != nil && tipWidth > 0.5 + let markerPercents = self.warningMarkerPercents + .map(Self.clampedPercent) + .filter { $0 > 0 && $0 < 100 } let cornerRadius = size.height / 2 let cornerSize = CGSize(width: cornerRadius, height: cornerRadius) @@ -71,6 +77,20 @@ struct UsageProgressBar: View { with: .color(MenuHighlightStyle.progressTint(self.isHighlighted, fallback: self.tint))) } + if !markerPercents.isEmpty { + let markerWidth = max(1 / scale, 2) + let markerColor: Color = self.isHighlighted ? .white : .primary.opacity(0.72) + for markerPercent in markerPercents { + let x = size.width * markerPercent / 100 + let markerRect = CGRect( + x: x - markerWidth / 2, + y: 0, + width: markerWidth, + height: size.height) + context.fill(Path(markerRect), with: .color(markerColor)) + } + } + // Pace tip: punch-out + center stripe drawn within the canvas context using Core Graphics // blend modes so no SwiftUI compositing modifier (.blendMode, .compositingGroup) is needed. if showTip { diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index ad30cc3ab8..8412d52dc2 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -33,6 +33,7 @@ extension UsageStore { self.statuses.removeValue(forKey: provider) self.lastKnownSessionRemaining.removeValue(forKey: provider) self.lastKnownSessionWindowSource.removeValue(forKey: provider) + self.quotaWarningState = self.quotaWarningState.filter { $0.key.provider != provider } self.lastTokenFetchAt.removeValue(forKey: provider) } return @@ -95,6 +96,7 @@ extension UsageStore { } let backfilled = await MainActor.run { let backfilled = scoped.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) + self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) self.lastKnownResetSnapshots[provider] = backfilled self.snapshots[provider] = backfilled diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 4c46c03395..201545c1db 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -259,6 +259,7 @@ extension UsageStore { } let backfilled = await MainActor.run { let backfilled = labeled.backfillingResetTimes(from: self.lastKnownResetSnapshots[provider]) + self.handleQuotaWarningTransitions(provider: provider, snapshot: backfilled) self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) self.lastKnownResetSnapshots[provider] = backfilled self.snapshots[provider] = backfilled diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index f50f7e30a6..43a8b4cd7a 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -53,6 +53,9 @@ extension UsageStore { _ = self.settings.refreshFrequency _ = self.settings.statusChecksEnabled _ = self.settings.sessionQuotaNotificationsEnabled + _ = self.settings.quotaWarningNotificationsEnabled + _ = self.settings.quotaWarningThresholds + _ = self.settings.quotaWarningSoundEnabled _ = self.settings.usageBarsShowUsed _ = self.settings.costUsageEnabled _ = self.settings.randomBlinkEnabled @@ -213,6 +216,7 @@ final class UsageStore { @ObservationIgnored var lastKnownResetSnapshots: [UsageProvider: UsageSnapshot] = [:] @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] + @ObservationIgnored var quotaWarningState: [QuotaWarningStateKey: QuotaWarningState] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] @ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] @ObservationIgnored var weeklyLimitResetDetectorStates: [String: WeeklyLimitResetDetectorState] = [:] @@ -618,6 +622,16 @@ final class UsageStore { case copilotSecondaryFallback } + struct QuotaWarningStateKey: Hashable { + let provider: UsageProvider + let window: QuotaWarningWindow + } + + struct QuotaWarningState { + var lastRemaining: Double? + var firedThresholds: Set = [] + } + private func sessionQuotaWindow( provider: UsageProvider, snapshot: UsageSnapshot) -> (window: RateWindow, source: SessionQuotaWindowSource)? @@ -713,6 +727,58 @@ final class UsageStore { self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) } + func handleQuotaWarningTransitions(provider: UsageProvider, snapshot: UsageSnapshot) { + guard self.settings.quotaWarningNotificationsEnabled else { return } + + self.handleQuotaWarningTransition(provider: provider, window: .session, rateWindow: snapshot.primary) + self.handleQuotaWarningTransition(provider: provider, window: .weekly, rateWindow: snapshot.secondary) + } + + private func handleQuotaWarningTransition( + provider: UsageProvider, + window: QuotaWarningWindow, + rateWindow: RateWindow?) + { + let key = QuotaWarningStateKey(provider: provider, window: window) + guard self.settings.quotaWarningEnabled(provider: provider, window: window) else { + self.quotaWarningState.removeValue(forKey: key) + return + } + guard let rateWindow else { + self.quotaWarningState.removeValue(forKey: key) + return + } + + let thresholds = self.settings.resolvedQuotaWarningThresholds(provider: provider, window: window) + let currentRemaining = rateWindow.remainingPercent + var state = self.quotaWarningState[key] ?? QuotaWarningState() + let cleared = QuotaWarningNotificationLogic.thresholdsToClear( + currentRemaining: currentRemaining, + alreadyFired: state.firedThresholds) + state.firedThresholds.subtract(cleared) + + if let threshold = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: state.lastRemaining, + currentRemaining: currentRemaining, + thresholds: thresholds, + alreadyFired: state.firedThresholds) + { + state.firedThresholds.formUnion(QuotaWarningNotificationLogic.firedThresholdsAfterWarning( + threshold: threshold, + thresholds: thresholds)) + self.sessionQuotaNotifier.postQuotaWarning( + event: QuotaWarningEvent( + window: window, + threshold: threshold, + currentRemaining: currentRemaining), + provider: provider, + soundEnabled: self.settings.quotaWarningSoundEnabled) + } + + state.lastRemaining = currentRemaining + self.quotaWarningState[key] = state + } + private func refreshStatus(_ provider: UsageProvider) async { guard self.settings.statusChecksEnabled else { return } guard let meta = self.providerMetadata[provider] else { return } diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index 43cb2382ad..c77598a1ea 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -85,6 +85,7 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { public var enterpriseHost: String? public var tokenAccounts: ProviderTokenAccountData? public var codexActiveSource: CodexActiveSource? + public var quotaWarnings: QuotaWarningConfig? public init( id: UsageProvider, @@ -98,7 +99,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { workspaceID: String? = nil, enterpriseHost: String? = nil, tokenAccounts: ProviderTokenAccountData? = nil, - codexActiveSource: CodexActiveSource? = nil) + codexActiveSource: CodexActiveSource? = nil, + quotaWarnings: QuotaWarningConfig? = nil) { self.id = id self.enabled = enabled @@ -112,6 +114,7 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.enterpriseHost = enterpriseHost self.tokenAccounts = tokenAccounts self.codexActiveSource = codexActiveSource + self.quotaWarnings = quotaWarnings } public var sanitizedAPIKey: String? { @@ -140,3 +143,109 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { return value.isEmpty ? nil : value } } + +public enum QuotaWarningWindow: String, Codable, Sendable, CaseIterable { + case session + case weekly + + public var displayName: String { + switch self { + case .session: + "session" + case .weekly: + "weekly" + } + } +} + +public struct QuotaWarningWindowConfig: Codable, Sendable, Equatable { + public var thresholds: [Int]? + public var enabled: Bool? + + public init(thresholds: [Int]? = nil, enabled: Bool? = nil) { + self.thresholds = thresholds.map(QuotaWarningThresholds.sanitized) + self.enabled = enabled + } + + public var hasOverride: Bool { + self.thresholds != nil || self.enabled != nil + } + + public func isEnabled(global: Bool) -> Bool { + self.enabled ?? (self.thresholds != nil ? true : global) + } +} + +public struct QuotaWarningConfig: Codable, Sendable, Equatable { + public var session: QuotaWarningWindowConfig? + public var weekly: QuotaWarningWindowConfig? + + public init( + session: QuotaWarningWindowConfig? = nil, + weekly: QuotaWarningWindowConfig? = nil) + { + self.session = session + self.weekly = weekly + } + + public func thresholds(for window: QuotaWarningWindow, global: [Int]) -> [Int] { + switch window { + case .session: + QuotaWarningThresholds.sanitized(self.session?.thresholds ?? global) + case .weekly: + QuotaWarningThresholds.sanitized(self.weekly?.thresholds ?? global) + } + } + + public func isEnabled(for window: QuotaWarningWindow, global: Bool) -> Bool { + switch window { + case .session: + self.session?.isEnabled(global: global) ?? global + case .weekly: + self.weekly?.isEnabled(global: global) ?? global + } + } + + public func hasOverride(for window: QuotaWarningWindow) -> Bool { + switch window { + case .session: + self.session?.hasOverride ?? false + case .weekly: + self.weekly?.hasOverride ?? false + } + } + + public var isEmpty: Bool { + self.session?.hasOverride != true && self.weekly?.hasOverride != true + } +} + +public enum QuotaWarningThresholds { + public static let defaults = [50, 20] + public static let allowedRange = 0...99 + + public static func sanitized(_ raw: [Int]) -> [Int] { + guard !raw.isEmpty else { return self.defaults } + + let unique = Set(raw.map(self.clamped)) + let sorted = unique.sorted(by: >) + return sorted.isEmpty ? self.defaults : sorted + } + + public static func active(_ raw: [Int]) -> [Int] { + self.sanitized(raw).filter { $0 > 0 } + } + + public static func resolved(upper: Int?, lower: Int?) -> [Int] { + guard upper != nil || lower != nil else { return self.defaults } + + let resolvedUpper = self.clamped(upper ?? self.defaults[0]) + let lowerDefault = resolvedUpper < self.defaults[1] ? 0 : self.defaults[1] + let resolvedLower = self.clamped(lower ?? lowerDefault) + return self.sanitized([resolvedUpper, resolvedLower]) + } + + public static func clamped(_ value: Int) -> Int { + min(max(value, self.allowedRange.lowerBound), self.allowedRange.upperBound) + } +} diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 2a5fb19120..4a3b08e9a2 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -54,6 +54,7 @@ public enum LogCategories { public static let perplexityWeb = "perplexity-web" public static let providerDetection = "provider-detection" public static let providers = "providers" + public static let quotaWarningNotifications = "quotaWarningNotifications" public static let sessionQuota = "sessionQuota" public static let sessionQuotaNotifications = "sessionQuotaNotifications" public static let settings = "settings" diff --git a/Tests/CodexBarTests/CodexActiveSourceConfigTests.swift b/Tests/CodexBarTests/CodexActiveSourceConfigTests.swift index 85ef3a406d..d9d5f256ec 100644 --- a/Tests/CodexBarTests/CodexActiveSourceConfigTests.swift +++ b/Tests/CodexBarTests/CodexActiveSourceConfigTests.swift @@ -22,6 +22,52 @@ struct CodexActiveSourceConfigTests { from: Data(legacyJSON.utf8)) #expect(decoded.providerConfig(for: .codex)?.codexActiveSource == nil) + #expect(decoded.providerConfig(for: .codex)?.quotaWarnings == nil) + } + + @Test + func `provider config round trips quota warning overrides`() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .codex, + quotaWarnings: QuotaWarningConfig( + session: QuotaWarningWindowConfig(thresholds: [10]), + weekly: QuotaWarningWindowConfig(thresholds: [50, 20]))), + ]) + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(CodexBarConfig.self, from: data) + let quotaWarnings = try #require(decoded.providerConfig(for: .codex)?.quotaWarnings) + + #expect(quotaWarnings.thresholds(for: .session, global: [80]) == [10]) + #expect(quotaWarnings.thresholds(for: .weekly, global: [80]) == [50, 20]) + } + + @Test + func `quota warning window enabled defaults stay backward compatible`() throws { + let legacyJSON = """ + { + "version": 1, + "providers": [ + { + "id": "codex", + "quotaWarnings": { + "session": { "thresholds": [10] }, + "weekly": { "enabled": false } + } + } + ] + } + """ + + let decoded = try JSONDecoder().decode(CodexBarConfig.self, from: Data(legacyJSON.utf8)) + let quotaWarnings = try #require(decoded.providerConfig(for: .codex)?.quotaWarnings) + + #expect(quotaWarnings.isEnabled(for: .session, global: false) == true) + #expect(quotaWarnings.isEnabled(for: .weekly, global: true) == false) + #expect(quotaWarnings.hasOverride(for: .session) == true) + #expect(quotaWarnings.hasOverride(for: .weekly) == true) } @Test diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 7b4cac5bad..a387fcf016 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -201,11 +201,14 @@ struct MenuCardModelTests { tokenCostUsageEnabled: false, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [25, 0]], now: now)) #expect(model.providerName == "Codex") #expect(model.metrics.count == 2) #expect(model.metrics.first?.percent == 78) + #expect(model.metrics.first?.warningMarkerPercents == [50, 20]) + #expect(model.metrics[1].warningMarkerPercents == [25]) #expect(model.planText == "Plus") #expect(model.subtitleText.hasPrefix("Updated")) #expect(model.progressColor != Color.clear) diff --git a/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift new file mode 100644 index 0000000000..f3bac6a62d --- /dev/null +++ b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift @@ -0,0 +1,68 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardQuotaWarningMarkerTests { + @Test + func `omits quota warning markers for disabled windows`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Plus Plan") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: now, + identity: identity) + let codexProjection = 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: codexProjection, + 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: false, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50], .weekly: []], + now: now)) + + #expect(model.metrics.count == 2) + #expect(model.metrics.first?.warningMarkerPercents == [50]) + #expect(model.metrics[1].warningMarkerPercents.isEmpty) + } +} diff --git a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift new file mode 100644 index 0000000000..669ed72844 --- /dev/null +++ b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift @@ -0,0 +1,113 @@ +import Testing +@testable import CodexBar + +struct QuotaWarningNotificationLogicTests { + @Test + func `quota warning copy includes current remaining and threshold`() { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 20, + currentRemaining: 12.4) + + #expect(copy.title == "Codex session quota low") + #expect(copy.body == "12% left. Reached your 20% session warning threshold.") + } + + @Test + func `quota warning copy clamps current remaining`() { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .weekly, + threshold: 50, + currentRemaining: -3) + + #expect(copy.title == "Codex weekly quota low") + #expect(copy.body == "0% left. Reached your 50% weekly warning threshold.") + } + + @Test + func `does nothing without crossing`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 60, + currentRemaining: 55, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == nil) + } + + @Test + func `detects downward crossing`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 55, + currentRemaining: 45, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == 50) + } + + @Test + func `skips already fired thresholds`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 55, + currentRemaining: 45, + thresholds: [50, 20], + alreadyFired: [50]) + + #expect(crossed == nil) + } + + @Test + func `chooses most severe threshold when crossing several at once`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 80, + currentRemaining: 10, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == 20) + } + + @Test + func `startup below threshold warns once at most severe threshold`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: nil, + currentRemaining: 10, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == 20) + } + + @Test + func `warning marks threshold and higher thresholds fired`() { + let fired = QuotaWarningNotificationLogic.firedThresholdsAfterWarning( + threshold: 20, + thresholds: [50, 20]) + + #expect(fired == [50, 20]) + } + + @Test + func `recovery clears only thresholds below current remaining`() { + let cleared = QuotaWarningNotificationLogic.thresholdsToClear( + currentRemaining: 30, + alreadyFired: [50, 20]) + + #expect(cleared == [20]) + } + + @Test + func `zero threshold does not post quota warning`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 10, + currentRemaining: 0, + thresholds: [10, 0], + alreadyFired: [10]) + + #expect(crossed == nil) + #expect(QuotaWarningNotificationLogic.firedThresholdsAfterWarning(threshold: 10, thresholds: [10, 0]) == [10]) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 1fd8647348..0e1ca7dd72 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -517,6 +517,121 @@ struct SettingsStoreTests { #expect(defaults.bool(forKey: key) == true) } + @Test + func `defaults quota warnings to disabled with global thresholds and sound`() throws { + let suite = "SettingsStoreTests-quota-warning-defaults" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.quotaWarningNotificationsEnabled == false) + #expect(store.quotaWarningThresholds == [50, 20]) + #expect(store.quotaWarningWindowEnabled(.session) == true) + #expect(store.quotaWarningWindowEnabled(.weekly) == true) + #expect(store.quotaWarningSoundEnabled == true) + #expect(defaults.array(forKey: "quotaWarningThresholds") as? [Int] == [50, 20]) + #expect(defaults.object(forKey: "quotaWarningSessionEnabled") as? Bool == true) + #expect(defaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool == true) + #expect(defaults.bool(forKey: "quotaWarningSoundEnabled") == true) + } + + @Test + func `global quota warning windows persist independently`() throws { + let suite = "SettingsStoreTests-quota-warning-window-enabled" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.setQuotaWarningWindowEnabled(.weekly, enabled: false) + + #expect(store.quotaWarningWindowEnabled(.session) == true) + #expect(store.quotaWarningWindowEnabled(.weekly) == false) + #expect(defaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool == false) + } + + @Test + func `sanitizes invalid quota warning thresholds from defaults`() throws { + let suite = "SettingsStoreTests-quota-warning-sanitize" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set([120, 20, 20, -5, 50], forKey: "quotaWarningThresholds") + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.quotaWarningThresholds == [99, 50, 20, 0]) + #expect(defaults.array(forKey: "quotaWarningThresholds") as? [Int] == [99, 50, 20, 0]) + } + + @Test + func `quota warning threshold pair resolves blanks and clamps bounds`() { + #expect(QuotaWarningThresholds.resolved(upper: nil, lower: nil) == [50, 20]) + #expect(QuotaWarningThresholds.resolved(upper: nil, lower: 10) == [50, 10]) + #expect(QuotaWarningThresholds.resolved(upper: 10, lower: nil) == [10, 0]) + #expect(QuotaWarningThresholds.resolved(upper: 120, lower: -5) == [99, 0]) + } + + @Test + func `provider quota warning override resolves before global thresholds`() throws { + let suite = "SettingsStoreTests-quota-warning-provider-override" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + store.quotaWarningThresholds = [50, 20] + + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + store.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: [10]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [10]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .weekly) == [50, 20]) + + store.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: nil) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + } + + @Test + func `provider quota warning windows override global enablement independently`() throws { + let suite = "SettingsStoreTests-quota-warning-provider-window-override" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.setQuotaWarningWindowEnabled(.weekly, enabled: false) + #expect(store.quotaWarningEnabled(provider: .codex, window: .weekly) == false) + + store.setQuotaWarningWindowEnabled(provider: .codex, window: .weekly, enabled: true) + store.setQuotaWarningWindowEnabled(provider: .codex, window: .session, enabled: false) + #expect(store.quotaWarningEnabled(provider: .codex, window: .weekly) == true) + #expect(store.quotaWarningEnabled(provider: .codex, window: .session) == false) + #expect(store.hasQuotaWarningOverride(provider: .codex, window: .weekly) == true) + #expect(store.hasQuotaWarningOverride(provider: .codex, window: .session) == true) + + store.setQuotaWarningWindowEnabled(provider: .codex, window: .weekly, enabled: nil) + #expect(store.quotaWarningEnabled(provider: .codex, window: .weekly) == false) + } + @Test func `defaults claude usage source to auto`() throws { let suite = "SettingsStoreTests-claude-source" diff --git a/Tests/CodexBarTests/StatusItemQuotaWarningFlashTests.swift b/Tests/CodexBarTests/StatusItemQuotaWarningFlashTests.swift new file mode 100644 index 0000000000..abf10e6d43 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemQuotaWarningFlashTests.swift @@ -0,0 +1,98 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemQuotaWarningFlashTests { + private func makeStatusBarForTesting() -> NSStatusBar { + NSStatusBar.system + } + + @Test + func `quota warning flash state lasts for configured duration`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemQuotaWarningFlashTests-duration"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + 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 now = Date() + controller.startQuotaWarningFlash(provider: .codex, postedAt: now) + + #expect(controller.quotaWarningFlashActive(provider: .codex, now: now.addingTimeInterval(59)) == true) + #expect(controller.quotaWarningFlashActive(provider: .codex, now: now.addingTimeInterval(61)) == false) + } + + @Test + func `quota warning flash image draws non template red overlay`() throws { + let size = NSSize(width: 16, height: 16) + let base = NSImage(size: size) + base.lockFocus() + NSColor.black.setFill() + NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill() + base.unlockFocus() + base.isTemplate = true + + let output = StatusItemController.quotaWarningFlashImage(base: base) + let outputData = try #require(output.tiffRepresentation) + let outputRep = try #require(NSBitmapImageRep(data: outputData)) + let center = try #require(outputRep.colorAt(x: 8, y: 8)) + + #expect(output.isTemplate == false) + #expect(center.redComponent > center.blueComponent) + } + + @Test + func `merged icon render signature includes quota warning flash for selected provider`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemQuotaWarningFlashTests-merged"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) + } + settings.openRouterAPIToken = "or-token" + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + controller.startQuotaWarningFlash(provider: .codex) + + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=1") == true) + } +} diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index b4cb0b6f87..69d063b8d5 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -5,21 +5,36 @@ import Testing @MainActor struct UsageStoreSessionQuotaTransitionTests { + private func makeSettings(suiteName: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + @MainActor final class SessionQuotaNotifierSpy: SessionQuotaNotifying { private(set) var posts: [(transition: SessionQuotaTransition, provider: UsageProvider)] = [] + private(set) var quotaWarningPosts: [( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool)] = [] func post(transition: SessionQuotaTransition, provider: UsageProvider, badge _: NSNumber?) { self.posts.append((transition: transition, provider: provider)) } + + func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool) { + self.quotaWarningPosts.append((event: event, provider: provider, soundEnabled: soundEnabled)) + } } @Test func `copilot switch from primary to secondary resets baseline`() { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-primary-secondary"), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-primary-secondary") settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.sessionQuotaNotificationsEnabled = true @@ -48,10 +63,7 @@ struct UsageStoreSessionQuotaTransitionTests { @Test func `copilot switch from secondary to primary resets baseline`() { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-secondary-primary"), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-secondary-primary") settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.sessionQuotaNotificationsEnabled = true @@ -80,10 +92,7 @@ struct UsageStoreSessionQuotaTransitionTests { @Test func `claude weekly primary fallback does not emit session quota notifications`() { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-weekly"), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-weekly") settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.sessionQuotaNotificationsEnabled = true @@ -112,10 +121,7 @@ struct UsageStoreSessionQuotaTransitionTests { @Test func `claude five hour primary still emits session quota notifications`() { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-session"), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-session") settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.sessionQuotaNotificationsEnabled = true @@ -141,4 +147,275 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.posts.map(\.provider) == [.claude]) } + + @Test + func `quota warning disabled does not post`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-disabled") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = false + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 90, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleQuotaWarningTransitions(provider: .codex, snapshot: snapshot) + + #expect(notifier.quotaWarningPosts.isEmpty) + } + + @Test + func `quota warning posts once per downward threshold crossing`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-once") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(notifier.quotaWarningPosts.first?.event.window == .session) + #expect(notifier.quotaWarningPosts.first?.event.threshold == 50) + } + + @Test + func `quota warning crossing multiple thresholds posts most severe only`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-severe") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 85, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [20]) + } + + @Test + func `quota warning recovers and can fire again`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-recover") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + for used in [40, 55, 10, 55] { + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: Double(used), + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date())) + } + + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [50, 50]) + } + + @Test + func `quota warning provider override beats global thresholds`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-override") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + settings.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: [10]) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 95, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [10]) + } + + @Test + func `quota warning session only config ignores weekly crossings`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-session-only") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: false) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session]) + } + + @Test + func `quota warning weekly only config ignores session crossings`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-weekly-only") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: false) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.weekly]) + } + + @Test + func `disabling quota warning window clears fired state`() { + let settings = self + .makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-disabled-clears-state") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + settings.setQuotaWarningWindowEnabled(.session, enabled: false) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(store.quotaWarningState[UsageStore.QuotaWarningStateKey(provider: .codex, window: .session)] == nil) + } } diff --git a/docs/refactor/cli.md b/docs/refactor/cli.md index 0eadaa7021..f849cc8873 100644 --- a/docs/refactor/cli.md +++ b/docs/refactor/cli.md @@ -73,7 +73,6 @@ read_when: - Config validation (bad region/source/apiKey field). - SettingsStore order/toggle invariants still pass. 8. **Verification** - - `swift test`, `swiftformat Sources Tests`, `swiftlint --strict`, `pnpm check`. + - `swift test`, `swiftformat Sources Tests`, `swiftlint --strict`, `make check`. - `./Scripts/compile_and_run.sh`. - CLI e2e: `codexbar --json-only ...`, `codexbar config validate`. - diff --git a/package.json b/package.json deleted file mode 100644 index f7a9875067..0000000000 --- a/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "codexbar", - "private": true, - "scripts": { - "start": "./Scripts/compile_and_run.sh", - "start:debug": "./Scripts/compile_and_run.sh", - "start:release": "sh -c './Scripts/package_app.sh release && (pkill -x CodexBar || pkill -f CodexBar.app || true) && cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app'", - "lint": "./Scripts/lint.sh lint", - "format": "./Scripts/lint.sh format", - "check": "./Scripts/lint.sh lint", - "docs:list": "node Scripts/docs-list.mjs", - "build": "swift build", - "test": "swift test", - "test:tty": "swift test --filter TTYIntegrationTests", - "test:live": "LIVE_TEST=1 swift test --filter LiveAccountTests", - "release": "./Scripts/package_app.sh release", - "restart": "pnpm start", - "stop": "pkill -x CodexBar || pkill -f CodexBar.app || true" - } -}