Skip to content
Closed
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
212 changes: 208 additions & 4 deletions Sources/CodexBar/CostHistoryChartMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ struct CostHistoryChartMenuView: View {
private let currencyCode: String
private let historyDays: Int
private let windowLabel: String?
private let projects: [CostUsageProjectBreakdown]
private let width: CGFloat
private let onHeightChange: ((CGFloat) -> Void)?
@State private var selectedDateKey: String?
Expand All @@ -58,6 +59,7 @@ struct CostHistoryChartMenuView: View {
currencyCode: String = "USD",
historyDays: Int = 30,
windowLabel: String? = nil,
projects: [CostUsageProjectBreakdown] = [],
onHeightChange: ((CGFloat) -> Void)? = nil,
width: CGFloat)
{
Expand All @@ -67,6 +69,7 @@ struct CostHistoryChartMenuView: View {
self.currencyCode = currencyCode
self.historyDays = max(1, min(365, historyDays))
self.windowLabel = windowLabel
self.projects = projects
self.onHeightChange = onHeightChange
self.width = width
}
Expand Down Expand Up @@ -224,6 +227,38 @@ struct CostHistoryChartMenuView: View {
.truncationMode(.head)
.frame(height: Self.detailPrimaryLineHeight, alignment: .leading)
}

if !self.projects.isEmpty {
VStack(alignment: .leading, spacing: Self.projectRowSpacing) {
Text("Projects")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(height: Self.detailPrimaryLineHeight, alignment: .leading)
ForEach(Array(self.projects.prefix(Self.maxVisibleProjectRows)), id: \.projectRowID) { project in
VStack(alignment: .leading, spacing: Self.projectSourceSpacing) {
self.projectParentRow(project)
if project.sources.count > 1 {

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 Show single differing source paths

When a project has exactly one source whose path differs from the canonical project path, such as usage that came only from a .codex/worktrees/... checkout, this condition hides the only source row. The submenu then shows the rolled-up main worktree path but drops the source CWD detail that the project rollup is supposed to preserve; consider showing sources when the sole source path differs from project.path, not only when there are multiple sources.

Useful? React with 👍 / 👎.

ForEach(Array(project.sources.prefix(Self.maxVisibleProjectSourceRows)), id: \.sourceRowID) {
source in
self.projectSourceRow(source)
}
let hiddenSourceCount = project.sources.count - Self.maxVisibleProjectSourceRows
if hiddenSourceCount > 0 {
Text("+ \(hiddenSourceCount) more")
.font(.caption2)
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
.lineLimit(1)
.padding(.leading, Self.projectSourceIndent)
.frame(height: Self.projectMoreRowHeight, alignment: .leading)
}
}
}
.frame(height: Self.projectEntryHeight(project), alignment: .topLeading)
}
}
.frame(height: Self.projectBlockHeight(projects: self.projects), alignment: .topLeading)
}
}
.padding(.horizontal, 16)
.padding(.vertical, Self.verticalPadding)
Expand Down Expand Up @@ -252,13 +287,25 @@ struct CostHistoryChartMenuView: View {
private static let chartHeight: CGFloat = 114
private static let axisLabelAreaHeight: CGFloat = 16
private static let outerSpacing: CGFloat = 10
private static let projectRowHeight: CGFloat = 31
private static let projectRowSpacing: CGFloat = 5
private static let maxVisibleProjectRows = 5
private static let projectSourceRowHeight: CGFloat = 29
private static let projectSourceSpacing: CGFloat = 3
private static let projectSourceIndent: CGFloat = 10
private static let projectMoreRowHeight: CGFloat = 16
private static let maxVisibleProjectSourceRows = 2
static let verticalPadding: CGFloat = 10

/// Deterministic total height of the rendered card for a given selection. NSMenu's modal
/// tracking run loop never delivers SwiftUI `onPreferenceChange`, so the live height can't be
/// measured via a GeometryReader while the menu is open. Every component height is fixed, so
/// we compute the total directly and resize from the hover handler instead.
private static func totalCardHeight(rows: [DetailRow], hasTotal: Bool) -> CGFloat {
private static func totalCardHeight(
rows: [DetailRow],
hasTotal: Bool,
projects: [CostUsageProjectBreakdown] = []) -> CGFloat
{
var height = self.verticalPadding * 2
height += self.chartHeight
height += self.axisLabelAreaHeight
Expand All @@ -268,9 +315,26 @@ struct CostHistoryChartMenuView: View {
height += self.outerSpacing
height += self.detailPrimaryLineHeight
}
if !projects.isEmpty {
height += self.outerSpacing
height += self.projectBlockHeight(projects: projects)
}
return height
}

private static func totalCardHeight(rows: [DetailRow], hasTotal: Bool, projectCount: Int) -> CGFloat {
let projects = (0..<projectCount).map { index in
CostUsageProjectBreakdown(
name: "Project \(index)",
path: "/tmp/project-\(index)",
totalTokens: nil,
totalCostUSD: nil,
daily: [],
modelBreakdowns: nil)
}
return self.totalCardHeight(rows: rows, hasTotal: hasTotal, projects: projects)
}

static func windowLabel(days: Int) -> String {
if days == 1 {
return L("Today")
Expand Down Expand Up @@ -403,6 +467,24 @@ struct CostHistoryChartMenuView: View {
return rowHeights + spacing
}

private static func projectBlockHeight(projects: [CostUsageProjectBreakdown]) -> CGFloat {
let visibleProjects = Array(projects.prefix(self.maxVisibleProjectRows))
guard !visibleProjects.isEmpty else { return 0 }
return self.detailPrimaryLineHeight
+ self.projectRowSpacing
+ visibleProjects.reduce(CGFloat(0)) { $0 + self.projectEntryHeight($1) }
+ CGFloat(max(visibleProjects.count - 1, 0)) * self.projectRowSpacing
}

private static func projectEntryHeight(_ project: CostUsageProjectBreakdown) -> CGFloat {
guard project.sources.count > 1 else { return self.projectRowHeight }
let visibleSources = min(project.sources.count, self.maxVisibleProjectSourceRows)
let moreRows = project.sources.count > self.maxVisibleProjectSourceRows ? 1 : 0
return self.projectRowHeight
+ CGFloat(visibleSources) * (self.projectSourceRowHeight + self.projectSourceSpacing)
+ CGFloat(moreRows) * (self.projectMoreRowHeight + self.projectSourceSpacing)
}

private static func defaultSelectedDateKey(model: Model) -> String? {
model.dateKeys.last?.key
}
Expand Down Expand Up @@ -469,7 +551,79 @@ struct CostHistoryChartMenuView: View {
private func notifyHeightChange(selectedDateKey: String?, model: Model) {
guard let onHeightChange = self.onHeightChange else { return }
let rows = selectedDateKey.map { self.breakdownRows(key: $0, model: model) } ?? []
onHeightChange(Self.totalCardHeight(rows: rows, hasTotal: self.totalCostUSD != nil))
onHeightChange(Self.totalCardHeight(
rows: rows,
hasTotal: self.totalCostUSD != nil,
projects: self.projects))
}

private func projectSummary(_ project: CostUsageProjectBreakdown) -> String {
let cost = project.totalCostUSD
.map { self.costString($0) } ?? "—"
guard let totalTokens = project.totalTokens else { return cost }
return "\(cost) · \(L("%@ tokens", UsageFormatter.tokenCountString(totalTokens)))"
}

@ViewBuilder
private func projectParentRow(_ project: CostUsageProjectBreakdown) -> some View {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 8) {
Text(project.name)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 8)
Text(self.projectSummary(project))
.font(.caption2)
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
.lineLimit(1)
.truncationMode(.head)
}
if let path = project.path {
Text(path)
.font(.caption2)
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
.lineLimit(1)
.truncationMode(.middle)
}
}
.frame(height: Self.projectRowHeight, alignment: .leading)
}

@ViewBuilder
private func projectSourceRow(_ source: CostUsageProjectSourceBreakdown) -> some View {
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 6) {
Text(source.name)
.font(.caption2)
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 6)
Text(self.projectSourceSummary(source))
.font(.caption2)
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
.lineLimit(1)
.truncationMode(.head)
}
if let path = source.path {
Text(path)
.font(.caption2)
.foregroundStyle(Color(nsColor: .quaternaryLabelColor))
.lineLimit(1)
.truncationMode(.middle)
}
}
.padding(.leading, Self.projectSourceIndent)
.frame(height: Self.projectSourceRowHeight, alignment: .leading)
}

