Skip to content
Merged
852 changes: 852 additions & 0 deletions Sources/CodexBar/HistoricalUsagePace.swift

Large diffs are not rendered by default.

18 changes: 8 additions & 10 deletions Sources/CodexBar/MenuBarDisplayText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,29 @@ enum MenuBarDisplayText {
return String(format: "%.0f%%", clamped)
}

static func paceText(provider: UsageProvider, window: RateWindow?, now: Date = .init()) -> String? {
guard let window else { return nil }
guard let pace = UsagePaceText.weeklyPace(provider: provider, window: window, now: now) else { return nil }
static func paceText(pace: UsagePace?) -> String? {
guard let pace else { return nil }
let deltaValue = Int(abs(pace.deltaPercent).rounded())
let sign = pace.deltaPercent >= 0 ? "+" : "-"
return "\(sign)\(deltaValue)%"
}

static func displayText(
mode: MenuBarDisplayMode,
provider: UsageProvider,
percentWindow: RateWindow?,
paceWindow: RateWindow?,
showUsed: Bool,
now: Date = .init()) -> String?
pace: UsagePace? = nil,
showUsed: Bool) -> String?
{
switch mode {
case .percent:
return self.percentText(window: percentWindow, showUsed: showUsed)
case .pace:
return self.paceText(provider: provider, window: paceWindow, now: now)
return self.paceText(pace: pace)
case .both:
guard let percent = percentText(window: percentWindow, showUsed: showUsed) else { return nil }
guard let pace = Self.paceText(provider: provider, window: paceWindow, now: now) else { return nil }
return "\(percent) · \(pace)"
let paceText: String? = Self.paceText(pace: pace)
guard let paceText else { return nil }
return "\(percent) · \(paceText)"
}
}
}
10 changes: 7 additions & 3 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,7 @@ extension UsageMenuCardView.Model {
let sourceLabel: String?
let kiloAutoMode: Bool
let hidePersonalInfo: Bool
let weeklyPace: UsagePace?
let now: Date

init(
Expand All @@ -657,6 +658,7 @@ extension UsageMenuCardView.Model {
sourceLabel: String? = nil,
kiloAutoMode: Bool = false,
hidePersonalInfo: Bool,
weeklyPace: UsagePace? = nil,
now: Date)
{
self.provider = provider
Expand All @@ -678,6 +680,7 @@ extension UsageMenuCardView.Model {
self.sourceLabel = sourceLabel
self.kiloAutoMode = kiloAutoMode
self.hidePersonalInfo = hidePersonalInfo
self.weeklyPace = weeklyPace
self.now = now
}
}
Expand Down Expand Up @@ -933,9 +936,9 @@ extension UsageMenuCardView.Model {
}
if let weekly = snapshot.secondary {
let paceDetail = Self.weeklyPaceDetail(
provider: input.provider,
window: weekly,
now: input.now,
pace: input.weeklyPace,
showUsed: input.usageBarsShowUsed)
var weeklyResetText = Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now)
var weeklyDetailText: String? = input.provider == .zai ? zaiTimeDetail : nil
Expand Down Expand Up @@ -1049,12 +1052,13 @@ extension UsageMenuCardView.Model {
}

private static func weeklyPaceDetail(
provider: UsageProvider,
window: RateWindow,
now: Date,
pace: UsagePace?,
showUsed: Bool) -> PaceDetail?
{
guard let detail = UsagePaceText.weeklyDetail(provider: provider, window: window, now: now) else { return nil }
guard let pace else { return nil }
let detail = UsagePaceText.weeklyDetail(pace: pace, now: now)
let expectedUsed = detail.expectedUsedPercent
let actualUsed = window.usedPercent
let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed)
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ struct MenuDescriptor {
{
entries.append(.text(detail, .secondary))
}
if let paceSummary = UsagePaceText.weeklySummary(provider: provider, window: weekly) {
if let pace = store.weeklyPace(provider: provider, window: weekly) {
let paceSummary = UsagePaceText.weeklySummary(pace: pace)
entries.append(.text(paceSummary, .secondary))
}
}
Expand Down
7 changes: 6 additions & 1 deletion Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,10 @@ struct ProvidersPane: View {
tokenError = nil
}

let now = Date()
let weeklyPace = snapshot?.secondary.flatMap { window in
self.store.weeklyPace(provider: provider, window: window, now: now)
}
let input = UsageMenuCardView.Model.Input(
provider: provider,
metadata: metadata,
Expand All @@ -356,7 +360,8 @@ struct ProvidersPane: View {
tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider),
showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage,
hidePersonalInfo: self.settings.hidePersonalInfo,
now: Date())
weeklyPace: weeklyPace,
now: now)
return UsageMenuCardView.Model.make(input)
}

Expand Down
11 changes: 11 additions & 0 deletions Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ struct CodexProviderImplementation: ProviderImplementation {
})

