@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,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
+ }