private func projectSourceSummary(_ source: CostUsageProjectSourceBreakdown) -> String {
let cost = source.totalCostUSD
.map { self.costString($0) } ?? "—"
guard let totalTokens = source.totalTokens else { return cost }
return "\(cost) · \(L("%@ tokens", UsageFormatter.tokenCountString(totalTokens)))"
}

private func nearestDateKey(to date: Date, model: Model) -> String? {
Expand Down Expand Up @@ -625,7 +779,11 @@ extension CostHistoryChartMenuView {
return self.detailBlockHeight(rows: rows)
}

static func _totalCardHeightForTesting(modeSubtitlePresence: [Bool], hasTotal: Bool) -> CGFloat {
static func _totalCardHeightForTesting(
modeSubtitlePresence: [Bool],
hasTotal: Bool,
projectCount: Int = 0) -> CGFloat
{
let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in
DetailRow(
id: "\(index)",
Expand All @@ -634,6 +792,52 @@ extension CostHistoryChartMenuView {
modeSubtitle: hasModeSubtitle ? "Mode" : nil,
accentColor: .blue)
}
return self.totalCardHeight(rows: rows, hasTotal: hasTotal)
return self.totalCardHeight(rows: rows, hasTotal: hasTotal, projectCount: projectCount)
}

static func _totalCardHeightForTesting(
modeSubtitlePresence: [Bool],
hasTotal: Bool,
projectSourceCounts: [Int]) -> CGFloat
{
let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in
DetailRow(
id: "\(index)",
title: "Model \(index)",
subtitle: "Cost",
modeSubtitle: hasModeSubtitle ? "Mode" : nil,
accentColor: .blue)
}
let projects = projectSourceCounts.enumerated().map { index, sourceCount in
CostUsageProjectBreakdown(
name: "Project \(index)",
path: "/tmp/project-\(index)",
totalTokens: nil,
totalCostUSD: nil,
daily: [],
modelBreakdowns: nil,
sources: (0..<sourceCount).map { sourceIndex in
CostUsageProjectSourceBreakdown(
name: "Source \(sourceIndex)",
path: "/tmp/project-\(index)-source-\(sourceIndex)",
totalTokens: nil,
totalCostUSD: nil,
daily: [],
modelBreakdowns: nil)
})
}
return self.totalCardHeight(rows: rows, hasTotal: hasTotal, projects: projects)
}
}

