mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
Add menubar cost tokens toggle
This commit is contained in:
parent
d9acd8c4cd
commit
bc396f5c46
15 changed files with 458 additions and 59 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`):
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
49
mac/Tests/CodeBurnMenubarTests/MenubarPayloadTests.swift
Normal file
49
mac/Tests/CodeBurnMenubarTests/MenubarPayloadTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
37
src/cli.ts
37
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<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 }
|
||||
|
|
|
|||
|
|
@ -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 }>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue