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 @@ -12,6 +12,7 @@
- Menu: move each usage window's used percentage and reset time into its title row, with all pace detail on one line (#2182). Thanks @jack24254029!

### Fixed
- Usage & Spend: keep validated Codex totals visible while the local scanner catches up, with refresh indicators in the dashboard and menu cost rows (#2397). Thanks @hhh2210!
- ZoomMate: preserve browser cookie scope so parent-domain sessions reach both API hosts without leaking host-only cookies (fixes #2507). Thanks @weddle!
- Sync: propagate provider configuration edits made by the CLI or directly in `config.json` to the iCloud fleet without echoing remotely applied writes.

Expand Down
26 changes: 20 additions & 6 deletions Sources/CodexBar/MenuCardView+Costs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ extension UsageMenuCardView.Model {
preferredCurrencyCode: String = "auto") -> String?
{
guard metadata.supportsCredits else { return nil }
if metadata.id == .codex, credits == nil, error == nil { return nil }
if metadata.id == .codex, credits == nil, error == nil {
return nil
}
if metadata.id == .amp,
let ampUsage = snapshot?.ampUsage,
let ampCredits = self.ampCreditsLine(ampUsage, preferredCurrencyCode: preferredCurrencyCode)
Expand Down Expand Up @@ -187,6 +189,7 @@ extension UsageMenuCardView.Model {
static func tokenUsageSection(
provider: UsageProvider,
enabled: Bool,
isRefreshing: Bool = false,
comparisonPeriodsEnabled: Bool,
snapshot: CostUsageTokenSnapshot?,
error: String?,
Expand Down Expand Up @@ -253,6 +256,7 @@ extension UsageMenuCardView.Model {
}
let err = (error?.isEmpty ?? true) ? nil : error
return TokenUsageSection(
isRefreshing: isRefreshing,
sessionLine: sessionLine,
monthLine: monthLine,
meteredLine: meteredLine,
Expand Down Expand Up @@ -338,13 +342,19 @@ extension UsageMenuCardView.Model {
return (entry, dayKey)
}
.max { lhs, rhs in
if lhs.dayKey != rhs.dayKey { return lhs.dayKey < rhs.dayKey }
if lhs.dayKey != rhs.dayKey {
return lhs.dayKey < rhs.dayKey
}
let lCost = lhs.entry.costUSD ?? -1
let rCost = rhs.entry.costUSD ?? -1
if lCost != rCost { return lCost < rCost }
if lCost != rCost {
return lCost < rCost
}
let lTokens = lhs.entry.totalTokens ?? -1
let rTokens = rhs.entry.totalTokens ?? -1
if lTokens != rTokens { return lTokens < rTokens }
if lTokens != rTokens {
return lTokens < rTokens
}
return lhs.entry.date < rhs.entry.date
}?.entry
}
Expand Down Expand Up @@ -396,8 +406,12 @@ extension UsageMenuCardView.Model {
private static func daysInBedrockBillingMonth(_ month: Int, year: Int) -> Int {
switch month {
case 2:
if year.isMultiple(of: 400) { return 29 }
if year.isMultiple(of: 100) { return 28 }
if year.isMultiple(of: 400) {
return 29
}
if year.isMultiple(of: 100) {
return 28
}
return year.isMultiple(of: 4) ? 29 : 28
case 4, 6, 9, 11:
return 30
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/MenuCardView+ModelInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ extension UsageMenuCardView.Model {
let usageBarsShowUsed: Bool
let resetTimeDisplayStyle: ResetTimeDisplayStyle
let tokenCostUsageEnabled: Bool
let tokenCostIsRefreshing: Bool
let codexLocalSessionCostLedgerEnabled: Bool
let tokenCostInlineDashboardEnabled: Bool
let tokenCostMenuSectionEnabled: Bool
Expand Down Expand Up @@ -62,6 +63,7 @@ extension UsageMenuCardView.Model {
usageBarsShowUsed: Bool,
resetTimeDisplayStyle: ResetTimeDisplayStyle,
tokenCostUsageEnabled: Bool,
tokenCostIsRefreshing: Bool = false,
codexLocalSessionCostLedgerEnabled: Bool = false,
tokenCostInlineDashboardEnabled: Bool? = nil,
tokenCostMenuSectionEnabled: Bool? = nil,
Expand Down Expand Up @@ -101,6 +103,7 @@ extension UsageMenuCardView.Model {
self.usageBarsShowUsed = usageBarsShowUsed
self.resetTimeDisplayStyle = resetTimeDisplayStyle
self.tokenCostUsageEnabled = tokenCostUsageEnabled
self.tokenCostIsRefreshing = tokenCostIsRefreshing
self.codexLocalSessionCostLedgerEnabled = codexLocalSessionCostLedgerEnabled
self.tokenCostInlineDashboardEnabled = tokenCostInlineDashboardEnabled ?? tokenCostUsageEnabled
self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? tokenCostUsageEnabled
Expand Down
17 changes: 14 additions & 3 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ struct UsageMenuCardView: View {
}

struct TokenUsageSection {
let isRefreshing: Bool
let sessionLine: String
let monthLine: String
let meteredLine: String?
Expand All @@ -123,6 +124,7 @@ struct UsageMenuCardView: View {
/// Explicit initializer so `meteredLine`/`comparisonLines` default to empty: callers
/// that predate them (and providers that never report them) keep their call sites.
init(
isRefreshing: Bool = false,
sessionLine: String,
monthLine: String,
meteredLine: String? = nil,
Expand All @@ -131,6 +133,7 @@ struct UsageMenuCardView: View {
errorLine: String?,
errorCopyText: String?)
{
self.isRefreshing = isRefreshing
self.sessionLine = sessionLine
self.monthLine = monthLine
self.meteredLine = meteredLine
Expand Down Expand Up @@ -457,9 +460,16 @@ private struct TokenUsageSectionContent: View {

var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(UsageMenuCardView.Model.tokenUsageHeader(provider: self.provider))
.font(.body)
.fontWeight(.medium)
HStack(spacing: 6) {
Text(UsageMenuCardView.Model.tokenUsageHeader(provider: self.provider))
.font(.body)
.fontWeight(.medium)
if self.tokenUsage.isRefreshing {
ProgressView()
.controlSize(.mini)
.accessibilityLabel(L("Refreshing"))
}
}
Text(self.tokenUsage.sessionLine)
.font(self.lineFont)
.lineLimit(1)
Expand Down Expand Up @@ -947,6 +957,7 @@ extension UsageMenuCardView.Model {
let tokenUsage = Self.tokenUsageSection(
provider: input.provider,
enabled: input.tokenCostMenuSectionEnabled,
isRefreshing: input.tokenCostIsRefreshing,
comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled,
snapshot: tokenUsageSnapshot,
error: input.tokenError,
Expand Down
23 changes: 21 additions & 2 deletions Sources/CodexBar/PreferencesSpendDashboardPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ struct SpendDashboardPane: View {
self.store = store
self._controller = State(initialValue: SpendDashboardController(requestBuilder: { mode in
await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode)
}, cachedLoader: { request in
await SpendDashboardSource.loadCached(request)
}))
}

Expand Down Expand Up @@ -293,11 +295,12 @@ struct SpendDashboardPane: View {
.frame(maxWidth: .infinity, minHeight: 220)
}
} else if self.controller.model.groups.isEmpty {
let emptyState = SpendDashboardEmptyState.make(isRefreshing: self.controller.isRefreshing)
SpendDashboardPanel {
ContentUnavailableView {
Label(L("No local cost history yet"), systemImage: "chart.bar.xaxis")
Label(emptyState.title, systemImage: "chart.bar.xaxis")
} description: {
Text(L("Turn on cost tracking or refresh after using a supported provider."))
Text(emptyState.message)
}
.frame(maxWidth: .infinity, minHeight: 220)
}
Expand Down Expand Up @@ -383,6 +386,22 @@ struct SpendDashboardPane: View {
}
}

struct SpendDashboardEmptyState: Equatable {
let title: String
let message: String

static func make(isRefreshing: Bool) -> Self {
if isRefreshing {
return Self(
title: L("Refreshing"),
message: L("Local estimated cost history across supported providers."))
}
return Self(
title: L("No local cost history yet"),
message: L("Turn on cost tracking or refresh after using a supported provider."))
}
}

private struct SpendCurrencySection: View {
let group: SpendDashboardModel.CurrencyGroup
let requestedDays: Int
Expand Down
108 changes: 107 additions & 1 deletion Sources/CodexBar/SpendDashboardController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ struct CodexSpendSnapshotLoadContext: Sendable {
enum SpendDashboardSource {
typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws
-> CostUsageTokenSnapshot
typealias CachedCodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async
-> CostUsageTokenSnapshot?
typealias CodexCacheRootResolver = @Sendable (CodexSpendScanRequest) -> URL

static let scanDays = 30

Expand Down Expand Up @@ -255,6 +258,70 @@ enum SpendDashboardSource {
})
}

static func loadCached(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult {
await self.loadCached(request, cacheRootResolver: { self.codexCacheRoot(for: $0) })
}

static func loadCached(
_ request: SpendDashboardLoadRequest,
cacheRootResolver: @escaping CodexCacheRootResolver) async -> SpendDashboardLoadResult
{
await self.loadCached(
request,
cacheRootResolver: cacheRootResolver,
cachedCodexSnapshotLoader: { context in
await CostUsageFetcher(cacheRoot: context.cacheRoot)
.loadCachedCodexTokenSnapshotForScopedHome(
now: context.now,
codexHomePath: context.account.homePath,
historyDays: context.historyDays,
includePiSessions: false,
includeProjectAndSessionBreakdowns: false)
})
}

static func loadCached(
_ request: SpendDashboardLoadRequest,
cachedCodexSnapshotLoader: CachedCodexSnapshotLoader) async -> SpendDashboardLoadResult
{
await self.loadCached(
request,
cacheRootResolver: { self.codexCacheRoot(for: $0) },
cachedCodexSnapshotLoader: cachedCodexSnapshotLoader)
}

private static func loadCached(
_ request: SpendDashboardLoadRequest,
cacheRootResolver: CodexCacheRootResolver,
cachedCodexSnapshotLoader: CachedCodexSnapshotLoader) async -> SpendDashboardLoadResult
{
var inputs = request.capturedInputs
for account in request.codexRequests {
guard !Task.isCancelled,
self.currentAuthFingerprint(for: account) == account.authFingerprint
else { continue }
let snapshot = await cachedCodexSnapshotLoader(CodexSpendSnapshotLoadContext(
account: account,
cacheRoot: cacheRootResolver(account),
now: request.now,
force: false,
historyDays: Self.scanDays,
refreshPricingInBackground: false,
includePiSessions: false))
guard !Task.isCancelled,
let snapshot,
self.currentAuthFingerprint(for: account) == account.authFingerprint
else { continue }
inputs.append(SpendDashboardModel.ProviderInput(
id: "codex:\(account.id)",
provider: .codex,
displayName: account.displayName,
modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,
snapshot: snapshot))
}
return SpendDashboardLoadResult(inputs: inputs, failedSourceIDs: request.unavailableSourceIDs)
}

static func load(
_ request: SpendDashboardLoadRequest,
codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult
Expand Down Expand Up @@ -498,7 +565,11 @@ enum SpendDashboardSource {
}

static func codexCacheRoot(for request: CodexSpendScanRequest) -> URL {
UsageStore.costUsageCacheDirectory()
let costUsageDirectory = UsageStore.costUsageCacheDirectory()
if request.source == .liveSystem {
return costUsageDirectory.deletingLastPathComponent()
Comment on lines +569 to +570

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep live cache keyed by Codex auth

When the live-system Codex account rotates in the same CODEX_HOME, this now points every live account at the shared ambient cache root instead of the cacheIdentity that includes the auth fingerprint. The cached dashboard primer only verifies the current auth before reading, but the persisted cost cache is not stamped with the auth that wrote it, so a report produced under the previous live account can be rehydrated under the newly signed-in account while the scan catches up. Keep the live cache account-scoped, or add an auth-stamp check before reusing the ambient cache.

Useful? React with 👍 / 👎.

}
return costUsageDirectory
.appendingPathComponent("accounts", isDirectory: true)
.appendingPathComponent(request.cacheIdentity, isDirectory: true)
}
Expand Down Expand Up @@ -577,6 +648,7 @@ final class SpendDashboardController {
typealias RequestBuilder = @MainActor @Sendable (SpendDashboardRequestBuildMode) async
-> SpendDashboardLoadRequest
typealias Loader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult
typealias CachedLoader = @Sendable (SpendDashboardLoadRequest) async -> SpendDashboardLoadResult

private enum ReconciliationObservation: Sendable {
case confirmedEmpty
Expand Down Expand Up @@ -674,6 +746,7 @@ final class SpendDashboardController {
private static let daysDefaultsKey = "settingsSpendDashboardDays"
private let userDefaults: UserDefaults
private let requestBuilder: RequestBuilder
private let cachedLoader: CachedLoader?
private let loader: Loader
private let nowProvider: @Sendable () -> Date
private var loadTask: Task<Void, Never>?
Expand All @@ -685,11 +758,13 @@ final class SpendDashboardController {
init(
userDefaults: UserDefaults = .standard,
requestBuilder: @escaping RequestBuilder,
cachedLoader: CachedLoader? = nil,
loader: @escaping Loader = SpendDashboardSource.load,
nowProvider: @escaping @Sendable () -> Date = { Date() })
{
self.userDefaults = userDefaults
self.requestBuilder = requestBuilder
self.cachedLoader = cachedLoader
self.loader = loader
self.nowProvider = nowProvider
self.selectedDays = Self.normalizedDays(userDefaults.integer(forKey: Self.daysDefaultsKey))
Expand Down Expand Up @@ -736,6 +811,12 @@ final class SpendDashboardController {
self.failedSourceCount = 0
self.rebuildModel()
}
let shouldPrimeCachedCodex: Bool = if case .ordinary = phase {
self.cachedLoader != nil && !Set(Self.codexOwnershipByID(configuration.codexAccountIdentities).keys)
.isSubset(of: Set(self.loadedInputs.map(\.id)))
} else {
false
}

guard configuration.costUsageEnabled, !configuration.providerIDs.isEmpty else {
self.loadedInputs = []
Expand All @@ -751,6 +832,18 @@ final class SpendDashboardController {
self.isRefreshing = true
self.loadTask = Task { [weak self] in
guard let self else { return }
if shouldPrimeCachedCodex, let cachedLoader = self.cachedLoader {
let cachedRequest = await self.requestBuilder(.captureOnly)
guard !Task.isCancelled,
generation == self.generation
else { return }
let cachedResult = await cachedLoader(cachedRequest)
guard !Task.isCancelled,
generation == self.generation,
cachedRequest.configuration == self.configuration
else { return }
self.applyCached(request: cachedRequest, result: cachedResult)
}
let request = await self.requestBuilder(phase.buildMode)
guard !Task.isCancelled,
generation == self.generation
Expand All @@ -764,6 +857,19 @@ final class SpendDashboardController {
}
}

private func applyCached(
request: SpendDashboardLoadRequest,
result: SpendDashboardLoadResult)
{
let cachedIDs = Set(result.inputs.map(\.id))
self.loadedInputs.removeAll { cachedIDs.contains($0.id) }
self.loadedInputs.append(contentsOf: result.inputs)
self.loadedAt = request.now
self.failedSourceCount = result.failedSourceCount
self.refreshRetainedCodexDisplayNames(request.configuration.codexAccountDisplayNames)
self.rebuildModel()
}

private func handleBuiltRequest(
_ request: SpendDashboardLoadRequest,
startedWith startConfiguration: SpendDashboardConfiguration,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/StatusItemController+MenuCardModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ extension StatusItemController {
usageBarsShowUsed: self.settings.usageBarsShowUsed,
resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle,
tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target),
tokenCostIsRefreshing: self.store.tokenCostRefreshIsActive(for: target),
codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled,
tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: target),
// openai/mistral's cost history always surfaces via the inline dashboard or a
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBar/UsageStore+TokenCost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,13 @@ extension UsageStore {
self.tokenRefreshInFlight.contains(provider)
}

func tokenCostRefreshIsActive(for provider: UsageProvider) -> Bool {
if self.tokenRefreshInFlight.contains(provider) {
return true
}
return provider == .codex && self.codexCostCatchUpActivity?.phase == .indexing
}

func tokenCostScope(for provider: UsageProvider) -> (codexHomePath: String?, signature: String) {
if provider == .vertexai {
return (nil, "vertexai:allow-claude-fallback=\(!self.isEnabled(.claude))")
Expand Down
Loading