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/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, makeRelative } from "../util.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()
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 }>({ in: 0, out: 0 })
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 [cmdRunning, setCmdRunning] = useState(false)
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<{ current: string; latest: string } | null>(null)
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 (cmdRunning) return
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
- const cmdName = line.slice(1).split(" ")[0]?.toLowerCase() ?? ""
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
- setMsgs((prev) => {
211
- const updated: Msg[] = [
212
- ...prev,
213
- {
214
- role: "assistant",
215
- content: [{ type: "text", text: r }],
216
- model: "system",
217
- provider: "system",
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 stream = agent.prompt(line, abortCtrl.current.signal)
249
+ const eventStream = agent.prompt(line, abortCtrl.current.signal)
234
250
 
235
- ;(async () => {
236
- for await (const ev of stream) {
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
- setStream("")
252
- setThinkStream("")
253
- setMsgs((prev) => {
254
- const updated = [...prev, ev.msg]
255
- agent.setMessages(updated)
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
- setMsgs((prev) => {
265
- const updated = [...prev, ev.result]
266
- agent.setMessages(updated)
267
- return updated
268
- })
269
- store.append(sessionId, ev.result)
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
- abortCtrl.current = null
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
- setMsgs((prev) => [...prev, errMsg])
305
+ commitMsg(errMsg)
306
+ } finally {
307
+ abortCtrl.current = null
309
308
  setBusy(false)
310
- })
311
-
312
- // Record user msg immediately
313
- const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
314
- setMsgs((prev) => {
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 (cmdRunning) return null
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 { join } from "node:path"
2
- import { semver } from "bun"
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 pkg = await Bun.file(join(import.meta.dir, "..", "package.json")).json()
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 = Bun.spawn(["bun", "info", "novacode", "version"], {
22
- stdout: "pipe",
23
- stderr: "ignore",
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
- const text = await new Response(proc.stdout).text()
26
- const latest = text.trim()
27
- if (latest) {
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.order(latest, current) === 1,
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
- const proc = Bun.spawn(["bun", "update", "-g", "novacode", "--latest"], {
52
- stdout: silent ? "ignore" : "inherit",
53
- stderr: silent ? "ignore" : "inherit",
54
- })
55
- const exitCode = await proc.exited
56
- if (exitCode === 0) {
57
- if (!silent) {
58
- console.log("✓ novacode updated to latest version successfully.")
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
- return true
61
- } else {
82
+ } catch (e) {
62
83
  if (!silent) {
63
- console.error(`Update failed (exit code ${exitCode})`)
84
+ console.error(`Update failed: ${(e as Error).message}`)
64
85
  process.exit(1)
65
86
  }
66
87
  return false