@spacek33z/autoauto 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +197 -0
  2. package/package.json +51 -0
  3. package/src/App.tsx +224 -0
  4. package/src/cli.ts +772 -0
  5. package/src/components/AgentPanel.tsx +254 -0
  6. package/src/components/Chat.test.tsx +71 -0
  7. package/src/components/Chat.tsx +308 -0
  8. package/src/components/CycleField.tsx +23 -0
  9. package/src/components/ModelPicker.tsx +97 -0
  10. package/src/components/PostUpdatePrompt.tsx +46 -0
  11. package/src/components/ResultsTable.tsx +172 -0
  12. package/src/components/RunCompletePrompt.tsx +90 -0
  13. package/src/components/RunSettingsOverlay.tsx +49 -0
  14. package/src/components/RunsTable.tsx +219 -0
  15. package/src/components/StatsHeader.tsx +100 -0
  16. package/src/daemon.ts +264 -0
  17. package/src/index.tsx +8 -0
  18. package/src/lib/agent/agent-provider.test.ts +133 -0
  19. package/src/lib/agent/claude-provider.ts +277 -0
  20. package/src/lib/agent/codex-provider.ts +413 -0
  21. package/src/lib/agent/default-providers.ts +10 -0
  22. package/src/lib/agent/index.ts +32 -0
  23. package/src/lib/agent/mock-provider.ts +61 -0
  24. package/src/lib/agent/opencode-provider.ts +424 -0
  25. package/src/lib/agent/types.ts +73 -0
  26. package/src/lib/auth.ts +11 -0
  27. package/src/lib/config.ts +152 -0
  28. package/src/lib/daemon-callbacks.ts +59 -0
  29. package/src/lib/daemon-client.ts +16 -0
  30. package/src/lib/daemon-lifecycle.ts +368 -0
  31. package/src/lib/daemon-spawn.ts +122 -0
  32. package/src/lib/daemon-status.ts +189 -0
  33. package/src/lib/daemon-watcher.ts +192 -0
  34. package/src/lib/experiment-loop.ts +679 -0
  35. package/src/lib/experiment.ts +356 -0
  36. package/src/lib/finalize.test.ts +143 -0
  37. package/src/lib/finalize.ts +511 -0
  38. package/src/lib/format.test.ts +32 -0
  39. package/src/lib/format.ts +44 -0
  40. package/src/lib/git.ts +176 -0
  41. package/src/lib/ideas-backlog.test.ts +54 -0
  42. package/src/lib/ideas-backlog.ts +109 -0
  43. package/src/lib/measure.ts +472 -0
  44. package/src/lib/model-options.ts +24 -0
  45. package/src/lib/programs.ts +247 -0
  46. package/src/lib/push-stream.ts +48 -0
  47. package/src/lib/run-context.ts +112 -0
  48. package/src/lib/run-setup.ts +34 -0
  49. package/src/lib/run.ts +383 -0
  50. package/src/lib/syntax-theme.ts +39 -0
  51. package/src/lib/system-prompts/experiment.ts +77 -0
  52. package/src/lib/system-prompts/finalize.ts +90 -0
  53. package/src/lib/system-prompts/index.ts +7 -0
  54. package/src/lib/system-prompts/setup.ts +516 -0
  55. package/src/lib/system-prompts/update.ts +188 -0
  56. package/src/lib/tool-events.ts +99 -0
  57. package/src/lib/validate-measurement.ts +326 -0
  58. package/src/lib/worktree.ts +40 -0
  59. package/src/screens/AuthErrorScreen.tsx +31 -0
  60. package/src/screens/ExecutionScreen.tsx +851 -0
  61. package/src/screens/FirstSetupScreen.tsx +168 -0
  62. package/src/screens/HomeScreen.tsx +406 -0
  63. package/src/screens/PreRunScreen.tsx +206 -0
  64. package/src/screens/SettingsScreen.tsx +189 -0
  65. package/src/screens/SetupScreen.tsx +226 -0
  66. package/src/tui.tsx +17 -0
  67. package/tsconfig.json +17 -0
