mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-23 15:34:19 +00:00
The TUI comparison picker silently ignored --model-a/--model-b (only the --format json path used them), and both paths required the canonical model id verbatim with no way to pass the display name the picker itself shows (e.g. "Opus 4.8" for claude-opus-4-8). Adds findModelStat, a shared lookup that matches the canonical id first and falls back to getShortModelName's existing canonical -> display mapping (case-insensitive) rather than a new alias table. Both the JSON path and renderCompare/CompareView now use it; renderCompare resolves --model-a/ --model-b up front and seeds CompareView's picked-models state so results load immediately instead of showing the picker. Also validates "both or neither" for the TUI (default) format, matching the JSON path. Fixes getagentseal/codeburn#767 (item 1).
548 lines
21 KiB
TypeScript
548 lines
21 KiB
TypeScript
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<Set<number>>(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 (
|
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1}>
|
|
<Text bold color={ORANGE}>Model Comparison</Text>
|
|
<Text> </Text>
|
|
<Text color={DIM}>Select two models to compare:</Text>
|
|
<Text> </Text>
|
|
{models.map((m, i) => {
|
|
const isCursor = i === cursor
|
|
const isSelected = selected.has(i)
|
|
const lowData = m.calls < LOW_DATA_THRESHOLD
|
|
const prefix = isCursor ? '> ' : ' '
|
|
return (
|
|
<Text key={m.model}>
|
|
<Text color={isCursor ? ORANGE : undefined}>{prefix}</Text>
|
|
<Text bold={isSelected} color={isSelected ? GREEN : undefined}>
|
|
{shortName(m.model).padEnd(MODEL_NAME_COL)}
|
|
</Text>
|
|
<Text>{m.calls.toLocaleString().padStart(8)} calls</Text>
|
|
<Text color={GOLD}>{formatCost(m.cost).padStart(10)}</Text>
|
|
{isSelected && <Text color={GREEN}> [selected]</Text>}
|
|
{lowData && <Text color={DIM}> low data</Text>}
|
|
</Text>
|
|
)
|
|
})}
|
|
</Box>
|
|
<Text> </Text>
|
|
<Text>
|
|
<Text color={ORANGE} bold>[space]</Text><Text dimColor> select </Text>
|
|
<Text color={ORANGE} bold>[enter]</Text><Text dimColor> compare </Text>
|
|
<Text color={ORANGE} bold>{'<>'}</Text><Text dimColor> switch period </Text>
|
|
<Text color={ORANGE} bold>[esc]</Text><Text dimColor> back </Text>
|
|
<Text color={ORANGE} bold>[q]</Text><Text dimColor> quit</Text>
|
|
</Text>
|
|
|
|
{recommendations.length > 0 && (
|
|
<Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={ORANGE} paddingX={1}>
|
|
<Text bold color={ORANGE}>Model defaults recommendation</Text>
|
|
<Text> </Text>
|
|
{recommendations.map(rec => (
|
|
<Box flexDirection="column" key={rec.project} marginBottom={1}>
|
|
<Text>
|
|
<Text>{rec.project}: </Text>
|
|
<Text bold>{rec.currentModel}</Text>
|
|
<Text>{' -> '}</Text>
|
|
<Text bold color={GREEN}>{rec.candidateModel}</Text>
|
|
</Text>
|
|
<Text color={DIM}> Current: {(rec.currentOneShotRate*100).toFixed(1)}% one-shot over {rec.currentEditTurns} edits, {formatCost(rec.currentCostPerEdit)}/edit</Text>
|
|
<Text color={DIM}> Candidate: {(rec.candidateOneShotRate*100).toFixed(1)}% one-shot over {rec.candidateEditTurns} edits, {formatCost(rec.candidateCostPerEdit)}/edit</Text>
|
|
<Text> To apply: <Text color="#00FFFF">codeburn act apply-model {rec.project}</Text></Text>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1} width={pw}>
|
|
<Text bold color={ORANGE}>{title}</Text>
|
|
<Text>
|
|
<Text>{''.padEnd(LABEL_WIDTH)}</Text>
|
|
<Text bold>{nameA.padStart(VALUE_WIDTH)}</Text>
|
|
<Text bold>{nameB.padStart(VALUE_WIDTH)}</Text>
|
|
</Text>
|
|
{rows.map(row => {
|
|
const fmtA = formatValue(row.valueA, row.formatFn)
|
|
const fmtB = formatValue(row.valueB, row.formatFn)
|
|
return (
|
|
<Text key={row.label}>
|
|
<Text color={DIM}>{row.label.padEnd(LABEL_WIDTH)}</Text>
|
|
<Text color={row.winner === 'a' ? GREEN : undefined}>{fmtA.padStart(VALUE_WIDTH)}</Text>
|
|
<Text color={row.winner === 'b' ? GREEN : undefined}>{fmtB.padStart(VALUE_WIDTH)}</Text>
|
|
</Text>
|
|
)
|
|
})}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1} width={pw}>
|
|
<Text bold color={ORANGE}>{title}</Text>
|
|
<Text>
|
|
<Text>{''.padEnd(LABEL_WIDTH)}</Text>
|
|
<Text bold>{nameA.padStart(VALUE_WIDTH)}</Text>
|
|
<Text bold>{nameB.padStart(VALUE_WIDTH)}</Text>
|
|
</Text>
|
|
{rows.map(row => (
|
|
<Text key={row.label}>
|
|
<Text color={DIM}>{row.label.padEnd(LABEL_WIDTH)}</Text>
|
|
<Text color={DIM}>{row.valueA.padStart(VALUE_WIDTH)}</Text>
|
|
<Text color={DIM}>{row.valueB.padStart(VALUE_WIDTH)}</Text>
|
|
</Text>
|
|
))}
|
|
{lowDataWarning && <Text color={GOLD}>{lowDataWarning}</Text>}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
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<string, ComparisonRow[]>()
|
|
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 (
|
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1} width={dashWidth}>
|
|
<Text>
|
|
<Text bold color={ORANGE}>{nameA}</Text>
|
|
<Text dimColor> vs </Text>
|
|
<Text bold color={ORANGE}>{nameB}</Text>
|
|
</Text>
|
|
</Box>
|
|
|
|
<Box width={dashWidth}>
|
|
<MetricPanel title={sectionOrder[0] ?? 'Performance'} rows={sectionRows.get(sectionOrder[0] ?? '') ?? []} nameA={nameA} nameB={nameB} pw={pw} />
|
|
<MetricPanel title={sectionOrder[1] ?? 'Efficiency'} rows={sectionRows.get(sectionOrder[1] ?? '') ?? []} nameA={nameA} nameB={nameB} pw={pw} />
|
|
</Box>
|
|
|
|
{categories.length > 0 && (
|
|
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1} width={dashWidth}>
|
|
<Text bold color={ORANGE}>Category Head-to-Head</Text>
|
|
<Text color={DIM}>one-shot rate per category</Text>
|
|
<Text>
|
|
<Text>{' '}</Text>
|
|
<Text color={BAR_A}>{FULL_BLOCK + FULL_BLOCK}</Text>
|
|
<Text> {nameA} </Text>
|
|
<Text color={BAR_B}>{FULL_BLOCK + FULL_BLOCK}</Text>
|
|
<Text> {nameB}</Text>
|
|
</Text>
|
|
{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 (
|
|
<React.Fragment key={cat.category}>
|
|
<Text> </Text>
|
|
<Text color={DIM}>{' '}{cat.category}</Text>
|
|
<Text>
|
|
<Text>{' '}</Text>
|
|
<Text color={BAR_A}>{FULL_BLOCK.repeat(Math.max(bwA, 1))}</Text>
|
|
<Text>{' '.repeat(Math.max(0, BAR_MAX_WIDTH - bwA))} </Text>
|
|
<Text color={cat.winner === 'a' ? GREEN : undefined}>{rateA.padStart(6)}</Text>
|
|
<Text color={DIM}> {turnsA}</Text>
|
|
</Text>
|
|
<Text>
|
|
<Text>{' '}</Text>
|
|
<Text color={BAR_B}>{FULL_BLOCK.repeat(Math.max(bwB, 1))}</Text>
|
|
<Text>{' '.repeat(Math.max(0, BAR_MAX_WIDTH - bwB))} </Text>
|
|
<Text color={cat.winner === 'b' ? GREEN : undefined}>{rateB.padStart(6)}</Text>
|
|
<Text color={DIM}> {turnsB}</Text>
|
|
</Text>
|
|
</React.Fragment>
|
|
)
|
|
})}
|
|
</Box>
|
|
)}
|
|
|
|
<Box width={dashWidth}>
|
|
{workingStyle.length > 0 && (
|
|
<ContextPanel title="Working Style" rows={workingStyle.map(r => ({ label: r.label, valueA: formatValue(r.valueA, r.formatFn), valueB: formatValue(r.valueB, r.formatFn) }))} nameA={nameA} nameB={nameB} pw={pw} />
|
|
)}
|
|
<ContextPanel title="Context" rows={contextRows} nameA={nameA} nameB={nameB} pw={pw} lowDataWarning={lowDataWarning} />
|
|
</Box>
|
|
|
|
<Text>
|
|
<Text color={ORANGE} bold>{'<>'}</Text><Text dimColor> switch period </Text>
|
|
<Text color={ORANGE} bold>[esc]</Text><Text dimColor> back </Text>
|
|
<Text color={ORANGE} bold>[q]</Text><Text dimColor> quit</Text>
|
|
</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
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<ModelStats[]>(() => aggregateModelStats(projects))
|
|
const [recommendations, setRecommendations] = useState<ModelDefaultRecommendation[]>(() => {
|
|
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<ModelStats | null>(null)
|
|
const [selectedB, setSelectedB] = useState<ModelStats | null>(null)
|
|
const [rows, setRows] = useState<ComparisonRow[]>([])
|
|
const [categories, setCategories] = useState<CategoryComparison[]>([])
|
|
const [style, setStyle] = useState<WorkingStyleRow[]>([])
|
|
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 (
|
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1}>
|
|
<Text bold color={ORANGE}>Model Comparison</Text>
|
|
<Text> </Text>
|
|
<Text color={DIM}>Need at least 2 models to compare. Found {models.length}.</Text>
|
|
</Box>
|
|
<Text> </Text>
|
|
<Text>
|
|
<Text color={ORANGE} bold>[esc]</Text><Text dimColor> back </Text>
|
|
<Text color={ORANGE} bold>[q]</Text><Text dimColor> quit</Text>
|
|
</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
const handleSelect = (a: ModelStats, b: ModelStats) => {
|
|
setPickedNames([a.model, b.model])
|
|
setLoadTrigger(t => t + 1)
|
|
}
|
|
|
|
if (phase === 'loading') {
|
|
return (
|
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1}>
|
|
<Text bold color={ORANGE}>Model Comparison</Text>
|
|
<Text> </Text>
|
|
<Text color={DIM}>Scanning self-corrections...</Text>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (phase === 'results' && selectedA && selectedB) {
|
|
return (
|
|
<ComparisonResults
|
|
modelA={selectedA}
|
|
modelB={selectedB}
|
|
rows={rows}
|
|
categories={categories}
|
|
workingStyle={style}
|
|
onBack={() => setPhase('select')}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ModelSelector
|
|
models={models}
|
|
recommendations={recommendations}
|
|
onSelect={handleSelect}
|
|
onBack={onBack}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export async function renderCompare(range: DateRange, provider: string, modelA?: string, modelB?: string): Promise<void> {
|
|
// 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(
|
|
<CompareView projects={projects} onBack={() => process.exit(0)} presetModels={presetModels} />
|
|
)
|
|
await waitUntilExit()
|
|
}
|