import { useCallback, useEffect, useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { Dropdown } from '../components/Dropdown'
import { EmptyNote } from '../components/EmptyState'
import { Panel } from '../components/Panel'
import { SectionSkeleton } from '../components/Skeleton'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { CompareJsonReport, ComparisonRow, DateRange, ModelStats, Period, WorkingStyleRow } from '../lib/types'
function fmtMetric(v: number | null, fn: 'cost' | 'number' | 'percent' | 'decimal'): string {
if (v === null) return '—'
if (fn === 'cost') return formatUsd(v)
if (fn === 'percent') return `${v.toFixed(0)}%`
if (fn === 'decimal') return v.toFixed(2)
return Math.round(v).toLocaleString('en-US')
}
// The CLI `compare` command has no --from/--to, so a custom range falls back to
// the selected period. Say so instead of silently ignoring the dates.
function RangeNote() {
return (
Compare uses the selected period, custom dates are not supported yet.
)
}
export function Compare({
period,
provider,
range = null,
refreshToken = 0,
ready = true,
}: {
period: Period
provider: string
range?: DateRange | null
refreshToken?: number
ready?: boolean
}) {
const models = usePolled(
() => codeburn.getCompareModels(period, provider),
[period, provider, refreshToken],
{ enabled: ready, memoKey: `comparemodels|${period}|${provider}` },
)
const [modelA, setModelA] = useState(null)
const [modelB, setModelB] = useState(null)
useEffect(() => {
if (!models.data) return
const available = new Set(models.data.map(model => model.model))
setModelA(current => current && available.has(current) ? current : models.data?.[0]?.model ?? null)
setModelB(current => current && available.has(current) ? current : models.data?.[1]?.model ?? null)
}, [models.data])
const resetToDefaults = useCallback(() => {
if (!models.data) return
setModelA(models.data[0]?.model ?? null)
setModelB(models.data[1]?.model ?? null)
}, [models.data])
if (!models.data) {
if (models.error) return
return
}
if (models.data.length < 2) {
return (
Need at least two models with usage in this range to compare.
)
}
const modelRows = models.data
const nudgeDistinct = (chosen: string) => modelRows.find(model => model.model !== chosen)?.model ?? null
return (
<>
{range && }
({ value: model.model, label: `${model.model} · ${model.calls.toLocaleString()} calls` }))}
onChange={next => {
setModelA(next)
if (next === modelB) setModelB(nudgeDistinct(next))
}}
/>
vs
({ value: model.model, label: `${model.model} · ${model.calls.toLocaleString()} calls` }))}
onChange={next => {
setModelB(next)
if (next === modelA) setModelA(nudgeDistinct(next))
}}
/>
{modelA && modelB && modelA !== modelB && (
)}
>
)
}
function CompareReport({
period,
provider,
modelA,
modelB,
refreshToken,
onError,
}: {
period: Period
provider: string
modelA: string
modelB: string
refreshToken: number
onError: () => void
}) {
const report = usePolled(
() => codeburn.getCompare(period, provider, modelA, modelB),
[period, provider, modelA, modelB, refreshToken],
{ memoKey: `compare|${period}|${provider}|${modelA}|${modelB}` },
)
useEffect(() => {
if (report.error) onError()
}, [report.error, onError])
if (!report.data) {
if (report.error) return
return
}
const performance = report.data.metrics.filter(metric => metric.section === 'Performance')
const efficiency = report.data.metrics.filter(metric => metric.section === 'Efficiency')
return (
)
}
function MetricCard({
title,
rows,
modelA,
modelB,
showWinners = false,
}: {
title: string
rows: Array
modelA: string
modelB: string
showWinners?: boolean
}) {
return (
{title}
{rows.map(row => {
const winner = 'winner' in row ? row.winner : 'none'
return (
{row.label}
{fmtMetric(row.valueA, row.formatFn)}
{fmtMetric(row.valueB, row.formatFn)}
)
})}
{showWinners &&
Green = better on that metric.
}
)
}
function MetricHeader({ modelA, modelB }: { modelA: string; modelB: string }) {
return Metric{modelA}{modelB}
}
function CategoryCard({ report }: { report: CompareJsonReport }) {
return (
Category head-to-head
One-shot rate · edit turns
{report.modelA.model}
{report.modelB.model}
{report.categories.map(category => (
{category.category}
{fmtMetric(category.oneShotRateA, 'percent')} ({category.editTurnsA})
{fmtMetric(category.oneShotRateB, 'percent')} ({category.editTurnsB})
))}
)
}
function cacheHitRate(model: ModelStats): string {
// reads over reads + fresh input (matches menubar-json + compare-stats).
const total = model.inputTokens + model.cacheReadTokens
return total > 0 ? `${Math.round(model.cacheReadTokens / total * 100)}%` : '—'
}
function daysOfData(model: ModelStats): string {
if (!model.firstSeen || !model.lastSeen) return '—'
return String(Math.max(1, Math.round((new Date(model.lastSeen).getTime() - new Date(model.firstSeen).getTime()) / 86_400_000) + 1))
}
function ContextCard({ modelA, modelB }: { modelA: ModelStats; modelB: ModelStats }) {
const rows = [
['Calls', modelA.calls.toLocaleString(), modelB.calls.toLocaleString()],
['Total cost', formatUsd(modelA.cost), formatUsd(modelB.cost)],
['Input tokens', formatCompact(modelA.inputTokens), formatCompact(modelB.inputTokens)],
['Output tokens', formatCompact(modelA.outputTokens), formatCompact(modelB.outputTokens)],
['Edit turns', modelA.editTurns.toLocaleString(), modelB.editTurns.toLocaleString()],
['Self-corrections', modelA.selfCorrections.toLocaleString(), modelB.selfCorrections.toLocaleString()],
['Cache hit rate', cacheHitRate(modelA), cacheHitRate(modelB)],
['Days of data', daysOfData(modelA), daysOfData(modelB)],
]
return (
Context
{rows.map(([label, valueA, valueB]) => (
{label}{valueA}{valueB}
))}
)
}