Add menubar cost tokens toggle

This commit is contained in:
ozymandiashh 2026-05-11 19:39:35 +03:00
parent d9acd8c4cd
commit bc396f5c46
15 changed files with 458 additions and 59 deletions

View file

@ -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`

View file

@ -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`):

View file

@ -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)
}
}

View file

@ -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() {

View file

@ -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: [],

View file

@ -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.

View file

@ -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"
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -342,7 +342,16 @@ program
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
const sessions = projects.flatMap(p => p.sessions)
const catTotals: Record<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
const catTotals: Record<string, {
turns: number
cost: number
editTurns: number
oneShotTurns: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}> = {}
const modelTotals: Record<string, { calls: number; cost: number }> = {}
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 }

View file

@ -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<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }>
categories: Record<string, {
turns: number
cost: number
editTurns: number
oneShotTurns: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}>
providers: Record<string, { calls: number; cost: number }>
}

View file

@ -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<string, { turns: number; cost: number; editTurns: number; oneShotTurns: number }> = {}
const catTotals: Record<string, {
turns: number
cost: number
editTurns: number
oneShotTurns: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}> = {}
const modelTotals: Record<string, { calls: number; cost: number }> = {}
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
}
}

View file

@ -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),

View file

@ -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', () => {

View file

@ -18,6 +18,21 @@ function emptyPeriod(label: string): PeriodData {
}
}
function category(overrides: Partial<PeriodData['categories'][number]> = {}): 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',