diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b74542..f6b0479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,15 @@ `Shell`, `ReadFile`, and `WriteFile`, and maps hidden managed Kimi Code model aliases to priced Kimi K2 entries. +### 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. + ## 0.9.9 - 2026-05-15 ### Added (CLI) diff --git a/README.md b/README.md index fc847c0..6369513 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,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 b1c89cb..f158305 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -21,6 +21,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 } } @@ -964,6 +967,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 @@ -1007,4 +1030,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 6191575..824c0b1 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -624,6 +624,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 @@ -695,7 +696,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. @@ -728,11 +729,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 { @@ -745,6 +742,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 2e44fae..4778d68 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 + } } 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 + } +} + +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 9803387..3536a79 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 751adbd..d995d1e 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 056f5b0..be9e445 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 0000000..028d72b --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/MenubarPayloadTests.swift @@ -0,0 +1,99 @@ +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) + } + + func testDisplayTokenTotalsExcludeCacheBuckets() 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, + "cacheReadTokens": 5000, + "cacheWriteTokens": 800, + "cacheHitPercent": 98, + "topActivities": [ + { + "name": "Coding", + "cost": 12.5, + "turns": 3, + "inputTokens": 50, + "outputTokens": 75, + "cacheReadTokens": 2500, + "cacheWriteTokens": 400, + "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, 5000) + XCTAssertEqual(payload.current.cacheWriteTokens, 800) + XCTAssertEqual(payload.current.totalTokens, 300) + + let activity = try XCTUnwrap(payload.current.topActivities.first) + XCTAssertEqual(activity.cacheReadTokens, 2500) + XCTAssertEqual(activity.cacheWriteTokens, 400) + XCTAssertEqual(activity.totalTokens, 125) + } +} diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 0947b06..34a946d 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -5,13 +5,52 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' -// Bumped to 7: new providers (Codebuff, Mistral Vibe, Kimi, Cline) and -// the per-provider menubar path now reads historical cost from the cache. -// Stale entries computed by older binaries may carry incorrect totals. -export const DAILY_CACHE_VERSION = 7 -const MIN_SUPPORTED_VERSION = 7 +// Bumped to 8 alongside the menubar Cost/Tokens toggle: v7 entries can contain +// provider rollups but still do not retain per-category token totals, so +// historical Activity rows could not switch to tokens without a clean +// recompute. +export const DAILY_CACHE_VERSION = 8 +// MIN_SUPPORTED_VERSION stays pinned to the active version. The migration path +// only fills in missing default fields; it does not recompute provider, +// category, or model rollups from raw sessions because those sessions are not +// stored in the cache. Older cache files are backed up and rebuilt cleanly. +const MIN_SUPPORTED_VERSION = DAILY_CACHE_VERSION const DAILY_CACHE_FILENAME = 'daily-cache.json' +export type DailyModelEntry = { + calls: number + cost: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number +} + +export type DailyCategoryEntry = { + turns: number + cost: number + editTurns: number + oneShotTurns: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number +} + +export type DailyProviderEntry = { + calls: number + cost: number + sessions: number + editTurns: number + oneShotTurns: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + models: Record + categories: Record +} + export type DailyEntry = { date: string cost: number @@ -23,16 +62,9 @@ export type DailyEntry = { cacheWriteTokens: number editTurns: number oneShotTurns: number - models: Record - categories: Record - providers: Record + models: Record + categories: Record + providers: Record } export type DailyCache = { @@ -75,7 +107,19 @@ function migrateDays(days: Record[]): DailyEntry[] { oneShotTurns: (d.oneShotTurns as number) ?? 0, models: (d.models as DailyEntry['models']) ?? {}, categories: (d.categories as DailyEntry['categories']) ?? {}, - providers: (d.providers as DailyEntry['providers']) ?? {}, + providers: Object.fromEntries(Object.entries((d.providers as Record>) ?? {}).map(([name, provider]) => [name, { + calls: provider.calls ?? 0, + cost: provider.cost ?? 0, + sessions: provider.sessions ?? 0, + editTurns: provider.editTurns ?? 0, + oneShotTurns: provider.oneShotTurns ?? 0, + inputTokens: provider.inputTokens ?? 0, + outputTokens: provider.outputTokens ?? 0, + cacheReadTokens: provider.cacheReadTokens ?? 0, + cacheWriteTokens: provider.cacheWriteTokens ?? 0, + models: provider.models ?? {}, + categories: provider.categories ?? {}, + }])), })) } diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index bc63fa6..4a4fc01 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -20,6 +20,46 @@ function emptyEntry(date: string): DailyEntry { } } +function emptyProviderEntry(): DailyEntry['providers'][string] { + return { + calls: 0, + cost: 0, + sessions: 0, + editTurns: 0, + oneShotTurns: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + models: {}, + categories: {}, + } +} + +function emptyCategoryEntry(): DailyEntry['categories'][string] { + return { + turns: 0, + cost: 0, + editTurns: 0, + oneShotTurns: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + } +} + +function emptyModelEntry(): DailyEntry['models'][string] { + return { + calls: 0, + cost: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + } +} + export function dateKey(iso: string): string { const d = new Date(iso) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` @@ -36,7 +76,14 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr for (const project of projects) { for (const session of project.sessions) { const sessionDate = dateKey(session.firstTimestamp) - ensure(sessionDate).sessions += 1 + const sessionDay = ensure(sessionDate) + sessionDay.sessions += 1 + const sessionProviders = new Set(session.turns.flatMap(turn => turn.assistantCalls.map(call => call.provider))) + for (const providerName of sessionProviders) { + const provider = sessionDay.providers[providerName] ?? emptyProviderEntry() + provider.sessions += 1 + sessionDay.providers[providerName] = provider + } for (const turn of session.turns) { if (turn.assistantCalls.length === 0) continue @@ -45,18 +92,38 @@ 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] ?? emptyCategoryEntry() 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 + const turnProviderTotals = new Map() + for (const call of turn.assistantCalls) { const callDate = dateKey(call.timestamp) const callDay = ensure(callDate) @@ -68,11 +135,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr callDay.cacheReadTokens += call.usage.cacheReadInputTokens callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens - const model = callDay.models[call.model] ?? { - calls: 0, cost: 0, - inputTokens: 0, outputTokens: 0, - cacheReadTokens: 0, cacheWriteTokens: 0, - } + const model = callDay.models[call.model] ?? emptyModelEntry() model.calls += 1 model.cost += call.costUSD model.inputTokens += call.usage.inputTokens @@ -81,10 +144,57 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr model.cacheWriteTokens += call.usage.cacheCreationInputTokens callDay.models[call.model] = model - const provider = callDay.providers[call.provider] ?? { calls: 0, cost: 0 } + const provider = callDay.providers[call.provider] ?? emptyProviderEntry() provider.calls += 1 provider.cost += call.costUSD + provider.inputTokens += call.usage.inputTokens + provider.outputTokens += call.usage.outputTokens + provider.cacheReadTokens += call.usage.cacheReadInputTokens + provider.cacheWriteTokens += call.usage.cacheCreationInputTokens + + const providerModel = provider.models[call.model] ?? emptyModelEntry() + providerModel.calls += 1 + providerModel.cost += call.costUSD + providerModel.inputTokens += call.usage.inputTokens + providerModel.outputTokens += call.usage.outputTokens + providerModel.cacheReadTokens += call.usage.cacheReadInputTokens + providerModel.cacheWriteTokens += call.usage.cacheCreationInputTokens + provider.models[call.model] = providerModel + callDay.providers[call.provider] = provider + + const providerTurn = turnProviderTotals.get(call.provider) ?? { + cost: 0, + calls: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + } + providerTurn.calls += 1 + providerTurn.cost += call.costUSD + providerTurn.inputTokens += call.usage.inputTokens + providerTurn.outputTokens += call.usage.outputTokens + providerTurn.cacheReadTokens += call.usage.cacheReadInputTokens + providerTurn.cacheWriteTokens += call.usage.cacheCreationInputTokens + turnProviderTotals.set(call.provider, providerTurn) + } + + for (const [providerName, totals] of turnProviderTotals) { + const provider = turnDay.providers[providerName] ?? emptyProviderEntry() + const providerCat = provider.categories[turn.category] ?? emptyCategoryEntry() + providerCat.turns += 1 + providerCat.cost += totals.cost + providerCat.editTurns += editTurns + providerCat.oneShotTurns += oneShotTurns + providerCat.inputTokens += totals.inputTokens + providerCat.outputTokens += totals.outputTokens + providerCat.cacheReadTokens += totals.cacheReadTokens + providerCat.cacheWriteTokens += totals.cacheWriteTokens + provider.categories[turn.category] = providerCat + provider.editTurns += editTurns + provider.oneShotTurns += oneShotTurns + turnDay.providers[providerName] = provider } } } @@ -96,7 +206,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 +234,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/main.ts b/src/main.ts index 74fd205..e5975bc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,7 @@ import { convertCost } from './currency.js' import { renderStatusBar } from './format.js' import { type PeriodData, type ProviderCost } from './menubar-json.js' import { buildMenubarPayload } from './menubar-json.js' -import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js' +import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js' import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { aggregateModelEfficiency } from './model-efficiency.js' @@ -381,7 +381,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 @@ -390,12 +399,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 } @@ -419,6 +442,25 @@ function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData } } +function dayForProvider(day: DailyEntry, providerName: string): DailyEntry { + const provider = day.providers[providerName] + return { + date: day.date, + cost: provider?.cost ?? 0, + calls: provider?.calls ?? 0, + sessions: provider?.sessions ?? 0, + inputTokens: provider?.inputTokens ?? 0, + outputTokens: provider?.outputTokens ?? 0, + cacheReadTokens: provider?.cacheReadTokens ?? 0, + cacheWriteTokens: provider?.cacheWriteTokens ?? 0, + editTurns: provider?.editTurns ?? 0, + oneShotTurns: provider?.oneShotTurns ?? 0, + models: provider?.models ?? {}, + categories: provider?.categories ?? {}, + providers: provider ? { [providerName]: provider } : {}, + } +} + program .command('status') .description('Compact status output (today + month)') @@ -465,11 +507,9 @@ program let scanProjects: ProjectSummary[] let scanRange: DateRange let cache: DailyCache - let todayProviderData: PeriodData | null = null - let usedPerProviderCachePath = false + cache = await hydrateCache() if (isAllProviders) { - cache = await hydrateCache() const todayProjects = await getTodayAllProjects() const todayDays = await getTodayAllDays() const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr) @@ -479,33 +519,18 @@ program scanProjects = todayProjects scanRange = todayRange } else { - cache = await loadDailyCache() - const cacheIsCurrent = cache.lastComputedDate !== null - && cache.lastComputedDate >= yesterdayStr - if (cacheIsCurrent && rangeStartStr < todayStr) { - const todayProviderProjects = fp(await parseAllSessions(todayRange, pf)) - todayProviderData = buildPeriodData(periodInfo.label, todayProviderProjects) - const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr) - let histCost = 0, histCalls = 0 - for (const d of historicalDays) { - const prov = d.providers[pf] - if (prov) { histCost += prov.cost; histCalls += prov.calls } - } - currentData = { - ...todayProviderData, - cost: todayProviderData.cost + histCost, - calls: todayProviderData.calls + histCalls, - } - scanProjects = todayProviderProjects - scanRange = todayRange - usedPerProviderCachePath = true - } else { - const fullProjects = fp(await parseAllSessions(periodInfo.range, pf)) - todayProviderData = buildPeriodData(periodInfo.label, fullProjects) - currentData = todayProviderData - scanProjects = fullProjects - scanRange = periodInfo.range - } + // Provider-scoped history comes from the daily cache so the menubar + // preserves the fast path added for provider tabs. Only today is + // reparsed because the cache intentionally excludes the in-progress + // local day. + const todayProjects = fp(await parseAllSessions(todayRange, pf)) + const todayProviderDays = aggregateProjectsIntoDays(todayProjects).map(d => dayForProvider(d, pf)) + const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr).map(d => dayForProvider(d, pf)) + const todayInRange = todayProviderDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) + const providerDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) + currentData = buildPeriodDataFromDays(providerDays, periodInfo.label) + scanProjects = todayProjects + scanRange = todayRange } // PROVIDERS @@ -539,14 +564,13 @@ program } // DAILY HISTORY (last 365 days) - // Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive - // a provider-filtered history without re-parsing. Tokens aren't broken down per provider - // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost). + // Cache stores per-provider cost/calls/tokens per day, so provider tabs + // can derive historical trend data without reparsing every session file. const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)) - const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr) let dailyHistory if (isAllProviders) { + const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr) const todayDays = (await getTodayAllDays()).filter(d => d.date === todayStr) const fullHistory = [...allCacheDays, ...todayDays] dailyHistory = fullHistory.map(d => { @@ -572,64 +596,34 @@ program topModels, } }) - } else if (usedPerProviderCachePath) { - const historyFromCache = allCacheDays.map(d => { - const prov = d.providers[pf] ?? { calls: 0, cost: 0 } - return { - date: d.date, - cost: prov.cost, - calls: prov.calls, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[], - } - }) - const todayCost = todayProviderData!.cost - const todayCalls = todayProviderData!.calls - if (todayCost > 0 || todayCalls > 0) { - historyFromCache.push({ - date: todayStr, - cost: todayCost, - calls: todayCalls, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: [], - }) - } - dailyHistory = historyFromCache } else { - const histFromCache = allCacheDays.map(d => { - const prov = d.providers[pf] ?? { calls: 0, cost: 0 } - return { - date: d.date, - cost: prov.cost, - calls: prov.calls, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[], - } - }) - const fallbackDays = aggregateProjectsIntoDays(scanProjects) - const liveDays = fallbackDays.map(d => { - const prov = d.providers[pf] ?? { calls: 0, cost: 0 } + const historyDays = [ + ...getDaysInRange(cache, historyStartStr, yesterdayStr).map(d => dayForProvider(d, pf)), + ...aggregateProjectsIntoDays(scanProjects).map(d => dayForProvider(d, pf)).filter(d => d.date === todayStr), + ] + dailyHistory = historyDays.map(d => { + const topModels = Object.entries(d.models) + .filter(([name]) => name !== '') + .sort(([, a], [, b]) => b.cost - a.cost) + .slice(0, 5) + .map(([name, m]) => ({ + name, + cost: m.cost, + calls: m.calls, + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + })) return { date: d.date, - cost: prov.cost, - calls: prov.calls, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[], + cost: d.cost, + calls: d.calls, + inputTokens: d.inputTokens, + outputTokens: d.outputTokens, + cacheReadTokens: d.cacheReadTokens, + cacheWriteTokens: d.cacheWriteTokens, + topModels, } }) - dailyHistory = [...histFromCache, ...liveDays] } const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) diff --git a/src/menubar-json.ts b/src/menubar-json.ts index bab4e40..4c8368f 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 c58937b..bf3ba6c 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, }) }) @@ -195,8 +199,46 @@ describe('aggregateProjectsIntoDays', () => { inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0, }) - expect(day.providers['claude']).toEqual({ calls: 1, cost: 7 }) - expect(day.providers['codex']).toEqual({ calls: 1, cost: 3 }) + expect(day.providers['claude']).toMatchObject({ + calls: 1, + cost: 7, + sessions: 1, + inputTokens: 100, + outputTokens: 200, + cacheReadTokens: 50, + cacheWriteTokens: 0, + categories: { + coding: { + turns: 1, + cost: 7, + editTurns: 0, + oneShotTurns: 0, + inputTokens: 100, + outputTokens: 200, + cacheReadTokens: 50, + cacheWriteTokens: 0, + }, + }, + models: { + 'Opus 4.7': { + calls: 1, + cost: 7, + inputTokens: 100, + outputTokens: 200, + cacheReadTokens: 50, + cacheWriteTokens: 0, + }, + }, + }) + expect(day.providers['codex']).toMatchObject({ + calls: 1, + cost: 3, + sessions: 1, + inputTokens: 100, + outputTokens: 200, + cacheReadTokens: 50, + cacheWriteTokens: 0, + }) }) }) @@ -217,8 +259,47 @@ 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 } }, - providers: { 'claude': { calls: 10, cost } }, + 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, + sessions: 2, + editTurns: 2, + oneShotTurns: 1, + inputTokens: 100, + outputTokens: 200, + cacheReadTokens: 300, + cacheWriteTokens: 50, + models: { + '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, + inputTokens: 50, + outputTokens: 100, + cacheReadTokens: 150, + cacheWriteTokens: 25, + }, + }, + }, + }, } } @@ -251,6 +332,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 f7493d0..ea1bd9e 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',