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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Menu: the compact multi-account layout now covers every stacked multi-account list — token accounts on any provider and Codex accounts (flat lists; workspace-grouped Codex lists keep their sections).

### Fixed
- Codex: persist and budget fork-parent discovery so missing parents quiesce between inventory changes instead of sweeping every rollout on each refresh (#2525, #2538). Thanks @xx205, and @Helmi and @kiranmagic7 for the investigation!
- Claude: Auto cold boot with Keychain disabled loads without manual refresh (#2494, fixes #2493). Thanks @gmkbenjamin!
- Menu: no more stray floating "Refresh" tooltip beside the menu when switching tabs with the cursor over the actions area.
- Providers: write the Factory and Cursor session files (bearer/refresh tokens, auth cookies) owner-only (0600), matching the codex/kimi/antigravity credential stores.
Expand Down
116 changes: 116 additions & 0 deletions Sources/CodexBar/CodexCostCatchUpPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import Foundation
import IOKit.ps

enum CodexCostCatchUpMode: String, Sendable {
case automatic
case accelerated
}

enum CodexCostCatchUpPowerSource: String, Sendable {
case ac
case battery
case unknown

static func current() -> Self {
guard let info = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
let source = IOPSGetProvidingPowerSourceType(info)?.takeUnretainedValue() as String?
else {
return .unknown
}
if source == kIOPSACPowerValue as String {
return .ac
}
if source == kIOPSBatteryPowerValue as String {
return .battery
}
return .unknown
}
}

enum CodexCostCatchUpPauseReason: Sendable, Equatable {
case lowPower
case thermal
case user
case noProgress
case error(String)
}

struct CodexCostCatchUpActivity: Sendable, Equatable {
enum Phase: Sendable, Equatable {
case indexing
case paused
case complete
}

let phase: Phase
let mode: CodexCostCatchUpMode
let processedBytes: Int64
let totalBytes: Int64
let completedFiles: Int
let totalFiles: Int
let pauseReason: CodexCostCatchUpPauseReason?
let staleSnapshotUpdatedAt: Date?

var fractionCompleted: Double? {
guard self.totalBytes > 0 else {
guard self.totalFiles > 0 else { return nil }
return min(1, max(0, Double(self.completedFiles) / Double(self.totalFiles)))
}
return min(1, max(0, Double(self.processedBytes) / Double(self.totalBytes)))
}
}

struct CodexCostCatchUpPolicy: Sendable {
struct Input: Sendable {
let mode: CodexCostCatchUpMode
let previousActiveDuration: TimeInterval?
let powerSource: CodexCostCatchUpPowerSource
let lowPowerModeEnabled: Bool
let thermalState: ProcessInfo.ThermalState
}

struct Decision: Sendable, Equatable {
enum Action: Sendable, Equatable {
case runAfter(TimeInterval)
case pause(TimeInterval, CodexCostCatchUpPauseReason)
}

let action: Action
let targetDutyCycle: Double?
}

static let automaticBurstDuration: TimeInterval = 2
static let constrainedRetryDelay: TimeInterval = 60

func decision(for input: Input) -> Decision {
if input.thermalState == .critical {
return Decision(
action: .pause(Self.constrainedRetryDelay, .thermal),
targetDutyCycle: nil)
}
if input.mode == .automatic {
if input.lowPowerModeEnabled {
return Decision(
action: .pause(Self.constrainedRetryDelay, .lowPower),
targetDutyCycle: nil)
}
if input.thermalState == .serious {
return Decision(
action: .pause(Self.constrainedRetryDelay, .thermal),
targetDutyCycle: nil)
}
}
if input.mode == .accelerated {
return Decision(action: .runAfter(0), targetDutyCycle: 1)
}

let dutyCycle = switch input.powerSource {
case .ac: 0.20
case .battery: 0.05
case .unknown: 0.15
}
let activeDuration = max(0, input.previousActiveDuration ?? Self.automaticBurstDuration)
let delay = activeDuration * (1 - dutyCycle) / dutyCycle
return Decision(action: .runAfter(delay), targetDutyCycle: dutyCycle)
}
}
157 changes: 157 additions & 0 deletions Sources/CodexBar/PreferencesSpendDashboardPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ func spendDashboardCoverageText(covered: Int, requested: Int) -> String {
"\(L("Coverage")): \(codexBarLocalizedInteger(covered)) / \(codexBarLocalizedInteger(requested))"
}

func codexCostCatchUpProgressText(_ activity: CodexCostCatchUpActivity) -> String {
if activity.totalBytes > 0 {
let processed = ByteCountFormatter.string(
fromByteCount: activity.processedBytes,
countStyle: .file)
let total = ByteCountFormatter.string(
fromByteCount: activity.totalBytes,
countStyle: .file)
return "\(processed) / \(total)"
}
if activity.totalFiles > 0 {
return "\(codexBarLocalizedInteger(activity.completedFiles)) / "
+ codexBarLocalizedInteger(activity.totalFiles)
}
return L("Loading…")
}

enum SpendDashboardModelHistoryPresentation: Equatable {
case unavailable
case empty
Expand All @@ -48,6 +65,7 @@ struct SpendDashboardPane: View {
@Bindable var settings: SettingsStore
@Bindable var store: UsageStore
@State private var controller: SpendDashboardController
@State private var isVisible = false

init(settings: SettingsStore, store: UsageStore) {
self.settings = settings
Expand All @@ -61,6 +79,7 @@ struct SpendDashboardPane: View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
self.header
self.codexCostCatchUpPanel

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 Bind catch-up controls to the dashboard account cache

When an account-scoped dashboard scan remains pending—for example, a Codex archive exceeding the 512 MiB per-refresh limit—this panel reports and controls UsageStore.codexCostCatchUpActivity, but dashboard rows are loaded from separate caches under accounts/<cacheIdentity> in SpendDashboardSource.load (SpendDashboardController.swift:273-283). The buttons therefore advance the store's default selected-account cache rather than the cache supplying the displayed row, and completing that task does not invalidate Codex dashboard data because Codex is excluded from sourceRevisions; users can click “Finish now” yet keep seeing the stale/partial dashboard result. Track catch-up per dashboard account cache or avoid presenting these controls as dashboard progress.

Useful? React with 👍 / 👎.

self.content
self.provenance
self.shareAction
Expand All @@ -69,13 +88,26 @@ struct SpendDashboardPane: View {
}
.background(FocusResigningBackground())
.onAppear {
self.isVisible = true
self.controller.refreshDateWindow()
self.controller.update(configuration: self.configuration)
if !self.controller.isRefreshing {
self.synchronizeCodexCostCatchUp()
}
}
.onChange(of: self.configuration) { _, configuration in
self.controller.update(configuration: configuration)
if self.isVisible, !self.controller.isRefreshing {
self.synchronizeCodexCostCatchUp()
}
}
.onChange(of: self.controller.isRefreshing) { _, isRefreshing in
if self.isVisible, !isRefreshing {
self.synchronizeCodexCostCatchUp()
}
}
.onDisappear {
self.isVisible = false
self.controller.stop()
}
.onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in
Expand Down Expand Up @@ -124,6 +156,131 @@ struct SpendDashboardPane: View {
}
}

@ViewBuilder
private var codexCostCatchUpPanel: some View {
if let activity = self.store.spendDashboardCodexCostCatchUpActivity,
activity.phase != .complete
{
SpendDashboardPanel {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Label(
self.codexCostCatchUpTitle(activity),
systemImage: activity.phase == .paused ? "pause.circle" : "externaldrive")
.font(.headline)
Spacer()
Text(codexCostCatchUpProgressText(activity))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}

if let progress = activity.fractionCompleted {
ProgressView(value: progress)
} else if activity.phase == .indexing {
ProgressView()
.controlSize(.small)
}

if let staleSnapshotUpdatedAt = activity.staleSnapshotUpdatedAt {
HStack(spacing: 6) {
Label(L("stale data"), systemImage: "clock.badge.exclamationmark")
Text(L(
"Updated relative %@",
staleSnapshotUpdatedAt.relativeDescription()))
}
.font(.caption.weight(.medium))
.foregroundStyle(.orange)
}

Text(self.codexCostCatchUpDetail(activity))
.font(.caption)
.foregroundStyle(.secondary)

HStack {
if activity.pauseReason == .user
|| activity.pauseReason == .noProgress
|| self.codexCostCatchUpHasError(activity)
{
Button(L("Refresh")) {
self.startCodexCostCatchUp(mode: .automatic)
}
} else if activity.mode == .automatic {
Button(L("Finish now")) {
self.startCodexCostCatchUp(mode: .accelerated)
}
} else {
Button(L("Continue in background")) {
self.startCodexCostCatchUp(mode: .automatic)
}
}

if activity.pauseReason != .user,
activity.pauseReason != .noProgress,
!self.codexCostCatchUpHasError(activity)
{
Button(L("Cancel")) {
self.store.stopSpendDashboardCodexCostCatchUp()
}
}
}
.controlSize(.small)
}
}
}
}

private func codexCostCatchUpHasError(_ activity: CodexCostCatchUpActivity) -> Bool {
if case .error = activity.pauseReason {
return true
}
return false
}

private func synchronizeCodexCostCatchUp() {
self.store.synchronizeSpendDashboardCodexCostCatchUp(
accounts: self.codexSpendScanRequests)
}

private func startCodexCostCatchUp(mode: CodexCostCatchUpMode) {
self.store.startSpendDashboardCodexCostCatchUpIfNeeded(
accounts: self.codexSpendScanRequests,
mode: mode)
}

private var codexSpendScanRequests: [CodexSpendScanRequest] {
guard self.configuration.costUsageEnabled,
self.configuration.providerIDs.contains(UsageProvider.codex.rawValue)
else { return [] }
return SpendDashboardSource.codexRequests(settings: self.settings, store: self.store)
}

private func codexCostCatchUpTitle(_ activity: CodexCostCatchUpActivity) -> String {
let prefix = L("Local estimated history")
switch activity.phase {
case .indexing:
return "\(prefix) · \(L("Refreshing"))"
case .paused:
return "\(prefix) · \(L("Inactive"))"
case .complete:
return "\(prefix) · \(L("Done"))"
}
}

private func codexCostCatchUpDetail(_ activity: CodexCostCatchUpActivity) -> String {
switch activity.pauseReason {
case .lowPower:
L("Battery Saver")
case .thermal, .user:
L("Inactive")
case .noProgress:
L("Error")
case let .error(message):
L("cost_status_error", L("Cost"), message)
case nil:
L("Estimated from local Codex logs for the selected account.")
}
}

@ViewBuilder
private var content: some View {
if !self.settings.costUsageEnabled {
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1353,3 +1353,5 @@
"Cost today unavailable" = "تكلفة اليوم: غير متوفر";
"30-day cost unavailable" = "تكلفة 30 يوماً: غير متوفر";
"Resets" = "إعادات الضبط";
"Finish now" = "إنهاء الآن";
"Continue in background" = "المتابعة في الخلفية";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1352,3 +1352,5 @@
"Cost today unavailable" = "Cost d’avui: No disponible";
"30-day cost unavailable" = "Cost de 30 dies: No disponible";
"Resets" = "Reinicis";
"Finish now" = "Finalitza ara";
"Continue in background" = "Continua en segon pla";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1350,3 +1350,5 @@
"Cost today unavailable" = "Kosten heute: Nicht verfügbar";
"30-day cost unavailable" = "Kosten 30 Tage: Nicht verfügbar";
"Resets" = "Zurücksetzungen";
"Finish now" = "Jetzt abschließen";
"Continue in background" = "Im Hintergrund fortfahren";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1354,3 +1354,5 @@
"Cost today unavailable" = "Cost today unavailable";
"30-day cost unavailable" = "30-day cost unavailable";
"Resets" = "Resets";
"Finish now" = "Finish now";
"Continue in background" = "Continue in background";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1348,3 +1348,5 @@
"Cost today unavailable" = "Coste de hoy: No disponible";
"30-day cost unavailable" = "Coste de 30 días: No disponible";
"Resets" = "Reinicios";
"Finish now" = "Finalizar ahora";
"Continue in background" = "Continuar en segundo plano";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/fa.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1353,3 +1353,5 @@
"Cost today unavailable" = "هزینهٔ امروز: در دسترس نیست";
"30-day cost unavailable" = "هزینهٔ ۳۰ روز: در دسترس نیست";
"Resets" = "بازنشانی‌ها";
"Finish now" = "اکنون تمام شود";
"Continue in background" = "ادامه در پس‌زمینه";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1349,3 +1349,5 @@
"Cost today unavailable" = "Coût aujourd’hui: Indisponible";
"30-day cost unavailable" = "Coût sur 30 j: Indisponible";
"Resets" = "Réinitialisations";
"Finish now" = "Terminer maintenant";
"Continue in background" = "Continuer en arrière-plan";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/gl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1349,3 +1349,5 @@
"Cost today unavailable" = "Custo de hoxe: Non dispoñible";
"30-day cost unavailable" = "Custo de 30 días: Non dispoñible";
"Resets" = "Reinicios";
"Finish now" = "Rematar agora";
"Continue in background" = "Continuar en segundo plano";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/id.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1353,3 +1353,5 @@
"Cost today unavailable" = "Biaya hari ini: Tidak tersedia";
"30-day cost unavailable" = "Biaya 30 hari: Tidak tersedia";
"Resets" = "Reset";
"Finish now" = "Selesaikan sekarang";
"Continue in background" = "Lanjutkan di latar belakang";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/it.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1353,3 +1353,5 @@
"Cost today unavailable" = "Costo oggi: Non disponibile";
"30-day cost unavailable" = "Costo 30 gg: Non disponibile";
"Resets" = "Ripristini";
"Finish now" = "Completa ora";
"Continue in background" = "Continua in background";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1350,3 +1350,5 @@
"Cost today unavailable" = "今日のコスト: 利用不可";
"30-day cost unavailable" = "30日間のコスト: 利用不可";
"Resets" = "リセット";
"Finish now" = "今すぐ完了";
"Continue in background" = "バックグラウンドで続行";
2 changes: 2 additions & 0 deletions Sources/CodexBar/Resources/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1317,3 +1317,5 @@
"Cost today unavailable" = "오늘 비용: 사용할 수 없음";
"30-day cost unavailable" = "30일 비용: 사용할 수 없음";
"Resets" = "재설정";
"Finish now" = "지금 완료";
"Continue in background" = "백그라운드에서 계속";
Loading