novacode 0.5.1 → 0.5.3
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 +28 -11
- package/dist/app-BZ42XPxw.mjs +21 -0
- package/dist/app-BZ42XPxw.mjs.map +1 -0
- package/dist/main.mjs +110 -0
- package/dist/main.mjs.map +1 -0
- package/package.json +29 -20
- 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 +11 -11
- 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 +63 -0
- package/src/session/compact.ts +1 -1
- package/src/session/store.ts +35 -35
- 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 +135 -124
- package/src/tui/prompts.tsx +205 -0
- package/src/types.ts +15 -0
- package/src/update.ts +48 -24
- package/src/util.ts +1 -28
- package/src/provider/registry.ts +0 -62
package/src/tui/app.tsx
CHANGED
|
@@ -1,27 +1,43 @@
|
|
|
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"
|
|
8
|
-
import { checkForUpdate } from "../update.ts"
|
|
9
|
-
import { formatToolArgs
|
|
7
|
+
import type { Msg, Prompts } from "../types.ts"
|
|
8
|
+
import { checkForUpdate, getCurrentVersion } from "../update.ts"
|
|
9
|
+
import { formatToolArgs } from "../util.ts"
|
|
10
10
|
import { formatMarkdown } from "./markdown.ts"
|
|
11
|
+
import { ConfirmPrompt, PasswordPrompt, SelectPrompt } from "./prompts.tsx"
|
|
12
|
+
|
|
13
|
+
type PromptMode =
|
|
14
|
+
| { type: "chat" }
|
|
15
|
+
| {
|
|
16
|
+
type: "select"
|
|
17
|
+
message: string
|
|
18
|
+
options: Array<{ value: string; label: string; hint?: string }>
|
|
19
|
+
header?: string
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
type: "password"
|
|
23
|
+
message: string
|
|
24
|
+
validate?: (v: string) => string | undefined
|
|
25
|
+
}
|
|
26
|
+
| { type: "confirm"; message: string }
|
|
27
|
+
|
|
11
28
|
export async function interactive(
|
|
12
29
|
agent: Agent,
|
|
13
30
|
store: SessionStore,
|
|
14
31
|
sessionId: string,
|
|
15
32
|
): Promise<void> {
|
|
16
|
-
// Hide system cursor during session
|
|
17
33
|
process.stdout.write("\x1B[?25l")
|
|
18
|
-
|
|
34
|
+
const version = await getCurrentVersion()
|
|
35
|
+
process.stdout.write(`${chalk.cyan.bold("⚡ novacode")} ${chalk.gray(`v${version}`)}\n`)
|
|
19
36
|
|
|
20
37
|
try {
|
|
21
38
|
const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />)
|
|
22
39
|
await waitUntilExit()
|
|
23
40
|
} finally {
|
|
24
|
-
// Restore system cursor on exit
|
|
25
41
|
process.stdout.write("\x1B[?25h")
|
|
26
42
|
}
|
|
27
43
|
}
|
|
@@ -66,13 +82,20 @@ function App({
|
|
|
66
82
|
const [busy, setBusy] = useState(false)
|
|
67
83
|
const [input, setInput] = useState("")
|
|
68
84
|
const [status, setStatus] = useState("")
|
|
69
|
-
const [usage, setUsage] = useState<{ in: number; out: number }>({
|
|
85
|
+
const [usage, setUsage] = useState<{ in: number; out: number }>({
|
|
86
|
+
in: 0,
|
|
87
|
+
out: 0,
|
|
88
|
+
})
|
|
70
89
|
const [selCmdIdx, setSelCmdIdx] = useState(0)
|
|
71
|
-
const [
|
|
90
|
+
const [mode, setMode] = useState<PromptMode>({ type: "chat" })
|
|
91
|
+
const resolveRef = useRef<((v: unknown) => void) | null>(null)
|
|
72
92
|
const history = useRef<string[]>([])
|
|
73
93
|
const hIdx = useRef(-1)
|
|
74
94
|
const abortCtrl = useRef<AbortController | null>(null)
|
|
75
|
-
const [updateInfo, setUpdateInfo] = useState<{
|
|
95
|
+
const [updateInfo, setUpdateInfo] = useState<{
|
|
96
|
+
current: string
|
|
97
|
+
latest: string
|
|
98
|
+
} | null>(null)
|
|
76
99
|
|
|
77
100
|
useEffect(() => {
|
|
78
101
|
const check = async () => {
|
|
@@ -93,19 +116,53 @@ function App({
|
|
|
93
116
|
)
|
|
94
117
|
: []
|
|
95
118
|
|
|
119
|
+
const prompts: Prompts = {
|
|
120
|
+
select: useCallback(
|
|
121
|
+
(config) =>
|
|
122
|
+
new Promise((resolve) => {
|
|
123
|
+
resolveRef.current = resolve as (v: unknown) => void
|
|
124
|
+
setMode({ type: "select", ...config })
|
|
125
|
+
}),
|
|
126
|
+
[],
|
|
127
|
+
),
|
|
128
|
+
password: useCallback(
|
|
129
|
+
(config) =>
|
|
130
|
+
new Promise((resolve) => {
|
|
131
|
+
resolveRef.current = resolve as (v: unknown) => void
|
|
132
|
+
setMode({ type: "password", ...config })
|
|
133
|
+
}),
|
|
134
|
+
[],
|
|
135
|
+
),
|
|
136
|
+
confirm: useCallback(
|
|
137
|
+
(config) =>
|
|
138
|
+
new Promise((resolve) => {
|
|
139
|
+
resolveRef.current = resolve as (v: unknown) => void
|
|
140
|
+
setMode({ type: "confirm", ...config })
|
|
141
|
+
}),
|
|
142
|
+
[],
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolvePrompt(value: unknown) {
|
|
147
|
+
const fn = resolveRef.current
|
|
148
|
+
resolveRef.current = null
|
|
149
|
+
setMode({ type: "chat" })
|
|
150
|
+
fn?.(value)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function commitMsg(msg: Msg) {
|
|
154
|
+
setMsgs((prev) => [...prev, msg])
|
|
155
|
+
agent.setMessages([...agent.messages, msg])
|
|
156
|
+
store.append(sessionId, msg)
|
|
157
|
+
}
|
|
158
|
+
|
|
96
159
|
// biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
|
|
97
160
|
useEffect(() => {
|
|
98
161
|
setSelCmdIdx(0)
|
|
99
162
|
}, [input])
|
|
100
163
|
|
|
101
|
-
useEffect(() => {
|
|
102
|
-
if (!cmdRunning) {
|
|
103
|
-
process.stdout.write("\x1B[?25l")
|
|
104
|
-
}
|
|
105
|
-
}, [cmdRunning])
|
|
106
|
-
|
|
107
164
|
useInput((ch, key) => {
|
|
108
|
-
if (
|
|
165
|
+
if (mode.type !== "chat") return
|
|
109
166
|
|
|
110
167
|
if (key.escape) {
|
|
111
168
|
if (abortCtrl.current) {
|
|
@@ -146,7 +203,7 @@ function App({
|
|
|
146
203
|
if (!key.return) {
|
|
147
204
|
setInput((prev) => {
|
|
148
205
|
if (key.backspace || key.delete) return prev.slice(0, -1)
|
|
149
|
-
return prev + ch
|
|
206
|
+
return prev + (ch || "")
|
|
150
207
|
})
|
|
151
208
|
return
|
|
152
209
|
}
|
|
@@ -168,71 +225,35 @@ function App({
|
|
|
168
225
|
hIdx.current = -1
|
|
169
226
|
|
|
170
227
|
if (line.startsWith("/")) {
|
|
171
|
-
|
|
172
|
-
const isInteractive =
|
|
173
|
-
["providers", "prov", "config", "cfg", "models", "model"].includes(cmdName) &&
|
|
174
|
-
!line.includes(" ")
|
|
175
|
-
|
|
176
|
-
if (isInteractive) {
|
|
177
|
-
setCmdRunning(true)
|
|
178
|
-
// Small delay to let Ink clear
|
|
179
|
-
setTimeout(() => {
|
|
180
|
-
dispatch(line, agent, store, sessionId).then((r) => {
|
|
181
|
-
process.stdin.setRawMode?.(true)
|
|
182
|
-
setCmdRunning(false)
|
|
183
|
-
if (r) {
|
|
184
|
-
setMsgs((prev) => {
|
|
185
|
-
const updated: Msg[] = [
|
|
186
|
-
...prev,
|
|
187
|
-
{
|
|
188
|
-
role: "assistant",
|
|
189
|
-
content: [{ type: "text", text: r }],
|
|
190
|
-
model: "system",
|
|
191
|
-
provider: "system",
|
|
192
|
-
usage: { in: 0, out: 0 },
|
|
193
|
-
stop: "stop",
|
|
194
|
-
ts: Date.now(),
|
|
195
|
-
},
|
|
196
|
-
]
|
|
197
|
-
agent.setMessages(updated)
|
|
198
|
-
return updated
|
|
199
|
-
})
|
|
200
|
-
}
|
|
201
|
-
})
|
|
202
|
-
}, 50)
|
|
203
|
-
return
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Slash commands
|
|
207
|
-
dispatch(line, agent, store, sessionId).then((r) => {
|
|
228
|
+
dispatch(line, agent, store, sessionId, prompts).then((r) => {
|
|
208
229
|
if (r) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
usage: { in: 0, out: 0 },
|
|
218
|
-
stop: "stop",
|
|
219
|
-
ts: Date.now(),
|
|
220
|
-
},
|
|
221
|
-
]
|
|
222
|
-
|
|
223
|
-
agent.setMessages(updated)
|
|
224
|
-
return updated
|
|
230
|
+
commitMsg({
|
|
231
|
+
role: "assistant",
|
|
232
|
+
content: [{ type: "text", text: r }],
|
|
233
|
+
model: "system",
|
|
234
|
+
provider: "system",
|
|
235
|
+
usage: { in: 0, out: 0 },
|
|
236
|
+
stop: "stop",
|
|
237
|
+
ts: Date.now(),
|
|
225
238
|
})
|
|
226
239
|
}
|
|
227
240
|
})
|
|
228
241
|
return
|
|
229
242
|
}
|
|
230
243
|
|
|
244
|
+
// Record user message before starting the stream
|
|
245
|
+
const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
|
|
246
|
+
commitMsg(userMsg)
|
|
247
|
+
|
|
231
248
|
abortCtrl.current = new AbortController()
|
|
232
|
-
const
|
|
249
|
+
const eventStream = agent.prompt(line, abortCtrl.current.signal)
|
|
233
250
|
|
|
234
|
-
|
|
235
|
-
|
|
251
|
+
runEventLoop(eventStream)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
async function runEventLoop(eventStream: ReturnType<Agent["prompt"]>) {
|
|
255
|
+
try {
|
|
256
|
+
for await (const ev of eventStream) {
|
|
236
257
|
switch (ev.type) {
|
|
237
258
|
case "start":
|
|
238
259
|
setBusy(true)
|
|
@@ -247,40 +268,22 @@ function App({
|
|
|
247
268
|
if (ev.text) setThinkStream((prev) => prev + ev.text)
|
|
248
269
|
break
|
|
249
270
|
case "assistant_msg":
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return updated
|
|
256
|
-
})
|
|
257
|
-
store.append(sessionId, ev.msg)
|
|
271
|
+
commitMsg(ev.msg)
|
|
272
|
+
setTimeout(() => {
|
|
273
|
+
setStream("")
|
|
274
|
+
setThinkStream("")
|
|
275
|
+
}, 0)
|
|
258
276
|
break
|
|
259
277
|
case "tool_call":
|
|
260
278
|
setStatus(chalk.dim(`⏳ ${ev.call.name}…`))
|
|
261
279
|
break
|
|
262
280
|
case "tool_result":
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
{
|
|
270
|
-
const args = ev.args
|
|
271
|
-
? `(${Object.values(ev.args)
|
|
272
|
-
.map((v) => {
|
|
273
|
-
const val = typeof v === "string" ? makeRelative(v) : JSON.stringify(v)
|
|
274
|
-
return val.length > 20 ? `${val.slice(0, 20)}…` : val
|
|
275
|
-
})
|
|
276
|
-
.join(", ")})`
|
|
277
|
-
: ""
|
|
278
|
-
setStatus(
|
|
279
|
-
ev.result.isError
|
|
280
|
-
? chalk.red(`✗ ${ev.result.tool}${args}`)
|
|
281
|
-
: chalk.green(`✓ ${ev.result.tool}${args}`),
|
|
282
|
-
)
|
|
283
|
-
}
|
|
281
|
+
commitMsg(ev.result)
|
|
282
|
+
setStatus(
|
|
283
|
+
ev.result.isError
|
|
284
|
+
? chalk.red(`✗ ${ev.result.tool}`)
|
|
285
|
+
: chalk.green(`✓ ${ev.result.tool}`),
|
|
286
|
+
)
|
|
284
287
|
break
|
|
285
288
|
case "turn_end":
|
|
286
289
|
setStatus("")
|
|
@@ -289,36 +292,44 @@ function App({
|
|
|
289
292
|
if (ev.usage) setUsage(ev.usage)
|
|
290
293
|
}
|
|
291
294
|
}
|
|
292
|
-
|
|
293
|
-
setBusy(false)
|
|
294
|
-
setStatus("")
|
|
295
|
-
setStream("")
|
|
296
|
-
setThinkStream("")
|
|
297
|
-
})().catch((err) => {
|
|
295
|
+
} catch (err) {
|
|
298
296
|
const errMsg: Msg = {
|
|
299
297
|
role: "assistant",
|
|
300
298
|
model: "system",
|
|
301
299
|
provider: "system",
|
|
302
|
-
content: [{ type: "text", text: chalk.red(`Error: ${err.message}`) }],
|
|
300
|
+
content: [{ type: "text", text: chalk.red(`Error: ${(err as Error).message}`) }],
|
|
303
301
|
usage: { in: 0, out: 0 },
|
|
304
302
|
stop: "error",
|
|
305
303
|
ts: Date.now(),
|
|
306
304
|
}
|
|
307
|
-
|
|
305
|
+
commitMsg(errMsg)
|
|
306
|
+
} finally {
|
|
307
|
+
abortCtrl.current = null
|
|
308
308
|
setBusy(false)
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const updated = [...prev, userMsg]
|
|
315
|
-
agent.setMessages(updated)
|
|
316
|
-
return updated
|
|
317
|
-
})
|
|
318
|
-
store.append(sessionId, userMsg)
|
|
319
|
-
})
|
|
309
|
+
setStream("")
|
|
310
|
+
setThinkStream("")
|
|
311
|
+
setStatus("")
|
|
312
|
+
}
|
|
313
|
+
}
|
|
320
314
|
|
|
321
|
-
if (
|
|
315
|
+
if (mode.type === "select") {
|
|
316
|
+
return (
|
|
317
|
+
<SelectPrompt
|
|
318
|
+
message={mode.message}
|
|
319
|
+
options={mode.options}
|
|
320
|
+
header={mode.header}
|
|
321
|
+
onSelect={resolvePrompt}
|
|
322
|
+
/>
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
if (mode.type === "password") {
|
|
326
|
+
return (
|
|
327
|
+
<PasswordPrompt message={mode.message} validate={mode.validate} onSubmit={resolvePrompt} />
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
if (mode.type === "confirm") {
|
|
331
|
+
return <ConfirmPrompt message={mode.message} onConfirm={resolvePrompt} />
|
|
332
|
+
}
|
|
322
333
|
|
|
323
334
|
const visibleMsgs = msgs.filter(hasMeaningfulContent)
|
|
324
335
|
const isLiveActive = !!(stream || thinkStream || busy)
|
|
@@ -445,6 +456,7 @@ const TOOL_STYLE: Record<string, string> = {
|
|
|
445
456
|
glob: "green",
|
|
446
457
|
find: "green",
|
|
447
458
|
grep: "green",
|
|
459
|
+
tree: "green",
|
|
448
460
|
}
|
|
449
461
|
|
|
450
462
|
function hasMeaningfulContent(msg: Msg): boolean {
|
|
@@ -493,7 +505,6 @@ function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
|
|
|
493
505
|
)
|
|
494
506
|
}
|
|
495
507
|
|
|
496
|
-
// Don't render empty assistant messages (often just tool call containers)
|
|
497
508
|
const hasVisibleContent = msg.content.some((c) => c.type === "text" || c.type === "thinking")
|
|
498
509
|
if (!hasVisibleContent) return null
|
|
499
510
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { Box, render, Text, useInput } from "ink"
|
|
2
|
+
import { useState } from "react"
|
|
3
|
+
|
|
4
|
+
interface SelectOption {
|
|
5
|
+
value: string
|
|
6
|
+
label: string
|
|
7
|
+
hint?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function SelectPrompt({
|
|
11
|
+
message,
|
|
12
|
+
options,
|
|
13
|
+
header,
|
|
14
|
+
onSelect,
|
|
15
|
+
}: {
|
|
16
|
+
message: string
|
|
17
|
+
options: SelectOption[]
|
|
18
|
+
header?: string
|
|
19
|
+
onSelect: (value: string | null) => void
|
|
20
|
+
}) {
|
|
21
|
+
const [idx, setIdx] = useState(0)
|
|
22
|
+
|
|
23
|
+
useInput((_, key) => {
|
|
24
|
+
if (key.escape) {
|
|
25
|
+
onSelect(null)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
if (key.upArrow) {
|
|
29
|
+
setIdx((i) => (i - 1 + options.length) % options.length)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
if (key.downArrow) {
|
|
33
|
+
setIdx((i) => (i + 1) % options.length)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
if (key.return) {
|
|
37
|
+
onSelect(options[idx]?.value ?? null)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Box flexDirection="column" paddingX={1}>
|
|
43
|
+
{header && (
|
|
44
|
+
<Box marginBottom={1}>
|
|
45
|
+
<Text>{header}</Text>
|
|
46
|
+
</Box>
|
|
47
|
+
)}
|
|
48
|
+
<Box marginBottom={1}>
|
|
49
|
+
<Text bold>{message}</Text>
|
|
50
|
+
</Box>
|
|
51
|
+
{options.map((opt, i) => (
|
|
52
|
+
<Box key={opt.value}>
|
|
53
|
+
<Text color={i === idx ? "green" : undefined}>
|
|
54
|
+
{i === idx ? "❯ " : " "}
|
|
55
|
+
{opt.label}
|
|
56
|
+
</Text>
|
|
57
|
+
{opt.hint && i === idx && <Text dimColor> {opt.hint}</Text>}
|
|
58
|
+
</Box>
|
|
59
|
+
))}
|
|
60
|
+
<Box marginTop={1}>
|
|
61
|
+
<Text dimColor>↑↓ navigate · Enter select · Esc cancel</Text>
|
|
62
|
+
</Box>
|
|
63
|
+
</Box>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function PasswordPrompt({
|
|
68
|
+
message,
|
|
69
|
+
validate,
|
|
70
|
+
onSubmit,
|
|
71
|
+
}: {
|
|
72
|
+
message: string
|
|
73
|
+
validate?: (v: string) => string | undefined
|
|
74
|
+
onSubmit: (value: string | null) => void
|
|
75
|
+
}) {
|
|
76
|
+
const [value, setValue] = useState("")
|
|
77
|
+
const [error, setError] = useState("")
|
|
78
|
+
|
|
79
|
+
useInput((ch, key) => {
|
|
80
|
+
if (key.escape) {
|
|
81
|
+
onSubmit(null)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (key.return) {
|
|
85
|
+
const err = validate?.(value)
|
|
86
|
+
if (err) {
|
|
87
|
+
setError(err)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
onSubmit(value)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (key.backspace || key.delete) {
|
|
94
|
+
setValue((v) => v.slice(0, -1))
|
|
95
|
+
setError("")
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
if (ch) {
|
|
99
|
+
setValue((v) => v + ch)
|
|
100
|
+
setError("")
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Box flexDirection="column" paddingX={1}>
|
|
106
|
+
<Box marginBottom={1}>
|
|
107
|
+
<Text bold>{message}</Text>
|
|
108
|
+
</Box>
|
|
109
|
+
<Box>
|
|
110
|
+
<Text color="green">│ </Text>
|
|
111
|
+
<Text dimColor>{"*".repeat(value.length)}</Text>
|
|
112
|
+
<Text color="green">│</Text>
|
|
113
|
+
</Box>
|
|
114
|
+
{error && (
|
|
115
|
+
<Box>
|
|
116
|
+
<Text color="red">{error}</Text>
|
|
117
|
+
</Box>
|
|
118
|
+
)}
|
|
119
|
+
<Box marginTop={1}>
|
|
120
|
+
<Text dimColor>Enter submit · Esc cancel</Text>
|
|
121
|
+
</Box>
|
|
122
|
+
</Box>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function ConfirmPrompt({
|
|
127
|
+
message,
|
|
128
|
+
onConfirm,
|
|
129
|
+
}: {
|
|
130
|
+
message: string
|
|
131
|
+
onConfirm: (value: boolean | null) => void
|
|
132
|
+
}) {
|
|
133
|
+
const [yes, setYes] = useState(true)
|
|
134
|
+
|
|
135
|
+
useInput((_, key) => {
|
|
136
|
+
if (key.escape) {
|
|
137
|
+
onConfirm(null)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (key.leftArrow || key.rightArrow || key.tab) {
|
|
141
|
+
setYes((y) => !y)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (key.return) {
|
|
145
|
+
onConfirm(yes)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<Box flexDirection="column" paddingX={1}>
|
|
151
|
+
<Box marginBottom={1}>
|
|
152
|
+
<Text bold>{message}</Text>
|
|
153
|
+
</Box>
|
|
154
|
+
<Box>
|
|
155
|
+
<Text color={yes ? "green" : undefined}>{yes ? "❯ " : " "}Yes</Text>
|
|
156
|
+
</Box>
|
|
157
|
+
<Box>
|
|
158
|
+
<Text color={!yes ? "red" : undefined}>{!yes ? "❯ " : " "}No</Text>
|
|
159
|
+
</Box>
|
|
160
|
+
<Box marginTop={1}>
|
|
161
|
+
<Text dimColor>←→ toggle · Enter confirm · Esc cancel</Text>
|
|
162
|
+
</Box>
|
|
163
|
+
</Box>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Standalone wrappers for use outside the main TUI (e.g. onboarding)
|
|
168
|
+
|
|
169
|
+
export function standaloneSelect(
|
|
170
|
+
message: string,
|
|
171
|
+
options: SelectOption[],
|
|
172
|
+
header?: string,
|
|
173
|
+
): Promise<string | null> {
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
const { unmount } = render(
|
|
176
|
+
<SelectPrompt
|
|
177
|
+
message={message}
|
|
178
|
+
options={options}
|
|
179
|
+
header={header}
|
|
180
|
+
onSelect={(v) => {
|
|
181
|
+
unmount()
|
|
182
|
+
resolve(v)
|
|
183
|
+
}}
|
|
184
|
+
/>,
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function standalonePassword(
|
|
190
|
+
message: string,
|
|
191
|
+
validate?: (v: string) => string | undefined,
|
|
192
|
+
): Promise<string | null> {
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
const { unmount } = render(
|
|
195
|
+
<PasswordPrompt
|
|
196
|
+
message={message}
|
|
197
|
+
validate={validate}
|
|
198
|
+
onSubmit={(v) => {
|
|
199
|
+
unmount()
|
|
200
|
+
resolve(v)
|
|
201
|
+
}}
|
|
202
|
+
/>,
|
|
203
|
+
)
|
|
204
|
+
})
|
|
205
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -224,6 +224,21 @@ export interface AssistantResult {
|
|
|
224
224
|
stop: StopReason
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/** Prompts — used by interactive commands within the TUI */
|
|
228
|
+
|
|
229
|
+
export interface Prompts {
|
|
230
|
+
select(config: {
|
|
231
|
+
message: string
|
|
232
|
+
header?: string
|
|
233
|
+
options: Array<{ value: string; label: string; hint?: string }>
|
|
234
|
+
}): Promise<string | null>
|
|
235
|
+
password(config: {
|
|
236
|
+
message: string
|
|
237
|
+
validate?: (v: string) => string | undefined
|
|
238
|
+
}): Promise<string | null>
|
|
239
|
+
confirm(config: { message: string }): Promise<boolean | null>
|
|
240
|
+
}
|
|
241
|
+
|
|
227
242
|
/** Commands */
|
|
228
243
|
|
|
229
244
|
export interface Cmd {
|
package/src/update.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { readFile } from "node:fs/promises"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
import semver from "semver"
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
3
8
|
|
|
4
9
|
let cachedLatest: string | null = null
|
|
10
|
+
let cachedCurrent: string | null = null
|
|
5
11
|
|
|
6
12
|
export async function getCurrentVersion(): Promise<string> {
|
|
13
|
+
if (cachedCurrent) return cachedCurrent
|
|
7
14
|
try {
|
|
8
|
-
const
|
|
9
|
-
|
|
15
|
+
const raw = await readFile(join(__dirname, "..", "package.json"), "utf-8")
|
|
16
|
+
const pkg = JSON.parse(raw)
|
|
17
|
+
cachedCurrent = (pkg.version as string) ?? "0.0.0"
|
|
18
|
+
return cachedCurrent
|
|
10
19
|
} catch {
|
|
11
20
|
return "0.0.0"
|
|
12
21
|
}
|
|
@@ -15,15 +24,20 @@ export async function getCurrentVersion(): Promise<string> {
|
|
|
15
24
|
export async function getLatestVersion(): Promise<string | null> {
|
|
16
25
|
if (cachedLatest) return cachedLatest
|
|
17
26
|
try {
|
|
18
|
-
const proc =
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
const proc = spawn("npm", ["info", "novacode", "version"], {
|
|
28
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
29
|
+
})
|
|
30
|
+
const text = await new Promise<string>((resolve, reject) => {
|
|
31
|
+
let out = ""
|
|
32
|
+
proc.stdout.on("data", (chunk: Buffer) => {
|
|
33
|
+
out += chunk.toString()
|
|
34
|
+
})
|
|
35
|
+
proc.on("error", reject)
|
|
36
|
+
proc.on("close", () => resolve(out.trim()))
|
|
21
37
|
})
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
cachedLatest = latest
|
|
26
|
-
return latest
|
|
38
|
+
if (text) {
|
|
39
|
+
cachedLatest = text
|
|
40
|
+
return text
|
|
27
41
|
}
|
|
28
42
|
} catch {}
|
|
29
43
|
return null
|
|
@@ -38,26 +52,36 @@ export async function checkForUpdate(): Promise<{
|
|
|
38
52
|
const latest = await getLatestVersion()
|
|
39
53
|
if (!latest) return null
|
|
40
54
|
return {
|
|
41
|
-
hasUpdate: semver.
|
|
55
|
+
hasUpdate: semver.gt(latest, current),
|
|
42
56
|
current,
|
|
43
57
|
latest,
|
|
44
58
|
}
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
export async function runUpdate(silent = false): Promise<boolean> {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
try {
|
|
63
|
+
const proc = spawn("npm", ["update", "-g", "novacode"], {
|
|
64
|
+
stdio: silent ? "ignore" : "inherit",
|
|
65
|
+
})
|
|
66
|
+
const exitCode = await new Promise<number>((resolve, reject) => {
|
|
67
|
+
proc.on("error", reject)
|
|
68
|
+
proc.on("close", (code) => resolve(code ?? -1))
|
|
69
|
+
})
|
|
70
|
+
if (exitCode === 0) {
|
|
71
|
+
if (!silent) {
|
|
72
|
+
console.log("✓ novacode updated to latest version successfully.")
|
|
73
|
+
}
|
|
74
|
+
return true
|
|
75
|
+
} else {
|
|
76
|
+
if (!silent) {
|
|
77
|
+
console.error(`Update failed (exit code ${exitCode})`)
|
|
78
|
+
process.exit(1)
|
|
79
|
+
}
|
|
80
|
+
return false
|
|
56
81
|
}
|
|
57
|
-
|
|
58
|
-
} else {
|
|
82
|
+
} catch (e) {
|
|
59
83
|
if (!silent) {
|
|
60
|
-
console.error(`Update failed (
|
|
84
|
+
console.error(`Update failed: ${(e as Error).message}`)
|
|
61
85
|
process.exit(1)
|
|
62
86
|
}
|
|
63
87
|
return false
|