import React, { useState, useEffect, useRef } from 'react' import { render, Box, Text, useInput, useApp, useStdout } from 'ink' import type { ModelStats, ComparisonRow, CategoryComparison, WorkingStyleRow } from './compare-stats.js' import { aggregateModelStats, computeComparison, computeCategoryComparison, computeWorkingStyle, findModelStat, scanSelfCorrections } from './compare-stats.js' import { formatCost } from './format.js' import { parseAllSessions, setInteractiveScanUI } from './parser.js' import { getAllProviders } from './providers/index.js' import type { ProjectSummary, DateRange } from './types.js' import { patchStdoutForWindows } from './ink-win.js' import { recommendModelDefault, type ModelDefaultRecommendation } from './act/model-defaults.js' const ORANGE = '#FF8C42' const GREEN = '#5BF5A0' const DIM = '#888888' const GOLD = '#FFD700' const BAR_A = '#6495ED' const BAR_B = '#5BF5A0' const LOW_DATA_THRESHOLD = 20 const LABEL_WIDTH = 20 const VALUE_WIDTH = 14 const MODEL_NAME_COL = 24 const BAR_MAX_WIDTH = 30 const MIN_WIDE = 90 const PANEL_CHROME = 4 const MS_PER_DAY = 24 * 60 * 60 * 1000 const FULL_BLOCK = '\u2588' function formatValue(value: number | null, fmt: ComparisonRow['formatFn']): string { if (value === null) return '-' switch (fmt) { case 'cost': return formatCost(value) case 'number': return Math.round(value).toLocaleString() case 'percent': return `${value.toFixed(1)}%` case 'decimal': return value.toFixed(2) } } function shortName(model: string): string { return model.replace(/^claude-/, '').replace(/-\d{8}$/, '') } function daysOfData(first: string, last: string): number { if (!first || !last) return 0 const ms = new Date(last).getTime() - new Date(first).getTime() return Math.max(1, Math.ceil(ms / MS_PER_DAY)) } function barWidth(rate: number): number { return Math.round((rate / 100) * BAR_MAX_WIDTH) } type ModelSelectorProps = { models: ModelStats[] recommendations: ModelDefaultRecommendation[] onSelect: (a: ModelStats, b: ModelStats) => void onBack: () => void } function ModelSelector({ models, recommendations, onSelect, onBack }: ModelSelectorProps) { const { exit } = useApp() const [cursor, setCursor] = useState(0) const [selected, setSelected] = useState>(new Set()) useInput((input, key) => { if (input === 'q') { exit(); return } if (key.escape) { onBack(); return } if (key.upArrow) { setCursor(c => (c - 1 + models.length) % models.length) return } if (key.downArrow) { setCursor(c => (c + 1) % models.length) return } if (input === ' ') { setSelected(prev => { const next = new Set(prev) if (next.has(cursor)) { next.delete(cursor) } else if (next.size < 2) { next.add(cursor) } return next }) return } if (key.return && selected.size === 2) { const indices = [...selected].sort((a, b) => a - b) onSelect(models[indices[0]!]!, models[indices[1]!]!) } }) return ( Model Comparison Select two models to compare: {models.map((m, i) => { const isCursor = i === cursor const isSelected = selected.has(i) const lowData = m.calls < LOW_DATA_THRESHOLD const prefix = isCursor ? '> ' : ' ' return ( {prefix} {shortName(m.model).padEnd(MODEL_NAME_COL)} {m.calls.toLocaleString().padStart(8)} calls {formatCost(m.cost).padStart(10)} {isSelected && [selected]} {lowData && low data} ) })} [space] select [enter] compare {'<>'} switch period [esc] back [q] quit {recommendations.length > 0 && ( Model defaults recommendation {recommendations.map(rec => ( {rec.project}: {rec.currentModel} {' -> '} {rec.candidateModel} Current: {(rec.currentOneShotRate*100).toFixed(1)}% one-shot over {rec.currentEditTurns} edits, {formatCost(rec.currentCostPerEdit)}/edit Candidate: {(rec.candidateOneShotRate*100).toFixed(1)}% one-shot over {rec.candidateEditTurns} edits, {formatCost(rec.candidateCostPerEdit)}/edit To apply: codeburn act apply-model {rec.project} ))} )} ) } type ComparisonResultsProps = { modelA: ModelStats modelB: ModelStats rows: ComparisonRow[] categories: CategoryComparison[] workingStyle: WorkingStyleRow[] onBack: () => void } function MetricPanel({ title, rows, nameA, nameB, pw }: { title: string; rows: ComparisonRow[]; nameA: string; nameB: string; pw: number }) { return ( {title} {''.padEnd(LABEL_WIDTH)} {nameA.padStart(VALUE_WIDTH)} {nameB.padStart(VALUE_WIDTH)} {rows.map(row => { const fmtA = formatValue(row.valueA, row.formatFn) const fmtB = formatValue(row.valueB, row.formatFn) return ( {row.label.padEnd(LABEL_WIDTH)} {fmtA.padStart(VALUE_WIDTH)} {fmtB.padStart(VALUE_WIDTH)} ) })} ) } function ContextPanel({ title, rows, nameA, nameB, pw, lowDataWarning }: { title: string; rows: { label: string; valueA: string; valueB: string }[]; nameA: string; nameB: string; pw: number; lowDataWarning?: string }) { return ( {title} {''.padEnd(LABEL_WIDTH)} {nameA.padStart(VALUE_WIDTH)} {nameB.padStart(VALUE_WIDTH)} {rows.map(row => ( {row.label.padEnd(LABEL_WIDTH)} {row.valueA.padStart(VALUE_WIDTH)} {row.valueB.padStart(VALUE_WIDTH)} ))} {lowDataWarning && {lowDataWarning}} ) } function ComparisonResults({ modelA, modelB, rows, categories, workingStyle, onBack }: ComparisonResultsProps) { const { exit } = useApp() const { stdout } = useStdout() const termWidth = stdout?.columns || 80 const dashWidth = Math.min(160, termWidth) const wide = dashWidth >= MIN_WIDE const halfWidth = wide ? Math.floor(dashWidth / 2) : dashWidth const nameA = shortName(modelA.model) const nameB = shortName(modelB.model) const lowDataA = modelA.calls < LOW_DATA_THRESHOLD const lowDataB = modelB.calls < LOW_DATA_THRESHOLD useInput((input, key) => { if (input === 'q') { exit(); return } if (key.escape) { onBack(); return } }) const sectionOrder: string[] = [] const sectionRows = new Map() for (const row of rows) { if (!sectionRows.has(row.section)) { sectionOrder.push(row.section) sectionRows.set(row.section, []) } sectionRows.get(row.section)!.push(row) } const fmtTokens = (n: number) => { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` return String(n) } const contextRows: { label: string; valueA: string; valueB: string }[] = [ { label: 'Calls', valueA: modelA.calls.toLocaleString(), valueB: modelB.calls.toLocaleString() }, { label: 'Total cost', valueA: formatCost(modelA.cost), valueB: formatCost(modelB.cost) }, { label: 'Input tokens', valueA: fmtTokens(modelA.inputTokens), valueB: fmtTokens(modelB.inputTokens) }, { label: 'Output tokens', valueA: fmtTokens(modelA.outputTokens), valueB: fmtTokens(modelB.outputTokens) }, { label: 'Days of data', valueA: String(daysOfData(modelA.firstSeen, modelA.lastSeen)), valueB: String(daysOfData(modelB.firstSeen, modelB.lastSeen)) }, { label: 'Edit turns', valueA: modelA.editTurns.toLocaleString(), valueB: modelB.editTurns.toLocaleString() }, { label: 'Self-corrections', valueA: modelA.selfCorrections.toLocaleString(), valueB: modelB.selfCorrections.toLocaleString() }, ] const lowDataWarning = (lowDataA || lowDataB) ? `Note: ${[lowDataA && shortName(modelA.model), lowDataB && shortName(modelB.model)].filter(Boolean).join(' and ')} ha${lowDataA && lowDataB ? 've' : 's'} fewer than ${LOW_DATA_THRESHOLD} calls` : undefined const pw = wide ? halfWidth : dashWidth return ( {nameA} vs {nameB} {categories.length > 0 && ( Category Head-to-Head one-shot rate per category {' '} {FULL_BLOCK + FULL_BLOCK} {nameA} {FULL_BLOCK + FULL_BLOCK} {nameB} {categories.map(cat => { const bwA = cat.oneShotRateA !== null ? barWidth(cat.oneShotRateA) : 0 const bwB = cat.oneShotRateB !== null ? barWidth(cat.oneShotRateB) : 0 const rateA = cat.oneShotRateA !== null ? `${cat.oneShotRateA.toFixed(1)}%` : '-' const rateB = cat.oneShotRateB !== null ? `${cat.oneShotRateB.toFixed(1)}%` : '-' const turnsA = cat.editTurnsA > 0 ? `(${cat.editTurnsA})` : '' const turnsB = cat.editTurnsB > 0 ? `(${cat.editTurnsB})` : '' return ( {' '}{cat.category} {' '} {FULL_BLOCK.repeat(Math.max(bwA, 1))} {' '.repeat(Math.max(0, BAR_MAX_WIDTH - bwA))} {rateA.padStart(6)} {turnsA} {' '} {FULL_BLOCK.repeat(Math.max(bwB, 1))} {' '.repeat(Math.max(0, BAR_MAX_WIDTH - bwB))} {rateB.padStart(6)} {turnsB} ) })} )} {workingStyle.length > 0 && ( ({ label: r.label, valueA: formatValue(r.valueA, r.formatFn), valueB: formatValue(r.valueB, r.formatFn) }))} nameA={nameA} nameB={nameB} pw={pw} /> )} {'<>'} switch period [esc] back [q] quit ) } type CompareViewProps = { projects: ProjectSummary[] onBack: () => void // Pre-resolved canonical model ids from --model-a/--model-b (already // validated to exist in `projects`' aggregated stats by the caller). When // set, comparison results load immediately instead of showing the picker. presetModels?: [string, string] } export function CompareView({ projects, onBack, presetModels }: CompareViewProps) { const { exit } = useApp() const [phase, setPhase] = useState<'select' | 'loading' | 'results'>('select') const [models, setModels] = useState(() => aggregateModelStats(projects)) const [recommendations, setRecommendations] = useState(() => { const recs: ModelDefaultRecommendation[] = [] for (const p of projects) { const rec = recommendModelDefault(p) if (rec) recs.push(rec) } return recs }) const [pickedNames, setPickedNames] = useState<[string, string] | null>(presetModels ?? null) const [selectedA, setSelectedA] = useState(null) const [selectedB, setSelectedB] = useState(null) const [rows, setRows] = useState([]) const [categories, setCategories] = useState([]) const [style, setStyle] = useState([]) const [loadTrigger, setLoadTrigger] = useState(presetModels ? 1 : 0) const projectsRef = useRef(projects) projectsRef.current = projects useEffect(() => { const newModels = aggregateModelStats(projects) setModels(newModels) const recs: ModelDefaultRecommendation[] = [] for (const p of projects) { const rec = recommendModelDefault(p) if (rec) recs.push(rec) } setRecommendations(recs) if (!pickedNames) return const hasA = newModels.some(m => m.model === pickedNames[0]) const hasB = newModels.some(m => m.model === pickedNames[1]) if (!hasA || !hasB) { setPickedNames(null) setPhase('select') return } // When the periodic CLI refresh updates `projects` while the user is // reading the results page, recompute the comparison rows IN PLACE rather // than flipping to a loading screen. Previously every 30s tick bounced the // user to a loading flash and reset their scroll position; the slow part // (scanSelfCorrections, which walks every provider's session dir) is // skipped on these refreshes — corrections drift slowly enough that // staying with the existing values until the user re-enters compare from // scratch is fine. if (phase === 'results') { const a = newModels.find(m => m.model === pickedNames[0]) const b = newModels.find(m => m.model === pickedNames[1]) if (!a || !b) return const aCopy = { ...a, selfCorrections: selectedA?.selfCorrections ?? 0 } const bCopy = { ...b, selfCorrections: selectedB?.selfCorrections ?? 0 } setSelectedA(aCopy) setSelectedB(bCopy) setRows(computeComparison(aCopy, bCopy)) setCategories(computeCategoryComparison(projects, a.model, b.model)) setStyle(computeWorkingStyle(projects, a.model, b.model)) return } // Initial load (or returning from select after picking) — full pipeline, // including scanSelfCorrections. setLoadTrigger(t => t + 1) }, [projects]) useEffect(() => { if (loadTrigger === 0 || !pickedNames) return let cancelled = false setPhase('loading') const currentModels = aggregateModelStats(projectsRef.current) const a = currentModels.find(m => m.model === pickedNames[0]) const b = currentModels.find(m => m.model === pickedNames[1]) if (!a || !b) { setPhase('select'); return } async function run() { const providers = await getAllProviders() const dirs: string[] = [] for (const p of providers) { const sessions = await p.discoverSessions() for (const s of sessions) dirs.push(s.path) } const corrections = await scanSelfCorrections(dirs) if (cancelled) return const currentProjects = projectsRef.current const aCopy = { ...a!, selfCorrections: corrections.get(a!.model) ?? 0 } const bCopy = { ...b!, selfCorrections: corrections.get(b!.model) ?? 0 } setSelectedA(aCopy) setSelectedB(bCopy) setRows(computeComparison(aCopy, bCopy)) setCategories(computeCategoryComparison(currentProjects, a!.model, b!.model)) setStyle(computeWorkingStyle(currentProjects, a!.model, b!.model)) setPhase('results') } run() return () => { cancelled = true } }, [loadTrigger]) useInput((input, key) => { if (phase !== 'select') return if (models.length < 2) { if (input === 'q') { exit(); return } if (key.escape) { onBack(); return } } }) if (models.length < 2) { return ( Model Comparison Need at least 2 models to compare. Found {models.length}. [esc] back [q] quit ) } const handleSelect = (a: ModelStats, b: ModelStats) => { setPickedNames([a.model, b.model]) setLoadTrigger(t => t + 1) } if (phase === 'loading') { return ( Model Comparison Scanning self-corrections... ) } if (phase === 'results' && selectedA && selectedB) { return ( setPhase('select')} /> ) } return ( ) } export async function renderCompare(range: DateRange, provider: string, modelA?: string, modelB?: string): Promise { // Interactive Ink UI: suppress the CLI scan-progress line for the whole // lifetime so it can't print over the rendered comparison. Plain CLI // commands still show progress. setInteractiveScanUI() const isTTY = process.stdin.isTTY && process.stdout.isTTY if (!isTTY) { process.stdout.write('Model comparison requires an interactive terminal.\n') return } patchStdoutForWindows() const projects = await parseAllSessions(range, provider) // --model-a/--model-b: resolve up front (by canonical id or display name, // same lookup the JSON path uses) so the TUI jumps straight to results // instead of ignoring the flags and showing the picker. let presetModels: [string, string] | undefined if (modelA && modelB) { const models = aggregateModelStats(projects) const a = findModelStat(models, modelA) const b = findModelStat(models, modelB) if (!a) { process.stderr.write(`codeburn compare: model not found: "${modelA}".\n`) process.exit(1) } if (!b) { process.stderr.write(`codeburn compare: model not found: "${modelB}".\n`) process.exit(1) } presetModels = [a.model, b.model] } const { waitUntilExit } = render( process.exit(0)} presetModels={presetModels} /> ) await waitUntilExit() }