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 @@ -32,6 +32,7 @@
### Fixed

- OpenCode Go: include recorded local token counts in daily and per-model history without inventing costs or treating missing counts as zero (#3995). Thanks @Yuxin-Qiao!
- Usage & Spend: start long daily ledgers with the newest 30 rows and a Show all control, reducing initial layout work while preserving full-period totals and charts (#3998). Thanks @Yuxin-Qiao!
- Security: preserve browser-cookie denial across restarts and CLI configuration, and stage credential writes privately before atomic replacement (reported in #3986). Thanks @bo-vavrik!
- Provider plugins: update bundled QuickJS-NG to 0.17.0 with upstream memory-safety and numeric-correctness fixes (#3987). Thanks @bo-vavrik!
- OpenRouter: explain the required API key field instead of reporting no available fetch strategy, and clarify where regular and Management keys belong (#3966, #3969). Thanks @harjothkhara!
Expand Down
143 changes: 71 additions & 72 deletions Sources/CodexBar/PreferencesSpendDashboardPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -880,19 +880,45 @@ private struct SpendDashboardTrendPanel: View {
}
}

struct SpendDailyChartPresentation: Equatable {
enum Content: Equatable {
case chart
case unavailable
}
enum SpendChartContent: Equatable {
case chart
case unavailable
}

struct SpendChartSeries: Equatable {
let name: String
let provider: UsageProvider
}

private struct SpendChartStyle: ViewModifier {
let series: [SpendChartSeries]
let currencyCode: String

struct Series: Equatable {
let name: String
let provider: UsageProvider
func body(content: Content) -> some View {
content
.chartForegroundStyleScale(
domain: self.series.map(\.name),
range: self.series.map {
let color = ProviderAccentPalette.color(for: $0.provider)
return Color(red: color.red, green: color.green, blue: color.blue)
})
.chartLegend(position: .bottom, alignment: .leading, spacing: 8)
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisGridLine()
AxisValueLabel {
if let amount = value.as(Double.self) {
Text(UsageFormatter.compactCurrencyString(amount, currencyCode: self.currencyCode))
}
}
}
}
}
}

let content: Content
let series: [Series]
struct SpendDailyChartPresentation: Equatable {
let content: SpendChartContent
let series: [SpendChartSeries]
let dayCount: Int

init(dailyPoints: [SpendDashboardModel.DailyPoint], aggregateTotal: Double?) {
Expand All @@ -902,7 +928,7 @@ struct SpendDailyChartPresentation: Equatable {
var seenNames: Set<String> = []
self.series = dailyPoints.compactMap { point in
guard seenNames.insert(point.providerName).inserted else { return nil }
return Series(name: point.providerName, provider: point.provider)
return SpendChartSeries(name: point.providerName, provider: point.provider)
}
}

Expand Down Expand Up @@ -944,22 +970,7 @@ private struct SpendDailyChartContent: View {
}
.chartXScale(domain: self.group.chartDomain)
.chartXAxis { AxisMarks(format: self.dayFormat) }
.chartForegroundStyleScale(
domain: presentation.series.map(\.name),
range: presentation.series.map { self.providerColor($0.provider) })
.chartLegend(position: .bottom, alignment: .leading, spacing: 8)
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisGridLine()
AxisValueLabel {
if let amount = value.as(Double.self) {
Text(UsageFormatter.compactCurrencyString(
amount,
currencyCode: self.group.currencyCode))
}
}
}
}
.modifier(SpendChartStyle(series: presentation.series, currencyCode: self.group.currencyCode))
.frame(height: 170)
.accessibilityLabel(L("Daily estimated spend"))
.accessibilityValue(presentation.accessibilityValue)
Expand All @@ -978,11 +989,6 @@ private struct SpendDailyChartContent: View {
let day = point.day.formatted(self.dayFormat)
return "\(point.providerName), \(day)"
}

private func providerColor(_ provider: UsageProvider) -> Color {
let color = ProviderAccentPalette.color(for: provider)
return Color(red: color.red, green: color.green, blue: color.blue)
}
}

/// Finds the id of the highest-`stackEnd` point per grouping key (day/hour), regardless of how
Expand Down Expand Up @@ -1017,18 +1023,8 @@ private func spendStackedBarSegmentShape(isTopOfStack: Bool) -> UnevenRoundedRec
}

struct SpendHourlyChartPresentation: Equatable {
enum Content: Equatable {
case chart
case unavailable
}

struct Series: Equatable {
let name: String
let provider: UsageProvider
}

let content: Content
let series: [Series]
let content: SpendChartContent
let series: [SpendChartSeries]
let hourCount: Int
let includeDateInPointLabels: Bool

Expand All @@ -1039,7 +1035,7 @@ struct SpendHourlyChartPresentation: Equatable {
var seenNames: Set<String> = []
self.series = hourlyPoints.compactMap { point in
guard seenNames.insert(point.providerName).inserted else { return nil }
return Series(name: point.providerName, provider: point.provider)
return SpendChartSeries(name: point.providerName, provider: point.provider)
}
}

Expand Down Expand Up @@ -1084,22 +1080,7 @@ private struct SpendHourlyChartContent: View {
currencyCode: self.group.currencyCode)))
}
.chartXScale(domain: self.group.hourlyChartDomain ?? self.group.chartDomain)
.chartForegroundStyleScale(
domain: presentation.series.map(\.name),
range: presentation.series.map { self.providerColor($0.provider) })
.chartLegend(position: .bottom, alignment: .leading, spacing: 8)
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisGridLine()
AxisValueLabel {
if let amount = value.as(Double.self) {
Text(UsageFormatter.compactCurrencyString(
amount,
currencyCode: self.group.currencyCode))
}
}
}
}
.modifier(SpendChartStyle(series: presentation.series, currencyCode: self.group.currencyCode))
.frame(height: 170)
.accessibilityLabel(L("Hourly estimated spend"))
.accessibilityValue(presentation.accessibilityValue)
Expand All @@ -1116,11 +1097,6 @@ private struct SpendHourlyChartContent: View {
timeZone: self.group.timeZone,
includeDate: includeDate)
}

