Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
49 changes: 48 additions & 1 deletion Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ struct UsageMenuCardView: View {
let tokenUsage: TokenUsageSection?
let placeholder: String?
let progressColor: Color
let resetWarnings: [ResetWarning]
}

let model: Model
Expand Down Expand Up @@ -140,6 +141,9 @@ struct UsageMenuCardView: View {
let hasCost = self.model.tokenUsage != nil || hasProviderCost

VStack(alignment: .leading, spacing: 12) {
if !self.model.resetWarnings.isEmpty {
ResetWarningBannerContent(warnings: self.model.resetWarnings)
}
if hasUsage {
VStack(alignment: .leading, spacing: 12) {
ForEach(self.model.metrics, id: \.id) { metric in
Expand Down Expand Up @@ -518,6 +522,45 @@ struct UsageMenuCardCreditsSectionView: View {
}
}

private struct ResetWarningBannerContent: View {
let warnings: [ResetWarning]

@Environment(\.menuItemHighlighted) private var isHighlighted

var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(self.warnings, id: \.windowKind) { warning in
Self.warningRow(warning: warning, isHighlighted: self.isHighlighted)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(.orange.opacity(0.12))
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(.orange.opacity(0.3), lineWidth: 1)))
}

private static func warningRow(warning: ResetWarning, isHighlighted: Bool) -> some View {
let hoursLeft = String(format: "%.1f", warning.hoursUntilReset)
let percentLeft = String(format: "%.0f", warning.remainingPercent)
let windowLabel = warning.windowKind == .session ? "Session" : "Weekly"
let label = "\(windowLabel) resets in \(hoursLeft)h — \(percentLeft)% remaining"
let textColor: Color = isHighlighted ? .primary : .orange
return HStack(spacing: 6) {
Image(systemName: "clock.badge.exclamationmark")
.font(.caption)
.foregroundStyle(.orange)
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(textColor)
}
}
}

private struct CreditsBarContent: View {
private static let fullScaleTokens: Double = 1000

Expand Down Expand Up @@ -670,6 +713,7 @@ extension UsageMenuCardView.Model {
let kiloAutoMode: Bool
let hidePersonalInfo: Bool
let weeklyPace: UsagePace?
let resetWarnings: [ResetWarning]?
let now: Date

init(
Expand All @@ -693,6 +737,7 @@ extension UsageMenuCardView.Model {
kiloAutoMode: Bool = false,
hidePersonalInfo: Bool,
weeklyPace: UsagePace? = nil,
resetWarnings: [ResetWarning]? = nil,
now: Date)
{
self.provider = provider
Expand All @@ -715,6 +760,7 @@ extension UsageMenuCardView.Model {
self.kiloAutoMode = kiloAutoMode
self.hidePersonalInfo = hidePersonalInfo
self.weeklyPace = weeklyPace
self.resetWarnings = resetWarnings
self.now = now
}
}
Expand Down Expand Up @@ -767,7 +813,8 @@ extension UsageMenuCardView.Model {
providerCost: providerCost,
tokenUsage: tokenUsage,
placeholder: placeholder,
progressColor: Self.progressColor(for: input.provider))
progressColor: Self.progressColor(for: input.provider),
resetWarnings: input.resetWarnings ?? [])
}

private static func usageNotes(input: Input) -> [String] {
Expand Down
25 changes: 25 additions & 0 deletions Sources/CodexBar/PreferencesGeneralPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,31 @@ 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: "Reset warnings",
subtitle: "Notifies when a usage window is about to reset and you still have " +
"significant capacity remaining.",
binding: self.$settings.resetWarningEnabled)
if self.settings.resetWarningEnabled {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Warning advance time")
.font(.body)
Text("How many hours before reset to trigger the warning.")
.font(.footnote)
.foregroundStyle(.tertiary)
}
Spacer()
Picker("Warning advance time", selection: self.$settings.resetWarningHours) {
ForEach([1, 2, 4, 8, 12, 24, 48], id: \.self) { hours in
Text(hours == 1 ? "1 hour" : "\(hours) hours").tag(hours)
}
}
.labelsHidden()
.pickerStyle(.menu)
.frame(maxWidth: 200)
}
}
}

