@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,206 @@
|
|
|
1
|
+
import { useState, useEffect } from "react"
|
|
2
|
+
import { useKeyboard } from "@opentui/react"
|
|
3
|
+
import {
|
|
4
|
+
type Screen,
|
|
5
|
+
type ProgramConfig,
|
|
6
|
+
getProgramDir,
|
|
7
|
+
loadProgramConfig,
|
|
8
|
+
} from "../lib/programs.ts"
|
|
9
|
+
import { getLatestRun, readAllResults, getAvgMeasurementDuration } from "../lib/run.ts"
|
|
10
|
+
import {
|
|
11
|
+
type ModelSlot,
|
|
12
|
+
cycleChoice,
|
|
13
|
+
formatEffortSlot,
|
|
14
|
+
formatModelSlot,
|
|
15
|
+
getEffortChoicesForSlot,
|
|
16
|
+
isEffortConfigurable,
|
|
17
|
+
mergeSelectedModelSlot,
|
|
18
|
+
PROVIDER_CHOICES,
|
|
19
|
+
PROVIDER_LABELS,
|
|
20
|
+
} from "../lib/config.ts"
|
|
21
|
+
import { CycleField } from "../components/CycleField.tsx"
|
|
22
|
+
import { ModelPicker } from "../components/ModelPicker.tsx"
|
|
23
|
+
|
|
24
|
+
export interface PreRunOverrides {
|
|
25
|
+
modelConfig: ModelSlot
|
|
26
|
+
maxExperiments: number
|
|
27
|
+
useWorktree: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface PreRunScreenProps {
|
|
31
|
+
cwd: string
|
|
32
|
+
programSlug: string
|
|
33
|
+
defaultModelConfig: ModelSlot
|
|
34
|
+
navigate: (screen: Screen) => void
|
|
35
|
+
onStart: (overrides: PreRunOverrides) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 0=maxExperiments, 1=provider, 2=model, 3=effort, 4=runMode
|
|
39
|
+
const FIELD_COUNT = 5
|
|
40
|
+
|
|
41
|
+
export function PreRunScreen({ cwd, programSlug, defaultModelConfig, navigate, onStart }: PreRunScreenProps) {
|
|
42
|
+
const [selected, setSelected] = useState(0)
|
|
43
|
+
const [maxExpText, setMaxExpText] = useState("")
|
|
44
|
+
const [modelSlot, setModelSlot] = useState<ModelSlot>(defaultModelConfig)
|
|
45
|
+
const [useWorktree, setUseWorktree] = useState(true)
|
|
46
|
+
const [pickingModel, setPickingModel] = useState(false)
|
|
47
|
+
const [programConfig, setProgramConfig] = useState<ProgramConfig | null>(null)
|
|
48
|
+
const [avgDurationMs, setAvgDurationMs] = useState<number | null>(null)
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const programDir = getProgramDir(cwd, programSlug)
|
|
52
|
+
loadProgramConfig(programDir).then((config) => {
|
|
53
|
+
setProgramConfig(config)
|
|
54
|
+
if (config.max_experiments) {
|
|
55
|
+
setMaxExpText(String(config.max_experiments))
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
getLatestRun(programDir).then(async (run) => {
|
|
59
|
+
if (!run) return
|
|
60
|
+
const results = await readAllResults(run.run_dir)
|
|
61
|
+
setAvgDurationMs(getAvgMeasurementDuration(results))
|
|
62
|
+
})
|
|
63
|
+
}, [cwd, programSlug])
|
|
64
|
+
|
|
65
|
+
function handleStart() {
|
|
66
|
+
const parsed = parseInt(maxExpText, 10)
|
|
67
|
+
if (isNaN(parsed) || parsed < 1) return // require a valid limit
|
|
68
|
+
onStart({ modelConfig: modelSlot, maxExperiments: parsed, useWorktree })
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function handleCycleProvider(direction: -1 | 1) {
|
|
72
|
+
setModelSlot((slot) => {
|
|
73
|
+
const nextProvider = cycleChoice(PROVIDER_CHOICES, slot.provider, direction)
|
|
74
|
+
const defaultModel = nextProvider === "claude" ? "sonnet" : "default"
|
|
75
|
+
return { provider: nextProvider, model: defaultModel, effort: slot.effort }
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function handleCycleEffort(direction: -1 | 1) {
|
|
80
|
+
if (!isEffortConfigurable(modelSlot)) return
|
|
81
|
+
const validEfforts = getEffortChoicesForSlot(modelSlot)
|
|
82
|
+
setModelSlot((slot) => ({ ...slot, effort: cycleChoice(validEfforts, slot.effort, direction) }))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
useKeyboard((key) => {
|
|
86
|
+
if (pickingModel) return
|
|
87
|
+
if (key.name === "escape") {
|
|
88
|
+
navigate("home")
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (key.name === "return") {
|
|
92
|
+
if (selected === 2) {
|
|
93
|
+
setPickingModel(true)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
handleStart()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Navigation
|
|
101
|
+
if (key.name === "tab" || key.name === "down" || key.name === "j") {
|
|
102
|
+
setSelected((s) => Math.min(FIELD_COUNT - 1, s + 1))
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (key.name === "shift-tab" || key.name === "up" || key.name === "k") {
|
|
106
|
+
setSelected((s) => Math.max(0, s - 1))
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Field-specific input
|
|
111
|
+
if (selected === 0) {
|
|
112
|
+
if (key.name === "backspace") setMaxExpText((t) => t.slice(0, -1))
|
|
113
|
+
else if (/^\d$/.test(key.name)) setMaxExpText((t) => t + key.name)
|
|
114
|
+
} else if (selected === 1) {
|
|
115
|
+
if (key.name === "left" || key.name === "h") handleCycleProvider(-1)
|
|
116
|
+
if (key.name === "right" || key.name === "l") handleCycleProvider(1)
|
|
117
|
+
} else if (selected === 2) {
|
|
118
|
+
if (key.name === "left" || key.name === "h" || key.name === "right" || key.name === "l") setPickingModel(true)
|
|
119
|
+
} else if (selected === 3) {
|
|
120
|
+
if (key.name === "left" || key.name === "h") handleCycleEffort(-1)
|
|
121
|
+
if (key.name === "right" || key.name === "l") handleCycleEffort(1)
|
|
122
|
+
} else if (selected === 4) {
|
|
123
|
+
if (key.name === "left" || key.name === "h" || key.name === "right" || key.name === "l") {
|
|
124
|
+
setUseWorktree((v) => !v)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// Time estimate
|
|
130
|
+
const avgMs = avgDurationMs
|
|
131
|
+
const repeats = programConfig?.repeats ?? 3
|
|
132
|
+
const maxExp = parseInt(maxExpText, 10)
|
|
133
|
+
const hasMaxExp = !isNaN(maxExp) && maxExp > 0
|
|
134
|
+
const effortDisplay = formatEffortSlot(modelSlot)
|
|
135
|
+
|
|
136
|
+
if (pickingModel) {
|
|
137
|
+
return (
|
|
138
|
+
<ModelPicker
|
|
139
|
+
cwd={cwd}
|
|
140
|
+
title={`Run Model — ${PROVIDER_LABELS[modelSlot.provider]}`}
|
|
141
|
+
providerId={modelSlot.provider}
|
|
142
|
+
onCancel={() => setPickingModel(false)}
|
|
143
|
+
onSelect={(slot) => {
|
|
144
|
+
setModelSlot((prev) => mergeSelectedModelSlot(prev, slot))
|
|
145
|
+
setPickingModel(false)
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<box flexDirection="column" flexGrow={1} border borderStyle="rounded" title={`Run: ${programSlug}`}>
|
|
153
|
+
<box height={1} />
|
|
154
|
+
|
|
155
|
+
<box flexDirection="column">
|
|
156
|
+
<text>
|
|
157
|
+
{selected === 0 ? (
|
|
158
|
+
<span fg="#7aa2f7"><strong>{` Max Experiments: ${maxExpText || ""}`}<span fg="#7aa2f7">{"\u2588"}</span></strong></span>
|
|
159
|
+
) : (
|
|
160
|
+
` Max Experiments: ${maxExpText || "(required)"}`
|
|
161
|
+
)}
|
|
162
|
+
</text>
|
|
163
|
+
{selected === 0 && (
|
|
164
|
+
<text fg="#888888">{" Type a number (required)"}</text>
|
|
165
|
+
)}
|
|
166
|
+
</box>
|
|
167
|
+
|
|
168
|
+
<box height={1} />
|
|
169
|
+
|
|
170
|
+
<CycleField label="Provider" value={PROVIDER_LABELS[modelSlot.provider]} isFocused={selected === 1} />
|
|
171
|
+
<CycleField label="Model" value={formatModelSlot(modelSlot)} isFocused={selected === 2} />
|
|
172
|
+
<CycleField label="Effort" value={effortDisplay.label} description={effortDisplay.description} isFocused={selected === 3} />
|
|
173
|
+
|
|
174
|
+
<box height={1} />
|
|
175
|
+
|
|
176
|
+
<CycleField label="Run Mode" value={useWorktree ? "Worktree (recommended)" : "In-place"} isFocused={selected === 4} />
|
|
177
|
+
{!useWorktree && (
|
|
178
|
+
<box flexDirection="column">
|
|
179
|
+
<text fg="#ff5555">{" \u26A0 DANGER: Runs git reset --hard in your main checkout."}</text>
|
|
180
|
+
<text fg="#ff5555">{" All uncommitted changes will be destroyed between experiments."}</text>
|
|
181
|
+
<text fg="#ff5555">{" Your branch will be changed. Only use on a clean, throwaway branch."}</text>
|
|
182
|
+
</box>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
<box height={1} />
|
|
186
|
+
|
|
187
|
+
{/* Time estimate */}
|
|
188
|
+
{avgMs != null && (
|
|
189
|
+
<box flexDirection="column">
|
|
190
|
+
<text fg="#888888">
|
|
191
|
+
{` Each measurement takes ~${(avgMs / 1000).toFixed(1)}s (×${repeats} repeats)`}
|
|
192
|
+
</text>
|
|
193
|
+
{hasMaxExp && (
|
|
194
|
+
<text fg="#888888">
|
|
195
|
+
{` ${maxExp} experiments \u2248 ~${Math.ceil((avgMs * maxExp * repeats) / 60000)} min (measurement only)`}
|
|
196
|
+
</text>
|
|
197
|
+
)}
|
|
198
|
+
</box>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
<box flexGrow={1} />
|
|
202
|
+
|
|
203
|
+
<text fg="#666666">{" Enter: start/open model picker | Escape: back | Tab: next field"}</text>
|
|
204
|
+
</box>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import { useKeyboard } from "@opentui/react"
|
|
3
|
+
import type { Screen } from "../lib/programs.ts"
|
|
4
|
+
import {
|
|
5
|
+
type ProjectConfig,
|
|
6
|
+
cycleChoice,
|
|
7
|
+
formatEffortSlot,
|
|
8
|
+
formatModelSlot,
|
|
9
|
+
getEffortChoicesForSlot,
|
|
10
|
+
isEffortConfigurable,
|
|
11
|
+
mergeSelectedModelSlot,
|
|
12
|
+
saveProjectConfig,
|
|
13
|
+
PROVIDER_CHOICES,
|
|
14
|
+
PROVIDER_LABELS,
|
|
15
|
+
} from "../lib/config.ts"
|
|
16
|
+
import { CycleField } from "../components/CycleField.tsx"
|
|
17
|
+
import { ModelPicker } from "../components/ModelPicker.tsx"
|
|
18
|
+
|
|
19
|
+
interface SettingsScreenProps {
|
|
20
|
+
cwd: string
|
|
21
|
+
navigate: (screen: Screen) => void
|
|
22
|
+
config: ProjectConfig
|
|
23
|
+
onConfigChange: (config: ProjectConfig) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Row layout:
|
|
27
|
+
// 0: exec provider 1: exec model 2: exec effort
|
|
28
|
+
// 3: support provider 4: support model 5: support effort
|
|
29
|
+
// 6: ideas backlog
|
|
30
|
+
const ROW_COUNT = 7
|
|
31
|
+
|
|
32
|
+
function slotKeyForRow(row: number): "executionModel" | "supportModel" {
|
|
33
|
+
return row < 3 ? "executionModel" : "supportModel"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function SettingsScreen({ cwd, navigate, config, onConfigChange }: SettingsScreenProps) {
|
|
37
|
+
const [selected, setSelected] = useState(0)
|
|
38
|
+
const [pickingSlot, setPickingSlot] = useState<"executionModel" | "supportModel" | null>(null)
|
|
39
|
+
|
|
40
|
+
const updateConfig = (updater: (prev: ProjectConfig) => ProjectConfig) => {
|
|
41
|
+
const next = updater(config)
|
|
42
|
+
onConfigChange(next)
|
|
43
|
+
saveProjectConfig(cwd, next)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cycleProvider(slotKey: "executionModel" | "supportModel", direction: -1 | 1) {
|
|
47
|
+
updateConfig((prev) => {
|
|
48
|
+
const slot = prev[slotKey]
|
|
49
|
+
const nextProvider = cycleChoice(PROVIDER_CHOICES, slot.provider, direction)
|
|
50
|
+
// Reset model to a sensible default when switching provider
|
|
51
|
+
const defaultModel = nextProvider === "claude" ? "sonnet" : "default"
|
|
52
|
+
const effort = nextProvider === "opencode" ? slot.effort : slot.effort
|
|
53
|
+
return { ...prev, [slotKey]: { provider: nextProvider, model: defaultModel, effort } }
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cycleValue(direction: -1 | 1) {
|
|
58
|
+
// Provider rows
|
|
59
|
+
if (selected === 0) return cycleProvider("executionModel", direction)
|
|
60
|
+
if (selected === 3) return cycleProvider("supportModel", direction)
|
|
61
|
+
|
|
62
|
+
// Model rows — open picker
|
|
63
|
+
if (selected === 1 || selected === 4) {
|
|
64
|
+
setPickingSlot(selected === 1 ? "executionModel" : "supportModel")
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Ideas backlog
|
|
69
|
+
if (selected === 6) {
|
|
70
|
+
updateConfig((prev) => ({ ...prev, ideasBacklogEnabled: !prev.ideasBacklogEnabled }))
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Effort rows
|
|
75
|
+
const slotKey = slotKeyForRow(selected)
|
|
76
|
+
updateConfig((prev) => {
|
|
77
|
+
const slot = { ...prev[slotKey] }
|
|
78
|
+
if (!isEffortConfigurable(slot)) return prev
|
|
79
|
+
const validEfforts = getEffortChoicesForSlot(slot)
|
|
80
|
+
slot.effort = cycleChoice(validEfforts, slot.effort, direction)
|
|
81
|
+
return { ...prev, [slotKey]: slot }
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
useKeyboard((key) => {
|
|
86
|
+
if (pickingSlot) return
|
|
87
|
+
if (key.name === "escape") navigate("home")
|
|
88
|
+
if (key.name === "up" || key.name === "k")
|
|
89
|
+
setSelected((s) => Math.max(0, s - 1))
|
|
90
|
+
if (key.name === "down" || key.name === "j")
|
|
91
|
+
setSelected((s) => Math.min(ROW_COUNT - 1, s + 1))
|
|
92
|
+
if (key.name === "left" || key.name === "h") cycleValue(-1)
|
|
93
|
+
if (key.name === "right" || key.name === "l") cycleValue(1)
|
|
94
|
+
if (key.name === "return" && (selected === 1 || selected === 4)) {
|
|
95
|
+
setPickingSlot(selected === 1 ? "executionModel" : "supportModel")
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const execSlot = config.executionModel
|
|
100
|
+
const supportSlot = config.supportModel
|
|
101
|
+
const execEffort = formatEffortSlot(execSlot)
|
|
102
|
+
const supportEffort = formatEffortSlot(supportSlot)
|
|
103
|
+
|
|
104
|
+
if (pickingSlot) {
|
|
105
|
+
const slot = config[pickingSlot]
|
|
106
|
+
const title = pickingSlot === "executionModel" ? "Execution Model" : "Support Model"
|
|
107
|
+
return (
|
|
108
|
+
<ModelPicker
|
|
109
|
+
cwd={cwd}
|
|
110
|
+
title={`${title} — ${PROVIDER_LABELS[slot.provider]}`}
|
|
111
|
+
providerId={slot.provider}
|
|
112
|
+
onCancel={() => setPickingSlot(null)}
|
|
113
|
+
onSelect={(newSlot) => {
|
|
114
|
+
updateConfig((prev) => ({
|
|
115
|
+
...prev,
|
|
116
|
+
[pickingSlot]: mergeSelectedModelSlot(prev[pickingSlot], newSlot),
|
|
117
|
+
}))
|
|
118
|
+
setPickingSlot(null)
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<box
|
|
126
|
+
flexDirection="column"
|
|
127
|
+
flexGrow={1}
|
|
128
|
+
border
|
|
129
|
+
borderStyle="rounded"
|
|
130
|
+
title="Settings"
|
|
131
|
+
>
|
|
132
|
+
<box height={1} />
|
|
133
|
+
<box flexDirection="row">
|
|
134
|
+
<text><strong>{" Execution Model "}</strong></text>
|
|
135
|
+
<text fg="#888888">{"(experiment agents)"}</text>
|
|
136
|
+
</box>
|
|
137
|
+
<CycleField
|
|
138
|
+
label="Provider"
|
|
139
|
+
value={PROVIDER_LABELS[execSlot.provider]}
|
|
140
|
+
isFocused={selected === 0}
|
|
141
|
+
/>
|
|
142
|
+
<CycleField
|
|
143
|
+
label="Model"
|
|
144
|
+
value={formatModelSlot(execSlot)}
|
|
145
|
+
isFocused={selected === 1}
|
|
146
|
+
/>
|
|
147
|
+
<CycleField
|
|
148
|
+
label="Effort"
|
|
149
|
+
value={execEffort.label}
|
|
150
|
+
description={execEffort.description}
|
|
151
|
+
isFocused={selected === 2}
|
|
152
|
+
/>
|
|
153
|
+
<box height={1} />
|
|
154
|
+
<box flexDirection="row">
|
|
155
|
+
<text><strong>{" Support Model "}</strong></text>
|
|
156
|
+
<text fg="#888888">{"(setup & finalize)"}</text>
|
|
157
|
+
</box>
|
|
158
|
+
<CycleField
|
|
159
|
+
label="Provider"
|
|
160
|
+
value={PROVIDER_LABELS[supportSlot.provider]}
|
|
161
|
+
isFocused={selected === 3}
|
|
162
|
+
/>
|
|
163
|
+
<CycleField
|
|
164
|
+
label="Model"
|
|
165
|
+
value={formatModelSlot(supportSlot)}
|
|
166
|
+
isFocused={selected === 4}
|
|
167
|
+
/>
|
|
168
|
+
<CycleField
|
|
169
|
+
label="Effort"
|
|
170
|
+
value={supportEffort.label}
|
|
171
|
+
description={supportEffort.description}
|
|
172
|
+
isFocused={selected === 5}
|
|
173
|
+
/>
|
|
174
|
+
<box height={1} />
|
|
175
|
+
<box flexDirection="row">
|
|
176
|
+
<text><strong>{" Experiment Memory "}</strong></text>
|
|
177
|
+
<text fg="#888888">{"(ideas backlog)"}</text>
|
|
178
|
+
</box>
|
|
179
|
+
<CycleField
|
|
180
|
+
label="Ideas Backlog"
|
|
181
|
+
value={config.ideasBacklogEnabled ? "On" : "Off"}
|
|
182
|
+
description={config.ideasBacklogEnabled
|
|
183
|
+
? "Capture why experiments worked or failed and what to try next"
|
|
184
|
+
: "Use results.tsv-based experiment memory only"}
|
|
185
|
+
isFocused={selected === 6}
|
|
186
|
+
/>
|
|
187
|
+
</box>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback, useEffect } from "react"
|
|
2
|
+
import { mkdir } from "node:fs/promises"
|
|
3
|
+
import { dirname } from "node:path"
|
|
4
|
+
import { useKeyboard } from "@opentui/react"
|
|
5
|
+
import type { TextareaOptions } from "@opentui/core"
|
|
6
|
+
import { Chat } from "../components/Chat.tsx"
|
|
7
|
+
import { getSetupSystemPrompt } from "../lib/system-prompts/index.ts"
|
|
8
|
+
import { getUpdateSystemPrompt } from "../lib/system-prompts/update.ts"
|
|
9
|
+
import { loadProgramSummaries, getProgramDir, type Screen, type ProgramSummary } from "../lib/programs.ts"
|
|
10
|
+
import { buildUpdateRunContext } from "../lib/run-context.ts"
|
|
11
|
+
import { formatModelLabel, type ModelSlot } from "../lib/config.ts"
|
|
12
|
+
import { formatShellError } from "../lib/git.ts"
|
|
13
|
+
|
|
14
|
+
type OpenTUISubmitEvent = Parameters<NonNullable<TextareaOptions["onSubmit"]>>[0]
|
|
15
|
+
|
|
16
|
+
const SETUP_TOOLS = ["Read", "Write", "Edit", "Bash", "Glob", "Grep"]
|
|
17
|
+
const SETUP_MAX_TURNS = 40
|
|
18
|
+
|
|
19
|
+
type SetupMode = "choose" | "scope" | "chat"
|
|
20
|
+
|
|
21
|
+
const MODE_OPTIONS = [
|
|
22
|
+
{
|
|
23
|
+
name: "Analyze my codebase",
|
|
24
|
+
description: "Scan your project and suggest what to optimize",
|
|
25
|
+
value: "analyze",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "I know what I want to optimize",
|
|
29
|
+
description: "Describe your target and start setting up",
|
|
30
|
+
value: "direct",
|
|
31
|
+
},
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
interface SetupScreenProps {
|
|
35
|
+
cwd: string
|
|
36
|
+
navigate: (screen: Screen) => void
|
|
37
|
+
modelConfig: ModelSlot
|
|
38
|
+
/** When set, enters update mode for an existing program */
|
|
39
|
+
programSlug?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function SetupScreen({ cwd, navigate, modelConfig, programSlug }: SetupScreenProps) {
|
|
43
|
+
const isUpdate = Boolean(programSlug)
|
|
44
|
+
const [existingPrograms, setExistingPrograms] = useState<ProgramSummary[]>([])
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!isUpdate) {
|
|
48
|
+
loadProgramSummaries(cwd).then(setExistingPrograms).catch(() => {})
|
|
49
|
+
}
|
|
50
|
+
}, [cwd, isUpdate])
|
|
51
|
+
|
|
52
|
+
// Setup mode: system prompt from setup.ts
|
|
53
|
+
const setupResult = useMemo(
|
|
54
|
+
() => isUpdate ? null : getSetupSystemPrompt(cwd, existingPrograms),
|
|
55
|
+
[cwd, existingPrograms, isUpdate],
|
|
56
|
+
)
|
|
57
|
+
const [setupReady, setSetupReady] = useState(false)
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!setupResult) return
|
|
61
|
+
setSetupReady(false)
|
|
62
|
+
mkdir(dirname(setupResult.referencePath), { recursive: true })
|
|
63
|
+
.then(() => Bun.write(setupResult.referencePath, setupResult.referenceContent))
|
|
64
|
+
.then(() => setSetupReady(true))
|
|
65
|
+
.catch(() => setSetupReady(true)) // proceed anyway — agent can still work without reference
|
|
66
|
+
}, [setupResult])
|
|
67
|
+
|
|
68
|
+
const [updateSystemPrompt, setUpdateSystemPrompt] = useState<string | null>(null)
|
|
69
|
+
const [updateInitialMessage, setUpdateInitialMessage] = useState<string | null>(null)
|
|
70
|
+
const [updateLoadError, setUpdateLoadError] = useState<string | null>(null)
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!programSlug) return
|
|
74
|
+
const programDir = getProgramDir(cwd, programSlug)
|
|
75
|
+
|
|
76
|
+
Promise.all([
|
|
77
|
+
getUpdateSystemPrompt(cwd, programSlug, programDir).then(async (result) => {
|
|
78
|
+
await mkdir(dirname(result.referencePath), { recursive: true })
|
|
79
|
+
await Bun.write(result.referencePath, result.referenceContent)
|
|
80
|
+
return result.systemPrompt
|
|
81
|
+
}),
|
|
82
|
+
buildUpdateRunContext(programDir),
|
|
83
|
+
]).then(([prompt, context]) => {
|
|
84
|
+
setUpdateSystemPrompt(prompt)
|
|
85
|
+
setUpdateInitialMessage(context)
|
|
86
|
+
}).catch((err: unknown) => {
|
|
87
|
+
setUpdateLoadError(formatShellError(err))
|
|
88
|
+
})
|
|
89
|
+
}, [cwd, programSlug])
|
|
90
|
+
|
|
91
|
+
const [mode, setMode] = useState<SetupMode>(isUpdate ? "chat" : "choose")
|
|
92
|
+
const [initialMessage, setInitialMessage] = useState<string | undefined>(undefined)
|
|
93
|
+
|
|
94
|
+
useKeyboard((key) => {
|
|
95
|
+
if (key.name === "escape") {
|
|
96
|
+
if (mode === "scope") {
|
|
97
|
+
setMode("choose")
|
|
98
|
+
} else {
|
|
99
|
+
navigate("home")
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const handleModeSelect = useCallback((_index: number, option: { value?: unknown } | null) => {
|
|
105
|
+
if (!option) return
|
|
106
|
+
if (option.value === "direct") {
|
|
107
|
+
setMode("chat")
|
|
108
|
+
} else if (option.value === "analyze") {
|
|
109
|
+
setMode("scope")
|
|
110
|
+
}
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
113
|
+
const handleScopeSubmit = useCallback((value: unknown) => {
|
|
114
|
+
if (typeof value !== "string") return
|
|
115
|
+
const scope = value.trim()
|
|
116
|
+
if (scope) {
|
|
117
|
+
setInitialMessage(`What could I optimize in this codebase, focusing on ${scope}?`)
|
|
118
|
+
} else {
|
|
119
|
+
setInitialMessage("What could I optimize in this codebase?")
|
|
120
|
+
}
|
|
121
|
+
setMode("chat")
|
|
122
|
+
}, []) as
|
|
123
|
+
& ((event: OpenTUISubmitEvent) => void)
|
|
124
|
+
& ((value: string) => void)
|
|
125
|
+
|
|
126
|
+
if (isUpdate && updateLoadError) {
|
|
127
|
+
return (
|
|
128
|
+
<box flexDirection="column" flexGrow={1}>
|
|
129
|
+
<box flexDirection="column" flexGrow={1} border borderStyle="rounded" title={`Update: ${programSlug}`}>
|
|
130
|
+
<box flexGrow={1} justifyContent="center" alignItems="center">
|
|
131
|
+
<text fg="#ff5555">Failed to load program: {updateLoadError}</text>
|
|
132
|
+
</box>
|
|
133
|
+
</box>
|
|
134
|
+
</box>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (isUpdate && (!updateSystemPrompt || !updateInitialMessage)) {
|
|
139
|
+
return (
|
|
140
|
+
<box flexDirection="column" flexGrow={1}>
|
|
141
|
+
<box flexDirection="column" flexGrow={1} border borderStyle="rounded" title={`Update: ${programSlug}`}>
|
|
142
|
+
<box flexGrow={1} justifyContent="center" alignItems="center">
|
|
143
|
+
<text fg="#888888">Loading program context...</text>
|
|
144
|
+
</box>
|
|
145
|
+
</box>
|
|
146
|
+
</box>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (mode === "choose") {
|
|
151
|
+
return (
|
|
152
|
+
<box flexDirection="column" flexGrow={1}>
|
|
153
|
+
<box flexDirection="column" flexGrow={1} border borderStyle="rounded" title="New Program">
|
|
154
|
+
<box height={1} />
|
|
155
|
+
<text>{" How would you like to start?"}</text>
|
|
156
|
+
<box height={1} />
|
|
157
|
+
<select
|
|
158
|
+
flexGrow={1}
|
|
159
|
+
focused
|
|
160
|
+
options={MODE_OPTIONS}
|
|
161
|
+
onSelect={handleModeSelect}
|
|
162
|
+
selectedBackgroundColor="#333333"
|
|
163
|
+
selectedTextColor="#ffffff"
|
|
164
|
+
/>
|
|
165
|
+
</box>
|
|
166
|
+
</box>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (mode === "scope") {
|
|
171
|
+
return (
|
|
172
|
+
<box flexDirection="column" flexGrow={1}>
|
|
173
|
+
<box flexDirection="column" flexGrow={1} border borderStyle="rounded" title="New Program">
|
|
174
|
+
<box height={1} />
|
|
175
|
+
<text>{" What area should I focus on?"}</text>
|
|
176
|
+
<box height={1} />
|
|
177
|
+
<box border borderStyle="rounded" height={3}>
|
|
178
|
+
<input
|
|
179
|
+
focused
|
|
180
|
+
placeholder='e.g. "web app", "packages/ui", "API server"'
|
|
181
|
+
onSubmit={handleScopeSubmit}
|
|
182
|
+
/>
|
|
183
|
+
</box>
|
|
184
|
+
<box height={1} />
|
|
185
|
+
<text fg="#888888">{" Press Enter to skip and analyze everything"}</text>
|
|
186
|
+
</box>
|
|
187
|
+
</box>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Wait for reference file to be written before rendering Chat
|
|
192
|
+
if (!isUpdate && !setupReady) {
|
|
193
|
+
return (
|
|
194
|
+
<box flexDirection="column" flexGrow={1}>
|
|
195
|
+
<box flexDirection="column" flexGrow={1} border borderStyle="rounded" title="New Program">
|
|
196
|
+
<box flexGrow={1} justifyContent="center" alignItems="center">
|
|
197
|
+
<text fg="#888888">Preparing...</text>
|
|
198
|
+
</box>
|
|
199
|
+
</box>
|
|
200
|
+
</box>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const modelLabel = formatModelLabel(modelConfig)
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<Chat
|
|
208
|
+
cwd={cwd}
|
|
209
|
+
systemPrompt={isUpdate ? updateSystemPrompt! : setupResult!.systemPrompt}
|
|
210
|
+
tools={SETUP_TOOLS}
|
|
211
|
+
allowedTools={SETUP_TOOLS}
|
|
212
|
+
maxTurns={SETUP_MAX_TURNS}
|
|
213
|
+
provider={modelConfig.provider}
|
|
214
|
+
model={modelConfig.model}
|
|
215
|
+
effort={modelConfig.effort}
|
|
216
|
+
initialMessage={isUpdate ? updateInitialMessage! : initialMessage}
|
|
217
|
+
emptyStateHint={isUpdate
|
|
218
|
+
? "Describe what you'd like to change about this program."
|
|
219
|
+
: (!initialMessage ? 'Describe what you want to optimize — e.g. "reduce bundle size", "improve API latency", "increase test coverage".' : undefined)}
|
|
220
|
+
inputPlaceholder={isUpdate
|
|
221
|
+
? 'e.g. "fix the measurement script" or "widen the scope"'
|
|
222
|
+
: (!initialMessage ? 'e.g. "I want to reduce the homepage load time"' : undefined)}
|
|
223
|
+
title={`${isUpdate ? "Update" : "Setup"} · ${modelLabel}`}
|
|
224
|
+
/>
|
|
225
|
+
)
|
|
226
|
+
}
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createCliRenderer } from "@opentui/core"
|
|
3
|
+
import { createRoot } from "@opentui/react"
|
|
4
|
+
import { registerDefaultProviders } from "./lib/agent/default-providers.ts"
|
|
5
|
+
import { App } from "./App.tsx"
|
|
6
|
+
|
|
7
|
+
registerDefaultProviders()
|
|
8
|
+
|
|
9
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false })
|
|
10
|
+
|
|
11
|
+
// Copy-on-select: when the user finishes a mouse selection, copy to clipboard via OSC 52
|
|
12
|
+
renderer.on("selection", (selection: { getSelectedText(): string }) => {
|
|
13
|
+
const text = selection.getSelectedText()
|
|
14
|
+
if (text) renderer.copyToClipboardOSC52(text)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
createRoot(renderer).render(<App />)
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"jsxImportSource": "@opentui/react",
|
|
8
|
+
"lib": ["ESNext", "DOM"],
|
|
9
|
+
"strict": true,
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"outDir": "dist"
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"],
|
|
16
|
+
"exclude": ["src/**/*.test.ts"]
|
|
17
|
+
}
|