diff --git a/CHANGELOG.md b/CHANGELOG.md index e7dd43d7..59fa958f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/README.md b/README.md index b3700224..e9c93f44 100644 --- a/README.md +++ b/README.md @@ -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`): diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 00b27e8b..ac9199a0 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -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 } } @@ -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 @@ -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) + } } diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 5868258e..a0f8be05 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -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 @@ -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. @@ -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 { @@ -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() { diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 2e44fae5..946da430 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -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 { @@ -112,6 +173,8 @@ extension MenubarPayload { oneShotRate: nil, inputTokens: 0, outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, cacheHitPercent: 0, topActivities: [], topModels: [], diff --git a/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift b/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift index 98033878..3536a798 100644 --- a/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift @@ -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) } @@ -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)) @@ -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. diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index 3374bd93..ab91c010 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -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) @@ -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) @@ -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 { @@ -1390,4 +1420,3 @@ private func relativeReset(_ date: Date) -> String { let days = Int(ceil(hours / 24)) return "in \(days)d" } - diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift index 056f5b09..be9e4452 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -8,10 +8,12 @@ struct HeroSection: View { SectionCaption(text: caption) HStack(alignment: .firstTextBaseline) { - Text(store.payload.current.cost.asCurrency()) + Text(primaryValue) .font(.system(size: 32, weight: .semibold, design: .rounded)) .monospacedDigit() .tracking(-1) + .lineLimit(1) + .minimumScaleFactor(0.75) .foregroundStyle( LinearGradient( colors: [Theme.brandAccent, Theme.brandAccentDeep], @@ -41,10 +43,20 @@ struct HeroSection: View { private var caption: String { let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label + let metricLabel = store.headlineMetric == .tokens ? "\(label) tokens" : label if store.selectedPeriod == .today { - return "\(label) · \(todayDate)" + return "\(metricLabel) · \(todayDate)" + } + return metricLabel + } + + private var primaryValue: String { + switch store.headlineMetric { + case .cost: + return store.payload.current.cost.asCurrency() + case .tokens: + return "\(store.payload.current.totalTokens.asCompactTokens()) tokens" } - return label } private var todayDate: String { diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarPayloadTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarPayloadTests.swift new file mode 100644 index 00000000..7a36d0c6 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/MenubarPayloadTests.swift @@ -0,0 +1,49 @@ +import Foundation +import XCTest +@testable import CodeBurnMenubar + +final class MenubarPayloadDecodingTests: XCTestCase { + func testDecodesLegacyTokenlessActivityPayload() throws { + let json = """ + { + "generated": "2026-05-11T12:00:00.000Z", + "current": { + "label": "Today", + "cost": 12.5, + "calls": 4, + "sessions": 2, + "oneShotRate": 0.75, + "inputTokens": 100, + "outputTokens": 200, + "cacheHitPercent": 0, + "topActivities": [ + { + "name": "Coding", + "cost": 12.5, + "turns": 3, + "oneShotRate": 0.75 + } + ], + "topModels": [], + "providers": { "claude": 12.5 } + }, + "optimize": { + "findingCount": 0, + "savingsUSD": 0, + "topFindings": [] + }, + "history": { "daily": [] } + } + """ + + let payload = try JSONDecoder().decode(MenubarPayload.self, from: Data(json.utf8)) + XCTAssertEqual(payload.current.cacheReadTokens, 0) + XCTAssertEqual(payload.current.cacheWriteTokens, 0) + XCTAssertEqual(payload.current.totalTokens, 300) + + let activity = try XCTUnwrap(payload.current.topActivities.first) + XCTAssertEqual(activity.cacheReadTokens, 0) + XCTAssertEqual(activity.cacheWriteTokens, 0) + XCTAssertEqual(activity.totalTokens, 0) + } +} diff --git a/src/cli.ts b/src/cli.ts index 4ebfe337..84e3b405 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -342,7 +342,16 @@ program function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { const sessions = projects.flatMap(p => p.sessions) - const catTotals: Record = {} + const catTotals: Record = {} const modelTotals: Record = {} let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 @@ -351,12 +360,26 @@ function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData outputTokens += sess.totalOutputTokens cacheReadTokens += sess.totalCacheReadTokens cacheWriteTokens += sess.totalCacheWriteTokens - for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { - if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } - catTotals[cat].turns += d.turns - catTotals[cat].cost += d.costUSD - catTotals[cat].editTurns += d.editTurns - catTotals[cat].oneShotTurns += d.oneShotTurns + for (const turn of sess.turns) { + if (!catTotals[turn.category]) { + catTotals[turn.category] = { + turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + } + } + const bucket = catTotals[turn.category]! + bucket.turns += 1 + if (turn.hasEdits) { + bucket.editTurns += 1 + if (turn.retries === 0) bucket.oneShotTurns += 1 + } + for (const call of turn.assistantCalls) { + bucket.cost += call.costUSD + bucket.inputTokens += call.usage.inputTokens + bucket.outputTokens += call.usage.outputTokens + bucket.cacheReadTokens += call.usage.cacheReadInputTokens + bucket.cacheWriteTokens += call.usage.cacheCreationInputTokens + } } for (const [model, d] of Object.entries(sess.modelBreakdown)) { if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 } diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 6c930657..73bb98e6 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -5,24 +5,18 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' -// Bumped to 5 alongside the Cursor per-project breakdown: prior daily -// entries recorded every Cursor session under a single 'cursor' project -// label. After the upgrade, the breakdown produces per-workspace project -// labels for new days; without invalidation the dashboard would show -// 'cursor' for historical days and `-Users-you-myproject` for new ones -// in the same window, producing a confusing mixed projection. -export const DAILY_CACHE_VERSION = 5 -// MIN_SUPPORTED_VERSION bumped to 5 too. The migration path +// Bumped to 6 alongside the menubar Cost/Tokens toggle: prior daily entries +// did not retain per-category token totals, so historical Activity rows could +// not switch to tokens without a clean recompute. +export const DAILY_CACHE_VERSION = 6 +// MIN_SUPPORTED_VERSION bumped with DAILY_CACHE_VERSION too. The migration path // (isMigratableCache + migrateDays) only fills in missing default fields; // it does NOT recompute the providers / categories / models rollups from // session data, because those raw sessions are not stored in the cache. -// So a migrated v2/v3/v4 cache would carry forward stale provider totals -// (single 'cursor' bucket instead of per-workspace) for the full cache -// retention window. Setting the floor to 5 forces those older caches to -// be discarded and recomputed cleanly. Confirmed by live test: -// menubar-json --period all reported cursor=$3.78 against a migrated -// v4 cache but $4.08 (correct) after the cache was discarded. -const MIN_SUPPORTED_VERSION = 5 +// So a migrated old cache would carry forward stale provider/category totals +// for the full cache retention window. Setting the floor to the active version +// forces those caches to be discarded and recomputed cleanly. +const MIN_SUPPORTED_VERSION = DAILY_CACHE_VERSION const DAILY_CACHE_FILENAME = 'daily-cache.json' export type DailyEntry = { @@ -44,7 +38,16 @@ export type DailyEntry = { cacheReadTokens: number cacheWriteTokens: number }> - categories: Record + categories: Record providers: Record } diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index bc63fa68..570315f1 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -45,16 +45,30 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr const editTurns = turn.hasEdits ? 1 : 0 const oneShotTurns = turn.hasEdits && turn.retries === 0 ? 1 : 0 - const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0) + const turnTotals = turn.assistantCalls.reduce((acc, call) => { + acc.cost += call.costUSD + acc.inputTokens += call.usage.inputTokens + acc.outputTokens += call.usage.outputTokens + acc.cacheReadTokens += call.usage.cacheReadInputTokens + acc.cacheWriteTokens += call.usage.cacheCreationInputTokens + return acc + }, { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }) turnDay.editTurns += editTurns turnDay.oneShotTurns += oneShotTurns - const cat = turnDay.categories[turn.category] ?? { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } + const cat = turnDay.categories[turn.category] ?? { + turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + } cat.turns += 1 - cat.cost += turnCost + cat.cost += turnTotals.cost cat.editTurns += editTurns cat.oneShotTurns += oneShotTurns + cat.inputTokens += turnTotals.inputTokens + cat.outputTokens += turnTotals.outputTokens + cat.cacheReadTokens += turnTotals.cacheReadTokens + cat.cacheWriteTokens += turnTotals.cacheWriteTokens turnDay.categories[turn.category] = cat for (const call of turn.assistantCalls) { @@ -96,7 +110,16 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr export function buildPeriodDataFromDays(days: DailyEntry[], label: string): PeriodData { let cost = 0, calls = 0, sessions = 0 let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 - const catTotals: Record = {} + const catTotals: Record = {} const modelTotals: Record = {} for (const d of days) { @@ -115,11 +138,18 @@ export function buildPeriodDataFromDays(days: DailyEntry[], label: string): Peri modelTotals[name] = acc } for (const [cat, c] of Object.entries(d.categories)) { - const acc = catTotals[cat] ?? { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } + const acc = catTotals[cat] ?? { + turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + } acc.turns += c.turns acc.cost += c.cost acc.editTurns += c.editTurns acc.oneShotTurns += c.oneShotTurns + acc.inputTokens += c.inputTokens + acc.outputTokens += c.outputTokens + acc.cacheReadTokens += c.cacheReadTokens + acc.cacheWriteTokens += c.cacheWriteTokens catTotals[cat] = acc } } diff --git a/src/menubar-json.ts b/src/menubar-json.ts index bab4e401..4c8368f7 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -10,7 +10,17 @@ export type PeriodData = { outputTokens: number cacheReadTokens: number cacheWriteTokens: number - categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }> + categories: Array<{ + name: string + cost: number + turns: number + editTurns: number + oneShotTurns: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + }> models: Array<{ name: string; cost: number; calls: number }> } @@ -55,11 +65,17 @@ export type MenubarPayload = { oneShotRate: number | null inputTokens: number outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number cacheHitPercent: number topActivities: Array<{ name: string cost: number turns: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number oneShotRate: number | null }> topModels: Array<{ @@ -106,10 +122,17 @@ function cacheHitPercent(inputTokens: number, cacheReadTokens: number): number { } function buildTopActivities(categories: PeriodData['categories']): MenubarPayload['current']['topActivities'] { + // The CLI supplies categories sorted by cost. There are fewer than 20 known + // task categories today, so the macOS token-mode resort still receives every + // category while keeping this payload compact if the taxonomy grows later. return categories.slice(0, TOP_ACTIVITIES_LIMIT).map(cat => ({ name: cat.name, cost: cat.cost, turns: cat.turns, + inputTokens: cat.inputTokens, + outputTokens: cat.outputTokens, + cacheReadTokens: cat.cacheReadTokens, + cacheWriteTokens: cat.cacheWriteTokens, oneShotRate: oneShotRateFor(cat.editTurns, cat.oneShotTurns), })) } @@ -171,6 +194,8 @@ export function buildMenubarPayload( oneShotRate: aggregateOneShotRate(current.categories), inputTokens: current.inputTokens, outputTokens: current.outputTokens, + cacheReadTokens: current.cacheReadTokens, + cacheWriteTokens: current.cacheWriteTokens, cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens), topActivities: buildTopActivities(current.categories), topModels: buildTopModels(current.models), diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index 9ca92390..ca832f26 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -129,6 +129,10 @@ describe('aggregateProjectsIntoDays', () => { cost: 3, editTurns: 1, oneShotTurns: 1, + inputTokens: 100, + outputTokens: 200, + cacheReadTokens: 50, + cacheWriteTokens: 0, }) }) @@ -217,7 +221,18 @@ describe('buildPeriodDataFromDays', () => { 'Opus 4.7': { calls: 8, cost: cost * 0.8, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, 'Haiku 4.5': { calls: 2, cost: cost * 0.2, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, }, - categories: { 'coding': { turns: 2, cost: cost * 0.5, editTurns: 2, oneShotTurns: 1 } }, + categories: { + 'coding': { + turns: 2, + cost: cost * 0.5, + editTurns: 2, + oneShotTurns: 1, + inputTokens: 50, + outputTokens: 100, + cacheReadTokens: 150, + cacheWriteTokens: 25, + }, + }, providers: { 'claude': { calls: 10, cost } }, } } @@ -251,6 +266,10 @@ describe('buildPeriodDataFromDays', () => { expect(coding.editTurns).toBe(4) expect(coding.oneShotTurns).toBe(2) expect(coding.cost).toBeCloseTo(15) + expect(coding.inputTokens).toBe(100) + expect(coding.outputTokens).toBe(200) + expect(coding.cacheReadTokens).toBe(300) + expect(coding.cacheWriteTokens).toBe(50) }) it('returns empty period totals when no days supplied', () => { diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index f7493d0b..ea1bd9e8 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -18,6 +18,21 @@ function emptyPeriod(label: string): PeriodData { } } +function category(overrides: Partial = {}): PeriodData['categories'][number] { + return { + name: 'Coding', + cost: 0, + turns: 0, + editTurns: 0, + oneShotTurns: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + ...overrides, + } +} + describe('buildMenubarPayload', () => { it('emits the full schema with current-period metrics and iso timestamp', () => { const period: PeriodData = { @@ -41,6 +56,8 @@ describe('buildMenubarPayload', () => { expect(payload.current.sessions).toBe(97) expect(payload.current.inputTokens).toBe(19100) expect(payload.current.outputTokens).toBe(675600) + expect(payload.current.cacheReadTokens).toBe(0) + expect(payload.current.cacheWriteTokens).toBe(0) }) it('computes per-category oneShotRate from editTurns and skips categories without edits', () => { @@ -49,8 +66,8 @@ describe('buildMenubarPayload', () => { cost: 0, calls: 0, sessions: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, categories: [ - { name: 'Coding', cost: 15.83, turns: 7, editTurns: 7, oneShotTurns: 6 }, - { name: 'Conversation', cost: 16.69, turns: 47, editTurns: 0, oneShotTurns: 0 }, + category({ name: 'Coding', cost: 15.83, turns: 7, editTurns: 7, oneShotTurns: 6 }), + category({ name: 'Conversation', cost: 16.69, turns: 47, editTurns: 0, oneShotTurns: 0 }), ], models: [], } @@ -69,9 +86,9 @@ describe('buildMenubarPayload', () => { cost: 0, calls: 0, sessions: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, categories: [ - { name: 'Coding', cost: 1, turns: 7, editTurns: 10, oneShotTurns: 8 }, - { name: 'Debugging', cost: 1, turns: 5, editTurns: 10, oneShotTurns: 6 }, - { name: 'Conversation', cost: 1, turns: 40, editTurns: 0, oneShotTurns: 0 }, + category({ name: 'Coding', cost: 1, turns: 7, editTurns: 10, oneShotTurns: 8 }), + category({ name: 'Debugging', cost: 1, turns: 5, editTurns: 10, oneShotTurns: 6 }), + category({ name: 'Conversation', cost: 1, turns: 40, editTurns: 0, oneShotTurns: 0 }), ], models: [], } @@ -84,7 +101,7 @@ describe('buildMenubarPayload', () => { label: 'Today', cost: 0, calls: 0, sessions: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, - categories: [{ name: 'Conversation', cost: 1, turns: 5, editTurns: 0, oneShotTurns: 0 }], + categories: [category({ name: 'Conversation', cost: 1, turns: 5, editTurns: 0, oneShotTurns: 0 })], models: [], } const payload = buildMenubarPayload(period, [], null) @@ -114,7 +131,7 @@ describe('buildMenubarPayload', () => { cost: 0, calls: 0, sessions: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, categories: Array.from({ length: 25 }, (_, i) => ({ - name: `Cat${i}`, cost: 1, turns: 1, editTurns: 1, oneShotTurns: 1, + ...category({ name: `Cat${i}`, cost: 1, turns: 1, editTurns: 1, oneShotTurns: 1 }), })), models: [], } @@ -122,6 +139,35 @@ describe('buildMenubarPayload', () => { expect(payload.current.topActivities).toHaveLength(20) }) + it('passes token totals through topActivities for the menubar token view', () => { + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 300, outputTokens: 120, cacheReadTokens: 900, cacheWriteTokens: 80, + categories: [ + category({ + name: 'Coding', + cost: 7, + turns: 2, + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 500, + cacheWriteTokens: 25, + }), + ], + models: [], + } + const payload = buildMenubarPayload(period, [], null) + expect(payload.current.cacheReadTokens).toBe(900) + expect(payload.current.cacheWriteTokens).toBe(80) + expect(payload.current.topActivities[0]).toMatchObject({ + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 500, + cacheWriteTokens: 25, + }) + }) + it('computes cacheHitPercent from cache reads over input plus cache reads', () => { const period: PeriodData = { label: 'Today',