feat(app): Models section — per-model pricing table with series dots, by-model/by-task, unpriced-row alias

Table from getModels(): calls/input/output/cache-read/cost/saved with a model-family series
dot (reuses the ListRow helper). Unpriced/proxy rows show the dim "add alias" affordance +
dashes. By-model / By-task SegTabs re-fetch (byTask). Compare… is a visual affordance (M2).

Implemented by Codex gpt-5.5 (high); committed by Fable (git blocked in Codex sandbox).
This commit is contained in:
iamtoruk 2026-07-10 18:47:07 -07:00
parent 87f5a9bac1
commit 18b4b728ff
4 changed files with 295 additions and 1 deletions

View file

@ -9,6 +9,7 @@ import { usePolled } from './hooks/usePolled'
import { codeburn } from './lib/ipc'
import { Overview } from './sections/Overview'
import { Optimize } from './sections/Optimize'
import { Models } from './sections/Models'
import { Spend } from './sections/Spend'
import type { MenubarPayload, Period } from './lib/types'
@ -74,6 +75,8 @@ export function App() {
<Spend period={period} provider={provider} />
) : section === 'optimize' ? (
<Optimize period={period} provider={provider} />
) : section === 'models' ? (
<Models period={period} provider={provider} />
) : (
<SectionPlaceholder title={SECTION_TITLES[section]} />
)}

View file

