Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions Sources/CodexBar/PreferencesGeneralPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 before quota runs out. Fires once per threshold per window.",
binding: self.$settings.quotaWarningNotificationsEnabled)
if self.settings.quotaWarningNotificationsEnabled {
QuotaWarningThresholdPicker(settings: self.settings)
}
}

Divider()
Expand Down Expand Up @@ -163,3 +170,35 @@ struct GeneralPane: View {
.foregroundStyle(.tertiary)
}
}

@MainActor
private struct QuotaWarningThresholdPicker: View {
@Bindable var settings: SettingsStore

private static let availableThresholds = [80, 50, 20, 10]

var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Warn at these remaining levels:")
.font(.footnote)
.foregroundStyle(.secondary)
HStack(spacing: 8) {
ForEach(Self.availableThresholds, id: \.self) { threshold in
Toggle("\(threshold)%", isOn: Binding(
get: { self.settings.quotaWarningThresholds.contains(threshold) },
set: { isOn in
var current = self.settings.quotaWarningThresholds
if isOn {
if !current.contains(threshold) { current.append(threshold) }
} else {
current.removeAll { $0 == threshold }
}
self.settings.quotaWarningThresholds = current.sorted(by: >)
}))
.toggleStyle(.checkbox)
}
}
}
.padding(.leading, 20)
}
}
67 changes: 67 additions & 0 deletions Sources/CodexBar/QuotaWarningNotifications.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import CodexBarCore
import Foundation

enum QuotaWarningWindow: String, Sendable {
case primary
case secondary
}

struct QuotaWarningEvent: Equatable, Sendable {
let threshold: Int
let window: QuotaWarningWindow
let currentRemaining: Double
}

enum QuotaWarningNotificationLogic {
/// Returns thresholds that were newly crossed downward.
/// A threshold is considered crossed when `previousRemaining > threshold` and
/// `currentRemaining <= threshold`, and the threshold has not already fired.
static func crossedThresholds(
previousRemaining: Double?,
currentRemaining: Double,
thresholds: [Int],
alreadyFired: Set<Int>
) -> [Int] {
guard let previousRemaining else { return [] }

var crossed: [Int] = []
for threshold in thresholds {
let t = Double(threshold)
if previousRemaining > t,
currentRemaining <= t,
!alreadyFired.contains(threshold)
{
crossed.append(threshold)
}
}
return crossed
}

/// Returns thresholds that should be cleared from `alreadyFired` because
/// the remaining percentage has risen back above them.
static func restoredThresholds(
currentRemaining: Double,
alreadyFired: Set<Int>
) -> Set<Int> {
Set(alreadyFired.filter { Double($0) < currentRemaining })
}
}

@MainActor
final class QuotaWarningNotifier {
private let logger = CodexBarLog.logger(LogCategories.quotaWarningNotifications)

init() {}

func post(event: QuotaWarningEvent, provider: UsageProvider) {
let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
let windowLabel = event.window == .primary ? "session" : "weekly"

let title = "\(providerName) \(windowLabel) quota low"
let body = "\(event.threshold)% remaining – currently at \(Int(event.currentRemaining))%."

let idPrefix = "quota-warning-\(provider.rawValue)-\(event.window.rawValue)-\(event.threshold)"
self.logger.info("enqueuing", metadata: ["prefix": idPrefix])
AppNotifications.shared.post(idPrefix: idPrefix, title: title, body: body)
}
}
16 changes: 16 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,22 @@ extension SettingsStore {
}
}

var quotaWarningNotificationsEnabled: Bool {
get { self.defaultsState.quotaWarningNotificationsEnabled }
set {
self.defaultsState.quotaWarningNotificationsEnabled = newValue
self.userDefaults.set(newValue, forKey: "quotaWarningNotificationsEnabled")
}
}

var quotaWarningThresholds: [Int] {
get { self.defaultsState.quotaWarningThresholdsRaw }
set {
self.defaultsState.quotaWarningThresholdsRaw = newValue
self.userDefaults.set(newValue, forKey: "quotaWarningThresholds")
}
}

