novacode 0.5.3 → 0.6.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/README.md +16 -23
- package/dist/app-bQ9a_p_K.mjs +22 -0
- package/dist/app-bQ9a_p_K.mjs.map +1 -0
- package/dist/main.mjs +33 -56
- package/dist/main.mjs.map +1 -1
- package/package.json +3 -4
- package/src/commands/compact.ts +1 -1
- package/src/commands/index.ts +46 -4
- package/src/commands/session.ts +23 -11
- package/src/main.ts +57 -27
- package/src/provider/gemini.ts +11 -3
- package/src/provider/openai.ts +28 -4
- package/src/provider/stream.ts +1 -3
- package/src/session/compact.ts +43 -10
- package/src/session/store.ts +170 -167
- package/src/tools/web.ts +1 -1
- package/src/tui/app.tsx +118 -221
- package/src/tui/components/liveArea.tsx +70 -0
- package/src/tui/components/message.tsx +117 -0
- package/src/tui/components/statusBar.tsx +64 -0
- package/src/tui/constants.ts +25 -0
- package/src/types.ts +14 -0
- package/src/util.ts +19 -0
- package/dist/app-BZ42XPxw.mjs +0 -21
- package/dist/app-BZ42XPxw.mjs.map +0 -1
package/src/tui/app.tsx
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import chalk from "chalk"
|
|
2
|
-
import { Box, render, Static, Text, useInput } from "ink"
|
|
2
|
+
import { Box, render, Static, Text, useApp, useInput } from "ink"
|
|
3
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
|
+
import { getProvider, MODELS } from "../config/providers.ts"
|
|
7
|
+
import { loadAuth } from "../config/store.ts"
|
|
8
|
+
import { generateSessionTitle } from "../session/compact.ts"
|
|
6
9
|
import type { SessionStore } from "../session/store.ts"
|
|
7
10
|
import type { Msg, Prompts } from "../types.ts"
|
|
8
11
|
import { checkForUpdate, getCurrentVersion } from "../update.ts"
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
12
|
+
import { Cursor, LiveArea } from "./components/liveArea.tsx"
|
|
13
|
+
import { hasMeaningfulContent, Message } from "./components/message.tsx"
|
|
14
|
+
import { StatusBar } from "./components/statusBar.tsx"
|
|
11
15
|
import { ConfirmPrompt, PasswordPrompt, SelectPrompt } from "./prompts.tsx"
|
|
12
16
|
|
|
13
17
|
type PromptMode =
|
|
@@ -35,57 +39,61 @@ export async function interactive(
|
|
|
35
39
|
process.stdout.write(`${chalk.cyan.bold("⚡ novacode")} ${chalk.gray(`v${version}`)}\n`)
|
|
36
40
|
|
|
37
41
|
try {
|
|
38
|
-
const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId}
|
|
42
|
+
const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />, {
|
|
43
|
+
exitOnCtrlC: false,
|
|
44
|
+
})
|
|
39
45
|
await waitUntilExit()
|
|
40
46
|
} finally {
|
|
41
47
|
process.stdout.write("\x1B[?25h")
|
|
48
|
+
await store.prune()
|
|
42
49
|
}
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
46
|
-
|
|
47
|
-
function Spinner() {
|
|
48
|
-
const [frame, setFrame] = useState(0)
|
|
49
|
-
|
|
50
|
-
useEffect(() => {
|
|
51
|
-
const timer = setInterval(() => {
|
|
52
|
-
setFrame((f) => (f + 1) % SPINNER_FRAMES.length)
|
|
53
|
-
}, 80)
|
|
54
|
-
return () => clearInterval(timer)
|
|
55
|
-
}, [])
|
|
56
|
-
|
|
57
|
-
return <Text color="yellow">{SPINNER_FRAMES[frame]}</Text>
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function Cursor() {
|
|
61
|
-
const [visible, setVisible] = useState(true)
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
const timer = setInterval(() => setVisible((v) => !v), 530)
|
|
64
|
-
return () => clearInterval(timer)
|
|
65
|
-
}, [])
|
|
66
|
-
return <Text color="green">{visible ? "│" : " "}</Text>
|
|
67
|
-
}
|
|
68
|
-
|
|
69
52
|
function App({
|
|
70
|
-
agent
|
|
53
|
+
agent,
|
|
71
54
|
store,
|
|
72
|
-
sessionId,
|
|
55
|
+
sessionId: initialSessionId,
|
|
73
56
|
}: {
|
|
74
57
|
agent: Agent
|
|
75
58
|
store: SessionStore
|
|
76
59
|
sessionId: string
|
|
77
60
|
}) {
|
|
78
|
-
const [
|
|
79
|
-
const [msgs, setMsgs] = useState<Msg[]>(
|
|
61
|
+
const [currSessionId, setCurrSessionId] = useState(initialSessionId)
|
|
62
|
+
const [msgs, setMsgs] = useState<Msg[]>(agent.messages)
|
|
63
|
+
|
|
64
|
+
const handleSwitchSession = useCallback(
|
|
65
|
+
async (newSessionId: string) => {
|
|
66
|
+
const s = await store.get(newSessionId)
|
|
67
|
+
if (!s) return
|
|
68
|
+
|
|
69
|
+
const provider = getProvider(s.provider)
|
|
70
|
+
const model =
|
|
71
|
+
MODELS.find((m) => m.id === s.model && m.provider === s.provider) ||
|
|
72
|
+
MODELS.find((m) => m.id === s.model)
|
|
73
|
+
if (provider && model) {
|
|
74
|
+
const auth = await loadAuth()
|
|
75
|
+
const apiKey = auth.apiKeys[s.provider] || ""
|
|
76
|
+
agent.updateConfig({
|
|
77
|
+
api: provider.api,
|
|
78
|
+
model,
|
|
79
|
+
apiKey,
|
|
80
|
+
baseUrl: provider.baseUrl,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const newMsgs = await store.messages(newSessionId)
|
|
85
|
+
agent.setMessages(newMsgs)
|
|
86
|
+
setMsgs(newMsgs)
|
|
87
|
+
setCurrSessionId(newSessionId)
|
|
88
|
+
},
|
|
89
|
+
[store, agent],
|
|
90
|
+
)
|
|
80
91
|
const [stream, setStream] = useState("")
|
|
81
92
|
const [thinkStream, setThinkStream] = useState("")
|
|
82
93
|
const [busy, setBusy] = useState(false)
|
|
83
94
|
const [input, setInput] = useState("")
|
|
84
95
|
const [status, setStatus] = useState("")
|
|
85
|
-
const [usage, setUsage] = useState<{ in: number; out: number }>({
|
|
86
|
-
in: 0,
|
|
87
|
-
out: 0,
|
|
88
|
-
})
|
|
96
|
+
const [usage, setUsage] = useState<{ in: number; out: number }>({ in: 0, out: 0 })
|
|
89
97
|
const [selCmdIdx, setSelCmdIdx] = useState(0)
|
|
90
98
|
const [mode, setMode] = useState<PromptMode>({ type: "chat" })
|
|
91
99
|
const resolveRef = useRef<((v: unknown) => void) | null>(null)
|
|
@@ -96,6 +104,9 @@ function App({
|
|
|
96
104
|
current: string
|
|
97
105
|
latest: string
|
|
98
106
|
} | null>(null)
|
|
107
|
+
const { exit } = useApp()
|
|
108
|
+
const lastExitPress = useRef<{ key: "C"; ts: number } | null>(null)
|
|
109
|
+
const [exitConfirmKey, setExitConfirmKey] = useState<"C" | null>(null)
|
|
99
110
|
|
|
100
111
|
useEffect(() => {
|
|
101
112
|
const check = async () => {
|
|
@@ -153,7 +164,9 @@ function App({
|
|
|
153
164
|
function commitMsg(msg: Msg) {
|
|
154
165
|
setMsgs((prev) => [...prev, msg])
|
|
155
166
|
agent.setMessages([...agent.messages, msg])
|
|
156
|
-
store.append(
|
|
167
|
+
store.append(currSessionId, msg).catch((err) => {
|
|
168
|
+
console.error("Error appending message to session store:", err)
|
|
169
|
+
})
|
|
157
170
|
}
|
|
158
171
|
|
|
159
172
|
// biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
|
|
@@ -162,12 +175,53 @@ function App({
|
|
|
162
175
|
}, [input])
|
|
163
176
|
|
|
164
177
|
useInput((ch, key) => {
|
|
178
|
+
if (key.ctrl && (ch === "c" || ch === "d")) {
|
|
179
|
+
if (busy) {
|
|
180
|
+
if (ch === "c") {
|
|
181
|
+
if (abortCtrl.current) {
|
|
182
|
+
abortCtrl.current.abort()
|
|
183
|
+
abortCtrl.current = null
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Idle state - handle exit
|
|
190
|
+
if (ch === "d") {
|
|
191
|
+
exit()
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Ctrl+C double-press exit logic
|
|
196
|
+
const now = Date.now()
|
|
197
|
+
if (
|
|
198
|
+
lastExitPress.current &&
|
|
199
|
+
lastExitPress.current.key === "C" &&
|
|
200
|
+
now - lastExitPress.current.ts < 2000
|
|
201
|
+
) {
|
|
202
|
+
exit()
|
|
203
|
+
} else {
|
|
204
|
+
lastExitPress.current = { key: "C", ts: now }
|
|
205
|
+
setExitConfirmKey("C")
|
|
206
|
+
// Clear the temporary status after 2 seconds
|
|
207
|
+
setTimeout(() => {
|
|
208
|
+
if (lastExitPress.current?.key === "C" && Date.now() - lastExitPress.current.ts >= 2000) {
|
|
209
|
+
lastExitPress.current = null
|
|
210
|
+
setExitConfirmKey(null)
|
|
211
|
+
}
|
|
212
|
+
}, 2000)
|
|
213
|
+
}
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
165
217
|
if (mode.type !== "chat") return
|
|
166
218
|
|
|
167
219
|
if (key.escape) {
|
|
168
220
|
if (abortCtrl.current) {
|
|
169
221
|
abortCtrl.current.abort()
|
|
170
222
|
abortCtrl.current = null
|
|
223
|
+
} else if (input) {
|
|
224
|
+
setInput("")
|
|
171
225
|
}
|
|
172
226
|
return
|
|
173
227
|
}
|
|
@@ -225,7 +279,7 @@ function App({
|
|
|
225
279
|
hIdx.current = -1
|
|
226
280
|
|
|
227
281
|
if (line.startsWith("/")) {
|
|
228
|
-
dispatch(line, agent, store,
|
|
282
|
+
dispatch(line, agent, store, currSessionId, prompts, exit, handleSwitchSession).then((r) => {
|
|
229
283
|
if (r) {
|
|
230
284
|
commitMsg({
|
|
231
285
|
role: "assistant",
|
|
@@ -241,7 +295,6 @@ function App({
|
|
|
241
295
|
return
|
|
242
296
|
}
|
|
243
297
|
|
|
244
|
-
// Record user message before starting the stream
|
|
245
298
|
const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
|
|
246
299
|
commitMsg(userMsg)
|
|
247
300
|
|
|
@@ -269,10 +322,9 @@ function App({
|
|
|
269
322
|
break
|
|
270
323
|
case "assistant_msg":
|
|
271
324
|
commitMsg(ev.msg)
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}, 0)
|
|
325
|
+
setStream("")
|
|
326
|
+
setThinkStream("")
|
|
327
|
+
|
|
276
328
|
break
|
|
277
329
|
case "tool_call":
|
|
278
330
|
setStatus(chalk.dim(`⏳ ${ev.call.name}…`))
|
|
@@ -287,6 +339,20 @@ function App({
|
|
|
287
339
|
break
|
|
288
340
|
case "turn_end":
|
|
289
341
|
setStatus("")
|
|
342
|
+
store
|
|
343
|
+
.get(currSessionId)
|
|
344
|
+
.then((s) => {
|
|
345
|
+
if (s && !s.title && agent.messages.length >= 2) {
|
|
346
|
+
generateSessionTitle(agent.messages, agent.model, agent.apiKey, agent.baseUrl)
|
|
347
|
+
.then((title) => {
|
|
348
|
+
if (title) {
|
|
349
|
+
store.setTitle(currSessionId, title).catch(() => {})
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
.catch(() => {})
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
.catch(() => {})
|
|
290
356
|
break
|
|
291
357
|
case "usage":
|
|
292
358
|
if (ev.usage) setUsage(ev.usage)
|
|
@@ -336,41 +402,12 @@ function App({
|
|
|
336
402
|
|
|
337
403
|
return (
|
|
338
404
|
<Box flexDirection="column" paddingX={1}>
|
|
339
|
-
{/* Messages - pushed to scrollback as they finish */}
|
|
340
405
|
<Static items={visibleMsgs}>
|
|
341
406
|
{(m, i) => <Message key={`${m.ts}-${i}`} msg={m} isFirst={i === 0} />}
|
|
342
407
|
</Static>
|
|
343
408
|
|
|
344
|
-
{
|
|
345
|
-
{isLiveActive && (
|
|
346
|
-
<Box flexDirection="column" marginTop={visibleMsgs.length > 0 ? 1 : 0}>
|
|
347
|
-
{thinkStream && (
|
|
348
|
-
<Text dimColor italic>
|
|
349
|
-
{thinkStream}
|
|
350
|
-
</Text>
|
|
351
|
-
)}
|
|
352
|
-
{stream && (
|
|
353
|
-
<Box flexDirection="row">
|
|
354
|
-
<Box flexGrow={1} flexShrink={1}>
|
|
355
|
-
<Text>
|
|
356
|
-
{formatMarkdown(stream)}
|
|
357
|
-
{!input && <Cursor />}
|
|
358
|
-
</Text>
|
|
359
|
-
</Box>
|
|
360
|
-
</Box>
|
|
361
|
-
)}
|
|
362
|
-
{busy && !stream && (
|
|
363
|
-
<Box flexDirection="row">
|
|
364
|
-
<Box marginRight={1}>
|
|
365
|
-
<Spinner />
|
|
366
|
-
</Box>
|
|
367
|
-
<Text dimColor>{status ? status.replace("⏳ ", "") : chalk.yellow("working…")}</Text>
|
|
368
|
-
</Box>
|
|
369
|
-
)}
|
|
370
|
-
</Box>
|
|
371
|
-
)}
|
|
409
|
+
<LiveArea stream={stream} thinkStream={thinkStream} busy={busy} status={status} />
|
|
372
410
|
|
|
373
|
-
{/* Input & Footer (Live) */}
|
|
374
411
|
<Box flexDirection="column" marginTop={visibleMsgs.length > 0 || isLiveActive ? 1 : 0}>
|
|
375
412
|
{updateInfo && (
|
|
376
413
|
<Box
|
|
@@ -403,155 +440,15 @@ function App({
|
|
|
403
440
|
</Box>
|
|
404
441
|
</Box>
|
|
405
442
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
color={i === selCmdIdx ? "black" : "yellow"}
|
|
415
|
-
backgroundColor={i === selCmdIdx ? "yellow" : undefined}
|
|
416
|
-
>
|
|
417
|
-
/{s.name.padEnd(10)}
|
|
418
|
-
</Text>
|
|
419
|
-
<Text dimColor> {s.desc}</Text>
|
|
420
|
-
</Box>
|
|
421
|
-
))}
|
|
422
|
-
</Box>
|
|
423
|
-
) : (
|
|
424
|
-
<Text dimColor>Enter to send · /help for commands</Text>
|
|
425
|
-
)}
|
|
426
|
-
</Box>
|
|
427
|
-
|
|
428
|
-
<Box>
|
|
429
|
-
<Text dimColor>{formatTokenUsage(usage.in, agent.model.contextWindow)}</Text>
|
|
430
|
-
<Text dimColor> │ </Text>
|
|
431
|
-
<Text dimColor>{agent.model.id}</Text>
|
|
432
|
-
{busy && <Text dimColor> │ Esc to stop</Text>}
|
|
433
|
-
</Box>
|
|
434
|
-
</Box>
|
|
443
|
+
<StatusBar
|
|
444
|
+
model={agent.model}
|
|
445
|
+
usage={usage}
|
|
446
|
+
busy={busy}
|
|
447
|
+
suggestions={suggestions}
|
|
448
|
+
selCmdIdx={selCmdIdx}
|
|
449
|
+
exitConfirmKey={exitConfirmKey}
|
|
450
|
+
/>
|
|
435
451
|
</Box>
|
|
436
452
|
</Box>
|
|
437
453
|
)
|
|
438
454
|
}
|
|
439
|
-
|
|
440
|
-
function fmtK(n: number): string {
|
|
441
|
-
const k = n / 1000
|
|
442
|
-
return k >= 100 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function formatTokenUsage(used: number, contextWindow: number): string {
|
|
446
|
-
if (used === 0) return `0/${fmtK(contextWindow)}`
|
|
447
|
-
const pct = Math.round((used / contextWindow) * 100)
|
|
448
|
-
return `${fmtK(used)}/${fmtK(contextWindow)} (${pct}%)`
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
const TOOL_STYLE: Record<string, string> = {
|
|
452
|
-
read: "blue",
|
|
453
|
-
write: "magenta",
|
|
454
|
-
edit: "yellow",
|
|
455
|
-
bash: "cyan",
|
|
456
|
-
glob: "green",
|
|
457
|
-
find: "green",
|
|
458
|
-
grep: "green",
|
|
459
|
-
tree: "green",
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
function hasMeaningfulContent(msg: Msg): boolean {
|
|
463
|
-
if (msg.role === "user") return true
|
|
464
|
-
if (msg.role === "tool_result") return true
|
|
465
|
-
if (msg.role === "assistant") {
|
|
466
|
-
if (msg.model === "system") return true
|
|
467
|
-
return msg.content.some((c) => {
|
|
468
|
-
if (c.type === "thinking") return c.text.trim().length > 0
|
|
469
|
-
if (c.type === "text") return c.text.trim().length > 0
|
|
470
|
-
return false
|
|
471
|
-
})
|
|
472
|
-
}
|
|
473
|
-
return false
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
|
|
477
|
-
if (msg.role === "user") {
|
|
478
|
-
return (
|
|
479
|
-
<Box marginTop={isFirst ? 0 : 1} flexDirection="row">
|
|
480
|
-
<Box flexShrink={0} marginRight={1}>
|
|
481
|
-
<Text bold color="green">
|
|
482
|
-
{">"}
|
|
483
|
-
</Text>
|
|
484
|
-
</Box>
|
|
485
|
-
<Box flexGrow={1} flexShrink={1}>
|
|
486
|
-
<Text>
|
|
487
|
-
{typeof msg.content === "string"
|
|
488
|
-
? msg.content
|
|
489
|
-
: msg.content.map((c) => (c.type === "text" ? c.text : "")).join("")}
|
|
490
|
-
</Text>
|
|
491
|
-
</Box>
|
|
492
|
-
</Box>
|
|
493
|
-
)
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
if (msg.role === "assistant") {
|
|
497
|
-
if (msg.model === "system") {
|
|
498
|
-
return (
|
|
499
|
-
<Box flexDirection="column" marginTop={0}>
|
|
500
|
-
{msg.content.map((c, i) =>
|
|
501
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
502
|
-
c.type === "text" ? <Text key={i}>{formatMarkdown(c.text)}</Text> : null,
|
|
503
|
-
)}
|
|
504
|
-
</Box>
|
|
505
|
-
)
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
const hasVisibleContent = msg.content.some((c) => c.type === "text" || c.type === "thinking")
|
|
509
|
-
if (!hasVisibleContent) return null
|
|
510
|
-
|
|
511
|
-
return (
|
|
512
|
-
<Box flexDirection="column" marginTop={0}>
|
|
513
|
-
{msg.content.map((c, i) => {
|
|
514
|
-
if (c.type === "thinking") {
|
|
515
|
-
return (
|
|
516
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
517
|
-
<Text key={i} dimColor italic>
|
|
518
|
-
{c.text}
|
|
519
|
-
</Text>
|
|
520
|
-
)
|
|
521
|
-
}
|
|
522
|
-
if (c.type === "text") {
|
|
523
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
524
|
-
return <Text key={i}>{formatMarkdown(c.text)}</Text>
|
|
525
|
-
}
|
|
526
|
-
return null
|
|
527
|
-
})}
|
|
528
|
-
</Box>
|
|
529
|
-
)
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
if (msg.role === "tool_result") {
|
|
533
|
-
const args = msg.args ? formatToolArgs(msg.args, true) : ""
|
|
534
|
-
|
|
535
|
-
const resText = msg.content
|
|
536
|
-
.map((c) => (c.type === "text" ? c.text : ""))
|
|
537
|
-
.join("")
|
|
538
|
-
.trim()
|
|
539
|
-
|
|
540
|
-
const isRead = msg.tool === "read"
|
|
541
|
-
const lineCount = isRead ? resText.split("\n").length : 0
|
|
542
|
-
const color = TOOL_STYLE[msg.tool] || "white"
|
|
543
|
-
|
|
544
|
-
return (
|
|
545
|
-
<Box flexDirection="row">
|
|
546
|
-
<Text color={msg.isError ? "red" : "green"}>{msg.isError ? "✗" : "✓"} </Text>
|
|
547
|
-
<Text color={color} bold>
|
|
548
|
-
{msg.tool}
|
|
549
|
-
</Text>
|
|
550
|
-
{args && <Text> {args}</Text>}
|
|
551
|
-
{isRead && !msg.isError && <Text dimColor> ({lineCount} lines)</Text>}
|
|
552
|
-
{msg.isError && resText && <Text color="red"> {resText.slice(0, 80)}</Text>}
|
|
553
|
-
</Box>
|
|
554
|
-
)
|
|
555
|
-
}
|
|
556
|
-
return null
|
|
557
|
-
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import chalk from "chalk"
|
|
2
|
+
import { Box, Text } from "ink"
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import { SPINNER_FRAMES } from "../constants.ts"
|
|
5
|
+
import { formatMarkdown } from "../markdown.ts"
|
|
6
|
+
|
|
7
|
+
export function Spinner() {
|
|
8
|
+
const [frame, setFrame] = useState(0)
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const timer = setInterval(() => {
|
|
12
|
+
setFrame((f) => (f + 1) % SPINNER_FRAMES.length)
|
|
13
|
+
}, 80)
|
|
14
|
+
return () => clearInterval(timer)
|
|
15
|
+
}, [])
|
|
16
|
+
|
|
17
|
+
return <Text color="yellow">{SPINNER_FRAMES[frame]}</Text>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Cursor() {
|
|
21
|
+
const [visible, setVisible] = useState(true)
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const timer = setInterval(() => setVisible((v) => !v), 530)
|
|
24
|
+
return () => clearInterval(timer)
|
|
25
|
+
}, [])
|
|
26
|
+
return <Text color="green">{visible ? "│" : " "}</Text>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function LiveArea({
|
|
30
|
+
stream,
|
|
31
|
+
thinkStream,
|
|
32
|
+
busy,
|
|
33
|
+
status,
|
|
34
|
+
}: {
|
|
35
|
+
stream: string
|
|
36
|
+
thinkStream: string
|
|
37
|
+
busy: boolean
|
|
38
|
+
status: string
|
|
39
|
+
}) {
|
|
40
|
+
const isActive = !!(stream || thinkStream || busy)
|
|
41
|
+
if (!isActive) return null
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Box flexDirection="column" marginTop={0}>
|
|
45
|
+
{thinkStream && (
|
|
46
|
+
<Text dimColor italic>
|
|
47
|
+
{thinkStream}
|
|
48
|
+
</Text>
|
|
49
|
+
)}
|
|
50
|
+
{stream && (
|
|
51
|
+
<Box flexDirection="row">
|
|
52
|
+
<Box flexGrow={1} flexShrink={1}>
|
|
53
|
+
<Text>
|
|
54
|
+
{formatMarkdown(stream)}
|
|
55
|
+
<Cursor />
|
|
56
|
+
</Text>
|
|
57
|
+
</Box>
|
|
58
|
+
</Box>
|
|
59
|
+
)}
|
|
60
|
+
{busy && !stream && (
|
|
61
|
+
<Box flexDirection="row">
|
|
62
|
+
<Box marginRight={1}>
|
|
63
|
+
<Spinner />
|
|
64
|
+
</Box>
|
|
65
|
+
<Text dimColor>{status ? status.replace("⏳ ", "") : chalk.yellow("working…")}</Text>
|
|
66
|
+
</Box>
|
|
67
|
+
)}
|
|
68
|
+
</Box>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Box, Text } from "ink"
|
|
2
|
+
import type { Msg } from "../../types.ts"
|
|
3
|
+
import { formatToolArgs } from "../../util.ts"
|
|
4
|
+
import { TERMINATION_PHRASES, TOOL_STYLE } from "../constants.ts"
|
|
5
|
+
import { formatMarkdown } from "../markdown.ts"
|
|
6
|
+
|
|
7
|
+
export function hasMeaningfulContent(msg: Msg): boolean {
|
|
8
|
+
if (msg.role === "user") return true
|
|
9
|
+
if (msg.role === "tool_result") return true
|
|
10
|
+
if (msg.role === "assistant") {
|
|
11
|
+
if (msg.model === "system") return true
|
|
12
|
+
if (msg.stop === "aborted") return true
|
|
13
|
+
return msg.content.some((c) => {
|
|
14
|
+
if (c.type === "thinking") return c.text.trim().length > 0
|
|
15
|
+
if (c.type === "text") return c.text.trim().length > 0
|
|
16
|
+
return false
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
|
|
23
|
+
if (msg.role === "user") {
|
|
24
|
+
return (
|
|
25
|
+
<Box marginTop={isFirst ? 0 : 1} flexDirection="row">
|
|
26
|
+
<Box flexShrink={0} marginRight={1}>
|
|
27
|
+
<Text bold color="green">
|
|
28
|
+
{">"}
|
|
29
|
+
</Text>
|
|
30
|
+
</Box>
|
|
31
|
+
<Box flexGrow={1} flexShrink={1}>
|
|
32
|
+
<Text>
|
|
33
|
+
{typeof msg.content === "string"
|
|
34
|
+
? msg.content
|
|
35
|
+
: msg.content.map((c) => (c.type === "text" ? c.text : "")).join("")}
|
|
36
|
+
</Text>
|
|
37
|
+
</Box>
|
|
38
|
+
</Box>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (msg.role === "assistant") {
|
|
43
|
+
if (msg.model === "system") {
|
|
44
|
+
return (
|
|
45
|
+
<Box flexDirection="column" marginTop={0}>
|
|
46
|
+
{msg.content.map((c, i) =>
|
|
47
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
48
|
+
c.type === "text" ? <Text key={i}>{formatMarkdown(c.text)}</Text> : null,
|
|
49
|
+
)}
|
|
50
|
+
</Box>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isAborted = msg.stop === "aborted"
|
|
55
|
+
const hasVisibleContent =
|
|
56
|
+
isAborted || msg.content.some((c) => c.type === "text" || c.type === "thinking")
|
|
57
|
+
if (!hasVisibleContent) return null
|
|
58
|
+
|
|
59
|
+
const termPhrase = isAborted
|
|
60
|
+
? (TERMINATION_PHRASES[msg.ts % TERMINATION_PHRASES.length] ?? "Terminated by user")
|
|
61
|
+
: ""
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Box flexDirection="column" marginTop={0}>
|
|
65
|
+
{msg.content.map((c, i) => {
|
|
66
|
+
if (c.type === "thinking") {
|
|
67
|
+
return (
|
|
68
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
69
|
+
<Text key={i} dimColor italic>
|
|
70
|
+
{c.text}
|
|
71
|
+
</Text>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
if (c.type === "text") {
|
|
75
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
76
|
+
return <Text key={i}>{formatMarkdown(c.text)}</Text>
|
|
77
|
+
}
|
|
78
|
+
return null
|
|
79
|
+
})}
|
|
80
|
+
{isAborted && (
|
|
81
|
+
<Box marginTop={0}>
|
|
82
|
+
<Text color="red" italic>
|
|
83
|
+
▲ {termPhrase}
|
|
84
|
+
</Text>
|
|
85
|
+
</Box>
|
|
86
|
+
)}
|
|
87
|
+
</Box>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (msg.role === "tool_result") {
|
|
92
|
+
const args = msg.args ? formatToolArgs(msg.args, true) : ""
|
|
93
|
+
|
|
94
|
+
const resText = msg.content
|
|
95
|
+
.map((c) => (c.type === "text" ? c.text : ""))
|
|
96
|
+
.join("")
|
|
97
|
+
.trim()
|
|
98
|
+
|
|
99
|
+
const isRead = msg.tool === "read"
|
|
100
|
+
const lineCount = isRead ? resText.split("\n").length : 0
|
|
101
|
+
const color = TOOL_STYLE[msg.tool] || "white"
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Box flexDirection="row">
|
|
105
|
+
<Text color={msg.isError ? "red" : "green"}>{msg.isError ? "✗" : "✓"} </Text>
|
|
106
|
+
<Text color={color} bold>
|
|
107
|
+
{msg.tool}
|
|
108
|
+
</Text>
|
|
109
|
+
{args && <Text> {args}</Text>}
|
|
110
|
+
{isRead && !msg.isError && <Text dimColor> ({lineCount} lines)</Text>}
|
|
111
|
+
{msg.isError && resText && <Text color="red"> {resText.slice(0, 80)}</Text>}
|
|
112
|
+
</Box>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Box, Text } from "ink"
|
|
2
|
+
import type { Model } from "../../types.ts"
|
|
3
|
+
|
|
4
|
+
function fmtK(n: number): string {
|
|
5
|
+
const k = n / 1000
|
|
6
|
+
return k >= 100 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatTokenUsage(used: number, contextWindow: number): string {
|
|
10
|
+
if (used === 0) return `0/${fmtK(contextWindow)}`
|
|
11
|
+
const pct = Math.round((used / contextWindow) * 100)
|
|
12
|
+
return `${fmtK(used)}/${fmtK(contextWindow)} (${pct}%)`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function StatusBar({
|
|
16
|
+
model,
|
|
17
|
+
usage,
|
|
18
|
+
busy,
|
|
19
|
+
suggestions,
|
|
20
|
+
selCmdIdx,
|
|
21
|
+
exitConfirmKey,
|
|
22
|
+
}: {
|
|
23
|
+
model: Model
|
|
24
|
+
usage: { in: number; out: number }
|
|
25
|
+
busy: boolean
|
|
26
|
+
suggestions: Array<{ name: string; desc: string }>
|
|
27
|
+
selCmdIdx: number
|
|
28
|
+
exitConfirmKey: "C" | null
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<Box justifyContent="space-between">
|
|
32
|
+
<Box>
|
|
33
|
+
{suggestions.length > 0 ? (
|
|
34
|
+
<Box flexDirection="column" marginLeft={2}>
|
|
35
|
+
{suggestions.map((s, i) => (
|
|
36
|
+
<Box key={s.name}>
|
|
37
|
+
<Text
|
|
38
|
+
color={i === selCmdIdx ? "black" : "yellow"}
|
|
39
|
+
backgroundColor={i === selCmdIdx ? "yellow" : undefined}
|
|
40
|
+
>
|
|
41
|
+
/{s.name.padEnd(10)}
|
|
42
|
+
</Text>
|
|
43
|
+
<Text dimColor> {s.desc}</Text>
|
|
44
|
+
</Box>
|
|
45
|
+
))}
|
|
46
|
+
</Box>
|
|
47
|
+
) : exitConfirmKey === "C" ? (
|
|
48
|
+
<Text color="yellow">Press Ctrl+C again to exit</Text>
|
|
49
|
+
) : busy ? (
|
|
50
|
+
<Text dimColor>Press Esc to abort or terminate</Text>
|
|
51
|
+
) : (
|
|
52
|
+
<Text dimColor>Enter to send · /help for commands</Text>
|
|
53
|
+
)}
|
|
54
|
+
</Box>
|
|
55
|
+
|
|
56
|
+
<Box>
|
|
57
|
+
<Text dimColor>{formatTokenUsage(usage.in, model.contextWindow)}</Text>
|
|
58
|
+
<Text dimColor> │ </Text>
|
|
59
|
+
<Text dimColor>{model.id}</Text>
|
|
60
|
+
{busy && <Text dimColor> │ Esc to stop</Text>}
|
|
61
|
+
</Box>
|
|
62
|
+
</Box>
|
|
63
|
+
)
|
|
64
|
+
}
|