diff --git a/CHANGELOG.md b/CHANGELOG.md index 596352d414..c5a7c48201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Settings: split provider pane "Settings" sections into "Menu bar" and "Connection" so metric pickers and auth/cookie/source controls are grouped by topic. ### Fixed +- Codex cost history: keep opening and refreshing the submenu fast as project history grows by comparing only the content it renders. Thanks @Yuxin-Qiao! - Codex cost history: keep model-less token events explicitly unattributed instead of pricing them as GPT-5 while preserving current turn model attribution. Thanks @hhh2210! - Gemini: recover expired Workspace and education OAuth sessions when current CLI packages omit `oauth2.js`, with explicit credential and install-path discovery fallbacks. Thanks @Yuxin-Qiao! - Codex accounts: confirm apparent weekly resets before publishing them and isolate reset detection by stable account ownership, preventing transient full gauges and confetti across same-email workspaces (#2054). Thanks @Yuxin-Qiao! diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 27fa756c07..581f684166 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -363,8 +363,7 @@ struct CostHistoryChartMenuView: View { var maxDetailRows = 0 var hasModeDetails = false for entry in sorted { - guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } - guard let date = self.dateFromDayKey(entry.date) else { continue } + guard let (costUSD, date) = self.chartPointInput(for: entry) else { continue } let point = Point( date: date, costUSD: costUSD, @@ -455,6 +454,12 @@ struct CostHistoryChartMenuView: View { return comps.date } + private static func chartPointInput(for entry: DailyEntry) -> (costUSD: Double, date: Date)? { + guard let costUSD = entry.costUSD, costUSD >= 0 else { return nil } + guard let date = self.dateFromDayKey(entry.date) else { return nil } + return (costUSD, date) + } + private static func peakPoint(model: Model) -> Point? { guard let key = model.peakKey else { return nil } return model.pointsByDateKey[key] @@ -769,10 +774,29 @@ extension CostHistoryChartMenuView { let historyDays: Int let windowLabel: String? let totalCostBitPattern: UInt64? - let daily: [DailyEntry] + let hasDailyEntries: Bool + let daily: [VisibleDailyFingerprint] let projects: [VisibleProjectFingerprint] } + struct VisibleDailyFingerprint: Equatable { + let date: String + let totalTokens: Int? + let requestCount: Int? + let costBitPattern: UInt64? + let modelBreakdowns: [VisibleModelBreakdownFingerprint] + } + + struct VisibleModelBreakdownFingerprint: Equatable { + let modelName: String + let costBitPattern: UInt64? + let totalTokens: Int? + let standardCostBitPattern: UInt64? + let priorityCostBitPattern: UInt64? + let standardTokens: Int? + let priorityTokens: Int? + } + struct VisibleProjectFingerprint: Equatable { let name: String let path: String? @@ -789,33 +813,22 @@ extension CostHistoryChartMenuView { let totalCostBitPattern: UInt64? } - static func renderFingerprint(from snapshot: CostUsageTokenSnapshot) -> RenderFingerprint { - self.makeRenderFingerprint(RenderFingerprintInputs( + static func renderFingerprint( + from snapshot: CostUsageTokenSnapshot, + provider: UsageProvider) -> RenderFingerprint + { + let projects = provider == .codex ? snapshot.projects : [] + return RenderFingerprint( currencyCode: snapshot.currencyCode, historyDays: snapshot.historyDays, windowLabel: snapshot.historyLabel, - totalCostUSD: snapshot.last30DaysCostUSD, - daily: snapshot.daily, - projects: snapshot.projects)) - } - - private struct RenderFingerprintInputs { - let currencyCode: String - let historyDays: Int - let windowLabel: String? - let totalCostUSD: Double? - let daily: [DailyEntry] - let projects: [CostUsageProjectBreakdown] - } - - private static func makeRenderFingerprint(_ inputs: RenderFingerprintInputs) -> RenderFingerprint { - RenderFingerprint( - currencyCode: inputs.currencyCode, - historyDays: inputs.historyDays, - windowLabel: inputs.windowLabel, - totalCostBitPattern: inputs.totalCostUSD.map(\.bitPattern), - daily: inputs.daily, - projects: Array(inputs.projects.prefix(self.maxVisibleProjectRows)).map { project in + totalCostBitPattern: snapshot.last30DaysCostUSD.map(\.bitPattern), + hasDailyEntries: !snapshot.daily.isEmpty, + daily: snapshot.daily + .filter { self.chartPointInput(for: $0) != nil } + .sorted { $0.date < $1.date } + .map(self.visibleDailyFingerprint), + projects: Array(projects.prefix(self.maxVisibleProjectRows)).map { project in let visibleSources = self.visibleProjectSources(project) return VisibleProjectFingerprint( name: project.name, @@ -833,6 +846,24 @@ extension CostHistoryChartMenuView { }) } + private static func visibleDailyFingerprint(_ entry: DailyEntry) -> VisibleDailyFingerprint { + VisibleDailyFingerprint( + date: entry.date, + totalTokens: entry.totalTokens, + requestCount: entry.requestCount, + costBitPattern: entry.costUSD.map(\.bitPattern), + modelBreakdowns: self.orderedBreakdownItems(entry.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) + }) + } + static func _defaultSelectedDateKeyForTesting(provider: UsageProvider, daily: [DailyEntry]) -> String? { self.defaultSelectedDateKey(model: self.makeModel(provider: provider, daily: daily)) } diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 79bf4ecef5..52ffa1628c 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -306,7 +306,7 @@ extension StatusItemController { guard let snapshot = self.tokenSnapshotForCostHistorySubmenu(provider: provider) else { return .text("none") } - return .costHistory(CostHistoryChartMenuView.renderFingerprint(from: snapshot)) + return .costHistory(CostHistoryChartMenuView.renderFingerprint(from: snapshot, provider: provider)) } private func usageHistoryRenderSignature(for provider: UsageProvider) -> String { diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift index b32da0b411..13060cbe06 100644 --- a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -291,8 +291,8 @@ struct CostHistoryChartMenuViewTests { @MainActor func `render fingerprint is stable for identical snapshots`() { let snapshot = Self.makeSnapshot(dailyCost: 1.23, projectCount: 5) - let first = CostHistoryChartMenuView.renderFingerprint(from: snapshot) - let second = CostHistoryChartMenuView.renderFingerprint(from: snapshot) + let first = CostHistoryChartMenuView.renderFingerprint(from: snapshot, provider: .codex) + let second = CostHistoryChartMenuView.renderFingerprint(from: snapshot, provider: .codex) #expect(first == second) #expect(first.projects.count == 5) @@ -302,8 +302,12 @@ struct CostHistoryChartMenuViewTests { @Test @MainActor func `render fingerprint changes when daily cost changes`() { - let before = CostHistoryChartMenuView.renderFingerprint(from: Self.makeSnapshot(dailyCost: 1.0)) - let after = CostHistoryChartMenuView.renderFingerprint(from: Self.makeSnapshot(dailyCost: 2.0)) + let before = CostHistoryChartMenuView.renderFingerprint( + from: Self.makeSnapshot(dailyCost: 1.0), + provider: .codex) + let after = CostHistoryChartMenuView.renderFingerprint( + from: Self.makeSnapshot(dailyCost: 2.0), + provider: .codex) #expect(before != after) } @@ -313,25 +317,25 @@ struct CostHistoryChartMenuViewTests { func `render fingerprint changes for total currency history window and label`() { let base = Self.makeSnapshot(dailyCost: 1.0) #expect( - CostHistoryChartMenuView.renderFingerprint(from: base) + CostHistoryChartMenuView.renderFingerprint(from: base, provider: .codex) != CostHistoryChartMenuView.renderFingerprint(from: Self.makeSnapshot( dailyCost: 1.0, - totalCostUSD: 9.99))) + totalCostUSD: 9.99), provider: .codex)) #expect( - CostHistoryChartMenuView.renderFingerprint(from: base) + CostHistoryChartMenuView.renderFingerprint(from: base, provider: .codex) != CostHistoryChartMenuView.renderFingerprint(from: Self.makeSnapshot( dailyCost: 1.0, - currencyCode: "EUR"))) + currencyCode: "EUR"), provider: .codex)) #expect( - CostHistoryChartMenuView.renderFingerprint(from: base) + CostHistoryChartMenuView.renderFingerprint(from: base, provider: .codex) != CostHistoryChartMenuView.renderFingerprint(from: Self.makeSnapshot( dailyCost: 1.0, - historyDays: 7))) + historyDays: 7), provider: .codex)) #expect( - CostHistoryChartMenuView.renderFingerprint(from: base) + CostHistoryChartMenuView.renderFingerprint(from: base, provider: .codex) != CostHistoryChartMenuView.renderFingerprint(from: Self.makeSnapshot( dailyCost: 1.0, - historyLabel: "Last week"))) + historyLabel: "Last week"), provider: .codex)) } @Test @@ -380,6 +384,183 @@ struct CostHistoryChartMenuViewTests { #expect(base != Self.fingerprint(dailyCost: 1.0, daily: reorderedDaily, projects: [])) } + @Test + @MainActor + func `render fingerprint ignores hidden daily accounting fields and source order`() { + let visibleModel = CostUsageDailyReport.ModelBreakdown( + modelName: "model-visible", + costUSD: 0.75, + totalTokens: 120, + requestCount: 1, + standardCostUSD: 0.5, + priorityCostUSD: 0.25, + standardTokens: 80, + priorityTokens: 40) + let base = CostUsageDailyReport.Entry( + date: "2026-06-07", + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 20, + cacheCreationTokens: 10, + totalTokens: 150, + requestCount: 2, + costUSD: 1, + modelsUsed: ["model-visible"], + modelBreakdowns: [visibleModel]) + let hiddenFieldsChanged = CostUsageDailyReport.Entry( + date: base.date, + inputTokens: 999, + outputTokens: 888, + cacheReadTokens: 777, + cacheCreationTokens: 666, + totalTokens: base.totalTokens, + requestCount: base.requestCount, + costUSD: base.costUSD, + modelsUsed: ["unused-model-name"], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown( + modelName: visibleModel.modelName, + costUSD: visibleModel.costUSD, + totalTokens: visibleModel.totalTokens, + requestCount: 999, + standardCostUSD: visibleModel.standardCostUSD, + priorityCostUSD: visibleModel.priorityCostUSD, + standardTokens: visibleModel.standardTokens, + priorityTokens: visibleModel.priorityTokens)]) + + #expect(Self.fingerprint(daily: [base]) == Self.fingerprint(daily: [hiddenFieldsChanged])) + + let secondDay = Self.entry(date: "2026-06-08", modelCount: 1) + #expect( + Self.fingerprint(daily: [base, secondDay]) + == Self.fingerprint(daily: [secondDay, base])) + + let hiddenModeTokens = CostUsageDailyReport.ModelBreakdown( + modelName: visibleModel.modelName, + costUSD: visibleModel.costUSD, + totalTokens: visibleModel.totalTokens, + standardTokens: 1, + priorityTokens: 2) + let changedHiddenModeTokens = CostUsageDailyReport.ModelBreakdown( + modelName: visibleModel.modelName, + costUSD: visibleModel.costUSD, + totalTokens: visibleModel.totalTokens, + standardTokens: 999, + priorityTokens: 888) + #expect( + Self.fingerprint(daily: [Self.entry(modelBreakdowns: [hiddenModeTokens])]) + == Self.fingerprint(daily: [Self.entry(modelBreakdowns: [changedHiddenModeTokens])])) + } + + @Test + @MainActor + func `render fingerprint excludes invalid daily rows that the chart drops`() { + let invalidRows = [ + Self.dailyEntry(date: "2026-06-07", costUSD: nil), + Self.dailyEntry(date: "2026-06-08", costUSD: -1), + Self.dailyEntry(date: "not-a-date", costUSD: 1), + ] + let differentInvalidRows = [ + Self.dailyEntry(date: "2026-06-09", costUSD: nil), + Self.dailyEntry(date: "2026-06-10", costUSD: -99), + Self.dailyEntry(date: "still-not-a-date", costUSD: 99), + ] + let empty = Self.fingerprint(daily: []) + + #expect(Self.fingerprint(daily: invalidRows) == Self.fingerprint(daily: differentInvalidRows)) + #expect(Self.fingerprint(daily: invalidRows) != empty) + #expect(Self.fingerprint(daily: [Self.dailyEntry(date: "2026-06-07", costUSD: 1)]) != empty) + } + + @Test + @MainActor + func `render fingerprint tracks every visible model breakdown field`() { + let base = CostUsageDailyReport.ModelBreakdown( + modelName: "model-visible", + costUSD: 1, + totalTokens: 100, + standardCostUSD: 0.75, + priorityCostUSD: 0.25, + standardTokens: 75, + priorityTokens: 25) + let variants = [ + CostUsageDailyReport.ModelBreakdown( + modelName: "model-renamed", + costUSD: base.costUSD, + totalTokens: base.totalTokens, + standardCostUSD: base.standardCostUSD, + priorityCostUSD: base.priorityCostUSD, + standardTokens: base.standardTokens, + priorityTokens: base.priorityTokens), + CostUsageDailyReport.ModelBreakdown( + modelName: base.modelName, + costUSD: 2, + totalTokens: base.totalTokens, + standardCostUSD: base.standardCostUSD, + priorityCostUSD: base.priorityCostUSD, + standardTokens: base.standardTokens, + priorityTokens: base.priorityTokens), + CostUsageDailyReport.ModelBreakdown( + modelName: base.modelName, + costUSD: base.costUSD, + totalTokens: 200, + standardCostUSD: base.standardCostUSD, + priorityCostUSD: base.priorityCostUSD, + standardTokens: base.standardTokens, + priorityTokens: base.priorityTokens), + CostUsageDailyReport.ModelBreakdown( + modelName: base.modelName, + costUSD: base.costUSD, + totalTokens: base.totalTokens, + standardCostUSD: 0.5, + priorityCostUSD: base.priorityCostUSD, + standardTokens: base.standardTokens, + priorityTokens: base.priorityTokens), + CostUsageDailyReport.ModelBreakdown( + modelName: base.modelName, + costUSD: base.costUSD, + totalTokens: base.totalTokens, + standardCostUSD: base.standardCostUSD, + priorityCostUSD: 0.5, + standardTokens: base.standardTokens, + priorityTokens: base.priorityTokens), + CostUsageDailyReport.ModelBreakdown( + modelName: base.modelName, + costUSD: base.costUSD, + totalTokens: base.totalTokens, + standardCostUSD: base.standardCostUSD, + priorityCostUSD: base.priorityCostUSD, + standardTokens: 50, + priorityTokens: base.priorityTokens), + CostUsageDailyReport.ModelBreakdown( + modelName: base.modelName, + costUSD: base.costUSD, + totalTokens: base.totalTokens, + standardCostUSD: base.standardCostUSD, + priorityCostUSD: base.priorityCostUSD, + standardTokens: base.standardTokens, + priorityTokens: 50), + ] + let baseFingerprint = Self.fingerprint(daily: [Self.entry(modelBreakdowns: [base])]) + + for variant in variants { + #expect(baseFingerprint != Self.fingerprint(daily: [Self.entry(modelBreakdowns: [variant])])) + } + } + + @Test + @MainActor + func `render fingerprint excludes projects hidden for non-codex providers`() { + let first = Self.fingerprint( + projects: [Self.makeProject(index: 0, sourceCount: 2)], + provider: .claude) + let changed = Self.fingerprint( + projects: [Self.makeProject(index: 0, sourceCount: 2, totalCostUSD: 99)], + provider: .claude) + + #expect(first.projects.isEmpty) + #expect(first == changed) + } + @Test @MainActor func `render fingerprint tracks visible project and source fields only`() { @@ -413,7 +594,7 @@ struct CostHistoryChartMenuViewTests { changedTopProjectTotals[0] = Self.makeProject(index: 0, sourceCount: 3, totalCostUSD: 42.0, totalTokens: 9999) #expect(base != Self.fingerprint(totalCostUSD: 6.0, daily: daily, projects: changedTopProjectTotals)) - var reorderedProjects = Array(projects.reversed()) + let reorderedProjects = Array(projects.reversed()) #expect(base != Self.fingerprint(totalCostUSD: 6.0, daily: daily, projects: reorderedProjects)) var promotedHiddenProject = projects @@ -523,6 +704,30 @@ struct CostHistoryChartMenuViewTests { : nil) } + private static func entry( + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown]) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: "2026-06-07", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: 1, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + + private static func dailyEntry(date: String, costUSD: Double?) -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: date, + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: costUSD, + modelsUsed: nil, + modelBreakdowns: nil) + } + private static func makeSnapshot( dailyCost: Double = 1.0, projectCount: Int = 0, @@ -562,7 +767,8 @@ struct CostHistoryChartMenuViewTests { historyDays: Int = 30, historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry]? = nil, - projects: [CostUsageProjectBreakdown]? = nil) -> CostHistoryChartMenuView.RenderFingerprint + projects: [CostUsageProjectBreakdown]? = nil, + provider: UsageProvider = .codex) -> CostHistoryChartMenuView.RenderFingerprint { CostHistoryChartMenuView.renderFingerprint(from: self.makeSnapshot( dailyCost: dailyCost, @@ -571,7 +777,7 @@ struct CostHistoryChartMenuViewTests { historyDays: historyDays, historyLabel: historyLabel, daily: daily, - projects: projects)) + projects: projects), provider: provider) } private static func makeProjects(count: Int, sourcesPerProject: Int) -> [CostUsageProjectBreakdown] { @@ -609,8 +815,12 @@ struct CostHistoryChartMenuViewTests { let sources = (0..