Add Optimize tab, token display modes, daily budget alerts, and project drill-down (#349)

* Add CodeBurn Pro Mac App Store app

SwiftUI MenuBarExtra with litellm-snapshot pricing, Claude/Codex/Copilot
parsers, session discovery, auto-refresh timer, and dashboard UI matching
the real menubar design.

* Add appstore/ to .gitignore

Private Mac App Store build, not for the public repo.

* Add Optimize tab, token display modes, daily budget alerts, and project drill-down

- New Optimize insight tab with Retry Tax and Routing Waste computations
  (pure math from session data, no LLM required)
- Retry tax: shows money wasted on failed edit retries, per-model breakdown
- Routing waste: counterfactual savings vs cheapest reliable model, per-model
- Token display modes: Cost ($), Tokens (up/down split), Total Tokens
- Daily budget alert: configurable threshold, flame turns yellow when exceeded
- Project drill-down: click project rows to see per-session cost, tokens, models
- Period-aware top sessions: 30-day view now shows 30-day costliest sessions
- Friendly project names: show directory name instead of full path, Home for ~
- Session cache TTL bumped to 180s to prevent re-parsing on non-today periods
- Audit fixes: array mutation, percentage rounding, ForEach ID collision,
  baseline minimum threshold, editTurns scoping, first-of-month edge case

* Fix model badge ForEach ID collision and budget warning provider filter

- SessionDetailsList model badges used id: \.name which silently drops
  duplicate model entries. Switched to enumerated offset-based ID.
- Hero budget warning compared against provider-filtered payload instead
  of todayPayload (all providers). Now matches the menubar flame tint.
This commit is contained in:
Resham Joshi 2026-05-18 14:51:15 -07:00 committed by GitHub
parent 5a837c94e9
commit 7cea9efb31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 917 additions and 54 deletions

View file

@ -26,6 +26,12 @@ final class AppStore {
}
var showingAccentPicker: Bool = false
var currency: String = "USD"
var displayMetric: DisplayMetric = DisplayMetric(rawValue: UserDefaults.standard.string(forKey: "CodeBurnDisplayMetric") ?? "") ?? .cost {
didSet { UserDefaults.standard.set(displayMetric.rawValue, forKey: "CodeBurnDisplayMetric") }
}
var dailyBudget: Double = UserDefaults.standard.double(forKey: "CodeBurnDailyBudget") {
didSet { UserDefaults.standard.set(dailyBudget, forKey: "CodeBurnDailyBudget") }
}
var isLoading: Bool { loadingCountsByKey.values.contains { $0 > 0 } }
var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 }
var hasAttemptedCurrentKeyLoad: Bool { attemptedKeys.contains(currentKey) }
@ -934,12 +940,17 @@ enum SubscriptionLoadState: Sendable, Equatable {
case transientFailure(retryAt: Date?) // 429 / network blip; backing off automatically
}
enum DisplayMetric: String {
case cost, tokens, totalTokens
}
enum InsightMode: String, CaseIterable, Identifiable {
case plan = "Plan"
case trend = "Trend"
case forecast = "Forecast"
case pulse = "Pulse"
case stats = "Stats"
case optimize = "Optimize"
var id: String { rawValue }
}

View file

@ -624,6 +624,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// Track currency so the menubar title catches up immediately on
// currency switch instead of waiting for the next 30s payload tick.
_ = self.store.currency
_ = self.store.displayMetric
_ = self.store.dailyBudget
// Track the live-quota state too so the flame icon re-tints on
// every subscription / codex usage update, not just every 30s.
_ = self.store.subscription
@ -709,7 +711,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// warning/critical/danger override with a fixed palette color so the
// user gets a glanceable signal even when the menu bar is busy.
let aggregate = store.aggregateQuotaStatus
let tint = Self.flameTint(for: aggregate.severity)
var tint = Self.flameTint(for: aggregate.severity)
if tint == nil, store.dailyBudget > 0,
let todayCost = store.todayPayload?.current.cost, todayCost >= store.dailyBudget {
tint = NSColor.systemYellow
}
let flameConfig: NSImage.SymbolConfiguration
if let tint {
flameConfig = baseConfig.applying(.init(paletteColors: [tint]))
@ -728,11 +734,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
let hasPayload = store.todayPayload != nil
let compact = isCompact
let fallback = compact ? "$-" : "$—"
let formatted = store.todayPayload?.current.cost
let valueText = compact
? (formatted?.asCompactCurrencyWhole() ?? fallback)
: " " + (formatted?.asCompactCurrency() ?? fallback)
let valueText: String
if store.displayMetric == .tokens, let p = store.todayPayload?.current {
let out = formatTokensMenubar(Double(p.outputTokens))
let inp = formatTokensMenubar(Double(p.inputTokens))
valueText = compact ? "\(out)\(inp)" : "\(out)\(inp)"
} else if store.displayMetric == .totalTokens, let p = store.todayPayload?.current {
let total = formatTokensMenubar(Double(p.inputTokens + p.outputTokens))
valueText = compact ? total : " \(total) tok"
} else {
let fallback = compact ? "$-" : "$—"
let formatted = store.todayPayload?.current.cost
valueText = compact
? (formatted?.asCompactCurrencyWhole() ?? fallback)
: " " + (formatted?.asCompactCurrency() ?? fallback)
}
var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0]
if !hasPayload {
@ -745,6 +761,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
button.attributedTitle = composed
}
private func formatTokensMenubar(_ n: Double) -> String {
if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) }
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
return String(format: "%.0f", n)
}
// MARK: - Popover
private func setupPopover() {

View file

@ -58,6 +58,36 @@ extension DailyHistoryEntry {
}
}
struct RetryTaxModelEntry: Codable, Sendable {
let name: String
let taxUSD: Double
let retries: Int
let retriesPerEdit: Double?
}
struct RetryTax: Codable, Sendable {
let totalUSD: Double
let retries: Int
let editTurns: Int
let byModel: [RetryTaxModelEntry]
}
struct RoutingWasteModelEntry: Codable, Sendable {
let name: String
let costPerEdit: Double
let editTurns: Int
let actualUSD: Double
let counterfactualUSD: Double
let savingsUSD: Double
}
struct RoutingWaste: Codable, Sendable {
let totalSavingsUSD: Double
let baselineModel: String
let baselineCostPerEdit: Double
let byModel: [RoutingWasteModelEntry]
}
struct CurrentBlock: Codable, Sendable {
let label: String
let cost: Double
@ -70,6 +100,38 @@ struct CurrentBlock: Codable, Sendable {
let topActivities: [ActivityEntry]
let topModels: [ModelEntry]
let providers: [String: Double]
let topProjects: [ProjectEntry]
let modelEfficiency: [ModelEfficiencyEntry]
let topSessions: [TopSessionEntry]
let retryTax: RetryTax
let routingWaste: RoutingWaste
}
extension CurrentBlock {
enum CodingKeys: String, CodingKey {
case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens,
cacheHitPercent, topActivities, topModels, providers, topProjects,
modelEfficiency, topSessions, retryTax, routingWaste
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
label = try c.decode(String.self, forKey: .label)
cost = try c.decode(Double.self, forKey: .cost)
calls = try c.decode(Int.self, forKey: .calls)
sessions = try c.decode(Int.self, forKey: .sessions)
oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
cacheHitPercent = try c.decode(Double.self, forKey: .cacheHitPercent)
topActivities = try c.decode([ActivityEntry].self, forKey: .topActivities)
topModels = try c.decode([ModelEntry].self, forKey: .topModels)
providers = try c.decode([String: Double].self, forKey: .providers)
topProjects = try c.decodeIfPresent([ProjectEntry].self, forKey: .topProjects) ?? []
modelEfficiency = try c.decodeIfPresent([ModelEfficiencyEntry].self, forKey: .modelEfficiency) ?? []
topSessions = try c.decodeIfPresent([TopSessionEntry].self, forKey: .topSessions) ?? []
retryTax = try c.decodeIfPresent(RetryTax.self, forKey: .retryTax) ?? RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: [])
routingWaste = try c.decodeIfPresent(RoutingWaste.self, forKey: .routingWaste) ?? RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: [])
}
}
struct ActivityEntry: Codable, Sendable {
@ -85,6 +147,54 @@ struct ModelEntry: Codable, Sendable {
let calls: Int
}
struct SessionModelEntry: Codable, Sendable {
let name: String
let cost: Double
}
struct SessionDetailEntry: Codable, Sendable {
let cost: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
let date: String
let models: [SessionModelEntry]
}
struct ProjectEntry: Codable, Sendable {
let name: String
let cost: Double
let sessions: Int
let avgCostPerSession: Double
let sessionDetails: [SessionDetailEntry]
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
cost = try c.decode(Double.self, forKey: .cost)
sessions = try c.decode(Int.self, forKey: .sessions)
avgCostPerSession = try c.decode(Double.self, forKey: .avgCostPerSession)
sessionDetails = try c.decodeIfPresent([SessionDetailEntry].self, forKey: .sessionDetails) ?? []
}
private enum CodingKeys: String, CodingKey {
case name, cost, sessions, avgCostPerSession, sessionDetails
}
}
struct ModelEfficiencyEntry: Codable, Sendable {
let name: String
let costPerEdit: Double?
let oneShotRate: Double?
}
struct TopSessionEntry: Codable, Sendable {
let project: String
let cost: Double
let calls: Int
let date: String
}
struct OptimizeBlock: Codable, Sendable {
let findingCount: Int
let savingsUSD: Double
@ -115,7 +225,12 @@ extension MenubarPayload {
cacheHitPercent: 0,
topActivities: [],
topModels: [],
providers: [:]
providers: [:],
topProjects: [],
modelEfficiency: [],
topSessions: [],
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: [])
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: [])

View file

