Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/macos/Sources/Burn/BrandIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ extension ProviderName {
var brandColor: Color {
switch self {
case .claude: return Color(red: 0.85, green: 0.47, blue: 0.34) // Claude coral #D97757
case .codex: return Color(red: 0.06, green: 0.64, blue: 0.50) // OpenAI green #10A37F
case .codex: return Color(red: 0.36, green: 0.42, blue: 1.0) // Codex blue/purple #5B6CFF
}
}

Expand Down
75 changes: 66 additions & 9 deletions apps/macos/Sources/Burn/BurnLedger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,72 @@ actor BurnLedger {
return Summary(cost: cost, tokens: tokens)
}

// MARK: - Ingest

/// Runs one incremental `burn ingest` sweep so the ledger reflects freshly
/// written turns. `burn summary` is read-only (it no longer ingests), and the
/// background `ingest --watch` doesn't keep up, so the live monitor calls this
/// each poll to keep the numbers moving. A warm incremental sweep is fast
/// (only new turns). No-op / `nil` when burn is unavailable.
func ingest() {
_ = runBurn(["ingest", "--quiet"])
/// One bucket of a `burn summary --bucket` time-series.
struct TimeseriesPoint {
let date: Date
let tokens: Int
let cost: Double
}

/// Per-bucket cost/token totals for `provider` over `[since, now]`, bucketed
/// by `bucket` (a burn duration like "30s"/"5m"/"1h"). Returns `nil` when
/// burn is unavailable or the query/parse fails. Runs `burn summary --bucket`
/// (read-only), so it's cheap to refresh.
func timeseries(provider: String, since: Date, bucket: String) async -> [TimeseriesPoint]? {
let iso = ISO8601DateFormatter().string(from: since)
let args = ["summary", "--provider", provider, "--since", iso, "--bucket", bucket, "--json"]
guard let output = runBurn(args),
let data = output.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let buckets = json["buckets"] as? [[String: Any]]
else { return nil }

let parser = ISO8601DateFormatter()
parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let plain = ISO8601DateFormatter()

return buckets.compactMap { entry -> TimeseriesPoint? in
guard let start = entry["start"] as? String,
let date = parser.date(from: start) ?? plain.date(from: start)
else { return nil }
let tokens = (entry["totalTokens"] as? NSNumber)?.intValue ?? 0
let cost = ((entry["totalCost"] as? [String: Any])?["total"] as? NSNumber)?.doubleValue ?? 0
return TimeseriesPoint(date: date, tokens: tokens, cost: cost)
}
}

// MARK: - Long-lived ingest watch

/// The running `burn ingest --watch` process, if any. `burn summary` is
/// read-only (~10ms) but a one-shot `burn ingest` sweep is multi-second on a
/// large ledger — far too slow to run per poll. Instead this long-lived watch
/// keeps the ledger fresh incrementally (FS-event driven, ~1s poll), so the
/// live view's summary polls stay fast.
private var watchProcess: Process?

/// Starts a background `burn ingest --watch` if one isn't already running.
/// Only runs with the bundled native helper (a login-shell child can't be
/// cleanly managed); the live chart still polls either way.
func startIngestWatch() {
guard watchProcess == nil else { return }
guard case .bundled(let url) = resolveTool() else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep live ingest working for PATH-only helpers

When the app is run via swift run or built without bundling the helper but a burn exists on PATH, resolveTool() returns .path here and the new watch is skipped. Since this commit also removed the per-poll ingest() call and the Live tab only calls read-only summary --bucket, PATH-only builds no longer freshen the ledger, so the live chart stays stale unless the user separately starts an ingest watcher.

Useful? React with 👍 / 👎.

let process = Process()
process.executableURL = url
process.arguments = ["ingest", "--watch", "--quiet"]
process.standardOutput = Pipe()
process.standardError = Pipe()
Comment on lines +132 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drain or discard the watch process stderr

In a ledger state that causes repeated ingest errors, burn ingest --watch --quiet still writes those errors to stderr (crates/relayburn-cli/src/commands/ingest.rs lines 257-264), but this long-lived child now has both pipes created and never read. Once the stderr pipe fills, the watcher can block and stop freshening the ledger, leaving the Live tab polling stale summaries; redirect the streams to /dev/null or continuously drain them.

Useful? React with 👍 / 👎.

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
Expand Down
9 changes: 3 additions & 6 deletions apps/macos/Sources/Burn/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ struct ContentView: View {

init(viewModel: UsageViewModel) {
self.viewModel = viewModel
_liveViewModel = StateObject(
wrappedValue: LiveBurnViewModel(provider: viewModel.selectedProvider))
// The live view shows all providers at once (its own per-provider
// toggles), independent of the Usage tab's single-select provider.
_liveViewModel = StateObject(wrappedValue: LiveBurnViewModel())
}

var body: some View {
Expand All @@ -52,10 +53,6 @@ struct ContentView: View {
.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. Lives in
Expand Down
Loading
Loading