var usageBarsShowUsed: Bool {
get { self.defaultsState.usageBarsShowUsed }
set {
Expand Down
6 changes: 6 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ extension SettingsStore {
if sessionQuotaDefault == nil {
userDefaults.set(true, forKey: "sessionQuotaNotificationsEnabled")
}
let quotaWarningNotificationsEnabled = userDefaults.object(
forKey: "quotaWarningNotificationsEnabled") as? Bool ?? false
let quotaWarningThresholdsRaw = userDefaults.array(
forKey: "quotaWarningThresholds") as? [Int] ?? [50, 20]
let usageBarsShowUsed = userDefaults.object(forKey: "usageBarsShowUsed") as? Bool ?? false
let resetTimesShowAbsolute = userDefaults.object(forKey: "resetTimesShowAbsolute") as? Bool ?? false
let menuBarShowsBrandIconWithPercent = userDefaults.object(
Expand Down Expand Up @@ -235,6 +239,8 @@ extension SettingsStore {
debugKeepCLISessionsAlive: debugKeepCLISessionsAlive,
statusChecksEnabled: statusChecksEnabled,
sessionQuotaNotificationsEnabled: sessionQuotaNotificationsEnabled,
quotaWarningNotificationsEnabled: quotaWarningNotificationsEnabled,
quotaWarningThresholdsRaw: quotaWarningThresholdsRaw,
usageBarsShowUsed: usageBarsShowUsed,
resetTimesShowAbsolute: resetTimesShowAbsolute,
menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent,
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ struct SettingsDefaultsState: Sendable {
var debugKeepCLISessionsAlive: Bool
var statusChecksEnabled: Bool
var sessionQuotaNotificationsEnabled: Bool
var quotaWarningNotificationsEnabled: Bool
var quotaWarningThresholdsRaw: [Int]
var usageBarsShowUsed: Bool
var resetTimesShowAbsolute: Bool
var menuBarShowsBrandIconWithPercent: Bool
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/UsageStore+Refresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ extension UsageStore {
self.statuses.removeValue(forKey: provider)
self.lastKnownSessionRemaining.removeValue(forKey: provider)
self.lastKnownSessionWindowSource.removeValue(forKey: provider)
self.lastKnownSecondaryRemaining.removeValue(forKey: provider)
self.quotaWarningFiredThresholds.removeValue(forKey: provider)
self.lastTokenFetchAt.removeValue(forKey: provider)
}
return
Expand Down Expand Up @@ -79,6 +81,7 @@ extension UsageStore {
case let .success(result):
let scoped = result.usage.scoped(to: provider)
await MainActor.run {
self.handleQuotaWarningTransition(provider: provider, snapshot: scoped)
self.handleSessionQuotaTransition(provider: provider, snapshot: scoped)
self.snapshots[provider] = scoped
self.lastSourceLabels[provider] = result.sourceLabel
Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBar/UsageStore+TokenAccounts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ extension UsageStore {
scoped
}
await MainActor.run {
if let account, self.lastWarningAccountID[provider] != account.id {
self.resetQuotaWarningState(for: provider)
self.lastWarningAccountID[provider] = account.id
}
self.handleQuotaWarningTransition(provider: provider, snapshot: labeled)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset warning state when selected token account changes

This path applies quota-warning transitions to whichever token account is currently selected, but the warning baselines/fired-threshold state in UsageStore are keyed only by provider/window. If a user switches from one account to another with a very different remaining quota, the first refresh can look like a large downward crossing and emit false warning notifications even though no quota was consumed on the newly selected account.

Useful? React with 👍 / 👎.

self.handleSessionQuotaTransition(provider: provider, snapshot: labeled)
self.snapshots[provider] = labeled
self.lastSourceLabels[provider] = result.sourceLabel
Expand Down
90 changes: 90 additions & 0 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ extension UsageStore {
_ = self.settings.refreshFrequency
_ = self.settings.statusChecksEnabled
_ = self.settings.sessionQuotaNotificationsEnabled
_ = self.settings.quotaWarningNotificationsEnabled
_ = self.settings.quotaWarningThresholds
_ = self.settings.usageBarsShowUsed
_ = self.settings.costUsageEnabled
_ = self.settings.randomBlinkEnabled
Expand Down Expand Up @@ -152,6 +154,11 @@ final class UsageStore {
@ObservationIgnored var codexHistoricalDatasetAccountKey: String?
@ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:]
@ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:]
@ObservationIgnored var lastKnownSecondaryRemaining: [UsageProvider: Double] = [:]
@ObservationIgnored var quotaWarningFiredThresholds: [UsageProvider: [QuotaWarningWindow: Set<Int>]] = [:]
@ObservationIgnored var lastWarningAccountID: [UsageProvider: UUID] = [:]
@ObservationIgnored private let quotaWarningNotifier: QuotaWarningNotifier
@ObservationIgnored private let quotaWarningLogger = CodexBarLog.logger(LogCategories.quotaWarning)
@ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:]
@ObservationIgnored private var hasCompletedInitialRefresh: Bool = false
@ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60
Expand All @@ -167,6 +174,7 @@ final class UsageStore {
registry: ProviderRegistry = .shared,
historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(),
sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(),
quotaWarningNotifier: QuotaWarningNotifier = QuotaWarningNotifier(),
startupBehavior: StartupBehavior = .automatic)
{
self.codexFetcher = fetcher
Expand All @@ -177,6 +185,7 @@ final class UsageStore {
self.registry = registry
self.historicalUsageHistoryStore = historicalUsageHistoryStore
self.sessionQuotaNotifier = sessionQuotaNotifier
self.quotaWarningNotifier = quotaWarningNotifier
self.startupBehavior = startupBehavior.resolved(isRunningTests: Self.isRunningTestsProcess())
self.providerMetadata = registry.metadata
self
Expand Down Expand Up @@ -589,6 +598,87 @@ final class UsageStore {
self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil)
}

func handleQuotaWarningTransition(provider: UsageProvider, snapshot: UsageSnapshot) {
// Always update tracking state so baselines stay current even when
// notifications are disabled. This prevents retroactive alerts when
// the user re-enables notifications after quota has dropped.
defer {
if let secondary = snapshot.secondary {
self.lastKnownSecondaryRemaining[provider] = secondary.remainingPercent
}
}

guard self.settings.quotaWarningNotificationsEnabled else { return }
let thresholds = self.settings.quotaWarningThresholds.sorted(by: >)
guard !thresholds.isEmpty else { return }

if let primary = snapshot.primary {
self.processWarningWindow(
provider: provider,
window: .primary,
currentRemaining: primary.remainingPercent,
previousRemaining: self.lastKnownSessionRemaining[provider],
thresholds: thresholds)
}

if let secondary = snapshot.secondary {
self.processWarningWindow(
provider: provider,
window: .secondary,
currentRemaining: secondary.remainingPercent,
previousRemaining: self.lastKnownSecondaryRemaining[provider],
thresholds: thresholds)
}
}

/// Resets quota warning baselines and fired thresholds for a provider.
/// Call when the selected token account changes to avoid false alerts
/// caused by a different account's remaining quota.
func resetQuotaWarningState(for provider: UsageProvider) {
self.lastKnownSecondaryRemaining.removeValue(forKey: provider)
self.quotaWarningFiredThresholds.removeValue(forKey: provider)
}

private func processWarningWindow(
provider: UsageProvider,
window: QuotaWarningWindow,
currentRemaining: Double,
previousRemaining: Double?,
thresholds: [Int])
{
var firedSet = self.quotaWarningFiredThresholds[provider]?[window] ?? []

let restored = QuotaWarningNotificationLogic.restoredThresholds(
currentRemaining: currentRemaining, alreadyFired: firedSet)
firedSet.subtract(restored)

let crossed = QuotaWarningNotificationLogic.crossedThresholds(
previousRemaining: previousRemaining,
currentRemaining: currentRemaining,
thresholds: thresholds,
alreadyFired: firedSet)

for threshold in crossed {
let event = QuotaWarningEvent(
threshold: threshold, window: window, currentRemaining: currentRemaining)
self.quotaWarningLogger.info(
"threshold crossed",
metadata: [
"provider": provider.rawValue,
"window": window.rawValue,
"threshold": "\(threshold)",
"remaining": "\(currentRemaining)",
])
self.quotaWarningNotifier.post(event: event, provider: provider)
firedSet.insert(threshold)
}

if self.quotaWarningFiredThresholds[provider] == nil {
self.quotaWarningFiredThresholds[provider] = [:]
}
self.quotaWarningFiredThresholds[provider]?[window] = firedSet
}

private func refreshStatus(_ provider: UsageProvider) async {
guard self.settings.statusChecksEnabled else { return }
guard let meta = self.providerMetadata[provider] else { return }
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBarCore/Logging/LogCategories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ public enum LogCategories {
public static let openRouterUsage = "openrouter-usage"
public static let providerDetection = "provider-detection"
public static let providers = "providers"
public static let quotaWarning = "quotaWarning"
public static let quotaWarningNotifications = "quotaWarningNotifications"
public static let sessionQuota = "sessionQuota"
public static let sessionQuotaNotifications = "sessionQuotaNotifications"
public static let settings = "settings"
Expand Down
Loading