private extension CostUsageProjectBreakdown {
var projectRowID: String {
self.path ?? "unknown:\(self.name)"
}
}

private extension CostUsageProjectSourceBreakdown {
var sourceRowID: String {
self.path ?? "unknown:\(self.name)"
}
}
2 changes: 2 additions & 0 deletions Sources/CodexBar/StatusItemController+HostedSubmenus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ extension StatusItemController {
snapshot.historyLabel ?? "",
snapshot.last30DaysCostUSD.map { String($0.bitPattern, radix: 16) } ?? "nil",
String(reflecting: snapshot.daily),
String(reflecting: snapshot.projects),
].joined(separator: "|")
}

Expand Down Expand Up @@ -434,6 +435,7 @@ extension StatusItemController {
currencyCode: tokenSnapshot.currencyCode,
historyDays: tokenSnapshot.historyDays,
windowLabel: tokenSnapshot.historyLabel,
projects: provider == .codex ? tokenSnapshot.projects : [],
onHeightChange: { height in
relay.hosting?.applyMeasuredHeight(width: width, height: height)
},
Expand Down
11 changes: 11 additions & 0 deletions Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,24 @@ extension StatusItemController {
].joined(separator: ",")
}
.joined(separator: ";")
let projects = snapshot.projects
.map { project in
[
project.name,
project.path ?? "",
"\(project.totalTokens ?? -1)",
Self.formatOptionalDoubleForSignature(project.totalCostUSD),
].joined(separator: ",")
Comment on lines +171 to +176

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 Include project sources in refresh signatures

When an open menu already has a Codex project row and only its source breakdown changes while the project total stays the same, this signature remains unchanged because it records only the parent project fields. In that case didMenuAdjunctReadinessChange can skip invalidation and leave the visible Projects section showing stale source rows; include source path/name/totals in the project signature.

Useful? React with 👍 / 👎.

}
.joined(separator: ";")
return [
"sessionTokens=\(snapshot.sessionTokens ?? -1)",
"sessionCost=\(Self.formatOptionalDoubleForSignature(snapshot.sessionCostUSD))",
"lastTokens=\(snapshot.last30DaysTokens ?? -1)",
"lastCost=\(Self.formatOptionalDoubleForSignature(snapshot.last30DaysCostUSD))",
"updated=\(Int(snapshot.updatedAt.timeIntervalSince1970 * 1000))",
"daily=\(daily)",
"projects=\(projects)",
].joined(separator: ",")
}

Expand Down
Loading