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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@
period-level `activities[]` rollup so a consumer can sum across days and
reconcile. Closes #279.

### Added (macOS menubar)
- **Cost/Tokens headline toggle.** The popover now has a Cost/Tokens switch
next to the insight tabs. Tokens mode flips the hero headline, Activity
row values and bars, and the always-visible status-item number to token
totals while keeping the existing currency selector scoped to money.
The menubar JSON payload now carries cache read/write token totals on
`current` and per-activity token totals so historical periods can render
the same metric without re-parsing raw sessions. Closes #305.

### Fixed (CLI)
- **Cursor sessions break down by project, not one row called "cursor".**
Cursor's chat history sat under a single dashboard row labeled `cursor`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ codeburn menubar

One command: downloads the latest `.app`, installs into `~/Applications`, and launches it. Re-run with `--force` to reinstall. Native Swift and SwiftUI app lives in `mac/` (see `mac/README.md` for build details).

The menubar icon always shows today's spend (so $0 is normal if you have not used AI tools today). Click to open a popover with agent tabs, period switcher (Today, 7 Days, 30 Days, Month, All), Trend, Forecast, Pulse, Stats, and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Refreshes every 30 seconds.
The menubar icon always shows today's spend by default (so $0 is normal if you have not used AI tools today), and the popover can switch the headline, Activity rows, and status-icon number between Cost and Tokens. Click to open a popover with agent tabs, period switcher (Today, 7 Days, 30 Days, Month, All), Trend, Forecast, Pulse, Stats, and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Refreshes every 30 seconds.

**Compact mode** shrinks the menubar item to fit the text, dropping decimals (e.g. `$110` instead of `$110.20`):

Expand Down
36 changes: 36 additions & 0 deletions mac/Sources/CodeBurnMenubar/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ final class AppStore {
var selectedProvider: ProviderFilter = .all
var selectedPeriod: Period = .today
var selectedInsight: InsightMode = .trend
var headlineMetric: HeadlineMetric = .persisted {
didSet { HeadlineMetric.persist(headlineMetric) }
}
var accentPreset: AccentPreset = ThemeState.shared.preset {
didSet { ThemeState.shared.preset = accentPreset }
}
Expand Down Expand Up @@ -814,6 +817,26 @@ enum Period: String, CaseIterable, Identifiable {
}
}

enum HeadlineMetric: String, CaseIterable, Identifiable {
case cost = "Cost"
case tokens = "Tokens"

private static let storageKey = "CodeBurnHeadlineMetric"

var id: String { rawValue }

static var persisted: HeadlineMetric {
guard let raw = UserDefaults.standard.string(forKey: storageKey),
let metric = HeadlineMetric(rawValue: raw)
else { return .cost }
return metric
}

static func persist(_ metric: HeadlineMetric) {
UserDefaults.standard.set(metric.rawValue, forKey: storageKey)
}
}

/// NumberFormatter is expensive to instantiate (~microseconds each) and currency/token values
/// are formatted dozens of times per popover refresh. These shared instances avoid thousands of
/// allocations per frame while SwiftUI's Observation framework still triggers redraws when
Expand Down Expand Up @@ -857,4 +880,17 @@ extension Int {
func asThousandsSeparated() -> String {
thousandsFormatter.string(from: NSNumber(value: self)) ?? "\(self)"
}

func asCompactTokens() -> String {
Double(self).asCompactTokens()
}
}

extension Double {
func asCompactTokens() -> String {
if self >= 1_000_000_000 { return String(format: "%.1fB", self / 1_000_000_000) }
if self >= 1_000_000 { return String(format: "%.1fM", self / 1_000_000) }
if self >= 1_000 { return String(format: "%.1fK", self / 1_000) }
return String(format: "%.0f", self)
}
}
30 changes: 24 additions & 6 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// Track currency so the menubar title catches up immediately on
// currency switch instead of waiting for the next 30s payload tick.
_ = self.store.currency
_ = self.store.headlineMetric
// Track the live-quota state too so the flame icon re-tints on
// every subscription / codex usage update, not just every 30s.
_ = self.store.subscription
Expand Down Expand Up @@ -443,7 +444,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// macOS reflow the status item in the menubar and detaches the
// anchored popover (it pops to a stale default position). The
// popoverDidClose delegate calls back through here once the popover
// is dismissed so the menubar cost catches up immediately on close.
// is dismissed so the menubar metric catches up immediately on close.
if popover != nil && popover.isShown { return }

