diff --git a/apps/macos/Sources/Burn/BurnLedger.swift b/apps/macos/Sources/Burn/BurnLedger.swift index 610ddb0e..8f329098 100644 --- a/apps/macos/Sources/Burn/BurnLedger.swift +++ b/apps/macos/Sources/Burn/BurnLedger.swift @@ -41,6 +41,76 @@ actor BurnLedger { return total } + /// One `burn summary` reading: cumulative cost and token count since a point. + struct Summary { + /// Total USD cost (`totalCost.total`). + let cost: Double + /// Total tokens across every model row's usage fields. + let tokens: Int + } + + /// Cumulative cost and token totals for `provider` since `since`, or `nil` + /// when burn is unavailable or the query fails. Cheap enough to poll on a + /// short interval: `burn summary` only queries the ledger (it no longer runs + /// an ingest sweep), so freshness comes from a separate `ingest --watch`. + func summary(provider: String, since: Date) async -> Summary? { + let iso = ISO8601DateFormatter().string(from: since) + let args = ["summary", "--provider", provider, "--since", iso, "--json"] + guard let output = runBurn(args), + let data = output.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + + let cost = ((json["totalCost"] as? [String: Any])?["total"] as? NSNumber)?.doubleValue ?? 0 + + // Total tokens = sum of every usage field across model rows. + var tokens = 0 + if let byModel = json["byModel"] as? [[String: Any]] { + let fields = ["input", "output", "reasoning", "cacheRead", "cacheCreate5m", "cacheCreate1h"] + for row in byModel { + guard let usage = row["usage"] as? [String: Any] else { continue } + for field in fields { + tokens += (usage[field] as? NSNumber)?.intValue ?? 0 + } + } + } + return Summary(cost: cost, tokens: tokens) + } + + // MARK: - Long-lived ingest watch + + /// The running `burn ingest --watch` process, if any. Kept alive for the + /// lifetime of the live view so freshly written turns land in the ledger and + /// the polled live chart actually moves. + private var watchProcess: Process? + + /// Starts a background `burn ingest --watch` (FS-event driven) if one isn't + /// already running. No-op when burn is unavailable. The PATH fallback can't + /// host a long-lived child cleanly through a login shell, so the watch only + /// runs with the bundled native helper; the live chart still polls either + /// way, it just won't self-freshen without it. + func startIngestWatch() { + guard watchProcess == nil else { return } + guard case .bundled(let url) = resolveTool() else { return } + let process = Process() + process.executableURL = url + process.arguments = ["ingest", "--watch", "--quiet"] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + watchProcess = process + } catch { + watchProcess = nil + } + } + + /// Terminates the background watch process, if running. + func stopIngestWatch() { + watchProcess?.terminate() + watchProcess = nil + } + // MARK: - Resolution & invocation private func resolveTool() -> Tool { diff --git a/apps/macos/Sources/Burn/ContentView.swift b/apps/macos/Sources/Burn/ContentView.swift index 98c57906..2fcd6291 100644 --- a/apps/macos/Sources/Burn/ContentView.swift +++ b/apps/macos/Sources/Burn/ContentView.swift @@ -1,18 +1,53 @@ import SwiftUI +/// The popover's tabs: the headline usage burndown, and the live burn-rate +/// stream. +private enum BurnTab: Hashable { + case usage + case live +} + /// The popover shown when the menu bar item is clicked. struct ContentView: View { @ObservedObject var viewModel: UsageViewModel + @StateObject private var liveViewModel: LiveBurnViewModel + @State private var tab: BurnTab = .usage + + init(viewModel: UsageViewModel) { + self.viewModel = viewModel + _liveViewModel = StateObject( + wrappedValue: LiveBurnViewModel(provider: viewModel.selectedProvider)) + } var body: some View { VStack(alignment: .leading, spacing: 14) { header Divider() - content + tabPicker + switch tab { + case .usage: + content + case .live: + LiveBurnView(viewModel: liveViewModel) + } } .padding(16) .frame(width: 380) .background(quitShortcut) + // Keep the live stream tracking whichever provider the picker selects. + .onChange(of: viewModel.selectedProvider) { provider in + liveViewModel.select(provider) + } + } + + /// Segmented switch between the usage burndown and the live stream. + private var tabPicker: some View { + Picker("", selection: $tab) { + Text("Usage").tag(BurnTab.usage) + Text("Live").tag(BurnTab.live) + } + .pickerStyle(.segmented) + .labelsHidden() } /// Registers ⌘Q while the popover is open. The app is menu-bar-only (no Dock diff --git a/apps/macos/Sources/Burn/LiveBurnView.swift b/apps/macos/Sources/Burn/LiveBurnView.swift new file mode 100644 index 00000000..22b352bc --- /dev/null +++ b/apps/macos/Sources/Burn/LiveBurnView.swift @@ -0,0 +1,205 @@ +import SwiftUI +import Charts + +/// The "Burn rate" tab: a moving, streaming chart of token burn that updates in +/// real time. The headline is the per-interval burn rate (tokens/sec) — a moving +/// rate is the compelling read — with a secondary cumulative-tokens line. +/// +/// Owns a `LiveBurnViewModel` whose lifecycle (poll timer + background ingest +/// watch) is bound to this view's appearance: started in `onAppear`, torn down +/// in `onDisappear`. Falls back to a hint when burn can't be queried, mirroring +/// how the rest of the app no-ops on a missing binary. +struct LiveBurnView: View { + @ObservedObject var viewModel: LiveBurnViewModel + + private let rateColor = Color.orange + private let cumulativeColor = Color.blue + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if viewModel.unavailable { + hint + } else if viewModel.samples.count < 2 { + warming + } else { + headline + rateChart.frame(height: 130) + cumulativeChart.frame(height: 90) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .onAppear { viewModel.start() } + .onDisappear { viewModel.stop() } + } + + // MARK: Headline + + private var headline: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 1) { + Text("Burn rate") + .font(.title3.weight(.bold)) + Text("live · trailing 5 min") + .font(.caption2) + .foregroundStyle(.tertiary) + } + Spacer() + VStack(alignment: .trailing, spacing: 1) { + Text(rateLabel) + .font(.headline.weight(.bold)) + .foregroundStyle(rateColor) + .monospacedDigit() + Text(spendLabel) + .font(.caption2) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } + + private var latest: LiveBurnSample? { viewModel.samples.last } + + private var rateLabel: String { + let rate = latest?.tokensPerSecond ?? 0 + if rate >= 1000 { + return String(format: "%.1fk tok/s", rate / 1000) + } + return "\(Int(rate.rounded())) tok/s" + } + + private var spendLabel: String { + let perMin = latest?.dollarsPerMinute ?? 0 + return String(format: "$%.2f/min", perMin) + } + + // MARK: Charts + + /// The moving burn-rate line (tokens/sec per polled interval). + private var rateChart: some View { + Chart(viewModel.samples) { sample in + AreaMark( + x: .value("Time", sample.date), + y: .value("Tokens/s", sample.tokensPerSecond) + ) + .foregroundStyle( + .linearGradient( + colors: [rateColor.opacity(0.28), rateColor.opacity(0.02)], + startPoint: .top, + endPoint: .bottom + ) + ) + .interpolationMethod(.monotone) + + LineMark( + x: .value("Time", sample.date), + y: .value("Tokens/s", sample.tokensPerSecond) + ) + .foregroundStyle(rateColor) + .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + .interpolationMethod(.monotone) + } + .chartXScale(domain: xDomain) + .chartXAxis(.hidden) + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine().foregroundStyle(Color.primary.opacity(0.08)) + AxisValueLabel { + if let v = value.as(Double.self) { + Text(tokenAxisLabel(v)) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + } + .chartCard() + } + + /// Cumulative tokens over the rolling window — a slower-moving companion. + private var cumulativeChart: some View { + Chart(viewModel.samples) { sample in + LineMark( + x: .value("Time", sample.date), + y: .value("Tokens", sample.tokens) + ) + .foregroundStyle(cumulativeColor) + .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + .interpolationMethod(.monotone) + } + .chartXScale(domain: xDomain) + .chartXAxis(.hidden) + .chartYAxis { + AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { value in + AxisGridLine().foregroundStyle(Color.primary.opacity(0.08)) + AxisValueLabel { + if let v = value.as(Double.self) { + Text(tokenAxisLabel(v)) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + } + .chartCard() + } + + /// Always span the full sample window so the line visibly slides left as new + /// samples arrive, even when the buffer isn't full yet. + private var xDomain: ClosedRange { + let dates = viewModel.samples.map(\.date) + guard let first = dates.first, let last = dates.last, first < last else { + let now = Date() + return now.addingTimeInterval(-1)...now + } + return first...last + } + + private func tokenAxisLabel(_ value: Double) -> String { + if value >= 1_000_000 { return String(format: "%.1fM", value / 1_000_000) } + if value >= 1_000 { return String(format: "%.0fk", value / 1_000) } + return "\(Int(value))" + } + + // MARK: Empty / warming states + + private var warming: some View { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Watching for live burn…") + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 220, alignment: .center) + } + + private var hint: some View { + VStack(spacing: 8) { + Image(systemName: "flame") + .font(.title) + .foregroundStyle(.tertiary) + Text("Live burn needs the bundled burn helper.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, minHeight: 220, alignment: .center) + .padding(.horizontal, 12) + } +} + +private extension View { + /// Shared card chrome matching `BurndownChartView`'s framing. + func chartCard() -> some View { + self + .padding(12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.06), lineWidth: 1) + ) + } +} diff --git a/apps/macos/Sources/Burn/LiveBurnViewModel.swift b/apps/macos/Sources/Burn/LiveBurnViewModel.swift new file mode 100644 index 00000000..acd3f9e7 --- /dev/null +++ b/apps/macos/Sources/Burn/LiveBurnViewModel.swift @@ -0,0 +1,128 @@ +import SwiftUI + +/// One polled reading of the live burn series: the cumulative totals at `date` +/// and the per-interval deltas ("burn rate") since the previous sample. +struct LiveBurnSample: Identifiable { + let id = UUID() + let date: Date + /// Cumulative USD cost over the rolling window at this instant. + let cost: Double + /// Cumulative token count over the rolling window at this instant. + let tokens: Int + /// Tokens burned per second since the previous sample (0 for the first). + let tokensPerSecond: Double + /// USD burned per minute since the previous sample (0 for the first). + let dollarsPerMinute: Double +} + +/// Drives the live "Burn rate" tab. Polls `burn summary --json` on a short timer +/// against a rolling window, keeps a bounded in-memory ring buffer of samples, +/// and derives a moving per-interval burn rate. Spawns a long-lived +/// `burn ingest --watch` for the lifetime of the view so the polled numbers +/// actually move (`summary` only queries the ledger; it no longer freshens it). +/// +/// Mirrors `UsageViewModel`'s `@MainActor` + `Task`/timer style and the +/// graceful no-op-on-failure behavior — when burn is missing the series simply +/// stays empty and the view shows a hint. +@MainActor +final class LiveBurnViewModel: ObservableObject { + /// The rolling series of samples (oldest first), capped at `maxSamples`. + @Published private(set) var samples: [LiveBurnSample] = [] + /// True once we've confirmed burn can't be queried, so the view can hint. + @Published private(set) var unavailable = false + + /// How often we poll `burn summary`. Cheap now that summary doesn't ingest, + /// so a tight cadence makes the chart feel live without hammering anything. + private let pollInterval: TimeInterval = 1.5 + /// Rolling window passed to `--since`: cumulative totals are measured over + /// the trailing few minutes so the line tracks recent activity, not all time. + private let windowSeconds: TimeInterval = 5 * 60 + /// Ring-buffer cap. At 1.5s/sample this is ~3.75 minutes of history on screen. + private let maxSamples = 150 + + private var provider: ProviderName + private var timer: Timer? + /// Guards against overlapping polls if a `summary` call runs long. + private var polling = false + + init(provider: ProviderName) { + self.provider = provider + } + + /// Begins polling and starts the background ingest watch. Idempotent. + func start() { + guard timer == nil else { return } + Task { await BurnLedger.shared.startIngestWatch() } + Task { await poll() } + timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in + Task { await self?.poll() } + } + } + + /// Stops polling and tears down the ingest watch. Called when the live view + /// disappears or the app closes. + func stop() { + timer?.invalidate() + timer = nil + Task { await BurnLedger.shared.stopIngestWatch() } + } + + /// Switches the tracked provider, clearing the series so the new provider's + /// readings start fresh. + func select(_ provider: ProviderName) { + guard provider != self.provider else { return } + self.provider = provider + samples = [] + unavailable = false + Task { await poll() } + } + + private func poll() async { + guard !polling else { return } + polling = true + defer { polling = false } + + let burnProvider = BurnLedger.burnProvider(for: provider) + let since = Date().addingTimeInterval(-windowSeconds) + guard let summary = await BurnLedger.shared.summary(provider: burnProvider, since: since) else { + // Only flip to "unavailable" before we've ever shown data, so a + // transient query failure doesn't blank an established chart. + if samples.isEmpty { unavailable = true } + return + } + unavailable = false + append(summary) + } + + private func append(_ summary: BurnLedger.Summary) { + let now = Date() + let tokensPerSecond: Double + let dollarsPerMinute: Double + if let prev = samples.last { + let dt = now.timeIntervalSince(prev.date) + if dt > 0 { + // Deltas can dip negative as the rolling window slides old turns + // out from under `--since`; clamp so the rate line stays sane. + tokensPerSecond = max(0, Double(summary.tokens - prev.tokens) / dt) + dollarsPerMinute = max(0, (summary.cost - prev.cost) / dt * 60) + } else { + tokensPerSecond = 0 + dollarsPerMinute = 0 + } + } else { + tokensPerSecond = 0 + dollarsPerMinute = 0 + } + + samples.append(LiveBurnSample( + date: now, + cost: summary.cost, + tokens: summary.tokens, + tokensPerSecond: tokensPerSecond, + dollarsPerMinute: dollarsPerMinute + )) + if samples.count > maxSamples { + samples.removeFirst(samples.count - maxSamples) + } + } +}