mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-19 16:13:56 +00:00
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:
parent
5a837c94e9
commit
7cea9efb31
10 changed files with 917 additions and 54 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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: [])
|
||||
|
|
|
|||
|
|
@ -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: +
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
103
src/main.ts
103
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 }>()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue