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
127 changes: 127 additions & 0 deletions Sources/CodexBar/CostHistoryChartMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ struct CostHistoryChartMenuView: View {
private let historyDays: Int
private let windowLabel: String?
private let projects: [CostUsageProjectBreakdown]
private let sessions: [CostUsageSessionBreakdown]
private let width: CGFloat
@State private var selectedDateKey: String?

Expand All @@ -59,6 +60,7 @@ struct CostHistoryChartMenuView: View {
historyDays: Int = 30,
windowLabel: String? = nil,
projects: [CostUsageProjectBreakdown] = [],
sessions: [CostUsageSessionBreakdown] = [],
width: CGFloat)
{
self.provider = provider
Expand All @@ -68,6 +70,7 @@ struct CostHistoryChartMenuView: View {
self.historyDays = max(1, min(365, historyDays))
self.windowLabel = windowLabel
self.projects = projects
self.sessions = sessions
self.width = width
}

Expand Down Expand Up @@ -284,6 +287,10 @@ struct CostHistoryChartMenuView: View {
}
.frame(height: Self.projectBlockHeight(projects: self.projects), alignment: .topLeading)
}

if !self.sessions.isEmpty {
self.sessionsBlock
}
}
.padding(.horizontal, 16)
.padding(.vertical, Self.verticalPadding)
Expand Down Expand Up @@ -327,8 +334,95 @@ struct CostHistoryChartMenuView: View {
private static let projectSourceIndent: CGFloat = 10
private static let projectMoreRowHeight: CGFloat = 16
private static let maxVisibleProjectSourceRows = 2
private static let sessionRowHeight: CGFloat = 44
private static let sessionRowSpacing: CGFloat = 5
private static let maxVisibleSessionRows = 5
static let verticalPadding: CGFloat = 10

private var sessionsBlock: some View {
let visibleCount = min(self.sessions.count, Self.maxVisibleSessionRows)
return VStack(alignment: .leading, spacing: Self.sessionRowSpacing) {
HStack {
Text("Conversations (\(self.windowLabel ?? Self.windowLabel(days: self.historyDays)))")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Text("\(self.sessions.count)")
.font(.caption2)
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
}
.frame(height: Self.detailPrimaryLineHeight, alignment: .leading)

ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: Self.sessionRowSpacing) {
ForEach(self.sessions) { session in
self.sessionRow(session)
}
}
}
.scrollIndicators(self.sessions.count > visibleCount ? .visible : .hidden)
.frame(
height: CGFloat(visibleCount) * Self.sessionRowHeight
+ CGFloat(max(visibleCount - 1, 0)) * Self.sessionRowSpacing,
alignment: .topLeading)
}
.frame(
height: Self.detailPrimaryLineHeight + Self.sessionRowSpacing
+ CGFloat(visibleCount) * Self.sessionRowHeight
+ CGFloat(max(visibleCount - 1, 0)) * Self.sessionRowSpacing,
alignment: .topLeading)
}