Divider()
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ struct ProvidersPane: View {
showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage,
hidePersonalInfo: self.settings.hidePersonalInfo,
weeklyPace: weeklyPace,
resetWarnings: self.store.activeResetWarnings[provider],
now: now)
return UsageMenuCardView.Model.make(input)
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/CodexBar/SessionQuotaNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ enum SessionQuotaNotificationLogic {
@MainActor
protocol SessionQuotaNotifying: AnyObject {
func post(transition: SessionQuotaTransition, provider: UsageProvider, badge: NSNumber?)
func postResetWarning(idPrefix: String, title: String, body: String)
}

@MainActor
Expand Down Expand Up @@ -60,4 +61,9 @@ final class SessionQuotaNotifier: SessionQuotaNotifying {
self.logger.info("enqueuing", metadata: ["prefix": idPrefix])
AppNotifications.shared.post(idPrefix: idPrefix, title: title, body: body, badge: badge)
}

func postResetWarning(idPrefix: String, title: String, body: String) {
self.logger.info("enqueuing reset warning", metadata: ["prefix": idPrefix])
AppNotifications.shared.post(idPrefix: idPrefix, title: title, body: body, badge: nil)
}
}
17 changes: 17 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,23 @@ extension SettingsStore {
}
}

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

var resetWarningHours: Int {
get { self.defaultsState.resetWarningHours }
set {
let clamped = max(1, min(48, newValue))
self.defaultsState.resetWarningHours = clamped
self.userDefaults.set(clamped, forKey: "resetWarningHours")
}
}

var debugLoadingPattern: LoadingPattern? {
get { self.debugLoadingPatternRaw.flatMap(LoadingPattern.init(rawValue:)) }
set { self.debugLoadingPatternRaw = newValue?.rawValue }
Expand Down
7 changes: 6 additions & 1 deletion Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ extension SettingsStore {
forKey: "mergedOverviewSelectedProviders") as? [String] ?? []
let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider")
let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false
let resetWarningEnabled = userDefaults.object(forKey: "resetWarningEnabled") as? Bool ?? true
let resetWarningHours = userDefaults.object(forKey: "resetWarningHours") as? Int
?? ResetWarningEvaluator.defaultWarningHours

return SettingsDefaultsState(
refreshFrequency: refreshFrequency,
Expand Down Expand Up @@ -264,7 +267,9 @@ extension SettingsStore {
mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview,
mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw,
selectedMenuProviderRaw: selectedMenuProviderRaw,
providerDetectionCompleted: providerDetectionCompleted)
providerDetectionCompleted: providerDetectionCompleted,
resetWarningEnabled: resetWarningEnabled,
resetWarningHours: resetWarningHours)
}
}

Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ struct SettingsDefaultsState {
var mergedOverviewSelectedProvidersRaw: [String]
var selectedMenuProviderRaw: String?
var providerDetectionCompleted: Bool
var resetWarningEnabled: Bool
var resetWarningHours: Int
}
1 change: 1 addition & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1483,6 +1483,7 @@ extension StatusItemController {
kiloAutoMode: kiloAutoMode,
hidePersonalInfo: self.settings.hidePersonalInfo,
weeklyPace: weeklyPace,
resetWarnings: self.store.activeResetWarnings[target],
now: now)
return UsageMenuCardView.Model.make(input)
}
Expand Down
71 changes: 71 additions & 0 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ final class UsageStore {
var pathDebugInfo: PathDebugSnapshot = .empty
var statuses: [UsageProvider: ProviderStatus] = [:]
var probeLogs: [UsageProvider: String] = [:]
var activeResetWarnings: [UsageProvider: [ResetWarning]] = [:]
var historicalPaceRevision: Int = 0
@ObservationIgnored var lastCreditsSnapshot: CreditsSnapshot?
@ObservationIgnored var creditsFailureStreak: Int = 0
Expand Down Expand Up @@ -155,6 +156,7 @@ final class UsageStore {
@ObservationIgnored var codexHistoricalDatasetAccountKey: String?
@ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:]
@ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:]
@ObservationIgnored var lastResetWarningDate: [String: Date] = [:]
@ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:]
@ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:]
@ObservationIgnored private var hasCompletedInitialRefresh: Bool = false
Expand Down Expand Up @@ -430,6 +432,7 @@ final class UsageStore {
await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt)
}

self.checkResetWarnings()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recompute reset warnings when warning settings change

Reset warnings are only recalculated inside the refresh path here, but the new settings (resetWarningEnabled / resetWarningHours) are not observed in UsageStore.observeSettingsChanges. As a result, changing those preferences does not immediately clear/re-evaluate activeResetWarnings; in Manual refresh mode, stale warning banners can remain indefinitely until the user triggers a refresh.

Useful? React with 👍 / 👎.

self.persistWidgetSnapshot(reason: "refresh")
}
}
Expand Down Expand Up @@ -602,6 +605,74 @@ final class UsageStore {
self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil)
}

// MARK: - Reset warnings