// Clear any previously-set image so the attachment is the only glyph rendered.
Expand Down Expand Up @@ -476,11 +477,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {

let hasPayload = store.todayPayload != nil
let compact = isCompact
let fallback = compact ? "$-" : "$—"
let formatted = store.todayPayload?.current.cost
let valueText = compact
? (formatted?.asCompactCurrencyWhole() ?? fallback)
: " " + (formatted?.asCompactCurrency() ?? fallback)
let valueText = statusValueText(compact: compact)

var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0]
if !hasPayload {
Expand All @@ -493,6 +490,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
button.attributedTitle = composed
}

private func statusValueText(compact: Bool) -> String {
guard let current = store.todayPayload?.current else {
let fallback = fallbackStatusText(compact: compact)
return compact ? fallback : " " + fallback
}
switch store.headlineMetric {
case .cost:
return compact ? current.cost.asCompactCurrencyWhole() : " " + current.cost.asCompactCurrency()
case .tokens:
let tokens = current.totalTokens.asCompactTokens()
return compact ? tokens : " \(tokens) tok"
}
}

private func fallbackStatusText(compact: Bool) -> String {
switch store.headlineMetric {
case .cost: return compact ? "$-" : "$—"
case .tokens: return compact ? "-" : "—"
}
}

// MARK: - Popover

private func setupPopover() {
Expand Down
63 changes: 63 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,78 @@ struct CurrentBlock: Codable, Sendable {
let oneShotRate: Double?
let inputTokens: Int
let outputTokens: Int
let cacheReadTokens: Int
let cacheWriteTokens: Int
let cacheHitPercent: Double
let topActivities: [ActivityEntry]
let topModels: [ModelEntry]
let providers: [String: Double]

var totalTokens: Int {
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens
}
}

struct ActivityEntry: Codable, Sendable {
let name: String
let cost: Double
let turns: Int
let inputTokens: Int
let outputTokens: Int
let cacheReadTokens: Int
let cacheWriteTokens: Int
let oneShotRate: Double?

var totalTokens: Int {
inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens
}
}

extension CurrentBlock {
enum CodingKeys: String, CodingKey {
case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens
case cacheReadTokens, cacheWriteTokens, cacheHitPercent, topActivities, topModels, providers
}

/// Legacy current blocks already carried input/output tokens; only cache
/// read/write tokens are new here, so malformed payloads still fail loudly
/// for the pre-existing required fields.
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)
cacheReadTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadTokens) ?? 0
cacheWriteTokens = try c.decodeIfPresent(Int.self, forKey: .cacheWriteTokens) ?? 0
cacheHitPercent = try c.decode(Double.self, forKey: .cacheHitPercent)
topActivities = try c.decode([ActivityEntry].self, forKey: .topActivities)
topModels = try c.decode([ModelEntry].self, forKey: .topModels)
providers = try c.decode([String: Double].self, forKey: .providers)
}
}

extension ActivityEntry {
enum CodingKeys: String, CodingKey {
case name, cost, turns, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, oneShotRate
}

/// Older activity rows only carried cost/turns/one-shot data, so every
/// per-activity token bucket defaults to zero for defensive readback.
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)
turns = try c.decode(Int.self, forKey: .turns)
inputTokens = try c.decodeIfPresent(Int.self, forKey: .inputTokens) ?? 0
outputTokens = try c.decodeIfPresent(Int.self, forKey: .outputTokens) ?? 0
cacheReadTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadTokens) ?? 0
cacheWriteTokens = try c.decodeIfPresent(Int.self, forKey: .cacheWriteTokens) ?? 0
oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate)
}
}

