From d1685eaf8342589be6dd7477c577cc44c96e826d Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:06:47 +0300 Subject: [PATCH 1/5] Show per-model cached tokens alongside cost in desktop and menubar --- app/renderer/lib/types.ts | 9 + app/renderer/sections/Models.test.tsx | 11 +- app/renderer/sections/Models.tsx | 19 +- app/renderer/sections/Overview.test.tsx | 51 +++- app/renderer/sections/Overview.tsx | 31 ++- .../CodeBurnMenubar/Data/MenubarPayload.swift | 40 +++ .../CodeBurnMenubar/Views/ModelsSection.swift | 119 ++++++--- .../ModelEntryTokenCountsTests.swift | 138 +++++++++++ .../ModelsSectionLayoutProofTests.swift | 211 ++++++++++++++++ src/day-aggregator.ts | 29 ++- src/main.ts | 8 +- src/menubar-json.ts | 78 +++++- src/usage-aggregator.ts | 52 +++- tests/cli-status-menubar.test.ts | 69 ++++++ tests/menubar-json.test.ts | 83 +++++++ tests/menubar-model-tokens.test.ts | 233 ++++++++++++++++++ 16 files changed, 1113 insertions(+), 68 deletions(-) create mode 100644 mac/Tests/CodeBurnMenubarTests/ModelEntryTokenCountsTests.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/ModelsSectionLayoutProofTests.swift create mode 100644 tests/menubar-model-tokens.test.ts diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index ed8b3800f..7571446ee 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -172,6 +172,15 @@ export type MenubarPayload = { savingsUSD: number savingsBaselineModel: string calls: number + // Per-model token counts (src/menubar-json.ts buildTopModels): billable + // output, cache read = reused input, cache write separate. Optional: + // older CLIs omit them, and a row whose contributing legacy data lacked + // counts omits them even on a new CLI. Absent means unknown — render a + // dash, never zero, and never substitute a period-wide figure. + inputTokens?: number + outputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number }> unpricedModels?: Array<{ model: string; calls: number; tokens: number }> localModelSavings: LocalModelSavings diff --git a/app/renderer/sections/Models.test.tsx b/app/renderer/sections/Models.test.tsx index f3625cfa9..a75082503 100644 --- a/app/renderer/sections/Models.test.tsx +++ b/app/renderer/sections/Models.test.tsx @@ -223,14 +223,21 @@ describe('Models', () => { expect(screen.queryByText('add alias ›')).not.toBeInTheDocument() }) - it('renders unpriced proxy rows as dim with alias affordance and dashes', async () => { + it('renders unpriced proxy rows as dim with alias affordance, keeping observed tokens visible', async () => { getModels.mockResolvedValue([rows[3]]) render() expect(await screen.findByText('my-proxy-model')).toHaveClass('dim') expect(screen.getByText('add alias ›')).toHaveClass('alias') - expect(screen.getAllByText('—')).toHaveLength(5) + // Tokens are observed usage, not a pricing artifact: they render even + // though the model has no pricing entry. Cache read shows its known zero. + expect(screen.getByText('4.8M')).toBeInTheDocument() + expect(screen.getByText('400K')).toBeInTheDocument() + expect(screen.getByText('0')).toBeInTheDocument() + expect(screen.getByText('4.8M')).not.toHaveClass('dim') + // Only cost and saved collapse to dashes. + expect(screen.getAllByText('—')).toHaveLength(2) expect(screen.queryByText('$0.00')).not.toBeInTheDocument() }) diff --git a/app/renderer/sections/Models.tsx b/app/renderer/sections/Models.tsx index baab9e52a..38009950b 100644 --- a/app/renderer/sections/Models.tsx +++ b/app/renderer/sections/Models.tsx @@ -271,7 +271,10 @@ function ModelsByTaskTable({ rows, onAddAlias }: { rows: ModelReportRow[]; onAdd function ModelTableRow({ row, onAddAlias }: { row: ModelReportRow; onAddAlias: () => void }) { const unpriced = row.costUSD === 0 && row.savingsUSD === 0 const cellClass = unpriced ? 'dim' : undefined - const tokenValue = (value: number) => (unpriced ? '—' : formatCompact(value)) + // Token columns are observed usage, not a pricing artifact: a model with no + // pricing entry still burned real input/output/cache-read tokens, so they + // render regardless. Only cost/saved collapse to dashes behind the alias + // affordance — there is no attributed cost to show for them. const dotStyle = { display: 'inline-block', background: seriesColorForModel(row.modelDisplayName || row.model), @@ -292,9 +295,9 @@ function ModelTableRow({ row, onAddAlias }: { row: ModelReportRow; onAddAlias: ( {row.providerDisplayName} {fmtInt(row.calls)} - {tokenValue(row.inputTokens)} - {tokenValue(row.outputTokens)} - {tokenValue(row.cacheReadTokens)} + {formatCompact(row.inputTokens)} + {formatCompact(row.outputTokens)} + {formatCompact(row.cacheReadTokens)} {unpriced ? '—' : formatUsd(row.costUSD)} 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)} @@ -336,15 +339,15 @@ function ModelGroupRow({ rows, onAddAlias }: { rows: ModelReportRow[]; onAddAlia function ModelTaskRow({ row }: { row: ModelReportRow }) { const unpriced = row.costUSD === 0 && row.savingsUSD === 0 const cellClass = unpriced ? 'dim' : undefined - const tokenValue = (value: number) => (unpriced ? '—' : formatCompact(value)) return ( {row.category ?? 'general'} {fmtInt(row.calls)} - {tokenValue(row.inputTokens)} - {tokenValue(row.outputTokens)} - {tokenValue(row.cacheReadTokens)} + {/* Observed usage renders even for unpriced models — see ModelTableRow. */} + {formatCompact(row.inputTokens)} + {formatCompact(row.outputTokens)} + {formatCompact(row.cacheReadTokens)} {unpriced ? '—' : formatUsd(row.costUSD)} 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)} diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx index ddf9146cd..0adcce5ba 100644 --- a/app/renderer/sections/Overview.test.tsx +++ b/app/renderer/sections/Overview.test.tsx @@ -624,8 +624,55 @@ describe('Overview', () => { expect(rows[1]).toHaveTextContent('$120.00') expect(rows[1]).toHaveTextContent('240') expect(rows[2]).toHaveTextContent('claude-opus-4') - // current.topModels carries no per-model tokens → both token cells show a dash. - expect(within(rows[1] as HTMLElement).getAllByText('—')).toHaveLength(2) + // This legacy-shaped payload carries no per-model counts → all three token + // cells (input, output, cache read) show a dash. + expect(within(rows[1] as HTMLElement).getAllByText('—')).toHaveLength(3) + }) + + it('prefers current.topModels for the models table when the payload carries per-model counts', async () => { + const now = new Date() + const payload = makePayload(now) + // New-CLI payload: per-model counts ride on current.topModels, including + // cache read. history.daily still carries different (per-day, truncated) + // aggregates that the table must NOT fall back to. + payload.current.topModels = [ + { name: 'claude-opus-4', cost: 200, savingsUSD: 0, savingsBaselineModel: '', calls: 100, inputTokens: 1_200_000, outputTokens: 340_000, cacheReadTokens: 56_000_000, cacheWriteTokens: 7_000 }, + { name: 'claude-haiku-4', cost: 4, savingsUSD: 0, savingsBaselineModel: '', calls: 12, inputTokens: 0, outputTokens: 0, cacheReadTokens: 900, cacheWriteTokens: 0 }, + ] + + render() + + const modelsTable = await screen.findByRole('table', { name: 'Models this period' }) + expect(within(modelsTable).getByRole('columnheader', { name: 'Cache read' })).toBeInTheDocument() + const rows = within(modelsTable).getAllByRole('row') + // Counts come from current.topModels (1.2M in), not the daily aggregation (40M in). + expect(rows[1]).toHaveTextContent('claude-opus-4') + expect(rows[1]).toHaveTextContent('1.2M') + expect(rows[1]).toHaveTextContent('340K') + expect(rows[1]).toHaveTextContent('56M') + expect(within(modelsTable).queryByText('40M')).not.toBeInTheDocument() + // Known zeros stay zeros: haiku's fresh input/output render as 0, its cache + // read as the real 900. + expect(within(rows[2] as HTMLElement).getAllByText('0')).toHaveLength(2) + expect(within(rows[2] as HTMLElement).getByText('900')).toBeInTheDocument() + }) + + it('falls back to aggregating history.daily when the payload predates per-model counts', async () => { + const now = new Date() + const payload = makePayload(now) + // Legacy all-provider payload: current.topModels has no counts, history.daily + // does (input/output only — the CLI never emitted per-model cache read there). + + render() + + const modelsTable = await screen.findByRole('table', { name: 'Models this period' }) + const rows = within(modelsTable).getAllByRole('row') + // Input/output still come from the daily aggregation (30 days × 40M/2M) ... + expect(rows[1]).toHaveTextContent('claude-opus-4') + expect(rows[1]).toHaveTextContent('1.2B') + expect(rows[1]).toHaveTextContent('60M') + // ... and the absent per-model cache read shows as a dash, not zero. + expect(within(rows[1] as HTMLElement).getAllByText('—')).toHaveLength(1) }) it('suppresses the week-over-week signal and MTD card for a custom range', async () => { diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index f2ac8753b..cb32838f5 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -464,16 +464,25 @@ type AggregatedModel = { name: string cost: number calls: number - // Absent in provider-filtered mode: `current.topModels` carries no per-model - // token counts, so the table shows "—" rather than a misleading zero. + // Absent when the payload carries no count for the row (an older CLI, or a + // row whose contributing legacy data lacked counts): the table shows "—" + // rather than a misleading zero. inputTokens?: number outputTokens?: number + cacheReadTokens?: number } /** Provider-filtered source: `current.topModels` is already period/range/provider-scoped by the CLI. */ function topModelsToAggregated(models: MenubarPayload['current']['topModels']): AggregatedModel[] { return models - .map(model => ({ name: model.name, cost: model.cost, calls: model.calls })) + .map(model => ({ + name: model.name, + cost: model.cost, + calls: model.calls, + ...(model.inputTokens === undefined ? {} : { inputTokens: model.inputTokens }), + ...(model.outputTokens === undefined ? {} : { outputTokens: model.outputTokens }), + ...(model.cacheReadTokens === undefined ? {} : { cacheReadTokens: model.cacheReadTokens }), + })) .sort((a, b) => b.cost - a.cost) } @@ -509,6 +518,8 @@ function ModelsTable({ models }: { models: AggregatedModel[] }) { Model Input tok Output tok + {/* Reused input tokens: prompts the provider served from cache. */} + Cache read Cost Calls @@ -519,6 +530,7 @@ function ModelsTable({ models }: { models: AggregatedModel[] }) { {model.name} {model.inputTokens === undefined ? '—' : formatCompact(model.inputTokens)} {model.outputTokens === undefined ? '—' : formatCompact(model.outputTokens)} + {model.cacheReadTokens === undefined ? '—' : formatCompact(model.cacheReadTokens)} {formatUsd(model.cost)} {model.calls.toLocaleString('en-US')} @@ -780,9 +792,16 @@ export function OverviewContent({ periodDaily[0] && periodDaily[0].date < defaultChartStart ? periodDaily[0].date : defaultChartStart, localDateKey(now), ) - // Provider-filtered history.daily has empty topModels, so source the models - // table from current.topModels (already period/range/provider-scoped) instead. - const models = provider !== 'all' + // Models this period come from `current.topModels` — period/range/provider- + // scoped by the CLI, and (on CLIs that emit per-model counts) carrying input/ + // output/cache-read counts for every model in the period, including days + // whose per-day top-5 history list no longer names them. history.daily is + // the fallback for payloads from older CLIs: its rows know input/output but + // not cache read, so the cache column shows "—" there. + const topModelsCarryCounts = data.current.topModels.some(model => + model.inputTokens !== undefined || model.outputTokens !== undefined, + ) + const models = provider !== 'all' || topModelsCarryCounts ? topModelsToAggregated(data.current.topModels) : aggregateModels(rangeActive ? sliceDailyToRange(data.history.daily, range.from, range.to) : periodDaily) const recent14 = data.history.daily.slice(-14) diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 4165ddc71..939ca77cc 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -488,6 +488,41 @@ struct ModelEntry: Codable, Sendable { let savingsUSD: Double let savingsBaselineModel: String let calls: Int + /// Per-model token counts: input, output, cache read (reused input), and + /// cache write, kept separate so the two cache flavors are never summed. + /// Nil on every CLI up to the token-breakdown release and on any row whose + /// contributing legacy data lacked counts: absent means "unknown", which + /// renders as a dash — never as zero, and never as a period-wide figure. + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheWriteTokens: Int? + + /// Whether any per-model count arrived. A row with none (legacy payload) + /// renders without the secondary token line rather than as a run of dashes. + var hasTokenCounts: Bool { + inputTokens != nil || outputTokens != nil || cacheReadTokens != nil + } + + init(name: String, + cost: Double, + savingsUSD: Double, + savingsBaselineModel: String, + calls: Int, + inputTokens: Int? = nil, + outputTokens: Int? = nil, + cacheReadTokens: Int? = nil, + cacheWriteTokens: Int? = nil) { + self.name = name + self.cost = cost + self.savingsUSD = savingsUSD + self.savingsBaselineModel = savingsBaselineModel + self.calls = calls + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheReadTokens = cacheReadTokens + self.cacheWriteTokens = cacheWriteTokens + } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) @@ -496,10 +531,15 @@ struct ModelEntry: Codable, Sendable { savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0 savingsBaselineModel = try c.decodeIfPresent(String.self, forKey: .savingsBaselineModel) ?? "" calls = try c.decode(Int.self, forKey: .calls) + inputTokens = try c.decodeIfPresent(Int.self, forKey: .inputTokens) + outputTokens = try c.decodeIfPresent(Int.self, forKey: .outputTokens) + cacheReadTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadTokens) + cacheWriteTokens = try c.decodeIfPresent(Int.self, forKey: .cacheWriteTokens) } private enum CodingKeys: String, CodingKey { case name, cost, savingsUSD, savingsBaselineModel, calls + case inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens } } diff --git a/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift index 4cc90d4bc..2f3b89956 100644 --- a/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift @@ -41,45 +41,107 @@ struct ModelsSection: View { } } +/// Compact token count for the narrow popover: `1.2K` / `3.4M`, plain digits +/// below a thousand. The exact values ride along in the row's accessibility +/// label, so compact rounding never hides the real number. +private func compactTokenCount(_ n: Int) -> String { + if n >= 1_000_000 { + return String(format: "%.1fM", Double(n) / 1_000_000) + } else if n >= 1_000 { + return String(format: "%.1fK", Double(n) / 1_000) + } + return "\(n)" +} + private struct ModelRow: View { let model: ModelEntry let maxCost: Double let showSavings: Bool var body: some View { - HStack(spacing: 8) { - // Bar tracks actual cost; for local models the cost is $0 and the - // bar will be empty. Saved counterfactual (if any) renders as - // green text in the saved column, never summed into the bar. - FixedBar(fraction: model.cost / maxCost) - .frame(width: 56, height: 6) - - Text(model.name) - .font(.system(size: 12.5, weight: .medium)) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(model.cost.asCompactCurrency()) - .font(.codeMono(size: 12, weight: .medium)) - .tracking(-0.2) - .frame(minWidth: 54, alignment: .trailing) - - if showSavings { - Text(model.savingsUSD > 0 ? model.savingsUSD.asCompactCurrency() : "—") - .font(.codeMono(size: 12)) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + // Bar tracks actual cost; for local models the cost is $0 and the + // bar will be empty. Saved counterfactual (if any) renders as + // green text in the saved column, never summed into the bar. + FixedBar(fraction: model.cost / maxCost) + .frame(width: 56, height: 6) + + Text(model.name) + .font(.system(size: 12.5, weight: .medium)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(model.cost.asCompactCurrency()) + .font(.codeMono(size: 12, weight: .medium)) .tracking(-0.2) - .foregroundStyle(model.savingsUSD > 0 ? Color.green : Color.secondary) .frame(minWidth: 54, alignment: .trailing) + + if showSavings { + Text(model.savingsUSD > 0 ? model.savingsUSD.asCompactCurrency() : "—") + .font(.codeMono(size: 12)) + .tracking(-0.2) + .foregroundStyle(model.savingsUSD > 0 ? Color.green : Color.secondary) + .frame(minWidth: 54, alignment: .trailing) + } + + Text("\(model.calls)") + .font(.system(size: 11)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(minWidth: 52, alignment: .trailing) } - Text("\(model.calls)") - .font(.system(size: 11)) - .monospacedDigit() - .foregroundStyle(.secondary) - .frame(minWidth: 52, alignment: .trailing) + // Token counts sit on their own secondary line under the model name: + // seven squashed columns cannot stay legible in the narrow popover, + // and the cost stays visually attached to its model either way. The + // line appears only when the payload carries counts for the row — an + // older CLI renders exactly as before. + if model.hasTokenCounts { + Text("\(count(model.inputTokens)) in · \(count(model.outputTokens)) out · \(count(model.cacheReadTokens)) cache read") + .font(.system(size: 10.5)) + .monospacedDigit() + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .padding(.leading, 64) + .accessibilityLabel(model.tokenAccessibilityText) + } } .padding(.horizontal, 2) .padding(.vertical, 1) } + + // Unknown (absent count) renders as a dash; a known zero renders as "0". + private func count(_ value: Int?) -> String { + value.map(compactTokenCount) ?? "—" + } +} + +extension ModelEntry { + /// Exact token counts for assistive tech, since the visible line rounds + /// compactly. Cache read is labelled as reused input, and cache write is + /// named separately so the two are never read as one bucket. Locale-pinned + /// comma grouping so the text is deterministic. + var tokenAccessibilityText: String { + func exact(_ value: Int) -> String { + var digits = String(value) + var grouped = "" + while digits.count > 3 { + let cut = digits.index(digits.endIndex, offsetBy: -3) + grouped = "," + digits[cut...] + grouped + digits = String(digits[.. 0 { parts.append("\(exact(cacheWriteTokens)) cache write") } + return parts.joined(separator: ", ") + } } private struct TokensLine: View { @@ -109,11 +171,6 @@ private struct TokensLine: View { } private func formatTokens(_ n: Int) -> String { - if n >= 1_000_000 { - return String(format: "%.1fM", Double(n) / 1_000_000) - } else if n >= 1_000 { - return String(format: "%.1fK", Double(n) / 1_000) - } - return "\(n)" + compactTokenCount(n) } } diff --git a/mac/Tests/CodeBurnMenubarTests/ModelEntryTokenCountsTests.swift b/mac/Tests/CodeBurnMenubarTests/ModelEntryTokenCountsTests.swift new file mode 100644 index 000000000..5236f96b8 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/ModelEntryTokenCountsTests.swift @@ -0,0 +1,138 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +/// Per-model token counts on `current.topModels` rows: decode shape, the +/// unknown-vs-zero rule, and the row presentation contract (compact secondary +/// line, exact values in accessibility, dashes for unknown). +@Suite("ModelEntry token counts") +struct ModelEntryTokenCountsTests { + + private func payloadJSON(topModels: String) -> Data { + Data(""" + { + "generated": "2026-09-07T00:00:00Z", + "current": { + "label": "Today", + "cost": 3.25, + "calls": 12, + "sessions": 2, + "inputTokens": 1000, + "outputTokens": 500, + "cacheHitPercent": 40, + "topModels": \(topModels) + }, + "optimize": { "findingCount": 0, "savingsUSD": 0, "topFindings": [] }, + "history": { "daily": [] } + } + """.utf8) + } + + private func decode(_ topModels: String) throws -> MenubarPayload { + try JSONDecoder().decode(MenubarPayload.self, from: payloadJSON(topModels: topModels)) + } + + @Test("decodes per-model counts when the payload carries them") + func decodesCounts() throws { + let payload = try decode(""" + [ + { "name": "Sonnet 4.6", "cost": 2.5, "savingsUSD": 0, "savingsBaselineModel": "", "calls": 9, + "inputTokens": 152300, "outputTokens": 40200, "cacheReadTokens": 1180000, "cacheWriteTokens": 46000 } + ] + """) + let row = payload.current.topModels[0] + #expect(row.inputTokens == 152_300) + #expect(row.outputTokens == 40_200) + #expect(row.cacheReadTokens == 1_180_000) + #expect(row.cacheWriteTokens == 46_000) + #expect(row.hasTokenCounts) + } + + @Test("counts stay nil on legacy rows that predate the fields") + func legacyRowsStayNil() throws { + let payload = try decode(""" + [ + { "name": "Sonnet 4.6", "cost": 2.5, "savingsUSD": 0, "savingsBaselineModel": "", "calls": 9 } + ] + """) + let row = payload.current.topModels[0] + #expect(row.inputTokens == nil) + #expect(row.outputTokens == nil) + #expect(row.cacheReadTokens == nil) + #expect(row.cacheWriteTokens == nil) + #expect(!row.hasTokenCounts) + } + + @Test("a known zero decodes as zero, never as unknown") + func knownZeroStaysZero() throws { + let payload = try decode(""" + [ + { "name": "Haiku 4.5", "cost": 0, "savingsUSD": 0, "savingsBaselineModel": "", "calls": 3, + "inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 900000, "cacheWriteTokens": 0 } + ] + """) + let row = payload.current.topModels[0] + #expect(row.inputTokens == 0) + #expect(row.outputTokens == 0) + #expect(row.cacheReadTokens == 900_000) + #expect(row.cacheWriteTokens == 0) + #expect(row.hasTokenCounts) + } + + @Test("partially present counts keep the missing ones nil") + func partialCountsStayNil() throws { + let payload = try decode(""" + [ + { "name": "Sonnet 4.6", "cost": 1, "savingsUSD": 0, "savingsBaselineModel": "", "calls": 1, + "inputTokens": 100 } + ] + """) + let row = payload.current.topModels[0] + #expect(row.inputTokens == 100) + #expect(row.outputTokens == nil) + #expect(row.cacheReadTokens == nil) + #expect(row.hasTokenCounts) + } + + @Test("secondary line: known zeros render as 0, unknown as a dash") + func secondaryLineRenderings() throws { + let zero = ModelEntry(name: "zero", cost: 0, savingsUSD: 0, savingsBaselineModel: "", calls: 1, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0) + #expect(zero.hasTokenCounts) + let unknown = ModelEntry(name: "unknown", cost: 1, savingsUSD: 0, savingsBaselineModel: "", calls: 1, + outputTokens: 12) + #expect(unknown.hasTokenCounts) + // hasTokenCounts drives whether the secondary line renders at all. + #expect(!ModelEntry(name: "legacy", cost: 1, savingsUSD: 0, savingsBaselineModel: "", calls: 1).hasTokenCounts) + } + + @Test("accessibility text: exact counts, cache read labelled reused input, cache write separate") + func accessibilityTextKeepsCacheKindsDistinct() throws { + let payload = try decode(""" + [ + { "name": "Sonnet 4.6", "cost": 2.5, "savingsUSD": 0, "savingsBaselineModel": "", "calls": 9, + "inputTokens": 152300, "outputTokens": 40200, "cacheReadTokens": 1180000, "cacheWriteTokens": 46000 } + ] + """) + let label = payload.current.topModels[0].tokenAccessibilityText + #expect(label.contains("152,300 input")) + #expect(label.contains("40,200 output")) + #expect(label.contains("1,180,000 cache read (reused input)")) + #expect(label.contains("46,000 cache write")) + // The two cache flavors must never merge into one bucket. + #expect(!label.contains("cache 1,226,000")) + } + + @Test("accessibility text omits zero cache write instead of pairing it with cache read") + func accessibilityTextOmitsZeroCacheWrite() throws { + let payload = try decode(""" + [ + { "name": "Haiku 4.5", "cost": 0, "savingsUSD": 0, "savingsBaselineModel": "", "calls": 3, + "inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 900000, "cacheWriteTokens": 0 } + ] + """) + let label = payload.current.topModels[0].tokenAccessibilityText + #expect(label.contains("900,000 cache read (reused input)")) + #expect(!label.contains("cache write")) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/ModelsSectionLayoutProofTests.swift b/mac/Tests/CodeBurnMenubarTests/ModelsSectionLayoutProofTests.swift new file mode 100644 index 000000000..1d0687076 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/ModelsSectionLayoutProofTests.swift @@ -0,0 +1,211 @@ +import AppKit +import SwiftUI +import Testing +@testable import CodeBurnMenubar + +/// Native layout proof for the Models section's per-model token line, rendered +/// through NSHostingView in an offscreen window at the popover's REAL 360pt +/// width using the actual popover root (`MenuBarContent`), so every horizontal +/// padding that affects a row is present. No status item is created, no +/// popover is shown, nothing is ordered onto the screen, and no CLI is +/// invoked: payloads are injected through the AppStore testing hooks and +/// refreshes are suppressed. +/// +/// When `CODEBURN_LAYOUT_PROOF_DIR` is set, each variant is written there as a +/// 2x PNG (the review evidence artifacts). The suite always renders through a +/// real AppKit layout pass and asserts the image comes out; PNG writing is a +/// best-effort side effect. +/// This is fixture / native-view evidence — NOT installed-app validation. +@Suite("Models section layout proof") +@MainActor +struct ModelsSectionLayoutProofTests { + + // MARK: - Fixtures + + /// Cost-descending model rows the way `buildTopModels` emits them: a long + /// display name with savings, a ≥1B cache-read count, a known-zero row, + /// and a legacy row that predates the counts (secondary line hidden). + private static func savingsPresentPayload() -> MenubarPayload { + payload(topModels: [ + ModelEntry(name: "Gemini 3.7 Flash Thinking (Preview Channel)", cost: 84.7, savingsUSD: 12.4, savingsBaselineModel: "", calls: 3311, + inputTokens: 152_300_456, outputTokens: 40_234_112, cacheReadTokens: 1_180_456_789, cacheWriteTokens: 46_112_003), + ModelEntry(name: "gpt-6-astra", cost: 51.2, savingsUSD: 0, savingsBaselineModel: "", calls: 8455, + inputTokens: 33_624_660, outputTokens: 5_018_920, cacheReadTokens: 12_345_678_901, cacheWriteTokens: 0), + ModelEntry(name: "Llama Local", cost: 0, savingsUSD: 9.9, savingsBaselineModel: "", calls: 82, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0), + ModelEntry(name: "Legacy Snapshot Model", cost: 9.99, savingsUSD: 0, savingsBaselineModel: "", calls: 4), + ]) + } + + /// No savings anywhere in the period, so the Saved column is absent and + /// the token line gets the extra room — plus a $0-cost row whose observed + /// tokens must still render. + private static func savingsAbsentPayload() -> MenubarPayload { + payload(topModels: [ + ModelEntry(name: "Claude Opus 4.8", cost: 331.2, savingsUSD: 0, savingsBaselineModel: "", calls: 4812, + inputTokens: 152_600_000, outputTokens: 9_640_000, cacheReadTokens: 119_400_000, cacheWriteTokens: 16_000_000), + ModelEntry(name: "my-proxy-model", cost: 0, savingsUSD: 0, savingsBaselineModel: "", calls: 176, + inputTokens: 4_800_000, outputTokens: 400_000, cacheReadTokens: 0, cacheWriteTokens: 0), + ]) + } + + private static func payload(topModels: [ModelEntry]) -> MenubarPayload { + MenubarPayload( + generated: "2026-09-07T00:00:00Z", + current: CurrentBlock( + label: "Today", + cost: 155.89, + calls: 11852, + sessions: 14, + oneShotRate: 0.74, + inputTokens: 186_000_000, + outputTokens: 45_000_000, + cacheHitPercent: 63.4, + codexCredits: 0, + topActivities: [], + topModels: topModels, + localModelSavings: LocalModelSavings(totalUSD: 9.9, calls: 82, byModel: [], byProvider: []), + providers: [:], + topProjects: [], + modelEfficiency: [], + topSessions: [], + retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []), + routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []), + tools: [], + skills: [], + subagents: [], + mcpServers: [] + ), + optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []), + history: HistoryBlock(daily: []), + combined: nil + ) + } + + // MARK: - Harness + + private func makeStore(payload: MenubarPayload) -> AppStore { + let store = AppStore() + store.setCacheDateToTodayForTesting() + store.suppressRefreshesForTesting() + store.menuPopoverVisible = true + // Pin the whole selection: saved menubar defaults must not decide what + // a fixture render shows. + store.selectedScope = .local + store.selectedPeriod = .today + store.selectedProvider = .all + store.selectedDays = [] + store.selectedClaudeConfigSourceId = nil + store.setCachedPayloadForTesting(payload, period: .today, provider: .all, fetchedAt: Date()) + // The popover renders its cold-cache overlay unless the store actually + // serves the fixture from `payload`. + if store.payload.current.cost != payload.current.cost { + Issue.record("store failed to serve the fixture payload for the current key") + } + return store + } + + /// The exact view the popover installs (CodeBurnApp.makePopoverContent): + /// the real root, the real width, the real environments. The popover + /// surface renders dark, so the scheme is pinned or every `.primary` text + /// renders black-on-black. + private func popoverContent(store: AppStore) -> some View { + MenuBarContent() + .environment(store) + .environment(UpdateChecker()) + .environment(\.colorScheme, .dark) + .frame(width: 360) + } + + /// Render through a REAL NSHostingView inside a borderless window that is + /// never ordered on screen, forcing a genuine AppKit layout pass (ImageRenderer + /// alone does not run the scroll-content layout this popover root needs). + /// Height comes from the hosting view's own fittingSize — the same signal + /// the popover's `.preferredContentSize` sizing uses — so the full Models + /// section is captured at the real 360pt width without inventing a canvas. + @discardableResult + private func render(name: String, store: AppStore, proofDir: String?) throws -> NSSize { + let hosting = NSHostingView(rootView: popoverContent(store: store)) + hosting.frame = NSRect(x: 0, y: 0, width: 360, height: 1) + hosting.layoutSubtreeIfNeeded() + let fitted = hosting.fittingSize + #expect(fitted.width == 360) + let size = NSSize(width: 360, height: max(fitted.height, 660)) // floor: popoverHeight + + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.borderless], + backing: .buffered, + defer: false, + ) + hosting.frame = NSRect(origin: .zero, size: size) + window.contentView = hosting + // Offscreen by construction: never ordered front, never visible. + window.orderOut(nil) + hosting.layoutSubtreeIfNeeded() + + guard let bitmap = hosting.bitmapImageRepForCachingDisplay(in: NSRect(origin: .zero, size: size)) else { + Issue.record("bitmapImageRepForCachingDisplay failed for \(name)") + return .zero + } + hosting.cacheDisplay(in: NSRect(origin: .zero, size: size), to: bitmap) + + if let proofDir { + let data = try #require(bitmap.representation(using: .png, properties: [:])) + let url = URL(fileURLWithPath: proofDir).appendingPathComponent("menubar-\(name).png") + try data.write(to: url) + } + return size + } + + // MARK: - Tests + + @Test("token line renders at the real 360pt popover width with savings present and absent") + func rendersAtPopoverWidth() throws { + let proofDir = ProcessInfo.processInfo.environment["CODEBURN_LAYOUT_PROOF_DIR"] + + // Full popover at its REAL 360×660 (context: the Models section sits + // below the fold, exactly as in the popover — the first row and the + // column header row are what fit). + let store = makeStore(payload: Self.savingsPresentPayload()) + #expect(store.hasCachedData) + let withSavings = try render(name: "savings-present", store: store, proofDir: proofDir) + #expect(withSavings.width == 360) + + let withoutSavings = try render(name: "savings-absent", store: makeStore(payload: Self.savingsAbsentPayload()), proofDir: proofDir) + #expect(withoutSavings.width == 360) + + // Section-only renders at the same real width: the section carries its + // own row padding and no extra horizontal wrapper in the popover, so + // this is the exact row layout context, uncut. (The section has no + // ScrollView, so ImageRenderer runs its layout faithfully.) + if let proofDir { + for (name, payload) in [("section-savings-present", Self.savingsPresentPayload()), + ("section-savings-absent", Self.savingsAbsentPayload())] { + let sectionStore = makeStore(payload: payload) + let renderer = ImageRenderer(content: ModelsSection() + .environment(sectionStore) + .environment(\.colorScheme, .dark) + .frame(width: 360)) + renderer.scale = 2 + if let cgImage = renderer.cgImage { + let rep = NSBitmapImageRep(cgImage: cgImage) + if let data = rep.representation(using: .png, properties: [:]) { + try data.write(to: URL(fileURLWithPath: proofDir).appendingPathComponent("menubar-\(name).png")) + } + } + } + } + } + + @Test("exact token counts ride in the row accessibility text at every count magnitude") + func accessibilityCarriesExactCounts() throws { + let store = makeStore(payload: Self.savingsPresentPayload()) + let rows = store.payload.current.topModels + #expect(rows[0].tokenAccessibilityText.contains("152,300,456 input")) + #expect(rows[0].tokenAccessibilityText.contains("1,180,456,789 cache read (reused input)")) + #expect(rows[1].tokenAccessibilityText.contains("12,345,678,901 cache read (reused input)")) + // The legacy row has no counts at all, so no accessibility line either. + #expect(rows[3].tokenAccessibilityText.isEmpty) + } +} diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index 101d6097a..f9ffca99f 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -258,7 +258,20 @@ export function buildPeriodDataFromDays(days: DailyEntry[], label: string): Peri let cost = 0, savingsUSD = 0, calls = 0, sessions = 0 let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 const catTotals: Record = {} - const modelTotals: Record = {} + // Per-model token counts, normalized the same way the day entries were + // written (output already billable — day-aggregator folds reasoning in per + // call). Merge keys stay the raw ids here; the payload resolves display + // names later (buildTopModels), so both aggregation paths land in the same + // rows as cost. + const modelTotals: Record = {} for (const d of days) { cost += d.cost @@ -271,10 +284,17 @@ export function buildPeriodDataFromDays(days: DailyEntry[], label: string): Peri cacheWriteTokens += d.cacheWriteTokens for (const [name, m] of Object.entries(d.models)) { - const acc = modelTotals[name] ?? { calls: 0, cost: 0, savingsUSD: 0 } + const acc = modelTotals[name] ?? { + calls: 0, cost: 0, savingsUSD: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + } acc.calls += m.calls acc.cost += m.cost acc.savingsUSD += (m.savingsUSD ?? 0) + acc.inputTokens += m.inputTokens + acc.outputTokens += m.outputTokens + acc.cacheReadTokens += m.cacheReadTokens + acc.cacheWriteTokens += m.cacheWriteTokens modelTotals[name] = acc } for (const [cat, c] of Object.entries(d.categories)) { @@ -303,6 +323,9 @@ export function buildPeriodDataFromDays(days: DailyEntry[], label: string): Peri .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })), models: Object.entries(modelTotals) .sort(([, a], [, b]) => b.cost - a.cost) - .map(([name, d]) => ({ name, ...d })), + .map(([name, d]) => ({ + name, + ...d, + })), } } diff --git a/src/main.ts b/src/main.ts index 6c6a11f35..e7fdb169d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -58,7 +58,13 @@ const { version } = require('../package.json') // v5: providerDetails carries per-provider tokens and sessions, which a v4 // record predates — the dock glance would read a provider as having no token // breakdown purely because the snapshot was written before this build. -const STATUS_SNAPSHOT_RENDER_VERSION = 5 +// v6: current.topModels rows carry per-model input/output/cache-read/write +// counts, which a v5 record predates — the Models sections would show no +// per-model token breakdown purely because the snapshot was written before +// this build. A v5 record is treated as a miss (one real recompute per +// query), then the fresh record is served; daily/session caches are separate +// version domains and are not touched. +const STATUS_SNAPSHOT_RENDER_VERSION = 6 const STATUS_SNAPSHOT_SEMANTIC_KEY = `${version}:render-${STATUS_SNAPSHOT_RENDER_VERSION}:daily-${DAILY_CACHE_VERSION}` import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' import { CodexThroughputReader, newestCodexSession, renderCodexThroughput } from './codex-throughput.js' diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 273e9d9a0..2c30e3c29 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -24,7 +24,27 @@ export type PeriodData = { /// non-menubar PeriodData producers don't have to compute it. codexCredits?: number categories: Array<{ name: string; cost: number; savingsUSD: number; turns: number; editTurns: number; oneShotTurns: number }> - models: Array<{ name: string; cost: number; savingsUSD: number; calls: number; estimatedCostUSD?: number }> + models: Array<{ + name: string + cost: number + savingsUSD: number + calls: number + estimatedCostUSD?: number + /// Per-model token counts for the period, normalized exactly like the + /// headline totals: billable output (reasoning tokens are added only + /// where the provider reports them separately from output — where output + /// already includes them they are never added twice), `cacheReadTokens` + /// = reused input, `cacheWriteTokens` kept separate so the two are never + /// summed. The attributed cost already includes cache pricing; the counts + /// never restate or rescale it. Optional so PeriodData producers + /// predating the field keep compiling; a consumer must render absent + /// counts as unknown — never as zero, and never substitute the + /// period-wide totals. + inputTokens?: number + outputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number + }> /// Models with usage in the period whose pricing lookup fails against the /// current tables (#638): their calls contribute $0 to `cost`. Optional so /// PeriodData producers that predate the field keep compiling. @@ -267,6 +287,15 @@ export type MenubarPayload = { /// Estimated portion of this model's `cost`; > 0 marks the row as priced /// from estimated tokens. Optional for payload back-compat. estimatedCostUSD?: number + /// Per-model token counts, same normalization as `PeriodData.models`: + /// billable output, cache read = reused input, cache write separate. + /// Add-only and optional — omitted when the period carries no count for + /// the row (an older producer, or any contributing legacy row without + /// counts), so a consumer must render absence as unknown, never as zero. + inputTokens?: number + outputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number }> /// See PeriodData.unpricedModels: usage priced at $0 for lack of pricing /// data. Empty when every model in the period resolved a price. Optional @@ -453,25 +482,64 @@ function buildTopActivities(categories: PeriodData['categories']): MenubarPayloa })) } +/// Per-model token counts merged alongside cost. A `undefined` accumulator is +/// "unknown", not zero: a legacy row that predates the counts must not turn the +/// merged row into a plausible-looking 0, so one unknown contributor marks the +/// merged count unknown and the field is omitted from the payload. +const MODEL_COUNT_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'] as const +type ModelCountKey = (typeof MODEL_COUNT_KEYS)[number] + +function mergeCount(target: { counts: Partial>; unknown: Set }, key: ModelCountKey, value: number | undefined): void { + // Once a contributor without this count has been seen, the merged count is + // unknown for good — later contributors must not resurrect a partial sum. + if (value === undefined) { + target.unknown.add(key) + delete target.counts[key] + return + } + if (target.unknown.has(key)) return + target.counts[key] = (target.counts[key] ?? 0) + value +} + function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] { // Day entries key models by the raw provider id (day-aggregator), so resolve // display names here — the menubar shows "Kimi K3" rather than "k3". Ids that - // collapse to one display name (e.g. k3 and kimi-k3) merge into a single row. - const merged = new Map() + // collapse to one display name (e.g. k3 and kimi-k3) merge into a single row, + // and their token counts merge under the same grouping as cost. + const merged = new Map> + unknown: Set + }>() for (const m of models) { if (m.name === SYNTHETIC_MODEL_NAME) continue const name = getShortModelName(m.name) - const acc = merged.get(name) ?? { cost: 0, calls: 0, savingsUSD: 0, estimatedCostUSD: 0 } + const acc = merged.get(name) ?? { cost: 0, calls: 0, savingsUSD: 0, estimatedCostUSD: 0, counts: {}, unknown: new Set() } acc.cost += m.cost acc.calls += m.calls acc.savingsUSD += m.savingsUSD ?? 0 acc.estimatedCostUSD += m.estimatedCostUSD ?? 0 + for (const key of MODEL_COUNT_KEYS) mergeCount(acc, key, m[key]) merged.set(name, acc) } return [...merged.entries()] .sort(([, a], [, b]) => b.cost - a.cost) .slice(0, TOP_MODELS_LIMIT) - .map(([name, d]) => ({ name, cost: d.cost, calls: d.calls, savingsUSD: d.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: d.estimatedCostUSD })) + .map(([name, d]) => ({ + name, + cost: d.cost, + calls: d.calls, + savingsUSD: d.savingsUSD, + savingsBaselineModel: '', + estimatedCostUSD: d.estimatedCostUSD, + ...(d.counts.inputTokens === undefined ? {} : { inputTokens: d.counts.inputTokens }), + ...(d.counts.outputTokens === undefined ? {} : { outputTokens: d.counts.outputTokens }), + ...(d.counts.cacheReadTokens === undefined ? {} : { cacheReadTokens: d.counts.cacheReadTokens }), + ...(d.counts.cacheWriteTokens === undefined ? {} : { cacheWriteTokens: d.counts.cacheWriteTokens }), + })) } function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] { diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index ff273f239..7b06ffa4f 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -3,7 +3,7 @@ import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory, type DateRange import { isBehavioralCall } from './behavioral-weight.js' import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, type ClaudeConfigSelector, type HydrationState, buildMenubarPayload } from './menubar-json.js' import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, filterProjectsByDateRange, isSessionHydrationComplete, sessionHydrationSnapshot } from './parser.js' -import { findUnpricedModels, getFlatRateModelsConfigHash, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel } from './models.js' +import { findUnpricedModels, getFlatRateModelsConfigHash, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel, billableOutputTokens } from './models.js' import { getAllProviders, safeDiscoverSessions } from './providers/index.js' import { loadPlugins, pluginPayloadSections } from './plugins/loader.js' import { collectLiveSessions } from './live-sessions.js' @@ -16,7 +16,7 @@ import { aggregateModelTaskTurns, sessionDurationMinutes } from './telemetry-sna import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js' import { buildPrAttribution, aggregateByBranch } from './sessions-report.js' import { scanAndDetect } from './optimize.js' -import { callBillableOutputTokens, sessionBillableOutputTokens } from './session-output.js' +import { callBillableOutputTokens, sessionBillableOutputTokens, sessionModelBillableOutputTokens, inferSessionProvider } from './session-output.js' import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, mergeDayEntries, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js' import { buildGranularHistory } from './granular-history.js' @@ -63,7 +63,17 @@ export function providerSliceHasUsage(slice: ProviderDaySlice): boolean { export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { const sessions = projects.flatMap(p => p.sessions) const catTotals: Record = {} - const modelTotals: Record = {} + const modelTotals: Record = {} let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0 for (const sess of sessions) { @@ -71,6 +81,13 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri outputTokens += sessionBillableOutputTokens(sess) cacheReadTokens += sess.totalCacheReadTokens cacheWriteTokens += sess.totalCacheWriteTokens + // Per-model output uses the same billable-output rule as the headline: + // reasoning tokens are added only where the provider reports them + // separately from output (never twice where output already includes + // them, #1075). modelBreakdown's raw token counters cannot be summed + // for display without it. A bucket no surviving call maps to falls + // back to its own counters under the session's provider. + const sessionModelOut = sessionModelBillableOutputTokens(sess) for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } catTotals[cat].turns += d.turns @@ -80,12 +97,17 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri catTotals[cat].oneShotTurns += d.oneShotTurns } for (const [model, d] of Object.entries(sess.modelBreakdown)) { - if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, savingsUSD: 0, estimatedCostUSD: 0, tokens: 0 } - modelTotals[model].calls += d.calls - modelTotals[model].cost += d.costUSD - modelTotals[model].savingsUSD += d.savingsUSD - modelTotals[model].estimatedCostUSD += d.estimatedCostUSD ?? 0 - modelTotals[model].tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens + if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, savingsUSD: 0, estimatedCostUSD: 0, tokens: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } + const acc = modelTotals[model] + acc.calls += d.calls + acc.cost += d.costUSD + acc.savingsUSD += d.savingsUSD + acc.estimatedCostUSD += d.estimatedCostUSD ?? 0 + acc.tokens += d.tokens.inputTokens + d.tokens.outputTokens + d.tokens.cacheReadInputTokens + d.tokens.cacheCreationInputTokens + acc.inputTokens += d.tokens.inputTokens + acc.outputTokens += sessionModelOut[model] ?? billableOutputTokens(inferSessionProvider(sess), d.tokens.outputTokens, d.tokens.reasoningTokens) + acc.cacheReadTokens += d.tokens.cacheReadInputTokens + acc.cacheWriteTokens += d.tokens.cacheCreationInputTokens } } @@ -109,7 +131,17 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })), models: Object.entries(modelTotals) .sort(([, a], [, b]) => b.cost - a.cost) - .map(([name, d]) => ({ name, calls: d.calls, cost: d.cost, savingsUSD: d.savingsUSD, estimatedCostUSD: d.estimatedCostUSD })), + .map(([name, d]) => ({ + name, + calls: d.calls, + cost: d.cost, + savingsUSD: d.savingsUSD, + estimatedCostUSD: d.estimatedCostUSD, + inputTokens: d.inputTokens, + outputTokens: d.outputTokens, + cacheReadTokens: d.cacheReadTokens, + cacheWriteTokens: d.cacheWriteTokens, + })), unpricedModels, workflow: { corrections: corrections.corrections, diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts index f3bfa9035..e5d837104 100644 --- a/tests/cli-status-menubar.test.ts +++ b/tests/cli-status-menubar.test.ts @@ -888,6 +888,75 @@ describe('codeburn status --format menubar-json', () => { } }) + it('recomputes over a pre-change snapshot so topModels carry token counts, then reuses the fresh record without re-invalidating', async () => { + // The per-model token counts changed the payload's rendering semantics + // without changing the envelope. A snapshot written by the pre-change + // binary carries the same corpus fingerprint and query key, so a record + // that predates the fields must be rejected by the semantic key (one real + // recompute), after which the fresh record is served as-is — the third + // identical call must neither rebuild nor rewrite the snapshot. + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-token-render-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'myapp') + await mkdir(projectDir, { recursive: true }) + const now = new Date() + const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000)) + const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z') + await writeFile( + join(projectDir, 'session.jsonl'), + [userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'), + ) + + const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize'] + const first = runCli(args, home) + expect(first.status, `stderr: ${first.stderr}`).toBe(0) + const seeded = JSON.parse(first.stdout) as { current: { topModels: Array> } } + expect(seeded.current.topModels[0]?.inputTokens).toBe(500) + + const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn')) + expect(snapshotFiles).toHaveLength(1) + const record = JSON.parse(await readFile(snapshotFiles[0]!, 'utf-8')) as { + semanticKey: string + payload: { current: { topModels: unknown[] } } + } + // Rewind the record to the pre-change contract: previous render version, + // topModels rows without any token counts. + record.semanticKey = record.semanticKey.replace(/:render-\d+:/, ':render-5:') + record.payload.current.topModels = [ + { name: 'Legacy Snapshot Model', cost: 9.99, calls: 4, savingsUSD: 0, savingsBaselineModel: '' }, + ] + await writeFile(snapshotFiles[0]!, JSON.stringify(record)) + + const second = runCli(args, home) + expect(second.status, `stderr: ${second.stderr}`).toBe(0) + const payload = JSON.parse(second.stdout) as { + current: { topModels: Array<{ name: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }> } + } + expect(payload.current.topModels.map(model => model.name)).not.toContain('Legacy Snapshot Model') + expect(payload.current.topModels[0]).toMatchObject({ + inputTokens: 500, + outputTokens: 50, + // The helper's usage carries no cache traffic: a KNOWN zero, which the + // pre-change record could not have expressed at all. + cacheReadTokens: 0, + cacheWriteTokens: 0, + }) + + const freshRecord = await readFile(snapshotFiles[0]!, 'utf-8') + const third = runCli(args, home) + expect(third.status, `stderr: ${third.stderr}`).toBe(0) + expect(JSON.parse(third.stdout)).toEqual(payload) + // No repeated invalidation: a warm record is served, not rebuilt or + // rewritten (loadStatusSnapshot only persists settle-window bookkeeping + // on a corpus mismatch, and saveStatusSnapshot only runs on a miss). + expect(await readFile(snapshotFiles[0]!, 'utf-8')).toBe(freshRecord) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + it('reprices from an updated live LiteLLM cache instead of serving a stale snapshot', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-pricing-gen-')) diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index fe96a4407..ed3cd66e0 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { buildMenubarPayload, type CombinedUsage, type LocalModelSavings, type PeriodData, type ProviderCost } from '../src/menubar-json.js' +import { getShortModelName } from '../src/models.js' import type { OptimizeResult } from '../src/optimize.js' function emptyPeriod(label: string): PeriodData { @@ -184,6 +185,88 @@ describe('buildMenubarPayload', () => { expect(payload.current.topModels.find(m => m.name === 'k3-agent')).toBeUndefined() }) + it('merges per-model token counts under the same display-name grouping as cost', () => { + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + categories: [], + models: [ + { name: 'k3', cost: 2.5, calls: 78, inputTokens: 1000, outputTokens: 200, cacheReadTokens: 3000, cacheWriteTokens: 400 }, + { name: 'kimi-k3', cost: 0.5, calls: 2, inputTokens: 10, outputTokens: 20, cacheReadTokens: 30, cacheWriteTokens: 40 }, + ], + } + const payload = buildMenubarPayload(period, [], null) + const kimiK3 = payload.current.topModels.find(m => m.name === 'Kimi K3')! + expect(kimiK3.inputTokens).toBe(1010) + expect(kimiK3.outputTokens).toBe(220) + expect(kimiK3.cacheReadTokens).toBe(3030) + expect(kimiK3.cacheWriteTokens).toBe(440) + }) + + it('keeps known-zero per-model counts as zeros instead of dashes or drops', () => { + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + categories: [], + models: [ + { name: 'k3', cost: 0, calls: 3, inputTokens: 0, outputTokens: 0, cacheReadTokens: 500, cacheWriteTokens: 0 }, + ], + } + const payload = buildMenubarPayload(period, [], null) + const row = payload.current.topModels[0]! + expect(row.inputTokens).toBe(0) + expect(row.outputTokens).toBe(0) + expect(row.cacheReadTokens).toBe(500) + expect(row.cacheWriteTokens).toBe(0) + }) + + it('omits per-model counts for a row any legacy contributor without counts folded into', () => { + // A period assembled from older rows that never carried counts must not + // grow a plausible-looking partial sum: unknown stays absent. + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + categories: [], + models: [ + { name: 'k3', cost: 2.5, calls: 78, inputTokens: 1000, outputTokens: 200, cacheReadTokens: 3000, cacheWriteTokens: 400 }, + { name: 'kimi-k3', cost: 0.5, calls: 2 }, + { name: 'kimi-for-coding', cost: 0.06, calls: 13, inputTokens: 5, outputTokens: 6, cacheReadTokens: 7, cacheWriteTokens: 8 }, + ], + } + const payload = buildMenubarPayload(period, [], null) + const merged = payload.current.topModels.find(m => m.name === 'Kimi K3')! + expect(merged.inputTokens).toBeUndefined() + expect(merged.outputTokens).toBeUndefined() + expect(merged.cacheReadTokens).toBeUndefined() + expect(merged.cacheWriteTokens).toBeUndefined() + // A row whose every contributor carried counts keeps them. + const intact = payload.current.topModels.find(m => m.name === getShortModelName('kimi-for-coding'))! + expect(intact.inputTokens).toBe(5) + expect(intact.outputTokens).toBe(6) + expect(intact.cacheReadTokens).toBe(7) + expect(intact.cacheWriteTokens).toBe(8) + }) + + it('keeps merged counts unknown regardless of the order contributors arrive in', () => { + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + categories: [], + models: [ + { name: 'kimi-k3', cost: 0.5, calls: 2 }, + { name: 'k3', cost: 2.5, calls: 78, inputTokens: 1000, outputTokens: 200, cacheReadTokens: 3000, cacheWriteTokens: 400 }, + ], + } + const payload = buildMenubarPayload(period, [], null) + const merged = payload.current.topModels.find(m => m.name === 'Kimi K3')! + expect(merged.inputTokens).toBeUndefined() + expect(merged.cacheWriteTokens).toBeUndefined() + }) + it('caps topActivities at 20 so all task categories can surface', () => { const period: PeriodData = { label: 'Today', diff --git a/tests/menubar-model-tokens.test.ts b/tests/menubar-model-tokens.test.ts new file mode 100644 index 000000000..1cbbf1d0f --- /dev/null +++ b/tests/menubar-model-tokens.test.ts @@ -0,0 +1,233 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { beforeAll, afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { getShortModelName, loadPricing, setModelAliases } from '../src/models.js' +import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js' +import { clearSessionCache } from '../src/parser.js' +import type { DateRange } from '../src/types.js' + +// Per-model token counts through the menubar payload, exercised against a real +// parsed fixture (not arithmetic helpers): two priced models with unequal +// input/output/cache-read/cache-write mixes, a cache-read-only model, an +// unpriced model, the durable-day path after the sources expire, and a +// provider-scoped build. + +const FIXTURE_DAY = Date.UTC(2026, 3, 16) +const RANGE: DateRange = { + start: new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000), + end: new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000), +} +const PERIOD = { range: RANGE, label: 'Fixture window' } + +const SONNET = 'claude-3-7-sonnet-20250219' +const HAIKU = 'claude-3-haiku-20240307' +const OPUS = 'claude-3-opus-20240229' +const UNPRICED = 'totally-unknown-model-xyz' + +let base: string +let cacheDir: string +const tmpDirs: string[] = [] + +beforeAll(async () => { + await loadPricing() +}) + +beforeEach(() => { + // Runs AFTER the global env-isolation beforeEach, so these win for the test body. + setModelAliases({}) +}) + +afterEach(async () => { + clearSessionCache() + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } +}) + +function claudeLine(id: string, model: string, ts: string, usage: { + input: number + output: number + cacheW: number + cacheR: number +}): string { + return JSON.stringify({ + type: 'assistant', + timestamp: ts, + sessionId: `s-${id}`, + message: { + type: 'message', role: 'assistant', model, id, + content: [], + usage: { + input_tokens: usage.input, + output_tokens: usage.output, + cache_creation_input_tokens: usage.cacheW, + cache_read_input_tokens: usage.cacheR, + }, + }, + }) +} + +/** Four sessions, one model each, with deliberately unequal token mixes. */ +async function seedFixture(): Promise { + base = await mkdtemp(join(tmpdir(), 'codeburn-model-tokens-src-')) + cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-model-tokens-cache-')) + tmpDirs.push(base, cacheDir) + + const projectDir = join(base, 'projects', 'p') + await mkdir(projectDir, { recursive: true }) + const t = (h: number): string => new Date(FIXTURE_DAY + h * 60 * 60 * 1000).toISOString() + const sessions: Array<{ id: string; model: string; usage: { input: number; output: number; cacheW: number; cacheR: number } }> = [ + // Two assistant turns → the counts must sum across calls of one session. + { id: 'sonnet', model: SONNET, usage: { input: 100_000, output: 20_000, cacheW: 30_000, cacheR: 400_000 } }, + { id: 'sonnet-2', model: SONNET, usage: { input: 100_000, output: 20_000, cacheW: 30_000, cacheR: 400_000 } }, + { id: 'haiku', model: HAIKU, usage: { input: 50_000, output: 10_000, cacheW: 5_000, cacheR: 100_000 } }, + // Cache-read-only: zero fresh input/output, all reused input. + { id: 'opus', model: OPUS, usage: { input: 0, output: 0, cacheW: 0, cacheR: 900_000 } }, + // Unpriced: tokens observed, pricing lookup fails → $0 attributed cost. + { id: 'unknown', model: UNPRICED, usage: { input: 7_000, output: 2_000, cacheW: 0, cacheR: 0 } }, + ] + for (const s of sessions) { + await writeFile( + join(projectDir, `${s.id}.jsonl`), + claudeLine(`msg-${s.id}`, s.model, t(1), s.usage) + '\n', + 'utf-8', + ) + } + + process.env['CLAUDE_CONFIG_DIR'] = base + process.env['CODEBURN_CACHE_DIR'] = cacheDir +} + +function rowFor(payload: { current: { topModels: Array<{ name: string }> } }, model: string): { + name: string + cost: number + calls: number + inputTokens?: number + outputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number +} { + const row = payload.current.topModels.find(m => m.name === getShortModelName(model)) + expect(row, `topModels row for ${model}`).toBeDefined() + return row! +} + +describe('per-model token counts in the menubar payload', () => { + it('carries unequal per-model counts through the fresh parse, reconciling with the headline totals', async () => { + await seedFixture() + + clearSessionCache() + const payload = await buildMenubarPayloadForRange(PERIOD, { provider: 'all', optimize: false, timeline: false }) + + const sonnet = rowFor(payload, SONNET) + expect(sonnet.calls).toBe(2) + expect(sonnet.inputTokens).toBe(200_000) + expect(sonnet.outputTokens).toBe(40_000) + expect(sonnet.cacheReadTokens).toBe(800_000) + expect(sonnet.cacheWriteTokens).toBe(60_000) + + const haiku = rowFor(payload, HAIKU) + expect(haiku.inputTokens).toBe(50_000) + expect(haiku.outputTokens).toBe(10_000) + expect(haiku.cacheReadTokens).toBe(100_000) + expect(haiku.cacheWriteTokens).toBe(5_000) + + // Cache-only model: a known zero in every non-cache column, real reused + // input in the cache column — never folded into input, never dropped. + const opus = rowFor(payload, OPUS) + expect(opus.inputTokens).toBe(0) + expect(opus.outputTokens).toBe(0) + expect(opus.cacheReadTokens).toBe(900_000) + expect(opus.cacheWriteTokens).toBe(0) + + // Unpriced model: counts are observed usage and must survive even though + // its attributed cost is $0. + const unpriced = rowFor(payload, UNPRICED) + expect(unpriced.cost).toBe(0) + expect(unpriced.inputTokens).toBe(7_000) + expect(unpriced.outputTokens).toBe(2_000) + + // Per-model rows reconcile with the period headline on a single-provider + // fixture (claude folds reasoning into output, so billable == raw here). + const models = payload.current.topModels + expect(models.reduce((s, m) => s + (m.inputTokens ?? 0), 0)).toBe(payload.current.inputTokens) + expect(models.reduce((s, m) => s + (m.outputTokens ?? 0), 0)).toBe(payload.current.outputTokens) + expect(models.reduce((s, m) => s + (m.cacheReadTokens ?? 0), 0)).toBe(payload.current.cacheReadTokens) + expect(models.reduce((s, m) => s + (m.cacheWriteTokens ?? 0), 0)).toBe(payload.current.cacheWriteTokens) + }) + + it('carries the same counts through the durable-day path after the session files are gone', async () => { + await seedFixture() + + // Warm the daily cache, then expire the sources: the headline and the + // per-model counts must both survive off the sealed day entries. + clearSessionCache() + await buildMenubarPayloadForRange(PERIOD, { provider: 'all', optimize: false, timeline: false }) + await rm(base, { recursive: true, force: true }) + + clearSessionCache() + const payload = await buildMenubarPayloadForRange(PERIOD, { provider: 'all', optimize: false, timeline: false }) + + const sonnet = rowFor(payload, SONNET) + expect(sonnet.calls).toBe(2) + expect(sonnet.inputTokens).toBe(200_000) + expect(sonnet.outputTokens).toBe(40_000) + expect(sonnet.cacheReadTokens).toBe(800_000) + expect(sonnet.cacheWriteTokens).toBe(60_000) + + const opus = rowFor(payload, OPUS) + expect(opus.cacheReadTokens).toBe(900_000) + expect(opus.inputTokens).toBe(0) + }) + + it('emits the same counts on the provider-scoped build', async () => { + await seedFixture() + + clearSessionCache() + const payload = await buildMenubarPayloadForRange(PERIOD, { provider: 'claude', optimize: false, timeline: false }) + + const sonnet = rowFor(payload, SONNET) + expect(sonnet.inputTokens).toBe(200_000) + expect(sonnet.cacheReadTokens).toBe(800_000) + const haiku = rowFor(payload, HAIKU) + expect(haiku.outputTokens).toBe(10_000) + expect(haiku.cacheWriteTokens).toBe(5_000) + }) + + it('returns no models for a range the fixture day is outside of', async () => { + await seedFixture() + + clearSessionCache() + const before = { + range: { + start: new Date(FIXTURE_DAY - 96 * 60 * 60 * 1000), + end: new Date(FIXTURE_DAY - 72 * 60 * 60 * 1000), + }, + label: 'Before fixture', + } + const payload = await buildMenubarPayloadForRange(before, { provider: 'all', optimize: false, timeline: false }) + expect(payload.current.topModels).toEqual([]) + }) + + it('merges aliased raw ids into one row whose counts sum like the cost does', async () => { + await seedFixture() + // Route haiku through sonnet: pricing, display name and now token counts + // must all land in the sonnet row. + setModelAliases({ [HAIKU]: SONNET }) + + clearSessionCache() + const payload = await buildMenubarPayloadForRange(PERIOD, { provider: 'all', optimize: false, timeline: false }) + + const sonnet = rowFor(payload, SONNET) + expect(sonnet.calls).toBe(3) + expect(sonnet.inputTokens).toBe(250_000) + expect(sonnet.outputTokens).toBe(50_000) + expect(sonnet.cacheReadTokens).toBe(900_000) + expect(sonnet.cacheWriteTokens).toBe(65_000) + // Four fixture models, one folded away by the alias. + expect(payload.current.topModels).toHaveLength(3) + }) +}) From 654583e4f0cf1040e09c33a87648ef46bd628f25 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:22:35 +0300 Subject: [PATCH 2/5] Add cache-read usage and quota pace to Capacity Dock --- mac/Sources/CodeBurnMenubar/AppStore.swift | 264 ++++++++++++++-- .../CodeBurnMenubar/Data/MenubarPayload.swift | 12 +- .../Data/QuotaPacePresentation.swift | 235 +++++++++++++++ .../CodeBurnMenubar/Data/QuotaSummary.swift | 41 +++ .../Views/CapacityDockView.swift | 268 +++++++++++++++-- .../CapacityDockGlanceTests.swift | 117 +++++++- .../CapacityDockPacePresentationTests.swift | 259 ++++++++++++++++ .../CapacityDockTodayTests.swift | 108 +++++++ .../QuotaFreshnessTests.swift | 216 +++++++++++++ src/main.ts | 15 +- src/menubar-json.ts | 19 +- src/status-snapshot-semantic.ts | 27 ++ src/usage-aggregator.ts | 48 +++ tests/cli-cache-read-pipeline.test.ts | 284 ++++++++++++++++++ tests/cli-status-menubar.test.ts | 117 ++++++++ tests/menubar-json.test.ts | 38 +++ tests/usage-aggregator.test.ts | 38 ++- 17 files changed, 2020 insertions(+), 86 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/CapacityDockPacePresentationTests.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/QuotaFreshnessTests.swift create mode 100644 src/status-snapshot-semantic.ts create mode 100644 tests/cli-cache-read-pipeline.test.ts diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index db1d527dd..2bf9d810a 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -5,6 +5,21 @@ private let cacheTTLSeconds: TimeInterval = 30 private let interactiveRefreshResetSeconds: TimeInterval = 120 private let menubarPeriodDefaultsKey = "CodeBurnMenubarPeriod" +private func quotaFetchWasCancelled(_ error: Error) -> Bool { + if error is CancellationError { return true } + if let error = error as? ClaudeSubscriptionService.FetchError, + case let .network(cause) = error, + cause is CancellationError { + return true + } + if let error = error as? CodexSubscriptionService.FetchError, + case let .network(cause) = error, + cause is CancellationError { + return true + } + return false +} + struct CachedPayload { let payload: MenubarPayload let fetchedAt: Date @@ -48,6 +63,11 @@ struct PayloadCacheKey: Hashable { @MainActor @Observable final class AppStore { + private struct QuotaRefreshToken { + let requestGeneration: Int + let lifecycleGeneration: Int + } + var selectedProvider: ProviderFilter = .all var selectedPeriod: Period = .today var selectedScope: MenubarScope = MenubarScope.savedMenubarScope() @@ -185,6 +205,20 @@ final class AppStore { var capacityDockProviderTransientFailures: Set = [] private var capacityDockProviderRefreshGenerations: [String: UInt64] = [:] @ObservationIgnored var capacityDockProviderQuotaService = CapacityDockProviderQuotaService.shared + /// Injectable seams keep the quota refresh state machine testable without + /// making the production tests contact provider endpoints. + @ObservationIgnored var claudeQuotaFetcher: @Sendable () async throws -> SubscriptionUsage? = { + try await ClaudeSubscriptionService.refreshIfBootstrapped() + } + @ObservationIgnored var codexQuotaFetcher: @Sendable () async throws -> CodexUsage? = { + try await CodexSubscriptionService.refreshIfBootstrapped() + } + @ObservationIgnored var claudeQuotaBootstrapChecker: @Sendable () -> Bool = { + ClaudeCredentialStore.isBootstrapCompleted + } + @ObservationIgnored var codexQuotaBootstrapChecker: @Sendable () -> Bool = { + CodexCredentialStore.isBootstrapCompleted + } @ObservationIgnored var capacityDockCredentialLoader: @Sendable (String) async throws -> CapacityDockProviderCredential = { try await CapacityDockProviderCredentialStore.loadAsync(for: $0) @@ -207,6 +241,15 @@ final class AppStore { /// resume after the await and re-populate the freshly-cleared state. private var claudeRefreshGen: Int = 0 private var codexRefreshGen: Int = 0 + /// Request tokens keep overlapping manual/cadence refreshes from restoring + /// an older state over a newer request. The lifecycle generation above still + /// handles disconnect; these tokens handle ordinary request supersession. + private var claudeRefreshRequestGen: Int = 0 + private var codexRefreshRequestGen: Int = 0 + private var claudeRefreshInFlightRequest: Int? + private var codexRefreshInFlightRequest: Int? + private var claudeRefreshRestoreState: SubscriptionLoadState? + private var codexRefreshRestoreState: SubscriptionLoadState? private var kimiRefreshGen: Int = 0 private var geminiRefreshGen: Int = 0 private var copilotRefreshGen: Int = 0 @@ -1361,6 +1404,74 @@ final class AppStore { await bootstrapCodex() } + private func beginClaudeQuotaRefresh() -> QuotaRefreshToken { + claudeRefreshRequestGen &+= 1 + let token = QuotaRefreshToken( + requestGeneration: claudeRefreshRequestGen, + lifecycleGeneration: claudeRefreshGen + ) + if claudeRefreshInFlightRequest == nil { + claudeRefreshRestoreState = subscriptionLoadState + } + claudeRefreshInFlightRequest = token.requestGeneration + // A populated subscription remains available to the plan bar, but its + // pace projection must be treated as stale for the whole await. + subscriptionLoadState = .loading + return token + } + + private func isCurrentClaudeQuotaRefresh(_ token: QuotaRefreshToken) -> Bool { + token.lifecycleGeneration == claudeRefreshGen + && token.requestGeneration == claudeRefreshRequestGen + && claudeRefreshInFlightRequest == token.requestGeneration + } + + private func finishClaudeQuotaRefresh(_ token: QuotaRefreshToken) { + guard isCurrentClaudeQuotaRefresh(token) else { return } + claudeRefreshInFlightRequest = nil + claudeRefreshRestoreState = nil + } + + private func restoreClaudeQuotaRefresh(_ token: QuotaRefreshToken) { + guard isCurrentClaudeQuotaRefresh(token) else { return } + subscriptionLoadState = claudeRefreshRestoreState + ?? (subscription == nil ? .failed : .loaded) + finishClaudeQuotaRefresh(token) + } + + private func beginCodexQuotaRefresh() -> QuotaRefreshToken { + codexRefreshRequestGen &+= 1 + let token = QuotaRefreshToken( + requestGeneration: codexRefreshRequestGen, + lifecycleGeneration: codexRefreshGen + ) + if codexRefreshInFlightRequest == nil { + codexRefreshRestoreState = codexLoadState + } + codexRefreshInFlightRequest = token.requestGeneration + codexLoadState = .loading + return token + } + + private func isCurrentCodexQuotaRefresh(_ token: QuotaRefreshToken) -> Bool { + token.lifecycleGeneration == codexRefreshGen + && token.requestGeneration == codexRefreshRequestGen + && codexRefreshInFlightRequest == token.requestGeneration + } + + private func finishCodexQuotaRefresh(_ token: QuotaRefreshToken) { + guard isCurrentCodexQuotaRefresh(token) else { return } + codexRefreshInFlightRequest = nil + codexRefreshRestoreState = nil + } + + private func restoreCodexQuotaRefresh(_ token: QuotaRefreshToken) { + guard isCurrentCodexQuotaRefresh(token) else { return } + codexLoadState = codexRefreshRestoreState + ?? (codexUsage == nil ? .failed : .loaded) + finishCodexQuotaRefresh(token) + } + func bootstrapSubscription() async { subscriptionLoadState = .bootstrapping do { @@ -1388,36 +1499,51 @@ final class AppStore { /// rather than every attempt. @discardableResult func refreshSubscriptionReportingSuccess() async -> Bool { - guard ClaudeCredentialStore.isBootstrapCompleted else { + guard claudeQuotaBootstrapChecker() else { if subscriptionLoadState != .notBootstrapped { subscriptionLoadState = .notBootstrapped } return false } - let gen = claudeRefreshGen - if subscription == nil { subscriptionLoadState = .loading } + let token = beginClaudeQuotaRefresh() do { - guard let usage = try await ClaudeSubscriptionService.refreshIfBootstrapped() else { + guard let usage = try await claudeQuotaFetcher() else { + restoreClaudeQuotaRefresh(token) return false } // Disconnect-during-fetch guard: if the user clicked Disconnect // while we were awaiting Anthropic, the generation token will // have advanced and we must drop this result instead of writing // it back over the freshly-cleared state. - guard gen == claudeRefreshGen else { return false } + guard isCurrentClaudeQuotaRefresh(token) else { return false } + guard !Task.isCancelled else { + restoreClaudeQuotaRefresh(token) + return false + } subscription = usage subscriptionError = nil subscriptionLoadState = .loaded + finishClaudeQuotaRefresh(token) await captureSnapshots(for: usage) return true } catch let err as ClaudeSubscriptionService.FetchError { - guard gen == claudeRefreshGen else { return false } + guard isCurrentClaudeQuotaRefresh(token) else { return false } + if Task.isCancelled || quotaFetchWasCancelled(err) { + restoreClaudeQuotaRefresh(token) + return false + } applyFetchError(err) + finishClaudeQuotaRefresh(token) return false } catch { - guard gen == claudeRefreshGen else { return false } + guard isCurrentClaudeQuotaRefresh(token) else { return false } + if Task.isCancelled || quotaFetchWasCancelled(error) { + restoreClaudeQuotaRefresh(token) + return false + } subscriptionError = sanitizeForUI(error.localizedDescription) subscriptionLoadState = .failed + finishClaudeQuotaRefresh(token) return false } } @@ -1432,11 +1558,17 @@ final class AppStore { // Bump the generation token so any in-flight refreshSubscription that // resumes after this point detects the disconnect and discards its // result instead of re-populating the cleared state. + let refreshRestoreState = claudeRefreshRestoreState claudeRefreshGen &+= 1 + claudeRefreshInFlightRequest = nil + claudeRefreshRestoreState = nil guard result.isSuccess else { // Nothing was removed, so nothing is disconnected. Leave the // connected state exactly as it was — the bootstrap flag is still // set, Disconnect stays available, and the banner says to retry. + if let refreshRestoreState { + subscriptionLoadState = refreshRestoreState + } subscriptionError = "Could not fully remove the local Claude credential cache. Disconnect again to retry." return } @@ -1473,43 +1605,64 @@ final class AppStore { @discardableResult func refreshCodexReportingSuccess() async -> Bool { - if case .dormant = codexLoadState, !CodexCredentialStore.isBootstrapCompleted { + if case .dormant = codexLoadState, !codexQuotaBootstrapChecker() { await bootstrapCodex() return codexLoadState == .loaded } - guard CodexCredentialStore.isBootstrapCompleted else { + guard codexQuotaBootstrapChecker() else { if codexLoadState != .notBootstrapped { codexLoadState = .notBootstrapped } return false } - let gen = codexRefreshGen - if codexUsage == nil { codexLoadState = .loading } + let token = beginCodexQuotaRefresh() do { - guard let usage = try await CodexSubscriptionService.refreshIfBootstrapped() else { + guard let usage = try await codexQuotaFetcher() else { + restoreCodexQuotaRefresh(token) + return false + } + guard isCurrentCodexQuotaRefresh(token) else { return false } + guard !Task.isCancelled else { + restoreCodexQuotaRefresh(token) return false } - guard gen == codexRefreshGen else { return false } codexUsage = usage codexError = nil codexLoadState = .loaded + finishCodexQuotaRefresh(token) return true } catch let err as CodexSubscriptionService.FetchError { - guard gen == codexRefreshGen else { return false } + guard isCurrentCodexQuotaRefresh(token) else { return false } + if Task.isCancelled || quotaFetchWasCancelled(err) { + restoreCodexQuotaRefresh(token) + return false + } applyCodexFetchError(err) + finishCodexQuotaRefresh(token) return false } catch { - guard gen == codexRefreshGen else { return false } + guard isCurrentCodexQuotaRefresh(token) else { return false } + if Task.isCancelled || quotaFetchWasCancelled(error) { + restoreCodexQuotaRefresh(token) + return false + } codexError = sanitizeForUI(error.localizedDescription) codexLoadState = .failed + finishCodexQuotaRefresh(token) return false } } func disconnectCodex() { let result = CodexSubscriptionService.disconnect() + let refreshRestoreState = codexRefreshRestoreState codexRefreshGen &+= 1 + codexRefreshInFlightRequest = nil + codexRefreshRestoreState = nil guard result.isSuccess else { // Nothing removed means nothing disconnected; keep state intact so // Disconnect stays available for a retry. + if let refreshRestoreState { + codexLoadState = refreshRestoreState + } codexError = "Could not fully remove the local Codex credential cache. Disconnect again to retry." return } @@ -2025,6 +2178,16 @@ final class AppStore { let present = rows.compactMap(value) return present.isEmpty ? nil : present.reduce(0, +) } + // Cache read is stricter than the other token fields: a tile whose rows + // are split across a legacy and a current CLI must not present a partial + // known sum as complete. If any ACTIVE row lacks the cache field, the + // tile reports none — an idle row (hasUsage false) carries a genuine + // zero and does not force unknown. + let cacheRead: Int? = { + let activeMissing = rows.contains { $0.hasUsage && $0.cacheReadTokens == nil } + guard !activeMissing else { return nil } + return sum(\.cacheReadTokens) + }() return ProviderDetail( id: id, label: provider.displayName, @@ -2033,7 +2196,8 @@ final class AppStore { hasUsage: rows.contains { $0.hasUsage }, inputTokens: sum(\.inputTokens), outputTokens: sum(\.outputTokens), - sessions: sum(\.sessions) + sessions: sum(\.sessions), + cacheReadTokens: cacheRead ) } @@ -2227,12 +2391,13 @@ final class AppStore { if case .notBootstrapped = subscriptionLoadState { return nil } if case .bootstrapping = subscriptionLoadState { return nil } if case .noCredentials = subscriptionLoadState { return nil } + let usageIsFresh = QuotaSummary.isFresh(fetchedAt: subscription?.fetchedAt) let connection: QuotaSummary.Connection = { switch subscriptionLoadState { case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected case .loading: return subscription == nil ? .loading : .stale - case .loaded: return .connected + case .loaded: return usageIsFresh ? .connected : .stale case .failed: return subscription == nil ? .loading : .stale case let .terminalFailure(reason): return .terminalFailure(reason: reason) case .transientFailure: return .transientFailure @@ -2242,22 +2407,44 @@ final class AppStore { var primary: QuotaSummary.Window? var details: [QuotaSummary.Window] = [] if let usage = subscription { + // Claude's rate-limit windows are fixed lengths, so each row + // carries its validated duration for pace presentation. if let pct = usage.fiveHourPercent { - details.append(.init(label: "5-hour", percent: pct / 100, resetsAt: usage.fiveHourResetsAt)) + details.append(.init( + label: "5-hour", percent: pct / 100, resetsAt: usage.fiveHourResetsAt, + windowSeconds: QuotaPacePresentation.claudeFiveHourSeconds, + fetchedAt: usage.fetchedAt + )) } if let pct = usage.sevenDayPercent { - let weekly = QuotaSummary.Window(label: "Weekly", percent: pct / 100, resetsAt: usage.sevenDayResetsAt) + let weekly = QuotaSummary.Window( + label: "Weekly", percent: pct / 100, resetsAt: usage.sevenDayResetsAt, + windowSeconds: QuotaPacePresentation.claudeSevenDaySeconds, + fetchedAt: usage.fetchedAt + ) primary = weekly details.append(weekly) } if let pct = usage.sevenDayOpusPercent { - details.append(.init(label: "Weekly · Opus", percent: pct / 100, resetsAt: usage.sevenDayOpusResetsAt)) + details.append(.init( + label: "Weekly · Opus", percent: pct / 100, resetsAt: usage.sevenDayOpusResetsAt, + windowSeconds: QuotaPacePresentation.claudeSevenDaySeconds, + fetchedAt: usage.fetchedAt + )) } if let pct = usage.sevenDaySonnetPercent { - details.append(.init(label: "Weekly · Sonnet", percent: pct / 100, resetsAt: usage.sevenDaySonnetResetsAt)) + details.append(.init( + label: "Weekly · Sonnet", percent: pct / 100, resetsAt: usage.sevenDaySonnetResetsAt, + windowSeconds: QuotaPacePresentation.claudeSevenDaySeconds, + fetchedAt: usage.fetchedAt + )) } for scoped in usage.scopedWeekly { - details.append(.init(label: "Weekly · \(scoped.label)", percent: scoped.percent / 100, resetsAt: scoped.resetsAt)) + details.append(.init( + label: "Weekly · \(scoped.label)", percent: scoped.percent / 100, resetsAt: scoped.resetsAt, + windowSeconds: QuotaPacePresentation.claudeSevenDaySeconds, + fetchedAt: usage.fetchedAt + )) } } let plan = subscription?.tier.displayName @@ -2268,12 +2455,13 @@ final class AppStore { if case .notBootstrapped = codexLoadState { return nil } if case .bootstrapping = codexLoadState { return nil } if case .noCredentials = codexLoadState { return nil } + let usageIsFresh = QuotaSummary.isFresh(fetchedAt: codexUsage?.fetchedAt) let connection: QuotaSummary.Connection = { switch codexLoadState { case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected case .loading: return codexUsage == nil ? .loading : .stale - case .loaded: return .connected + case .loaded: return usageIsFresh ? .connected : .stale case .failed: return codexUsage == nil ? .loading : .stale case let .terminalFailure(reason): return .terminalFailure(reason: reason) case .transientFailure: return .transientFailure @@ -2283,13 +2471,23 @@ final class AppStore { var primary: QuotaSummary.Window? var details: [QuotaSummary.Window] = [] if let usage = codexUsage { + // Codex reports each rate window's length itself, so every row + // carries its own validated duration for pace presentation. if let w = usage.primary { - let row = QuotaSummary.Window(label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + let row = QuotaSummary.Window( + label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt, + windowSeconds: w.limitWindowSeconds, + fetchedAt: usage.fetchedAt + ) primary = row details.append(row) } if let w = usage.secondary { - let row = QuotaSummary.Window(label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + let row = QuotaSummary.Window( + label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt, + windowSeconds: w.limitWindowSeconds, + fetchedAt: usage.fetchedAt + ) // Some Codex plans (free / guest tiers) only return a secondary // window. Promote it to primary so the chip bar always has a // data source instead of rendering as an empty track. @@ -2302,10 +2500,18 @@ final class AppStore { // the main Codex window. for extra in usage.additionalLimits { if let p = extra.primary, p.usedPercent > 0 { - details.append(.init(label: "\(extra.name) · \(p.windowLabel)", percent: p.usedPercent / 100, resetsAt: p.resetsAt)) + details.append(.init( + label: "\(extra.name) · \(p.windowLabel)", percent: p.usedPercent / 100, resetsAt: p.resetsAt, + windowSeconds: p.limitWindowSeconds, + fetchedAt: usage.fetchedAt + )) } if let s = extra.secondary, s.usedPercent > 0 { - details.append(.init(label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt)) + details.append(.init( + label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt, + windowSeconds: s.limitWindowSeconds, + fetchedAt: usage.fetchedAt + )) } } // No rate windows here, so the allowance feeds the bar and badge. @@ -2313,7 +2519,9 @@ final class AppStore { let row = QuotaSummary.Window( label: credits.shortLabel, percent: credits.usedPercent / 100, - resetsAt: credits.resetsAt + resetsAt: credits.resetsAt, + windowSeconds: credits.windowSeconds, + fetchedAt: usage.fetchedAt ) if primary == nil { primary = row } details.append(row) diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 4165ddc71..4612a85aa 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -378,6 +378,11 @@ struct ProviderDetail: Codable, Sendable { let inputTokens: Int? let outputTokens: Int? let sessions: Int? + /// Input tokens re-served from the provider's prompt cache for the period, + /// accounted separately from `inputTokens` and priced at the cache-read + /// rate inside `cost`. Nil on CLIs that predate per-provider cache + /// accounting: absent means unknown, never a fabricated zero. + let cacheReadTokens: Int? init( id: String, @@ -387,7 +392,8 @@ struct ProviderDetail: Codable, Sendable { hasUsage: Bool, inputTokens: Int? = nil, outputTokens: Int? = nil, - sessions: Int? = nil + sessions: Int? = nil, + cacheReadTokens: Int? = nil ) { self.id = id self.label = label @@ -397,10 +403,11 @@ struct ProviderDetail: Codable, Sendable { self.inputTokens = inputTokens self.outputTokens = outputTokens self.sessions = sessions + self.cacheReadTokens = cacheReadTokens } private enum CodingKeys: String, CodingKey { - case id, label, cost, calls, hasUsage, inputTokens, outputTokens, sessions + case id, label, cost, calls, hasUsage, inputTokens, outputTokens, sessions, cacheReadTokens } init(from decoder: Decoder) throws { @@ -419,6 +426,7 @@ struct ProviderDetail: Codable, Sendable { inputTokens = try c.decodeIfPresent(Int.self, forKey: .inputTokens) outputTokens = try c.decodeIfPresent(Int.self, forKey: .outputTokens) sessions = try c.decodeIfPresent(Int.self, forKey: .sessions) + cacheReadTokens = try c.decodeIfPresent(Int.self, forKey: .cacheReadTokens) } } diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift new file mode 100644 index 000000000..54009fd33 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift @@ -0,0 +1,235 @@ +import Foundation + +/// Turns a quota window into the Capacity Dock's one-line pace caption: the +/// whole-window average interpretation `QuotaPace` defends (#726 phase 1), +/// rendered as "on pace", a deficit/reserve stage, an estimated exhaustion, +/// or the explicit exhausted state. Deliberately text-only: the math lives in +/// `QuotaPace`, the wording lives here, so both stay testable without a view. +/// +/// Honesty rules this type enforces: +/// - The caption describes the AVERAGE pace across the whole elapsed window, +/// never a measured recent rate. The hover/accessibility text says so. +/// - Only validated window durations are used. A window without +/// `windowSeconds` gets no estimate — the label ("Weekly") is not a length. +/// - `Window.percent` is a 0...1 fraction; `QuotaPace` consumes 0...100. +/// This is the one place the unit crosses, and it is tested. Anything +/// outside 0...1 (negative, >1, NaN, infinity) is rejected, not clamped. +/// - A projection is valid only while the sample that produced the window is +/// inside `QuotaSummary`'s ten-minute freshness horizon. A connected +/// account can still carry an old loaded sample while a refresh is pending; +/// that sample must not become a forecast. +/// - The estimate is an ETA measured from `now` ("est. out in 3h 20m"), never +/// a lead before reset, and every countdown is computed against the passed +/// `now`, never a hidden wall clock, so fixtures stay deterministic. +/// - Stale, failed, loading or disconnected quota data gets nothing. A reset +/// in the past, or further out than one full window (clock/data skew), and +/// non-finite reset timestamps are refused before any branch. +enum QuotaPacePresentation { + /// Claude's rate-limit windows are fixed lengths (the same values the + /// plan popover projects with), so they are validated durations. + static let claudeFiveHourSeconds = 5 * 3600 + static let claudeSevenDaySeconds = 7 * 24 * 3600 + + /// What a window column draws in its reserved pace slot. The slot itself + /// stays empty when no `Line` is defensible. + struct Line: Equatable { + enum Kind: Equatable { + /// Projected from the whole-window average. + case estimate + /// The window is at exactly 100% and has not reset yet. + case exhausted + } + + /// Visual weight for the caption: muted for a healthy pace, amber for + /// a deficit or projected overflow, red once the limit is reached. + enum Tone: Equatable { + case neutral + case warning + case danger + } + + let kind: Kind + let tone: Tone + /// Compact caption for the column, e.g. "est. out in 3h 20m". + let text: String + /// Hover/accessibility text carrying the full honest reading. + let helpText: String + } + + /// The caption for one window, or nil when nothing defensible remains. + static func line( + for window: QuotaSummary.Window, + connection: QuotaSummary.Connection, + now: Date = Date() + ) -> Line? { + // Last-known or in-flight data cannot back a projection. + guard connection == .connected else { return nil } + // A connected summary can still be an old loaded sample while a + // refresh is pending. Keep the pace caption honest by tying it to the + // same injected `now` used by the calculation and by the tests. + guard window.isFresh(at: now) else { return nil } + guard let windowSeconds = window.windowSeconds, windowSeconds > 0 else { return nil } + guard let resetsAt = window.resetsAt else { return nil } + // `percent` arrives as a fraction; the pace math consumes percent. + // Reject non-finite or out-of-range samples before either branch: a + // negative fraction clamped to zero would invent a healthy forecast, + // and >1 is not a "100% used" signal, it is a broken sample. + guard window.percent.isFinite, window.percent >= 0, window.percent <= 1 else { return nil } + let usedPercent = window.percent * 100 + + // Reset in the past, or further out than one full window, or a + // non-finite timestamp: clock/data skew. Guard both branches — an + // "exhausted" window whose reset is months away is impossible data, + // not a limit that is actually reached. + let remaining = resetsAt.timeIntervalSince(now) + guard remaining.isFinite else { return nil } + guard remaining > 0, remaining <= TimeInterval(windowSeconds) else { return nil } + + if usedPercent >= 100 { + return Line( + kind: .exhausted, + tone: .danger, + text: "limit reached", + helpText: "This window's limit is fully used. It resets in \(countdownLabel(seconds: remaining))." + ) + } + + guard let result = QuotaPace.evaluate( + usedPercent: usedPercent, + resetsAt: resetsAt, + windowSeconds: windowSeconds, + now: now + ) else { return nil } + let tone: Line.Tone = result.willOverflow || result.deltaPercent > 2 ? .warning : .neutral + return Line( + kind: .estimate, + tone: tone, + text: caption(for: result, windowSeconds: windowSeconds, now: now), + helpText: helpText( + result: result, + windowSeconds: windowSeconds, + now: now, + resetsAt: resetsAt + ) + ) + } + + /// One line per displayed window, in report order. Distinct scope windows + /// that merely share a duration (Claude's Weekly vs Weekly · Opus vs + /// Weekly · Sonnet, or Codex's extra per-model limits) each keep their own + /// caption — they report different usage and different exhaustion risk. + /// Only a genuinely identical duplicate window (same label, percent, + /// reset and duration) is suppressed. + static func lines( + for windows: [QuotaSummary.Window], + connection: QuotaSummary.Connection, + now: Date = Date() + ) -> [Line?] { + var seen: [QuotaSummary.Window] = [] + return windows.map { window in + guard !seen.contains(window) else { return nil } + seen.append(window) + return line(for: window, connection: connection, now: now) + } + } + + /// Whether the panel must reserve a pace slot under the window columns. + /// Deliberately time-independent: the slot is reserved whenever connected + /// data carries a displayable window with validated duration and a reset + /// date, even while the caption itself is still empty (window younger + /// than 3%). Reserving on data alone keeps the computed panel height from + /// changing under the pointer as wall-clock time crosses a threshold. + static func reservesLine( + for windows: [QuotaSummary.Window], + connection: QuotaSummary.Connection + ) -> Bool { + guard connection == .connected else { return false } + return windows.contains { window in + guard let seconds = window.windowSeconds, seconds > 0 else { return false } + return window.resetsAt != nil + } + } + + /// Compact caption. The estimate is the projection itself: an over-pace + /// window reads as "est. out in " and a window under pace + /// reads as "est. N% at reset", so the useful number always fits one + /// narrow column. On windows at or under + /// `QuotaPace.etaSuppressionMaxSeconds` there is no projection or ETA at + /// all — a linear read of a short window cries wolf after one burst — so + /// only the deficit/reserve stage shows. + static func caption(for result: QuotaPace.Result, windowSeconds: Int, now: Date = Date()) -> String { + let compact = TimeInterval(windowSeconds) <= QuotaPace.etaSuppressionMaxSeconds + if compact { + if abs(result.deltaPercent) <= 2 { return "on pace" } + if result.deltaPercent > 0 { return "\(Int(result.deltaPercent.rounded()))% in deficit" } + return "\(Int(-result.deltaPercent.rounded()))% in reserve" + } + if result.willOverflow, let hitsLimitAt = result.hitsLimitAt { + return "est. out in \(countdownLabel(from: now, to: hitsLimitAt))" + } + return "est. \(Int(result.projectedPercent.rounded()))% at reset" + } + + private static func helpText( + result: QuotaPace.Result, + windowSeconds: Int, + now: Date, + resetsAt: Date + ) -> String { + let basis = "Estimated from the average pace across this whole " + + "\(windowLengthLabel(seconds: windowSeconds)) window so far — " + + "not a measured recent rate." + let projected = Int(result.projectedPercent.rounded()) + let projectionSentence: String + if result.willOverflow, let hitsLimitAt = result.hitsLimitAt { + projectionSentence = "At that pace the limit is reached in " + + "\(countdownLabel(from: now, to: hitsLimitAt)), before the reset in " + + "\(countdownLabel(from: now, to: resetsAt))." + } else if abs(result.deltaPercent) <= 2 { + projectionSentence = "Projected \(projected)% used by the reset — on pace." + } else if result.deltaPercent > 0 { + projectionSentence = String( + format: "Projected %d%% used by the reset, %.0f%% ahead of the pace the elapsed window implies.", + projected, result.deltaPercent + ) + } else { + projectionSentence = String( + format: "Projected %d%% used by the reset, %.0f%% of the window still in reserve.", + projected, -result.deltaPercent + ) + } + return basis + " " + projectionSentence + } + + /// Human label for a validated duration: the two lengths Claude and Codex + /// actually report, else a plain hours/days rendering of the number. + private static func windowLengthLabel(seconds: Int) -> String { + switch seconds { + case claudeFiveHourSeconds: return "5-hour" + case claudeSevenDaySeconds: return "7-day" + default: + let hours = seconds / 3600 + if hours >= 24, seconds % 86400 == 0 { return "\(hours / 24)-day" } + return "\(hours)-hour" + } + } + + /// "2d 3h" / "3h 20m" / "45m" / "<1m", mirroring the window column's own + /// countdown shape. Computed against the passed dates, never a hidden + /// wall clock, so fixtures stay deterministic. + static func countdownLabel(from now: Date, to date: Date) -> String { + countdownLabel(seconds: date.timeIntervalSince(now)) + } + + /// Countdown label for an already-computed interval (clamped at zero). + static func countdownLabel(seconds: TimeInterval) -> String { + let value = max(0, seconds) + if value < 60 { return "<1m" } + let minutes = Int(value / 60) + let hours = minutes / 60 + let days = hours / 24 + if days > 0 { return "\(days)d \(hours % 24)h" } + if hours > 0 { return "\(hours)h \(minutes % 60)m" } + return "\(minutes)m" + } +} diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift index 70a21b359..8abfeab91 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift @@ -4,6 +4,17 @@ import Foundation /// Capacity Dock. Every CodeBurn-owned provider adapter normalizes into this /// presentation type. struct QuotaSummary: Equatable { + /// Quota providers use a ten-minute freshness horizon for last-known + /// snapshots. A projection from an older sample is misleading even when + /// the credentials are still connected, so pace presentation must omit it. + static let freshnessThreshold: TimeInterval = 10 * 60 + + static func isFresh(fetchedAt: Date?, now: Date = Date()) -> Bool { + guard let fetchedAt else { return false } + let age = now.timeIntervalSince(fetchedAt) + return age.isFinite && age >= 0 && age <= freshnessThreshold + } + enum Connection: Equatable { case connected case disconnected // no credentials present @@ -30,6 +41,36 @@ struct QuotaSummary: Equatable { let label: String let percent: Double // 0..1 let resetsAt: Date? + /// Length of this window in seconds, carried only from metadata the + /// provider service itself validates (Codex's `limitWindowSeconds`, + /// Claude's fixed 5-hour/7-day windows). Nil means the duration is not + /// known — pace presentation must omit the estimate rather than infer + /// a length from the label or the reset date. + let windowSeconds: Int? + /// Timestamp of the provider sample that produced this window. Nil is + /// preserved for legacy/unsupported summaries and is not fresh enough + /// to support a pace projection. + let fetchedAt: Date? + + init( + label: String, + percent: Double, + resetsAt: Date?, + windowSeconds: Int? = nil, + fetchedAt: Date? = nil + ) { + self.label = label + self.percent = percent + self.resetsAt = resetsAt + self.windowSeconds = windowSeconds + self.fetchedAt = fetchedAt + } + + /// A pace estimate is valid only while the underlying sample remains + /// inside the established live-quota freshness horizon. + func isFresh(at now: Date = Date()) -> Bool { + QuotaSummary.isFresh(fetchedAt: fetchedAt, now: now) + } } /// Color band thresholds for the inline chip bar and aggregate menubar diff --git a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift index 05361afea..4973195ae 100644 --- a/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/CapacityDockView.swift @@ -99,7 +99,7 @@ enum CapacityDockMetrics { if CapacityDockGlance.drawsWindows(quota) { height += CapacityDockGlance.windows(quota).isEmpty ? CapacityDockGlance.windowsEmptyHeight - : CapacityDockGlance.windowsHeight + : CapacityDockGlance.windowsHeight(for: quota) } height += CapacityDockConnectionAction.resolve(quota: quota) == nil ? 0 : 38 let connectionExtra: CGFloat = switch quota.connection { @@ -135,13 +135,81 @@ enum CapacityDockGlance { /// Held as a constant because the panel frame is computed, not fitted. static let pillHeight: CGFloat = 46 static let pillGap: CGFloat = 6 - /// Three stacked lines, 13 + 13 + 12, with two 3pt gaps. Taller than the - /// 17pt burned figure beside it, so it sets the row. - static let todayContentHeight: CGFloat = 44 + /// Four stacked lines, 13 + 13 + 13 + 12, with three 3pt gaps. Taller than the + /// 17pt burned figure beside it, so it sets the row. The fourth line is the + /// cache-read figure, which drops out when the payload carries no + /// provider-scoped cache accounting — the reserved height does not move with + /// it, exactly like the input/output pair above it. + static let todayContentHeight: CGFloat = 60 /// 8 top + 24 percent + 2 + 13 label + 2 + 12 reset + 16 bottom. static let windowsHeight: CGFloat = 77 + /// The column content height inside `windowsHeight`: the section's top and + /// bottom padding are owned by `windowsSection`, while a grid owns the rows. + static let windowContentHeight: CGFloat = windowsHeight - sectionPadTop - contentInset + /// A real gap keeps adjacent scope labels and captions visually separate. + /// The old zero-spacing HStack let intrinsic Text widths bleed across columns. + static let windowsColumnGap: CGFloat = 8 + static let windowsRowGap: CGFloat = 6 + /// One 9.5pt pace caption under the reset line, with its 2pt gap. Reserved + /// whenever connected data carries a window with validated duration, even + /// while the caption itself is still empty — the slot must not appear and + /// disappear with wall-clock time under an open panel. + static let paceLineHeight: CGFloat = 12 + static let paceLineGap: CGFloat = 2 /// 8 top + one secondary line + 16 bottom. static let windowsEmptyHeight: CGFloat = 37 + + /// Whether the windows row carries pace slots: connected data with at + /// least one displayed window holding validated duration metadata. + static func drawsPace(_ quota: QuotaSummary) -> Bool { + guard drawsWindows(quota) else { return false } + let shown = windows(quota) + guard !shown.isEmpty else { return false } + return QuotaPacePresentation.reservesLine(for: shown, connection: quota.connection) + } + + /// The windows row's height for this quota: the plain row, or the row with + /// the pace slot every column reserves. Must stay in step with + /// `windowColumn`, which draws the slot under every column when this fires. + static func windowsHeight(for quota: QuotaSummary) -> CGFloat { + let shown = windows(quota) + guard !shown.isEmpty else { return windowsEmptyHeight } + return ( + sectionPadTop + + windowsGridHeight(for: quota) + + contentInset + ).rounded() + } + + /// One or two windows stay on one compact row. Three and four windows use + /// two columns and enough row height for every percentage, reset, and pace + /// caption. This is shared by `detailHeight` and the actual SwiftUI grid. + static func windowColumnCount(for windowCount: Int) -> Int { + guard windowCount > 0 else { return 0 } + return min(windowCount, 2) + } + + static func windowRowCount(for windowCount: Int) -> Int { + let columns = windowColumnCount(for: windowCount) + guard columns > 0 else { return 0 } + return (windowCount + columns - 1) / columns + } + + static func windowRowHeight(hasPaceSlot: Bool) -> CGFloat { + windowContentHeight + (hasPaceSlot ? paceLineGap + paceLineHeight : 0) + } + + /// Height of the grid alone, excluding this section's top and bottom pads. + static func windowsGridHeight(for quota: QuotaSummary) -> CGFloat { + let count = windows(quota).count + guard count > 0 else { return 0 } + let rows = windowRowCount(for: count) + let rowHeight = windowRowHeight(hasPaceSlot: drawsPace(quota)) + return ( + CGFloat(rows) * rowHeight + + CGFloat(max(0, rows - 1)) * windowsRowGap + ).rounded() + } /// The staleness or reconnect line under the header. It is a section like any /// other, so the panel has to reserve its height: the frame is computed, not /// fitted, and an unreserved line squeezes every block below it. @@ -944,6 +1012,12 @@ struct CapacityDockDetailView: View { // arrows drop out instead. if let input = today.inputTokens { tokenLine("arrow.down", Double(input)) } if let output = today.outputTokens { tokenLine("arrow.up", Double(output)) } + // Same absence rule as the arrows: cache read is shown only + // when the payload carries it for this provider, so a legacy + // CLI reads as unknown rather than as a fabricated zero. + if let cacheRead = today.cacheReadTokens { + cacheReadLine(Double(cacheRead)) + } Text("\(today.calls.asThousandsSeparated()) calls") .font(.system(size: 10)) .monospacedDigit() @@ -975,24 +1049,62 @@ struct CapacityDockDetailView: View { .frame(height: 13 * s) } - /// One column per quota window, in the order the provider reported them. + /// Reused input tokens, kept apart from the `arrow.down` fresh-input figure + /// and from cache writes: this is the part of the prompt the provider served + /// from its cache at the discounted rate that `cost` already includes. + @ViewBuilder + private func cacheReadLine(_ value: Double) -> some View { + let s = model.detailScale + let explanation = "Input tokens reused from this provider's prompt cache today — " + + "not fresh input (arrow.down), not cache writes, and already priced at the " + + "cache-read rate inside the burned figure." + HStack(spacing: 4 * s) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Color.capacityDockText.opacity(0.6)) + Text(value.asCompactTokens().lowercasedThousands()) + .font(.system(size: 10.5)) + .monospacedDigit() + .foregroundStyle(Color.capacityDockText) + Text("cache read") + .font(.system(size: 9.5)) + .foregroundStyle(Color.capacityDockText.opacity(0.6)) + } + .frame(height: 13 * s) + .help(explanation) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Cache read: \(value.asCompactTokens().lowercasedThousands()) tokens") + .accessibilityHint(explanation) + } + + /// One cell per quota window, in the order the provider reported them. + /// One or two windows stay on a compact row. Three or four windows use a + /// two-column grid whose cell width comes from the actual content geometry, + /// including its inter-column gap. When the panel reserved pace slots + /// (`drawsPace`), every cell draws the slot — empty where its window has no + /// defensible caption — so rows stay aligned with the height the panel + /// reserved. @ViewBuilder private func windowsSection(_ quota: QuotaSummary) -> some View { let s = model.detailScale let windows = CapacityDockGlance.windows(quota) + let hasPaceSlot = CapacityDockGlance.drawsPace(quota) + let paceLines = QuotaPacePresentation.lines( + for: windows, + connection: quota.connection + ) Group { if windows.isEmpty { budgetLine() .frame(height: CapacityDockGlance.captionLine * s) } else { - // A single window has no siblings to line up with, so it reads as - // a left-aligned figure rather than a lone centred digit. - let alignment: HorizontalAlignment = windows.count == 1 ? .leading : .center - HStack(spacing: 0) { - ForEach(Array(windows.enumerated()), id: \.offset) { _, window in - windowColumn(window, alignment: alignment) - } - } + windowGrid( + windows, + paceLines: paceLines, + hasPaceSlot: hasPaceSlot, + scale: s + ) + .frame(height: CapacityDockGlance.windowsGridHeight(for: quota) * s) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -1001,39 +1113,145 @@ struct CapacityDockDetailView: View { .padding(.horizontal, CapacityDockGlance.contentInset * s) } + @ViewBuilder + private func windowGrid( + _ windows: [QuotaSummary.Window], + paceLines: [QuotaPacePresentation.Line?], + hasPaceSlot: Bool, + scale: CGFloat + ) -> some View { + let columnCount = CapacityDockGlance.windowColumnCount(for: windows.count) + let rowCount = CapacityDockGlance.windowRowCount(for: windows.count) + let alignment: HorizontalAlignment = windows.count == 1 ? .leading : .center + GeometryReader { geometry in + let columnSpacing = columnCount > 1 + ? CapacityDockGlance.windowsColumnGap * scale + : 0 + let columnWidth = max( + 0, + (geometry.size.width - columnSpacing * CGFloat(max(0, columnCount - 1))) + / CGFloat(max(columnCount, 1)) + ) + VStack(spacing: windows.count > 2 ? CapacityDockGlance.windowsRowGap * scale : 0) { + ForEach(0.. some View { - let s = model.detailScale VStack(alignment: alignment, spacing: 0) { PercentGaugeText( label: window.percentLabel, fraction: window.percent, - font: .system(size: 20, weight: .semibold) + font: .system(size: 20 * scale, weight: .semibold) ) - .frame(height: 24 * s) + .frame(width: width, height: 24 * scale, alignment: alignment == .leading ? .leading : .center) Text(CapacityDockQuotaPresentation.displayLabel(window.label)) - .font(.system(size: 11)) + .font(.system(size: 11 * scale)) .foregroundStyle(Color.capacityDockText.opacity(0.6)) .lineLimit(1) - .frame(height: CapacityDockGlance.captionLine * s) - .padding(.top, 2 * s) + .minimumScaleFactor(0.7) + .truncationMode(.middle) + .frame( + width: width, + height: CapacityDockGlance.captionLine * scale, + alignment: alignment == .leading ? .leading : .center + ) + .padding(.top, 2 * scale) + .help(window.label) + .accessibilityLabel("Quota window " + window.label) Text(window.resetsInLabel) - .font(.system(size: 10)) + .font(.system(size: 10 * scale)) .monospacedDigit() .foregroundStyle(Color.capacityDockText.opacity(0.3)) - .lineLimit(1) - .frame(height: 12 * s) - .padding(.top, 2 * s) + // Reset labels come from the validated countdown formatter and + // are short (for example, "3d 11h"). Let the complete value + // keep its natural one-line width at the small 0.9x card scale; + // the grid cells are substantially wider than these labels. + .fixedSize(horizontal: true, vertical: true) + .frame( + width: width, + height: 12 * scale, + alignment: alignment == .leading ? .leading : .center + ) + .padding(.top, 2 * scale) + .accessibilityLabel("Resets " + window.resetsInLabel) + if hasPaceSlot { + paceCaption(paceLine) + .frame( + width: width, + height: CapacityDockGlance.paceLineHeight * scale, + alignment: alignment == .leading ? .leading : .center + ) + .padding(.top, CapacityDockGlance.paceLineGap * scale) + } } .frame( - maxWidth: .infinity, + width: width, alignment: alignment == .leading ? .leading : .center ) } + /// The whole-window-average pace reading under one quota window. An absent + /// caption leaves its reserved slot empty: no estimate is the honest state + /// for a too-young window, and inventing one is not. + @ViewBuilder + private func paceCaption(_ line: QuotaPacePresentation.Line?) -> some View { + if let line { + let color: Color = switch line.tone { + case .danger: .red.opacity(0.92) + case .warning: .orange.opacity(0.9) + case .neutral: Color.capacityDockText.opacity(0.45) + } + Text(line.text) + .font(.system(size: 9.5, weight: .medium)) + .monospacedDigit() + .foregroundStyle(color) + .lineLimit(1) + .minimumScaleFactor(0.8) + // The full caption remains in the tooltip/accessibility tree; + // middle truncation retains both the estimate kind and its + // useful endpoint when a future caption grows longer. + .truncationMode(.middle) + .help(line.helpText) + .accessibilityElement(children: .ignore) + .accessibilityLabel(line.text) + .accessibilityHint(line.helpText) + } + } + /// No quota window exists for this provider, so money is the capacity. @ViewBuilder private func budgetLine() -> some View { diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift index 60834bf11..4a3c7ce63 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockGlanceTests.swift @@ -2,8 +2,13 @@ import Foundation import Testing @testable import CodeBurnMenubar -private func window(_ label: String, _ percent: Double, resetsAt: Date? = nil) -> QuotaSummary.Window { - QuotaSummary.Window(label: label, percent: percent, resetsAt: resetsAt) +private func window( + _ label: String, + _ percent: Double, + resetsAt: Date? = nil, + windowSeconds: Int? = nil +) -> QuotaSummary.Window { + QuotaSummary.Window(label: label, percent: percent, resetsAt: resetsAt, windowSeconds: windowSeconds) } private func quota( @@ -80,6 +85,75 @@ struct CapacityDockGlanceTests { #expect(CapacityDockGlance.windows(quota([])).isEmpty) } + @Test("Pace slots reserve height only for connected windows with a validated duration") + func paceSlotReservedByDuration() { + let resetsAt = Date().addingTimeInterval(3 * 24 * 3600) + let withoutDuration = [window("5-hour", 0.2, resetsAt: resetsAt), window("Weekly", 0.5, resetsAt: resetsAt)] + let withDuration = [ + window("5-hour", 0.2, resetsAt: resetsAt, windowSeconds: QuotaPacePresentation.claudeFiveHourSeconds), + window("Weekly", 0.5, resetsAt: resetsAt, windowSeconds: QuotaPacePresentation.claudeSevenDaySeconds), + ] + #expect(!CapacityDockGlance.drawsPace(quota(withoutDuration))) + #expect(CapacityDockGlance.drawsPace(quota(withDuration))) + // Missing duration still draws the plain row. + #expect(CapacityDockGlance.windowsHeight(for: quota(withoutDuration)) == CapacityDockGlance.windowsHeight) + let step = CapacityDockGlance.paceLineGap + CapacityDockGlance.paceLineHeight + #expect(CapacityDockGlance.windowsHeight(for: quota(withDuration)) == CapacityDockGlance.windowsHeight + step) + // Stale data carries the durations but no defensible estimate, so the + // slot is not reserved and the panel is shorter. + #expect(!CapacityDockGlance.drawsPace(quota(withDuration, connection: .stale))) + // The computed panel height reflects the reserved slot. + func detailHeight(_ windows: [QuotaSummary.Window], connection: QuotaSummary.Connection) -> CGFloat { + CapacityDockMetrics.detailHeight( + quota: quota(windows, connection: connection), + sessionCount: nil, + hasToday: false, + tailEdge: .right, + scale: 1 + ) + } + #expect(detailHeight(withDuration, connection: .connected) - detailHeight(withoutDuration, connection: .connected) == step) + for scale in [0.9, 1.0, 1.15, 1.25, 1.4] { + let h = CapacityDockMetrics.detailHeight( + quota: quota(withDuration), + sessionCount: 2, + hasToday: true, + tailEdge: .bottom, + scale: CGFloat(scale) + ) + #expect(h == h.rounded()) + } + } + + @Test("Three or four windows use two constrained columns and two rows") + func multiWindowGridGeometry() { + let plainThree = quota([ + window("5-hour", 0.2), + window("Weekly · Opus", 0.5), + window("Weekly · Sonnet", 0.7), + ]) + let plainFour = quota([ + window("5-hour", 0.2), + window("Weekly", 0.5), + window("Weekly · Opus", 0.7), + window("Weekly · Sonnet", 0.9), + ]) + #expect(CapacityDockGlance.windowColumnCount(for: 1) == 1) + #expect(CapacityDockGlance.windowColumnCount(for: 2) == 2) + #expect(CapacityDockGlance.windowColumnCount(for: 3) == 2) + #expect(CapacityDockGlance.windowColumnCount(for: 4) == 2) + #expect(CapacityDockGlance.windowRowCount(for: 1) == 1) + #expect(CapacityDockGlance.windowRowCount(for: 2) == 1) + #expect(CapacityDockGlance.windowRowCount(for: 3) == 2) + #expect(CapacityDockGlance.windowRowCount(for: 4) == 2) + #expect(abs(CapacityDockGlance.windowsGridHeight(for: plainThree) - (2 * 53 + 6)) < 0.001) + #expect(abs(CapacityDockGlance.windowsHeight(for: plainThree) - (8 + 2 * 53 + 6 + 16)) < 0.001) + #expect(CapacityDockGlance.windowsHeight(for: plainFour) == CapacityDockGlance.windowsHeight(for: plainThree)) + // One and two windows retain the original compact one-row geometry. + #expect(CapacityDockGlance.windowsHeight(for: quota([window("Weekly", 0.5)])) == CapacityDockGlance.windowsHeight) + #expect(CapacityDockGlance.windowsHeight(for: quota([window("5-hour", 0.2), window("Weekly", 0.5)])) == CapacityDockGlance.windowsHeight) + } + @Test("The pill tint ramps green, yellow, orange, red at 70, 80 and 90 percent") func severityRamp() { #expect(CapacityDockGlance.severityColor(0.0) == .green) @@ -206,10 +280,10 @@ struct CapacityDockGlanceTests { full == CapacityDockGlance.headerHeight + CapacityDockGlance.sessionsHeight(count: 1) + CapacityDockGlance.todayHeight - + CapacityDockGlance.windowsHeight + + CapacityDockGlance.windowsHeight(for: quota(three)) ) - // 44 header + 83 sessions + 81 today + 77 windows - #expect(full == 285) + // 44 header + 83 sessions + 97 today + 136 two-row windows grid. + #expect(full == 360) // The panel opens and closes on the same 16pt inset it uses sideways. let headerParts: CGFloat = CapacityDockGlance.contentInset + 20 + 8 #expect(CapacityDockGlance.headerHeight == headerParts) @@ -217,22 +291,34 @@ struct CapacityDockGlanceTests { CapacityDockGlance.windowsHeight == CapacityDockGlance.sectionPadTop + 53 + CapacityDockGlance.contentInset ) - // Today is three stacked lines (13 + 3 + 13 + 3 + 12) inside its padding. - #expect(CapacityDockGlance.todayContentHeight == 44) - #expect(CapacityDockGlance.todayHeight == 81) + // Today is four stacked lines (13 + 3 + 13 + 3 + 13 + 3 + 12) inside its + // padding: the cache-read line joined the input/output pair, and the row + // keeps one fixed height whether or not that line draws. + #expect(CapacityDockGlance.todayContentHeight == 60) + #expect(CapacityDockGlance.todayHeight == 97) // Past four sessions the list scrolls, so the panel stops growing. let capped = height(4, hasToday: true, windows: three) #expect(height(12, hasToday: true, windows: three) == capped) - #expect(capped == 44 + CapacityDockGlance.sessionsHeight(count: 4) + 81 + 77) + #expect( + capped == 44 + + CapacityDockGlance.sessionsHeight(count: 4) + + 97 + + CapacityDockGlance.windowsHeight(for: quota(three)) + ) // Each section is independently droppable. #expect(full - height(nil, hasToday: true, windows: three) == CapacityDockGlance.sessionsHeight(count: 1)) #expect(full - height(1, hasToday: false, windows: three) == CapacityDockGlance.todayHeight) #expect( height(1, hasToday: true, windows: []) - full - == CapacityDockGlance.windowsEmptyHeight - CapacityDockGlance.windowsHeight + == CapacityDockGlance.windowsEmptyHeight - CapacityDockGlance.windowsHeight(for: quota(three)) + ) + // One and two columns stay on the compact row; three windows need the + // second grid row so every scope remains visible. + #expect(height(1, hasToday: true, windows: [three[0]]) < full) + #expect( + height(1, hasToday: true, windows: [three[0], three[1]]) + == height(1, hasToday: true, windows: [three[0]]) ) - // Column count does not change the row's height. - #expect(height(1, hasToday: true, windows: [three[0]]) == full) } @Test("A vertical tail adds its allowance to the panel height") @@ -255,7 +341,12 @@ struct CapacityDockGlanceTests { func heightIsAlwaysWhole() { for scale in [0.9, 1.0, 1.15, 1.25, 1.4] { let height = CapacityDockMetrics.detailHeight( - quota: quota([window("5-hour", 0.2), window("Weekly", 0.5)]), + quota: quota([ + window("5-hour", 0.2), + window("Weekly", 0.5), + window("Weekly · Opus", 0.7), + window("Weekly · Sonnet", 0.9), + ]), sessionCount: 4, hasToday: true, tailEdge: .bottom, diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockPacePresentationTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockPacePresentationTests.swift new file mode 100644 index 000000000..c6f208183 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockPacePresentationTests.swift @@ -0,0 +1,259 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +/// Fixtures for the Capacity Dock pace caption. These drive the production +/// `QuotaPacePresentation` path that the window columns render, not helper +/// arithmetic: every assertion names the exact caption string the view will +/// draw, which is also what pins the fraction/percent unit crossing. +private struct Fixture { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let week = 7 * 24 * 3600 + let fiveHours = 5 * 3600 + + /// resetsAt such that `fraction` of the window has elapsed at `now`. + func resets(afterElapsedFraction fraction: Double, windowSeconds: Int) -> Date { + now.addingTimeInterval(TimeInterval(windowSeconds) * (1 - fraction)) + } + + func window( + _ label: String, + _ percent: Double, + resetsAt: Date, + windowSeconds: Int + ) -> QuotaSummary.Window { + QuotaSummary.Window( + label: label, + percent: percent, + resetsAt: resetsAt, + windowSeconds: windowSeconds, + fetchedAt: now + ) + } + + func line( + percent: Double, + elapsedFraction: Double, + windowSeconds: Int, + connection: QuotaSummary.Connection = .connected + ) -> QuotaPacePresentation.Line? { + QuotaPacePresentation.line( + for: window("Weekly", percent, resetsAt: resets(afterElapsedFraction: elapsedFraction, windowSeconds: windowSeconds), windowSeconds: windowSeconds), + connection: connection, + now: now + ) + } +} + +@Suite("Capacity Dock quota pace caption") +struct CapacityDockPacePresentationTests { + private let f = Fixture() + + @Test("A window fraction is consumed as percent, not as a raw 0..1 value") + func fractionBecomesPercent() { + // 0.5 fraction at halfway through the week is 50% used — on pace. + let line = f.line(percent: 0.5, elapsedFraction: 0.5, windowSeconds: f.week) + #expect(line?.kind == .estimate) + #expect(line?.text == "est. 100% at reset") + #expect(line?.tone == .neutral) + } + + @Test("Ahead of pace with a projected overflow names an exhaustion ETA from now") + func deficitProjectsEarlyExhaustion() { + // used 60%, expected 40% -> projected 150%; the limit is hit 44h 48m + // from `now`, while the reset itself is still 4d 4h away. + let line = f.line(percent: 0.6, elapsedFraction: 0.4, windowSeconds: f.week) + #expect(line?.kind == .estimate) + #expect(line?.text == "est. out in 1d 20h") + #expect(line?.tone == .warning) + #expect(!(line?.text.contains("early") ?? false)) + } + + @Test("The ETA is now-to-limit, which differs from now-to-reset") + func etaIsNowToLimitNotLeadBeforeReset() { + let hitsLimitAt = f.now.addingTimeInterval(44 * 3600 + 48 * 60) // 44h 48m + let resetsAt = f.resets(afterElapsedFraction: 0.4, windowSeconds: f.week) + let nowToLimit = QuotaPacePresentation.countdownLabel(from: f.now, to: hitsLimitAt) + let nowToReset = QuotaPacePresentation.countdownLabel(from: f.now, to: resetsAt) + // The two intervals differ, so the caption must state the ETA from now, + // not a "lead before reset" that would come out as reset-minus-limit. + #expect(nowToLimit == "1d 20h") + #expect(nowToReset == "4d 4h") + #expect(nowToLimit != nowToReset) + } + + @Test("Behind pace stays in reserve with a projection, no alarm") + func reserveStaysNeutral() { + let line = f.line(percent: 0.2, elapsedFraction: 0.5, windowSeconds: f.week) + #expect(line?.text == "est. 40% at reset") + #expect(line?.tone == .neutral) + } + + @Test("A window younger than 3% elapsed gets no estimate") + func earlyWindowIsSilent() { + #expect(f.line(percent: 0.5, elapsedFraction: 0.01, windowSeconds: f.week) == nil) + } + + @Test("No usage yet reads as a zero projection, not as zero-signal") + func noUsageYet() { + let line = f.line(percent: 0.0, elapsedFraction: 0.5, windowSeconds: f.week) + #expect(line?.text == "est. 0% at reset") + #expect(line?.tone == .neutral) + } + + @Test("A fully used window is exhausted, and a past reset is stale") + func exhaustedState() { + let reached = f.line(percent: 1.0, elapsedFraction: 0.5, windowSeconds: f.week) + #expect(reached?.kind == .exhausted) + #expect(reached?.text == "limit reached") + #expect(reached?.tone == .danger) + let window = f.window( + "Weekly", 1.0, + resetsAt: f.now.addingTimeInterval(-100), + windowSeconds: f.week + ) + #expect(QuotaPacePresentation.line(for: window, connection: .connected, now: f.now) == nil) + } + + @Test("Short windows show stage only, never a burst ETA") + func shortWindowSuppressesETA() { + let line = f.line(percent: 0.9, elapsedFraction: 0.5, windowSeconds: f.fiveHours) + #expect(line?.kind == .estimate) + #expect(line?.text == "40% in deficit") + #expect(line?.tone == .warning) + #expect(!(line?.text.contains("est.") ?? false)) + } + + @Test("Stale, failed and disconnected data get nothing") + func nonConnectedDataIsSilent() { + for connection: QuotaSummary.Connection in [.stale, .loading, .transientFailure, .disconnected] { + #expect(f.line(percent: 0.6, elapsedFraction: 0.4, windowSeconds: f.week, connection: connection) == nil) + } + } + + @Test("An old connected sample gets no pace caption") + func oldConnectedSampleIsSilent() { + let old = f.window( + "Weekly", + 0.6, + resetsAt: f.resets(afterElapsedFraction: 0.4, windowSeconds: f.week), + windowSeconds: f.week + ) + let stale = QuotaSummary.Window( + label: old.label, + percent: old.percent, + resetsAt: old.resetsAt, + windowSeconds: old.windowSeconds, + fetchedAt: f.now.addingTimeInterval(-QuotaSummary.freshnessThreshold - 1) + ) + #expect(QuotaPacePresentation.line(for: stale, connection: .connected, now: f.now) == nil) + } + + @Test("Negative, over-one, non-finite and missing metadata get nothing") + func invalidInputsAreSilent() { + // A negative fraction must be rejected, not clamped into a healthy 0%. + #expect(f.line(percent: -0.1, elapsedFraction: 0.5, windowSeconds: f.week) == nil) + // Over 100% is not a "limit reached" signal — it is a broken sample. + #expect(f.line(percent: 1.2, elapsedFraction: 0.5, windowSeconds: f.week) == nil) + #expect(f.line(percent: .nan, elapsedFraction: 0.5, windowSeconds: f.week) == nil) + #expect(f.line(percent: .infinity, elapsedFraction: 0.5, windowSeconds: f.week) == nil) + #expect(f.line(percent: 0.5, elapsedFraction: 0.5, windowSeconds: 0) == nil) + #expect(f.line(percent: 0.5, elapsedFraction: 0.5, windowSeconds: -100) == nil) + + let noDuration = QuotaSummary.Window( + label: "Weekly", + percent: 0.5, + resetsAt: f.resets(afterElapsedFraction: 0.5, windowSeconds: f.week), + windowSeconds: nil, + fetchedAt: f.now + ) + #expect(QuotaPacePresentation.line(for: noDuration, connection: .connected, now: f.now) == nil) + + let noReset = QuotaSummary.Window( + label: "Weekly", + percent: 0.5, + resetsAt: nil, + windowSeconds: f.week, + fetchedAt: f.now + ) + #expect(QuotaPacePresentation.line(for: noReset, connection: .connected, now: f.now) == nil) + } + + @Test("Clock skew in either direction gets nothing") + func clockSkewIsSilent() { + let tooFar = f.window("Weekly", 0.5, resetsAt: f.now.addingTimeInterval(TimeInterval(f.week + 100)), windowSeconds: f.week) + #expect(QuotaPacePresentation.line(for: tooFar, connection: .connected, now: f.now) == nil) + let elapsed = f.window("Weekly", 0.5, resetsAt: f.now.addingTimeInterval(-1), windowSeconds: f.week) + #expect(QuotaPacePresentation.line(for: elapsed, connection: .connected, now: f.now) == nil) + } + + @Test("An impossible exhausted window (reset far past the duration) is rejected") + func impossibleExhaustedIsSilent() { + // 100% used, but the reset is thirty days out on a weekly window: + // that is clock/data skew, not an actually-exhausted limit. + let window = f.window("Weekly", 1.0, resetsAt: f.now.addingTimeInterval(30 * 24 * 3600), windowSeconds: f.week) + #expect(QuotaPacePresentation.line(for: window, connection: .connected, now: f.now) == nil) + } + + @Test("A non-finite reset timestamp is rejected before any branch") + func nonFiniteResetIsSilent() { + let window = f.window("Weekly", 1.0, resetsAt: Date(timeIntervalSinceReferenceDate: .infinity), windowSeconds: f.week) + #expect(QuotaPacePresentation.line(for: window, connection: .connected, now: f.now) == nil) + } + + @Test("A mislabeled duration is used as given, not re-inferred from the label") + func durationIsUsedAsGiven() { + let thirtyDays = 30 * 24 * 3600 + let window = f.window( + "Weekly", 0.9, + resetsAt: f.now.addingTimeInterval(2 * 24 * 3600), + windowSeconds: thirtyDays + ) + let line = QuotaPacePresentation.line(for: window, connection: .connected, now: f.now) + #expect(line?.kind == .estimate) + #expect(line?.text == "est. 96% at reset") + } + + @Test("Distinct scopes sharing a duration each keep their own caption") + func distinctScopesAreNotSuppressed() { + // A healthy aggregate Weekly and an exhausted Weekly · Opus share the + // same 7-day duration; the healthy window must not hide the exhausted + // one. + let weekly = f.window("Weekly", 0.5, resetsAt: f.resets(afterElapsedFraction: 0.5, windowSeconds: f.week), windowSeconds: f.week) + let opus = f.window("Weekly · Opus", 1.0, resetsAt: f.resets(afterElapsedFraction: 0.5, windowSeconds: f.week), windowSeconds: f.week) + let lines = QuotaPacePresentation.lines(for: [weekly, opus], connection: .connected, now: f.now) + #expect(lines[0]?.text == "est. 100% at reset") + #expect(lines[1]?.text == "limit reached") + } + + @Test("Same-duration windows with different reset dates both keep captions") + func sameDurationDifferentResets() { + let a = f.window("Limit A", 0.5, resetsAt: f.now.addingTimeInterval(3 * 24 * 3600), windowSeconds: f.week) + let b = f.window("Limit B", 0.9, resetsAt: f.now.addingTimeInterval(2 * 24 * 3600), windowSeconds: f.week) + let lines = QuotaPacePresentation.lines(for: [a, b], connection: .connected, now: f.now) + #expect(lines[0] != nil) + #expect(lines[1] != nil) + } + + @Test("Only a genuinely identical duplicate window is suppressed") + func exactDuplicatesAreSuppressed() { + let resetsAt = f.resets(afterElapsedFraction: 0.5, windowSeconds: f.week) + let a = f.window("Weekly", 0.5, resetsAt: resetsAt, windowSeconds: f.week) + let duplicate = f.window("Weekly", 0.5, resetsAt: resetsAt, windowSeconds: f.week) + let lines = QuotaPacePresentation.lines(for: [a, duplicate], connection: .connected, now: f.now) + #expect(lines[0] != nil) + #expect(lines[1] == nil) + } + + @Test("The panel reserves a slot on metadata, independent of wall-clock time") + func reservationIsMetadataOnly() { + let resetsAt = f.now.addingTimeInterval(3 * 24 * 3600) + let eligible = [QuotaSummary.Window(label: "Weekly", percent: 0.5, resetsAt: resetsAt, windowSeconds: f.week, fetchedAt: f.now)] + let noDuration = [QuotaSummary.Window(label: "Weekly", percent: 0.5, resetsAt: resetsAt, fetchedAt: f.now)] + let noReset = [QuotaSummary.Window(label: "Weekly", percent: 0.5, resetsAt: nil, windowSeconds: f.week, fetchedAt: f.now)] + #expect(QuotaPacePresentation.reservesLine(for: eligible, connection: .connected)) + #expect(!QuotaPacePresentation.reservesLine(for: noDuration, connection: .connected)) + #expect(!QuotaPacePresentation.reservesLine(for: noReset, connection: .connected)) + #expect(!QuotaPacePresentation.reservesLine(for: eligible, connection: .stale)) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockTodayTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockTodayTests.swift index 2573755b2..9001dd28a 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockTodayTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockTodayTests.swift @@ -181,6 +181,106 @@ struct CapacityDockTodayTests { #expect(row?.outputTokens == nil) } + @Test("Cache read is the hovered provider's own figure, not the machine's") + func cacheReadIsProviderScoped() { + let store = store(todayPayload( + cost: 278.94, + calls: 1_542, + inputTokens: 9_000_000, + outputTokens: 400_000, + providerDetails: [ + ProviderDetail(id: "claude", label: "Claude", cost: 190.10, calls: 900, + hasUsage: true, inputTokens: 6_000_000, outputTokens: 250_000, + sessions: 12, cacheReadTokens: 4_200_000), + ProviderDetail(id: "codex", label: "Codex", cost: 88.84, calls: 642, + hasUsage: true, inputTokens: 3_000_000, outputTokens: 150_000, + sessions: 4, cacheReadTokens: 120_000), + ] + )) + #expect(store.capacityDockToday(for: .claude)?.cacheReadTokens == 4_200_000) + #expect(store.capacityDockToday(for: .codex)?.cacheReadTokens == 120_000) + } + + @Test("A tile spanning several rows sums cache read, absent until a row reports it") + func combinedTileSumsCacheRead() { + let cursor = CapacityDockProvider(rawValue: "cursor")! + let sums = store(todayPayload( + cost: 1, + calls: 4, + inputTokens: 0, + outputTokens: 0, + providerDetails: [ + ProviderDetail(id: "cursor", label: "Cursor", cost: 0.5, calls: 2, hasUsage: true, + cacheReadTokens: 3_000), + ProviderDetail(id: "cursor-agent", label: "Cursor Agent", cost: 0.5, calls: 2, hasUsage: true, + cacheReadTokens: 40_000), + ] + )) + #expect(sums.capacityDockToday(for: cursor)?.cacheReadTokens == 43_000) + + // Neither row reports cache read -> unknown, not a fabricated zero. + let absent = store(todayPayload( + cost: 1, calls: 4, inputTokens: 0, outputTokens: 0, + providerDetails: [ + ProviderDetail(id: "cursor", label: "Cursor", cost: 0.5, calls: 2, hasUsage: true), + ProviderDetail(id: "cursor-agent", label: "Cursor Agent", cost: 0.5, calls: 2, hasUsage: true), + ] + )) + #expect(absent.capacityDockToday(for: cursor)?.cacheReadTokens == nil) + } + + @Test("Known zero cache read stays zero; a legacy row stays unknown") + func cacheReadZeroVersusMissing() { + let store = store(todayPayload( + cost: 1, calls: 4, inputTokens: 0, outputTokens: 0, + providerDetails: [ + ProviderDetail(id: "claude", label: "Claude", cost: 1, calls: 4, hasUsage: true, + cacheReadTokens: 0), + ] + )) + #expect(store.capacityDockToday(for: .claude)?.cacheReadTokens == 0) + } + + @Test("A partial cache-read sum is unknown, not a complete-looking total") + func partialCacheReadSumStaysUnknown() { + // One tile row reports cache read, the other is an active row from a + // CLI that predates the field: the tile must report none rather than + // present the one known row's figure as the whole account's total. + let cursor = CapacityDockProvider(rawValue: "cursor")! + let partial = store(todayPayload( + cost: 1, calls: 6, inputTokens: 0, outputTokens: 0, + providerDetails: [ + ProviderDetail(id: "cursor", label: "Cursor", cost: 0.5, calls: 2, hasUsage: true, + cacheReadTokens: 3_000), + ProviderDetail(id: "cursor-agent", label: "Cursor Agent", cost: 0.5, calls: 4, hasUsage: true), + ] + )) + #expect(partial.capacityDockToday(for: cursor)?.cacheReadTokens == nil) + // An idle row (no usage) does not force unknown: its cache read is a + // genuine zero. + let idle = store(todayPayload( + cost: 0.5, calls: 2, inputTokens: 0, outputTokens: 0, + providerDetails: [ + ProviderDetail(id: "cursor", label: "Cursor", cost: 0.5, calls: 2, hasUsage: true, + cacheReadTokens: 3_000), + ProviderDetail(id: "cursor-agent", label: "Cursor Agent", cost: 0, calls: 0, hasUsage: false), + ] + )) + #expect(idle.capacityDockToday(for: cursor)?.cacheReadTokens == 3_000) + } + + @Test("Large cache counts survive without overflow") + func cacheReadLargeCounts() { + let store = store(todayPayload( + cost: 1, calls: 4, inputTokens: 0, outputTokens: 0, + providerDetails: [ + ProviderDetail(id: "claude", label: "Claude", cost: 1, calls: 4, hasUsage: true, + cacheReadTokens: 2_147_483_647), + ] + )) + #expect(store.capacityDockToday(for: .claude)?.cacheReadTokens == 2_147_483_647) + } + @Test("Kimi Code reads its CLI row rather than the CLI's separate kimi provider") func dockIDMapsToTheCLIProviderID() { #expect(CapacityDockProvider.kimiCode.payloadProviderID == "kimicode") @@ -295,6 +395,7 @@ struct CapacityDockTodayTests { #expect(old.inputTokens == nil) #expect(old.outputTokens == nil) #expect(old.sessions == nil) + #expect(old.cacheReadTokens == nil) let current = """ {"id":"claude","label":"Claude","cost":190.1,"calls":900,"hasUsage":true, @@ -304,5 +405,12 @@ struct CapacityDockTodayTests { #expect(new.inputTokens == 6_000_000) #expect(new.outputTokens == 250_000) #expect(new.sessions == 12) + + let withCache = """ + {"id":"claude","label":"Claude","cost":190.1,"calls":900,"hasUsage":true, + "inputTokens":6000000,"outputTokens":250000,"sessions":12,"cacheReadTokens":4200000} + """ + let cached = try JSONDecoder().decode(ProviderDetail.self, from: Data(withCache.utf8)) + #expect(cached.cacheReadTokens == 4_200_000) } } diff --git a/mac/Tests/CodeBurnMenubarTests/QuotaFreshnessTests.swift b/mac/Tests/CodeBurnMenubarTests/QuotaFreshnessTests.swift new file mode 100644 index 000000000..08dcd6e60 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/QuotaFreshnessTests.swift @@ -0,0 +1,216 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +private let quotaFreshnessNow = Date(timeIntervalSince1970: 1_900_000_000) + +private func claudeUsage(fetchedAt: Date) -> SubscriptionUsage { + SubscriptionUsage( + tier: .pro, + rawTier: "pro", + fiveHourPercent: 40, + fiveHourResetsAt: quotaFreshnessNow.addingTimeInterval(4 * 3600), + sevenDayPercent: 20, + sevenDayResetsAt: quotaFreshnessNow.addingTimeInterval(6 * 24 * 3600), + sevenDayOpusPercent: nil, + sevenDayOpusResetsAt: nil, + sevenDaySonnetPercent: nil, + sevenDaySonnetResetsAt: nil, + scopedWeekly: [], + fetchedAt: fetchedAt + ) +} + +private func codexUsage(fetchedAt: Date) -> CodexUsage { + CodexUsage( + plan: .plus, + primary: CodexUsage.Window( + usedPercent: 40, + resetsAt: quotaFreshnessNow.addingTimeInterval(4 * 3600), + limitWindowSeconds: 5 * 3600 + ), + secondary: nil, + additionalLimits: [], + creditsBalance: nil, + hasCredits: false, + creditsUnlimited: false, + creditLimit: nil, + resetCredits: nil, + fetchedAt: fetchedAt + ) +} + +private actor CodexRefreshGate { + private var waiters: [CheckedContinuation] = [] + private var isClosed = false + + var waiterCount: Int { waiters.count } + + func wait() async throws -> CodexUsage? { + if isClosed { return nil } + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if isClosed { + continuation.resume(returning: nil) + } else { + waiters.append(continuation) + } + } + } + + func release(_ result: Result) { + guard !waiters.isEmpty else { return } + waiters.removeFirst().resume(with: result) + } + + func releaseAll() { + while !waiters.isEmpty { + waiters.removeFirst().resume(returning: nil) + } + } + + func close() { + isClosed = true + releaseAll() + } +} + +private func waitForWaiterCount(_ gate: CodexRefreshGate, _ expected: Int) async -> Bool { + // A full SwiftPM run schedules this suite alongside credential and process + // tests that can briefly occupy the main actor. Give the controlled fetch + // a bounded scheduler window instead of relying on a tiny yield count. + for _ in 0..<1_000 { + if await gate.waiterCount == expected { return true } + try? await Task.sleep(for: .milliseconds(1)) + } + return await gate.waiterCount == expected +} + +@Suite("Capacity Dock quota freshness", .serialized) +@MainActor +struct QuotaFreshnessTests { + @Test("freshness accepts the established ten-minute boundary and rejects older or future samples") + func freshnessBoundary() { + let boundary = quotaFreshnessNow.addingTimeInterval(-QuotaSummary.freshnessThreshold) + #expect(QuotaSummary.isFresh(fetchedAt: boundary, now: quotaFreshnessNow)) + #expect(!QuotaSummary.isFresh( + fetchedAt: boundary.addingTimeInterval(-0.1), + now: quotaFreshnessNow + )) + #expect(!QuotaSummary.isFresh( + fetchedAt: quotaFreshnessNow.addingTimeInterval(1), + now: quotaFreshnessNow + )) + #expect(!QuotaSummary.isFresh(fetchedAt: nil, now: quotaFreshnessNow)) + } + + @Test("window metadata preserves sample age for the pace presentation") + func windowCarriesFreshness() { + let fetchedAt = quotaFreshnessNow.addingTimeInterval(-60) + let window = QuotaSummary.Window( + label: "Weekly", + percent: 0.4, + resetsAt: quotaFreshnessNow.addingTimeInterval(6 * 24 * 3600), + windowSeconds: 7 * 24 * 3600, + fetchedAt: fetchedAt + ) + #expect(window.isFresh(at: quotaFreshnessNow)) + #expect(!window.isFresh(at: quotaFreshnessNow.addingTimeInterval(QuotaSummary.freshnessThreshold + 1))) + #expect(!QuotaSummary.Window( + label: "Legacy", + percent: 0.4, + resetsAt: window.resetsAt, + windowSeconds: window.windowSeconds + ).isFresh(at: quotaFreshnessNow)) + } + + @Test("Claude loaded data becomes stale when its sample ages past the pace horizon") + func staleClaudeSummaryDoesNotLookConnected() { + let store = AppStore() + let now = Date() + let fetchedAt = now.addingTimeInterval(-QuotaSummary.freshnessThreshold - 1) + store.subscription = claudeUsage(fetchedAt: fetchedAt) + store.subscriptionLoadState = .loaded + + let summary = store.quotaSummary(for: .claude) + #expect(summary?.connection == .stale) + #expect(summary?.details.first?.fetchedAt == fetchedAt) + #expect(summary?.details.first?.isFresh(at: now) == false) + } + + @Test("Codex loaded data becomes stale when its sample ages past the pace horizon") + func staleCodexSummaryDoesNotLookConnected() { + let store = AppStore() + let now = Date() + let fetchedAt = now.addingTimeInterval(-QuotaSummary.freshnessThreshold - 1) + store.codexUsage = codexUsage(fetchedAt: fetchedAt) + store.codexLoadState = .loaded + + let summary = store.quotaSummary(for: .codex) + #expect(summary?.connection == .stale) + #expect(summary?.details.first?.fetchedAt == fetchedAt) + #expect(summary?.details.first?.isFresh(at: now) == false) + } + + @Test("fresh loaded samples remain connected") + func freshSummariesRemainConnected() { + let store = AppStore() + store.subscription = claudeUsage(fetchedAt: Date()) + store.subscriptionLoadState = .loaded + #expect(store.quotaSummary(for: .claude)?.connection == .connected) + + store.codexUsage = codexUsage(fetchedAt: Date()) + store.codexLoadState = .loaded + #expect(store.quotaSummary(for: .codex)?.connection == .connected) + } + + @Test("overlapping refreshes keep the newer loading state until it finishes") + func overlappingRefreshesDoNotRestoreAnOlderState() async { + let gate = CodexRefreshGate() + let store = AppStore() + store.codexUsage = codexUsage(fetchedAt: Date()) + store.codexLoadState = .loaded + store.codexQuotaBootstrapChecker = { true } + store.codexQuotaFetcher = { try await gate.wait() } + + let first = Task { await store.refreshCodexReportingSuccess() } + guard await waitForWaiterCount(gate, 1) else { + await gate.close() + _ = await first.value + #expect(Bool(false), "first refresh did not enter the controlled fetch") + return + } + #expect(store.codexLoadState == .loading) + + let second = Task { await store.refreshCodexReportingSuccess() } + guard await waitForWaiterCount(gate, 2) else { + await gate.close() + _ = await first.value + _ = await second.value + #expect(Bool(false), "second refresh did not enter the controlled fetch") + return + } + + // The superseded request must not restore `.loaded` while request 2 is + // still waiting. The current request's nil result restores the state + // that was present before the refresh pair began. + await gate.release(.success(nil)) + #expect(await first.value == false) + #expect(store.codexLoadState == .loading) + await gate.release(.success(nil)) + #expect(await second.value == false) + #expect(store.codexLoadState == .loaded) + await gate.close() + } + + @Test("a cancelled refresh restores the prior state instead of reporting failure") + func cancelledRefreshRestoresPriorState() async { + let store = AppStore() + store.codexUsage = codexUsage(fetchedAt: Date()) + store.codexLoadState = .loaded + store.codexQuotaBootstrapChecker = { true } + store.codexQuotaFetcher = { throw CancellationError() } + + #expect(await store.refreshCodexReportingSuccess() == false) + #expect(store.codexLoadState == .loaded) + } +} diff --git a/src/main.ts b/src/main.ts index 6c6a11f35..d200c39cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,8 @@ import { getProvider } from './providers/index.js' import { getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js' import { convertCost, formatCost } from './currency.js' import { renderStatusBar } from './format.js' -import { DAILY_CACHE_VERSION, toDateString } from './daily-cache.js' +import { toDateString } from './daily-cache.js' +import { statusSnapshotSemanticKey } from './status-snapshot-semantic.js' import { dateKey } from './day-aggregator.js' import { sessionModelBillableOutputTokens } from './session-output.js' import { isBehavioralCall } from './behavioral-weight.js' @@ -52,14 +53,10 @@ import { createRequire } from 'node:module' const require = createRequire(import.meta.url) const { version } = require('../package.json') -// Bump when the menubar payload's rendering semantics change without a package -// release or daily-cache version change. The envelope version in session-cache -// protects record shape; this protects the meaning of an otherwise valid one. -// v5: providerDetails carries per-provider tokens and sessions, which a v4 -// record predates — the dock glance would read a provider as having no token -// breakdown purely because the snapshot was written before this build. -const STATUS_SNAPSHOT_RENDER_VERSION = 5 -const STATUS_SNAPSHOT_SEMANTIC_KEY = `${version}:render-${STATUS_SNAPSHOT_RENDER_VERSION}:daily-${DAILY_CACHE_VERSION}` +// The snapshot semantic revision + key live in their own module so the CLI's +// snapshot read/write path and its regression tests agree on the same value +// without importing the CLI entry point (which parses argv as a side effect). +const STATUS_SNAPSHOT_SEMANTIC_KEY = statusSnapshotSemanticKey(version) import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' import { CodexThroughputReader, newestCodexSession, renderCodexThroughput } from './codex-throughput.js' diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 273e9d9a0..8fadb2742 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -91,6 +91,15 @@ export type ProviderCost = { outputTokens?: number /** Provider-scoped session count for the period, absent under the same rule. */ sessions?: number + /** Provider-scoped prompt-cache read tokens for the period, absent under the + * same rule: no day in the period reported cache reads for this provider, + * so a consumer must render unknown rather than zero. Distinct from fresh + * input (never double-counted into it) and priced inside `cost`. */ + cacheReadTokens?: number + /** Internal accounting flag, never emitted: true when some active day slice + * lacked the cache field, so `cacheReadTokens` is a partial sum that must + * be dropped rather than labelled complete. */ + cacheReadIncomplete?: boolean } import type { OptimizeResult } from './optimize.js' import { getCurrency } from './currency.js' @@ -283,10 +292,10 @@ export type MenubarPayload = { /// provider name (round-trips as `--provider`), `label` the display name, /// and `hasUsage` the period-activity signal used by provider pickers. /// The `providers` map keys stay lowercased display names for compatibility. - /// `inputTokens`, `outputTokens` and `sessions` are add-only and optional: - /// they are omitted when the period carries no per-provider breakdown for - /// them, so a consumer must render the absence rather than substitute a - /// period-wide figure. + /// `inputTokens`, `outputTokens`, `sessions` and `cacheReadTokens` are + /// add-only and optional: they are omitted when the period carries no + /// per-provider breakdown for them, so a consumer must render the absence + /// rather than substitute a period-wide figure. providerDetails: Array<{ id: string label: string @@ -296,6 +305,7 @@ export type MenubarPayload = { inputTokens?: number outputTokens?: number sessions?: number + cacheReadTokens?: number }> topProjects: Array<{ name: string @@ -513,6 +523,7 @@ function buildProviderDetails(providers: ProviderCost[]): MenubarPayload['curren ...(p.inputTokens === undefined ? {} : { inputTokens: p.inputTokens }), ...(p.outputTokens === undefined ? {} : { outputTokens: p.outputTokens }), ...(p.sessions === undefined ? {} : { sessions: p.sessions }), + ...(p.cacheReadTokens === undefined || p.cacheReadIncomplete ? {} : { cacheReadTokens: p.cacheReadTokens }), })) } diff --git a/src/status-snapshot-semantic.ts b/src/status-snapshot-semantic.ts new file mode 100644 index 000000000..796ebf2cb --- /dev/null +++ b/src/status-snapshot-semantic.ts @@ -0,0 +1,27 @@ +import { DAILY_CACHE_VERSION } from './daily-cache.js' + +/// Bump when the menubar payload's rendering semantics change without a +/// package release or daily-cache version change. The envelope version in +/// session-cache protects record shape; this protects the meaning of an +/// otherwise valid one. Each revision must be distinct from every OTHER +/// branch's revision: a snapshot written by a different change must not be +/// accepted here while lacking this change's fields. +/// v5: providerDetails carries per-provider tokens and sessions, which a v4 +/// record predates — the dock glance would read a provider as having no +/// token breakdown purely because the snapshot was written before that. +/// v6: taken by PR1265 (per-model counts). +/// v7: providerDetails also carries per-provider cacheReadTokens, which a v6 +/// record predates — the dock's cache-read row would stay hidden behind a +/// warm snapshot even once the live payload had the data. +export const STATUS_SNAPSHOT_RENDER_VERSION = 7 + +/// The semantic key recorded on every status snapshot. A snapshot whose stored +/// key differs (an older render revision, or a different daily-cache version) +/// is rejected by `loadStatusSnapshot` and recomputed exactly once, then +/// reused stably under the new key. +export function statusSnapshotSemanticKey( + version: string, + renderVersion: number = STATUS_SNAPSHOT_RENDER_VERSION, +): string { + return `${version}:render-${renderVersion}:daily-${DAILY_CACHE_VERSION}` +} diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index ff273f239..2738429c7 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -31,6 +31,12 @@ export type ProviderSliceTotal = { inputTokens?: number outputTokens?: number sessions?: number + cacheReadTokens?: number + /// True when at least one active day slice in this fold carried no + /// per-provider cache accounting (a day finalized before the field existed). + /// The caller uses it to keep the period total UNKNOWN rather than a partial + /// known sum: an incomplete total must not masquerade as a complete one. + cacheReadIncomplete?: boolean } /// Folds one day's provider slice into the period total. Tokens and sessions are @@ -45,6 +51,15 @@ export function addProviderSlice(totals: Record, nam if (slice.inputTokens !== undefined) total.inputTokens = (total.inputTokens ?? 0) + slice.inputTokens if (slice.outputTokens !== undefined) total.outputTokens = (total.outputTokens ?? 0) + slice.outputTokens if (slice.sessions !== undefined) total.sessions = (total.sessions ?? 0) + slice.sessions + if (slice.cacheReadTokens !== undefined) { + total.cacheReadTokens = (total.cacheReadTokens ?? 0) + slice.cacheReadTokens + } else if (providerSliceHasUsage(slice)) { + // An active day recorded before per-provider cache accounting has no + // cache read to contribute; an idle day (no usage) is a genuine zero and + // leaves the total alone. Marking incomplete here lets the consumer drop + // the partial sum rather than label it complete. + total.cacheReadIncomplete = true + } totals[name] = total } @@ -59,6 +74,37 @@ export function providerSliceHasUsage(slice: ProviderDaySlice): boolean { || (slice.cacheWriteTokens ?? 0) > 0 } +/// Preserve the optional cache-read contract when a provider-scoped durable +/// query projects a day down to one provider. `sliceDayToProvider` keeps the +/// original provider slice under `day.providers`, while the day-level numeric +/// fields use zero-compatible legacy defaults for the older aggregates. The +/// provider detail must inspect that slice directly or an active legacy row +/// with no cache field would become a fabricated known zero. +function cacheReadForProviderDays(days: DailyEntry[], provider: string): Pick { + let cacheReadTokens = 0 + let hasCacheReadValue = false + let cacheReadIncomplete = false + for (const day of days) { + const slice = day.providers[provider] + if (!slice) continue + // An explicit zero is a real known value even when the rest of the slice + // is idle (for example, a configured provider with a finalized zero row). + // Check field presence before the activity predicate so scoped queries do + // not turn that contract value into unknown. + if (slice.cacheReadTokens !== undefined) { + cacheReadTokens += slice.cacheReadTokens + hasCacheReadValue = true + continue + } + if (!providerSliceHasUsage(slice)) continue + cacheReadIncomplete = true + } + return { + ...(hasCacheReadValue ? { cacheReadTokens } : {}), + ...(cacheReadIncomplete ? { cacheReadIncomplete: true } : {}), + } +} + export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { const sessions = projects.flatMap(p => p.sessions) @@ -921,6 +967,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: if (sources.length > 0) providers.push({ name: p.name, displayName: p.displayName, cost: 0, calls: 0, hasUsage: false }) } } else { + const providerCacheRead = cacheReadForProviderDays(cacheDaysForPeriod ?? [], pf) providers.push({ name: pf, displayName: displayNameByName.get(pf) ?? pf, @@ -931,6 +978,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: inputTokens: currentData.inputTokens, outputTokens: currentData.outputTokens, sessions: currentData.sessions, + ...providerCacheRead, hasUsage: currentData.cost > 0 || currentData.savingsUSD > 0 || currentData.calls > 0 diff --git a/tests/cli-cache-read-pipeline.test.ts b/tests/cli-cache-read-pipeline.test.ts new file mode 100644 index 000000000..ef2356714 --- /dev/null +++ b/tests/cli-cache-read-pipeline.test.ts @@ -0,0 +1,284 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { mkdtemp } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { DAILY_CACHE_VERSION } from '../src/daily-cache.js' +import { getDailyCacheConfigHash } from '../src/usage-aggregator.js' + +type ProviderSeed = { + calls: number + cost: number + sessions: number + inputTokens: number + outputTokens: number + cacheReadTokens?: number +} + +function dateStringUtc(date: Date): string { + return date.toISOString().slice(0, 10) +} + +function dayAtUtcOffset(offset: number): string { + const now = new Date() + const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + return dateStringUtc(new Date(today - offset * 24 * 60 * 60 * 1000)) +} + +function seededDay(date: string, providers: Record) { + const rows = Object.values(providers) + return { + date, + cost: rows.reduce((sum, row) => sum + row.cost, 0), + savingsUSD: 0, + calls: rows.reduce((sum, row) => sum + row.calls, 0), + sessions: rows.reduce((sum, row) => sum + row.sessions, 0), + inputTokens: rows.reduce((sum, row) => sum + row.inputTokens, 0), + outputTokens: rows.reduce((sum, row) => sum + row.outputTokens, 0), + cacheReadTokens: rows.reduce((sum, row) => sum + (row.cacheReadTokens ?? 0), 0), + cacheWriteTokens: 0, + editTurns: 0, + oneShotTurns: 0, + models: {}, + categories: {}, + providers, + } +} + +function runCli(args: string[], home: string, extraEnv: Record = {}) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), + CODEX_HOME: join(home, '.codex'), + KIMI_CODE_HOME: join(home, '.kimi'), + CODEBURN_DESKTOP_SESSIONS_DIR: join(home, '.desktop-sessions'), + TZ: 'UTC', + ...extraEnv, + }, + encoding: 'utf-8', + timeout: 60_000, + }) +} + +function detail(payload: { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number; hasUsage?: boolean }> } }, id: string) { + return payload.current.providerDetails.find(row => row.id === id) +} + +describe('status menubar cache-read pipeline', () => { + it('combines fresh and durable provider slices while honoring selected dates and unknown legacy fields', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cache-read-pipeline-')) + const knownDate = dayAtUtcOffset(10) + const excludedDate = dayAtUtcOffset(9) + const partialDate = dayAtUtcOffset(8) + const todayDate = dayAtUtcOffset(0) + + try { + await mkdir(join(home, '.claude', 'projects', 'fresh-project'), { recursive: true }) + await mkdir(join(home, '.codex'), { recursive: true }) + await mkdir(join(home, '.kimi'), { recursive: true }) + await mkdir(join(home, '.desktop-sessions'), { recursive: true }) + await mkdir(join(home, '.cache', 'codeburn'), { recursive: true }) + + const freshTimestamp = new Date(Date.now() - 10 * 60_000).toISOString() + await writeFile( + join(home, '.claude', 'projects', 'fresh-project', 'fresh.jsonl'), + [ + JSON.stringify({ + type: 'user', + sessionId: 'fresh-cache-session', + timestamp: freshTimestamp, + message: { role: 'user', content: 'exercise the durable cache path' }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'fresh-cache-session', + timestamp: new Date(Date.now() - 9 * 60_000).toISOString(), + message: { + id: 'fresh-cache-message', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { + input_tokens: 500, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 700, + }, + }, + }), + ].join('\n') + '\n', + ) + + const cache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: getDailyCacheConfigHash(), + tzKey: 'UTC', + lastComputedDate: dayAtUtcOffset(1), + complete: true, + days: [ + seededDay(knownDate, { + claude: { calls: 2, cost: 10, sessions: 1, inputTokens: 100, outputTokens: 20, cacheReadTokens: 1111 }, + codex: { calls: 3, cost: 20, sessions: 1, inputTokens: 200, outputTokens: 30, cacheReadTokens: 2222 }, + // Explicit zero with no activity: this is a known zero, not an + // absent legacy field, and must survive a provider-scoped query. + gemini: { calls: 0, cost: 0, sessions: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, + hermes: { calls: 1, cost: 40, sessions: 1, inputTokens: 400, outputTokens: 50 }, + }), + // This day is deliberately inside the selected range but omitted by + // --days below. Its values prove that provider/date selection happens + // after durable loading, rather than selecting a whole cache file. + seededDay(excludedDate, { + claude: { calls: 2, cost: 11, sessions: 1, inputTokens: 110, outputTokens: 21, cacheReadTokens: 9001 }, + codex: { calls: 3, cost: 21, sessions: 1, inputTokens: 210, outputTokens: 31, cacheReadTokens: 9002 }, + gemini: { calls: 1, cost: 31, sessions: 1, inputTokens: 310, outputTokens: 41, cacheReadTokens: 9003 }, + hermes: { calls: 1, cost: 41, sessions: 1, inputTokens: 410, outputTokens: 51, cacheReadTokens: 9004 }, + }), + // Included alongside knownDate: this gives Hermes one known row and + // one active legacy-missing row, so a partial sum must stay unknown. + seededDay(partialDate, { + hermes: { calls: 1, cost: 42, sessions: 1, inputTokens: 420, outputTokens: 52, cacheReadTokens: 3333 }, + }), + ], + } + await writeFile( + join(home, '.cache', 'codeburn', `daily-cache.v${DAILY_CACHE_VERSION}.json`), + JSON.stringify(cache), + 'utf-8', + ) + + const args = [ + 'status', '--format', 'menubar-json', '--provider', 'all', '--days', `${knownDate},${partialDate},${todayDate}`, + '--no-optimize', '--no-timeline', + ] + const all = runCli(args, home) + expect(all.status, `stderr: ${all.stderr}`).toBe(0) + const allPayload = JSON.parse(all.stdout) as { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number; hasUsage?: boolean }> } } + + // Historical Claude (1111) + the real fresh parse (700) are both present. + expect(detail(allPayload, 'claude')?.cacheReadTokens).toBe(1811) + // The selected day has a distinct durable Codex value, while the excluded + // day's 9002 never enters the total. + expect(detail(allPayload, 'codex')?.cacheReadTokens).toBe(2222) + expect(detail(allPayload, 'gemini')?.cacheReadTokens).toBe(0) // known zero + expect(detail(allPayload, 'hermes')).toMatchObject({ hasUsage: true }) + expect(detail(allPayload, 'hermes')).not.toHaveProperty('cacheReadTokens') // active legacy missing + + const selectedClaude = runCli([ + 'status', '--format', 'menubar-json', '--provider', 'claude', '--days', `${knownDate},${partialDate},${todayDate}`, + '--no-optimize', '--no-timeline', + ], home) + expect(selectedClaude.status, `stderr: ${selectedClaude.stderr}`).toBe(0) + const claudePayload = JSON.parse(selectedClaude.stdout) as { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> } } + expect(detail(claudePayload, 'claude')?.cacheReadTokens).toBe(1811) + + const selectedCodex = runCli([ + 'status', '--format', 'menubar-json', '--provider', 'codex', '--days', `${knownDate},${partialDate},${todayDate}`, + '--no-optimize', '--no-timeline', + ], home) + expect(selectedCodex.status, `stderr: ${selectedCodex.stderr}`).toBe(0) + const codexPayload = JSON.parse(selectedCodex.stdout) as { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> } } + expect(detail(codexPayload, 'codex')?.cacheReadTokens).toBe(2222) + + const selectedGemini = runCli([ + 'status', '--format', 'menubar-json', '--provider', 'gemini', '--days', `${knownDate},${partialDate},${todayDate}`, + '--no-optimize', '--no-timeline', + ], home) + expect(selectedGemini.status, `stderr: ${selectedGemini.stderr}`).toBe(0) + const geminiPayload = JSON.parse(selectedGemini.stdout) as { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number; hasUsage?: boolean }> } } + expect(detail(geminiPayload, 'gemini')).toMatchObject({ hasUsage: false, cacheReadTokens: 0 }) + + const selectedHermes = runCli([ + 'status', '--format', 'menubar-json', '--provider', 'hermes', '--days', `${knownDate},${partialDate},${todayDate}`, + '--no-optimize', '--no-timeline', + ], home) + expect(selectedHermes.status, `stderr: ${selectedHermes.stderr}`).toBe(0) + const hermesPayload = JSON.parse(selectedHermes.stdout) as { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number; hasUsage?: boolean }> } } + expect(detail(hermesPayload, 'hermes')).toMatchObject({ hasUsage: true }) + expect(detail(hermesPayload, 'hermes')).not.toHaveProperty('cacheReadTokens') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, 120_000) + + it('keeps fresh cache reads in a selected Claude config provider detail', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-cache-read-config-scope-')) + const work = join(home, 'claude-work') + const personal = join(home, 'claude-personal') + const base = new Date(Date.now() - 10 * 60_000) + const ts = (offset: number) => new Date(base.getTime() + offset).toISOString() + const assistant = (sessionId: string, timestamp: string, cacheRead: number) => JSON.stringify({ + type: 'assistant', + sessionId, + timestamp, + message: { + id: `${sessionId}-assistant`, + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { + input_tokens: 500, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: cacheRead, + }, + }, + }) + const sourceEnv = { + CLAUDE_CONFIG_DIR: '', + CLAUDE_CONFIG_DIRS: `${work}:${personal}`, + } + + try { + await mkdir(join(work, 'projects', 'selected'), { recursive: true }) + await mkdir(join(personal, 'projects', 'other'), { recursive: true }) + await mkdir(join(home, '.cache', 'codeburn'), { recursive: true }) + await writeFile( + join(work, 'projects', 'selected', 'work.jsonl'), + [ + JSON.stringify({ type: 'user', sessionId: 'selected-session', timestamp: ts(0), message: { role: 'user', content: 'fixture' } }), + assistant('selected-session', ts(60_000), 4321), + ].join('\n') + '\n', + ) + await writeFile( + join(personal, 'projects', 'other', 'personal.jsonl'), + [ + JSON.stringify({ type: 'user', sessionId: 'other-session', timestamp: ts(0), message: { role: 'user', content: 'fixture' } }), + assistant('other-session', ts(60_000), 9876), + ].join('\n') + '\n', + ) + + const all = runCli([ + 'status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize', '--no-timeline', + ], home, sourceEnv) + expect(all.status, `stderr: ${all.stderr}`).toBe(0) + const allPayload = JSON.parse(all.stdout) as { + claudeConfigs?: { options: Array<{ id: string; label: string }> } + } + const selectedId = allPayload.claudeConfigs?.options.find(option => option.label === 'claude-work')?.id + expect(selectedId).toBeTruthy() + + const selected = runCli([ + 'status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', + '--claude-config-source', selectedId!, '--no-optimize', '--no-timeline', + ], home, sourceEnv) + expect(selected.status, `stderr: ${selected.stderr}`).toBe(0) + const selectedPayload = JSON.parse(selected.stdout) as { + current: { cacheReadTokens: number; providerDetails: Array<{ id: string; cacheReadTokens?: number }> } + } + expect(selectedPayload.current.cacheReadTokens).toBe(4321) + expect(detail(selectedPayload, 'claude')?.cacheReadTokens).toBe(4321) + } finally { + await rm(home, { recursive: true, force: true }) + } + }, 120_000) +}) diff --git a/tests/cli-status-menubar.test.ts b/tests/cli-status-menubar.test.ts index f3bfa9035..cb53018a9 100644 --- a/tests/cli-status-menubar.test.ts +++ b/tests/cli-status-menubar.test.ts @@ -888,6 +888,123 @@ describe('codeburn status --format menubar-json', () => { } }) + it('carries per-provider cache read through the parse path', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-cache-read-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'myapp') + await mkdir(projectDir, { recursive: true }) + const now = new Date() + const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000)) + const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') + const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') + + await writeFile( + join(projectDir, 'session.jsonl'), + [ + userLine('s1', ts1), + JSON.stringify({ + type: 'assistant', + sessionId: 's1', + timestamp: ts2, + message: { + id: 'msg-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { input_tokens: 500, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 400 }, + }, + }), + ].join('\n'), + ) + + const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize'] + const result = runCli(args, home) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + const payload = JSON.parse(result.stdout) as { + current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> } + } + expect(payload.current.providerDetails.find(provider => provider.id === 'claude')?.cacheReadTokens).toBe(400) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('recomputes a snapshot from the previous render revision, then reuses it stably', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-cache-render-')) + + try { + const projectDir = join(home, '.claude', 'projects', 'myapp') + await mkdir(projectDir, { recursive: true }) + const now = new Date() + const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000)) + const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z') + const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z') + + await writeFile( + join(projectDir, 'session.jsonl'), + [ + userLine('s1', ts1), + JSON.stringify({ + type: 'assistant', + sessionId: 's1', + timestamp: ts2, + message: { + id: 'msg-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { input_tokens: 500, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 400 }, + }, + }), + ].join('\n'), + ) + + const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize'] + + // First run writes a snapshot under the current (v7) semantic key. + const first = runCli(args, home) + expect(first.status, `stderr: ${first.stderr}`).toBe(0) + + const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn')) + expect(snapshotFiles).toHaveLength(1) + const record = JSON.parse(await readFile(snapshotFiles[0]!, 'utf-8')) as { + semanticKey: string + payload: { current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> } } + } + // Downgrade to the PREVIOUS render revision (v6, taken by PR1265) and + // strip the cache field, simulating a warm snapshot from that branch: + // it must be rejected rather than served as if it had this data. + record.semanticKey = record.semanticKey.replace(/:render-\d+:/, ':render-6:') + for (const row of record.payload.current.providerDetails) delete row.cacheReadTokens + await writeFile(snapshotFiles[0]!, JSON.stringify(record)) + + // Recompute: the v6 record is rejected and rebuilt with cache data. + const second = runCli(args, home) + expect(second.status, `stderr: ${second.stderr}`).toBe(0) + const rebuilt = JSON.parse(second.stdout) as { + current: { providerDetails: Array<{ id: string; cacheReadTokens?: number }> } + } + expect(rebuilt.current.providerDetails.find(provider => provider.id === 'claude')?.cacheReadTokens).toBe(400) + + // Stable reuse: the recomputed v7 snapshot is served as-is. Inject a + // sentinel into the on-disk payload (keeping the v7 key and fingerprint) + // and confirm the next run returns it rather than recomputing. + const files = findSnapshotFiles(join(home, '.cache', 'codeburn')) + const fresh = JSON.parse(await readFile(files[0]!, 'utf-8')) as { + semanticKey: string + payload: { sentinel?: string } + } + expect(fresh.semanticKey).toContain(':render-7:') + fresh.payload.sentinel = 'reused-v7' + await writeFile(files[0]!, JSON.stringify(fresh)) + + const third = runCli(args, home) + expect(third.status, `stderr: ${third.stderr}`).toBe(0) + expect(JSON.parse(third.stdout)).toHaveProperty('sentinel', 'reused-v7') + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + it('reprices from an updated live LiteLLM cache instead of serving a stale snapshot', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-pricing-gen-')) diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index fe96a4407..fa021eefa 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -281,6 +281,44 @@ describe('buildMenubarPayload', () => { ]) }) + it('carries per-provider cache read into providerDetails', () => { + const providers: ProviderCost[] = [ + { name: 'claude', displayName: 'Claude', cost: 190.1, calls: 900, hasUsage: true, inputTokens: 6_000_000, outputTokens: 250_000, sessions: 12, cacheReadTokens: 4_200_000 }, + { name: 'codex', displayName: 'Codex', cost: 88.84, calls: 642, hasUsage: true, inputTokens: 3_000_000, outputTokens: 150_000, sessions: 4, cacheReadTokens: 0 }, + ] + const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null) + expect(payload.current.providerDetails).toEqual([ + { id: 'claude', label: 'Claude', cost: 190.1, calls: 900, hasUsage: true, inputTokens: 6_000_000, outputTokens: 250_000, sessions: 12, cacheReadTokens: 4_200_000 }, + { id: 'codex', label: 'Codex', cost: 88.84, calls: 642, hasUsage: true, inputTokens: 3_000_000, outputTokens: 150_000, sessions: 4, cacheReadTokens: 0 }, + ]) + }) + + it('omits the cache-read key entirely when no day reported it', () => { + // Add-only contract: absent means unknown, not zero, so a legacy row must + // stay absent rather than being emitted as 0. + const payload = buildMenubarPayload( + emptyPeriod('Today'), + [{ name: 'claude', displayName: 'Claude', cost: 190.1, calls: 900, hasUsage: true, inputTokens: 6_000_000, outputTokens: 250_000, sessions: 12 }], + null, + ) + expect(payload.current.providerDetails).toEqual([ + { id: 'claude', label: 'Claude', cost: 190.1, calls: 900, hasUsage: true, inputTokens: 6_000_000, outputTokens: 250_000, sessions: 12 }, + ]) + expect(Object.keys(payload.current.providerDetails[0]!)).not.toContain('cacheReadTokens') + }) + + it('drops a partial cache-read sum when an active day lacked counts', () => { + // A fold that summed some days but missed a legacy active day must not be + // labelled complete: the emitter omits the key rather than publish a + // partial number. + const payload = buildMenubarPayload( + emptyPeriod('Today'), + [{ name: 'claude', displayName: 'Claude', cost: 190.1, calls: 900, hasUsage: true, cacheReadTokens: 4_200_000, cacheReadIncomplete: true }], + null, + ) + expect(Object.keys(payload.current.providerDetails[0]!)).not.toContain('cacheReadTokens') + }) + it('omits the token and session keys entirely when the period has no breakdown', () => { // Add-only contract: a consumer must be able to tell "no breakdown" from // zero, so absent stays absent rather than being emitted as 0. diff --git a/tests/usage-aggregator.test.ts b/tests/usage-aggregator.test.ts index a88185553..df6063ddf 100644 --- a/tests/usage-aggregator.test.ts +++ b/tests/usage-aggregator.test.ts @@ -52,15 +52,15 @@ describe('addProviderSlice', () => { addProviderSlice(totals, 'claude', { cost: 2.5, calls: 1, savingsUSD: 0, inputTokens: 50, outputTokens: 5, sessions: 1 }) addProviderSlice(totals, 'codex', { cost: 1, calls: 3, savingsUSD: 0, inputTokens: 7, outputTokens: 3, sessions: 1 }) - expect(totals.claude).toEqual({ cost: 12.5, calls: 5, hasUsage: true, inputTokens: 150, outputTokens: 25, sessions: 3 }) - expect(totals.codex).toEqual({ cost: 1, calls: 3, hasUsage: true, inputTokens: 7, outputTokens: 3, sessions: 1 }) + expect(totals.claude).toEqual({ cost: 12.5, calls: 5, hasUsage: true, inputTokens: 150, outputTokens: 25, sessions: 3, cacheReadIncomplete: true }) + expect(totals.codex).toEqual({ cost: 1, calls: 3, hasUsage: true, inputTokens: 7, outputTokens: 3, sessions: 1, cacheReadIncomplete: true }) }) it('leaves tokens absent (not zero) when no day carried a breakdown', () => { const totals: Record = {} // A day finalized before per-provider tokens were cached. addProviderSlice(totals, 'claude', { cost: 3, calls: 2, savingsUSD: 0 }) - expect(totals.claude).toEqual({ cost: 3, calls: 2, hasUsage: true }) + expect(totals.claude).toEqual({ cost: 3, calls: 2, hasUsage: true, cacheReadIncomplete: true }) expect(totals.claude!.inputTokens).toBeUndefined() expect(totals.claude!.outputTokens).toBeUndefined() expect(totals.claude!.sessions).toBeUndefined() @@ -68,13 +68,41 @@ describe('addProviderSlice', () => { // One day that does report them makes the total reportable again, counting // only what was actually reported. addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, inputTokens: 9, outputTokens: 4 }) - expect(totals.claude).toEqual({ cost: 4, calls: 3, hasUsage: true, inputTokens: 9, outputTokens: 4 }) + expect(totals.claude).toEqual({ cost: 4, calls: 3, hasUsage: true, inputTokens: 9, outputTokens: 4, cacheReadIncomplete: true }) }) it('keeps a token-only day visible as usage', () => { const totals: Record = {} addProviderSlice(totals, 'hermes', { cost: 0, calls: 0, savingsUSD: 0, inputTokens: 12, outputTokens: 0 }) - expect(totals.hermes).toEqual({ cost: 0, calls: 0, hasUsage: true, inputTokens: 12, outputTokens: 0 }) + expect(totals.hermes).toEqual({ cost: 0, calls: 0, hasUsage: true, inputTokens: 12, outputTokens: 0, cacheReadIncomplete: true }) + }) + + it('sums cache read per provider, keeping zero and absence distinct', () => { + const totals: Record = {} + addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 100 }) + addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 50 }) + addProviderSlice(totals, 'codex', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 0 }) + addProviderSlice(totals, 'grok', { cost: 1, calls: 1, savingsUSD: 0 }) + + expect(totals.claude!.cacheReadTokens).toBe(150) + expect(totals.claude!.cacheReadIncomplete).toBeUndefined() + expect(totals.codex!.cacheReadTokens).toBe(0) // a reported zero, not unknown + expect(totals.grok!.cacheReadTokens).toBeUndefined() // absent, not zero + }) + + it('marks the total incomplete when an active day lacks cache read', () => { + const totals: Record = {} + addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 100 }) + addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0 }) + expect(totals.claude!.cacheReadIncomplete).toBe(true) + }) + + it('does not mark incomplete when only idle days lack cache read', () => { + const totals: Record = {} + addProviderSlice(totals, 'claude', { cost: 1, calls: 1, savingsUSD: 0, cacheReadTokens: 100 }) + addProviderSlice(totals, 'claude', { cost: 0, calls: 0, savingsUSD: 0 }) + expect(totals.claude!.cacheReadIncomplete).toBeUndefined() + expect(totals.claude!.cacheReadTokens).toBe(100) }) }) From 98ad43fe8d0d69195fdfbe20d89b65284f5d064d Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:33:55 +0300 Subject: [PATCH 3/5] Use platform path delimiter in cache-read integration test --- tests/cli-cache-read-pipeline.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cli-cache-read-pipeline.test.ts b/tests/cli-cache-read-pipeline.test.ts index ef2356714..c108b4b94 100644 --- a/tests/cli-cache-read-pipeline.test.ts +++ b/tests/cli-cache-read-pipeline.test.ts @@ -2,7 +2,7 @@ import { mkdir, rm, writeFile } from 'node:fs/promises' import { mkdtemp } from 'node:fs/promises' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { delimiter as pathDelimiter, join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -235,7 +235,7 @@ describe('status menubar cache-read pipeline', () => { }) const sourceEnv = { CLAUDE_CONFIG_DIR: '', - CLAUDE_CONFIG_DIRS: `${work}:${personal}`, + CLAUDE_CONFIG_DIRS: [work, personal].join(pathDelimiter), } try { From 610cae48c79edb6dbdc0926ac98fb7265cc6a168 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:32:14 +0300 Subject: [PATCH 4/5] feat(menubar): plain pace verdict, also on the agent-tab quota card Ports the two parts of #1287 the maintainer asked to keep, expressed through this branch's `QuotaPacePresentation` rather than through `QuotaPace.Verdict`: - Placement: `QuotaDetailRow` in the agent-tab quota hover card becomes a VStack and draws the pace line under the bar, indented past the label column, with the three tones the dock already uses. The card is fitted, so a silent caption collapses there instead of reserving a slot the way the dock's computed frame must. - Wording: the long-window caption drops "est. N% at reset" / "est. out in X" for the plain "Lasts until reset" / "Runs out in X", and the exhausted state becomes "Limit reached". The projected percentage it came from stays in the hover text, which has room for the reasoning. Short windows keep the on-pace / deficit / reserve stage (#726), and the freshness, connection and duration gates are untouched. `QuotaPace.inferredWindowSeconds(label:)` is deliberately NOT ported. Grok Build chooses its window label from the distance to the reset, so a monthly cycle passing through the 4-12 day band is labeled "Weekly" and a label round trip paces a month's budget against seven days. The adapter's real `windowSeconds` is the only source here, and a new test pins that shape: 72% used with six days to a monthly reset reads "Lasts until reset", while the same sample against an inferred 7-day window overflows and would have printed "Runs out in 9h 20m". The same test asserts a window with no duration metadata stays silent rather than falling back to its label. Verified with a standalone swiftc harness over QuotaPace, QuotaPacePresentation and QuotaSummary: this host has Command Line Tools only, so `swift test` cannot load the Testing module. Closes #1267 --- CHANGELOG.md | 1 + docs/design/capacity-dock.md | 14 +++ .../Data/QuotaPacePresentation.swift | 33 ++++--- .../CodeBurnMenubar/Views/AgentTabStrip.swift | 86 ++++++++++++++----- .../CapacityDockPacePresentationTests.swift | 71 +++++++++++++-- 5 files changed, 160 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c634d971..6869b8aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added (macOS) +- **The Capacity Dock shows today's cache-read tokens and tells you whether each quota window will last to its reset.** The Today section gains a provider-scoped cache-read figure beside input, output and calls; a known zero prints as `0` while missing or incomplete historical accounting stays unknown rather than becoming a fabricated zero, and because cache reads were already priced into the burned figure this adds visibility without changing any total. Each quota window then gets one line under it: `Lasts until reset`, `Runs out in 2d 8h`, or — on windows of six hours or less, where one burst would make a linear ETA cry wolf — the pace stage the Plan tab uses (`On pace`, `40% in deficit`, `30% in reserve`). The same line appears under each bar in the agent-tab quota hover card. The projection runs against the window length the provider adapter reports, never a length guessed from the display label, so a monthly cycle whose label happens to read `Weekly` is still paced against its month; it stays silent early in a window, on an exhausted window, without a reset time or a validated duration, and on stale, disconnected or older-than-ten-minute data. Four quota windows move to a two-column grid so scope labels, reset times and captions stay readable, and the dock reserves the caption's height whether or not a column has one so the bubble cannot resize under the pointer. Status snapshot revision 7 invalidates older cached payloads without purging daily history, and no extra polling is introduced. (#1267) - **The macOS menubar item can show a second line.** Settings → General → Display gains a "Second row" switch, off by default, and a picker for what that line shows: quota remaining with its reset countdown for whichever connected provider is nearest its limit, today's all-provider cost, today's total tokens, or the number of running sessions. Both lines render as one attributed title at 9pt with their line height clamped to 10pt, so the pair fits the standard 22pt menu bar, and the second line hides itself whenever its metric has no data yet, leaving the existing single-row figure exactly as it was. This is a deliberately small first slice of the multi-row layout request: no layout editor, no presets, no live preview, no per-item provider or period scoping. The setting persists as `CodeBurnMenubarSecondRowEnabled` and `CodeBurnMenubarSecondRowMetric` in the app's own defaults domain alongside the existing menubar period, scope and metric keys. (#1252) - **The Capacity Dock gauge can report the short usage window instead of the weekly one, without expanding the dock.** The resting rail shows one number per provider, and that number was always the weekly (else monthly) billing window. Clicking the provider already resting in the rail now switches its gauge to the provider's short rolling window — Claude's 5-hour limit, Codex's 5-hour or daily window, any `Hourly`, `Daily` or session row an adapter reports — and clicking again switches back. The choice is stored per provider under `CodeBurnCapacityDockGlanceWindows`, so Claude can sit on its 5-hour window while Codex stays weekly, and it survives relaunch. A per-model row such as `Weekly · Opus` is never read as a short window, a provider that reports only one window keeps the plain click-to-pin behaviour, and a stored horizon the provider stops reporting falls back to the window it does report rather than blanking the gauge to `--`. The rail's geometry is untouched, VoiceOver and keyboard users get the switch as a named action on the provider cell with the window named in the cell's value, and Escape or a click outside still unpins the dock. (#1243) diff --git a/docs/design/capacity-dock.md b/docs/design/capacity-dock.md index fb76371f2..2d273ff25 100644 --- a/docs/design/capacity-dock.md +++ b/docs/design/capacity-dock.md @@ -112,6 +112,20 @@ V1 does not include: countdown. The most constrained available window supplies the ring value; this matches the reference's glance-first use and avoids understating a provider whose secondary window is closer to exhaustion. +- Under that, one line says whether the window lasts: `Lasts until reset` or + `Runs out in 2d 8h`, and on windows of six hours or less, where a linear + run-out ETA is not defensible off a single burst, the pace stage instead + (`On pace`, `40% in deficit`, `30% in reserve`). It is the same whole-window + projection the Plan tab's caption uses, computed against the window length + the adapter reports — never a length inferred from the display label, which + mislabels any provider that picks its label from the distance to the reset. + It is silent early in a window, on an exhausted window, without a reset time, + without a validated duration, and on data that is stale, disconnected or + older than the ten-minute freshness horizon. The dock reserves its height + whether or not a column has a caption, because the panel's frame is computed + rather than fitted; the agent-tab quota hover card draws the same line under + its bar, indented past the label column, and simply omits it when silent + because that card is fitted. - Stale or retrying data remains visible and is labeled/dimmed. A terminal authentication/configuration failure provides a Connect/Reconnect action in the bubble itself. Network, rate-limit, parse, and provider outages remain diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift index 54009fd33..85bf7866a 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaPacePresentation.swift @@ -1,6 +1,7 @@ import Foundation -/// Turns a quota window into the Capacity Dock's one-line pace caption: the +/// Turns a quota window into the one-line pace caption the Capacity Dock's +/// window columns and the agent-tab quota hover card both draw (#1215): the /// whole-window average interpretation `QuotaPace` defends (#726 phase 1), /// rendered as "on pace", a deficit/reserve stage, an estimated exhaustion, /// or the explicit exhausted state. Deliberately text-only: the math lives in @@ -89,7 +90,7 @@ enum QuotaPacePresentation { return Line( kind: .exhausted, tone: .danger, - text: "limit reached", + text: "Limit reached", helpText: "This window's limit is fully used. It resets in \(countdownLabel(seconds: remaining))." ) } @@ -150,24 +151,28 @@ enum QuotaPacePresentation { } } - /// Compact caption. The estimate is the projection itself: an over-pace - /// window reads as "est. out in " and a window under pace - /// reads as "est. N% at reset", so the useful number always fits one - /// narrow column. On windows at or under - /// `QuotaPace.etaSuppressionMaxSeconds` there is no projection or ETA at - /// all — a linear read of a short window cries wolf after one burst — so - /// only the deficit/reserve stage shows. + /// Compact caption, phrased as the plain "am I going to make it?" answer + /// #1287 argued for rather than as a projection the reader has to decode: + /// a window that lands at or under the limit reads "Lasts until reset", + /// one that does not reads "Runs out in ". The projected + /// percentage it came from stays in `helpText`, where there is room for + /// the reasoning. On windows at or under + /// `QuotaPace.etaSuppressionMaxSeconds` there is no defensible ETA at all + /// — a linear read of a short window cries wolf after one burst — so + /// those keep the on-pace / deficit / reserve stage instead (#726). static func caption(for result: QuotaPace.Result, windowSeconds: Int, now: Date = Date()) -> String { let compact = TimeInterval(windowSeconds) <= QuotaPace.etaSuppressionMaxSeconds if compact { - if abs(result.deltaPercent) <= 2 { return "on pace" } + if abs(result.deltaPercent) <= 2 { return "On pace" } if result.deltaPercent > 0 { return "\(Int(result.deltaPercent.rounded()))% in deficit" } return "\(Int(-result.deltaPercent.rounded()))% in reserve" } - if result.willOverflow, let hitsLimitAt = result.hitsLimitAt { - return "est. out in \(countdownLabel(from: now, to: hitsLimitAt))" - } - return "est. \(Int(result.projectedPercent.rounded()))% at reset" + guard result.willOverflow else { return "Lasts until reset" } + // A long overflowing window always yields an ETA (a projection over + // 100% implies a positive rate), but if that ever stopped holding, + // saying it lasts would be the one wrong answer. + guard let hitsLimitAt = result.hitsLimitAt else { return "Won't last until reset" } + return "Runs out in \(countdownLabel(from: now, to: hitsLimitAt))" } private static func helpText( diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index a166cedda..dae264325 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -377,7 +377,14 @@ private struct QuotaDetailPopover: View { } private var rowsCard: some View { - VStack(alignment: .leading, spacing: 6) { + // Resolve every window's pace caption once, against this summary's + // connection and freshness, so the card and the Capacity Dock cannot + // disagree about a window and an exact duplicate is captioned once. + let paceLines = QuotaPacePresentation.lines( + for: quota.details, + connection: quota.connection + ) + return VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { Text("\(quota.providerFilter.rawValue) usage") .font(.system(size: 11, weight: .semibold)) @@ -409,8 +416,8 @@ private struct QuotaDetailPopover: View { .fixedSize(horizontal: true, vertical: false) } } - ForEach(Array(quota.details.enumerated()), id: \.offset) { _, w in - QuotaDetailRow(window: w) + ForEach(Array(quota.details.enumerated()), id: \.offset) { index, w in + QuotaDetailRow(window: w, paceLine: paceLines[index]) } if !quota.footerLines.isEmpty { Divider() @@ -446,33 +453,68 @@ private struct QuotaDetailPopover: View { private struct QuotaDetailRow: View { let window: QuotaSummary.Window + /// Whether this window lasts to its reset, already resolved against the + /// summary's connection and sample freshness by `QuotaPacePresentation`. + /// Nil means the row says nothing at all: this card is fitted, so a silent + /// caption collapses rather than leaving a gap — unlike the Capacity Dock, + /// whose frame is computed and therefore reserves the slot either way. + let paceLine: QuotaPacePresentation.Line? + + /// The label column's width. The caption hangs under the bar, so it is + /// indented past the label by this plus the row's own spacing. + private static let labelWidth: CGFloat = 92 + private static let rowSpacing: CGFloat = 8 var body: some View { - HStack(spacing: 8) { - Text(window.label) - .font(.system(size: 10.5)) - .frame(width: 92, alignment: .leading) - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(Color.secondary.opacity(0.18)) - Capsule() - .fill(barColor) - .frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1)))) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: Self.rowSpacing) { + Text(window.label) + .font(.system(size: 10.5)) + .frame(width: Self.labelWidth, alignment: .leading) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.18)) + Capsule() + .fill(barColor) + .frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1)))) + } + } + .frame(height: 4) + Text(window.percentLabel) + .font(.codeMono(size: 10.5, weight: .medium)) + .frame(width: 36, alignment: .trailing) + if !window.resetsInLabel.isEmpty { + Text(window.resetsInLabel) + .font(.codeMono(size: 10)) + .foregroundStyle(.secondary) + .frame(width: 50, alignment: .trailing) } } - .frame(height: 4) - Text(window.percentLabel) - .font(.codeMono(size: 10.5, weight: .medium)) - .frame(width: 36, alignment: .trailing) - if !window.resetsInLabel.isEmpty { - Text(window.resetsInLabel) - .font(.codeMono(size: 10)) - .foregroundStyle(.secondary) - .frame(width: 50, alignment: .trailing) + if let paceLine { + Text(paceLine.text) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(paceTint) + .lineLimit(1) + .padding(.leading, Self.labelWidth + Self.rowSpacing) + .help(paceLine.helpText) + .accessibilityLabel(paceLine.text) + .accessibilityHint(paceLine.helpText) } } } + /// Muted while the pace is healthy, amber for a deficit or a projected + /// overflow, red once the limit is actually reached — the same three + /// tones the dock's caption uses, so one surface cannot look calmer than + /// the other about the same window. + private var paceTint: AnyShapeStyle { + switch paceLine?.tone { + case .danger: return AnyShapeStyle(Color.red.opacity(0.92)) + case .warning: return AnyShapeStyle(Color.orange) + default: return AnyShapeStyle(.tertiary) + } + } + private var barColor: Color { switch QuotaSummary.severity(for: window.percent) { case .normal: return Color.green.opacity(0.85) diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityDockPacePresentationTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityDockPacePresentationTests.swift index c6f208183..5963d3682 100644 --- a/mac/Tests/CodeBurnMenubarTests/CapacityDockPacePresentationTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CapacityDockPacePresentationTests.swift @@ -52,9 +52,12 @@ struct CapacityDockPacePresentationTests { @Test("A window fraction is consumed as percent, not as a raw 0..1 value") func fractionBecomesPercent() { // 0.5 fraction at halfway through the week is 50% used — on pace. + // Read as a raw 0..1 value it would be 0.5% used, which projects to 1% + // at reset; the help text is where that number is still visible. let line = f.line(percent: 0.5, elapsedFraction: 0.5, windowSeconds: f.week) #expect(line?.kind == .estimate) - #expect(line?.text == "est. 100% at reset") + #expect(line?.text == "Lasts until reset") + #expect(line?.helpText.contains("Projected 100% used by the reset") == true) #expect(line?.tone == .neutral) } @@ -64,7 +67,7 @@ struct CapacityDockPacePresentationTests { // from `now`, while the reset itself is still 4d 4h away. let line = f.line(percent: 0.6, elapsedFraction: 0.4, windowSeconds: f.week) #expect(line?.kind == .estimate) - #expect(line?.text == "est. out in 1d 20h") + #expect(line?.text == "Runs out in 1d 20h") #expect(line?.tone == .warning) #expect(!(line?.text.contains("early") ?? false)) } @@ -85,7 +88,8 @@ struct CapacityDockPacePresentationTests { @Test("Behind pace stays in reserve with a projection, no alarm") func reserveStaysNeutral() { let line = f.line(percent: 0.2, elapsedFraction: 0.5, windowSeconds: f.week) - #expect(line?.text == "est. 40% at reset") + #expect(line?.text == "Lasts until reset") + #expect(line?.helpText.contains("30% of the window still in reserve") == true) #expect(line?.tone == .neutral) } @@ -97,7 +101,8 @@ struct CapacityDockPacePresentationTests { @Test("No usage yet reads as a zero projection, not as zero-signal") func noUsageYet() { let line = f.line(percent: 0.0, elapsedFraction: 0.5, windowSeconds: f.week) - #expect(line?.text == "est. 0% at reset") + #expect(line?.text == "Lasts until reset") + #expect(line?.helpText.contains("Projected 0% used by the reset") == true) #expect(line?.tone == .neutral) } @@ -105,7 +110,7 @@ struct CapacityDockPacePresentationTests { func exhaustedState() { let reached = f.line(percent: 1.0, elapsedFraction: 0.5, windowSeconds: f.week) #expect(reached?.kind == .exhausted) - #expect(reached?.text == "limit reached") + #expect(reached?.text == "Limit reached") #expect(reached?.tone == .danger) let window = f.window( "Weekly", 1.0, @@ -121,7 +126,9 @@ struct CapacityDockPacePresentationTests { #expect(line?.kind == .estimate) #expect(line?.text == "40% in deficit") #expect(line?.tone == .warning) - #expect(!(line?.text.contains("est.") ?? false)) + // Neither the long-window verdict nor an ETA may appear here. + #expect(!(line?.text.contains("Runs out") ?? false)) + #expect(!(line?.text.contains("until reset") ?? false)) } @Test("Stale, failed and disconnected data get nothing") @@ -211,7 +218,53 @@ struct CapacityDockPacePresentationTests { ) let line = QuotaPacePresentation.line(for: window, connection: .connected, now: f.now) #expect(line?.kind == .estimate) - #expect(line?.text == "est. 96% at reset") + #expect(line?.text == "Lasts until reset") + #expect(line?.helpText.contains("Projected 96% used by the reset") == true) + } + + @Test("A monthly cycle whose label reads Weekly paces against the month, not 7 days") + func grokBuildMonthlyCycleLabeledWeekly() { + // Grok Build picks its window label from the distance to the reset, so + // a monthly cycle sitting in the 4-12 day band is labeled "Weekly". + // Deriving the length from that label — the round trip #1287 used — + // paces a month's budget against 7 days. + let month = 30 * 24 * 3600 + let resetsAt = f.now.addingTimeInterval(6 * 24 * 3600) // inside the band + let window = f.window("Weekly", 0.72, resetsAt: resetsAt, windowSeconds: month) + + // 24 of 30 days elapsed at 72% used projects to 90%: it lasts. + let line = QuotaPacePresentation.line(for: window, connection: .connected, now: f.now) + #expect(line?.text == "Lasts until reset") + #expect(line?.tone == .neutral) + #expect(line?.helpText.contains("30-day window") == true) + + // The same sample against the label-inferred 7-day window is 1 of 7 + // days elapsed, which projects past 500% and would print an alarming + // run-out ETA on a healthy account. That is the reading the real + // `windowSeconds` must never produce. + let asWeekly = QuotaPace.evaluate( + usedPercent: 72, + resetsAt: resetsAt, + windowSeconds: 7 * 24 * 3600, + now: f.now + ) + #expect(asWeekly?.willOverflow == true) + #expect(line?.text != QuotaPacePresentation.caption( + for: asWeekly!, + windowSeconds: 7 * 24 * 3600, + now: f.now + )) + + // And with no duration metadata at all, the label stays a label: the + // caption is silent rather than falling back to inferring "Weekly". + let noDuration = QuotaSummary.Window( + label: window.label, + percent: window.percent, + resetsAt: resetsAt, + windowSeconds: nil, + fetchedAt: f.now + ) + #expect(QuotaPacePresentation.line(for: noDuration, connection: .connected, now: f.now) == nil) } @Test("Distinct scopes sharing a duration each keep their own caption") @@ -222,8 +275,8 @@ struct CapacityDockPacePresentationTests { let weekly = f.window("Weekly", 0.5, resetsAt: f.resets(afterElapsedFraction: 0.5, windowSeconds: f.week), windowSeconds: f.week) let opus = f.window("Weekly · Opus", 1.0, resetsAt: f.resets(afterElapsedFraction: 0.5, windowSeconds: f.week), windowSeconds: f.week) let lines = QuotaPacePresentation.lines(for: [weekly, opus], connection: .connected, now: f.now) - #expect(lines[0]?.text == "est. 100% at reset") - #expect(lines[1]?.text == "limit reached") + #expect(lines[0]?.text == "Lasts until reset") + #expect(lines[1]?.text == "Limit reached") } @Test("Same-duration windows with different reset dates both keep captions") From 8c3a6cc1b762af43278316ebe22fa0b89207f056 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:52:29 +0300 Subject: [PATCH 5/5] fix: single-pass session output, signed exact token text, cap disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the per-model token counts, addressed on the merged head. buildPeriodData walked every assistant call in every session twice: once through sessionBillableOutputTokens for the headline and again through sessionModelBillableOutputTokens for the per-model split. Both derive from the same traversal and the same sawUsage fallback decision, so fold them into one sessionBillableOutput(session) returning { total, byModel }; the two existing exports become thin wrappers, keeping their semantics for other callers. A call whose model resolves to no breakdown bucket still counts toward the session total and still lands in no row, exactly as before, so no total moves. The macOS accessibility text grouped a signed string, counting the minus as a leading digit and emitting it twice: a -1,234,567 count read as "--1,234,567", a different number to a screen reader. Group the magnitude and re-apply the sign; magnitude is unsigned, so Int.min no longer traps on negation either. Negative counts are not expected from the CLI, but the payload field is a plain Int and a corrupt or hand-edited snapshot carries whatever it carries. Two findings are disclosed rather than changed: * buildTopModels' unknown-vs-zero guard cannot fire on the durable path. ModelDayStats types the four counts as required numbers and daily-cache sanitizeModels runs each through num(), so a missing value becomes a known 0 and a carried day under-reports as exact instead of unknown. Making it reachable means widening ModelDayStats to optional counts and teaching every daily-cache arithmetic site (fold, subtract, reduce) plus buildPeriodDataFromDays to propagate absence — a durable-cache contract change, too broad to ride along here. The guard stays: PeriodData already types these counts optional, so fresh-session and plugin-sourced rows may legitimately omit them. * The all-provider Overview models table now reads current.topModels and so inherits the payload's 20-row TOP_MODELS_LIMIT, which its previous uncapped daily-history union did not have. Kept, because that union was itself a per-day top-five truncation whose tail rows were already partial sums, but the PR body's "the cap is unchanged" claim was wrong for this view and the trade-off is now recorded at the call site. --- CHANGELOG.md | 3 + app/renderer/sections/Overview.tsx | 8 +++ .../CodeBurnMenubar/Views/ModelsSection.swift | 6 +- .../ModelEntryTokenCountsTests.swift | 19 ++++++ src/menubar-json.ts | 13 ++++ src/session-output.ts | 66 ++++++++++--------- src/usage-aggregator.ts | 13 ++-- 7 files changed, 92 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6869b8aa6..dcef834db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- **Per-model token counts sit beside per-model cost in the desktop Overview and the macOS menubar Models rows.** A menubar row gains a secondary line such as `12.3K in · 4.5K out · 67.8K cache read` under the model name, with its cost staying on the primary line, and the Overview models table gains a Cache read column. The desktop Models tables keep observed token counts visible for models that have no attributed cost, where usage was previously hidden. Counts are carried through both fresh-session and durable-day aggregation using the same model-name grouping as cost and the same billable-output normalization, so reasoning tokens are added only where the provider reports them separately. Cache read means reused input; **cache pricing was already included in attributed cost**, so this exposes usage without changing any total, and cache write stays a separate payload field surfaced in the macOS accessibility description. The new fields are optional: older payloads still decode, a missing count stays unknown and renders as `—` rather than a fabricated zero, a known zero prints as `0`, and an alias merge whose contributors disagree presents no partial count. The all-provider Overview table now reads from the period-scoped model rows rather than a union of each day's top-five history, which makes its numbers period-accurate but also applies the payload's existing 20-row cap to that view for the first time. Status snapshot revision 8 invalidates older cached payloads once so an unchanged session corpus also receives the new fields; daily and session cache formats and stored history are unchanged. (#1265) + ### Added (macOS) - **The Capacity Dock shows today's cache-read tokens and tells you whether each quota window will last to its reset.** The Today section gains a provider-scoped cache-read figure beside input, output and calls; a known zero prints as `0` while missing or incomplete historical accounting stays unknown rather than becoming a fabricated zero, and because cache reads were already priced into the burned figure this adds visibility without changing any total. Each quota window then gets one line under it: `Lasts until reset`, `Runs out in 2d 8h`, or — on windows of six hours or less, where one burst would make a linear ETA cry wolf — the pace stage the Plan tab uses (`On pace`, `40% in deficit`, `30% in reserve`). The same line appears under each bar in the agent-tab quota hover card. The projection runs against the window length the provider adapter reports, never a length guessed from the display label, so a monthly cycle whose label happens to read `Weekly` is still paced against its month; it stays silent early in a window, on an exhausted window, without a reset time or a validated duration, and on stale, disconnected or older-than-ten-minute data. Four quota windows move to a two-column grid so scope labels, reset times and captions stay readable, and the dock reserves the caption's height whether or not a column has one so the bubble cannot resize under the pointer. Status snapshot revision 7 invalidates older cached payloads without purging daily history, and no extra polling is introduced. (#1267) - **The macOS menubar item can show a second line.** Settings → General → Display gains a "Second row" switch, off by default, and a picker for what that line shows: quota remaining with its reset countdown for whichever connected provider is nearest its limit, today's all-provider cost, today's total tokens, or the number of running sessions. Both lines render as one attributed title at 9pt with their line height clamped to 10pt, so the pair fits the standard 22pt menu bar, and the second line hides itself whenever its metric has no data yet, leaving the existing single-row figure exactly as it was. This is a deliberately small first slice of the multi-row layout request: no layout editor, no presets, no live preview, no per-item provider or period scoping. The setting persists as `CodeBurnMenubarSecondRowEnabled` and `CodeBurnMenubarSecondRowMetric` in the app's own defaults domain alongside the existing menubar period, scope and metric keys. (#1252) diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index df9786abb..bf610d35e 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -805,6 +805,14 @@ export function OverviewContent({ // whose per-day top-5 history list no longer names them. history.daily is // the fallback for payloads from older CLIs: its rows know input/output but // not cache read, so the cache column shows "—" there. + // + // TRADE-OFF, all-provider view: the previous `aggregateModels` source was + // uncapped (a union over each day's top-5), whereas `current.topModels` is + // capped at the CLI's TOP_MODELS_LIMIT of 20 rows. All-provider therefore + // gains that cap here. Accepted: the union it replaces was itself truncated + // per day, so its rows past the top few were already partial sums, and 20 + // period-accurate rows beat an unbounded list of per-day leftovers. Raising + // the cap is a payload-size decision for the CLI, not this table. const topModelsCarryCounts = data.current.topModels.some(model => model.inputTokens !== undefined || model.outputTokens !== undefined, ) diff --git a/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift index 2f3b89956..2fbcb0a22 100644 --- a/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift @@ -125,7 +125,11 @@ extension ModelEntry { /// comma grouping so the text is deterministic. var tokenAccessibilityText: String { func exact(_ value: Int) -> String { - var digits = String(value) + // Group the MAGNITUDE, then re-apply the sign. Grouping the signed + // string treats "-" as a leading digit, so the sign is emitted + // twice ("--1,234,567"). `magnitude` is unsigned, so Int.min is + // handled too rather than trapping on negation. + var digits = String(value.magnitude) var grouped = "" while digits.count > 3 { let cut = digits.index(digits.endIndex, offsetBy: -3) diff --git a/mac/Tests/CodeBurnMenubarTests/ModelEntryTokenCountsTests.swift b/mac/Tests/CodeBurnMenubarTests/ModelEntryTokenCountsTests.swift index 5236f96b8..b1c779338 100644 --- a/mac/Tests/CodeBurnMenubarTests/ModelEntryTokenCountsTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/ModelEntryTokenCountsTests.swift @@ -135,4 +135,23 @@ struct ModelEntryTokenCountsTests { #expect(label.contains("900,000 cache read (reused input)")) #expect(!label.contains("cache write")) } + + @Test("accessibility text groups a negative count with a single minus sign") + func accessibilityTextGroupsNegativeCountsOnce() throws { + // A negative count is not expected from the CLI, but the payload field + // is a plain Int and a corrupt or hand-edited snapshot can carry one. + // Grouping the signed string would emit the sign twice ("--1,234,567"), + // which a screen reader reads as a different number. + let payload = try decode(""" + [ + { "name": "Broken", "cost": 0, "savingsUSD": 0, "savingsBaselineModel": "", "calls": 1, + "inputTokens": -1234567, "outputTokens": -12, "cacheReadTokens": 0, "cacheWriteTokens": 0 } + ] + """) + let label = payload.current.topModels[0].tokenAccessibilityText + #expect(label.contains("-1,234,567 input")) + #expect(!label.contains("--")) + // Under the grouping threshold the sign still appears exactly once. + #expect(label.contains("-12 output")) + } } diff --git a/src/menubar-json.ts b/src/menubar-json.ts index d11938a1c..21bf90193 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -513,6 +513,19 @@ function buildTopActivities(categories: PeriodData['categories']): MenubarPayloa /// "unknown", not zero: a legacy row that predates the counts must not turn the /// merged row into a plausible-looking 0, so one unknown contributor marks the /// merged count unknown and the field is omitted from the payload. +/// +/// KNOWN GAP: on the durable (daily-cache) path this guard cannot fire today. +/// `ModelDayStats` types the four counts as required numbers and daily-cache's +/// `sanitizeModels` runs every field through `num()`, which turns a missing +/// value into a known `0`. A day carried forward from a generation that +/// predates the counts therefore contributes an exact zero and the period row +/// under-reports as if it were complete, instead of going unknown here. Making +/// it reachable means widening `ModelDayStats` to optional counts and teaching +/// every daily-cache arithmetic site (fold, subtract, reduce) plus +/// `buildPeriodDataFromDays` to propagate absence — a durable-cache contract +/// change, tracked separately. The guard stays because the PeriodData contract +/// already types these counts optional: fresh-session and plugin-sourced rows +/// may legitimately omit them. const MODEL_COUNT_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'] as const type ModelCountKey = (typeof MODEL_COUNT_KEYS)[number] diff --git a/src/session-output.ts b/src/session-output.ts index 133c042d3..0b694d6ed 100644 --- a/src/session-output.ts +++ b/src/session-output.ts @@ -35,34 +35,54 @@ export function resolveModelBreakdownKey( } /** - * Per-model displayed output, keyed like this session's `modelBreakdown`. - * Call usage wins while provider identity is known. Aggregate-only / - * stub sessions fall back to each existing bucket so a finite - * sessionBillableOutputTokens cannot leave model Output Tokens at 0. + * One walk over the session's calls producing BOTH the session-level billable + * output and the per-model split. The two were previously derived by separate + * passes; every caller that wants both (the menubar period aggregation) would + * otherwise traverse every assistant call twice for no extra information. + * + * The fallback is shared deliberately: whether calls carried usage decides the + * total and the per-model map together, so they can never disagree about which + * source they came from. */ -export function sessionModelBillableOutputTokens(session: SessionSummary): Record { +export function sessionBillableOutput(session: SessionSummary): { total: number, byModel: Record } { const breakdown = session.modelBreakdown ?? {} - const out: Record = {} + const byModel: Record = {} + let total = 0 let sawUsage = false for (const turn of session.turns ?? []) { for (const call of turn.assistantCalls ?? []) { if (!call.usage) continue sawUsage = true + const billable = callBillableOutputTokens(call) + total += billable + // A call whose model maps to no bucket still counts toward the session + // total — dropping it there would under-report the headline — but it has + // no row to land in, exactly as before. const key = resolveModelBreakdownKey(call, breakdown) if (!key) continue - out[key] = (out[key] ?? 0) + callBillableOutputTokens(call) + byModel[key] = (byModel[key] ?? 0) + billable } } - if (sawUsage) return out + if (sawUsage) return { total, byModel } + const provider = inferSessionProvider(session) - for (const [model, d] of Object.entries(session.modelBreakdown ?? {})) { - out[model] = billableOutputTokens( - provider, - d.tokens?.outputTokens ?? 0, - d.tokens?.reasoningTokens ?? 0, - ) + for (const [model, d] of Object.entries(breakdown)) { + byModel[model] = billableOutputTokens(provider, d.tokens?.outputTokens ?? 0, d.tokens?.reasoningTokens ?? 0) + } + return { + total: billableOutputTokens(provider, session.totalOutputTokens ?? 0, session.totalReasoningTokens ?? 0), + byModel, } - return out +} + +/** + * Per-model displayed output, keyed like this session's `modelBreakdown`. + * Call usage wins while provider identity is known. Aggregate-only / + * stub sessions fall back to each existing bucket so a finite + * sessionBillableOutputTokens cannot leave model Output Tokens at 0. + */ +export function sessionModelBillableOutputTokens(session: SessionSummary): Record { + return sessionBillableOutput(session).byModel } /** First on-call provider, then a model-name fallback. Sessions are usually one provider. */ @@ -94,19 +114,5 @@ export function callBillableOutputTokens(call: CallLike): number { /** Display/report output: exclusive providers add reasoning; inclusive ones do not. */ export function sessionBillableOutputTokens(session: SessionSummary): number { - let fromCalls = 0 - let sawUsage = false - for (const turn of session.turns ?? []) { - for (const call of turn.assistantCalls ?? []) { - if (!call.usage) continue - sawUsage = true - fromCalls += callBillableOutputTokens(call) - } - } - if (sawUsage) return fromCalls - return billableOutputTokens( - inferSessionProvider(session), - session.totalOutputTokens ?? 0, - session.totalReasoningTokens ?? 0, - ) + return sessionBillableOutput(session).total } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 047a7b865..220ea77af 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -19,7 +19,7 @@ import { aggregateModelTaskTurns, sessionDurationMinutes } from './telemetry-sna import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js' import { buildPrAttribution, aggregateByBranch } from './sessions-report.js' import { scanAndDetect } from './optimize.js' -import { callBillableOutputTokens, sessionBillableOutputTokens, sessionModelBillableOutputTokens, inferSessionProvider } from './session-output.js' +import { callBillableOutputTokens, sessionBillableOutput, sessionBillableOutputTokens, inferSessionProvider } from './session-output.js' import { getDaysInRange, ensureCacheHydrated, loadDailyCache, cachedProjectIdentities, emptyCache, mergeDayEntries, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js' import { buildGranularHistory } from './granular-history.js' import { spendProjectIdentity } from './spend-flow.js' @@ -133,16 +133,19 @@ export function buildPeriodData(label: string, projects: ProjectSummary[]): Peri for (const sess of sessions) { inputTokens += sess.totalInputTokens - outputTokens += sessionBillableOutputTokens(sess) - cacheReadTokens += sess.totalCacheReadTokens - cacheWriteTokens += sess.totalCacheWriteTokens // Per-model output uses the same billable-output rule as the headline: // reasoning tokens are added only where the provider reports them // separately from output (never twice where output already includes // them, #1075). modelBreakdown's raw token counters cannot be summed // for display without it. A bucket no surviving call maps to falls // back to its own counters under the session's provider. - const sessionModelOut = sessionModelBillableOutputTokens(sess) + // + // One walk yields both: the headline total and the per-model split come + // out of the same pass over this session's assistant calls. + const { total: sessionOut, byModel: sessionModelOut } = sessionBillableOutput(sess) + outputTokens += sessionOut + cacheReadTokens += sess.totalCacheReadTokens + cacheWriteTokens += sess.totalCacheWriteTokens for (const [cat, d] of Object.entries(sess.categoryBreakdown)) { if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } catTotals[cat].turns += d.turns