func checkResetWarnings() {
guard self.settings.resetWarningEnabled else {
self.activeResetWarnings = [:]
return
}

let warningHours = self.settings.resetWarningHours
let now = Date()
var newWarnings: [UsageProvider: [ResetWarning]] = [:]

for provider in self.enabledProviders() {
guard let snapshot = self.snapshots[provider] else { continue }
var providerWarnings: [ResetWarning] = []

if let primary = snapshot.primary,
let warning = ResetWarningEvaluator.evaluate(
provider: provider,
window: primary,
windowKind: .session,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use provider-specific window kinds for reset warnings

This hard-codes the primary window as .session (and secondary as .weekly a few lines below), but several providers do not map their windows that way. For example, Kimi’s primary is weekly quota and secondary is a 5-hour rate-limit window, so warnings/notifications end up mislabeled and can notify on the wrong semantic lane. That makes the new warning text inaccurate for those providers and can confuse users about what is actually resetting.

Useful? React with 👍 / 👎.

warningHours: warningHours,
now: now)
{
providerWarnings.append(warning)
}

if let secondary = snapshot.secondary,
let warning = ResetWarningEvaluator.evaluate(
provider: provider,
window: secondary,
windowKind: .weekly,
warningHours: warningHours,
now: now)
{
providerWarnings.append(warning)
}

if !providerWarnings.isEmpty {
newWarnings[provider] = providerWarnings
for warning in providerWarnings {
let key = "reset-\(provider.rawValue)-\(warning.windowKind.rawValue)"
if ResetWarningEvaluator.shouldNotify(
warning: warning,
lastNotifiedAt: self.lastResetWarningDate[key],
now: now)
{
self.lastResetWarningDate[key] = now
self.postResetWarningNotification(warning: warning)
}
}
}
}

self.activeResetWarnings = newWarnings
}

private func postResetWarningNotification(warning: ResetWarning) {
let providerName = ProviderDescriptorRegistry.descriptor(for: warning.providerID).metadata.displayName
let windowLabel = warning.windowKind == .session ? "session" : "weekly"
let hoursLeft = String(format: "%.0f", warning.hoursUntilReset)
let percentLeft = String(format: "%.0f", warning.remainingPercent)
let title = "\(providerName) \(windowLabel) resets in \(hoursLeft)h"
let body = "You still have \(percentLeft)% remaining. Consider starting long-running tasks."
let idPrefix = "reset-warning-\(warning.providerID.rawValue)-\(warning.windowKind.rawValue)"
self.sessionQuotaNotifier.postResetWarning(idPrefix: idPrefix, title: title, body: body)
}

private func refreshStatus(_ provider: UsageProvider) async {
guard self.settings.statusChecksEnabled else { return }
guard let meta = self.providerMetadata[provider] else { return }
Expand Down
72 changes: 72 additions & 0 deletions Sources/CodexBarCore/ResetWarningState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation

public struct ResetWarning: Equatable, Sendable {
public let providerID: UsageProvider
public let windowKind: WindowKind
public let remainingPercent: Double
public let resetsAt: Date
public let hoursUntilReset: Double

public enum WindowKind: String, Sendable {
case session
case weekly
}

public init(
providerID: UsageProvider,
windowKind: WindowKind,
remainingPercent: Double,
resetsAt: Date,
hoursUntilReset: Double)
{
self.providerID = providerID
self.windowKind = windowKind
self.remainingPercent = remainingPercent
self.resetsAt = resetsAt
self.hoursUntilReset = hoursUntilReset
}
}

public enum ResetWarningEvaluator {
public static let minimumRemainingPercent: Double = 20
public static let defaultWarningHours: Int = 8
public static let notificationCooldownHours: Double = 1

public static func evaluate(
provider: UsageProvider,
window: RateWindow,
windowKind: ResetWarning.WindowKind,
warningHours: Int,
minimumRemainingPercent: Double = ResetWarningEvaluator.minimumRemainingPercent,
now: Date = .init()) -> ResetWarning?
{
guard let resetsAt = window.resetsAt else { return nil }
let secondsUntilReset = resetsAt.timeIntervalSince(now)
guard secondsUntilReset > 0 else { return nil }

let hoursUntilReset = secondsUntilReset / 3600
let warningWindow = Double(warningHours)

guard hoursUntilReset <= warningWindow else { return nil }

let remaining = window.remainingPercent
guard remaining >= minimumRemainingPercent else { return nil }

return ResetWarning(
providerID: provider,
windowKind: windowKind,
remainingPercent: remaining,
resetsAt: resetsAt,
hoursUntilReset: hoursUntilReset)
}

public static func shouldNotify(
warning: ResetWarning,
lastNotifiedAt: Date?,
now: Date = .init()) -> Bool
{
guard let lastNotifiedAt else { return true }
let cooldownSeconds = Self.notificationCooldownHours * 3600
return now.timeIntervalSince(lastNotifiedAt) >= cooldownSeconds
}
}
Loading
Loading