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