@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,851 @@
1
+ import { useState, useEffect, useRef, useCallback, useMemo } from "react"
2
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react"
3
+ import type { Screen, ProgramConfig } from "../lib/programs.ts"
4
+ import { getProgramDir } from "../lib/programs.ts"
5
+ import { formatModelLabel, type ModelSlot } from "../lib/config.ts"
6
+ import type { RunState, ExperimentResult, TerminationReason } from "../lib/run.ts"
7
+ import { getRunStats } from "../lib/run.ts"
8
+ import { removeWorktree } from "../lib/worktree.ts"
9
+ import { runFinalize, type FinalizeResult } from "../lib/finalize.ts"
10
+ import { formatShellError } from "../lib/git.ts"
11
+ import {
12
+ spawnDaemon,
13
+ watchRunDir,
14
+ sendStop,
15
+ sendAbort,
16
+ forceKillDaemon,
17
+ reconstructState,
18
+ getDaemonStatus,
19
+ updateMaxExperiments,
20
+ getMaxExperiments,
21
+ type DaemonWatcher,
22
+ } from "../lib/daemon-client.ts"
23
+ import { RunCompletePrompt } from "../components/RunCompletePrompt.tsx"
24
+ import { RunSettingsOverlay } from "../components/RunSettingsOverlay.tsx"
25
+ import { StatsHeader } from "../components/StatsHeader.tsx"
26
+ import { ResultsTable } from "../components/ResultsTable.tsx"
27
+ import { AgentPanel } from "../components/AgentPanel.tsx"
28
+ import { syntaxStyle } from "../lib/syntax-theme.ts"
29
+
30
+ type ExecutionPhase = "starting" | "running" | "complete" | "finalizing" | "finalize_complete" | "error"
31
+
32
+ function truncateStreamText(prev: string, text: string): string {
33
+ const next = prev + text
34
+ return next.length > 8000 ? next.slice(-6000) : next
35
+ }
36
+
37
+ interface ExecutionScreenProps {
38
+ cwd: string
39
+ programSlug: string
40
+ modelConfig: ModelSlot
41
+ supportModelConfig: ModelSlot
42
+ ideasBacklogEnabled: boolean
43
+ navigate: (screen: Screen) => void
44
+ maxExperiments: number
45
+ /** Use git worktree for isolation (default true). When false, runs in-place in main checkout. */
46
+ useWorktree?: boolean
47
+ /** If set, attach to an existing run instead of starting a new one */
48
+ attachRunId?: string
49
+ readOnly?: boolean
50
+ /** Called when user chooses to update the program (from run complete or error screen) */
51
+ onUpdateProgram?: (programSlug: string) => void
52
+ /** If true, automatically start finalize when the run is loaded as complete */
53
+ autoFinalize?: boolean
54
+ }
55
+
56
+ const PHASE_LABELS: Record<string, string> = {
57
+ baseline: "Establishing baseline...",
58
+ agent_running: "Agent running...",
59
+ measuring: "Measuring...",
60
+ reverting: "Reverting...",
61
+ kept: "Kept improvement!",
62
+ idle: "Running experiments...",
63
+ stopping: "Stopping...",
64
+ complete: "Complete",
65
+ finalizing: "Finalizing...",
66
+ }
67
+
68
+ function getPhaseLabel(phase: RunState["phase"], error?: string | null, isStopping = false): string {
69
+ if (isStopping) return "Stopping after current experiment..."
70
+ if (phase === "crashed") return `Crashed: ${error ?? "unknown"}`
71
+ return PHASE_LABELS[phase] ?? phase
72
+ }
73
+
74
+ const formatHeaderModelLabel = formatModelLabel
75
+
76
+ function getRunModelConfig(state: RunState | null, fallback: ModelSlot): ModelSlot {
77
+ if (
78
+ state?.model &&
79
+ (state.provider === "claude" || state.provider === "codex" || state.provider === "opencode") &&
80
+ (state.effort === "low" || state.effort === "medium" || state.effort === "high" || state.effort === "max")
81
+ ) {
82
+ return { provider: state.provider, model: state.model, effort: state.effort }
83
+ }
84
+ return fallback
85
+ }
86
+
87
+ function IdeasPanel({ text }: { text: string }) {
88
+ return (
89
+ <scrollbox flexGrow={1} stickyScroll stickyStart="bottom">
90
+ <box paddingX={1} flexDirection="column">
91
+ <markdown content={text} syntaxStyle={syntaxStyle} conceal />
92
+ </box>
93
+ </scrollbox>
94
+ )
95
+ }
96
+
97
+ function Divider({ width, label }: { width: number; label?: string }) {
98
+ const innerWidth = Math.max(width - 2, 0)
99
+ if (label) {
100
+ const labelStr = `─ ${label} `
101
+ const rest = "─".repeat(Math.max(innerWidth - labelStr.length, 0))
102
+ return <text fg="#666666">{labelStr}{rest}</text>
103
+ }
104
+ return <text fg="#666666">{"─".repeat(innerWidth)}</text>
105
+ }
106
+
107
+ export function ExecutionScreen({ cwd, programSlug, modelConfig, supportModelConfig, ideasBacklogEnabled, navigate, maxExperiments, useWorktree = true, attachRunId, readOnly = false, autoFinalize = false, onUpdateProgram }: ExecutionScreenProps) {
108
+ const { width: termWidth, height: termHeight } = useTerminalDimensions()
109
+ const compact = termHeight < 30
110
+ const [phase, setPhase] = useState<ExecutionPhase>("starting")
111
+ const [runState, setRunState] = useState<RunState | null>(null)
112
+ const [currentPhaseLabel, setCurrentPhaseLabel] = useState(attachRunId ? "Connecting..." : "Starting daemon...")
113
+ const [experimentNumber, setExperimentNumber] = useState(0)
114
+ const [lastError, setLastError] = useState<string | null>(null)
115
+ const [terminationReason, setTerminationReason] = useState<TerminationReason | null>(null)
116
+
117
+ const [results, setResults] = useState<ExperimentResult[]>([])
118
+ const [metricHistory, setMetricHistory] = useState<number[]>([])
119
+ const [agentStreamText, setAgentStreamText] = useState("")
120
+ const [toolStatus, setToolStatus] = useState<string | null>(null)
121
+ const [totalCostUsd, setTotalCostUsd] = useState(0)
122
+ const [programConfig, setProgramConfig] = useState<ProgramConfig | null>(null)
123
+ const [runDir, setRunDir] = useState<string | null>(null)
124
+ const [finalizeResult, setFinalizeResult] = useState<FinalizeResult | null>(null)
125
+ const [tableFocused, setTableFocused] = useState(false)
126
+ const [selectedResult, setSelectedResult] = useState<ExperimentResult | null>(null)
127
+ const [showStopConfirm, setShowStopConfirm] = useState(false)
128
+ const [stopping, setStopping] = useState(false)
129
+ const [showSettings, setShowSettings] = useState(false)
130
+ const [maxExpText, setMaxExpText] = useState(String(maxExperiments))
131
+ const maxExpTextRef = useRef(maxExpText)
132
+ const [settingsError, setSettingsError] = useState<string | null>(null)
133
+ const [ideasText, setIdeasText] = useState("")
134
+ const [showIdeas, setShowIdeas] = useState(true)
135
+
136
+ const secondaryMetricsConfig = useMemo(() => programConfig?.secondary_metrics, [programConfig])
137
+ const ideasVisible = showIdeas && ideasText.length > 0
138
+
139
+ const watcherRef = useRef<DaemonWatcher | null>(null)
140
+ const abortControllerRef = useRef<AbortController>(new AbortController())
141
+ const abortSentRef = useRef(false)
142
+ const abortTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
143
+
144
+ const stoppingRef = useRef(false)
145
+ stoppingRef.current = stopping // ref for use inside effect closures
146
+ const parsedMaxExperiments = Number.parseInt(maxExpText, 10)
147
+ const displayMaxExperiments = Number.isFinite(parsedMaxExperiments) && parsedMaxExperiments > 0
148
+ ? parsedMaxExperiments
149
+ : maxExperiments
150
+ const headerModelLabel = useMemo(
151
+ () => formatHeaderModelLabel(getRunModelConfig(runState, modelConfig)),
152
+ [runState, modelConfig],
153
+ )
154
+
155
+ useEffect(() => {
156
+ let cancelled = false
157
+ const programDir = getProgramDir(cwd, programSlug)
158
+
159
+ ;(async () => {
160
+ try {
161
+ let activeRunDir: string
162
+
163
+ if (attachRunId) {
164
+ // Attach mode: reconstruct state from existing run
165
+ activeRunDir = `${programDir}/runs/${attachRunId}`
166
+ const status = await getDaemonStatus(activeRunDir)
167
+ if (!status.alive) {
168
+ // Daemon died — show complete state
169
+ try {
170
+ const reconstructed = await reconstructState(activeRunDir, programDir)
171
+ const currentMax = await getMaxExperiments(activeRunDir)
172
+ if (!cancelled) {
173
+ setRunDir(activeRunDir)
174
+ setRunState(reconstructed.state)
175
+ setResults(reconstructed.results)
176
+ setMetricHistory(reconstructed.metricHistory)
177
+ setProgramConfig(reconstructed.programConfig)
178
+ setTotalCostUsd(reconstructed.state.total_cost_usd ?? 0)
179
+ setExperimentNumber(reconstructed.state.experiment_number)
180
+ setAgentStreamText(reconstructed.streamText)
181
+ setIdeasText(reconstructed.ideasText)
182
+ setTerminationReason(reconstructed.state.termination_reason ?? null)
183
+ if (currentMax != null) {
184
+ const text = String(currentMax)
185
+ setMaxExpText(text)
186
+ maxExpTextRef.current = text
187
+ }
188
+ if (reconstructed.state.phase === "crashed") {
189
+ setLastError(reconstructed.state.error ?? "Daemon crashed")
190
+ setPhase("error")
191
+ } else if (reconstructed.state.phase === "complete") {
192
+ setPhase("complete")
193
+ } else {
194
+ setLastError(`Daemon is not running; last phase was ${reconstructed.state.phase}`)
195
+ setPhase("error")
196
+ }
197
+ }
198
+ } catch (err: unknown) {
199
+ if (!cancelled) {
200
+ setLastError(formatShellError(err))
201
+ setPhase("error")
202
+ }
203
+ }
204
+ return
205
+ }
206
+
207
+ // Daemon alive — reconstruct and watch
208
+ const reconstructed = await reconstructState(activeRunDir, programDir)
209
+ if (!cancelled) {
210
+ setRunDir(activeRunDir)
211
+ setRunState(reconstructed.state)
212
+ setResults(reconstructed.results)
213
+ setMetricHistory(reconstructed.metricHistory)
214
+ setProgramConfig(reconstructed.programConfig)
215
+ setTotalCostUsd(reconstructed.state.total_cost_usd ?? 0)
216
+ setExperimentNumber(reconstructed.state.experiment_number)
217
+ setAgentStreamText(reconstructed.streamText)
218
+ setIdeasText(reconstructed.ideasText)
219
+ setPhase("running")
220
+ // Sync maxExpText from run-config for settings panel
221
+ const currentMax = await getMaxExperiments(activeRunDir)
222
+ if (!cancelled && currentMax != null) {
223
+ const text = String(currentMax)
224
+ setMaxExpText(text)
225
+ maxExpTextRef.current = text
226
+ }
227
+ }
228
+ } else {
229
+ // Spawn mode: create worktree, spawn daemon
230
+ const result = await spawnDaemon(cwd, programSlug, modelConfig, maxExperiments, ideasBacklogEnabled, useWorktree)
231
+ if (cancelled) return
232
+
233
+ activeRunDir = result.runDir
234
+ setRunDir(result.runDir)
235
+
236
+ // Load program config for display
237
+ const { loadProgramConfig } = await import("../lib/programs.ts")
238
+ const config = await loadProgramConfig(programDir)
239
+ if (!cancelled) {
240
+ setProgramConfig(config)
241
+ setPhase("running")
242
+ }
243
+ }
244
+
245
+ // Start watching the run directory
246
+ if (!cancelled) {
247
+ const watcher = watchRunDir(activeRunDir, {
248
+ onStateChange: (state) => {
249
+ if (cancelled) return
250
+ setRunState(state)
251
+ setExperimentNumber(state.experiment_number)
252
+ setTotalCostUsd(state.total_cost_usd ?? 0)
253
+ setCurrentPhaseLabel(getPhaseLabel(state.phase, state.error, stoppingRef.current))
254
+
255
+ // Detect completion
256
+ if (state.phase === "complete" || state.phase === "crashed") {
257
+ setTerminationReason(state.termination_reason ?? null)
258
+ if (state.phase === "crashed") {
259
+ setLastError(state.error ?? "Daemon crashed")
260
+ setPhase("error")
261
+ } else {
262
+ setPhase("complete")
263
+ }
264
+ watcher.stop()
265
+ }
266
+ },
267
+ onResultsChange: (newResults, newMetricHistory) => {
268
+ if (cancelled) return
269
+ setResults(newResults)
270
+ setMetricHistory(newMetricHistory)
271
+ },
272
+ onStreamChange: (text) => {
273
+ if (cancelled) return
274
+ setAgentStreamText(prev => truncateStreamText(prev, text))
275
+ },
276
+ onStreamReset: () => {
277
+ if (cancelled) return
278
+ setAgentStreamText("")
279
+ setToolStatus(null)
280
+ },
281
+ onToolStatus: (status) => {
282
+ if (cancelled) return
283
+ setToolStatus(status)
284
+ },
285
+ onIdeasChange: (text) => {
286
+ if (cancelled) return
287
+ setIdeasText(text)
288
+ },
289
+ onDaemonDied: () => {
290
+ if (cancelled) return
291
+ // Re-read final state
292
+ reconstructState(activeRunDir, programDir).then((final) => {
293
+ if (cancelled) return
294
+ setRunState(final.state)
295
+ setResults(final.results)
296
+ setMetricHistory(final.metricHistory)
297
+ setTerminationReason(final.state.termination_reason ?? null)
298
+ if (final.state.phase === "crashed") {
299
+ setLastError(final.state.error ?? "Daemon died unexpectedly")
300
+ setPhase("error")
301
+ } else if (final.state.phase === "complete") {
302
+ setPhase("complete")
303
+ } else {
304
+ setLastError(`Daemon died unexpectedly while ${final.state.phase}`)
305
+ setPhase("error")
306
+ }
307
+ }).catch(() => {
308
+ if (!cancelled) {
309
+ setLastError("Daemon died and state could not be read")
310
+ setPhase("error")
311
+ }
312
+ })
313
+ watcher.stop()
314
+ },
315
+ }, { startAtEnd: Boolean(attachRunId) })
316
+ watcherRef.current = watcher
317
+ }
318
+ } catch (err: unknown) {
319
+ if (!cancelled) {
320
+ setLastError(formatShellError(err))
321
+ setPhase("error")
322
+ }
323
+ }
324
+ })()
325
+
326
+ return () => {
327
+ cancelled = true
328
+ watcherRef.current?.stop()
329
+ }
330
+ }, [cwd, programSlug, modelConfig, maxExperiments, useWorktree, attachRunId, ideasBacklogEnabled])
331
+
332
+ const cleanupRunEnvironment = useCallback(async () => {
333
+ if (runState?.in_place) {
334
+ if (runState.original_branch) {
335
+ const { checkoutBranch } = await import("../lib/git.ts")
336
+ await checkoutBranch(cwd, runState.original_branch).catch(() => {})
337
+ }
338
+ } else if (runState?.worktree_path) {
339
+ await removeWorktree(cwd, runState.worktree_path).catch(() => {})
340
+ }
341
+ }, [cwd, runState])
342
+
343
+ const handleAbandon = useCallback(async () => {
344
+ await cleanupRunEnvironment()
345
+ navigate("home")
346
+ }, [cleanupRunEnvironment, navigate])
347
+
348
+ const handleFinalize = useCallback(async () => {
349
+ if (!runState || !runDir || !programConfig) return
350
+
351
+ const finalizeAbort = new AbortController()
352
+ abortControllerRef.current = finalizeAbort
353
+
354
+ setPhase("finalizing")
355
+ setAgentStreamText("")
356
+ setToolStatus(null)
357
+ setCurrentPhaseLabel("Finalizing...")
358
+
359
+ try {
360
+ const result = await runFinalize(
361
+ cwd,
362
+ programSlug,
363
+ runDir,
364
+ runState,
365
+ programConfig,
366
+ supportModelConfig,
367
+ {
368
+ onStreamText: (text) => setAgentStreamText(prev => truncateStreamText(prev, text)),
369
+ onToolStatus: (status) => setToolStatus(status),
370
+ },
371
+ finalizeAbort.signal,
372
+ runState.in_place ? undefined : runState.worktree_path, // In-place mode uses projectRoot (cwd)
373
+ )
374
+ // In-place mode: restore original branch after finalize
375
+ if (runState.in_place && runState.original_branch) {
376
+ const { checkoutBranch } = await import("../lib/git.ts")
377
+ await checkoutBranch(cwd, runState.original_branch).catch(() => {})
378
+ }
379
+ setFinalizeResult(result)
380
+ setTotalCostUsd(prev => prev + (result.cost?.total_cost_usd ?? 0))
381
+ setPhase("finalize_complete")
382
+ } catch (err: unknown) {
383
+ setLastError(formatShellError(err, "Finalize failed"))
384
+ setPhase("error")
385
+ }
386
+ }, [cwd, programSlug, runDir, runState, programConfig, supportModelConfig])
387
+
388
+ const handleUpdateProgram = useCallback(async () => {
389
+ await cleanupRunEnvironment()
390
+ onUpdateProgram?.(programSlug)
391
+ }, [cleanupRunEnvironment, programSlug, onUpdateProgram])
392
+
393
+ // Auto-finalize: trigger finalize immediately when attaching to a completed run with autoFinalize
394
+ const autoFinalizeTriggered = useRef(false)
395
+ useEffect(() => {
396
+ if (autoFinalize && phase === "complete" && !autoFinalizeTriggered.current) {
397
+ autoFinalizeTriggered.current = true
398
+ handleFinalize()
399
+ }
400
+ }, [autoFinalize, phase, handleFinalize])
401
+
402
+ useKeyboard((key) => {
403
+ if (phase === "finalize_complete") {
404
+ if (key.name === "escape") {
405
+ navigate("home")
406
+ }
407
+ return
408
+ }
409
+
410
+ if (phase === "error") {
411
+ if (key.name === "escape") {
412
+ navigate("home")
413
+ } else if (key.name === "u" && runState && !readOnly && onUpdateProgram) {
414
+ handleUpdateProgram()
415
+ }
416
+ return
417
+ }
418
+
419
+ if (phase === "complete" && readOnly) {
420
+ if (key.name === "escape") {
421
+ navigate("home")
422
+ } else if (key.name === "i") {
423
+ setShowIdeas(v => !v)
424
+ } else if (key.name === "f") {
425
+ handleFinalize()
426
+ }
427
+ return
428
+ }
429
+
430
+ if (phase === "complete") {
431
+ // Escape navigates home; RunCompletePrompt handles the rest
432
+ if (key.name === "escape") {
433
+ navigate("home")
434
+ return
435
+ }
436
+ return
437
+ }
438
+
439
+ // Settings overlay
440
+ if (showSettings) {
441
+ if (key.name === "escape") {
442
+ setShowSettings(false)
443
+ return
444
+ }
445
+ if (key.name === "backspace" || /^\d$/.test(key.name)) {
446
+ const prev = maxExpTextRef.current
447
+ const next = key.name === "backspace" ? prev.slice(0, -1) : prev + key.name
448
+ maxExpTextRef.current = next
449
+ setMaxExpText(next)
450
+
451
+ // Validate and auto-save
452
+ const parsed = parseInt(next, 10)
453
+ if (next === "" || isNaN(parsed) || parsed < 1) {
454
+ setSettingsError("Must be a positive integer")
455
+ } else if (parsed < experimentNumber) {
456
+ setSettingsError(`Must be at least ${experimentNumber} (experiments already done)`)
457
+ } else {
458
+ setSettingsError(null)
459
+ if (runDir) updateMaxExperiments(runDir, parsed)
460
+ }
461
+ }
462
+ return
463
+ }
464
+
465
+ // Stop confirmation dialog
466
+ if (showStopConfirm) {
467
+ if (key.name === "y") {
468
+ setShowStopConfirm(false)
469
+ setStopping(true)
470
+ setCurrentPhaseLabel("Stopping after current experiment...")
471
+ if (runDir) sendStop(runDir).catch(() => {})
472
+ } else if (key.name === "n" || key.name === "escape") {
473
+ setShowStopConfirm(false)
474
+ }
475
+ return
476
+ }
477
+
478
+ // During execution: Tab to toggle table focus
479
+ if ((phase === "starting" || phase === "running") && key.name === "tab") {
480
+ setTableFocused(f => !f)
481
+ return
482
+ }
483
+
484
+ // Escape: deselect first, then unfocus table, then detach (go back while daemon continues)
485
+ if (key.name === "escape") {
486
+ if (selectedResult) {
487
+ setSelectedResult(null)
488
+ return
489
+ }
490
+ if (tableFocused) {
491
+ setTableFocused(false)
492
+ return
493
+ }
494
+ // Detach from daemon — it keeps running in background
495
+ watcherRef.current?.stop()
496
+ navigate("home")
497
+ return
498
+ }
499
+
500
+ // Stop/abort during execution
501
+ if (phase === "starting" || phase === "running") {
502
+ if (key.name === "i") {
503
+ setShowIdeas(v => !v)
504
+ return
505
+ }
506
+
507
+ if (key.name === "s") {
508
+ setShowSettings(true)
509
+ return
510
+ }
511
+
512
+ if (key.name === "q") {
513
+ // Show stop confirmation
514
+ setShowStopConfirm(true)
515
+ return
516
+ }
517
+
518
+ if (key.ctrl && key.name === "c") {
519
+ if (abortSentRef.current) {
520
+ // Second Ctrl+C: force kill after timeout
521
+ if (runDir) {
522
+ forceKillDaemon(runDir).catch(() => {})
523
+ }
524
+ } else {
525
+ // First Ctrl+C: abort
526
+ abortSentRef.current = true
527
+ if (runDir) sendAbort(runDir).catch(() => {})
528
+ // Set up SIGKILL escalation after 5s
529
+ abortTimerRef.current = setTimeout(() => {
530
+ if (runDir) forceKillDaemon(runDir).catch(() => {})
531
+ }, 5_000)
532
+ }
533
+ return
534
+ }
535
+ }
536
+
537
+ // During finalize: Ctrl+C to abort
538
+ if (phase === "finalizing" && (key.ctrl && key.name === "c")) {
539
+ abortControllerRef.current.abort()
540
+ }
541
+ })
542
+
543
+ // Clean up abort timer on unmount
544
+ useEffect(() => {
545
+ return () => {
546
+ if (abortTimerRef.current) clearTimeout(abortTimerRef.current)
547
+ }
548
+ }, [])
549
+
550
+ return (
551
+ <box flexDirection="column" flexGrow={1}>
552
+ {(phase === "starting" || phase === "running") && (
553
+ <box flexDirection="column" flexGrow={1} border borderStyle="rounded" title={`${programSlug}`}>
554
+ <StatsHeader
555
+ experimentNumber={experimentNumber}
556
+ maxExperiments={displayMaxExperiments}
557
+ width={termWidth}
558
+ modelLabel={headerModelLabel}
559
+ totalKeeps={runState?.total_keeps ?? 0}
560
+ totalDiscards={runState?.total_discards ?? 0}
561
+ totalCrashes={runState?.total_crashes ?? 0}
562
+ currentBaseline={runState?.current_baseline ?? 0}
563
+ originalBaseline={runState?.original_baseline ?? 0}
564
+ bestMetric={runState?.best_metric ?? 0}
565
+ direction={programConfig?.direction ?? "lower"}
566
+ metricField={programConfig?.metric_field ?? "metric"}
567
+ totalCostUsd={totalCostUsd}
568
+ metricHistory={metricHistory}
569
+ currentPhaseLabel={currentPhaseLabel}
570
+ improvementPct={runState && programConfig ? getRunStats(runState, programConfig.direction).improvement_pct : 0}
571
+ />
572
+
573
+ {compact ? (
574
+ <>
575
+ <box paddingX={1}>
576
+ <text>
577
+ {tableFocused ? (
578
+ <>
579
+ <span fg="#7aa2f7"><strong>[ Results ]</strong></span>
580
+ {" "}
581
+ <span fg="#666666">Agent</span>
582
+ </>
583
+ ) : (
584
+ <>
585
+ <span fg="#666666">Results</span>
586
+ {" "}
587
+ <span fg="#7aa2f7"><strong>[ {selectedResult ? `Experiment #${selectedResult.experiment_number}` : "Agent"} ]</strong></span>
588
+ </>
589
+ )}
590
+ {" "}
591
+ <span fg="#666666">Tab ⇄</span>
592
+ </text>
593
+ </box>
594
+ {tableFocused ? (
595
+ <ResultsTable
596
+ results={results}
597
+ metricField={programConfig?.metric_field ?? "metric"}
598
+ secondaryMetrics={secondaryMetricsConfig}
599
+ width={termWidth}
600
+ experimentNumber={experimentNumber}
601
+ focused={tableFocused}
602
+ selectedResult={selectedResult}
603
+ onSelect={setSelectedResult}
604
+ />
605
+ ) : (
606
+ <AgentPanel
607
+ streamingText={agentStreamText}
608
+ toolStatus={toolStatus}
609
+ isRunning={phase === "running"}
610
+ selectedResult={selectedResult}
611
+ phaseLabel={currentPhaseLabel}
612
+ experimentNumber={experimentNumber}
613
+
614
+ secondaryMetrics={secondaryMetricsConfig}
615
+ />
616
+ )}
617
+ </>
618
+ ) : (
619
+ <>
620
+ <Divider width={termWidth} label="Results" />
621
+
622
+ <ResultsTable
623
+ results={results}
624
+ metricField={programConfig?.metric_field ?? "metric"}
625
+ secondaryMetrics={secondaryMetricsConfig}
626
+ width={termWidth}
627
+ experimentNumber={experimentNumber}
628
+ focused={tableFocused}
629
+ selectedResult={selectedResult}
630
+ onSelect={setSelectedResult}
631
+ />
632
+
633
+ {ideasVisible ? (
634
+ <>
635
+ <box flexDirection="row">
636
+ <box flexGrow={3}>
637
+ <Divider width={Math.ceil(termWidth * 0.6)} label={selectedResult ? `Experiment #${selectedResult.experiment_number}` : "Agent"} />
638
+ </box>
639
+ <box flexGrow={2}>
640
+ <Divider width={Math.floor(termWidth * 0.4)} label="Ideas" />
641
+ </box>
642
+ </box>
643
+ <box flexDirection="row" flexGrow={1}>
644
+ <box flexDirection="column" flexGrow={3}>
645
+ <AgentPanel
646
+ streamingText={agentStreamText}
647
+ toolStatus={toolStatus}
648
+ isRunning={phase === "running"}
649
+ selectedResult={selectedResult}
650
+ secondaryMetrics={secondaryMetricsConfig}
651
+ />
652
+ </box>
653
+ <box flexDirection="column" flexGrow={2}>
654
+ <IdeasPanel text={ideasText} />
655
+ </box>
656
+ </box>
657
+ </>
658
+ ) : (
659
+ <>
660
+ <Divider width={termWidth} label={selectedResult ? `Experiment #${selectedResult.experiment_number}` : "Agent"} />
661
+ <AgentPanel
662
+ streamingText={agentStreamText}
663
+ toolStatus={toolStatus}
664
+ isRunning={phase === "running"}
665
+ selectedResult={selectedResult}
666
+ secondaryMetrics={secondaryMetricsConfig}
667
+ />
668
+ </>
669
+ )}
670
+ </>
671
+ )}
672
+
673
+ {showSettings && (
674
+ <RunSettingsOverlay
675
+ maxExpText={maxExpText}
676
+ experimentNumber={experimentNumber}
677
+ validationError={settingsError}
678
+ />
679
+ )}
680
+
681
+ {showStopConfirm && (
682
+ <box paddingX={1}>
683
+ <text fg="#e0af68" selectable>Stop after current experiment finishes? (y/n)</text>
684
+ </box>
685
+ )}
686
+
687
+ {stopping && !showStopConfirm && (
688
+ <box paddingX={1}>
689
+ <text fg="#e0af68" selectable>Stopping after current experiment...</text>
690
+ </box>
691
+ )}
692
+
693
+ {lastError && (
694
+ <box paddingX={1}>
695
+ <text fg="#ff5555" selectable>{lastError}</text>
696
+ </box>
697
+ )}
698
+ </box>
699
+ )}
700
+
701
+ {phase === "complete" && runState && readOnly && (
702
+ <box flexDirection="column" flexGrow={1} border borderStyle="rounded" title={`${programSlug}`}>
703
+ <StatsHeader
704
+ experimentNumber={experimentNumber}
705
+ maxExperiments={displayMaxExperiments}
706
+ width={termWidth}
707
+ modelLabel={headerModelLabel}
708
+ totalKeeps={runState.total_keeps}
709
+ totalDiscards={runState.total_discards}
710
+ totalCrashes={runState.total_crashes}
711
+ currentBaseline={runState.current_baseline}
712
+ originalBaseline={runState.original_baseline}
713
+ bestMetric={runState.best_metric}
714
+ direction={programConfig?.direction ?? "lower"}
715
+ metricField={programConfig?.metric_field ?? "metric"}
716
+ totalCostUsd={totalCostUsd}
717
+ metricHistory={metricHistory}
718
+ currentPhaseLabel="Complete"
719
+ improvementPct={programConfig ? getRunStats(runState, programConfig.direction).improvement_pct : 0}
720
+ />
721
+ <Divider width={termWidth} label="Results" />
722
+ <ResultsTable
723
+ results={results}
724
+ metricField={programConfig?.metric_field ?? "metric"}
725
+ secondaryMetrics={secondaryMetricsConfig}
726
+ width={termWidth}
727
+ experimentNumber={experimentNumber}
728
+ focused={tableFocused}
729
+ selectedResult={selectedResult}
730
+ onSelect={setSelectedResult}
731
+ />
732
+ <Divider width={termWidth} label={selectedResult ? `Experiment #${selectedResult.experiment_number}` : "Agent"} />
733
+ {ideasVisible ? (
734
+ <box flexDirection="row" flexGrow={1}>
735
+ <box flexDirection="column" flexGrow={3}>
736
+ <AgentPanel
737
+ streamingText={agentStreamText}
738
+ toolStatus={toolStatus}
739
+ isRunning={false}
740
+ selectedResult={selectedResult}
741
+ secondaryMetrics={secondaryMetricsConfig}
742
+ />
743
+ </box>
744
+ <box flexDirection="column" flexGrow={2}>
745
+ <Divider width={Math.floor(termWidth * 0.38)} label="Ideas" />
746
+ <IdeasPanel text={ideasText} />
747
+ </box>
748
+ </box>
749
+ ) : (
750
+ <AgentPanel
751
+ streamingText={agentStreamText}
752
+ toolStatus={toolStatus}
753
+ isRunning={false}
754
+ selectedResult={selectedResult}
755
+ secondaryMetrics={secondaryMetricsConfig}
756
+ />
757
+ )}
758
+ <box paddingX={1}>
759
+ <text fg="#888888">Esc back · f finalize{ideasText.length > 0 ? " · i toggle ideas" : ""}</text>
760
+ </box>
761
+ </box>
762
+ )}
763
+
764
+ {phase === "complete" && runState && !readOnly && (
765
+ <RunCompletePrompt
766
+ state={runState}
767
+ direction={programConfig?.direction ?? "lower"}
768
+ terminationReason={terminationReason}
769
+ error={null}
770
+ onFinalize={handleFinalize}
771
+ onAbandon={handleAbandon}
772
+ onUpdateProgram={handleUpdateProgram}
773
+ />
774
+ )}
775
+
776
+ {phase === "finalizing" && (
777
+ <box flexDirection="column" flexGrow={1} border borderStyle="rounded" title="Finalize">
778
+ <StatsHeader
779
+ experimentNumber={experimentNumber}
780
+ maxExperiments={displayMaxExperiments}
781
+ width={termWidth}
782
+ modelLabel={headerModelLabel}
783
+ totalKeeps={runState?.total_keeps ?? 0}
784
+ totalDiscards={runState?.total_discards ?? 0}
785
+ totalCrashes={runState?.total_crashes ?? 0}
786
+ currentBaseline={runState?.current_baseline ?? 0}
787
+ originalBaseline={runState?.original_baseline ?? 0}
788
+ bestMetric={runState?.best_metric ?? 0}
789
+ direction={programConfig?.direction ?? "lower"}
790
+ metricField={programConfig?.metric_field ?? "metric"}
791
+ totalCostUsd={totalCostUsd}
792
+ metricHistory={metricHistory}
793
+ currentPhaseLabel={currentPhaseLabel}
794
+ improvementPct={runState && programConfig ? getRunStats(runState, programConfig.direction).improvement_pct : 0}
795
+ />
796
+
797
+ <Divider width={termWidth} label="Agent" />
798
+
799
+ <AgentPanel
800
+ streamingText={agentStreamText}
801
+ toolStatus={toolStatus}
802
+ isRunning={true}
803
+ />
804
+ </box>
805
+ )}
806
+
807
+ {phase === "finalize_complete" && finalizeResult && (
808
+ <box flexDirection="column" flexGrow={1} border borderStyle="rounded" title="Finalize Complete">
809
+ <box flexDirection="column" paddingX={1}>
810
+ {finalizeResult.mode === "grouped" && finalizeResult.groups.length > 0 ? (
811
+ <>
812
+ <text fg="#9ece6a" selectable>Created {finalizeResult.groups.length} branch{finalizeResult.groups.length > 1 ? "es" : ""}:</text>
813
+ <box height={1} />
814
+ {finalizeResult.groups.map((g) => (
815
+ <box key={g.name} flexDirection="column">
816
+ <text fg="#9ece6a" selectable> {g.branchName}</text>
817
+ <text fg="#666666" selectable> {g.files.length} file{g.files.length > 1 ? "s" : ""}: {g.files.map((f) => f.split("/").pop()).join(", ")}</text>
818
+ </box>
819
+ ))}
820
+ </>
821
+ ) : (
822
+ <text fg="#888888" selectable>No group branches created</text>
823
+ )}
824
+ <box height={1} />
825
+ <text fg="#ffffff">Summary saved to run directory. Press Escape to go back.</text>
826
+ </box>
827
+ <scrollbox flexGrow={1} focused>
828
+ <box paddingX={1} flexDirection="column">
829
+ <markdown content={finalizeResult.summary} syntaxStyle={syntaxStyle} />
830
+ </box>
831
+ </scrollbox>
832
+ </box>
833
+ )}
834
+
835
+ {phase === "error" && (
836
+ <box flexDirection="column" flexGrow={1} border borderStyle="rounded" title="Error">
837
+ <box padding={1}>
838
+ <text fg="#ff5555" selectable>{lastError ?? "Unknown error"}</text>
839
+ </box>
840
+ <box padding={1}>
841
+ <text fg="#888888">
842
+ {runState && !readOnly && onUpdateProgram
843
+ ? "Press u to update program · Escape to go back"
844
+ : "Press Escape to go back"}
845
+ </text>
846
+ </box>
847
+ </box>
848
+ )}
849
+ </box>
850
+ )
851
+ }