@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,254 @@
1
+ import { useMemo } from "react"
2
+ import type { ExperimentResult } from "../lib/run.ts"
3
+ import { parseSecondaryValues } from "../lib/run.ts"
4
+ import type { SecondaryMetric } from "../lib/programs.ts"
5
+ import { syntaxStyle } from "../lib/syntax-theme.ts"
6
+ import { statusColor } from "./ResultsTable.tsx"
7
+
8
+ interface AgentPanelProps {
9
+ streamingText: string
10
+ toolStatus: string | null
11
+ isRunning: boolean
12
+ selectedResult?: ExperimentResult | null
13
+ phaseLabel?: string | null
14
+ experimentNumber?: number
15
+ secondaryMetrics?: Record<string, SecondaryMetric>
16
+ }
17
+
18
+ function ExperimentDetail({ result, secondaryMetrics }: {
19
+ result: ExperimentResult
20
+ secondaryMetrics?: Record<string, SecondaryMetric>
21
+ }) {
22
+ const { quality_gates: gateValues, secondary_metrics: secondaryValues } = parseSecondaryValues(result.secondary_values)
23
+ const gateEntries = Object.entries(gateValues)
24
+ const secondaryEntries = Object.entries(secondaryValues)
25
+
26
+ return (
27
+ <box flexDirection="column" paddingX={1} gap={1}>
28
+ <box flexDirection="column">
29
+ <text selectable><strong fg="#ffffff">Experiment #{result.experiment_number}</strong></text>
30
+ <text fg="#666666">{"─".repeat(40)}</text>
31
+ </box>
32
+
33
+ <box flexDirection="column">
34
+ <text selectable><strong fg="#ffffff">Status: </strong><strong fg={statusColor(result.status)}>{result.status}</strong></text>
35
+ <text selectable><strong fg="#ffffff">Commit: </strong><strong fg="#ffffff">{result.commit}</strong></text>
36
+ <text selectable><strong fg="#ffffff">Metric: </strong><strong fg="#ffffff">{result.metric_value ?? "—"}</strong></text>
37
+ </box>
38
+
39
+ {gateEntries.length > 0 && (
40
+ <box flexDirection="column">
41
+ <text><strong fg="#ffffff">Quality Gates:</strong></text>
42
+ {gateEntries.map(([key, val]) => (
43
+ <text key={key} fg="#ffffff" selectable> {key}: {String(val)}</text>
44
+ ))}
45
+ </box>
46
+ )}
47
+
48
+ {secondaryEntries.length > 0 && (
49
+ <box flexDirection="column">
50
+ <text><strong fg="#ffffff">Secondary Metrics:</strong></text>
51
+ {secondaryEntries.map(([key, val]) => {
52
+ const dir = secondaryMetrics?.[key]?.direction
53
+ const dirLabel = dir ? ` (${dir} is better)` : ""
54
+ return (
55
+ <text key={key} fg="#ffffff" selectable> {key}: {String(val)}{dirLabel}</text>
56
+ )
57
+ })}
58
+ </box>
59
+ )}
60
+
61
+ <box flexDirection="column">
62
+ <text><strong fg="#ffffff">Description:</strong></text>
63
+ <text fg="#ffffff" selectable>{result.description}</text>
64
+ </box>
65
+ </box>
66
+ )
67
+ }
68
+
69
+ type StreamSegment =
70
+ | { type: "text"; content: string }
71
+ | { type: "event"; time: number; status?: string }
72
+
73
+ function parseStreamSegments(text: string): StreamSegment[] {
74
+ const segments: StreamSegment[] = []
75
+ const lines = text.split("\n")
76
+ let textLines: string[] = []
77
+
78
+ function flushText() {
79
+ if (textLines.length > 0) {
80
+ const content = textLines.join("\n")
81
+ if (content.trim()) {
82
+ segments.push({ type: "text", content })
83
+ }
84
+ textLines = []
85
+ }
86
+ }
87
+
88
+ for (let i = 0; i < lines.length; i++) {
89
+ const timeMatch = lines[i].match(/^\[time:(\d+)\]$/)
90
+
91
+ if (timeMatch) {
92
+ flushText()
93
+ const epoch = Number(timeMatch[1])
94
+ // Merge with following tool marker if present
95
+ const nextToolMatch = lines[i + 1]?.match(/^\[tool\] (.+)$/)
96
+ if (nextToolMatch) {
97
+ segments.push({ type: "event", time: epoch, status: nextToolMatch[1] })
98
+ i++
99
+ } else {
100
+ segments.push({ type: "event", time: epoch })
101
+ }
102
+ } else if (lines[i].startsWith("[tool] ")) {
103
+ flushText()
104
+ segments.push({ type: "event", time: 0, status: lines[i].slice(7) })
105
+ } else {
106
+ textLines.push(lines[i])
107
+ }
108
+ }
109
+
110
+ flushText()
111
+ return segments
112
+ }
113
+
114
+ const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
115
+
116
+ function formatTimestamp(epoch: number): string {
117
+ if (epoch === 0) return ""
118
+ const date = new Date(epoch)
119
+ const now = new Date()
120
+ const isToday = date.getFullYear() === now.getFullYear() &&
121
+ date.getMonth() === now.getMonth() &&
122
+ date.getDate() === now.getDate()
123
+
124
+ const hours = String(date.getHours()).padStart(2, "0")
125
+ const minutes = String(date.getMinutes()).padStart(2, "0")
126
+ const time = `${hours}:${minutes}`
127
+
128
+ if (isToday) return time
129
+ return `${MONTH_NAMES[date.getMonth()]} ${date.getDate()} ${time}`
130
+ }
131
+
132
+ export function AgentPanel({ streamingText, toolStatus, isRunning, selectedResult, phaseLabel, experimentNumber, secondaryMetrics }: AgentPanelProps) {
133
+ const { segments, hasMarkers, lastTextIdx } = useMemo(() => {
134
+ const segs = parseStreamSegments(streamingText)
135
+ return {
136
+ segments: segs,
137
+ hasMarkers: segs.some(s => s.type === "event"),
138
+ lastTextIdx: segs.findLastIndex(s => s.type === "text"),
139
+ }
140
+ }, [streamingText])
141
+
142
+ if (selectedResult) {
143
+ return (
144
+ <box flexDirection="column" flexGrow={1}>
145
+ <scrollbox flexGrow={1}>
146
+ <ExperimentDetail result={selectedResult} secondaryMetrics={secondaryMetrics} />
147
+ </scrollbox>
148
+ <box paddingX={1}>
149
+ <text fg="#666666">Esc to return to live view</text>
150
+ </box>
151
+ </box>
152
+ )
153
+ }
154
+
155
+ return (
156
+ <box flexDirection="column" flexGrow={1}>
157
+ <scrollbox flexGrow={1} stickyScroll stickyStart="bottom">
158
+ {!streamingText && isRunning && (
159
+ <box paddingX={1} flexDirection="column">
160
+ <WaitingIndicator phaseLabel={phaseLabel} experimentNumber={experimentNumber} toolStatus={toolStatus} />
161
+ </box>
162
+ )}
163
+ {streamingText && (
164
+ <box paddingX={1} flexDirection="column">
165
+ {toolStatus && isRunning && !hasMarkers && (
166
+ <text fg="#666666" selectable>{toolStatus}</text>
167
+ )}
168
+ {hasMarkers ? (
169
+ segments.map((segment, i) => {
170
+ if (segment.type === "event") {
171
+ const ts = formatTimestamp(segment.time)
172
+ if (segment.status) {
173
+ return (
174
+ <text key={i} fg="#666666" selectable>
175
+ {ts ? <><span fg="#555555">{ts}</span>{" "}</> : null}{segment.status}
176
+ </text>
177
+ )
178
+ }
179
+ return ts ? <text key={i} fg="#555555" selectable>{ts}</text> : null
180
+ }
181
+ return (
182
+ <markdown key={i} content={segment.content} syntaxStyle={syntaxStyle} streaming={isRunning && i === lastTextIdx} />
183
+ )
184
+ })
185
+ ) : (
186
+ <markdown content={streamingText} syntaxStyle={syntaxStyle} streaming={isRunning} />
187
+ )}
188
+ </box>
189
+ )}
190
+ </scrollbox>
191
+ </box>
192
+ )
193
+ }
194
+
195
+ function WaitingIndicator({ phaseLabel, experimentNumber, toolStatus }: { phaseLabel?: string | null; experimentNumber?: number; toolStatus?: string | null }) {
196
+ const expLabel = experimentNumber ? `#${experimentNumber}` : ""
197
+
198
+ // Phase-specific status
199
+ if (phaseLabel) {
200
+ const lower = phaseLabel.toLowerCase()
201
+ if (lower.includes("baseline") && !lower.includes("re-baseline")) {
202
+ return (
203
+ <box flexDirection="column">
204
+ <text><span fg="#ffffff">{">"}</span> <span fg="#ffffff">Establishing baseline</span></text>
205
+ <text fg="#666666"> Running measurement to set the starting metric</text>
206
+ </box>
207
+ )
208
+ }
209
+ if (lower.includes("measuring") || lower.includes("re-baseline")) {
210
+ return (
211
+ <box flexDirection="column">
212
+ <text><span fg="#ffffff">{">"}</span> <span fg="#ffffff">{phaseLabel}</span></text>
213
+ <text fg="#666666"> Evaluating experiment {expLabel} via measure.sh</text>
214
+ </box>
215
+ )
216
+ }
217
+ if (lower.includes("reverting")) {
218
+ return (
219
+ <box flexDirection="column">
220
+ <text><span fg="#ffffff">{">"}</span> <span fg="#e0af68">{phaseLabel}</span></text>
221
+ <text fg="#666666"> Resetting to last known good state</text>
222
+ </box>
223
+ )
224
+ }
225
+ if (lower.includes("kept")) {
226
+ return <text><span fg="#ffffff">{">"}</span> <span fg="#9ece6a">{phaseLabel}</span></text>
227
+ }
228
+ if (lower.includes("starting daemon")) {
229
+ return (
230
+ <box flexDirection="column">
231
+ <text><span fg="#ffffff">{">"}</span> <span fg="#ffffff">Starting daemon</span></text>
232
+ <text fg="#666666"> Creating worktree and spawning background process</text>
233
+ </box>
234
+ )
235
+ }
236
+ }
237
+
238
+ // Agent running but no text yet — show tool if available, otherwise thinking
239
+ if (toolStatus) {
240
+ return (
241
+ <box flexDirection="column">
242
+ <text><span fg="#ffffff">{">"}</span> <span fg="#ffffff">Agent working</span> <span fg="#666666">{expLabel}</span></text>
243
+ <text fg="#666666"> {toolStatus}</text>
244
+ </box>
245
+ )
246
+ }
247
+
248
+ return (
249
+ <box flexDirection="column">
250
+ <text><span fg="#ffffff">{">"}</span> <span fg="#ffffff">Agent thinking</span> <span fg="#666666">{expLabel}</span></text>
251
+ <text fg="#666666"> Building context and waiting for first response</text>
252
+ </box>
253
+ )
254
+ }
@@ -0,0 +1,71 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { TextareaRenderable, type Renderable } from "@opentui/core"
3
+ import { testRender } from "@opentui/react/test-utils"
4
+ import { act } from "react"
5
+ import { Chat } from "./Chat.tsx"
6
+ import { setProvider } from "../lib/agent/index.ts"
7
+ import { MockProvider } from "../lib/agent/mock-provider.ts"
8
+
9
+ let testSetup: Awaited<ReturnType<typeof testRender>> | null = null
10
+
11
+ afterEach(async () => {
12
+ if (testSetup) {
13
+ await act(async () => {
14
+ testSetup?.renderer.destroy()
15
+ })
16
+ testSetup = null
17
+ }
18
+ })
19
+
20
+ function findTextarea(renderable: Renderable): TextareaRenderable | null {
21
+ if (renderable instanceof TextareaRenderable) return renderable
22
+ for (const child of renderable.getChildren()) {
23
+ const textarea = findTextarea(child)
24
+ if (textarea) return textarea
25
+ }
26
+ return null
27
+ }
28
+
29
+ async function pressRaw(sequence: string): Promise<void> {
30
+ await act(async () => {
31
+ await testSetup?.mockInput.pressKeys([sequence])
32
+ await testSetup?.renderOnce()
33
+ })
34
+ }
35
+
36
+ describe("Chat", () => {
37
+ test("expands textarea after Shift+Enter newline", async () => {
38
+ setProvider("claude", new MockProvider([]))
39
+ testSetup = await testRender(<Chat provider="claude" />, {
40
+ width: 80,
41
+ height: 20,
42
+ useKittyKeyboard: {},
43
+ })
44
+
45
+ await act(async () => {
46
+ await testSetup?.renderOnce()
47
+ })
48
+
49
+ const textarea = findTextarea(testSetup.renderer.root)
50
+ expect(textarea).not.toBeNull()
51
+
52
+ await pressRaw("h")
53
+ await pressRaw("e")
54
+ await pressRaw("l")
55
+ await pressRaw("l")
56
+ await pressRaw("o")
57
+ await pressRaw("\x1b[13;2u")
58
+ await pressRaw("w")
59
+ await pressRaw("o")
60
+ await pressRaw("r")
61
+ await pressRaw("l")
62
+ await pressRaw("d")
63
+
64
+ expect(textarea!.plainText).toBe("hello\nworld")
65
+ expect(textarea!.height).toBe(2)
66
+
67
+ const frame = testSetup.captureCharFrame()
68
+ expect(frame).toContain("hello")
69
+ expect(frame).toContain("world")
70
+ })
71
+ })
@@ -0,0 +1,308 @@
1
+ import { useState, useEffect, useRef, useCallback } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import type { TextareaRenderable } from "@opentui/core"
4
+ import { DEFAULT_SYSTEM_PROMPT } from "../lib/system-prompts/index.ts"
5
+ import type { EffortLevel } from "../lib/config.ts"
6
+ import { syntaxStyle } from "../lib/syntax-theme.ts"
7
+ import { getProvider, type AgentProviderID, type AgentSession } from "../lib/agent/index.ts"
8
+ import { formatToolEvent } from "../lib/tool-events.ts"
9
+ import { formatShellError } from "../lib/git.ts"
10
+
11
+ const SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
12
+
13
+ function ToolStatusSpinner({ status }: { status: string }) {
14
+ const [tick, setTick] = useState(0)
15
+
16
+ useEffect(() => {
17
+ const interval = setInterval(() => setTick((t) => t + 1), 100)
18
+ return () => clearInterval(interval)
19
+ }, [])
20
+
21
+ const spinner = SPINNER_CHARS[tick % SPINNER_CHARS.length]
22
+ const seconds = Math.floor(tick / 10)
23
+ const timeStr =
24
+ seconds >= 60
25
+ ? `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`
26
+ : `${seconds}s`
27
+
28
+ return (
29
+ <text fg="#888888" selectable>
30
+ {spinner} {status} ({timeStr})
31
+ </text>
32
+ )
33
+ }
34
+
35
+ interface ChatMessage {
36
+ id: string
37
+ role: "user" | "assistant"
38
+ content: string
39
+ }
40
+
41
+
42
+ interface ChatProps {
43
+ /** Working directory for agent tools (target repo path) */
44
+ cwd?: string
45
+ /** System prompt for the agent */
46
+ systemPrompt?: string
47
+ /** Tools to make available to the agent */
48
+ tools?: string[]
49
+ /** Tools to auto-allow without permission prompts */
50
+ allowedTools?: string[]
51
+ /** Max conversation turns (user message + assistant response pairs) */
52
+ maxTurns?: number
53
+ /** Model alias ('sonnet', 'opus') or full model ID */
54
+ model?: string
55
+ provider?: AgentProviderID
56
+ /** Reasoning effort level */
57
+ effort?: EffortLevel
58
+ /** Auto-submit this message on mount as the first user message */
59
+ initialMessage?: string
60
+ /** Hint shown in the empty chat area before any messages */
61
+ emptyStateHint?: string
62
+ /** Placeholder text for the input field */
63
+ inputPlaceholder?: string
64
+ /** Title shown on the scrollbox border */
65
+ title?: string
66
+ }
67
+
68
+ export function Chat({
69
+ cwd,
70
+ systemPrompt = DEFAULT_SYSTEM_PROMPT,
71
+ tools,
72
+ allowedTools,
73
+ maxTurns,
74
+ model,
75
+ provider = "claude",
76
+ effort,
77
+ initialMessage,
78
+ emptyStateHint,
79
+ inputPlaceholder,
80
+ title,
81
+ }: ChatProps) {
82
+ const [messages, setMessages] = useState<ChatMessage[]>([])
83
+ const [streamingText, setStreamingText] = useState("")
84
+ const [isStreaming, setIsStreaming] = useState(false)
85
+ const [toolStatus, setToolStatus] = useState<string | null>(null)
86
+ const [error, setError] = useState<string | null>(null)
87
+ const sessionRef = useRef<AgentSession | null>(null)
88
+ const textareaRef = useRef<TextareaRenderable | null>(null)
89
+ const [inputKey, setInputKey] = useState(0)
90
+ const [inputBoxHeight, setInputBoxHeight] = useState(3)
91
+
92
+ // Capture config in refs — the agent session is long-lived and should not
93
+ // restart when parent re-renders. These are stable for the component lifetime.
94
+ const configRef = useRef({ cwd, systemPrompt, tools, allowedTools, maxTurns, provider, model, effort, initialMessage })
95
+ const defaultPlaceholder = inputPlaceholder ?? "Ask something..."
96
+
97
+ useEffect(() => {
98
+ const config = configRef.current
99
+ const session = getProvider(config.provider).createSession({
100
+ systemPrompt: config.systemPrompt,
101
+ tools: config.tools ?? [],
102
+ allowedTools: config.allowedTools,
103
+ maxTurns: config.maxTurns,
104
+ cwd: config.cwd,
105
+ model: config.model,
106
+ effort: config.effort,
107
+ })
108
+ sessionRef.current = session
109
+
110
+ // Auto-submit initial message if provided
111
+ if (config.initialMessage) {
112
+ const text = config.initialMessage.trim()
113
+ if (text) {
114
+ setMessages([{ id: crypto.randomUUID(), role: "user", content: text }])
115
+ session.pushMessage(text)
116
+ setIsStreaming(true)
117
+ setInputKey((k) => k + 1)
118
+ }
119
+ }
120
+
121
+ ;(async () => {
122
+ try {
123
+ for await (const event of session) {
124
+ switch (event.type) {
125
+ case "text_delta":
126
+ setStreamingText((prev) => prev + event.text)
127
+ setToolStatus(null)
128
+ break
129
+ case "tool_use":
130
+ setToolStatus(formatToolEvent(event.tool, event.input ?? {}))
131
+ break
132
+ case "assistant_complete":
133
+ // Skip tool-only turns that produced no visible text
134
+ if (event.text.trim()) {
135
+ setMessages((prev) => [
136
+ ...prev,
137
+ {
138
+ id: crypto.randomUUID(),
139
+ role: "assistant",
140
+ content: event.text,
141
+ },
142
+ ])
143
+ }
144
+ setStreamingText("")
145
+ setIsStreaming(false)
146
+ setToolStatus(null)
147
+ break
148
+ case "error":
149
+ setError(event.error)
150
+ setIsStreaming(false)
151
+ break
152
+ case "result":
153
+ if (!event.success && event.error) {
154
+ setError(`Agent error: ${event.error}`)
155
+ }
156
+ setIsStreaming(false)
157
+ break
158
+ }
159
+ }
160
+ } catch (err: unknown) {
161
+ setError(formatShellError(err))
162
+ setIsStreaming(false)
163
+ }
164
+ })()
165
+
166
+ return () => {
167
+ session.close()
168
+ sessionRef.current = null
169
+ }
170
+ }, [])
171
+
172
+ const handleSubmit = useCallback(
173
+ (value: string) => {
174
+ const text = value.trim()
175
+ if (!text || isStreaming || !sessionRef.current) return
176
+
177
+ setMessages((prev) => [
178
+ ...prev,
179
+ { id: crypto.randomUUID(), role: "user", content: text },
180
+ ])
181
+
182
+ sessionRef.current.pushMessage(text)
183
+
184
+ setIsStreaming(true)
185
+ setStreamingText("")
186
+ setError(null)
187
+ setInputKey((k) => k + 1)
188
+ },
189
+ [isStreaming]
190
+ )
191
+
192
+ const handleTextareaSubmit = useCallback(
193
+ () => {
194
+ const textarea = textareaRef.current
195
+ if (!textarea) return
196
+ const value = textarea.plainText
197
+ handleSubmit(value)
198
+ textarea.setText("")
199
+ },
200
+ [handleSubmit],
201
+ )
202
+
203
+ // Wire up onSubmit and auto-grow imperatively — React reconciler only maps these for <input>, not <textarea>
204
+ useEffect(() => {
205
+ const textarea = textareaRef.current
206
+ if (!textarea) return
207
+ textarea.onSubmit = handleTextareaSubmit
208
+ textarea.onContentChange = () => {
209
+ // +2 for top/bottom border
210
+ const h = Math.min(8, Math.max(3, Math.max(textarea.lineCount, textarea.virtualLineCount) + 2))
211
+ setInputBoxHeight(h)
212
+ }
213
+ // Reset height on remount (after submit clears content)
214
+ setInputBoxHeight(3)
215
+ }, [inputKey, handleTextareaSubmit])
216
+
217
+ // Auto-focus textarea when user starts typing while a non-interactable
218
+ // element (e.g. the messages scrollbox) has focus
219
+ useKeyboard((key) => {
220
+ const textarea = textareaRef.current
221
+ if (!textarea || isStreaming) return
222
+ if (textarea.focused) return
223
+ // Only intercept printable single-character keys (no ctrl/meta combos)
224
+ if (key.ctrl || key.meta || key.name.length !== 1) return
225
+ textarea.focus()
226
+ textarea.insertText(key.name)
227
+ key.stopPropagation()
228
+ })
229
+
230
+ return (
231
+ <box flexDirection="column" flexGrow={1}>
232
+ <scrollbox
233
+ focused={isStreaming}
234
+ flexGrow={1}
235
+ border
236
+ borderStyle="rounded"
237
+ stickyScroll
238
+ stickyStart="bottom"
239
+ title={title}
240
+ >
241
+ {messages.length === 0 && !streamingText ? (
242
+ <text fg="#888888">
243
+ {emptyStateHint ?? "Type a message below and press Enter to start a conversation."}
244
+ </text>
245
+ ) : (
246
+ <box flexDirection="column">
247
+ {messages.map((msg) => (
248
+ <box key={msg.id} flexDirection="column" backgroundColor={msg.role === "user" ? "#1a1a2e" : undefined}>
249
+ <text fg={msg.role === "user" ? "#ffffff" : "#9ece6a"}>
250
+ <strong>{msg.role === "user" ? "You" : "AutoAuto"}</strong>
251
+ </text>
252
+ {msg.role === "assistant" ? (
253
+ <markdown content={msg.content} syntaxStyle={syntaxStyle} />
254
+ ) : (
255
+ <text selectable>{msg.content}</text>
256
+ )}
257
+ <text>{""}</text>
258
+ </box>
259
+ ))}
260
+
261
+ {streamingText && (
262
+ <box flexDirection="column">
263
+ <text fg="#9ece6a">
264
+ <strong>AutoAuto</strong>
265
+ </text>
266
+ <markdown content={streamingText} syntaxStyle={syntaxStyle} streaming />
267
+ </box>
268
+ )}
269
+
270
+ {isStreaming && !streamingText && (
271
+ <box flexDirection="column">
272
+ <text fg="#9ece6a">
273
+ <strong>AutoAuto</strong>
274
+ </text>
275
+ {toolStatus ? (
276
+ <ToolStatusSpinner key={toolStatus} status={toolStatus} />
277
+ ) : (
278
+ <text fg="#888888">Thinking...</text>
279
+ )}
280
+ </box>
281
+ )}
282
+
283
+ {toolStatus && isStreaming && streamingText && (
284
+ <ToolStatusSpinner key={toolStatus} status={toolStatus} />
285
+ )}
286
+
287
+ {error && <text fg="#ff5555" selectable>Error: {error}</text>}
288
+ </box>
289
+ )}
290
+ </scrollbox>
291
+
292
+ <box border borderStyle="rounded" height={inputBoxHeight} maxHeight={8} title="Message (Shift+Enter for newline)">
293
+ <textarea
294
+ key={inputKey}
295
+ ref={textareaRef}
296
+ placeholder={
297
+ isStreaming ? "Waiting for response..." : defaultPlaceholder
298
+ }
299
+ focused={!isStreaming}
300
+ keyBindings={[
301
+ { name: "return", action: "submit" as const },
302
+ { name: "return", shift: true, action: "newline" as const },
303
+ ]}
304
+ />
305
+ </box>
306
+ </box>
307
+ )
308
+ }
@@ -0,0 +1,23 @@
1
+ interface CycleFieldProps {
2
+ label: string
3
+ value: string
4
+ description?: string
5
+ isFocused: boolean
6
+ }
7
+
8
+ export function CycleField({ label, value, description, isFocused }: CycleFieldProps) {
9
+ return (
10
+ <box flexDirection="column">
11
+ <text>
12
+ {isFocused ? (
13
+ <span fg="#7aa2f7"><strong>{` ${label}: \u25C2 ${value} \u25B8`}</strong></span>
14
+ ) : (
15
+ ` ${label}: ${value}`
16
+ )}
17
+ </text>
18
+ {isFocused && description && (
19
+ <text fg="#888888">{` ${description}`}</text>
20
+ )}
21
+ </box>
22
+ )
23
+ }