diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index b1c89cb..e6f5017 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -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 } } diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 6191575..bbf98dd 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -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() { diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 2e44fae..8ca9fa1 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -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: []) diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index 82f2ceb..d4ca7ae 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -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: + diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index 751adbd..e1255a2 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -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.. [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 diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift index 056f5b0..50d07ee 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -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 { diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index dae7406..9353070 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -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() diff --git a/src/main.ts b/src/main.ts index 74fd205..a6b509e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 } diff --git a/src/menubar-json.ts b/src/menubar-json.ts index bab4e40..660f82c 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -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 = '' +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 + 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), diff --git a/src/parser.ts b/src/parser.ts index 38b29c7..2700e46 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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()