return [
ProviderSettingsToggleDescriptor(
id: "codex-historical-tracking",
title: "Historical tracking",
subtitle: "Stores local Codex usage history (8 weeks) to personalize Pace predictions.",
binding: context.boolBinding(\.historicalTrackingEnabled),
statusText: nil,
actions: [],
isVisible: nil,
onChange: nil,
onAppDidBecomeActive: nil,
onAppearWhenEnabled: nil),
ProviderSettingsToggleDescriptor(
id: "codex-openai-web-extras",
title: "OpenAI web extras",
Expand Down
8 changes: 8 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ extension SettingsStore {
}
}

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

var menuBarMetricPreferencesRaw: [String: String] {
get { self.defaultsState.menuBarMetricPreferencesRaw }
set {
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStore+MenuObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ extension SettingsStore {
_ = self.menuBarShowsBrandIconWithPercent
_ = self.menuBarShowsHighestUsage
_ = self.menuBarDisplayMode
_ = self.historicalTrackingEnabled
_ = self.showAllTokenAccountsInMenu
_ = self.menuBarMetricPreferencesRaw
_ = self.costUsageEnabled
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ extension SettingsStore {
forKey: "menuBarShowsBrandIconWithPercent") as? Bool ?? false
let menuBarDisplayModeRaw = userDefaults.string(forKey: "menuBarDisplayMode")
?? MenuBarDisplayMode.percent.rawValue
let historicalTrackingEnabled = userDefaults.object(forKey: "historicalTrackingEnabled") as? Bool ?? false
let showAllTokenAccountsInMenu = userDefaults.object(forKey: "showAllTokenAccountsInMenu") as? Bool ?? false
let storedPreferences = userDefaults.dictionary(forKey: "menuBarMetricPreferences") as? [String: String] ?? [:]
var resolvedPreferences = storedPreferences
Expand Down Expand Up @@ -238,6 +239,7 @@ extension SettingsStore {
resetTimesShowAbsolute: resetTimesShowAbsolute,
menuBarShowsBrandIconWithPercent: menuBarShowsBrandIconWithPercent,
menuBarDisplayModeRaw: menuBarDisplayModeRaw,
historicalTrackingEnabled: historicalTrackingEnabled,
showAllTokenAccountsInMenu: showAllTokenAccountsInMenu,
menuBarMetricPreferencesRaw: resolvedPreferences,
costUsageEnabled: costUsageEnabled,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct SettingsDefaultsState: Sendable {
var resetTimesShowAbsolute: Bool
var menuBarShowsBrandIconWithPercent: Bool
var menuBarDisplayModeRaw: String?
var historicalTrackingEnabled: Bool
var showAllTokenAccountsInMenu: Bool
var menuBarMetricPreferencesRaw: [String: String]
var costUsageEnabled: Bool
Expand Down
17 changes: 13 additions & 4 deletions Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -442,18 +442,27 @@ extension StatusItemController {

func menuBarDisplayText(for provider: UsageProvider, snapshot: UsageSnapshot?) -> String? {
let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot)
let mode = self.settings.menuBarDisplayMode
let now = Date()
let pace: UsagePace? = switch mode {
case .percent:
nil
case .pace, .both:
snapshot?.secondary.flatMap { window in
self.store.weeklyPace(provider: provider, window: window, now: now)
}
}
let displayText = MenuBarDisplayText.displayText(
mode: self.settings.menuBarDisplayMode,
provider: provider,
mode: mode,
percentWindow: percentWindow,
paceWindow: snapshot?.secondary,
pace: pace,
showUsed: self.settings.usageBarsShowUsed)

let sessionExhausted = (snapshot?.primary?.remainingPercent ?? 100) <= 0
let weeklyExhausted = (snapshot?.secondary?.remainingPercent ?? 100) <= 0

if provider == .codex,
self.settings.menuBarDisplayMode == .percent,
mode == .percent,
!self.settings.usageBarsShowUsed,
sessionExhausted || weeklyExhausted,
let creditsRemaining = self.store.credits?.remaining,
Expand Down
8 changes: 6 additions & 2 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1395,7 +1395,10 @@ extension StatusItemController {

let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil
let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto

let now = Date()
let weeklyPace = snapshot?.secondary.flatMap { window in
self.store.weeklyPace(provider: target, window: window, now: now)
}
let input = UsageMenuCardView.Model.Input(
provider: target,
metadata: metadata,
Expand All @@ -1416,7 +1419,8 @@ extension StatusItemController {
sourceLabel: sourceLabel,
kiloAutoMode: kiloAutoMode,
hidePersonalInfo: self.settings.hidePersonalInfo,
now: Date())
weeklyPace: weeklyPace,
now: now)
return UsageMenuCardView.Model.make(input)
}

Expand Down
47 changes: 27 additions & 20 deletions Sources/CodexBar/UsagePaceText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,18 @@ enum UsagePaceText {
let stage: UsagePace.Stage
}

private static let minimumExpectedPercent: Double = 3

static func weeklySummary(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? {
guard let detail = weeklyDetail(provider: provider, window: window, now: now) else { return nil }
static func weeklySummary(pace: UsagePace, now: Date = .init()) -> String {
let detail = self.weeklyDetail(pace: pace, now: now)
if let rightLabel = detail.rightLabel {
return "Pace: \(detail.leftLabel) · \(rightLabel)"
}
return "Pace: \(detail.leftLabel)"
}

static func weeklyDetail(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> WeeklyDetail? {
guard let pace = weeklyPace(provider: provider, window: window, now: now) else { return nil }
return WeeklyDetail(
leftLabel: Self.detailLeftLabel(for: pace),
rightLabel: Self.detailRightLabel(for: pace, now: now),
static func weeklyDetail(pace: UsagePace, now: Date = .init()) -> WeeklyDetail {
WeeklyDetail(
leftLabel: self.detailLeftLabel(for: pace),
rightLabel: self.detailRightLabel(for: pace, now: now),
expectedUsedPercent: pace.expectedUsedPercent,
stage: pace.stage)
}
Expand All @@ -41,11 +38,23 @@ enum UsagePaceText {
}

private static func detailRightLabel(for pace: UsagePace, now: Date) -> String? {
if pace.willLastToReset { return "Lasts until reset" }
guard let etaSeconds = pace.etaSeconds else { return nil }
let etaText = Self.durationText(seconds: etaSeconds, now: now)
if etaText == "now" { return "Runs out now" }
return "Runs out in \(etaText)"
let etaLabel: String?
if pace.willLastToReset {
etaLabel = "Lasts until reset"
} else if let etaSeconds = pace.etaSeconds {
let etaText = Self.durationText(seconds: etaSeconds, now: now)
etaLabel = etaText == "now" ? "Runs out now" : "Runs out in \(etaText)"
} else {
etaLabel = nil
}

guard let runOutProbability = pace.runOutProbability else { return etaLabel }
let roundedRisk = self.roundedRiskPercent(runOutProbability)
let riskLabel = "≈ \(roundedRisk)% run-out risk"
if let etaLabel {
return "\(etaLabel) · \(riskLabel)"
}
return riskLabel
}

private static func durationText(seconds: TimeInterval, now: Date) -> String {
Expand All @@ -56,11 +65,9 @@ enum UsagePaceText {
return countdown
}

static func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? {
guard provider == .codex || provider == .claude else { return nil }
guard window.remainingPercent > 0 else { return nil }
guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) else { return nil }
guard pace.expectedUsedPercent >= Self.minimumExpectedPercent else { return nil }
return pace
private static func roundedRiskPercent(_ probability: Double) -> Int {
let percent = probability.clamped(to: 0...1) * 100
let rounded = (percent / 5).rounded() * 5
return Int(rounded)
}
}
Loading