mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 14:34:32 +00:00
The release's new capabilities were unevenly surfaced: the Spend punchcard existed only on the web dashboard, and the menubar decoded nothing of the PR-attribution block the payload already carries. - Desktop app: new Punchcard component (hour-of-day x weekday spend matrix, ported from the dash and restyled to the app's tokens) on the Spend page. Fed by a dedicated getTimeline bridge channel that fetches the payload WITH history.timeline; every other fetch keeps the lean --no-timeline path, and the serve child makes the extra fetch cheap. Hides gracefully when the payload has no timeline (older CLI). - Menubar: PullRequestsSection renders the top three PRs by attributed spend under the Workflow strip; MenubarPayload now decodes the pullRequests block (decodeIfPresent, so older payloads are unchanged). Hidden when absent or empty. Deliberately NOT ported: codex Tok/s (reads rollout files per session - too heavy for payload cadence; stays a TUI/report analytics view) and the punchcard in the menubar (a 7x24 matrix has no legible place in a compact popover). App suite 468 green (bridge channel pinned, mocks extended), swift build + 156 tests green, CLI suite green.
512 lines
18 KiB
Swift
512 lines
18 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] = []
|
|
/// Attributed pull-request spend for the period (all-provider payloads
|
|
/// only). Optional so payloads from older CLIs still decode; absent or
|
|
/// empty -> the Pull requests section hides.
|
|
var pullRequests: PullRequestsBlock? = nil
|
|
}
|
|
|
|
struct PullRequestsBlock: Codable, Sendable {
|
|
let rows: [PullRequestRow]
|
|
}
|
|
|
|
struct PullRequestRow: Codable, Sendable {
|
|
let url: String
|
|
let label: String
|
|
let cost: Double
|
|
let sessions: Int
|
|
}
|
|
|
|
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, pullRequests
|
|
}
|
|
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) ?? []
|
|
pullRequests = try c.decodeIfPresent(PullRequestsBlock.self, forKey: .pullRequests)
|
|
}
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|