@ -0,0 +1,139 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ModelReportRow } from '../lib/types'
import { Models } from './Models'
const { getModels } = vi.hoisted(() => ({
getModels: vi.fn<(period: string, provider: string, byTask: boolean) => Promise<ModelReportRow[]>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getModels } }
})
const rows: ModelReportRow[] = [
{
provider: 'anthropic',
providerDisplayName: 'Anthropic',
model: 'claude-opus-4.8',
modelDisplayName: 'Claude Opus 4.8',
category: null,
topCategory: 'coding',
topCategoryShare: 0.71,
inputTokens: 152_600_000,
outputTokens: 9_640_000,
cacheWriteTokens: 16_000_000,
cacheReadTokens: 119_400_000,
totalTokens: 297_640_000,
calls: 4812,
costUSD: 331.2,
savingsUSD: 86.4,
savingsBaselineModel: 'Claude Opus 4.8',
credits: null,
},
{
provider: 'openai',
providerDisplayName: 'OpenAI',
model: 'gpt-5.5-codex',
modelDisplayName: 'GPT-5.5 Codex',
category: null,
topCategory: 'debugging',
topCategoryShare: 0.42,
inputTokens: 86_900_000,
outputTokens: 7_520_000,
cacheWriteTokens: 3_200_000,
cacheReadTokens: 45_100_000,
totalTokens: 142_720_000,
calls: 2704,
costUSD: 137.9,
savingsUSD: 35.1,
savingsBaselineModel: 'GPT-5.5 Codex',
credits: null,
},
{
provider: 'custom',
providerDisplayName: 'Custom',
model: 'my-proxy-model',
modelDisplayName: 'my-proxy-model',
category: null,
inputTokens: 4_800_000,
outputTokens: 400_000,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalTokens: 5_200_000,
calls: 176,
costUSD: 0,
savingsUSD: 0,
savingsBaselineModel: '',
credits: null,
},
]
const byTaskRows: ModelReportRow[] = [
{
...rows[0],
category: 'coding',
calls: 3400,
inputTokens: 100_000_000,
outputTokens: 6_100_000,
cacheReadTokens: 88_000_000,
totalTokens: 210_100_000,
costUSD: 244.12,
savingsUSD: 61.22,
},
]
describe('Models', () => {
beforeEach(() => {
getModels.mockReset()
})
it('renders priced model rows with series dots, costs, and savings', async () => {
getModels.mockResolvedValue(rows)
const { container } = render(<Models period="30days" provider="all" />)
expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument()
expect(screen.getByText('4,812')).toBeInTheDocument()
expect(screen.getByText('152.6M')).toBeInTheDocument()
expect(screen.getByText('9.64M')).toBeInTheDocument()
expect(screen.getByText('119.4M')).toBeInTheDocument()
expect(screen.getByText('$331.20')).toBeInTheDocument()
expect(screen.getByText('$86.40')).toHaveClass('pos')
expect(screen.getByText('GPT-5.5 Codex')).toBeInTheDocument()
expect(screen.getByText('$137.90')).toBeInTheDocument()
expect(screen.getByText('$35.10')).toHaveClass('pos')
const dots = [...container.querySelectorAll('.mdot')]
expect(dots[0]).toHaveAttribute('style', expect.stringContaining('var(--blue)'))
expect(dots[1]).toHaveAttribute('style', expect.stringContaining('var(--cyan)'))
})
it('renders unpriced proxy rows as dim with alias affordance and cost dashes', async () => {
getModels.mockResolvedValue(rows)
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(2)
expect(screen.queryByText('$0.00')).not.toBeInTheDocument()
})
it('refetches with byTask=true and renders the task category', async () => {
getModels.mockResolvedValueOnce(rows).mockResolvedValueOnce(byTaskRows)
render(<Models period="week" provider="anthropic" />)
expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument()
expect(getModels).toHaveBeenCalledWith('week', 'anthropic', false)
fireEvent.click(screen.getByRole('button', { name: 'By task' }))
await waitFor(() => expect(getModels).toHaveBeenCalledWith('week', 'anthropic', true))
expect(await screen.findByText('coding')).toBeInTheDocument()
expect(screen.getByText('$244.12')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,152 @@
import { useState } from 'react'
import { seriesColorForModel } from '../components/ListRow'
import { Panel } from '../components/Panel'
import { SegTabs } from '../components/SegTabs'
import { usePolled } from '../hooks/usePolled'
import { codeburn } from '../lib/ipc'
import type { ModelReportRow, Period } from '../lib/types'
type ModelsLens = 'model' | 'task'
const LENSES = [
{ value: 'model', label: 'By model' },
{ value: 'task', label: 'By task' },
]
function fmtUsd(n: number): string {
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function fmtInt(n: number): string {
return n.toLocaleString('en-US')
}
function fmtCompact(n: number): string {
if (n === 0) return '0'
return new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: n >= 10_000_000 ? 1 : 2,
}).format(n)
}
function EmptyNote({ children }: { children: React.ReactNode }) {
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
}
export function Models({ period, provider }: { period: Period; provider: string }) {
const [lens, setLens] = useState<ModelsLens>('model')
const byTask = lens === 'task'
const report = usePolled<ModelReportRow[]>(
() => codeburn.getModels(period, provider, byTask),
[period, provider, byTask],
)
if (!report.data) {
if (report.error?.kind === 'not-found') {
return (
<Panel title="Locate the codeburn CLI">
<p style={{ color: 'var(--t2)', margin: '0 0 6px', fontSize: 12.5 }}>
CodeBurn Desktop reads your model usage by running the{' '}
<code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>codeburn</code> command, but it isn&apos;t
on your PATH yet.
</p>
<p style={{ color: 'var(--t3)', margin: 0, fontSize: 11.5 }}>
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--lav)' }}>npm i -g codeburn</code>,
then reopen this window.
</p>
</Panel>
)
}
if (report.error) {
return (
<Panel title="Couldn't read models">
<p style={{ color: 'var(--red)', margin: 0, fontSize: 12 }}>{report.error.message}</p>
</Panel>
)
}
return (
<Panel title="Models">
<EmptyNote>Scanning model usage</EmptyNote>
</Panel>
)
}
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'flex-start' }}>
<SegTabs options={LENSES} value={lens} onChange={value => setLens(value as ModelsLens)} />
<span className="btn btn-s" aria-disabled="true">
Compare
</span>
</div>
<Panel bodyStyle={{ overflowX: 'auto' }}>
{report.data.length ? (
<ModelsTable rows={report.data} byTask={byTask} />
) : (
<EmptyNote>No model usage in this range yet.</EmptyNote>
)}
</Panel>
</>
)
}
function ModelsTable({ rows, byTask }: { rows: ModelReportRow[]; byTask: boolean }) {
return (
<table>
<thead>
<tr>
<th>Model</th>
<th>Calls</th>
<th>Input</th>
<th>Output</th>
<th>Cache read</th>
<th>Cost</th>
<th>Saved</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<ModelTableRow key={`${row.provider}-${row.model}-${row.category ?? 'all'}-${i}`} row={row} byTask={byTask} />
))}
</tbody>
</table>
)
}
function ModelTableRow({ row, byTask }: { row: ModelReportRow; byTask: boolean }) {
const unpriced = (row.costUSD === 0 && row.calls > 0) || row.credits !== null
const cellClass = unpriced ? 'dim' : undefined
const dotStyle = {
display: 'inline-block',
background: seriesColorForModel(row.modelDisplayName || row.model),
marginRight: 8,
}
return (
<tr>
<td className={cellClass} title={row.model}>
<span className="mdot" style={dotStyle} />
{row.modelDisplayName}
{byTask && row.category ? (
<>
{' · '}
<span className="est">{row.category}</span>
</>
) : null}
{unpriced ? (
<>
{' '}
<span className="alias">add alias </span>
</>
) : null}
</td>
<td className={cellClass}>{fmtInt(row.calls)}</td>
<td className={cellClass}>{fmtCompact(row.inputTokens)}</td>
<td className={cellClass}>{fmtCompact(row.outputTokens)}</td>
<td className={cellClass}>{fmtCompact(row.cacheReadTokens)}</td>
<td className={cellClass}>{unpriced ? '—' : fmtUsd(row.costUSD)}</td>
<td className={unpriced ? 'dim' : row.savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : fmtUsd(row.savingsUSD)}</td>
</tr>
)
}

View file

@ -149,7 +149,7 @@ th:last-child, td:last-child { padding-right: 2px; }
td { font-family: var(--mono); font-size: 11.5px; text-align: right; padding: 7px 10px; border-bottom: 1px solid var(--hair); font-variant-numeric: tabular-nums; white-space: nowrap; }
td:first-child { font-family: var(--sans); font-size: 12.5px; font-weight: 520; }
tr:last-child td { border-bottom: 0; }
td .pos { color: var(--mint); }
td.pos, td .pos { color: var(--mint); }
.dim { color: var(--t3); }
.est { font-size: 9.5px; color: var(--t3); font-family: var(--mono); }
.alias { color: var(--lav); font-family: var(--sans); font-size: 11px; font-weight: 530; }