@@ -0,0 +1,97 @@
1
+ import { useCallback, useEffect, useState } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import type { ModelSlot } from "../lib/config.ts"
4
+ import type { AgentProviderID } from "../lib/agent/index.ts"
5
+ import { loadModelPickerOptions, type ModelPickerOption } from "../lib/model-options.ts"
6
+ import { formatShellError } from "../lib/git.ts"
7
+
8
+ interface SelectOption {
9
+ name: string
10
+ description: string
11
+ value?: ModelSlot
12
+ }
13
+
14
+ interface ModelPickerProps {
15
+ cwd: string
16
+ title: string
17
+ providerId: AgentProviderID
18
+ onSelect: (slot: ModelSlot) => void
19
+ onCancel: () => void
20
+ }
21
+
22
+ export function ModelPicker({ cwd, title, providerId, onSelect, onCancel }: ModelPickerProps) {
23
+ const [options, setOptions] = useState<SelectOption[]>([])
24
+ const [loading, setLoading] = useState(true)
25
+ const [error, setError] = useState<string | null>(null)
26
+
27
+ const load = useCallback(async (forceRefresh = false) => {
28
+ setLoading(true)
29
+ setError(null)
30
+ try {
31
+ const models = await loadModelPickerOptions(providerId, cwd, forceRefresh)
32
+ if (models.length === 0) {
33
+ setOptions([{
34
+ name: "No models available",
35
+ description: providerId === "opencode" ? "Run opencode auth login or /connect" : "No models found",
36
+ }])
37
+ } else {
38
+ setOptions(models.map(toSelectOption))
39
+ }
40
+ } catch (err) {
41
+ const message = formatShellError(err)
42
+ setError(message)
43
+ setOptions([{ name: "Unavailable", description: message }])
44
+ } finally {
45
+ setLoading(false)
46
+ }
47
+ }, [cwd, providerId])
48
+
49
+ useEffect(() => {
50
+ load().catch(() => {})
51
+ }, [load])
52
+
53
+ useKeyboard((key) => {
54
+ if (key.name === "escape") {
55
+ onCancel()
56
+ return
57
+ }
58
+ if (key.name === "r" && providerId === "opencode") {
59
+ load(true).catch(() => {})
60
+ }
61
+ })
62
+
63
+ const hint = providerId === "opencode"
64
+ ? " Enter: select | r: refresh | Escape: cancel"
65
+ : " Enter: select | Escape: cancel"
66
+
67
+ return (
68
+ <box flexDirection="column" flexGrow={1} border borderStyle="rounded" title={title}>
69
+ <box height={1} />
70
+ <text fg="#888888">{hint}</text>
71
+ <box height={1} />
72
+ {error && <text fg="#ff5555">{` Error: ${error}`}</text>}
73
+ {loading && <text fg="#888888">{" Loading models..."}</text>}
74
+ <select
75
+ flexGrow={1}
76
+ focused
77
+ options={options}
78
+ selectedBackgroundColor="#333333"
79
+ selectedTextColor="#ffffff"
80
+ onSelect={(_index: number, option: SelectOption | null) => {
81
+ if (!option?.value) return
82
+ onSelect(option.value)
83
+ }}
84
+ />
85
+ </box>
86
+ )
87
+ }
88
+
89
+ function toSelectOption(option: ModelPickerOption): SelectOption {
90
+ // Strip provider prefix from labels (e.g. "Claude / Sonnet" → "Sonnet")
91
+ const label = option.label.replace(/^(?:Claude|Codex|OpenCode)\s*\/\s*/, "")
92
+ return {
93
+ name: label,
94
+ description: option.description ?? "",
95
+ value: option.value,
96
+ }
97
+ }
@@ -0,0 +1,46 @@
1
+ import { useState } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+
4
+ interface PostUpdatePromptProps {
5
+ programSlug: string
6
+ onStartRun: () => void
7
+ onGoHome: () => void
8
+ }
9
+
10
+ export function PostUpdatePrompt({ programSlug, onStartRun, onGoHome }: PostUpdatePromptProps) {
11
+ const [selected, setSelected] = useState(0)
12
+
13
+ useKeyboard((key) => {
14
+ if (key.name === "up" || key.name === "k") {
15
+ setSelected(0)
16
+ } else if (key.name === "down" || key.name === "j") {
17
+ setSelected(1)
18
+ } else if (key.name === "return") {
19
+ if (selected === 0) onStartRun()
20
+ else onGoHome()
21
+ } else if (key.name === "escape") {
22
+ onGoHome()
23
+ }
24
+ })
25
+
26
+ return (
27
+ <box flexDirection="column" flexGrow={1} border borderStyle="rounded" title="Program Updated">
28
+ <box flexDirection="column" padding={1}>
29
+ <text fg="#9ece6a"><strong>Program updated</strong></text>
30
+ <box height={1} />
31
+ <text selectable>Program: {programSlug}</text>
32
+ <box height={1} />
33
+ <text><strong>What would you like to do?</strong></text>
34
+ <box height={1} />
35
+ <text fg={selected === 0 ? "#ffffff" : "#888888"}>
36
+ {selected === 0 ? " > " : " "}
37
+ Start a new run
38
+ </text>
39
+ <text fg={selected === 1 ? "#ffffff" : "#888888"}>
40
+ {selected === 1 ? " > " : " "}
41
+ Go back to home
42
+ </text>
43
+ </box>
44
+ </box>
45
+ )
46
+ }
@@ -0,0 +1,172 @@
1
+ import { memo, useState, useEffect, useMemo } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import type { ExperimentResult, ExperimentStatus } from "../lib/run.ts"
4
+ import { parseSecondaryValues } from "../lib/run.ts"
5
+ import type { SecondaryMetric } from "../lib/programs.ts"
6
+ import { allocateColumnWidths, formatCell, type ColumnSpec } from "../lib/format.ts"
7
+
8
+ interface ResultsTableProps {
9
+ results: ExperimentResult[]
10
+ metricField: string
11
+ secondaryMetrics?: Record<string, SecondaryMetric>
12
+ width: number
13
+ experimentNumber?: number
14
+ focused?: boolean
15
+ selectedResult?: ExperimentResult | null
16
+ onSelect?: (result: ExperimentResult) => void
17
+ }
18
+
19
+ export function statusColor(status: ExperimentStatus): string {
20
+ switch (status) {
21
+ case "keep": return "#9ece6a"
22
+ case "discard": return "#ff5555"
23
+ case "crash": return "#ff5555"
24
+ case "measurement_failure": return "#e0af68"
25
+ }
26
+ }
27
+
28
+ const ResultRow = memo(function ResultRow({ result: r, secondaryFields, highlighted, selected, columnWidths, lineWidth, rowWidth }: {
29
+ result: ExperimentResult
30
+ secondaryFields: string[]
31
+ highlighted?: boolean
32
+ selected?: boolean
33
+ columnWidths: number[]
34
+ lineWidth: number
35
+ rowWidth: number
36
+ }) {
37
+ const bg = selected ? "#3d59a1" : highlighted ? "#292e42" : undefined
38
+ const fg = statusColor(r.status)
39
+
40
+ const secondaryValues = secondaryFields.length > 0 ? parseSecondaryValues(r.secondary_values) : null
41
+ const fixedCells = [
42
+ formatCell(String(r.experiment_number), columnWidths[0]),
43
+ formatCell(r.commit, columnWidths[1]),
44
+ formatCell(r.metric_value != null ? String(r.metric_value) : "—", columnWidths[2]),
45
+ ]
46
+ const secondaryCells = secondaryFields.map((field, i) => {
47
+ const val = secondaryValues?.secondary_metrics[field]
48
+ return formatCell(val != null ? String(val) : "—", columnWidths[3 + i])
49
+ })
50
+ const trailingCells = [
51
+ formatCell(r.status, columnWidths[3 + secondaryFields.length]),
52
+ formatCell(r.description, columnWidths[4 + secondaryFields.length]),
53
+ ]
54
+
55
+ const line = formatCell(`${fixedCells.join("")}${secondaryCells.join("")}${trailingCells.join("")}`, lineWidth)
56
+
57
+ return (
58
+ <box width={rowWidth} paddingX={1} backgroundColor={bg}>
59
+ <text width={lineWidth} fg={fg} selectable>
60
+ {line}
61
+ </text>
62
+ </box>
63
+ )
64
+ })
65
+
66
+ // outer border (2) + paddingX (2)
67
+ const CHROME_WIDTH = 4
68
+ const ROW_CHROME_WIDTH = 2
69
+
70
+ function labelWidth(label: string, base: number, max: number): number {
71
+ return Math.min(Math.max(base, label.length + 2), max)
72
+ }
73
+
74
+ export function ResultsTable({ results, metricField, secondaryMetrics, width, experimentNumber, focused, selectedResult, onSelect }: ResultsTableProps) {
75
+ const secondaryFields = useMemo(() => secondaryMetrics ? Object.keys(secondaryMetrics) : [], [secondaryMetrics])
76
+
77
+ // Skip the baseline row (#0) — it's in the stats header
78
+ const experiments = results.filter(r => r.experiment_number > 0)
79
+ const innerWidth = Math.max(width - CHROME_WIDTH, 0)
80
+ const rowWidth = innerWidth + ROW_CHROME_WIDTH
81
+
82
+ const columnSpecs = useMemo(() => {
83
+ const fixedCols: ColumnSpec[] = [
84
+ { ideal: 4, min: 3 }, // #
85
+ { ideal: 10, min: 0 }, // commit collapses first on narrow terminals
86
+ { ideal: labelWidth(metricField, 16, 24), min: 8 },
87
+ ]
88
+ const secondaryCols = secondaryFields.map((field) => ({
89
+ ideal: labelWidth(field, 12, 18),
90
+ min: 8,
91
+ }))
92
+ const statusCol = { ideal: 20, min: 8 }
93
+ const usedBeforeDescription = [...fixedCols, ...secondaryCols, statusCol]
94
+ .reduce((sum, spec) => sum + spec.ideal, 0)
95
+ const trailingCols: ColumnSpec[] = [
96
+ statusCol,
97
+ { ideal: Math.max(innerWidth - usedBeforeDescription, 24), min: 8 },
98
+ ]
99
+ return [...fixedCols, ...secondaryCols, ...trailingCols]
100
+ }, [metricField, secondaryFields, innerWidth])
101
+
102
+ const columnWidths = useMemo(() => allocateColumnWidths(innerWidth, columnSpecs), [innerWidth, columnSpecs])
103
+
104
+ const [highlightIndex, setHighlightIndex] = useState(0)
105
+
106
+ // Reset highlight to latest row when table gains focus
107
+ useEffect(() => {
108
+ if (focused && experiments.length > 0) {
109
+ setHighlightIndex(experiments.length - 1)
110
+ }
111
+ }, [focused, experiments.length])
112
+
113
+ useKeyboard((key) => {
114
+ if (!focused || experiments.length === 0) return
115
+
116
+ if (key.name === "up" || key.name === "k") {
117
+ setHighlightIndex(i => Math.max(0, i - 1))
118
+ } else if (key.name === "down" || key.name === "j") {
119
+ setHighlightIndex(i => Math.min(experiments.length - 1, i + 1))
120
+ } else if (key.name === "enter") {
121
+ onSelect?.(experiments[highlightIndex])
122
+ }
123
+ })
124
+
125
+ // Keep highlight in bounds when results change
126
+ const safeHighlight = experiments.length > 0 ? Math.min(highlightIndex, experiments.length - 1) : 0
127
+
128
+ // Build header
129
+ const headerCells = [
130
+ formatCell("#", columnWidths[0]),
131
+ formatCell("commit", columnWidths[1]),
132
+ formatCell(metricField, columnWidths[2]),
133
+ ...secondaryFields.map((field, i) => formatCell(field, columnWidths[3 + i])),
134
+ formatCell("status", columnWidths[3 + secondaryFields.length]),
135
+ formatCell("description", columnWidths[4 + secondaryFields.length]),
136
+ ]
137
+ const headerLine = formatCell(headerCells.join(""), innerWidth)
138
+
139
+ return (
140
+ <box flexDirection="column" flexGrow={1}>
141
+ <box width={rowWidth} paddingX={1}>
142
+ <text width={innerWidth} fg="#ffffff">
143
+ {headerLine}
144
+ </text>
145
+ </box>
146
+ <scrollbox flexGrow={1} stickyScroll={!focused} stickyStart="bottom">
147
+ {experiments.length === 0 ? (
148
+ <box width={rowWidth} paddingX={1}>
149
+ <text width={innerWidth} fg="#ffffff">
150
+ {formatCell(experimentNumber != null && experimentNumber > 0
151
+ ? `Running experiment #${experimentNumber}...`
152
+ : "Running baseline measurement...", innerWidth)}
153
+ </text>
154
+ </box>
155
+ ) : (
156
+ experiments.map((r, i) => (
157
+ <ResultRow
158
+ key={r.experiment_number}
159
+ result={r}
160
+ secondaryFields={secondaryFields}
161
+ columnWidths={columnWidths}
162
+ lineWidth={innerWidth}
163
+ rowWidth={rowWidth}
164
+ highlighted={focused && i === safeHighlight}
165
+ selected={selectedResult?.experiment_number === r.experiment_number}
166
+ />
167
+ ))
168
+ )}
169
+ </scrollbox>
170
+ </box>
171
+ )
172
+ }
@@ -0,0 +1,90 @@
1
+ import { useState } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import type { RunState } from "../lib/run.ts"
4
+ import { getRunStats } from "../lib/run.ts"
5
+ import type { TerminationReason } from "../lib/experiment-loop.ts"
6
+
7
+ interface RunCompletePromptProps {
8
+ state: RunState
9
+ direction: "lower" | "higher"
10
+ terminationReason: TerminationReason | null
11
+ error: string | null
12
+ onFinalize: () => void
13
+ onAbandon: () => void
14
+ onUpdateProgram: () => void
15
+ }
16
+
17
+ export function RunCompletePrompt({
18
+ state,
19
+ direction,
20
+ terminationReason,
21
+ error,
22
+ onFinalize,
23
+ onAbandon,
24
+ onUpdateProgram,
25
+ }: RunCompletePromptProps) {
26
+ const [selected, setSelected] = useState(0)
27
+ const stats = getRunStats(state, direction)
28
+
29
+ useKeyboard((key) => {
30
+ if (key.name === "up" || key.name === "k") {
31
+ setSelected((s) => Math.max(0, s - 1))
32
+ } else if (key.name === "down" || key.name === "j") {
33
+ setSelected((s) => Math.min(2, s + 1))
34
+ } else if (key.name === "return") {
35
+ if (selected === 0) onFinalize()
36
+ else if (selected === 1) onUpdateProgram()
37
+ else onAbandon()
38
+ } else if (key.name === "f") {
39
+ onFinalize()
40
+ } else if (key.name === "u") {
41
+ onUpdateProgram()
42
+ } else if (key.name === "a") {
43
+ onAbandon()
44
+ }
45
+ })
46
+
47
+ const reasonLabel =
48
+ terminationReason === "aborted" ? "Aborted by user"
49
+ : terminationReason === "max_experiments" ? `Reached max experiments (${state.experiment_number})`
50
+ : terminationReason === "stagnation" ? "Stopped — no improvements (stagnation)"
51
+ : "Run complete"
52
+
53
+ const improvementStr = stats.improvement_pct !== 0
54
+ ? ` (${stats.improvement_pct > 0 ? "+" : ""}${stats.improvement_pct.toFixed(1)}%)`
55
+ : ""
56
+
57
+ return (
58
+ <box flexDirection="column" flexGrow={1} border borderStyle="rounded" title="Run Complete">
59
+ <box flexDirection="column" padding={1}>
60
+ <text fg="#9ece6a" selectable><strong>{reasonLabel}</strong></text>
61
+ <box height={1} />
62
+ <text selectable>Program: {state.program_slug}</text>
63
+ <text selectable>Branch: {state.branch_name}</text>
64
+ <text selectable>Experiments: {stats.total_experiments} ({stats.total_keeps} kept, {stats.total_discards} discarded, {stats.total_crashes} crashed)</text>
65
+ <text selectable>Original baseline: {state.original_baseline}</text>
66
+ <text selectable>Best metric: {state.best_metric}{improvementStr}</text>
67
+ {stats.total_keeps > 0 && (
68
+ <text selectable>Keep rate: {(stats.keep_rate * 100).toFixed(0)}%</text>
69
+ )}
70
+ {error && <text fg="#ff5555" selectable>Error: {error}</text>}
71
+
72
+ <box height={1} />
73
+ <text><strong>What would you like to do?</strong></text>
74
+ <box height={1} />
75
+ <text fg={selected === 0 ? "#ffffff" : "#888888"}>
76
+ {selected === 0 ? " > " : " "}
77
+ Finalize (review & package changes)
78
+ </text>
79
+ <text fg={selected === 1 ? "#ffffff" : "#888888"}>
80
+ {selected === 1 ? " > " : " "}
81
+ Update Program (edit config/scripts)
82
+ </text>
83
+ <text fg={selected === 2 ? "#ffffff" : "#888888"}>
84
+ {selected === 2 ? " > " : " "}
85
+ Abandon (keep branch as-is)
86
+ </text>
87
+ </box>
88
+ </box>
89
+ )
90
+ }
@@ -0,0 +1,49 @@
1
+ interface RunSettingsOverlayProps {
2
+ maxExpText: string
3
+ experimentNumber: number
4
+ validationError: string | null
5
+ }
6
+
7
+ export function RunSettingsOverlay({ maxExpText, experimentNumber, validationError }: RunSettingsOverlayProps) {
8
+ const parsed = parseInt(maxExpText, 10)
9
+ const hasMax = !isNaN(parsed) && parsed > 0
10
+
11
+ return (
12
+ <box
13
+ position="absolute"
14
+ left={0}
15
+ top={0}
16
+ width="100%"
17
+ height="100%"
18
+ justifyContent="center"
19
+ alignItems="center"
20
+ zIndex={100}
21
+ >
22
+ <box
23
+ border
24
+ borderStyle="rounded"
25
+ title="Run Settings"
26
+ flexDirection="column"
27
+ paddingX={1}
28
+ width={50}
29
+ backgroundColor="#1a1b26"
30
+ >
31
+ <box>
32
+ <text>
33
+ <span fg="#7aa2f7"><strong>{`Max Experiments: ${maxExpText}`}<span fg="#7aa2f7">{"\u2588"}</span></strong></span>
34
+ {" "}
35
+ {hasMax && (
36
+ <span fg="#666666">{`(${experimentNumber} of ${parsed} done)`}</span>
37
+ )}
38
+ </text>
39
+ </box>
40
+ {validationError ? (
41
+ <text fg="#ff5555" selectable>{validationError}</text>
42
+ ) : (
43
+ <text fg="#888888">Type a number to set the experiment limit</text>
44
+ )}
45
+ <text fg="#666666">Esc: close</text>
46
+ </box>
47
+ </box>
48
+ )
49
+ }
@@ -0,0 +1,219 @@
1
+ import { memo } from "react"
2
+ import type { RunInfo, RunState } from "../lib/run.ts"
3
+ import type { ProgramConfig } from "../lib/programs.ts"
4
+ import { allocateColumnWidths, formatCell } from "../lib/format.ts"
5
+ import { formatModelSlot, type EffortLevel } from "../lib/config.ts"
6
+
7
+ export interface RunsTableProps {
8
+ runs: RunInfo[]
9
+ /** Map from program_slug → ProgramConfig for computing gains */
10
+ programConfigs: Record<string, ProgramConfig>
11
+ width: number
12
+ focused?: boolean
13
+ selectedIndex?: number
14
+ }
15
+
16
+ function phaseColor(state: RunState | null): string {
17
+ if (!state) return "#666666"
18
+ switch (state.phase) {
19
+ case "agent_running":
20
+ case "measuring":
21
+ case "baseline":
22
+ return "#7aa2f7" // blue — in progress
23
+ case "complete":
24
+ return "#9ece6a" // green
25
+ case "crashed":
26
+ return "#ff5555" // red
27
+ case "stopping":
28
+ case "finalizing":
29
+ return "#e0af68" // yellow
30
+ default:
31
+ return "#666666" // dim
32
+ }
33
+ }
34
+
35
+ function formatTokens(n: number | undefined): string {
36
+ if (n == null || n === 0) return "—"
37
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
38
+ if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`
39
+ return String(n)
40
+ }
41
+
42
+ function formatDuration(startedAt: string, updatedAt: string, phase: string): string {
43
+ const start = new Date(startedAt).getTime()
44
+ const end = phase === "complete" || phase === "crashed"
45
+ ? new Date(updatedAt).getTime()
46
+ : Date.now()
47
+ const ms = Math.max(0, end - start)
48
+ const totalSec = Math.floor(ms / 1000)
49
+ const hours = Math.floor(totalSec / 3600)
50
+ const minutes = Math.floor((totalSec % 3600) / 60)
51
+ const seconds = totalSec % 60
52
+ if (hours > 0) return `${hours}h ${minutes}m`
53
+ return `${minutes}m ${seconds}s`
54
+ }
55
+
56
+ function formatGains(
57
+ state: RunState,
58
+ config: ProgramConfig | undefined,
59
+ ): { text: string; color: string } {
60
+ const hasKeeps = state.total_keeps > 0
61
+
62
+ if (!hasKeeps) {
63
+ return { text: "—", color: "#666666" }
64
+ }
65
+
66
+ if (!config) {
67
+ return { text: "—", color: "#666666" }
68
+ }
69
+
70
+ const original = state.original_baseline
71
+ const best = state.best_metric
72
+
73
+ if (original === 0) {
74
+ return { text: "—", color: "#666666" }
75
+ }
76
+
77
+ const absDelta = best - original
78
+ const relPct = ((best - original) / Math.abs(original)) * 100
79
+
80
+ const isGood = config.direction === "lower" ? absDelta < 0 : absDelta > 0
81
+ const color = isGood ? "#9ece6a" : "#ff5555"
82
+
83
+ const sign = absDelta >= 0 ? "+" : ""
84
+ const absStr = Math.abs(absDelta) >= 1000
85
+ ? `${sign}${(absDelta / 1000).toFixed(1)}K`
86
+ : `${sign}${Number(absDelta.toFixed(2))}`
87
+ const relStr = `${sign}${relPct.toFixed(1)}%`
88
+
89
+ return { text: `${absStr} (${relStr})`, color }
90
+ }
91
+
92
+ const VALID_EFFORTS = new Set<string>(["low", "medium", "high", "max"])
93
+
94
+ function formatModelEffort(state: RunState): string {
95
+ if (!state.model) return "—"
96
+ const provider = state.provider === "opencode" || state.provider === "codex" ? state.provider : "claude"
97
+ const effort: EffortLevel = VALID_EFFORTS.has(state.effort ?? "") ? state.effort as EffortLevel : "high"
98
+ if (provider === "opencode") {
99
+ return formatModelSlot({ provider, model: state.model, effort }, true)
100
+ }
101
+ // Short format: "sonnet/high", "cx/sonnet/high", "opus/max"
102
+ const prefix = provider === "codex" ? "cx/" : ""
103
+ return state.effort ? `${prefix}${state.model}/${state.effort}` : `${prefix}${state.model}`
104
+ }
105
+
106
+
107
+
108
+ const COL_STATUS = 2 // color dot
109
+ const COL_PROGRAM = 30
110
+ const COL_EXP = 5 // "##"
111
+ const COL_MODEL = 16 // "cx/sonnet/high" or "sonnet/high"
112
+ const COL_TOKENS = 7
113
+ const COL_TIME = 9
114
+ const COL_GAINS_MIN = 16
115
+ const CHROME = 4 // border + padding
116
+
117
+ interface RunColumnWidths {
118
+ status: number
119
+ program: number
120
+ exp: number
121
+ model: number
122
+ tokens: number
123
+ time: number
124
+ gains: number
125
+ }
126
+
127
+ const RunRow = memo(function RunRow({
128
+ run,
129
+ config,
130
+ widths,
131
+ selected,
132
+ }: {
133
+ run: RunInfo
134
+ config: ProgramConfig | undefined
135
+ widths: RunColumnWidths
136
+ selected: boolean
137
+ }) {
138
+ const state = run.state
139
+ if (!state) return null
140
+
141
+ const dotColor = phaseColor(state)
142
+ const totalExp = state.total_keeps + state.total_discards + state.total_crashes
143
+ const gains = formatGains(state, config)
144
+ const slug = state.program_slug
145
+
146
+ return (
147
+ <box paddingX={1} backgroundColor={selected ? "#333333" : undefined}>
148
+ <text selectable>
149
+ <span fg={dotColor}>{formatCell("● ", widths.status)}</span>
150
+ <span fg="#ffffff">{formatCell(slug, widths.program)}</span>
151
+ <span fg="#ffffff">{formatCell(String(totalExp), widths.exp)}</span>
152
+ <span fg="#ffffff">{formatCell(formatModelEffort(state), widths.model)}</span>
153
+ <span fg="#ffffff">{formatCell(formatTokens(state.total_tokens), widths.tokens)}</span>
154
+ <span fg="#ffffff">{formatCell(formatDuration(state.started_at, state.updated_at, state.phase), widths.time)}</span>
155
+ <span fg={gains.color}>{formatCell(gains.text, widths.gains)}</span>
156
+ </text>
157
+ </box>
158
+ )
159
+ })
160
+
161
+ export function RunsTable({ runs, programConfigs, width, focused = false, selectedIndex = 0 }: RunsTableProps) {
162
+ const innerWidth = Math.max(width - CHROME, 0)
163
+ const fixedWidth = COL_STATUS + COL_PROGRAM + COL_EXP + COL_MODEL + COL_TOKENS + COL_TIME
164
+ const [statusWidth, programWidth, expWidth, modelWidth, tokensWidth, timeWidth, gainsWidth] = allocateColumnWidths(innerWidth, [
165
+ { ideal: COL_STATUS, min: 0 },
166
+ { ideal: COL_PROGRAM, min: 8 },
167
+ { ideal: COL_EXP, min: 0 },
168
+ { ideal: COL_MODEL, min: 0 },
169
+ { ideal: COL_TOKENS, min: 0 },
170
+ { ideal: COL_TIME, min: 0 },
171
+ { ideal: Math.max(innerWidth - fixedWidth, COL_GAINS_MIN), min: 0 },
172
+ ])
173
+ const widths = {
174
+ status: statusWidth,
175
+ program: programWidth,
176
+ exp: expWidth,
177
+ model: modelWidth,
178
+ tokens: tokensWidth,
179
+ time: timeWidth,
180
+ gains: gainsWidth,
181
+ }
182
+
183
+ const validRuns = runs.filter((r) => r.state != null)
184
+
185
+ return (
186
+ <box flexDirection="column" flexGrow={1}>
187
+ {/* Header */}
188
+ <box paddingX={1}>
189
+ <text fg="#666666">
190
+ {formatCell("", widths.status)}
191
+ {formatCell("program", widths.program)}
192
+ {formatCell("exp", widths.exp)}
193
+ {formatCell("model", widths.model)}
194
+ {formatCell("tokens", widths.tokens)}
195
+ {formatCell("time", widths.time)}
196
+ {formatCell("gains", widths.gains)}
197
+ </text>
198
+ </box>
199
+
200
+ <scrollbox flexGrow={1} stickyScroll stickyStart="bottom">
201
+ {validRuns.length === 0 ? (
202
+ <box paddingX={1}>
203
+ <text fg="#666666">No runs yet.</text>
204
+ </box>
205
+ ) : (
206
+ validRuns.map((run, index) => (
207
+ <RunRow
208
+ key={`${run.state!.program_slug}-${run.run_id}`}
209
+ run={run}
210
+ config={programConfigs[run.state!.program_slug]}
211
+ widths={widths}
212
+ selected={focused && index === selectedIndex}
213
+ />
214
+ ))
215
+ )}
216
+ </scrollbox>
217
+ </box>
218
+ )
219
+ }