Skip to content
Open
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
223 changes: 190 additions & 33 deletions mac/Sources/CodeBurnMenubar/AppStore.swift

Large diffs are not rendered by default.

62 changes: 44 additions & 18 deletions mac/Sources/CodeBurnMenubar/Data/DataClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,53 @@ struct CLIDecodeFailure: Error, CustomStringConvertible {
/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
/// commands through `/bin/zsh -c` anymore.
struct DataClient {
static func fetch(period: Period, day: String? = nil, days: Set<String> = [], provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload {
static func fetch(period: Period,
day: String? = nil,
days: Set<String> = [],
provider: ProviderFilter,
includeOptimize: Bool,
scope: MenubarScope = .local) async throws -> MenubarPayload {
let subcommand = statusSubcommand(
period: period,
day: day,
days: days,
provider: provider,
includeOptimize: includeOptimize,
scope: scope
)
let result = try await runCLI(subcommand: subcommand)
guard result.exitCode == 0 else {
throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr)
}
do {
return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout)
} catch {
let snippet = String(decoding: result.stdout.prefix(2048), as: UTF8.self)
throw DataClientError.decode(CLIDecodeFailure(
underlying: error,
stdoutByteCount: result.stdout.count,
stdoutSnippet: snippet,
stderr: result.stderr
))
}
}

static func statusSubcommand(period: Period,
day: String? = nil,
days: Set<String> = [],
provider: ProviderFilter,
includeOptimize: Bool,
scope: MenubarScope = .local) -> [String] {
let effectiveScope: MenubarScope = days.count > 1 ? .local : scope
let effectiveProvider: ProviderFilter = effectiveScope == .combined ? .all : provider
var subcommand = [
"status",
"--format", "menubar-json",
"--provider", provider.cliArg,
"--provider", effectiveProvider.cliArg,
]
if effectiveScope == .combined {
subcommand.append(contentsOf: ["--scope", effectiveScope.cliArg])
}
if days.count > 1 {
subcommand.append(contentsOf: ["--days", days.sorted().joined(separator: ",")])
} else if let day {
Expand All @@ -58,22 +99,7 @@ struct DataClient {
if !includeOptimize {
subcommand.append("--no-optimize")
}

let result = try await runCLI(subcommand: subcommand)
guard result.exitCode == 0 else {
throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr)
}
do {
return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout)
} catch {
let snippet = String(decoding: result.stdout.prefix(2048), as: UTF8.self)
throw DataClientError.decode(CLIDecodeFailure(
underlying: error,
stdoutByteCount: result.stdout.count,
stdoutSnippet: snippet,
stderr: result.stderr
))
}
return subcommand
}

struct ProcessResult {
Expand Down
37 changes: 36 additions & 1 deletion mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,40 @@ struct MenubarPayload: Codable, Sendable {
let current: CurrentBlock
let optimize: OptimizeBlock
let history: HistoryBlock
let combined: CombinedUsage?
}

struct CombinedUsage: Codable, Sendable {
let perDevice: [CombinedDeviceUsage]
let combined: CombinedUsageTotals
}

struct CombinedDeviceUsage: Codable, Sendable {
let id: String
let name: String
let local: Bool
let error: String?
let cost: Double
let calls: Int
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let cacheCreateTokens: Int
let cacheReadTokens: Int
let totalTokens: Int
}

struct CombinedUsageTotals: Codable, Sendable {
let cost: Double
let calls: Int
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let cacheCreateTokens: Int
let cacheReadTokens: Int
let totalTokens: Int
let deviceCount: Int
let reachableCount: Int
}

struct HistoryBlock: Codable, Sendable {
Expand Down Expand Up @@ -387,6 +421,7 @@ extension MenubarPayload {
mcpServers: []
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: [])
history: HistoryBlock(daily: []),
combined: nil
)
}
39 changes: 39 additions & 0 deletions mac/Sources/CodeBurnMenubar/MenubarScope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation

private let menubarScopeDefaultsKey = "CodeBurnMenubarScope"

enum MenubarScope: String, CaseIterable, Identifiable, Sendable {
case local = "Local"
case combined = "Combined"

var id: String { rawValue }

var cliArg: String {
switch self {
case .local: "local"
case .combined: "combined"
}
}

var menubarDefaultsValue: String {
switch self {
case .local: "local"
case .combined: "combined"
}
}

init(menubarDefaultsValue: String?) {
switch menubarDefaultsValue {
case "combined": self = .combined
default: self = .local
}
}

static func savedMenubarScope(defaults: UserDefaults = .standard) -> MenubarScope {
MenubarScope(menubarDefaultsValue: defaults.string(forKey: menubarScopeDefaultsKey))
}

func persistAsMenubarDefault(defaults: UserDefaults = .standard) {
defaults.set(menubarDefaultsValue, forKey: menubarScopeDefaultsKey)
}
}
122 changes: 115 additions & 7 deletions mac/Sources/CodeBurnMenubar/Views/HeroSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,25 @@ struct HeroSection: View {
HStack(spacing: 2) {
Image(systemName: "arrow.up")
.font(.system(size: 9, weight: .semibold))
Text(formatTokens(Double(store.payload.current.outputTokens)))
Text(formatTokens(Double(totals.outputTokens)))
}
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
HStack(spacing: 2) {
Image(systemName: "arrow.down")
.font(.system(size: 9, weight: .semibold))
Text(formatTokens(Double(store.payload.current.inputTokens)))
Text(formatTokens(Double(totals.inputTokens)))
}
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.tertiary)
} else {
Text("\(store.payload.current.calls.asThousandsSeparated()) calls")
Text("\(totals.calls.asThousandsSeparated()) calls")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
Text("\(store.payload.current.sessions) sessions")
Text("\(totals.sessions) sessions")
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.tertiary)
Expand All @@ -55,7 +55,7 @@ struct HeroSection: View {

if !store.isDayMode,
store.selectedPeriod == .today,
store.isOverDailyBudget {
store.shouldShowDailyBudgetWarning {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10))
Expand All @@ -66,6 +66,18 @@ struct HeroSection: View {
.padding(.top, 2)
}

if let usage = combinedUsage {
CombinedDeviceBreakdown(usage: usage, formatTokens: formatTokens)
} else if store.activeScope == .combined, store.lastError != nil {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10))
Text("Combined unavailable · showing local")
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.secondary)
}