@ -129,9 +129,6 @@ struct AgentTabStrip: View {
private func cost(for filter: ProviderFilter) -> Double? {
let data = periodAll
if filter == .all { return data.current.cost }
if filter == store.selectedProvider, store.hasCachedData {
return store.payload.current.cost
}
let providers = Dictionary(
data.current.providers.map { ($0.key.lowercased(), $0.value) },
uniquingKeysWith: +

View file

@ -1,8 +1,5 @@
import SwiftUI
private let trendDays = 19
private let trendBarWidth: CGFloat = 13
private let trendBarGap: CGFloat = 4
private let trendChartHeight: CGFloat = 90
// Cached formatters and a calendar to avoid allocating fresh ones on every
@ -82,10 +79,11 @@ struct HeatmapSection: View {
} else {
PlanInsight(usage: store.subscription)
}
case .trend: TrendInsight(days: store.payload.history.daily)
case .trend: TrendInsight(days: store.payload.history.daily, period: store.selectedPeriod)
case .forecast: ForecastInsight(days: store.payload.history.daily)
case .pulse: PulseInsight(payload: store.payload)
case .stats: StatsInsight(payload: store.payload)
case .optimize: OptimizeInsight(payload: store.payload)
}
}
}
@ -122,10 +120,25 @@ private struct InsightPillSwitcher: View {
private struct TrendInsight: View {
let days: [DailyHistoryEntry]
let period: Period
private var trendDayCount: Int {
switch period {
case .today, .sevenDays: return 19
case .thirtyDays: return 30
case .month: return 31
case .all: return min(days.count, 90)
}
}
private var barGap: CGFloat {
trendDayCount > 45 ? 2 : 4
}
var body: some View {
let bars = buildTrendBars(from: days)
let stats = computeTrendStats(bars: bars, allDays: days)
let dayCount = trendDayCount
let bars = buildTrendBars(from: days, dayCount: dayCount)
let stats = computeTrendStats(bars: bars, allDays: days, dayCount: dayCount)
// Tokens are real for the .all-providers view; per-provider history doesn't carry
// token breakdown yet, so fall back to $ when no tokens are present.
let totalTokens = bars.reduce(0.0) { $0 + $1.tokens }
@ -139,7 +152,7 @@ private struct TrendInsight: View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 1) {
Text("Last \(trendDays) days")
Text("Last \(dayCount) days")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text(formatHero(useTokens: useTokens, tokens: totalTokens, dollars: stats.totalThisWindow))
@ -152,7 +165,7 @@ private struct TrendInsight: View {
HStack(spacing: 3) {
Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.system(size: 9, weight: .bold))
Text("\(delta >= 0 ? "+" : "")\(String(format: "%.0f", delta))% vs prior \(trendDays)d")
Text("\(delta >= 0 ? "+" : "")\(String(format: "%.0f", delta))% vs prior \(dayCount)d")
.font(.system(size: 10.5))
.monospacedDigit()
}
@ -165,7 +178,8 @@ private struct TrendInsight: View {
maxValue: maxValue,
avgValue: avgValue,
metric: metric,
formatValue: { formatValue($0, useTokens: useTokens) }
formatValue: { formatValue($0, useTokens: useTokens) },
barGap: barGap
)
.zIndex(1)
@ -209,20 +223,26 @@ private struct TrendChart: View {
let avgValue: Double
let metric: (TrendBar) -> Double
let formatValue: (Double) -> String
let barGap: CGFloat
@State private var hoveredBarID: TrendBar.ID?
private var peakBarID: TrendBar.ID? {
bars.filter { metric($0) > 0 }.max(by: { metric($0) < metric($1) })?.id
}
var body: some View {
let avgFraction = maxValue > 0 ? CGFloat(min(avgValue / maxValue, 1.0)) : 0
ZStack(alignment: .bottomLeading) {
HStack(alignment: .bottom, spacing: trendBarGap) {
HStack(alignment: .bottom, spacing: barGap) {
ForEach(bars) { bar in
BarColumn(
bar: bar,
value: metric(bar),
maxValue: maxValue,
isHovered: hoveredBarID == bar.id
isHovered: hoveredBarID == bar.id,
isPeak: bar.id == peakBarID
)
.onHover { hovering in
hoveredBarID = hovering ? bar.id : (hoveredBarID == bar.id ? nil : hoveredBarID)
@ -245,8 +265,6 @@ private struct TrendChart: View {
}
.frame(height: trendChartHeight)
.overlay(alignment: .bottomLeading) {
// Floats below the chart without taking layout space. Opaque dark card hides
// whatever sits beneath it (mini stats, activity rows).
if let hoveredBar {
BarTooltipCard(bar: hoveredBar, value: metric(hoveredBar), formatValue: formatValue)
.padding(.top, 6)
@ -270,16 +288,24 @@ private struct BarColumn: View {
let value: Double
let maxValue: Double
let isHovered: Bool
let isPeak: Bool
var body: some View {
let fraction = maxValue > 0 ? CGFloat(value / maxValue) : 0
let height = max(2, trendChartHeight * fraction)
VStack(spacing: 2) {
VStack(spacing: 0) {
Spacer(minLength: 0)
if isPeak && value > 0 {
RoundedRectangle(cornerRadius: 2)
.fill(Color.yellow.opacity(0.85))
.frame(maxWidth: .infinity)
.frame(height: max(2, trendChartHeight * 0.05))
}
RoundedRectangle(cornerRadius: 2)
.fill(barColor)
.frame(width: trendBarWidth, height: height)
.frame(maxWidth: .infinity)
.frame(height: height)
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Theme.brandAccent.opacity(isHovered ? 0.9 : 0), lineWidth: 1)
@ -293,7 +319,9 @@ private struct BarColumn: View {
private var barColor: Color {
if bar.isToday { return Theme.brandAccent }
if value <= 0 { return Color.secondary.opacity(0.15) }
return isHovered ? Theme.brandAccent.opacity(0.85) : Theme.brandAccent.opacity(0.55)
if isHovered { return Theme.brandAccent.opacity(0.85) }
let ratio = maxValue > 0 ? value / maxValue : 0
return Theme.brandAccent.opacity(0.42 + ratio * 0.48)
}
}
@ -342,18 +370,23 @@ private struct BarTooltipCard: View {
if !bar.topModels.isEmpty {
VStack(alignment: .leading, spacing: 3) {
ForEach(bar.topModels.prefix(4), id: \.name) { m in
ForEach(Array(bar.topModels.prefix(4).enumerated()), id: \.element.name) { idx, m in
HStack(spacing: 6) {
Circle().fill(Theme.brandAccent.opacity(0.7)).frame(width: 4, height: 4)
RoundedRectangle(cornerRadius: 1)
.fill(Theme.brandAccent.opacity(0.75 - Double(idx) * 0.12))
.frame(width: 3, height: 12)
Text(m.name)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(primaryText)
.lineLimit(1)
Spacer()
if m.cost > 0 {
Text(m.cost.asCompactCurrency())
.font(.codeMono(size: 9.5, weight: .semibold))
.foregroundStyle(secondaryText)
}
Text("\(formatTokensCompact(Double(m.totalTokens))) tok")
.font(.codeMono(size: 9.5, weight: .medium))
.foregroundStyle(secondaryText)
Text("(\(formatTokensCompact(Double(m.inputTokens)))/\(formatTokensCompact(Double(m.outputTokens))))")
.font(.codeMono(size: 9, weight: .regular))
.foregroundStyle(tertiaryText)
}
}
@ -390,7 +423,7 @@ private struct MiniStat: View {
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 1) {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.tertiary)
@ -399,7 +432,13 @@ private struct MiniStat: View {
.monospacedDigit()
.foregroundStyle(.primary)
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(nsColor: .separatorColor).opacity(0.35))
)
}
}
@ -424,7 +463,7 @@ private struct TrendStats {
let yesterdayBar: TrendBar?
}
private func buildTrendBars(from days: [DailyHistoryEntry]) -> [TrendBar] {
private func buildTrendBars(from days: [DailyHistoryEntry], dayCount: Int) -> [TrendBar] {
let calendar = gregorianCalendar
let formatter = yyyymmdd
let entryByDate = Dictionary(days.map { ($0.date, $0) }, uniquingKeysWith: { _, new in new })
@ -432,7 +471,7 @@ private func buildTrendBars(from days: [DailyHistoryEntry]) -> [TrendBar] {
let todayKey = formatter.string(from: today)
var bars: [TrendBar] = []
for offset in (0..<trendDays).reversed() {
for offset in (0..<dayCount).reversed() {
guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { continue }
let key = formatter.string(from: d)
let entry = entryByDate[key]
@ -448,7 +487,7 @@ private func buildTrendBars(from days: [DailyHistoryEntry]) -> [TrendBar] {
return bars
}
private func computeTrendStats(bars: [TrendBar], allDays: [DailyHistoryEntry]) -> TrendStats {
private func computeTrendStats(bars: [TrendBar], allDays: [DailyHistoryEntry], dayCount: Int) -> TrendStats {
let total = bars.reduce(0.0) { $0 + $1.cost }
let active = bars.filter { $0.cost > 0 }.count
let avg = bars.isEmpty ? 0 : total / Double(bars.count)
@ -457,8 +496,8 @@ private func computeTrendStats(bars: [TrendBar], allDays: [DailyHistoryEntry]) -
let calendar = gregorianCalendar
let formatter = yyyymmdd
let today = calendar.startOfDay(for: Date())
let priorWindowStart = calendar.date(byAdding: .day, value: -(2 * trendDays - 1), to: today)
let thisWindowStart = calendar.date(byAdding: .day, value: -(trendDays - 1), to: today)
let priorWindowStart = calendar.date(byAdding: .day, value: -(2 * dayCount - 1), to: today)
let thisWindowStart = calendar.date(byAdding: .day, value: -(dayCount - 1), to: today)
var deltaPercent: Double? = nil
if let priorStart = priorWindowStart, let thisStart = thisWindowStart {
let priorStartStr = formatter.string(from: priorStart)
@ -629,16 +668,19 @@ private struct PulseInsight: View {
let payload: MenubarPayload
var body: some View {
HStack(spacing: 10) {
PulseTile(label: "Cache hit", value: cacheHitText, color: Theme.brandAccent)
PulseTile(label: "1-shot", value: oneShotText, color: oneShotColor)
PulseTile(
label: "Cost / session",
value: payload.current.sessions > 0
? (payload.current.cost / Double(payload.current.sessions)).asCompactCurrency()
: "",
color: .secondary
)
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
PulseTile(label: "Cache hit", value: cacheHitText, color: Theme.brandAccent)
PulseTile(label: "1-shot", value: oneShotText, color: oneShotColor)
PulseTile(
label: "Cost / session",
value: payload.current.sessions > 0
? (payload.current.cost / Double(payload.current.sessions)).asCompactCurrency()
: "",
color: .secondary
)
}
CostPerEditCaption(models: payload.current.modelEfficiency)
}
}
@ -682,6 +724,53 @@ private struct PulseTile: View {
}
}
private struct CostPerEditCaption: View {
let models: [ModelEfficiencyEntry]
var body: some View {
let valid = models.compactMap { m -> (String, Double)? in
guard let cpe = m.costPerEdit, cpe > 0 else { return nil }
return (m.name, cpe)
}.sorted(by: { $0.1 < $1.1 })
if let best = valid.first {
HStack(spacing: 4) {
Image(systemName: "pencil.line")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.tertiary)
Text("Cost/edit")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text(formatCPE(best.1))
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(Theme.brandAccent)
Text(best.0)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
if valid.count > 1, let worst = valid.last, worst.0 != best.0 {
Text("")
.font(.system(size: 9))
.foregroundStyle(.quaternary)
Text(formatCPE(worst.1))
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(.primary)
Text(worst.0)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
}
}
}
private func formatCPE(_ v: Double) -> String {
if v < 0.01 { return String(format: "$%.3f", v) }
return String(format: "$%.2f", v)
}
}
/// Connects optimize findings directly to plan utilization: "address N findings to recover X
/// tokens" framed as the same currency the rest of the Plan view uses (effective tokens).
/// Scoped to whatever period the user selected (today / 7d / 30d / month / all).
@ -777,6 +866,112 @@ private struct StatsInsight: View {
.foregroundStyle(Theme.brandAccent)
}
}
if !payload.current.topProjects.isEmpty {
Divider().opacity(0.5)
TopProjectsList(projects: payload.current.topProjects)
}
if let top = payload.current.topSessions.first, top.cost > 0 {
HStack(spacing: 4) {
Image(systemName: "flame")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(Theme.brandAccent)
Text("Costliest session")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Spacer()
Text(top.cost.asCompactCurrency())
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(.secondary)
.monospacedDigit()
Text("· \(projectDisplayName(top.project))")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
}
}
}
private struct RetryTaxSection: View {
let retryTax: RetryTax
let totalCost: Double
@State private var expanded = false
var body: some View {
if retryTax.totalUSD > 0 {
Divider().opacity(0.5)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Image(systemName: "arrow.2.squarepath")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.orange)
Text("Retry tax")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Spacer()
Text(retryTax.totalUSD.asCompactCurrency())
.font(.codeMono(size: 11, weight: .bold))
.foregroundStyle(.orange)
.monospacedDigit()
if totalCost > 0 {
Text("(\(Int((retryTax.totalUSD / totalCost * 100).rounded()))%)")
.font(.system(size: 9.5))
.foregroundStyle(.tertiary)
}
Image(systemName: "chevron.right")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.quaternary)
.rotationEffect(.degrees(expanded ? 90 : 0))
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
expanded.toggle()
}
}
Text("\(retryTax.retries) retries across \(retryTax.editTurns) edits")
.font(.system(size: 9.5))
.foregroundStyle(.quaternary)
if expanded {
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(retryTax.byModel.enumerated()), id: \.offset) { idx, model in
HStack(spacing: 0) {
Text(model.name)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
if let rpe = model.retriesPerEdit {
Text(String(format: "%.1f ret/edit", rpe))
.font(.system(size: 9))
.foregroundStyle(.quaternary)
.padding(.trailing, 8)
}
Text(model.taxUSD.asCompactCurrency())
.font(.codeMono(size: 10, weight: .semibold))
.foregroundStyle(.orange.opacity(0.85))
.monospacedDigit()
}
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(RoundedRectangle(cornerRadius: 4).fill(.orange.opacity(0.05)))
.transition(
.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top))
.animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)),
removal: .opacity.animation(.easeOut(duration: 0.12))
)
)
}
}
.padding(.top, 2)
}
}
}
}
}
@ -798,6 +993,257 @@ private struct StatRow: View {
}
}
private func projectDisplayName(_ path: String) -> String {
path.split(separator: "/").last.map(String.init) ?? path
}
private struct TopProjectsList: View {
let projects: [ProjectEntry]
@State private var expanded: String?
var body: some View {
let top = Array(projects.prefix(3))
let maxCost = top.first?.cost ?? 1
VStack(alignment: .leading, spacing: 5) {
ForEach(Array(top.enumerated()), id: \.offset) { idx, project in
let expandKey = "\(idx):\(project.name)"
let isOpen = expanded == expandKey
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.quaternary)
.rotationEffect(.degrees(isOpen ? 90 : 0))
Text(projectDisplayName(project.name))
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Spacer()
Text("\(project.sessions) sess")
.font(.system(size: 9.5))
.foregroundStyle(.quaternary)
Text(project.cost.asCompactCurrency())
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(.secondary)
.monospacedDigit()
RoundedRectangle(cornerRadius: 2)
.fill(Theme.brandAccent.opacity(0.5))
.frame(
width: max(2, 40 * CGFloat(project.cost / max(maxCost, 0.01))),
height: 6
)
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
expanded = isOpen ? nil : expandKey
}
}
if isOpen, !project.sessionDetails.isEmpty {
SessionDetailsList(sessions: project.sessionDetails)
.padding(.top, 6)
.padding(.leading, 14)
}
}
}
}
}
}
private struct SessionDetailsList: View {
let sessions: [SessionDetailEntry]
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(sessions.prefix(5).enumerated()), id: \.offset) { idx, sess in
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 0) {
Text(sess.cost.asCompactCurrency())
.font(.codeMono(size: 10, weight: .semibold))
.foregroundStyle(.primary)
.monospacedDigit()
.frame(width: 52, alignment: .trailing)
Text(" \(sess.calls) calls")
.font(.system(size: 9))
.foregroundStyle(.quaternary)
Spacer()
HStack(spacing: 3) {
Image(systemName: "arrow.down")
.font(.system(size: 7, weight: .semibold))
Text(compactTokens(sess.inputTokens))
}
.font(.system(size: 9))
.foregroundStyle(.tertiary)
HStack(spacing: 3) {
Image(systemName: "arrow.up")
.font(.system(size: 7, weight: .semibold))
Text(compactTokens(sess.outputTokens))
}
.font(.system(size: 9))
.foregroundStyle(.tertiary)
.padding(.leading, 4)
}
HStack(spacing: 4) {
ForEach(Array(sess.models.prefix(3).enumerated()), id: \.offset) { _, model in
Text(model.name)
.font(.system(size: 8.5, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1.5)
.background(Theme.brandAccent.opacity(0.1))
.clipShape(Capsule())
}
}
.padding(.leading, 52)
}
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(.primary.opacity(0.03))
)
.transition(
.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top))
.animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)),
removal: .opacity.animation(.easeOut(duration: 0.15))
)
)
}
}
}
private func compactTokens(_ n: Int) -> String {
let d = Double(n)
if d >= 1_000_000 { return String(format: "%.1fM", d / 1_000_000) }
if d >= 1_000 { return String(format: "%.0fK", d / 1_000) }
return "\(n)"
}
}
// MARK: - Optimize
private struct OptimizeInsight: View {
let payload: MenubarPayload
var body: some View {
let totalWaste = payload.current.retryTax.totalUSD + payload.current.routingWaste.totalSavingsUSD
let cost = payload.current.cost
VStack(alignment: .leading, spacing: 12) {
if totalWaste > 0, cost > 0 {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text("Potential savings")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text(totalWaste.asCompactCurrency())
.font(.system(size: 24, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.orange)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(Int((totalWaste / cost * 100).rounded()))% of spend")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.orange.opacity(0.8))
Text("could be optimized")
.font(.system(size: 9.5))
.foregroundStyle(.quaternary)
}
}
.padding(.bottom, 2)
}
RetryTaxSection(retryTax: payload.current.retryTax, totalCost: cost)
RoutingWasteSection(routingWaste: payload.current.routingWaste, totalCost: cost)
}
}
}
private struct RoutingWasteSection: View {
let routingWaste: RoutingWaste
let totalCost: Double
@State private var expanded = false
var body: some View {
if routingWaste.totalSavingsUSD > 0 {
Divider().opacity(0.5)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.swap")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.purple)
Text("Routing waste")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Spacer()
Text(routingWaste.totalSavingsUSD.asCompactCurrency())
.font(.codeMono(size: 11, weight: .bold))
.foregroundStyle(.purple)
.monospacedDigit()
if totalCost > 0 {
Text("(\(Int((routingWaste.totalSavingsUSD / totalCost * 100).rounded()))%)")
.font(.system(size: 9.5))
.foregroundStyle(.tertiary)
}
Image(systemName: "chevron.right")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.quaternary)
.rotationEffect(.degrees(expanded ? 90 : 0))
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
expanded.toggle()
}
}
if !routingWaste.baselineModel.isEmpty {
Text("vs \(routingWaste.baselineModel) @ \(routingWaste.baselineCostPerEdit.asCompactCurrency())/edit")
.font(.system(size: 9.5))
.foregroundStyle(.quaternary)
}
if expanded {
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(routingWaste.byModel.enumerated()), id: \.offset) { idx, model in
HStack(spacing: 0) {
Text(model.name)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
Text(String(format: "$%.2f/edit", model.costPerEdit))
.font(.system(size: 9))
.foregroundStyle(.quaternary)
.padding(.trailing, 8)
Text(model.savingsUSD.asCompactCurrency())
.font(.codeMono(size: 10, weight: .semibold))
.foregroundStyle(.purple.opacity(0.85))
.monospacedDigit()
}
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(RoundedRectangle(cornerRadius: 4).fill(.purple.opacity(0.05)))
.transition(
.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top))
.animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)),
removal: .opacity.animation(.easeOut(duration: 0.12))
)
)
}
}
.padding(.top, 2)
}
}
}
}
}
private struct AllStats {
let favoriteModel: String
let activeDaysFraction: String

View file

@ -8,7 +8,7 @@ struct HeroSection: View {
SectionCaption(text: caption)
HStack(alignment: .firstTextBaseline) {
Text(store.payload.current.cost.asCurrency())
Text(heroText)
.font(.system(size: 32, weight: .semibold, design: .rounded))
.monospacedDigit()
.tracking(-1)
@ -23,22 +23,73 @@ struct HeroSection: View {
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(store.payload.current.calls.asThousandsSeparated()) calls")
if store.displayMetric == .tokens {
HStack(spacing: 2) {
Image(systemName: "arrow.up")
.font(.system(size: 9, weight: .semibold))
Text(formatTokens(Double(store.payload.current.outputTokens)))
}
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
Text("\(store.payload.current.sessions) sessions")
HStack(spacing: 2) {
Image(systemName: "arrow.down")
.font(.system(size: 9, weight: .semibold))
Text(formatTokens(Double(store.payload.current.inputTokens)))
}
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.tertiary)
} else {
Text("\(store.payload.current.calls.asThousandsSeparated()) calls")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
Text("\(store.payload.current.sessions) sessions")
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.tertiary)
}
}
}
if store.selectedPeriod == .today,
store.dailyBudget > 0,
let todayCost = store.todayPayload?.current.cost,
todayCost >= store.dailyBudget {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10))
Text("Daily budget of \(store.dailyBudget.asCurrency()) exceeded")
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.orange)
.padding(.top, 2)
}
}
.padding(.horizontal, 14)
.padding(.top, 10)
.padding(.bottom, 12)
}
private var heroText: String {
if store.displayMetric == .tokens || store.displayMetric == .totalTokens {
let total = Double(store.payload.current.inputTokens + store.payload.current.outputTokens)
if total >= 1_000_000_000 { return String(format: "%.2fB tok", total / 1_000_000_000) }
if total >= 1_000_000 { return String(format: "%.1fM tok", total / 1_000_000) }
if total >= 1_000 { return String(format: "%.0fK tok", total / 1_000) }
return String(format: "%.0f tok", total)
}
return store.payload.current.cost.asCurrency()
}
private func formatTokens(_ n: Double) -> String {
if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) }
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
return String(format: "%.0f", n)
}
private var caption: String {
let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
if store.selectedPeriod == .today {

View file

@ -40,6 +40,14 @@ private struct GeneralSettingsTab: View {
Text(code).tag(code)
}
}
Picker("Metric", selection: Binding(
get: { store.displayMetric },
set: { store.displayMetric = $0 }
)) {
Text("Cost ($)").tag(DisplayMetric.cost)
Text("Tokens (↑↓)").tag(DisplayMetric.tokens)
Text("Total Tokens").tag(DisplayMetric.totalTokens)
}
Picker("Accent", selection: Binding(
get: { store.accentPreset },
set: { store.accentPreset = $0 }
@ -49,6 +57,23 @@ private struct GeneralSettingsTab: View {
}
}
}
Section("Alerts") {
Picker("Daily budget", selection: Binding(
get: { store.dailyBudget },
set: { store.dailyBudget = $0 }
)) {
Text("Off").tag(0.0)
Text("$25").tag(25.0)
Text("$50").tag(50.0)
Text("$100").tag(100.0)
Text("$200").tag(200.0)
Text("$500").tag(500.0)
}
Text("Flame icon turns yellow when you pass the daily budget.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.padding()

View file

@ -1,3 +1,4 @@
import { homedir } from 'node:os'
import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
@ -476,8 +477,14 @@ program
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
scanProjects = todayProjects
scanRange = todayRange
const isTodayOnly = opts.period === 'today'
if (isTodayOnly) {
scanProjects = todayProjects
scanRange = todayRange
} else {
scanProjects = fp(await parseAllSessions(periodInfo.range, 'all'))
scanRange = periodInfo.range
}
} else {
cache = await loadDailyCache()
const cacheIsCurrent = cache.lastComputedDate !== null
@ -632,8 +639,98 @@ program
dailyHistory = [...histFromCache, ...liveDays]
}
const home = homedir()
const friendlyProject = (p: ProjectSummary) => {
const resolved = p.projectPath || p.project
if (resolved === home || resolved === home + '/') return 'Home'
return resolved.split('/').filter(Boolean).pop() || p.project
}
currentData.projects = scanProjects.map(p => ({
name: friendlyProject(p),
cost: p.totalCostUSD,
sessions: p.sessions.length,
sessionDetails: [...p.sessions]
.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
.slice(0, 10)
.map(s => ({
cost: s.totalCostUSD,
calls: s.apiCalls,
inputTokens: s.totalInputTokens,
outputTokens: s.totalOutputTokens,
date: s.firstTimestamp?.split('T')[0] ?? '',
models: Object.entries(s.modelBreakdown)
.map(([name, m]) => ({ name, cost: m.costUSD }))
.sort((a, b) => b.cost - a.cost)
.slice(0, 3),
})),
}))
const effMap = aggregateModelEfficiency(scanProjects)
currentData.modelEfficiency = [...effMap.entries()].map(([name, eff]) => ({
name,
costPerEdit: eff.costPerEditUSD,
oneShotRate: eff.oneShotRate,
}))
const retryTaxByModel = [...effMap.values()]
.filter(m => m.retries > 0 && m.editTurns > 0)
.map(m => ({
name: m.model,
taxUSD: m.retries * (m.editCostUSD / m.editTurns),
retries: m.retries,
retriesPerEdit: m.retriesPerEdit,
}))
.sort((a, b) => b.taxUSD - a.taxUSD)
const retryTax = {
totalUSD: retryTaxByModel.reduce((s, m) => s + m.taxUSD, 0),
retries: retryTaxByModel.reduce((s, m) => s + m.retries, 0),
editTurns: [...effMap.values()].filter(m => m.retries > 0).reduce((s, m) => s + m.editTurns, 0),
byModel: retryTaxByModel.slice(0, 5),
}
currentData.topSessions = scanProjects.flatMap(p =>
p.sessions.map(s => ({
project: friendlyProject(p),
cost: s.totalCostUSD,
calls: s.apiCalls,
date: s.firstTimestamp?.split('T')[0] ?? '',
}))
).sort((a, b) => b.cost - a.cost).slice(0, 5)
// Routing waste: find cheapest reliable model (≥90% 1-shot, ≥5 edits),
// then compute how much each pricier model overpaid.
const reliableModels = [...effMap.values()]
.filter(m => m.oneShotRate !== null && m.oneShotRate >= 90 && m.editTurns >= 5
&& (m.costPerEditUSD ?? 0) >= 0.01)
.sort((a, b) => (a.costPerEditUSD ?? Infinity) - (b.costPerEditUSD ?? Infinity))
const baseline = reliableModels[0]
const routingWasteByModel = baseline
? [...effMap.values()]
.filter(m => m.model !== baseline.model && m.editTurns > 0 && (m.costPerEditUSD ?? 0) > (baseline.costPerEditUSD ?? 0))
.map(m => {
const counterfactual = m.editTurns * (baseline.costPerEditUSD ?? 0)
return {
name: m.model,
costPerEdit: m.costPerEditUSD ?? 0,
editTurns: m.editTurns,
actualUSD: m.editCostUSD,
counterfactualUSD: counterfactual,
savingsUSD: m.editCostUSD - counterfactual,
}
})
.filter(m => m.savingsUSD > 0)
.sort((a, b) => b.savingsUSD - a.savingsUSD)
: []
const routingWaste = {
totalSavingsUSD: routingWasteByModel.reduce((s, m) => s + m.savingsUSD, 0),
baselineModel: baseline?.model ?? '',
baselineCostPerEdit: baseline?.costPerEditUSD ?? 0,
byModel: routingWasteByModel.slice(0, 5),
}
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste)))
return
}

