Skip to content
70 changes: 70 additions & 0 deletions apps/macos/Sources/Burn/BurnLedger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
37 changes: 36 additions & 1 deletion apps/macos/Sources/Burn/ContentView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
205 changes: 205 additions & 0 deletions apps/macos/Sources/Burn/LiveBurnView.swift
Original file line number Diff line number Diff line change
@@ -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<Date> {
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)
)
}
}
Loading
Loading