private func providerColor(_ provider: UsageProvider) -> Color {
let color = ProviderAccentPalette.color(for: provider)
return Color(red: color.red, green: color.green, blue: color.blue)
}
}

private enum SpendDailyLedgerLayout {
Expand All @@ -1141,8 +1117,27 @@ private enum SpendDailyLedgerLayout {
+ (horizontalPadding * 2)
}

/// Bound initial ledger layout on long ranges; expansion still exposes the complete history.
func spendDailyLedgerVisibleSummaries(
_ summaries: [SpendDashboardModel.DailySummary],
showsAllRows: Bool,
collapsedRowCount: Int) -> [SpendDashboardModel.DailySummary]
{
Array(summaries.suffix(showsAllRows ? summaries.count : collapsedRowCount).reversed())
}

private struct SpendDailyLedger: View {
let group: SpendDashboardModel.CurrencyGroup
@State private var showsAllRows = false

static let collapsedRowCount = 30

private var visibleSummaries: [SpendDashboardModel.DailySummary] {
spendDailyLedgerVisibleSummaries(
self.group.dailySummaries,
showsAllRows: self.showsAllRows,
collapsedRowCount: Self.collapsedRowCount)
}

var body: some View {
SpendDashboardPanel {
Expand All @@ -1161,20 +1156,24 @@ private struct SpendDailyLedger: View {
VStack(alignment: .leading, spacing: 0) {
self.header
Divider()
LazyVStack(spacing: 0) {
ForEach(self.group.dailySummaries.reversed()) { summary in
VStack(spacing: 0) {
ForEach(Array(self.visibleSummaries.enumerated()), id: \.element.id) { index, summary in
if index > 0 {
Divider()
}
SpendDailyLedgerRow(
summary: summary,
currencyCode: self.group.currencyCode,
timeZone: self.group.timeZone)
if summary.day != self.group.dailySummaries.first?.day {
Divider()
}
}
}
}
.frame(minWidth: SpendDailyLedgerLayout.minimumTableWidth, alignment: .leading)
}
SpendPanelExpandButton(
rowCount: self.group.dailySummaries.count,
collapsedRowCount: Self.collapsedRowCount,
showsAllRows: self.$showsAllRows)
}
}
}
Expand Down Expand Up @@ -1243,14 +1242,14 @@ private struct SpendDailyLedgerRow: View {
HStack(spacing: 5) {
ForEach(self.activeProviders.prefix(4)) { row in
SpendProviderIcon(provider: row.provider)
.help(row.displayName)
}
if self.activeProviders.count > 4 {
Text("+\(codexBarLocalizedInteger(self.activeProviders.count - 4))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.help(self.activeProviders.map(\.displayName).joined(separator: ", "))
}
}

Expand Down
18 changes: 18 additions & 0 deletions Tests/CodexBarTests/MenuBarLayoutRendererTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2004,6 +2004,24 @@ private final class MenuBarLayoutSizeCountingImage: NSImage {
}

extension MenuBarLayoutRendererTests {
@Test
func `default status item preserves the 0_65_0 width contract`() {
let icon = NSImage(size: NSSize(width: 16, height: 16))
let renderer = MenuBarLayoutRenderer()
let output = renderer.render(
layout: .defaultLayout, data: self.data(), icon: icon, options: self.options())
#expect(output.attributedTitle.string == "\u{2009}50%")
// The renderer and sizing implementation are unchanged from v0.65.0.
#expect(output.statusItemWidth(gap: .tight) == 49)
#expect(output.statusItemWidth(gap: .regular) == 56)
let iconOnly = renderer.render(
layout: MenuBarLayout(lines: [[.icon]]), data: self.data(), icon: icon, options: self.options())
#expect(iconOnly.statusItemWidth(gap: .tight) == 19)
#expect(iconOnly.statusItemWidth(gap: .regular) == 26)
print("STATUS_WIDTH default regular=\(output.statusItemWidth(gap: .regular)) "
+ "tight=\(output.statusItemWidth(gap: .tight)) iconOnly regular=26 tight=19")
}

@Test
func `plain single line status content reuses a template image by value`() throws {
let cache = MenuBarLayoutTitleCache(capacity: 2)
Expand Down
17 changes: 17 additions & 0 deletions Tests/CodexBarTests/SpendDashboardDailyLedgerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ import Testing
@testable import CodexBarCore

struct SpendDashboardDailyLedgerTests {
@Test(arguments: [0, 7, 30, 31, 365])
func `ledger expansion preserves every day in newest first order`(days: Int) {
let summaries = (0..<days).map { day in
SpendDashboardModel.DailySummary(
day: Self.now.addingTimeInterval(Double(day) * 86400),
providers: [],
totalTokens: day,
requestCount: day,
totalCost: Double(day))
}
let collapsed = spendDailyLedgerVisibleSummaries(summaries, showsAllRows: false, collapsedRowCount: 30)
#expect(collapsed.count == min(days, 30))
#expect(collapsed == Array(summaries.suffix(30).reversed()))
let expanded = spendDailyLedgerVisibleSummaries(summaries, showsAllRows: true, collapsedRowCount: 30)
#expect(expanded == Array(summaries.reversed()))
}

@Test(arguments: [true, false])
func `unpriced history outside the window is idle only with complete activity`(complete: Bool) throws {
let claude = Self.input(
Expand Down
94 changes: 94 additions & 0 deletions Tests/CodexBarTests/SpendDashboardLongRangeRenderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import AppKit
import CodexBarCore
import SwiftUI
import XCTest
@testable import CodexBar

/// Opt-in synthetic proof; never opens Settings or reads account configuration.
@MainActor
final class SpendDashboardLongRangeRenderTests: XCTestCase {
func test_longRangeStartsWithBoundedLedger() throws {
guard let path = ProcessInfo.processInfo.environment["CODEXBAR_LONG_RANGE_PROOF_DIR"] else {
throw XCTSkip("Set CODEXBAR_LONG_RANGE_PROOF_DIR to render the synthetic long-range ledger.")
}
let directory = URL(fileURLWithPath: path, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = try XCTUnwrap(TimeZone(secondsFromGMT: 0))
let now = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 9, day: 20, hour: 12)))
let dayFormat = DateFormatter()
dayFormat.calendar = calendar
dayFormat.timeZone = calendar.timeZone
dayFormat.locale = Locale(identifier: "en_US_POSIX")
dayFormat.dateFormat = "yyyy-MM-dd"
let daily = try (0..<365).map { offset in
let date = try XCTUnwrap(calendar.date(byAdding: .day, value: -offset, to: now))
return CostUsageDailyReport.Entry(
date: dayFormat.string(from: date),
inputTokens: 800,
outputTokens: 200,
totalTokens: 1000,
requestCount: 1,
costUSD: 1,
modelsUsed: ["fixture-model"],
modelBreakdowns: [.init(modelName: "fixture-model", costUSD: 1, totalTokens: 1000)])
}
let snapshot = CostUsageTokenSnapshot(
sessionTokens: 1000,
sessionCostUSD: 1,
last30DaysTokens: 365_000,
last30DaysCostUSD: 365,
historyDays: 365,
historyCoverageIsEstablished: true,
daily: daily,
updatedAt: now)
let input = SpendDashboardModel.ProviderInput(provider: .codex, displayName: "Codex", snapshot: snapshot)
var buildTimes: [Double] = []
var model = SpendDashboardModel(requestedDays: 365, groups: [])
for _ in 0..<11 {
let start = ContinuousClock.now
model = SpendDashboardModel.build(
inputs: [input], reportingPeriod: .allTime, now: now, calendar: calendar)
buildTimes.append(Self.milliseconds(start.duration(to: .now)))
}
let group = try XCTUnwrap(model.groups.first)
XCTAssertEqual(group.dailySummaries.count, 365)
XCTAssertEqual(group.totalCost, 365)
XCTAssertEqual(group.totalTokens, 365_000)
XCTAssertEqual(group.dailySummaries.compactMap(\.requestCount).reduce(0, +), 365)
print("LONG_RANGE model_build_median_ms=\(buildTimes.dropFirst().sorted()[5]) days=365 cost=365 tokens=365000")

var layoutTimes: [Double] = []
for iteration in 0..<4 {
let start = ContinuousClock.now
let view = SpendDashboardCurrencySection(group: group, requestedDays: model.requestedDays)
.padding(24)
.frame(width: 760)
.environment(\.locale, Locale(identifier: "en_US_POSIX"))
.background(Color(nsColor: .windowBackgroundColor))
let hosting = NSHostingView(rootView: view)
hosting.appearance = NSAppearance(named: .aqua)
let size = hosting.fittingSize
hosting.frame = CGRect(origin: .zero, size: size)
hosting.layoutSubtreeIfNeeded()
layoutTimes.append(Self.milliseconds(start.duration(to: .now)))
print("LONG_RANGE layout_ms=\(layoutTimes.last!) height=\(size.height) iteration=\(iteration)")
if iteration == 0 {
// Capture the first screenful at a fixed size so before/after images remain comparable.
let height = min(size.height, 2000)
let rect = CGRect(
x: 0, y: hosting.isFlipped ? 0 : size.height - height, width: size.width, height: height)
let bitmap = try XCTUnwrap(hosting.bitmapImageRepForCachingDisplay(in: rect))
hosting.cacheDisplay(in: rect, to: bitmap)
let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
try png.write(to: directory.appendingPathComponent("long-range.png"))
}
XCTAssertLessThan(size.height, 3000, "The initial ledger should not mount all 365 days.")
}
print("LONG_RANGE layout_median_ms=\(layoutTimes.dropFirst().sorted()[1])")
}

private static func milliseconds(_ duration: Duration) -> Double {
Double(duration.components.seconds) * 1000 + Double(duration.components.attoseconds) / 1e15
}
}
Loading
Loading