if let savingsCaption {
HStack(spacing: 4) {
Image(systemName: "leaf.fill")
Expand All @@ -83,13 +95,22 @@ struct HeroSection: View {

private var heroText: String {
if store.displayMetric == .tokens || store.displayMetric == .totalTokens {
let total = Double(store.payload.current.inputTokens + store.payload.current.outputTokens)
let total = Double(totals.totalTokens)
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()
return totals.cost.asCurrency()
}

private var combinedUsage: CombinedUsage? {
guard store.activeScope == .combined else { return nil }
return store.payload.combined
}

private var totals: HeroTotals {
HeroTotals(payload: store.payload, activeScope: store.activeScope)
}

private func formatTokens(_ n: Double) -> String {
Expand All @@ -101,6 +122,9 @@ struct HeroSection: View {

private var caption: String {
let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
if combinedUsage != nil {
return "Combined · \(label)"
}
if !store.isDayMode && store.selectedPeriod == .today {
return "\(label) · \(todayDate)"
}
Expand All @@ -113,6 +137,7 @@ struct HeroSection: View {
/// (above) and hypothetical avoided spend (below) never get summed
/// into a misleading "real cost" by the reader.
private var savingsCaption: String? {
guard combinedUsage == nil else { return nil }
let savings = store.payload.current.localModelSavings.totalUSD
guard savings > 0 else { return nil }
return "Saved \(savings.asCurrency()) with local models"
Expand All @@ -124,3 +149,86 @@ struct HeroSection: View {
return formatter.string(from: Date())
}
}

struct HeroTotals: Equatable {
let cost: Double
let calls: Int
let sessions: Int
let inputTokens: Int
let outputTokens: Int
let totalTokens: Int

init(cost: Double, calls: Int, sessions: Int, inputTokens: Int, outputTokens: Int, totalTokens: Int) {
self.cost = cost
self.calls = calls
self.sessions = sessions
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.totalTokens = totalTokens
}

init(payload: MenubarPayload, activeScope: MenubarScope) {
if activeScope == .combined, let combined = payload.combined?.combined {
self.init(
cost: combined.cost,
calls: combined.calls,
sessions: combined.sessions,
inputTokens: combined.inputTokens,
outputTokens: combined.outputTokens,
totalTokens: combined.inputTokens + combined.outputTokens
)
return
}

let current = payload.current
self.init(
cost: current.cost,
calls: current.calls,
sessions: current.sessions,
inputTokens: current.inputTokens,
outputTokens: current.outputTokens,
totalTokens: current.inputTokens + current.outputTokens
)
}
}

private struct CombinedDeviceBreakdown: View {
let usage: CombinedUsage
let formatTokens: (Double) -> String

var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
Image(systemName: "desktopcomputer")
.font(.system(size: 10))
Text("\(usage.combined.reachableCount) of \(usage.combined.deviceCount) devices")
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.secondary)

VStack(spacing: 3) {
ForEach(usage.perDevice, id: \.id) { device in
HStack(spacing: 6) {
Image(systemName: device.error == nil ? "circle.fill" : "exclamationmark.triangle.fill")
.font(.system(size: device.error == nil ? 5 : 9, weight: .semibold))
.foregroundStyle(device.error == nil ? Color.secondary.opacity(0.75) : Theme.semanticWarning)
.frame(width: 10)
Text(device.local ? "\(device.name) · local" : device.name)
.font(.system(size: 10.5, weight: .medium))
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: 6)
Text(device.error == nil ? device.cost.asCurrency() : "Unavailable")
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.secondary)
Text(formatTokens(Double(device.totalTokens)))
.font(.system(size: 10))
.monospacedDigit()
.foregroundStyle(.tertiary)
}
}
}
}
}
}
Loading
Loading