View file

@ -12,6 +12,9 @@ export type PeriodData = {
cacheWriteTokens: number
categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; calls: number }>
projects?: Array<{ name: string; cost: number; sessions: number; sessionDetails?: Array<{ cost: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number }> }> }>
modelEfficiency?: Array<{ name: string; costPerEdit: number | null; oneShotRate: number | null }>
topSessions?: Array<{ project: string; cost: number; calls: number; date: string }>
}
export type ProviderCost = {
@ -25,6 +28,9 @@ const TOP_MODELS_LIMIT = 20
const TOP_FINDINGS_LIMIT = 10
const HISTORY_DAYS_LIMIT = 365
const SYNTHETIC_MODEL_NAME = '<synthetic>'
const TOP_PROJECTS_LIMIT = 5
const TOP_SESSIONS_LIMIT = 3
const MODEL_EFFICIENCY_LIMIT = 5
export type DailyModelBreakdown = {
name: string
@ -68,6 +74,55 @@ export type MenubarPayload = {
calls: number
}>
providers: Record<string, number>
topProjects: Array<{
name: string
cost: number
sessions: number
avgCostPerSession: number
sessionDetails: Array<{
cost: number
calls: number
inputTokens: number
outputTokens: number
date: string
models: Array<{ name: string; cost: number }>
}>
}>
modelEfficiency: Array<{
name: string
costPerEdit: number | null
oneShotRate: number | null
}>
topSessions: Array<{
project: string
cost: number
calls: number
date: string
}>
retryTax: {
totalUSD: number
retries: number
editTurns: number
byModel: Array<{
name: string
taxUSD: number
retries: number
retriesPerEdit: number | null
}>
}
routingWaste: {
totalSavingsUSD: number
baselineModel: string
baselineCostPerEdit: number
byModel: Array<{
name: string
costPerEdit: number
editTurns: number
actualUSD: number
counterfactualUSD: number
savingsUSD: number
}>
}
}
optimize: {
findingCount: number
@ -155,11 +210,49 @@ function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['h
return { daily: trimmed }
}
function buildTopProjects(projects: PeriodData['projects']): MenubarPayload['current']['topProjects'] {
return (projects ?? [])
.filter(p => p.cost > 0)
.sort((a, b) => b.cost - a.cost)
.slice(0, TOP_PROJECTS_LIMIT)
.map(p => ({
name: p.name,
cost: p.cost,
sessions: p.sessions,
avgCostPerSession: p.sessions > 0 ? p.cost / p.sessions : 0,
sessionDetails: (p.sessionDetails ?? []).map(s => ({
cost: s.cost,
calls: s.calls,
inputTokens: s.inputTokens,
outputTokens: s.outputTokens,
date: s.date,
models: s.models,
})),
}))
}
function buildModelEfficiency(models: PeriodData['modelEfficiency']): MenubarPayload['current']['modelEfficiency'] {
return (models ?? [])
.filter(m => m.costPerEdit !== null)
.sort((a, b) => (a.costPerEdit ?? Infinity) - (b.costPerEdit ?? Infinity))
.slice(0, MODEL_EFFICIENCY_LIMIT)
.map(m => ({ name: m.name, costPerEdit: m.costPerEdit, oneShotRate: m.oneShotRate }))
}
function buildTopSessions(sessions: PeriodData['topSessions']): MenubarPayload['current']['topSessions'] {
return (sessions ?? [])
.sort((a, b) => b.cost - a.cost)
.slice(0, TOP_SESSIONS_LIMIT)
.map(s => ({ project: s.project, cost: s.cost, calls: s.calls, date: s.date }))
}
export function buildMenubarPayload(
current: PeriodData,
providers: ProviderCost[],
optimize: OptimizeResult | null,
dailyHistory?: DailyHistoryEntry[],
retryTax?: MenubarPayload['current']['retryTax'],
routingWaste?: MenubarPayload['current']['routingWaste'],
): MenubarPayload {
return {
generated: new Date().toISOString(),
@ -175,6 +268,11 @@ export function buildMenubarPayload(
topActivities: buildTopActivities(current.categories),
topModels: buildTopModels(current.models),
providers: buildProviders(providers),
topProjects: buildTopProjects(current.projects ?? []),
modelEfficiency: buildModelEfficiency(current.modelEfficiency ?? []),
topSessions: buildTopSessions(current.topSessions ?? []),
retryTax: retryTax ?? { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: routingWaste ?? { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
},
optimize: buildOptimize(optimize),
history: buildHistory(dailyHistory),

View file

@ -1823,7 +1823,7 @@ async function parseProviderSources(
return projects
}
const CACHE_TTL_MS = 60_000
const CACHE_TTL_MS = 180_000
const MAX_CACHE_ENTRIES = 10
const sessionCache = new Map<string, { data: ProjectSummary[]; ts: number }>()