novacode 0.2.0
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/LICENSE +201 -0
- package/README.md +89 -0
- package/package.json +56 -0
- package/src/agent/agent.ts +87 -0
- package/src/agent/loop.ts +218 -0
- package/src/agent/prompt.ts +50 -0
- package/src/commands/compact.ts +28 -0
- package/src/commands/index.ts +62 -0
- package/src/commands/models.ts +86 -0
- package/src/commands/providers.ts +222 -0
- package/src/commands/session.ts +40 -0
- package/src/config/providers.ts +199 -0
- package/src/config/store.ts +67 -0
- package/src/main.ts +169 -0
- package/src/onboarding/wizard.ts +58 -0
- package/src/provider/gemini.ts +254 -0
- package/src/provider/openai.ts +218 -0
- package/src/provider/registry.ts +62 -0
- package/src/provider/stream.ts +77 -0
- package/src/session/compact.ts +126 -0
- package/src/session/store.ts +206 -0
- package/src/tools/fs.ts +195 -0
- package/src/tools/git.ts +82 -0
- package/src/tools/index.ts +33 -0
- package/src/tools/search.ts +252 -0
- package/src/tools/shell.ts +89 -0
- package/src/tools/web.ts +239 -0
- package/src/tui/app.tsx +517 -0
- package/src/tui/markdown.ts +62 -0
- package/src/tui/print.ts +75 -0
- package/src/types.ts +233 -0
- package/src/util.ts +88 -0
package/src/tui/app.tsx
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
import { Box, render, Static, Text, useInput } from "ink"
|
|
3
|
+
import { useEffect, useRef, useState } from "react"
|
|
4
|
+
import type { Agent } from "../agent/agent.ts"
|
|
5
|
+
import { COMMANDS, dispatch } from "../commands/index.ts"
|
|
6
|
+
import type { SessionStore } from "../session/store.ts"
|
|
7
|
+
import type { Msg } from "../types.ts"
|
|
8
|
+
import { formatToolArgs, makeRelative } from "../util.ts"
|
|
9
|
+
import { formatMarkdown } from "./markdown.ts"
|
|
10
|
+
export async function interactive(
|
|
11
|
+
agent: Agent,
|
|
12
|
+
store: SessionStore,
|
|
13
|
+
sessionId: string,
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
// Hide system cursor during session
|
|
16
|
+
process.stdout.write("\x1B[?25l")
|
|
17
|
+
process.stdout.write(chalk.bold.cyan("⚡ novacode\n"))
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />)
|
|
21
|
+
await waitUntilExit()
|
|
22
|
+
} finally {
|
|
23
|
+
// Restore system cursor on exit
|
|
24
|
+
process.stdout.write("\x1B[?25h")
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
29
|
+
|
|
30
|
+
function Spinner() {
|
|
31
|
+
const [frame, setFrame] = useState(0)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const timer = setInterval(() => {
|
|
35
|
+
setFrame((f) => (f + 1) % SPINNER_FRAMES.length)
|
|
36
|
+
}, 80)
|
|
37
|
+
return () => clearInterval(timer)
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
return <Text color="yellow">{SPINNER_FRAMES[frame]}</Text>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Cursor() {
|
|
44
|
+
const [visible, setVisible] = useState(true)
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const timer = setInterval(() => setVisible((v) => !v), 530)
|
|
47
|
+
return () => clearInterval(timer)
|
|
48
|
+
}, [])
|
|
49
|
+
return <Text color="green">{visible ? "│" : " "}</Text>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function App({
|
|
53
|
+
agent: initialAgent,
|
|
54
|
+
store,
|
|
55
|
+
sessionId,
|
|
56
|
+
}: {
|
|
57
|
+
agent: Agent
|
|
58
|
+
store: SessionStore
|
|
59
|
+
sessionId: string
|
|
60
|
+
}) {
|
|
61
|
+
const [agent, _setAgent] = useState(initialAgent)
|
|
62
|
+
const [msgs, setMsgs] = useState<Msg[]>(initialAgent.messages)
|
|
63
|
+
const [stream, setStream] = useState("")
|
|
64
|
+
const [thinkStream, setThinkStream] = useState("")
|
|
65
|
+
const [busy, setBusy] = useState(false)
|
|
66
|
+
const [input, setInput] = useState("")
|
|
67
|
+
const [status, setStatus] = useState("")
|
|
68
|
+
const [usage, setUsage] = useState<{ in: number; out: number }>({ in: 0, out: 0 })
|
|
69
|
+
const [selCmdIdx, setSelCmdIdx] = useState(0)
|
|
70
|
+
const [cmdRunning, setCmdRunning] = useState(false)
|
|
71
|
+
const history = useRef<string[]>([])
|
|
72
|
+
const hIdx = useRef(-1)
|
|
73
|
+
const abortCtrl = useRef<AbortController | null>(null)
|
|
74
|
+
|
|
75
|
+
const isTypingCmd = input.startsWith("/") && !input.includes(" ")
|
|
76
|
+
const suggestions = isTypingCmd
|
|
77
|
+
? COMMANDS.filter(
|
|
78
|
+
(c) =>
|
|
79
|
+
c.name.startsWith(input.slice(1).toLowerCase()) ||
|
|
80
|
+
c.aliases?.some((a) => a.startsWith(input.slice(1).toLowerCase())),
|
|
81
|
+
)
|
|
82
|
+
: []
|
|
83
|
+
|
|
84
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
setSelCmdIdx(0)
|
|
87
|
+
}, [input])
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!cmdRunning) {
|
|
91
|
+
process.stdout.write("\x1B[?25l")
|
|
92
|
+
}
|
|
93
|
+
}, [cmdRunning])
|
|
94
|
+
|
|
95
|
+
useInput((ch, key) => {
|
|
96
|
+
if (cmdRunning) return
|
|
97
|
+
|
|
98
|
+
if (key.escape) {
|
|
99
|
+
if (abortCtrl.current) {
|
|
100
|
+
abortCtrl.current.abort()
|
|
101
|
+
abortCtrl.current = null
|
|
102
|
+
}
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (key.upArrow) {
|
|
106
|
+
if (isTypingCmd && suggestions.length > 0) {
|
|
107
|
+
setSelCmdIdx((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1))
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (history.current.length > 0) {
|
|
111
|
+
hIdx.current = Math.min(hIdx.current + 1, history.current.length - 1)
|
|
112
|
+
setInput(history.current[hIdx.current] ?? "")
|
|
113
|
+
}
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (key.downArrow) {
|
|
117
|
+
if (isTypingCmd && suggestions.length > 0) {
|
|
118
|
+
setSelCmdIdx((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0))
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
hIdx.current = Math.max(hIdx.current - 1, -1)
|
|
122
|
+
setInput(hIdx.current >= 0 ? (history.current[hIdx.current] ?? "") : "")
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (key.tab) {
|
|
126
|
+
if (isTypingCmd && suggestions.length > 0) {
|
|
127
|
+
const match = suggestions[selCmdIdx]
|
|
128
|
+
if (match) {
|
|
129
|
+
setInput(`/${match.name} `)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (!key.return) {
|
|
135
|
+
setInput((prev) => {
|
|
136
|
+
if (key.backspace || key.delete) return prev.slice(0, -1)
|
|
137
|
+
return prev + ch
|
|
138
|
+
})
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (busy) return
|
|
143
|
+
|
|
144
|
+
let line = input.trim()
|
|
145
|
+
if (!line) return
|
|
146
|
+
|
|
147
|
+
if (isTypingCmd && suggestions.length > 0) {
|
|
148
|
+
const match = suggestions[selCmdIdx]
|
|
149
|
+
if (match) {
|
|
150
|
+
line = `/${match.name}`
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
setInput("")
|
|
155
|
+
history.current.unshift(line)
|
|
156
|
+
hIdx.current = -1
|
|
157
|
+
|
|
158
|
+
if (line.startsWith("/")) {
|
|
159
|
+
const cmdName = line.slice(1).split(" ")[0]?.toLowerCase() ?? ""
|
|
160
|
+
const isInteractive =
|
|
161
|
+
["providers", "prov", "config", "cfg", "models", "model"].includes(cmdName) &&
|
|
162
|
+
!line.includes(" ")
|
|
163
|
+
|
|
164
|
+
if (isInteractive) {
|
|
165
|
+
setCmdRunning(true)
|
|
166
|
+
// Small delay to let Ink clear
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
dispatch(line, agent, store, sessionId).then((r) => {
|
|
169
|
+
process.stdin.setRawMode?.(true)
|
|
170
|
+
setCmdRunning(false)
|
|
171
|
+
if (r) {
|
|
172
|
+
setMsgs((prev) => {
|
|
173
|
+
const updated: Msg[] = [
|
|
174
|
+
...prev,
|
|
175
|
+
{
|
|
176
|
+
role: "assistant",
|
|
177
|
+
content: [{ type: "text", text: r }],
|
|
178
|
+
model: "system",
|
|
179
|
+
provider: "system",
|
|
180
|
+
usage: { in: 0, out: 0 },
|
|
181
|
+
stop: "stop",
|
|
182
|
+
ts: Date.now(),
|
|
183
|
+
},
|
|
184
|
+
]
|
|
185
|
+
agent.setMessages(updated)
|
|
186
|
+
return updated
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
}, 50)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Slash commands
|
|
195
|
+
dispatch(line, agent, store, sessionId).then((r) => {
|
|
196
|
+
if (r) {
|
|
197
|
+
setMsgs((prev) => {
|
|
198
|
+
const updated: Msg[] = [
|
|
199
|
+
...prev,
|
|
200
|
+
{
|
|
201
|
+
role: "assistant",
|
|
202
|
+
content: [{ type: "text", text: r }],
|
|
203
|
+
model: "system",
|
|
204
|
+
provider: "system",
|
|
205
|
+
usage: { in: 0, out: 0 },
|
|
206
|
+
stop: "stop",
|
|
207
|
+
ts: Date.now(),
|
|
208
|
+
},
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
agent.setMessages(updated)
|
|
212
|
+
return updated
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
abortCtrl.current = new AbortController()
|
|
220
|
+
const stream = agent.prompt(line, abortCtrl.current.signal)
|
|
221
|
+
|
|
222
|
+
;(async () => {
|
|
223
|
+
for await (const ev of stream) {
|
|
224
|
+
switch (ev.type) {
|
|
225
|
+
case "start":
|
|
226
|
+
setBusy(true)
|
|
227
|
+
setStream("")
|
|
228
|
+
setThinkStream("")
|
|
229
|
+
setStatus("")
|
|
230
|
+
break
|
|
231
|
+
case "text_delta":
|
|
232
|
+
if (ev.text) setStream((prev) => prev + ev.text)
|
|
233
|
+
break
|
|
234
|
+
case "thinking_delta":
|
|
235
|
+
if (ev.text) setThinkStream((prev) => prev + ev.text)
|
|
236
|
+
break
|
|
237
|
+
case "assistant_msg":
|
|
238
|
+
setStream("")
|
|
239
|
+
setThinkStream("")
|
|
240
|
+
setMsgs((prev) => {
|
|
241
|
+
const updated = [...prev, ev.msg]
|
|
242
|
+
agent.setMessages(updated)
|
|
243
|
+
return updated
|
|
244
|
+
})
|
|
245
|
+
store.append(sessionId, ev.msg)
|
|
246
|
+
break
|
|
247
|
+
case "tool_call":
|
|
248
|
+
setStatus(chalk.dim(`⏳ ${ev.call.name}…`))
|
|
249
|
+
break
|
|
250
|
+
case "tool_result":
|
|
251
|
+
setMsgs((prev) => {
|
|
252
|
+
const updated = [...prev, ev.result]
|
|
253
|
+
agent.setMessages(updated)
|
|
254
|
+
return updated
|
|
255
|
+
})
|
|
256
|
+
store.append(sessionId, ev.result)
|
|
257
|
+
{
|
|
258
|
+
const args = ev.args
|
|
259
|
+
? `(${Object.values(ev.args)
|
|
260
|
+
.map((v) => {
|
|
261
|
+
const val = typeof v === "string" ? makeRelative(v) : JSON.stringify(v)
|
|
262
|
+
return val.length > 20 ? `${val.slice(0, 20)}…` : val
|
|
263
|
+
})
|
|
264
|
+
.join(", ")})`
|
|
265
|
+
: ""
|
|
266
|
+
setStatus(
|
|
267
|
+
ev.result.isError
|
|
268
|
+
? chalk.red(`✗ ${ev.result.tool}${args}`)
|
|
269
|
+
: chalk.green(`✓ ${ev.result.tool}${args}`),
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
break
|
|
273
|
+
case "turn_end":
|
|
274
|
+
setStatus("")
|
|
275
|
+
break
|
|
276
|
+
case "usage":
|
|
277
|
+
if (ev.usage) setUsage(ev.usage)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
abortCtrl.current = null
|
|
281
|
+
setBusy(false)
|
|
282
|
+
setStatus("")
|
|
283
|
+
setStream("")
|
|
284
|
+
setThinkStream("")
|
|
285
|
+
})().catch((err) => {
|
|
286
|
+
const errMsg: Msg = {
|
|
287
|
+
role: "assistant",
|
|
288
|
+
model: "system",
|
|
289
|
+
provider: "system",
|
|
290
|
+
content: [{ type: "text", text: chalk.red(`Error: ${err.message}`) }],
|
|
291
|
+
usage: { in: 0, out: 0 },
|
|
292
|
+
stop: "error",
|
|
293
|
+
ts: Date.now(),
|
|
294
|
+
}
|
|
295
|
+
setMsgs((prev) => [...prev, errMsg])
|
|
296
|
+
setBusy(false)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
// Record user msg immediately
|
|
300
|
+
const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
|
|
301
|
+
setMsgs((prev) => {
|
|
302
|
+
const updated = [...prev, userMsg]
|
|
303
|
+
agent.setMessages(updated)
|
|
304
|
+
return updated
|
|
305
|
+
})
|
|
306
|
+
store.append(sessionId, userMsg)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
if (cmdRunning) return null
|
|
310
|
+
|
|
311
|
+
const visibleMsgs = msgs.filter(hasMeaningfulContent)
|
|
312
|
+
const isLiveActive = !!(stream || thinkStream || busy)
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<Box flexDirection="column" paddingX={1}>
|
|
316
|
+
{/* Messages - pushed to scrollback as they finish */}
|
|
317
|
+
<Static items={visibleMsgs}>
|
|
318
|
+
{(m, i) => <Message key={`${m.ts}-${i}`} msg={m} isFirst={i === 0} />}
|
|
319
|
+
</Static>
|
|
320
|
+
|
|
321
|
+
{/* Live Area (Streaming, active tool calls, working indicator) */}
|
|
322
|
+
{isLiveActive && (
|
|
323
|
+
<Box flexDirection="column" marginTop={visibleMsgs.length > 0 ? 1 : 0}>
|
|
324
|
+
{thinkStream && (
|
|
325
|
+
<Text dimColor italic>
|
|
326
|
+
{thinkStream}
|
|
327
|
+
</Text>
|
|
328
|
+
)}
|
|
329
|
+
{stream && (
|
|
330
|
+
<Box flexDirection="row">
|
|
331
|
+
<Box flexGrow={1} flexShrink={1}>
|
|
332
|
+
<Text>
|
|
333
|
+
{formatMarkdown(stream)}
|
|
334
|
+
{!input && <Cursor />}
|
|
335
|
+
</Text>
|
|
336
|
+
</Box>
|
|
337
|
+
</Box>
|
|
338
|
+
)}
|
|
339
|
+
{busy && !stream && (
|
|
340
|
+
<Box flexDirection="row">
|
|
341
|
+
<Box marginRight={1}>
|
|
342
|
+
<Spinner />
|
|
343
|
+
</Box>
|
|
344
|
+
<Text dimColor>{status ? status.replace("⏳ ", "") : chalk.yellow("working…")}</Text>
|
|
345
|
+
</Box>
|
|
346
|
+
)}
|
|
347
|
+
</Box>
|
|
348
|
+
)}
|
|
349
|
+
|
|
350
|
+
{/* Input & Footer (Live) */}
|
|
351
|
+
<Box flexDirection="column" marginTop={visibleMsgs.length > 0 || isLiveActive ? 1 : 0}>
|
|
352
|
+
<Box flexDirection="row">
|
|
353
|
+
<Box flexShrink={0} marginRight={1}>
|
|
354
|
+
<Text bold color="green">
|
|
355
|
+
{">"}
|
|
356
|
+
</Text>
|
|
357
|
+
</Box>
|
|
358
|
+
<Box flexGrow={1} flexShrink={1}>
|
|
359
|
+
<Text>
|
|
360
|
+
{input}
|
|
361
|
+
<Cursor />
|
|
362
|
+
</Text>
|
|
363
|
+
</Box>
|
|
364
|
+
</Box>
|
|
365
|
+
|
|
366
|
+
{/* Dynamic Status / Info Line */}
|
|
367
|
+
<Box justifyContent="space-between">
|
|
368
|
+
<Box>
|
|
369
|
+
{suggestions.length > 0 ? (
|
|
370
|
+
<Box flexDirection="column" marginLeft={2}>
|
|
371
|
+
{suggestions.map((s, i) => (
|
|
372
|
+
<Box key={s.name}>
|
|
373
|
+
<Text
|
|
374
|
+
color={i === selCmdIdx ? "black" : "yellow"}
|
|
375
|
+
backgroundColor={i === selCmdIdx ? "yellow" : undefined}
|
|
376
|
+
>
|
|
377
|
+
/{s.name.padEnd(10)}
|
|
378
|
+
</Text>
|
|
379
|
+
<Text dimColor> {s.desc}</Text>
|
|
380
|
+
</Box>
|
|
381
|
+
))}
|
|
382
|
+
</Box>
|
|
383
|
+
) : (
|
|
384
|
+
<Text dimColor>Enter to send · /help for commands</Text>
|
|
385
|
+
)}
|
|
386
|
+
</Box>
|
|
387
|
+
|
|
388
|
+
<Box>
|
|
389
|
+
<Text dimColor>{formatTokenUsage(usage.in, agent.model.contextWindow)}</Text>
|
|
390
|
+
<Text dimColor> │ </Text>
|
|
391
|
+
<Text dimColor>{agent.model.id}</Text>
|
|
392
|
+
{busy && <Text dimColor> │ Esc to stop</Text>}
|
|
393
|
+
</Box>
|
|
394
|
+
</Box>
|
|
395
|
+
</Box>
|
|
396
|
+
</Box>
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function fmtK(n: number): string {
|
|
401
|
+
const k = n / 1000
|
|
402
|
+
return k >= 100 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function formatTokenUsage(used: number, contextWindow: number): string {
|
|
406
|
+
if (used === 0) return `0/${fmtK(contextWindow)}`
|
|
407
|
+
const pct = Math.round((used / contextWindow) * 100)
|
|
408
|
+
return `${fmtK(used)}/${fmtK(contextWindow)} (${pct}%)`
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const TOOL_STYLE: Record<string, string> = {
|
|
412
|
+
read: "blue",
|
|
413
|
+
write: "magenta",
|
|
414
|
+
edit: "yellow",
|
|
415
|
+
bash: "cyan",
|
|
416
|
+
glob: "green",
|
|
417
|
+
find: "green",
|
|
418
|
+
grep: "green",
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function hasMeaningfulContent(msg: Msg): boolean {
|
|
422
|
+
if (msg.role === "user") return true
|
|
423
|
+
if (msg.role === "tool_result") return true
|
|
424
|
+
if (msg.role === "assistant") {
|
|
425
|
+
if (msg.model === "system") return true
|
|
426
|
+
return msg.content.some((c) => {
|
|
427
|
+
if (c.type === "thinking") return c.text.trim().length > 0
|
|
428
|
+
if (c.type === "text") return c.text.trim().length > 0
|
|
429
|
+
return false
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
return false
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
|
|
436
|
+
if (msg.role === "user") {
|
|
437
|
+
return (
|
|
438
|
+
<Box marginTop={isFirst ? 0 : 1} flexDirection="row">
|
|
439
|
+
<Box flexShrink={0} marginRight={1}>
|
|
440
|
+
<Text bold color="green">
|
|
441
|
+
{">"}
|
|
442
|
+
</Text>
|
|
443
|
+
</Box>
|
|
444
|
+
<Box flexGrow={1} flexShrink={1}>
|
|
445
|
+
<Text>
|
|
446
|
+
{typeof msg.content === "string"
|
|
447
|
+
? msg.content
|
|
448
|
+
: msg.content.map((c) => (c.type === "text" ? c.text : "")).join("")}
|
|
449
|
+
</Text>
|
|
450
|
+
</Box>
|
|
451
|
+
</Box>
|
|
452
|
+
)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (msg.role === "assistant") {
|
|
456
|
+
if (msg.model === "system") {
|
|
457
|
+
return (
|
|
458
|
+
<Box flexDirection="column" marginTop={0}>
|
|
459
|
+
{msg.content.map((c, i) =>
|
|
460
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
461
|
+
c.type === "text" ? <Text key={i}>{formatMarkdown(c.text)}</Text> : null,
|
|
462
|
+
)}
|
|
463
|
+
</Box>
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Don't render empty assistant messages (often just tool call containers)
|
|
468
|
+
const hasVisibleContent = msg.content.some((c) => c.type === "text" || c.type === "thinking")
|
|
469
|
+
if (!hasVisibleContent) return null
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<Box flexDirection="column" marginTop={0}>
|
|
473
|
+
{msg.content.map((c, i) => {
|
|
474
|
+
if (c.type === "thinking") {
|
|
475
|
+
return (
|
|
476
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
477
|
+
<Text key={i} dimColor italic>
|
|
478
|
+
{c.text}
|
|
479
|
+
</Text>
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
if (c.type === "text") {
|
|
483
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
484
|
+
return <Text key={i}>{formatMarkdown(c.text)}</Text>
|
|
485
|
+
}
|
|
486
|
+
return null
|
|
487
|
+
})}
|
|
488
|
+
</Box>
|
|
489
|
+
)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (msg.role === "tool_result") {
|
|
493
|
+
const args = msg.args ? formatToolArgs(msg.args, true) : ""
|
|
494
|
+
|
|
495
|
+
const resText = msg.content
|
|
496
|
+
.map((c) => (c.type === "text" ? c.text : ""))
|
|
497
|
+
.join("")
|
|
498
|
+
.trim()
|
|
499
|
+
|
|
500
|
+
const isRead = msg.tool === "read"
|
|
501
|
+
const lineCount = isRead ? resText.split("\n").length : 0
|
|
502
|
+
const color = TOOL_STYLE[msg.tool] || "white"
|
|
503
|
+
|
|
504
|
+
return (
|
|
505
|
+
<Box flexDirection="row">
|
|
506
|
+
<Text color={msg.isError ? "red" : "green"}>{msg.isError ? "✗" : "✓"} </Text>
|
|
507
|
+
<Text color={color} bold>
|
|
508
|
+
{msg.tool}
|
|
509
|
+
</Text>
|
|
510
|
+
{args && <Text> {args}</Text>}
|
|
511
|
+
{isRead && !msg.isError && <Text dimColor> ({lineCount} lines)</Text>}
|
|
512
|
+
{msg.isError && resText && <Text color="red"> {resText.slice(0, 80)}</Text>}
|
|
513
|
+
</Box>
|
|
514
|
+
)
|
|
515
|
+
}
|
|
516
|
+
return null
|
|
517
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
|
|
3
|
+
export class MarkdownRenderer {
|
|
4
|
+
#inCodeBlock = false
|
|
5
|
+
#codeBlockLang = ""
|
|
6
|
+
|
|
7
|
+
renderLine(line: string): string {
|
|
8
|
+
if (line.startsWith("```")) {
|
|
9
|
+
if (this.#inCodeBlock) {
|
|
10
|
+
this.#inCodeBlock = false
|
|
11
|
+
return chalk.dim(`└${"─".repeat(50)}`)
|
|
12
|
+
}
|
|
13
|
+
this.#inCodeBlock = true
|
|
14
|
+
this.#codeBlockLang = line.slice(3).trim()
|
|
15
|
+
return chalk.dim(
|
|
16
|
+
"┌" +
|
|
17
|
+
"─".repeat(10) +
|
|
18
|
+
` [Code: ${this.#codeBlockLang || "text"}] ` +
|
|
19
|
+
"─".repeat(40 - (this.#codeBlockLang?.length || 4)),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (this.#inCodeBlock) {
|
|
24
|
+
return chalk.cyan(`│ ${line}`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (line.startsWith("#")) {
|
|
28
|
+
const match = line.match(/^(#{1,6})\s+(.*)$/)
|
|
29
|
+
if (match?.[1] && match[2]) {
|
|
30
|
+
const level = match[1].length
|
|
31
|
+
const content = match[2]
|
|
32
|
+
if (level === 1) return chalk.bold.magenta.underline(content)
|
|
33
|
+
if (level === 2) return chalk.bold.blue(content)
|
|
34
|
+
return chalk.bold.cyan(content)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let formatted = line
|
|
39
|
+
if (formatted.startsWith("- ") || formatted.startsWith("* ")) {
|
|
40
|
+
formatted = ` ${chalk.yellow("•")} ${formatted.slice(2)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
formatted = formatted.replace(/`([^`]+)`/g, (_, code) => chalk.yellow(code))
|
|
44
|
+
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, (_, bold) => chalk.bold(bold))
|
|
45
|
+
formatted = formatted.replace(/__([^_]+)__/g, (_, bold) => chalk.bold(bold))
|
|
46
|
+
formatted = formatted.replace(/\*([^*]+)\*/g, (_, italic) => chalk.italic(italic))
|
|
47
|
+
formatted = formatted.replace(/_([^_]+)_/g, (_, italic) => chalk.italic(italic))
|
|
48
|
+
formatted = formatted.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
|
|
49
|
+
return `${chalk.blue(text)} ${chalk.dim(`(${url})`)}`
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return formatted
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function formatMarkdown(text: string): string {
|
|
57
|
+
const renderer = new MarkdownRenderer()
|
|
58
|
+
return text
|
|
59
|
+
.split("\n")
|
|
60
|
+
.map((line) => renderer.renderLine(line))
|
|
61
|
+
.join("\n")
|
|
62
|
+
}
|
package/src/tui/print.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
import type { Agent } from "../agent/agent.ts"
|
|
3
|
+
import type { Msg } from "../types.ts"
|
|
4
|
+
import { formatToolArgs } from "../util.ts"
|
|
5
|
+
import { MarkdownRenderer } from "./markdown.ts"
|
|
6
|
+
|
|
7
|
+
const TOOL_STYLE: Record<string, (s: string) => string> = {
|
|
8
|
+
read: (s) => chalk.blue.bold(s),
|
|
9
|
+
write: (s) => chalk.magenta.bold(s),
|
|
10
|
+
edit: (s) => chalk.yellow.bold(s),
|
|
11
|
+
bash: (s) => chalk.cyan.bold(s),
|
|
12
|
+
glob: (s) => chalk.green.bold(s),
|
|
13
|
+
find: (s) => chalk.green.bold(s),
|
|
14
|
+
grep: (s) => chalk.green.bold(s),
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function stylizeTool(name: string): string {
|
|
18
|
+
const stylize = TOOL_STYLE[name] || ((s) => chalk.white.bold(s))
|
|
19
|
+
return stylize(name)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runPrintMode(
|
|
23
|
+
agent: Agent,
|
|
24
|
+
prompt: string,
|
|
25
|
+
signal?: AbortSignal,
|
|
26
|
+
): Promise<Msg[] | undefined> {
|
|
27
|
+
const stream = agent.prompt(prompt, signal)
|
|
28
|
+
let output = ""
|
|
29
|
+
let lastEventWasTool = false
|
|
30
|
+
|
|
31
|
+
const renderer = new MarkdownRenderer()
|
|
32
|
+
let lineBuffer = ""
|
|
33
|
+
|
|
34
|
+
for await (const event of stream) {
|
|
35
|
+
if (signal?.aborted) break
|
|
36
|
+
if (event.type === "text_delta") {
|
|
37
|
+
output += event.text
|
|
38
|
+
lineBuffer += event.text
|
|
39
|
+
|
|
40
|
+
if (lineBuffer.includes("\n")) {
|
|
41
|
+
const lines = lineBuffer.split("\n")
|
|
42
|
+
lineBuffer = lines.pop() ?? ""
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
process.stdout.write(`${renderer.renderLine(line)}\n`)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
lastEventWasTool = false
|
|
48
|
+
}
|
|
49
|
+
if (event.type === "tool_call") {
|
|
50
|
+
const argsObj = event.call.args
|
|
51
|
+
const argsStr = argsObj ? ` ${formatToolArgs(argsObj, true)}` : ""
|
|
52
|
+
if (!lastEventWasTool) {
|
|
53
|
+
process.stderr.write("\n")
|
|
54
|
+
}
|
|
55
|
+
process.stderr.write(`⏳ ${stylizeTool(event.call.name)}${argsStr}… `)
|
|
56
|
+
lastEventWasTool = true
|
|
57
|
+
}
|
|
58
|
+
if (event.type === "tool_result") {
|
|
59
|
+
const status = event.result.isError ? chalk.red("✗") : chalk.green("✓")
|
|
60
|
+
const argsObj = event.result.args
|
|
61
|
+
const argsStr = argsObj ? ` ${formatToolArgs(argsObj, true)}` : ""
|
|
62
|
+
process.stderr.write(`\r${status} ${stylizeTool(event.result.tool)}${argsStr}\x1B[K\n`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (lineBuffer) {
|
|
67
|
+
process.stdout.write(renderer.renderLine(lineBuffer))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (output && !output.endsWith("\n")) {
|
|
71
|
+
process.stdout.write("\n")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return stream.result
|
|
75
|
+
}
|