From 18b4b728ff073f5d1e22aaca4e20ea996b85bee1 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Fri, 10 Jul 2026 18:47:07 -0700 Subject: [PATCH] =?UTF-8?q?feat(app):=20Models=20section=20=E2=80=94=20per?= =?UTF-8?q?-model=20pricing=20table=20with=20series=20dots,=20by-model/by-?= =?UTF-8?q?task,=20unpriced-row=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- app/renderer/App.tsx | 3 + app/renderer/sections/Models.test.tsx | 139 +++++++++++++++++++++++ app/renderer/sections/Models.tsx | 152 ++++++++++++++++++++++++++ app/renderer/styles/indigo.css | 2 +- 4 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 app/renderer/sections/Models.test.tsx create mode 100644 app/renderer/sections/Models.tsx diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 362b1f2..aa9e6fc 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -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() { ) : section === 'optimize' ? ( + ) : section === 'models' ? ( + ) : ( )} diff --git a/app/renderer/sections/Models.test.tsx b/app/renderer/sections/Models.test.tsx new file mode 100644 index 0000000..cb5fb56 --- /dev/null +++ b/app/renderer/sections/Models.test.tsx @@ -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>(), +})) +vi.mock('../lib/ipc', async orig => { + const actual = await orig() + 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() + + 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() + + 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() + + 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() + }) +}) diff --git a/app/renderer/sections/Models.tsx b/app/renderer/sections/Models.tsx new file mode 100644 index 0000000..20e7bf2 --- /dev/null +++ b/app/renderer/sections/Models.tsx @@ -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

{children}

+} + +export function Models({ period, provider }: { period: Period; provider: string }) { + const [lens, setLens] = useState('model') + const byTask = lens === 'task' + const report = usePolled( + () => codeburn.getModels(period, provider, byTask), + [period, provider, byTask], + ) + + if (!report.data) { + if (report.error?.kind === 'not-found') { + return ( + +

+ CodeBurn Desktop reads your model usage by running the{' '} + codeburn command, but it isn't + on your PATH yet. +

+

+ Install it with npm i -g codeburn, + then reopen this window. +

+
+ ) + } + if (report.error) { + return ( + +

{report.error.message}

+
+ ) + } + return ( + + Scanning model usage… + + ) + } + + return ( + <> +
+ setLens(value as ModelsLens)} /> + + Compare… + +
+ + {report.data.length ? ( + + ) : ( + No model usage in this range yet. + )} + + + ) +} + +function ModelsTable({ rows, byTask }: { rows: ModelReportRow[]; byTask: boolean }) { + return ( + + + + + + + + + + + + + + {rows.map((row, i) => ( + + ))} + +
ModelCallsInputOutputCache readCostSaved
+ ) +} + +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 ( + + + + {row.modelDisplayName} + {byTask && row.category ? ( + <> + {' · '} + {row.category} + + ) : null} + {unpriced ? ( + <> + {' '} + add alias › + + ) : null} + + {fmtInt(row.calls)} + {fmtCompact(row.inputTokens)} + {fmtCompact(row.outputTokens)} + {fmtCompact(row.cacheReadTokens)} + {unpriced ? '—' : fmtUsd(row.costUSD)} + 0 ? 'pos' : undefined}>{unpriced ? '—' : fmtUsd(row.savingsUSD)} + + ) +} diff --git a/app/renderer/styles/indigo.css b/app/renderer/styles/indigo.css index 9e22d6b..859c91d 100644 --- a/app/renderer/styles/indigo.css +++ b/app/renderer/styles/indigo.css @@ -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; }