Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.38.1 — Unreleased

### Added
- Usage refresh: add an opt-in Adaptive cadence that polls every 2–30 minutes based on recent menu use, Low Power Mode, and thermal state. Thanks @hhh2210!
- Codex: show a conservative 1.5× pace-headroom hint in menus and CLI output when usage is safely ahead of the reset curve. Thanks @astuteprogrammer!

### Changed
Expand All @@ -20,7 +21,6 @@
- Settings: recover collapsed sidebars and undersized saved window frames when reopening Settings. Thanks @ProspectOre!
- z.ai: parse successful BigModel CN quota responses that omit the optional message field, while preserving useful API-code errors. Thanks @joeVenner!
- Claude: block background delegated CLI OAuth refresh when the keychain holds MCP-only state (`mcpOAuth` without `claudeAiOauth`) while preserving explicit Refresh recovery (#1844). Thanks @Yuxin-Qiao!

## 0.38.0 — 2026-07-03

### Added
Expand Down
70 changes: 70 additions & 0 deletions Sources/CodexBar/AdaptiveRefreshPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation

/// Decides how long to wait before the next automatic usage refresh.
/// Pure by construction: every signal arrives via `Input`, so the same
/// input always yields the same `Decision` with no clock or system reads.
struct AdaptiveRefreshPolicy: Sendable {
struct Input: Sendable, Equatable {
let now: Date
let lastMenuOpenAt: Date?
let lowPowerModeEnabled: Bool
let thermalState: ProcessInfo.ThermalState
}

enum Reason: String, Sendable {
case recentInteraction
case warm
case idle
case longIdle
case constrained
}

struct Decision: Sendable, Equatable {
let delay: Duration
let reason: Reason
}

private static let recentInteractionThreshold: TimeInterval = 5 * 60
private static let warmThreshold: TimeInterval = 60 * 60
private static let idleThreshold: TimeInterval = 4 * 60 * 60

/// Representative cadence for consumers that need a single interval but cannot reach live
/// signals (`ProviderRegistry` builds provider specs before a `UsageStore` exists). Matches
/// `warmDelay`: the steady-state cadence while the user is active, which is when
/// interval-derived heuristics such as the persistent-CLI-session idle window matter most.
static let nominalIntervalForHeuristics: TimeInterval = 5 * 60

private static let recentInteractionDelay: Duration = .seconds(2 * 60)
private static let warmDelay: Duration = .seconds(5 * 60)
private static let idleDelay: Duration = .seconds(15 * 60)
private static let longIdleDelay: Duration = .seconds(30 * 60)
private static let constrainedDelay: Duration = .seconds(30 * 60)

func nextDelay(for input: Input) -> Decision {
if input.lowPowerModeEnabled || Self.isConstrained(input.thermalState) {
return Decision(delay: Self.constrainedDelay, reason: .constrained)
}

guard let lastMenuOpenAt = input.lastMenuOpenAt else {
return Decision(delay: Self.longIdleDelay, reason: .longIdle)
}

// A future or clock-adjusted timestamp yields a negative age, which reads as recent.
let age = input.now.timeIntervalSince(lastMenuOpenAt)

if age <= Self.recentInteractionThreshold {
return Decision(delay: Self.recentInteractionDelay, reason: .recentInteraction)
}
if age <= Self.warmThreshold {
return Decision(delay: Self.warmDelay, reason: .warm)
}
if age < Self.idleThreshold {
return Decision(delay: Self.idleDelay, reason: .idle)
}
return Decision(delay: Self.longIdleDelay, reason: .longIdle)
}

private static func isConstrained(_ state: ProcessInfo.ThermalState) -> Bool {
state == .serious || state == .critical
}
}
10 changes: 9 additions & 1 deletion Sources/CodexBar/ProviderRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ struct ProviderRegistry {
costUsageHistoryDays: settings.costUsageHistoryDays,
persistsCLISessions: true,
persistentCLISessionIdleWindow: Self.persistentCLISessionIdleWindow(
refreshInterval: settings.refreshFrequency.seconds))
refreshInterval: Self.nominalRefreshInterval(for: settings.refreshFrequency)))
})
specs[provider] = spec
}
Expand All @@ -96,6 +96,14 @@ struct ProviderRegistry {
max(180, (refreshInterval ?? 120) + 60)
}

