Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

## 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)
- **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)

Expand Down
9 changes: 9 additions & 0 deletions app/renderer/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,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
Expand Down
11 changes: 9 additions & 2 deletions app/renderer/sections/Models.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Models period="30days" provider="all" />)

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

Expand Down
19 changes: 11 additions & 8 deletions app/renderer/sections/Models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -292,9 +295,9 @@ function ModelTableRow({ row, onAddAlias }: { row: ModelReportRow; onAddAlias: (
<span style={{ ...providerTagStyle, display: 'block', marginTop: 2, paddingLeft: 16 }}>{row.providerDisplayName}</span>
</td>
<td className={cellClass}>{fmtInt(row.calls)}</td>
<td className={cellClass}>{tokenValue(row.inputTokens)}</td>
<td className={cellClass}>{tokenValue(row.outputTokens)}</td>
<td className={cellClass}>{tokenValue(row.cacheReadTokens)}</td>
<td>{formatCompact(row.inputTokens)}</td>
<td>{formatCompact(row.outputTokens)}</td>
<td>{formatCompact(row.cacheReadTokens)}</td>
<td className={cellClass}>{unpriced ? '—' : formatUsd(row.costUSD)}</td>
<td className={unpriced ? 'dim' : row.savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)}</td>
</tr>
Expand Down Expand Up @@ -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 (
<tr className="model-task-row">
<td className={cellClass}>{row.category ?? 'general'}</td>
<td className={cellClass}>{fmtInt(row.calls)}</td>
<td className={cellClass}>{tokenValue(row.inputTokens)}</td>
<td className={cellClass}>{tokenValue(row.outputTokens)}</td>
<td className={cellClass}>{tokenValue(row.cacheReadTokens)}</td>
{/* Observed usage renders even for unpriced models — see ModelTableRow. */}
<td>{formatCompact(row.inputTokens)}</td>
<td>{formatCompact(row.outputTokens)}</td>
<td>{formatCompact(row.cacheReadTokens)}</td>
<td className={cellClass}>{unpriced ? '—' : formatUsd(row.costUSD)}</td>
<td className={unpriced ? 'dim' : row.savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)}</td>
</tr>
Expand Down
51 changes: 49 additions & 2 deletions app/renderer/sections/Overview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -638,8 +638,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(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)

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(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)

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 () => {
Expand Down
39 changes: 33 additions & 6 deletions app/renderer/sections/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -465,16 +465,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)
}

Expand Down Expand Up @@ -510,6 +519,8 @@ function ModelsTable({ models }: { models: AggregatedModel[] }) {
<th>Model</th>
<th className="num">Input tok</th>
<th className="num">Output tok</th>
{/* Reused input tokens: prompts the provider served from cache. */}
<th className="num" title="Reused input tokens served from the provider's cache">Cache read</th>
<th className="num">Cost</th>
<th className="num">Calls</th>
</tr>
Expand All @@ -520,6 +531,7 @@ function ModelsTable({ models }: { models: AggregatedModel[] }) {
<td className="ov-model-name">{model.name}</td>
<td className="num mono">{model.inputTokens === undefined ? '—' : formatCompact(model.inputTokens)}</td>
<td className="num mono">{model.outputTokens === undefined ? '—' : formatCompact(model.outputTokens)}</td>
<td className="num mono">{model.cacheReadTokens === undefined ? '—' : formatCompact(model.cacheReadTokens)}</td>
<td className="num mono">{formatUsd(model.cost)}</td>
<td className="num">{model.calls.toLocaleString('en-US')}</td>
</tr>
Expand Down Expand Up @@ -787,9 +799,24 @@ 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.
//
// 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,
)
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)
Expand Down
14 changes: 14 additions & 0 deletions docs/design/capacity-dock.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading