novacode 0.5.2 → 0.5.5
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 +15 -4
- package/dist/app-QfQR2FN9.mjs +21 -0
- package/dist/app-QfQR2FN9.mjs.map +1 -0
- package/dist/main.mjs +110 -0
- package/dist/main.mjs.map +1 -0
- package/package.json +25 -18
- package/src/agent/loop.ts +27 -8
- package/src/commands/index.ts +4 -3
- package/src/commands/models.ts +8 -9
- package/src/commands/providers.ts +63 -72
- package/src/config/providers.ts +12 -4
- package/src/config/store.ts +6 -7
- package/src/main.ts +7 -6
- package/src/onboarding/wizard.ts +26 -30
- package/src/provider/gemini.ts +12 -5
- package/src/provider/openai.ts +6 -9
- package/src/provider/stream.ts +64 -3
- package/src/session/compact.ts +1 -1
- package/src/session/store.ts +59 -36
- package/src/tools/fs.ts +10 -16
- package/src/tools/git.ts +26 -9
- package/src/tools/search.ts +33 -11
- package/src/tools/shell.ts +26 -25
- package/src/tui/app.tsx +141 -324
- package/src/tui/components/liveArea.tsx +73 -0
- package/src/tui/components/message.tsx +113 -0
- package/src/tui/components/statusBar.tsx +58 -0
- package/src/tui/prompts.tsx +205 -0
- package/src/types.ts +15 -0
- package/src/update.ts +44 -23
- package/src/util.ts +1 -28
- package/src/provider/registry.ts +0 -62
package/src/tui/app.tsx
CHANGED
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
import chalk from "chalk"
|
|
2
2
|
import { Box, render, Static, Text, useInput } from "ink"
|
|
3
|
-
import { useEffect, useRef, useState } from "react"
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
4
4
|
import type { Agent } from "../agent/agent.ts"
|
|
5
5
|
import { COMMANDS, dispatch } from "../commands/index.ts"
|
|
6
6
|
import type { SessionStore } from "../session/store.ts"
|
|
7
|
-
import type { Msg } from "../types.ts"
|
|
7
|
+
import type { Msg, Prompts } from "../types.ts"
|
|
8
8
|
import { checkForUpdate, getCurrentVersion } from "../update.ts"
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { Cursor, LiveArea } from "./components/liveArea.tsx"
|
|
10
|
+
import { hasMeaningfulContent, Message } from "./components/message.tsx"
|
|
11
|
+
import { StatusBar } from "./components/statusBar.tsx"
|
|
12
|
+
import { ConfirmPrompt, PasswordPrompt, SelectPrompt } from "./prompts.tsx"
|
|
13
|
+
|
|
14
|
+
type PromptMode =
|
|
15
|
+
| { type: "chat" }
|
|
16
|
+
| {
|
|
17
|
+
type: "select"
|
|
18
|
+
message: string
|
|
19
|
+
options: Array<{ value: string; label: string; hint?: string }>
|
|
20
|
+
header?: string
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
type: "password"
|
|
24
|
+
message: string
|
|
25
|
+
validate?: (v: string) => string | undefined
|
|
26
|
+
}
|
|
27
|
+
| { type: "confirm"; message: string }
|
|
28
|
+
|
|
11
29
|
export async function interactive(
|
|
12
30
|
agent: Agent,
|
|
13
31
|
store: SessionStore,
|
|
14
32
|
sessionId: string,
|
|
15
33
|
): Promise<void> {
|
|
16
|
-
// Hide system cursor during session
|
|
17
34
|
process.stdout.write("\x1B[?25l")
|
|
18
35
|
const version = await getCurrentVersion()
|
|
19
36
|
process.stdout.write(`${chalk.cyan.bold("⚡ novacode")} ${chalk.gray(`v${version}`)}\n`)
|
|
@@ -22,37 +39,12 @@ export async function interactive(
|
|
|
22
39
|
const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />)
|
|
23
40
|
await waitUntilExit()
|
|
24
41
|
} finally {
|
|
25
|
-
// Restore system cursor on exit
|
|
26
42
|
process.stdout.write("\x1B[?25h")
|
|
27
43
|
}
|
|
28
44
|
}
|
|
29
45
|
|
|
30
|
-
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
31
|
-
|
|
32
|
-
function Spinner() {
|
|
33
|
-
const [frame, setFrame] = useState(0)
|
|
34
|
-
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
const timer = setInterval(() => {
|
|
37
|
-
setFrame((f) => (f + 1) % SPINNER_FRAMES.length)
|
|
38
|
-
}, 80)
|
|
39
|
-
return () => clearInterval(timer)
|
|
40
|
-
}, [])
|
|
41
|
-
|
|
42
|
-
return <Text color="yellow">{SPINNER_FRAMES[frame]}</Text>
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function Cursor() {
|
|
46
|
-
const [visible, setVisible] = useState(true)
|
|
47
|
-
useEffect(() => {
|
|
48
|
-
const timer = setInterval(() => setVisible((v) => !v), 530)
|
|
49
|
-
return () => clearInterval(timer)
|
|
50
|
-
}, [])
|
|
51
|
-
return <Text color="green">{visible ? "│" : " "}</Text>
|
|
52
|
-
}
|
|
53
|
-
|
|
54
46
|
function App({
|
|
55
|
-
agent
|
|
47
|
+
agent,
|
|
56
48
|
store,
|
|
57
49
|
sessionId,
|
|
58
50
|
}: {
|
|
@@ -60,8 +52,7 @@ function App({
|
|
|
60
52
|
store: SessionStore
|
|
61
53
|
sessionId: string
|
|
62
54
|
}) {
|
|
63
|
-
const [
|
|
64
|
-
const [msgs, setMsgs] = useState<Msg[]>(initialAgent.messages)
|
|
55
|
+
const [msgs, setMsgs] = useState<Msg[]>(agent.messages)
|
|
65
56
|
const [stream, setStream] = useState("")
|
|
66
57
|
const [thinkStream, setThinkStream] = useState("")
|
|
67
58
|
const [busy, setBusy] = useState(false)
|
|
@@ -69,11 +60,15 @@ function App({
|
|
|
69
60
|
const [status, setStatus] = useState("")
|
|
70
61
|
const [usage, setUsage] = useState<{ in: number; out: number }>({ in: 0, out: 0 })
|
|
71
62
|
const [selCmdIdx, setSelCmdIdx] = useState(0)
|
|
72
|
-
const [
|
|
63
|
+
const [mode, setMode] = useState<PromptMode>({ type: "chat" })
|
|
64
|
+
const resolveRef = useRef<((v: unknown) => void) | null>(null)
|
|
73
65
|
const history = useRef<string[]>([])
|
|
74
66
|
const hIdx = useRef(-1)
|
|
75
67
|
const abortCtrl = useRef<AbortController | null>(null)
|
|
76
|
-
const [updateInfo, setUpdateInfo] = useState<{
|
|
68
|
+
const [updateInfo, setUpdateInfo] = useState<{
|
|
69
|
+
current: string
|
|
70
|
+
latest: string
|
|
71
|
+
} | null>(null)
|
|
77
72
|
|
|
78
73
|
useEffect(() => {
|
|
79
74
|
const check = async () => {
|
|
@@ -94,19 +89,53 @@ function App({
|
|
|
94
89
|
)
|
|
95
90
|
: []
|
|
96
91
|
|
|
92
|
+
const prompts: Prompts = {
|
|
93
|
+
select: useCallback(
|
|
94
|
+
(config) =>
|
|
95
|
+
new Promise((resolve) => {
|
|
96
|
+
resolveRef.current = resolve as (v: unknown) => void
|
|
97
|
+
setMode({ type: "select", ...config })
|
|
98
|
+
}),
|
|
99
|
+
[],
|
|
100
|
+
),
|
|
101
|
+
password: useCallback(
|
|
102
|
+
(config) =>
|
|
103
|
+
new Promise((resolve) => {
|
|
104
|
+
resolveRef.current = resolve as (v: unknown) => void
|
|
105
|
+
setMode({ type: "password", ...config })
|
|
106
|
+
}),
|
|
107
|
+
[],
|
|
108
|
+
),
|
|
109
|
+
confirm: useCallback(
|
|
110
|
+
(config) =>
|
|
111
|
+
new Promise((resolve) => {
|
|
112
|
+
resolveRef.current = resolve as (v: unknown) => void
|
|
113
|
+
setMode({ type: "confirm", ...config })
|
|
114
|
+
}),
|
|
115
|
+
[],
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolvePrompt(value: unknown) {
|
|
120
|
+
const fn = resolveRef.current
|
|
121
|
+
resolveRef.current = null
|
|
122
|
+
setMode({ type: "chat" })
|
|
123
|
+
fn?.(value)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function commitMsg(msg: Msg) {
|
|
127
|
+
setMsgs((prev) => [...prev, msg])
|
|
128
|
+
agent.setMessages([...agent.messages, msg])
|
|
129
|
+
store.append(sessionId, msg)
|
|
130
|
+
}
|
|
131
|
+
|
|
97
132
|
// biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
|
|
98
133
|
useEffect(() => {
|
|
99
134
|
setSelCmdIdx(0)
|
|
100
135
|
}, [input])
|
|
101
136
|
|
|
102
|
-
useEffect(() => {
|
|
103
|
-
if (!cmdRunning) {
|
|
104
|
-
process.stdout.write("\x1B[?25l")
|
|
105
|
-
}
|
|
106
|
-
}, [cmdRunning])
|
|
107
|
-
|
|
108
137
|
useInput((ch, key) => {
|
|
109
|
-
if (
|
|
138
|
+
if (mode.type !== "chat") return
|
|
110
139
|
|
|
111
140
|
if (key.escape) {
|
|
112
141
|
if (abortCtrl.current) {
|
|
@@ -147,7 +176,7 @@ function App({
|
|
|
147
176
|
if (!key.return) {
|
|
148
177
|
setInput((prev) => {
|
|
149
178
|
if (key.backspace || key.delete) return prev.slice(0, -1)
|
|
150
|
-
return prev + ch
|
|
179
|
+
return prev + (ch || "")
|
|
151
180
|
})
|
|
152
181
|
return
|
|
153
182
|
}
|
|
@@ -169,71 +198,34 @@ function App({
|
|
|
169
198
|
hIdx.current = -1
|
|
170
199
|
|
|
171
200
|
if (line.startsWith("/")) {
|
|
172
|
-
|
|
173
|
-
const isInteractive =
|
|
174
|
-
["providers", "prov", "config", "cfg", "models", "model"].includes(cmdName) &&
|
|
175
|
-
!line.includes(" ")
|
|
176
|
-
|
|
177
|
-
if (isInteractive) {
|
|
178
|
-
setCmdRunning(true)
|
|
179
|
-
// Small delay to let Ink clear
|
|
180
|
-
setTimeout(() => {
|
|
181
|
-
dispatch(line, agent, store, sessionId).then((r) => {
|
|
182
|
-
process.stdin.setRawMode?.(true)
|
|
183
|
-
setCmdRunning(false)
|
|
184
|
-
if (r) {
|
|
185
|
-
setMsgs((prev) => {
|
|
186
|
-
const updated: Msg[] = [
|
|
187
|
-
...prev,
|
|
188
|
-
{
|
|
189
|
-
role: "assistant",
|
|
190
|
-
content: [{ type: "text", text: r }],
|
|
191
|
-
model: "system",
|
|
192
|
-
provider: "system",
|
|
193
|
-
usage: { in: 0, out: 0 },
|
|
194
|
-
stop: "stop",
|
|
195
|
-
ts: Date.now(),
|
|
196
|
-
},
|
|
197
|
-
]
|
|
198
|
-
agent.setMessages(updated)
|
|
199
|
-
return updated
|
|
200
|
-
})
|
|
201
|
-
}
|
|
202
|
-
})
|
|
203
|
-
}, 50)
|
|
204
|
-
return
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Slash commands
|
|
208
|
-
dispatch(line, agent, store, sessionId).then((r) => {
|
|
201
|
+
dispatch(line, agent, store, sessionId, prompts).then((r) => {
|
|
209
202
|
if (r) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
usage: { in: 0, out: 0 },
|
|
219
|
-
stop: "stop",
|
|
220
|
-
ts: Date.now(),
|
|
221
|
-
},
|
|
222
|
-
]
|
|
223
|
-
|
|
224
|
-
agent.setMessages(updated)
|
|
225
|
-
return updated
|
|
203
|
+
commitMsg({
|
|
204
|
+
role: "assistant",
|
|
205
|
+
content: [{ type: "text", text: r }],
|
|
206
|
+
model: "system",
|
|
207
|
+
provider: "system",
|
|
208
|
+
usage: { in: 0, out: 0 },
|
|
209
|
+
stop: "stop",
|
|
210
|
+
ts: Date.now(),
|
|
226
211
|
})
|
|
227
212
|
}
|
|
228
213
|
})
|
|
229
214
|
return
|
|
230
215
|
}
|
|
231
216
|
|
|
217
|
+
const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
|
|
218
|
+
commitMsg(userMsg)
|
|
219
|
+
|
|
232
220
|
abortCtrl.current = new AbortController()
|
|
233
|
-
const
|
|
221
|
+
const eventStream = agent.prompt(line, abortCtrl.current.signal)
|
|
222
|
+
|
|
223
|
+
runEventLoop(eventStream)
|
|
224
|
+
})
|
|
234
225
|
|
|
235
|
-
|
|
236
|
-
|
|
226
|
+
async function runEventLoop(eventStream: ReturnType<Agent["prompt"]>) {
|
|
227
|
+
try {
|
|
228
|
+
for await (const ev of eventStream) {
|
|
237
229
|
switch (ev.type) {
|
|
238
230
|
case "start":
|
|
239
231
|
setBusy(true)
|
|
@@ -248,40 +240,21 @@ function App({
|
|
|
248
240
|
if (ev.text) setThinkStream((prev) => prev + ev.text)
|
|
249
241
|
break
|
|
250
242
|
case "assistant_msg":
|
|
243
|
+
commitMsg(ev.msg)
|
|
251
244
|
setStream("")
|
|
252
245
|
setThinkStream("")
|
|
253
|
-
|
|
254
|
-
const updated = [...prev, ev.msg]
|
|
255
|
-
agent.setMessages(updated)
|
|
256
|
-
return updated
|
|
257
|
-
})
|
|
258
|
-
store.append(sessionId, ev.msg)
|
|
246
|
+
|
|
259
247
|
break
|
|
260
248
|
case "tool_call":
|
|
261
249
|
setStatus(chalk.dim(`⏳ ${ev.call.name}…`))
|
|
262
250
|
break
|
|
263
251
|
case "tool_result":
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
{
|
|
271
|
-
const args = ev.args
|
|
272
|
-
? `(${Object.values(ev.args)
|
|
273
|
-
.map((v) => {
|
|
274
|
-
const val = typeof v === "string" ? makeRelative(v) : JSON.stringify(v)
|
|
275
|
-
return val.length > 20 ? `${val.slice(0, 20)}…` : val
|
|
276
|
-
})
|
|
277
|
-
.join(", ")})`
|
|
278
|
-
: ""
|
|
279
|
-
setStatus(
|
|
280
|
-
ev.result.isError
|
|
281
|
-
? chalk.red(`✗ ${ev.result.tool}${args}`)
|
|
282
|
-
: chalk.green(`✓ ${ev.result.tool}${args}`),
|
|
283
|
-
)
|
|
284
|
-
}
|
|
252
|
+
commitMsg(ev.result)
|
|
253
|
+
setStatus(
|
|
254
|
+
ev.result.isError
|
|
255
|
+
? chalk.red(`✗ ${ev.result.tool}`)
|
|
256
|
+
: chalk.green(`✓ ${ev.result.tool}`),
|
|
257
|
+
)
|
|
285
258
|
break
|
|
286
259
|
case "turn_end":
|
|
287
260
|
setStatus("")
|
|
@@ -290,77 +263,62 @@ function App({
|
|
|
290
263
|
if (ev.usage) setUsage(ev.usage)
|
|
291
264
|
}
|
|
292
265
|
}
|
|
293
|
-
|
|
294
|
-
setBusy(false)
|
|
295
|
-
setStatus("")
|
|
296
|
-
setStream("")
|
|
297
|
-
setThinkStream("")
|
|
298
|
-
})().catch((err) => {
|
|
266
|
+
} catch (err) {
|
|
299
267
|
const errMsg: Msg = {
|
|
300
268
|
role: "assistant",
|
|
301
269
|
model: "system",
|
|
302
270
|
provider: "system",
|
|
303
|
-
content: [{ type: "text", text: chalk.red(`Error: ${err.message}`) }],
|
|
271
|
+
content: [{ type: "text", text: chalk.red(`Error: ${(err as Error).message}`) }],
|
|
304
272
|
usage: { in: 0, out: 0 },
|
|
305
273
|
stop: "error",
|
|
306
274
|
ts: Date.now(),
|
|
307
275
|
}
|
|
308
|
-
|
|
276
|
+
commitMsg(errMsg)
|
|
277
|
+
} finally {
|
|
278
|
+
abortCtrl.current = null
|
|
309
279
|
setBusy(false)
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const updated = [...prev, userMsg]
|
|
316
|
-
agent.setMessages(updated)
|
|
317
|
-
return updated
|
|
318
|
-
})
|
|
319
|
-
store.append(sessionId, userMsg)
|
|
320
|
-
})
|
|
280
|
+
setStream("")
|
|
281
|
+
setThinkStream("")
|
|
282
|
+
setStatus("")
|
|
283
|
+
}
|
|
284
|
+
}
|
|
321
285
|
|
|
322
|
-
if (
|
|
286
|
+
if (mode.type === "select") {
|
|
287
|
+
return (
|
|
288
|
+
<SelectPrompt
|
|
289
|
+
message={mode.message}
|
|
290
|
+
options={mode.options}
|
|
291
|
+
header={mode.header}
|
|
292
|
+
onSelect={resolvePrompt}
|
|
293
|
+
/>
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
if (mode.type === "password") {
|
|
297
|
+
return (
|
|
298
|
+
<PasswordPrompt message={mode.message} validate={mode.validate} onSubmit={resolvePrompt} />
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
if (mode.type === "confirm") {
|
|
302
|
+
return <ConfirmPrompt message={mode.message} onConfirm={resolvePrompt} />
|
|
303
|
+
}
|
|
323
304
|
|
|
324
305
|
const visibleMsgs = msgs.filter(hasMeaningfulContent)
|
|
325
306
|
const isLiveActive = !!(stream || thinkStream || busy)
|
|
326
307
|
|
|
327
308
|
return (
|
|
328
309
|
<Box flexDirection="column" paddingX={1}>
|
|
329
|
-
{/* Messages - pushed to scrollback as they finish */}
|
|
330
310
|
<Static items={visibleMsgs}>
|
|
331
311
|
{(m, i) => <Message key={`${m.ts}-${i}`} msg={m} isFirst={i === 0} />}
|
|
332
312
|
</Static>
|
|
333
313
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
)}
|
|
342
|
-
{stream && (
|
|
343
|
-
<Box flexDirection="row">
|
|
344
|
-
<Box flexGrow={1} flexShrink={1}>
|
|
345
|
-
<Text>
|
|
346
|
-
{formatMarkdown(stream)}
|
|
347
|
-
{!input && <Cursor />}
|
|
348
|
-
</Text>
|
|
349
|
-
</Box>
|
|
350
|
-
</Box>
|
|
351
|
-
)}
|
|
352
|
-
{busy && !stream && (
|
|
353
|
-
<Box flexDirection="row">
|
|
354
|
-
<Box marginRight={1}>
|
|
355
|
-
<Spinner />
|
|
356
|
-
</Box>
|
|
357
|
-
<Text dimColor>{status ? status.replace("⏳ ", "") : chalk.yellow("working…")}</Text>
|
|
358
|
-
</Box>
|
|
359
|
-
)}
|
|
360
|
-
</Box>
|
|
361
|
-
)}
|
|
314
|
+
<LiveArea
|
|
315
|
+
stream={stream}
|
|
316
|
+
thinkStream={thinkStream}
|
|
317
|
+
busy={busy}
|
|
318
|
+
status={status}
|
|
319
|
+
hasMessages={visibleMsgs.length > 0}
|
|
320
|
+
/>
|
|
362
321
|
|
|
363
|
-
{/* Input & Footer (Live) */}
|
|
364
322
|
<Box flexDirection="column" marginTop={visibleMsgs.length > 0 || isLiveActive ? 1 : 0}>
|
|
365
323
|
{updateInfo && (
|
|
366
324
|
<Box
|
|
@@ -393,155 +351,14 @@ function App({
|
|
|
393
351
|
</Box>
|
|
394
352
|
</Box>
|
|
395
353
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
<Text
|
|
404
|
-
color={i === selCmdIdx ? "black" : "yellow"}
|
|
405
|
-
backgroundColor={i === selCmdIdx ? "yellow" : undefined}
|
|
406
|
-
>
|
|
407
|
-
/{s.name.padEnd(10)}
|
|
408
|
-
</Text>
|
|
409
|
-
<Text dimColor> {s.desc}</Text>
|
|
410
|
-
</Box>
|
|
411
|
-
))}
|
|
412
|
-
</Box>
|
|
413
|
-
) : (
|
|
414
|
-
<Text dimColor>Enter to send · /help for commands</Text>
|
|
415
|
-
)}
|
|
416
|
-
</Box>
|
|
417
|
-
|
|
418
|
-
<Box>
|
|
419
|
-
<Text dimColor>{formatTokenUsage(usage.in, agent.model.contextWindow)}</Text>
|
|
420
|
-
<Text dimColor> │ </Text>
|
|
421
|
-
<Text dimColor>{agent.model.id}</Text>
|
|
422
|
-
{busy && <Text dimColor> │ Esc to stop</Text>}
|
|
423
|
-
</Box>
|
|
424
|
-
</Box>
|
|
354
|
+
<StatusBar
|
|
355
|
+
model={agent.model}
|
|
356
|
+
usage={usage}
|
|
357
|
+
busy={busy}
|
|
358
|
+
suggestions={suggestions}
|
|
359
|
+
selCmdIdx={selCmdIdx}
|
|
360
|
+
/>
|
|
425
361
|
</Box>
|
|
426
362
|
</Box>
|
|
427
363
|
)
|
|
428
364
|
}
|
|
429
|
-
|
|
430
|
-
function fmtK(n: number): string {
|
|
431
|
-
const k = n / 1000
|
|
432
|
-
return k >= 100 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function formatTokenUsage(used: number, contextWindow: number): string {
|
|
436
|
-
if (used === 0) return `0/${fmtK(contextWindow)}`
|
|
437
|
-
const pct = Math.round((used / contextWindow) * 100)
|
|
438
|
-
return `${fmtK(used)}/${fmtK(contextWindow)} (${pct}%)`
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const TOOL_STYLE: Record<string, string> = {
|
|
442
|
-
read: "blue",
|
|
443
|
-
write: "magenta",
|
|
444
|
-
edit: "yellow",
|
|
445
|
-
bash: "cyan",
|
|
446
|
-
glob: "green",
|
|
447
|
-
find: "green",
|
|
448
|
-
grep: "green",
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function hasMeaningfulContent(msg: Msg): boolean {
|
|
452
|
-
if (msg.role === "user") return true
|
|
453
|
-
if (msg.role === "tool_result") return true
|
|
454
|
-
if (msg.role === "assistant") {
|
|
455
|
-
if (msg.model === "system") return true
|
|
456
|
-
return msg.content.some((c) => {
|
|
457
|
-
if (c.type === "thinking") return c.text.trim().length > 0
|
|
458
|
-
if (c.type === "text") return c.text.trim().length > 0
|
|
459
|
-
return false
|
|
460
|
-
})
|
|
461
|
-
}
|
|
462
|
-
return false
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
|
|
466
|
-
if (msg.role === "user") {
|
|
467
|
-
return (
|
|
468
|
-
<Box marginTop={isFirst ? 0 : 1} flexDirection="row">
|
|
469
|
-
<Box flexShrink={0} marginRight={1}>
|
|
470
|
-
<Text bold color="green">
|
|
471
|
-
{">"}
|
|
472
|
-
</Text>
|
|
473
|
-
</Box>
|
|
474
|
-
<Box flexGrow={1} flexShrink={1}>
|
|
475
|
-
<Text>
|
|
476
|
-
{typeof msg.content === "string"
|
|
477
|
-
? msg.content
|
|
478
|
-
: msg.content.map((c) => (c.type === "text" ? c.text : "")).join("")}
|
|
479
|
-
</Text>
|
|
480
|
-
</Box>
|
|
481
|
-
</Box>
|
|
482
|
-
)
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
if (msg.role === "assistant") {
|
|
486
|
-
if (msg.model === "system") {
|
|
487
|
-
return (
|
|
488
|
-
<Box flexDirection="column" marginTop={0}>
|
|
489
|
-
{msg.content.map((c, i) =>
|
|
490
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
491
|
-
c.type === "text" ? <Text key={i}>{formatMarkdown(c.text)}</Text> : null,
|
|
492
|
-
)}
|
|
493
|
-
</Box>
|
|
494
|
-
)
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// Don't render empty assistant messages (often just tool call containers)
|
|
498
|
-
const hasVisibleContent = msg.content.some((c) => c.type === "text" || c.type === "thinking")
|
|
499
|
-
if (!hasVisibleContent) return null
|
|
500
|
-
|
|
501
|
-
return (
|
|
502
|
-
<Box flexDirection="column" marginTop={0}>
|
|
503
|
-
{msg.content.map((c, i) => {
|
|
504
|
-
if (c.type === "thinking") {
|
|
505
|
-
return (
|
|
506
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
507
|
-
<Text key={i} dimColor italic>
|
|
508
|
-
{c.text}
|
|
509
|
-
</Text>
|
|
510
|
-
)
|
|
511
|
-
}
|
|
512
|
-
if (c.type === "text") {
|
|
513
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
514
|
-
return <Text key={i}>{formatMarkdown(c.text)}</Text>
|
|
515
|
-
}
|
|
516
|
-
return null
|
|
517
|
-
})}
|
|
518
|
-
</Box>
|
|
519
|
-
)
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
if (msg.role === "tool_result") {
|
|
523
|
-
const args = msg.args ? formatToolArgs(msg.args, true) : ""
|
|
524
|
-
|
|
525
|
-
const resText = msg.content
|
|
526
|
-
.map((c) => (c.type === "text" ? c.text : ""))
|
|
527
|
-
.join("")
|
|
528
|
-
.trim()
|
|
529
|
-
|
|
530
|
-
const isRead = msg.tool === "read"
|
|
531
|
-
const lineCount = isRead ? resText.split("\n").length : 0
|
|
532
|
-
const color = TOOL_STYLE[msg.tool] || "white"
|
|
533
|
-
|
|
534
|
-
return (
|
|
535
|
-
<Box flexDirection="row">
|
|
536
|
-
<Text color={msg.isError ? "red" : "green"}>{msg.isError ? "✗" : "✓"} </Text>
|
|
537
|
-
<Text color={color} bold>
|
|
538
|
-
{msg.tool}
|
|
539
|
-
</Text>
|
|
540
|
-
{args && <Text> {args}</Text>}
|
|
541
|
-
{isRead && !msg.isError && <Text dimColor> ({lineCount} lines)</Text>}
|
|
542
|
-
{msg.isError && resText && <Text color="red"> {resText.slice(0, 80)}</Text>}
|
|
543
|
-
</Box>
|
|
544
|
-
)
|
|
545
|
-
}
|
|
546
|
-
return null
|
|
547
|
-
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
import { Box, Text } from "ink"
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import { formatMarkdown } from "../markdown.ts"
|
|
5
|
+
|
|
6
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
7
|
+
|
|
8
|
+
export function Spinner() {
|
|
9
|
+
const [frame, setFrame] = useState(0)
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const timer = setInterval(() => {
|
|
13
|
+
setFrame((f) => (f + 1) % SPINNER_FRAMES.length)
|
|
14
|
+
}, 80)
|
|
15
|
+
return () => clearInterval(timer)
|
|
16
|
+
}, [])
|
|
17
|
+
|
|
18
|
+
return <Text color="yellow">{SPINNER_FRAMES[frame]}</Text>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Cursor() {
|
|
22
|
+
const [visible, setVisible] = useState(true)
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const timer = setInterval(() => setVisible((v) => !v), 530)
|
|
25
|
+
return () => clearInterval(timer)
|
|
26
|
+
}, [])
|
|
27
|
+
return <Text color="green">{visible ? "│" : " "}</Text>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function LiveArea({
|
|
31
|
+
stream,
|
|
32
|
+
thinkStream,
|
|
33
|
+
busy,
|
|
34
|
+
status,
|
|
35
|
+
hasMessages,
|
|
36
|
+
}: {
|
|
37
|
+
stream: string
|
|
38
|
+
thinkStream: string
|
|
39
|
+
busy: boolean
|
|
40
|
+
status: string
|
|
41
|
+
hasMessages: boolean
|
|
42
|
+
}) {
|
|
43
|
+
const isActive = !!(stream || thinkStream || busy)
|
|
44
|
+
if (!isActive) return null
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Box flexDirection="column" marginTop={hasMessages ? 1 : 0}>
|
|
48
|
+
{thinkStream && (
|
|
49
|
+
<Text dimColor italic>
|
|
50
|
+
{thinkStream}
|
|
51
|
+
</Text>
|
|
52
|
+
)}
|
|
53
|
+
{stream && (
|
|
54
|
+
<Box flexDirection="row">
|
|
55
|
+
<Box flexGrow={1} flexShrink={1}>
|
|
56
|
+
<Text>
|
|
57
|
+
{formatMarkdown(stream)}
|
|
58
|
+
<Cursor />
|
|
59
|
+
</Text>
|
|
60
|
+
</Box>
|
|
61
|
+
</Box>
|
|
62
|
+
)}
|
|
63
|
+
{busy && !stream && (
|
|
64
|
+
<Box flexDirection="row">
|
|
65
|
+
<Box marginRight={1}>
|
|
66
|
+
<Spinner />
|
|
67
|
+
</Box>
|
|
68
|
+
<Text dimColor>{status ? status.replace("⏳ ", "") : chalk.yellow("working…")}</Text>
|
|
69
|
+
</Box>
|
|
70
|
+
)}
|
|
71
|
+
</Box>
|
|
72
|
+
)
|
|
73
|
+
}
|