novacode 0.6.0 → 0.7.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/dist/app-CbJSUNmf.mjs +22 -0
- package/dist/app-CbJSUNmf.mjs.map +1 -0
- package/dist/main.mjs +42 -29
- package/dist/main.mjs.map +1 -1
- package/package.json +1 -2
- package/dist/app-bQ9a_p_K.mjs +0 -22
- package/dist/app-bQ9a_p_K.mjs.map +0 -1
- package/src/agent/agent.ts +0 -87
- package/src/agent/loop.ts +0 -237
- package/src/agent/prompt.ts +0 -50
- package/src/commands/compact.ts +0 -28
- package/src/commands/index.ts +0 -128
- package/src/commands/models.ts +0 -85
- package/src/commands/providers.ts +0 -213
- package/src/commands/session.ts +0 -52
- package/src/config/providers.ts +0 -207
- package/src/config/store.ts +0 -66
- package/src/main.ts +0 -205
- package/src/onboarding/wizard.ts +0 -54
- package/src/provider/gemini.ts +0 -269
- package/src/provider/openai.ts +0 -239
- package/src/provider/stream.ts +0 -138
- package/src/session/compact.ts +0 -159
- package/src/session/store.ts +0 -209
- package/src/tools/fs.ts +0 -189
- package/src/tools/git.ts +0 -99
- package/src/tools/index.ts +0 -33
- package/src/tools/search.ts +0 -274
- package/src/tools/shell.ts +0 -90
- package/src/tools/web.ts +0 -239
- package/src/tui/app.tsx +0 -454
- package/src/tui/components/liveArea.tsx +0 -70
- package/src/tui/components/message.tsx +0 -117
- package/src/tui/components/statusBar.tsx +0 -64
- package/src/tui/constants.ts +0 -25
- package/src/tui/markdown.ts +0 -62
- package/src/tui/prompts.tsx +0 -205
- package/src/types.ts +0 -262
- package/src/update.ts +0 -89
- package/src/util.ts +0 -80
package/src/tui/app.tsx
DELETED
|
@@ -1,454 +0,0 @@
|
|
|
1
|
-
import chalk from "chalk"
|
|
2
|
-
import { Box, render, Static, Text, useApp, useInput } from "ink"
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from "react"
|
|
4
|
-
import type { Agent } from "../agent/agent.ts"
|
|
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"
|
|
9
|
-
import type { SessionStore } from "../session/store.ts"
|
|
10
|
-
import type { Msg, Prompts } from "../types.ts"
|
|
11
|
-
import { checkForUpdate, getCurrentVersion } from "../update.ts"
|
|
12
|
-
import { Cursor, LiveArea } from "./components/liveArea.tsx"
|
|
13
|
-
import { hasMeaningfulContent, Message } from "./components/message.tsx"
|
|
14
|
-
import { StatusBar } from "./components/statusBar.tsx"
|
|
15
|
-
import { ConfirmPrompt, PasswordPrompt, SelectPrompt } from "./prompts.tsx"
|
|
16
|
-
|
|
17
|
-
type PromptMode =
|
|
18
|
-
| { type: "chat" }
|
|
19
|
-
| {
|
|
20
|
-
type: "select"
|
|
21
|
-
message: string
|
|
22
|
-
options: Array<{ value: string; label: string; hint?: string }>
|
|
23
|
-
header?: string
|
|
24
|
-
}
|
|
25
|
-
| {
|
|
26
|
-
type: "password"
|
|
27
|
-
message: string
|
|
28
|
-
validate?: (v: string) => string | undefined
|
|
29
|
-
}
|
|
30
|
-
| { type: "confirm"; message: string }
|
|
31
|
-
|
|
32
|
-
export async function interactive(
|
|
33
|
-
agent: Agent,
|
|
34
|
-
store: SessionStore,
|
|
35
|
-
sessionId: string,
|
|
36
|
-
): Promise<void> {
|
|
37
|
-
process.stdout.write("\x1B[?25l")
|
|
38
|
-
const version = await getCurrentVersion()
|
|
39
|
-
process.stdout.write(`${chalk.cyan.bold("⚡ novacode")} ${chalk.gray(`v${version}`)}\n`)
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />, {
|
|
43
|
-
exitOnCtrlC: false,
|
|
44
|
-
})
|
|
45
|
-
await waitUntilExit()
|
|
46
|
-
} finally {
|
|
47
|
-
process.stdout.write("\x1B[?25h")
|
|
48
|
-
await store.prune()
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function App({
|
|
53
|
-
agent,
|
|
54
|
-
store,
|
|
55
|
-
sessionId: initialSessionId,
|
|
56
|
-
}: {
|
|
57
|
-
agent: Agent
|
|
58
|
-
store: SessionStore
|
|
59
|
-
sessionId: string
|
|
60
|
-
}) {
|
|
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
|
-
)
|
|
91
|
-
const [stream, setStream] = useState("")
|
|
92
|
-
const [thinkStream, setThinkStream] = useState("")
|
|
93
|
-
const [busy, setBusy] = useState(false)
|
|
94
|
-
const [input, setInput] = useState("")
|
|
95
|
-
const [status, setStatus] = useState("")
|
|
96
|
-
const [usage, setUsage] = useState<{ in: number; out: number }>({ in: 0, out: 0 })
|
|
97
|
-
const [selCmdIdx, setSelCmdIdx] = useState(0)
|
|
98
|
-
const [mode, setMode] = useState<PromptMode>({ type: "chat" })
|
|
99
|
-
const resolveRef = useRef<((v: unknown) => void) | null>(null)
|
|
100
|
-
const history = useRef<string[]>([])
|
|
101
|
-
const hIdx = useRef(-1)
|
|
102
|
-
const abortCtrl = useRef<AbortController | null>(null)
|
|
103
|
-
const [updateInfo, setUpdateInfo] = useState<{
|
|
104
|
-
current: string
|
|
105
|
-
latest: string
|
|
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)
|
|
110
|
-
|
|
111
|
-
useEffect(() => {
|
|
112
|
-
const check = async () => {
|
|
113
|
-
const info = await checkForUpdate()
|
|
114
|
-
if (info?.hasUpdate) {
|
|
115
|
-
setUpdateInfo({ current: info.current, latest: info.latest })
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
check()
|
|
119
|
-
}, [])
|
|
120
|
-
|
|
121
|
-
const isTypingCmd = input.startsWith("/") && !input.includes(" ")
|
|
122
|
-
const suggestions = isTypingCmd
|
|
123
|
-
? COMMANDS.filter(
|
|
124
|
-
(c) =>
|
|
125
|
-
c.name.startsWith(input.slice(1).toLowerCase()) ||
|
|
126
|
-
c.aliases?.some((a) => a.startsWith(input.slice(1).toLowerCase())),
|
|
127
|
-
)
|
|
128
|
-
: []
|
|
129
|
-
|
|
130
|
-
const prompts: Prompts = {
|
|
131
|
-
select: useCallback(
|
|
132
|
-
(config) =>
|
|
133
|
-
new Promise((resolve) => {
|
|
134
|
-
resolveRef.current = resolve as (v: unknown) => void
|
|
135
|
-
setMode({ type: "select", ...config })
|
|
136
|
-
}),
|
|
137
|
-
[],
|
|
138
|
-
),
|
|
139
|
-
password: useCallback(
|
|
140
|
-
(config) =>
|
|
141
|
-
new Promise((resolve) => {
|
|
142
|
-
resolveRef.current = resolve as (v: unknown) => void
|
|
143
|
-
setMode({ type: "password", ...config })
|
|
144
|
-
}),
|
|
145
|
-
[],
|
|
146
|
-
),
|
|
147
|
-
confirm: useCallback(
|
|
148
|
-
(config) =>
|
|
149
|
-
new Promise((resolve) => {
|
|
150
|
-
resolveRef.current = resolve as (v: unknown) => void
|
|
151
|
-
setMode({ type: "confirm", ...config })
|
|
152
|
-
}),
|
|
153
|
-
[],
|
|
154
|
-
),
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function resolvePrompt(value: unknown) {
|
|
158
|
-
const fn = resolveRef.current
|
|
159
|
-
resolveRef.current = null
|
|
160
|
-
setMode({ type: "chat" })
|
|
161
|
-
fn?.(value)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function commitMsg(msg: Msg) {
|
|
165
|
-
setMsgs((prev) => [...prev, msg])
|
|
166
|
-
agent.setMessages([...agent.messages, msg])
|
|
167
|
-
store.append(currSessionId, msg).catch((err) => {
|
|
168
|
-
console.error("Error appending message to session store:", err)
|
|
169
|
-
})
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
|
|
173
|
-
useEffect(() => {
|
|
174
|
-
setSelCmdIdx(0)
|
|
175
|
-
}, [input])
|
|
176
|
-
|
|
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
|
-
|
|
217
|
-
if (mode.type !== "chat") return
|
|
218
|
-
|
|
219
|
-
if (key.escape) {
|
|
220
|
-
if (abortCtrl.current) {
|
|
221
|
-
abortCtrl.current.abort()
|
|
222
|
-
abortCtrl.current = null
|
|
223
|
-
} else if (input) {
|
|
224
|
-
setInput("")
|
|
225
|
-
}
|
|
226
|
-
return
|
|
227
|
-
}
|
|
228
|
-
if (key.upArrow) {
|
|
229
|
-
if (isTypingCmd && suggestions.length > 0) {
|
|
230
|
-
setSelCmdIdx((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1))
|
|
231
|
-
return
|
|
232
|
-
}
|
|
233
|
-
if (history.current.length > 0) {
|
|
234
|
-
hIdx.current = Math.min(hIdx.current + 1, history.current.length - 1)
|
|
235
|
-
setInput(history.current[hIdx.current] ?? "")
|
|
236
|
-
}
|
|
237
|
-
return
|
|
238
|
-
}
|
|
239
|
-
if (key.downArrow) {
|
|
240
|
-
if (isTypingCmd && suggestions.length > 0) {
|
|
241
|
-
setSelCmdIdx((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0))
|
|
242
|
-
return
|
|
243
|
-
}
|
|
244
|
-
hIdx.current = Math.max(hIdx.current - 1, -1)
|
|
245
|
-
setInput(hIdx.current >= 0 ? (history.current[hIdx.current] ?? "") : "")
|
|
246
|
-
return
|
|
247
|
-
}
|
|
248
|
-
if (key.tab) {
|
|
249
|
-
if (isTypingCmd && suggestions.length > 0) {
|
|
250
|
-
const match = suggestions[selCmdIdx]
|
|
251
|
-
if (match) {
|
|
252
|
-
setInput(`/${match.name} `)
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
return
|
|
256
|
-
}
|
|
257
|
-
if (!key.return) {
|
|
258
|
-
setInput((prev) => {
|
|
259
|
-
if (key.backspace || key.delete) return prev.slice(0, -1)
|
|
260
|
-
return prev + (ch || "")
|
|
261
|
-
})
|
|
262
|
-
return
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (busy) return
|
|
266
|
-
|
|
267
|
-
let line = input.trim()
|
|
268
|
-
if (!line) return
|
|
269
|
-
|
|
270
|
-
if (isTypingCmd && suggestions.length > 0) {
|
|
271
|
-
const match = suggestions[selCmdIdx]
|
|
272
|
-
if (match) {
|
|
273
|
-
line = `/${match.name}`
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
setInput("")
|
|
278
|
-
history.current.unshift(line)
|
|
279
|
-
hIdx.current = -1
|
|
280
|
-
|
|
281
|
-
if (line.startsWith("/")) {
|
|
282
|
-
dispatch(line, agent, store, currSessionId, prompts, exit, handleSwitchSession).then((r) => {
|
|
283
|
-
if (r) {
|
|
284
|
-
commitMsg({
|
|
285
|
-
role: "assistant",
|
|
286
|
-
content: [{ type: "text", text: r }],
|
|
287
|
-
model: "system",
|
|
288
|
-
provider: "system",
|
|
289
|
-
usage: { in: 0, out: 0 },
|
|
290
|
-
stop: "stop",
|
|
291
|
-
ts: Date.now(),
|
|
292
|
-
})
|
|
293
|
-
}
|
|
294
|
-
})
|
|
295
|
-
return
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
|
|
299
|
-
commitMsg(userMsg)
|
|
300
|
-
|
|
301
|
-
abortCtrl.current = new AbortController()
|
|
302
|
-
const eventStream = agent.prompt(line, abortCtrl.current.signal)
|
|
303
|
-
|
|
304
|
-
runEventLoop(eventStream)
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
async function runEventLoop(eventStream: ReturnType<Agent["prompt"]>) {
|
|
308
|
-
try {
|
|
309
|
-
for await (const ev of eventStream) {
|
|
310
|
-
switch (ev.type) {
|
|
311
|
-
case "start":
|
|
312
|
-
setBusy(true)
|
|
313
|
-
setStream("")
|
|
314
|
-
setThinkStream("")
|
|
315
|
-
setStatus("")
|
|
316
|
-
break
|
|
317
|
-
case "text_delta":
|
|
318
|
-
if (ev.text) setStream((prev) => prev + ev.text)
|
|
319
|
-
break
|
|
320
|
-
case "thinking_delta":
|
|
321
|
-
if (ev.text) setThinkStream((prev) => prev + ev.text)
|
|
322
|
-
break
|
|
323
|
-
case "assistant_msg":
|
|
324
|
-
commitMsg(ev.msg)
|
|
325
|
-
setStream("")
|
|
326
|
-
setThinkStream("")
|
|
327
|
-
|
|
328
|
-
break
|
|
329
|
-
case "tool_call":
|
|
330
|
-
setStatus(chalk.dim(`⏳ ${ev.call.name}…`))
|
|
331
|
-
break
|
|
332
|
-
case "tool_result":
|
|
333
|
-
commitMsg(ev.result)
|
|
334
|
-
setStatus(
|
|
335
|
-
ev.result.isError
|
|
336
|
-
? chalk.red(`✗ ${ev.result.tool}`)
|
|
337
|
-
: chalk.green(`✓ ${ev.result.tool}`),
|
|
338
|
-
)
|
|
339
|
-
break
|
|
340
|
-
case "turn_end":
|
|
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(() => {})
|
|
356
|
-
break
|
|
357
|
-
case "usage":
|
|
358
|
-
if (ev.usage) setUsage(ev.usage)
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
} catch (err) {
|
|
362
|
-
const errMsg: Msg = {
|
|
363
|
-
role: "assistant",
|
|
364
|
-
model: "system",
|
|
365
|
-
provider: "system",
|
|
366
|
-
content: [{ type: "text", text: chalk.red(`Error: ${(err as Error).message}`) }],
|
|
367
|
-
usage: { in: 0, out: 0 },
|
|
368
|
-
stop: "error",
|
|
369
|
-
ts: Date.now(),
|
|
370
|
-
}
|
|
371
|
-
commitMsg(errMsg)
|
|
372
|
-
} finally {
|
|
373
|
-
abortCtrl.current = null
|
|
374
|
-
setBusy(false)
|
|
375
|
-
setStream("")
|
|
376
|
-
setThinkStream("")
|
|
377
|
-
setStatus("")
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
if (mode.type === "select") {
|
|
382
|
-
return (
|
|
383
|
-
<SelectPrompt
|
|
384
|
-
message={mode.message}
|
|
385
|
-
options={mode.options}
|
|
386
|
-
header={mode.header}
|
|
387
|
-
onSelect={resolvePrompt}
|
|
388
|
-
/>
|
|
389
|
-
)
|
|
390
|
-
}
|
|
391
|
-
if (mode.type === "password") {
|
|
392
|
-
return (
|
|
393
|
-
<PasswordPrompt message={mode.message} validate={mode.validate} onSubmit={resolvePrompt} />
|
|
394
|
-
)
|
|
395
|
-
}
|
|
396
|
-
if (mode.type === "confirm") {
|
|
397
|
-
return <ConfirmPrompt message={mode.message} onConfirm={resolvePrompt} />
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const visibleMsgs = msgs.filter(hasMeaningfulContent)
|
|
401
|
-
const isLiveActive = !!(stream || thinkStream || busy)
|
|
402
|
-
|
|
403
|
-
return (
|
|
404
|
-
<Box flexDirection="column" paddingX={1}>
|
|
405
|
-
<Static items={visibleMsgs}>
|
|
406
|
-
{(m, i) => <Message key={`${m.ts}-${i}`} msg={m} isFirst={i === 0} />}
|
|
407
|
-
</Static>
|
|
408
|
-
|
|
409
|
-
<LiveArea stream={stream} thinkStream={thinkStream} busy={busy} status={status} />
|
|
410
|
-
|
|
411
|
-
<Box flexDirection="column" marginTop={visibleMsgs.length > 0 || isLiveActive ? 1 : 0}>
|
|
412
|
-
{updateInfo && (
|
|
413
|
-
<Box
|
|
414
|
-
borderStyle="round"
|
|
415
|
-
borderColor="yellow"
|
|
416
|
-
paddingX={1}
|
|
417
|
-
marginBottom={1}
|
|
418
|
-
flexDirection="column"
|
|
419
|
-
>
|
|
420
|
-
<Text color="yellow" bold>
|
|
421
|
-
⬆ Update Available (v{updateInfo.current} → v{updateInfo.latest})
|
|
422
|
-
</Text>
|
|
423
|
-
<Text dimColor>
|
|
424
|
-
Run <Text color="cyan">/update</Text> or <Text color="cyan">nova update</Text> to
|
|
425
|
-
upgrade.
|
|
426
|
-
</Text>
|
|
427
|
-
</Box>
|
|
428
|
-
)}
|
|
429
|
-
<Box flexDirection="row">
|
|
430
|
-
<Box flexShrink={0} marginRight={1}>
|
|
431
|
-
<Text bold color="green">
|
|
432
|
-
{">"}
|
|
433
|
-
</Text>
|
|
434
|
-
</Box>
|
|
435
|
-
<Box flexGrow={1} flexShrink={1}>
|
|
436
|
-
<Text>
|
|
437
|
-
{input}
|
|
438
|
-
<Cursor />
|
|
439
|
-
</Text>
|
|
440
|
-
</Box>
|
|
441
|
-
</Box>
|
|
442
|
-
|
|
443
|
-
<StatusBar
|
|
444
|
-
model={agent.model}
|
|
445
|
-
usage={usage}
|
|
446
|
-
busy={busy}
|
|
447
|
-
suggestions={suggestions}
|
|
448
|
-
selCmdIdx={selCmdIdx}
|
|
449
|
-
exitConfirmKey={exitConfirmKey}
|
|
450
|
-
/>
|
|
451
|
-
</Box>
|
|
452
|
-
</Box>
|
|
453
|
-
)
|
|
454
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
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
|
-
}
|