codeburn/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Resham Joshi 8097052f67
menubar: add Workflow strip after the Models section (#785)
Compact one-row strip: correction rate with count, median time to first
edit, and the top reworked file, plus one coaching note derived locally
with the same thresholds and copy as the CLI's workflow-insights
buildCoachingNotes. Decodes two new optional payload blocks
(current.workflow and current.topReworkedFiles), so payloads from older
CLIs still parse. The section hides entirely when there is no signal and
individual stats never render as zero placeholders. It reads the store's
current payload, so it follows the selected agent tab automatically.

Adds unit tests for the duration formatter, note selection, model
derivation, and payload decoding.

Co-authored-by: reviewer <review@local>
2026-07-20 13:55:55 -07:00

496 lines
17 KiB
Swift

import Foundation
/// Shape of `codeburn status --format menubar-json --period <period>`.
/// `current` is scoped to the requested period; the whole payload reflects that slice.
struct MenubarPayload: Codable, Sendable {
let generated: String
let current: CurrentBlock
let optimize: OptimizeBlock
let history: HistoryBlock
let combined: CombinedUsage?
let claudeConfigs: ClaudeConfigSelector?
init(generated: String,
current: CurrentBlock,
optimize: OptimizeBlock,
history: HistoryBlock,
combined: CombinedUsage?,
claudeConfigs: ClaudeConfigSelector? = nil) {
self.generated = generated
self.current = current
self.optimize = optimize
self.history = history
self.combined = combined
self.claudeConfigs = claudeConfigs
}
enum CodingKeys: String, CodingKey {
case generated, current, optimize, history, combined, claudeConfigs
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
generated = try c.decode(String.self, forKey: .generated)
current = try c.decode(CurrentBlock.self, forKey: .current)
optimize = try c.decode(OptimizeBlock.self, forKey: .optimize)
history = try c.decode(HistoryBlock.self, forKey: .history)
combined = try c.decodeIfPresent(CombinedUsage.self, forKey: .combined)
claudeConfigs = try c.decodeIfPresent(ClaudeConfigSelector.self, forKey: .claudeConfigs)
}
}
struct ClaudeConfigSelector: Codable, Sendable {
let selectedId: String?
let options: [ClaudeConfigOption]
}
struct ClaudeConfigOption: Codable, Identifiable, Hashable, Sendable {
let id: String
let label: String
let path: String
}
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 {
let daily: [DailyHistoryEntry]
}
struct DailyModelBreakdown: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
var totalTokens: Int { inputTokens + outputTokens }
enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD, calls, inputTokens, outputTokens
}
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)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
calls = try c.decode(Int.self, forKey: .calls)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
}
}
struct DailyHistoryEntry: Codable, Sendable {
let date: String
let cost: Double
let savingsUSD: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
let cacheReadTokens: Int
let cacheWriteTokens: Int
let topModels: [DailyModelBreakdown]
/// Pricing-ratio prior: input + 5x output + cache_creation + 0.1x cache_read.
/// Matches Anthropic's published per-token pricing on Sonnet/Opus closely enough to be a useful proxy.
var effectiveTokens: Double {
Double(inputTokens) + 5.0 * Double(outputTokens) + Double(cacheWriteTokens) + 0.1 * Double(cacheReadTokens)
}
}
extension DailyHistoryEntry {
/// Required for legacy payloads (no topModels emitted yet).
enum CodingKeys: String, CodingKey {
case date, cost, savingsUSD, calls, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, topModels
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
date = try c.decode(String.self, forKey: .date)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
calls = try c.decode(Int.self, forKey: .calls)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
cacheReadTokens = try c.decode(Int.self, forKey: .cacheReadTokens)
cacheWriteTokens = try c.decode(Int.self, forKey: .cacheWriteTokens)
topModels = try c.decodeIfPresent([DailyModelBreakdown].self, forKey: .topModels) ?? []
}
}
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]
}
/// Workflow-intelligence rollup for the period. `correctionRate` and
/// `medianTimeToFirstEditMs` are null when not computable (no user turns / no
/// session ever edited), so both are optional.
struct WorkflowBlock: Codable, Sendable {
let corrections: Int
let correctionRate: Double?
let medianTimeToFirstEditMs: Double?
}
/// One entry of `topReworkedFiles`. `path` is basename-only (the CLI trims it
/// for privacy before the payload can leave the machine); `sessions` is the
/// distinct-session count and `edits` the total edit-family calls.
struct ReworkedFileEntry: Codable, Sendable {
let path: String
let sessions: Int
let edits: Int
}
struct CurrentBlock: Codable, Sendable {
let label: String
let cost: Double
let calls: Int
let sessions: Int
let oneShotRate: Double?
let inputTokens: Int
let outputTokens: Int
let cacheHitPercent: Double
/// Codex credits consumed in the period (nil on payloads from older builds).
let codexCredits: Double?
let topActivities: [ActivityEntry]
let topModels: [ModelEntry]
let localModelSavings: LocalModelSavings
let providers: [String: Double]
let topProjects: [ProjectEntry]
let modelEfficiency: [ModelEfficiencyEntry]
let topSessions: [TopSessionEntry]
let retryTax: RetryTax
let routingWaste: RoutingWaste
let tools: [ToolEntry]
let skills: [SkillEntry]
let subagents: [SubagentEntry]
let mcpServers: [McpServerEntry]
/// Workflow-intelligence rollup. Optional so payloads from older CLIs
/// (which never emit it) still decode; absent -> the Workflow strip hides.
/// Declared last with a default so the memberwise initializer stays
/// backward-compatible for existing construction sites.
var workflow: WorkflowBlock? = nil
/// Files most reworked by edit-family calls. Empty on older CLIs.
var topReworkedFiles: [ReworkedFileEntry] = []
}
extension CurrentBlock {
enum CodingKeys: String, CodingKey {
case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens,
cacheHitPercent, codexCredits, topActivities, topModels, localModelSavings, providers, topProjects,
modelEfficiency, topSessions, retryTax, routingWaste,
tools, skills, subagents, mcpServers,
workflow, topReworkedFiles
}
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.decodeIfPresent(Double.self, forKey: .cacheHitPercent) ?? 0
codexCredits = try c.decodeIfPresent(Double.self, forKey: .codexCredits)
topActivities = try c.decodeIfPresent([ActivityEntry].self, forKey: .topActivities) ?? []
topModels = try c.decodeIfPresent([ModelEntry].self, forKey: .topModels) ?? []
localModelSavings = try c.decodeIfPresent(LocalModelSavings.self, forKey: .localModelSavings) ?? LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: [])
providers = try c.decodeIfPresent([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: [])
tools = try c.decodeIfPresent([ToolEntry].self, forKey: .tools) ?? []
skills = try c.decodeIfPresent([SkillEntry].self, forKey: .skills) ?? []
subagents = try c.decodeIfPresent([SubagentEntry].self, forKey: .subagents) ?? []
mcpServers = try c.decodeIfPresent([McpServerEntry].self, forKey: .mcpServers) ?? []
workflow = try c.decodeIfPresent(WorkflowBlock.self, forKey: .workflow)
topReworkedFiles = try c.decodeIfPresent([ReworkedFileEntry].self, forKey: .topReworkedFiles) ?? []
}
}
struct LocalModelSavingsByModel: Codable, Sendable {
let name: String
let calls: Int
let actualUSD: Double
let savingsUSD: Double
let baselineModel: String
let inputTokens: Int
let outputTokens: Int
}
struct LocalModelSavingsByProvider: Codable, Sendable {
let name: String
let calls: Int
let savingsUSD: Double
}
struct LocalModelSavings: Codable, Sendable {
let totalUSD: Double
let calls: Int
let byModel: [LocalModelSavingsByModel]
let byProvider: [LocalModelSavingsByProvider]
}
struct ActivityEntry: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
let turns: Int
let oneShotRate: Double?
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)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
turns = try c.decode(Int.self, forKey: .turns)
oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate)
}
private enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD, turns, oneShotRate
}
}
struct ModelEntry: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
let savingsBaselineModel: String
let calls: Int
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)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
savingsBaselineModel = try c.decodeIfPresent(String.self, forKey: .savingsBaselineModel) ?? ""
calls = try c.decode(Int.self, forKey: .calls)
}
private enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD, savingsBaselineModel, calls
}
}
struct SessionModelEntry: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: Double
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)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
}
private enum CodingKeys: String, CodingKey {
case name, cost, savingsUSD
}
}
struct SessionDetailEntry: Codable, Sendable {
let cost: Double
let savingsUSD: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
let date: String
let models: [SessionModelEntry]
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
calls = try c.decode(Int.self, forKey: .calls)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
date = try c.decode(String.self, forKey: .date)
models = try c.decodeIfPresent([SessionModelEntry].self, forKey: .models) ?? []
}
private enum CodingKeys: String, CodingKey {
case cost, savingsUSD, calls, inputTokens, outputTokens, date, models
}
}
struct ProjectEntry: Codable, Sendable {
let name: String
let cost: Double
let savingsUSD: 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)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
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, savingsUSD, 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 savingsUSD: Double
let calls: Int
let date: String
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
project = try c.decode(String.self, forKey: .project)
cost = try c.decode(Double.self, forKey: .cost)
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
calls = try c.decode(Int.self, forKey: .calls)
date = try c.decode(String.self, forKey: .date)
}
private enum CodingKeys: String, CodingKey {
case project, cost, savingsUSD, calls, date
}
}
struct ToolEntry: Codable, Sendable {
let name: String
let calls: Int
}
struct SkillEntry: Codable, Sendable {
let name: String
let turns: Int
let cost: Double
}
struct SubagentEntry: Codable, Sendable {
let name: String
let calls: Int
let cost: Double
}
struct McpServerEntry: Codable, Sendable {
let name: String
let calls: Int
}
struct OptimizeBlock: Codable, Sendable {
let findingCount: Int
let savingsUSD: Double
let topFindings: [FindingEntry]
}
struct FindingEntry: Codable, Sendable {
let title: String
let impact: String
let savingsUSD: Double
}
// MARK: - Empty fallback
extension MenubarPayload {
/// Strictly-empty payload. Used as the fallback before real data arrives, so no
/// plausible-looking fake numbers leak into the UI.
static let empty = MenubarPayload(
generated: "",
current: CurrentBlock(
label: "",
cost: 0,
calls: 0,
sessions: 0,
oneShotRate: nil,
inputTokens: 0,
outputTokens: 0,
cacheHitPercent: 0,
codexCredits: nil,
topActivities: [],
topModels: [],
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
providers: [:],
topProjects: [],
modelEfficiency: [],
topSessions: [],
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []),
tools: [],
skills: [],
subagents: [],
mcpServers: []
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: []),
combined: nil,
claudeConfigs: nil
)
}