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