struct ModelEntry: Codable, Sendable {
Expand Down Expand Up @@ -112,6 +173,8 @@ extension MenubarPayload {
oneShotRate: nil,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheHitPercent: 0,
topActivities: [],
topModels: [],
Expand Down
53 changes: 45 additions & 8 deletions mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct ActivitySection: View {
isExpanded: $isExpanded,
trailing: {
HStack(spacing: 8) {
Text("Cost").frame(minWidth: 54, alignment: .trailing)
Text(store.headlineMetric.rawValue).frame(minWidth: metricColumnWidth, alignment: .trailing)
Text("Turns").frame(minWidth: 52, alignment: .trailing)
Text("1-shot").frame(minWidth: 44, alignment: .trailing)
}
Expand All @@ -20,32 +20,62 @@ struct ActivitySection: View {
}
) {
VStack(alignment: .leading, spacing: 7) {
let maxCost = store.payload.current.topActivities.map(\.cost).max() ?? 1
ForEach(store.payload.current.topActivities, id: \.name) { activity in
ActivityRow(activity: activity, maxCost: maxCost)
let activities = sortedActivities
let maxValue = max(activities.map(metricValue).max() ?? 1, 1)
ForEach(activities, id: \.name) { activity in
ActivityRow(
activity: activity,
metric: store.headlineMetric,
metricValue: metricValue(activity),
maxValue: maxValue,
metricColumnWidth: metricColumnWidth
)
}
}
}
}

private var metricColumnWidth: CGFloat {
store.headlineMetric == .tokens ? 62 : 54
}

private var sortedActivities: [ActivityEntry] {
store.payload.current.topActivities.sorted { lhs, rhs in
let lhsValue = metricValue(lhs)
let rhsValue = metricValue(rhs)
if lhsValue == rhsValue { return lhs.name < rhs.name }
return lhsValue > rhsValue
}
}

private func metricValue(_ activity: ActivityEntry) -> Double {
switch store.headlineMetric {
case .cost: return activity.cost
case .tokens: return Double(activity.totalTokens)
}
}
}

struct ActivityRow: View {
let activity: ActivityEntry
let maxCost: Double
let metric: HeadlineMetric
let metricValue: Double
let maxValue: Double
let metricColumnWidth: CGFloat

var body: some View {
HStack(spacing: 8) {
FixedBar(fraction: activity.cost / maxCost)
FixedBar(fraction: metricValue / maxValue)
.frame(width: 56, height: 6)

Text(activity.name)
.font(.system(size: 12.5, weight: .medium))
.frame(maxWidth: .infinity, alignment: .leading)

Text(activity.cost.asCompactCurrency())
Text(primaryText)
.font(.codeMono(size: 12, weight: .medium))
.tracking(-0.2)
.frame(minWidth: 54, alignment: .trailing)
.frame(minWidth: metricColumnWidth, alignment: .trailing)

Text("\(activity.turns)")
.font(.system(size: 11))
Expand All @@ -67,6 +97,13 @@ struct ActivityRow: View {
guard let rate = activity.oneShotRate else { return "—" }
return "\(Int(rate * 100))%"
}

private var primaryText: String {
switch metric {
case .cost: return activity.cost.asCompactCurrency()
case .tokens: return activity.totalTokens.asCompactTokens()
}
}
}

/// Fixed-width horizontal bar that shows a fill fraction.
Expand Down
37 changes: 33 additions & 4 deletions mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ struct HeatmapSection: View {

var body: some View {
VStack(alignment: .leading, spacing: 10) {
InsightPillSwitcher(selected: bindingMode, visibleModes: visibleModes)
HStack(spacing: 6) {
InsightPillSwitcher(selected: bindingMode, visibleModes: visibleModes)
Spacer(minLength: 4)
HeadlineMetricSwitcher()
}
content
}
.frame(maxWidth: .infinity, alignment: .leading)
Expand Down Expand Up @@ -103,9 +107,9 @@ private struct InsightPillSwitcher: View {
selected = mode
} label: {
Text(mode.rawValue)
.font(.system(size: 11, weight: .medium))
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
.padding(.horizontal, 10)
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
Expand All @@ -118,6 +122,32 @@ private struct InsightPillSwitcher: View {
}
}

private struct HeadlineMetricSwitcher: View {
@Environment(AppStore.self) private var store

var body: some View {
HStack(spacing: 3) {
ForEach(HeadlineMetric.allCases) { metric in
Button {
store.headlineMetric = metric
} label: {
Text(metric.rawValue)
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(store.headlineMetric == metric ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
.padding(.horizontal, 7)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(store.headlineMetric == metric ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.10)))
)
}
.buttonStyle(.plain)
}
}
.help("Switch headline and activity metric")
}
}

// MARK: - Trend (14-day bar chart with peak + average)

private struct TrendInsight: View {
Expand Down Expand Up @@ -1390,4 +1420,3 @@ private func relativeReset(_ date: Date) -> String {
let days = Int(ceil(hours / 24))
return "in \(days)d"
}

Loading
Loading