@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.
- package/README.md +197 -0
- package/package.json +51 -0
- package/src/App.tsx +224 -0
- package/src/cli.ts +772 -0
- package/src/components/AgentPanel.tsx +254 -0
- package/src/components/Chat.test.tsx +71 -0
- package/src/components/Chat.tsx +308 -0
- package/src/components/CycleField.tsx +23 -0
- package/src/components/ModelPicker.tsx +97 -0
- package/src/components/PostUpdatePrompt.tsx +46 -0
- package/src/components/ResultsTable.tsx +172 -0
- package/src/components/RunCompletePrompt.tsx +90 -0
- package/src/components/RunSettingsOverlay.tsx +49 -0
- package/src/components/RunsTable.tsx +219 -0
- package/src/components/StatsHeader.tsx +100 -0
- package/src/daemon.ts +264 -0
- package/src/index.tsx +8 -0
- package/src/lib/agent/agent-provider.test.ts +133 -0
- package/src/lib/agent/claude-provider.ts +277 -0
- package/src/lib/agent/codex-provider.ts +413 -0
- package/src/lib/agent/default-providers.ts +10 -0
- package/src/lib/agent/index.ts +32 -0
- package/src/lib/agent/mock-provider.ts +61 -0
- package/src/lib/agent/opencode-provider.ts +424 -0
- package/src/lib/agent/types.ts +73 -0
- package/src/lib/auth.ts +11 -0
- package/src/lib/config.ts +152 -0
- package/src/lib/daemon-callbacks.ts +59 -0
- package/src/lib/daemon-client.ts +16 -0
- package/src/lib/daemon-lifecycle.ts +368 -0
- package/src/lib/daemon-spawn.ts +122 -0
- package/src/lib/daemon-status.ts +189 -0
- package/src/lib/daemon-watcher.ts +192 -0
- package/src/lib/experiment-loop.ts +679 -0
- package/src/lib/experiment.ts +356 -0
- package/src/lib/finalize.test.ts +143 -0
- package/src/lib/finalize.ts +511 -0
- package/src/lib/format.test.ts +32 -0
- package/src/lib/format.ts +44 -0
- package/src/lib/git.ts +176 -0
- package/src/lib/ideas-backlog.test.ts +54 -0
- package/src/lib/ideas-backlog.ts +109 -0
- package/src/lib/measure.ts +472 -0
- package/src/lib/model-options.ts +24 -0
- package/src/lib/programs.ts +247 -0
- package/src/lib/push-stream.ts +48 -0
- package/src/lib/run-context.ts +112 -0
- package/src/lib/run-setup.ts +34 -0
- package/src/lib/run.ts +383 -0
- package/src/lib/syntax-theme.ts +39 -0
- package/src/lib/system-prompts/experiment.ts +77 -0
- package/src/lib/system-prompts/finalize.ts +90 -0
- package/src/lib/system-prompts/index.ts +7 -0
- package/src/lib/system-prompts/setup.ts +516 -0
- package/src/lib/system-prompts/update.ts +188 -0
- package/src/lib/tool-events.ts +99 -0
- package/src/lib/validate-measurement.ts +326 -0
- package/src/lib/worktree.ts +40 -0
- package/src/screens/AuthErrorScreen.tsx +31 -0
- package/src/screens/ExecutionScreen.tsx +851 -0
- package/src/screens/FirstSetupScreen.tsx +168 -0
- package/src/screens/HomeScreen.tsx +406 -0
- package/src/screens/PreRunScreen.tsx +206 -0
- package/src/screens/SettingsScreen.tsx +189 -0
- package/src/screens/SetupScreen.tsx +226 -0
- package/src/tui.tsx +17 -0
- 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
|
+
}
|