/// `RefreshFrequency.seconds` is nil for `.adaptive`, which would collapse the idle window to
/// its floor and churn persistent CLI sessions between adaptive ticks. No `UsageStore` exists
/// when specs are built, so `.adaptive` maps to the policy's nominal interval instead of a
/// live decision; `.manual` stays nil.
static func nominalRefreshInterval(for frequency: RefreshFrequency) -> TimeInterval? {
frequency == .adaptive ? AdaptiveRefreshPolicy.nominalIntervalForHeuristics : frequency.seconds
}

@MainActor
static func makeSettingsSnapshot(
settings: SettingsStore,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@
"refresh_5min" = "5 دقائق";
"refresh_15min" = "15 دقيقة";
"refresh_30min" = "30 دقيقة";
"refresh_adaptive" = "تكيفي";

/* Additional keys */
"not_found" = "لم يعثر عليه";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@
"refresh_5min" = "5 min";
"refresh_15min" = "15 min";
"refresh_30min" = "30 min";
"refresh_adaptive" = "Adaptatiu";

/* Additional keys */
"not_found" = "No trobat";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,7 @@
"refresh_5min" = "5 Min";
"refresh_15min" = "15 Min";
"refresh_30min" = "30 Min";
"refresh_adaptive" = "Adaptiv";

/* Additional keys */
"not_found" = "Nicht gefunden";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@
"refresh_5min" = "5 min";
"refresh_15min" = "15 min";
"refresh_30min" = "30 min";
"refresh_adaptive" = "Adaptive";

/* Additional keys */
"not_found" = "Not found";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,7 @@
"refresh_5min" = "5 min";
"refresh_15min" = "15 min";
"refresh_30min" = "30 min";
"refresh_adaptive" = "Adaptativo";

/* Additional keys */
"not_found" = "No encontrado";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/fa.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@
"refresh_5min" = "۵ دقیقه";
"refresh_15min" = "۱۵ دقیقه";
"refresh_30min" = "۳۰ دقیقه";
"refresh_adaptive" = "تطبیقی";

/* Additional keys */
"not_found" = "پیدا نشد";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@
"refresh_5min" = "5 minutes";
"refresh_15min" = "15 minutes";
"refresh_30min" = "30 minutes";
"refresh_adaptive" = "Adaptatif";

/* Additional keys */
"not_found" = "Pas trouvé";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/id.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@
"refresh_5min" = "5 mnt";
"refresh_15min" = "15 mnt";
"refresh_30min" = "30 mnt";
"refresh_adaptive" = "Adaptif";

/* Additional keys */
"not_found" = "Tidak ditemukan";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/it.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@
"refresh_5min" = "5 min.";
"refresh_15min" = "15 min.";
"refresh_30min" = "30 min.";
"refresh_adaptive" = "Adattivo";

/* Additional keys */
"not_found" = "Non trovato";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@
"refresh_5min" = "5分";
"refresh_15min" = "15分";
"refresh_30min" = "30分";
"refresh_adaptive" = "アダプティブ";

/* Additional keys */
"not_found" = "見つかりません";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@
"refresh_5min" = "5분";
"refresh_15min" = "15분";
"refresh_30min" = "30분";
"refresh_adaptive" = "적응형";
"not_found" = "찾을 수 없음";
"cost_header_estimated" = "비용(추정)";
"cost_estimate_hint" = "로컬 로그에서 추정 · 실제 청구액과 다를 수 있음";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/nl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@
"refresh_5min" = "5 min";
"refresh_15min" = "15 minuten";
"refresh_30min" = "30 min";
"refresh_adaptive" = "Adaptief";

/* Additional keys */
"not_found" = "Niet gevonden";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/pl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@
"refresh_5min" = "Co 5 min";
"refresh_15min" = "Co 15 min";
"refresh_30min" = "Co 30 min";
"refresh_adaptive" = "Adaptacyjny";

/* Additional keys */
"not_found" = "Nie znaleziono";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@
"refresh_5min" = "5 min";
"refresh_15min" = "15 min";
"refresh_30min" = "30 min";
"refresh_adaptive" = "Adaptativo";

/* Additional keys */
"not_found" = "Não encontrado";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/sv.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@
"refresh_5min" = "5 min";
"refresh_15min" = "15 min";
"refresh_30min" = "30 min";
"refresh_adaptive" = "Adaptiv";

/* Additional keys */
"not_found" = "Hittades inte";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/th.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@
"refresh_5min" = "5 นาที";
"refresh_15min" = "15 นาที";
"refresh_30min" = "30 นาที";
"refresh_adaptive" = "ปรับอัตโนมัติ";

/* Additional keys */
"not_found" = "ไม่พบ";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/tr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@
"refresh_5min" = "5 dak";
"refresh_15min" = "15 dak";
"refresh_30min" = "30 dak";
"refresh_adaptive" = "Uyarlanabilir";

/* Additional keys */
"not_found" = "Bulunamadı";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/uk.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@
"refresh_5min" = "5 хв";
"refresh_15min" = "15 хв";
"refresh_30min" = "30 хв";
"refresh_adaptive" = "Адаптивний";

/* Additional keys */
"not_found" = "Не знайдено";
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/Resources/vi.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,7 @@
"refresh_5min" = "5 phút";
"refresh_15min" = "15 phút";
"refresh_30min" = "30 phút";
"refresh_adaptive" = "Thích ứng";

/* Additional keys */
"not_found" = "Không tìm thấy";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,7 @@
"refresh_5min" = "5 分钟";
"refresh_15min" = "15 分钟";
"refresh_30min" = "30 分钟";
"refresh_adaptive" = "自适应";
"not_found" = "未找到";
"CodexBar can't show its menu bar icon" = "CodexBar 无法显示菜单栏图标";
"Dismiss" = "关闭";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,7 @@
"refresh_5min" = "5 分鐘";
"refresh_15min" = "15 分鐘";
"refresh_30min" = "30 分鐘";
"refresh_adaptive" = "自適應";
"not_found" = "找不到";
"CodexBar can't show its menu bar icon" = "CodexBar 無法顯示選單列圖示";
"Dismiss" = "關閉";
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@ enum RefreshFrequency: String, CaseIterable, Identifiable {
case fiveMinutes
case fifteenMinutes
case thirtyMinutes
/// Newest/most advanced option; kept last so the picker still lists the fixed intervals
/// in ascending cadence order before it.
case adaptive

var id: String {
self.rawValue
}

/// nil for `.manual` (no timer) and `.adaptive` (delay is computed per tick by
/// `AdaptiveRefreshPolicy`, not a fixed interval).
var seconds: TimeInterval? {
switch self {
case .manual: nil
Expand All @@ -23,6 +28,7 @@ enum RefreshFrequency: String, CaseIterable, Identifiable {
case .fiveMinutes: 300
case .fifteenMinutes: 900
case .thirtyMinutes: 1800
case .adaptive: nil
}
Comment thread
steipete marked this conversation as resolved.
}

Expand All @@ -34,6 +40,7 @@ enum RefreshFrequency: String, CaseIterable, Identifiable {
case .fiveMinutes: L("refresh_5min")
case .fifteenMinutes: L("refresh_15min")
case .thirtyMinutes: L("refresh_30min")
case .adaptive: L("refresh_adaptive")
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ extension StatusItemController {
}

func menuWillOpen(_ menu: NSMenu) {
// Records interaction and may bring an adaptive timer forward; never refreshes synchronously.
self.store.noteMenuOpened()

let trace = self.beginMenuOperationTrace("menuWillOpen", breadcrumb: "menuWillOpen")
defer { self.endMenuOperationTrace(trace, menu: menu, provider: self.menuProvider(for: menu)) }

Expand Down
Loading