private func sessionRow(_ session: CostUsageSessionBreakdown) -> some View {
HStack(alignment: .top, spacing: 8) {
VStack(alignment: .leading, spacing: 1) {
Text("Session \(Self.shortSessionID(session.sessionID))")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Text(Self.sessionUsageLine(session))
.font(.caption2)
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
.lineLimit(1)
.truncationMode(.tail)
Text(session.lastActivity, format: .dateTime.month(.abbreviated).day().hour().minute())
.font(.caption2)
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
.lineLimit(1)
}
Spacer(minLength: 8)
Text(session.costUSD.map(self.costString) ?? "—")
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(height: Self.sessionRowHeight, alignment: .topLeading)
.accessibilityElement(children: .combine)
}

static func shortSessionID(_ sessionID: String) -> String {
let trimmed = sessionID.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count > 12 else { return trimmed }
return "\(trimmed.prefix(4))...\(trimmed.suffix(8))"
}

private static func sessionUsageLine(_ session: CostUsageSessionBreakdown) -> String {
let models = session.modelBreakdowns.map(\.modelName)
let modelLabel = if models.isEmpty {
"Unknown model"
} else if models.count == 1 {
models[0]
} else {
"\(models[0]) +\(models.count - 1)"
}
let input = session.inputTokens.map(UsageFormatter.tokenCountString) ?? "—"
let cached = session.cachedInputTokens.map(UsageFormatter.tokenCountString) ?? "—"
let output = session.outputTokens.map(UsageFormatter.tokenCountString) ?? "—"
return "\(modelLabel) · \(input) input · \(cached) cached · \(output) output"
}

static func windowLabel(days: Int) -> String {
if days == 1 {
return L("Today")
Expand Down Expand Up @@ -788,6 +882,7 @@ extension CostHistoryChartMenuView {
let hasDailyEntries: Bool
let daily: [VisibleDailyFingerprint]
let projects: [VisibleProjectFingerprint]
let sessions: [VisibleSessionFingerprint]
}

struct VisibleDailyFingerprint: Equatable {
Expand Down Expand Up @@ -824,11 +919,23 @@ extension CostHistoryChartMenuView {
let totalCostBitPattern: UInt64?
}

struct VisibleSessionFingerprint: Equatable {
let sessionID: String
let lastActivityBitPattern: UInt64
let inputTokens: Int?
let cachedInputTokens: Int?
let outputTokens: Int?
let totalTokens: Int?
let costBitPattern: UInt64?
let models: [VisibleModelBreakdownFingerprint]
}

static func renderFingerprint(
from snapshot: CostUsageTokenSnapshot,
provider: UsageProvider) -> RenderFingerprint
{
let projects = provider == .codex ? snapshot.projects : []
let sessions = provider == .codex ? snapshot.sessions : []
return RenderFingerprint(
currencyCode: snapshot.currencyCode,
historyDays: snapshot.historyDays,
Expand All @@ -854,6 +961,26 @@ extension CostHistoryChartMenuView {
totalTokens: source.totalTokens,
totalCostBitPattern: source.totalCostUSD.map(\.bitPattern))
})
},
sessions: sessions.map { session in
VisibleSessionFingerprint(
sessionID: session.sessionID,
lastActivityBitPattern: session.lastActivity.timeIntervalSince1970.bitPattern,
inputTokens: session.inputTokens,
cachedInputTokens: session.cachedInputTokens,
outputTokens: session.outputTokens,
totalTokens: session.totalTokens,
costBitPattern: session.costUSD.map(\.bitPattern),
models: session.modelBreakdowns.map { item in
VisibleModelBreakdownFingerprint(
modelName: item.modelName,
costBitPattern: item.costUSD.map(\.bitPattern),
totalTokens: item.totalTokens,
standardCostBitPattern: item.standardCostUSD.map(\.bitPattern),
priorityCostBitPattern: item.priorityCostUSD.map(\.bitPattern),
standardTokens: item.standardCostUSD == nil ? nil : item.standardTokens,
priorityTokens: item.priorityCostUSD == nil ? nil : item.priorityTokens)
})
})
}

Expand Down
13 changes: 13 additions & 0 deletions Sources/CodexBar/MenuCardView+ModelHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,15 @@ extension UsageMenuCardView.Model {
else {
return nil
}
// Local Codex session costs are independent from OAuth, CLI quota, and OpenAI web
// dashboard access. Do not present a failed account-level quota fetch as a failure of
// a valid local API-key ledger.
if input.codexLocalSessionCostLedgerEnabled,
self.hasLocalCodexTokenUsage(input),
self.isRemoteCodexQuotaFetchError(lastError)
{
return nil
}
if self.shouldShowRateLimitsUnavailablePlaceholder(input: input, lastError: lastError) {
return nil
}
Expand Down Expand Up @@ -402,6 +411,10 @@ extension UsageMenuCardView.Model {
self.tokenUsageSnapshot(input: input) != nil
}

private static func isRemoteCodexQuotaFetchError(_ error: String) -> Bool {
error.localizedCaseInsensitiveContains("Codex usage is temporarily unavailable")
}

private static func shouldShowRateLimitsUnavailablePlaceholder(input: Input, lastError: String? = nil) -> Bool {
let currentError = lastError ?? input.lastError
if let currentError = currentError?.trimmingCharacters(in: .whitespacesAndNewlines),
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 codexLocalSessionCostLedgerEnabled: Bool
let tokenCostInlineDashboardEnabled: Bool
let tokenCostMenuSectionEnabled: Bool
let costComparisonPeriodsEnabled: Bool
Expand Down Expand Up @@ -57,6 +58,7 @@ extension UsageMenuCardView.Model {
usageBarsShowUsed: Bool,
resetTimeDisplayStyle: ResetTimeDisplayStyle,
tokenCostUsageEnabled: Bool,
codexLocalSessionCostLedgerEnabled: Bool = false,
tokenCostInlineDashboardEnabled: Bool? = nil,
tokenCostMenuSectionEnabled: Bool? = nil,
costComparisonPeriodsEnabled: Bool = false,
Expand Down Expand Up @@ -91,6 +93,7 @@ extension UsageMenuCardView.Model {
self.usageBarsShowUsed = usageBarsShowUsed
self.resetTimeDisplayStyle = resetTimeDisplayStyle
self.tokenCostUsageEnabled = tokenCostUsageEnabled
self.codexLocalSessionCostLedgerEnabled = codexLocalSessionCostLedgerEnabled
self.tokenCostInlineDashboardEnabled = tokenCostInlineDashboardEnabled ?? tokenCostUsageEnabled
self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? tokenCostUsageEnabled
self.costComparisonPeriodsEnabled = costComparisonPeriodsEnabled
Expand Down
5 changes: 4 additions & 1 deletion Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ struct ProvidersPane: View {
isPresented: Binding(
get: { self.activeConfirmation != nil },
set: { isPresented in
if !isPresented { self.activeConfirmation = nil }
if !isPresented {
self.activeConfirmation = nil
}
}),
actions: {
if let active = self.activeConfirmation {
Expand Down Expand Up @@ -684,6 +686,7 @@ struct ProvidersPane: View {
usageBarsShowUsed: self.settings.usageBarsShowUsed,
resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle,
tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider),
codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled,
tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: provider),
// Display style only controls the main menu. Provider details always expose
// available cost data in their Usage section.
Expand Down
23 changes: 21 additions & 2 deletions Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ struct CodexProviderImplementation: ProviderImplementation {
].joined(separator: " ")

return [
ProviderSettingsToggleDescriptor(
id: "codex-local-session-cost-ledger",
title: "Local session cost estimates",
subtitle: [
"Uses this Mac's Codex sessions instead of the selected managed account's session history.",
"Works with organization API keys and does not require OpenAI billing or administrator access.",
"Uses locally cached or bundled model prices without making a network request.",
"This provider-specific toggle does not enable cost summaries for other providers.",
].joined(separator: " "),
binding: context.boolBinding(\.codexLocalSessionCostLedgerEnabled),
statusText: nil,
actions: [],
isVisible: nil,
onChange: nil,
onAppDidBecomeActive: nil,
onAppearWhenEnabled: nil),
ProviderSettingsToggleDescriptor(
id: "codex-historical-tracking",
title: "Historical tracking",
Expand Down Expand Up @@ -165,8 +181,11 @@ struct CodexProviderImplementation: ProviderImplementation {
return [
ProviderSettingsPickerDescriptor(
id: "codex-usage-source",
title: "Usage source",
subtitle: "Auto falls back to the next source if the preferred one fails.",
title: "Quota usage source",
subtitle: [
"Controls live session and weekly quota fetching only.",
"Local session cost estimates work independently.",
].joined(separator: " "),
binding: usageBinding,
options: usageOptions,
isVisible: nil,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ extension UsageStore {

func codexCreditsFetcher() -> UsageFetcher {
// Credits are remote Codex account state, so they need the same managed-home routing as the
// primary Codex usage fetch. Local token-cost scanning intentionally stays ambient-system scoped.
// primary Codex usage fetch. Token-cost scanning owns its selected managed or ambient scope separately.
self.makeFetchContext(provider: .codex, override: nil).fetcher
}

Expand Down
9 changes: 9 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,15 @@ extension SettingsStore {
}
}

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

var costUsageHistoryDays: Int {
get { self.defaultsState.costUsageHistoryDays }
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 @@ -37,6 +37,7 @@ extension SettingsStore {
_ = self.menuBarMetricPreferencesRaw
_ = self.copilotIconSecondaryWindowIDRaw
_ = self.costUsageEnabled
_ = self.codexLocalSessionCostLedgerEnabled
_ = self.costUsageHistoryDays
_ = self.costComparisonPeriodsEnabled
_ = self.costSummaryDisplayStyle
Expand Down
5 changes: 3 additions & 2 deletions Sources/CodexBar/SettingsStore+MenuPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,9 @@ extension SettingsStore {
}

func isCostUsageEffectivelyEnabled(for provider: UsageProvider) -> Bool {
self.costUsageEnabled
&& ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost
let isEnabled = self.costUsageEnabled ||
(provider == .codex && self.codexLocalSessionCostLedgerEnabled)
return isEnabled && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost
}

var resetTimeDisplayStyle: ResetTimeDisplayStyle {
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ extension SettingsStore {
let copilotBudgetExtrasEnabled = userDefaults.object(forKey: "copilotBudgetExtrasEnabled") as? Bool ?? false
let copilotIconSecondaryWindowIDRaw = Self.loadCopilotIconSecondaryWindowIDRaw(userDefaults: userDefaults)
let costUsageEnabled = userDefaults.object(forKey: "tokenCostUsageEnabled") as? Bool ?? false
let codexLocalSessionCostLedgerEnabled = userDefaults.object(
forKey: "codexLocalSessionCostLedgerEnabled") as? Bool ?? false
let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30
let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays))
let costComparisonPeriodsEnabled = userDefaults.object(
Expand Down Expand Up @@ -535,6 +537,7 @@ extension SettingsStore {
copilotBudgetExtrasEnabled: copilotBudgetExtrasEnabled,
copilotIconSecondaryWindowIDRaw: copilotIconSecondaryWindowIDRaw,
costUsageEnabled: costUsageEnabled,
codexLocalSessionCostLedgerEnabled: codexLocalSessionCostLedgerEnabled,
costUsageHistoryDays: costUsageHistoryDays,
costComparisonPeriodsEnabled: costComparisonPeriodsEnabled,
costSummaryDisplayStyleRaw: costSummaryDisplayStyleRaw,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ struct SettingsDefaultsState {
var copilotBudgetExtrasEnabled: Bool
var copilotIconSecondaryWindowIDRaw: String
var costUsageEnabled: Bool
var codexLocalSessionCostLedgerEnabled: Bool
var costUsageHistoryDays: Int
var costComparisonPeriodsEnabled: Bool
var costSummaryDisplayStyleRaw: String
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/StatusItemController+HostedSubmenus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ extension StatusItemController {
historyDays: tokenSnapshot.historyDays,
windowLabel: tokenSnapshot.historyLabel,
projects: provider == .codex ? tokenSnapshot.projects : [],
sessions: provider == .codex ? tokenSnapshot.sessions : [],
width: width)
let hosting = MenuHostingView(rootView: chartView)
hosting.applyMeasuredHeight(
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 @@ -127,6 +127,7 @@ extension StatusItemController {
usageBarsShowUsed: self.settings.usageBarsShowUsed,
resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle,
tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target),
codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled,
tokenCostInlineDashboardEnabled: self.settings.costSummaryShowsInlineDashboard(for: target),
tokenCostMenuSectionEnabled: !UsageStore.tokenCostRequiresProviderSnapshot(target) &&
self.settings.costSummaryShowsSubmenu(for: target),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ extension StatusItemController {
from: dashboard?.usageBreakdown ?? [])
var parts = [
"costEnabled=\(self.settings.costUsageEnabled ? "1" : "0")",
"codexLocalCost=\(self.settings.codexLocalSessionCostLedgerEnabled ? "1" : "0")",
"costStyle=\(self.settings.costSummaryDisplayStyle.rawValue)",
"openAIAttached=\(self.store.openAIDashboardAttachmentAuthorized ? "1" : "0")",
"openAILogin=\(self.store.openAIDashboardRequiresLogin ? "1" : "0")",
Expand Down
Loading