novacode 0.5.2 → 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 +26 -2
- 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 +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 +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 +132 -122
- 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,35 @@
|
|
|
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 { formatToolArgs
|
|
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()
|
|
19
35
|
process.stdout.write(`${chalk.cyan.bold("⚡ novacode")} ${chalk.gray(`v${version}`)}\n`)
|
|
@@ -22,7 +38,6 @@ export async function interactive(
|
|
|
22
38
|
const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />)
|
|
23
39
|
await waitUntilExit()
|
|
24
40
|
} finally {
|
|
25
|
-
// Restore system cursor on exit
|
|
26
41
|
process.stdout.write("\x1B[?25h")
|
|
27
42
|
}
|
|
28
43
|
}
|
|
@@ -67,13 +82,20 @@ function App({
|
|
|
67
82
|
const [busy, setBusy] = useState(false)
|
|
68
83
|
const [input, setInput] = useState("")
|
|
69
84
|
const [status, setStatus] = useState("")
|
|
70
|
-
const [usage, setUsage] = useState<{ in: number; out: number }>({
|
|
85
|
+
const [usage, setUsage] = useState<{ in: number; out: number }>({
|
|
86
|
+
in: 0,
|
|
87
|
+
out: 0,
|
|
88
|
+
})
|
|
71
89
|
const [selCmdIdx, setSelCmdIdx] = useState(0)
|
|
72
|
-
const [
|
|
90
|
+
const [mode, setMode] = useState<PromptMode>({ type: "chat" })
|
|
91
|
+
const resolveRef = useRef<((v: unknown) => void) | null>(null)
|
|
73
92
|
const history = useRef<string[]>([])
|
|
74
93
|
const hIdx = useRef(-1)
|
|
75
94
|
const abortCtrl = useRef<AbortController | null>(null)
|
|
76
|
-
const [updateInfo, setUpdateInfo] = useState<{
|
|
95
|
+
const [updateInfo, setUpdateInfo] = useState<{
|
|
96
|
+
current: string
|
|
97
|
+
latest: string
|
|
98
|
+
} | null>(null)
|
|
77
99
|
|
|
78
100
|
useEffect(() => {
|
|
79
101
|
const check = async () => {
|
|
@@ -94,19 +116,53 @@ function App({
|
|
|
94
116
|
)
|
|
95
117
|
: []
|
|
96
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
|
+
|
|
97
159
|
// biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
|
|
98
160
|
useEffect(() => {
|
|
99
161
|
setSelCmdIdx(0)
|
|
100
162
|
}, [input])
|
|
101
163
|
|
|
102
|
-
useEffect(() => {
|
|
103
|
-
if (!cmdRunning) {
|
|
104
|
-
process.stdout.write("\x1B[?25l")
|
|
105
|
-
}
|
|
106
|
-
}, [cmdRunning])
|
|
107
|
-
|
|
108
164
|
useInput((ch, key) => {
|
|
109
|
-
if (
|
|
165
|
+
if (mode.type !== "chat") return
|
|
110
166
|
|
|
111
167
|
if (key.escape) {
|
|
112
168
|
if (abortCtrl.current) {
|
|
@@ -147,7 +203,7 @@ function App({
|
|
|
147
203
|
if (!key.return) {
|
|
148
204
|
setInput((prev) => {
|
|
149
205
|
if (key.backspace || key.delete) return prev.slice(0, -1)
|
|
150
|
-
return prev + ch
|
|
206
|
+
return prev + (ch || "")
|
|
151
207
|
})
|
|
152
208
|
return
|
|
153
209
|
}
|
|
@@ -169,71 +225,35 @@ function App({
|
|
|
169
225
|
hIdx.current = -1
|
|
170
226
|
|
|
171
227
|
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) => {
|
|
228
|
+
dispatch(line, agent, store, sessionId, prompts).then((r) => {
|
|
209
229
|
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
|
|
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(),
|
|
226
238
|
})
|
|
227
239
|
}
|
|
228
240
|
})
|
|
229
241
|
return
|
|
230
242
|
}
|
|
231
243
|
|
|
244
|
+
// Record user message before starting the stream
|
|
245
|
+
const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
|
|
246
|
+
commitMsg(userMsg)
|
|
247
|
+
|
|
232
248
|
abortCtrl.current = new AbortController()
|
|
233
|
-
const
|
|
249
|
+
const eventStream = agent.prompt(line, abortCtrl.current.signal)
|
|
234
250
|
|
|
235
|
-
|
|
236
|
-
|
|
251
|
+
runEventLoop(eventStream)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
async function runEventLoop(eventStream: ReturnType<Agent["prompt"]>) {
|
|
255
|
+
try {
|
|
256
|
+
for await (const ev of eventStream) {
|
|
237
257
|
switch (ev.type) {
|
|
238
258
|
case "start":
|
|
239
259
|
setBusy(true)
|
|
@@ -248,40 +268,22 @@ function App({
|
|
|
248
268
|
if (ev.text) setThinkStream((prev) => prev + ev.text)
|
|
249
269
|
break
|
|
250
270
|
case "assistant_msg":
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return updated
|
|
257
|
-
})
|
|
258
|
-
store.append(sessionId, ev.msg)
|
|
271
|
+
commitMsg(ev.msg)
|
|
272
|
+
setTimeout(() => {
|
|
273
|
+
setStream("")
|
|
274
|
+
setThinkStream("")
|
|
275
|
+
}, 0)
|
|
259
276
|
break
|
|
260
277
|
case "tool_call":
|
|
261
278
|
setStatus(chalk.dim(`⏳ ${ev.call.name}…`))
|
|
262
279
|
break
|
|
263
280
|
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
|
-
}
|
|
281
|
+
commitMsg(ev.result)
|
|
282
|
+
setStatus(
|
|
283
|
+
ev.result.isError
|
|
284
|
+
? chalk.red(`✗ ${ev.result.tool}`)
|
|
285
|
+
: chalk.green(`✓ ${ev.result.tool}`),
|
|
286
|
+
)
|
|
285
287
|
break
|
|
286
288
|
case "turn_end":
|
|
287
289
|
setStatus("")
|
|
@@ -290,36 +292,44 @@ function App({
|
|
|
290
292
|
if (ev.usage) setUsage(ev.usage)
|
|
291
293
|
}
|
|
292
294
|
}
|
|
293
|
-
|
|
294
|
-
setBusy(false)
|
|
295
|
-
setStatus("")
|
|
296
|
-
setStream("")
|
|
297
|
-
setThinkStream("")
|
|
298
|
-
})().catch((err) => {
|
|
295
|
+
} catch (err) {
|
|
299
296
|
const errMsg: Msg = {
|
|
300
297
|
role: "assistant",
|
|
301
298
|
model: "system",
|
|
302
299
|
provider: "system",
|
|
303
|
-
content: [{ type: "text", text: chalk.red(`Error: ${err.message}`) }],
|
|
300
|
+
content: [{ type: "text", text: chalk.red(`Error: ${(err as Error).message}`) }],
|
|
304
301
|
usage: { in: 0, out: 0 },
|
|
305
302
|
stop: "error",
|
|
306
303
|
ts: Date.now(),
|
|
307
304
|
}
|
|
308
|
-
|
|
305
|
+
commitMsg(errMsg)
|
|
306
|
+
} finally {
|
|
307
|
+
abortCtrl.current = null
|
|
309
308
|
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
|
-
})
|
|
309
|
+
setStream("")
|
|
310
|
+
setThinkStream("")
|
|
311
|
+
setStatus("")
|
|
312
|
+
}
|
|
313
|
+
}
|
|
321
314
|
|
|
322
|
-
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
|
+
}
|
|
323
333
|
|
|
324
334
|
const visibleMsgs = msgs.filter(hasMeaningfulContent)
|
|
325
335
|
const isLiveActive = !!(stream || thinkStream || busy)
|
|
@@ -446,6 +456,7 @@ const TOOL_STYLE: Record<string, string> = {
|
|
|
446
456
|
glob: "green",
|
|
447
457
|
find: "green",
|
|
448
458
|
grep: "green",
|
|
459
|
+
tree: "green",
|
|
449
460
|
}
|
|
450
461
|
|
|
451
462
|
function hasMeaningfulContent(msg: Msg): boolean {
|
|
@@ -494,7 +505,6 @@ function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
|
|
|
494
505
|
)
|
|
495
506
|
}
|
|
496
507
|
|
|
497
|
-
// Don't render empty assistant messages (often just tool call containers)
|
|
498
508
|
const hasVisibleContent = msg.content.some((c) => c.type === "text" || c.type === "thinking")
|
|
499
509
|
if (!hasVisibleContent) return null
|
|
500
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,5 +1,10 @@
|
|
|
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
|
|
5
10
|
let cachedCurrent: string | null = null
|
|
@@ -7,7 +12,8 @@ let cachedCurrent: string | null = null
|
|
|
7
12
|
export async function getCurrentVersion(): Promise<string> {
|
|
8
13
|
if (cachedCurrent) return cachedCurrent
|
|
9
14
|
try {
|
|
10
|
-
const
|
|
15
|
+
const raw = await readFile(join(__dirname, "..", "package.json"), "utf-8")
|
|
16
|
+
const pkg = JSON.parse(raw)
|
|
11
17
|
cachedCurrent = (pkg.version as string) ?? "0.0.0"
|
|
12
18
|
return cachedCurrent
|
|
13
19
|
} catch {
|
|
@@ -18,15 +24,20 @@ export async function getCurrentVersion(): Promise<string> {
|
|
|
18
24
|
export async function getLatestVersion(): Promise<string | null> {
|
|
19
25
|
if (cachedLatest) return cachedLatest
|
|
20
26
|
try {
|
|
21
|
-
const proc =
|
|
22
|
-
|
|
23
|
-
|
|
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()))
|
|
24
37
|
})
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
cachedLatest = latest
|
|
29
|
-
return latest
|
|
38
|
+
if (text) {
|
|
39
|
+
cachedLatest = text
|
|
40
|
+
return text
|
|
30
41
|
}
|
|
31
42
|
} catch {}
|
|
32
43
|
return null
|
|
@@ -41,26 +52,36 @@ export async function checkForUpdate(): Promise<{
|
|
|
41
52
|
const latest = await getLatestVersion()
|
|
42
53
|
if (!latest) return null
|
|
43
54
|
return {
|
|
44
|
-
hasUpdate: semver.
|
|
55
|
+
hasUpdate: semver.gt(latest, current),
|
|
45
56
|
current,
|
|
46
57
|
latest,
|
|
47
58
|
}
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
export async function runUpdate(silent = false): Promise<boolean> {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
59
81
|
}
|
|
60
|
-
|
|
61
|
-
} else {
|
|
82
|
+
} catch (e) {
|
|
62
83
|
if (!silent) {
|
|
63
|
-
console.error(`Update failed (
|
|
84
|
+
console.error(`Update failed: ${(e as Error).message}`)
|
|
64
85
|
process.exit(1)
|
|
65
86
|
}
|
|
66
87
|
return false
|