novacode 0.5.2 → 0.5.5

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,36 @@
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"
10
- import { formatMarkdown } from "./markdown.ts"
9
+ import { Cursor, LiveArea } from "./components/liveArea.tsx"
10
+ import { hasMeaningfulContent, Message } from "./components/message.tsx"
11
+ import { StatusBar } from "./components/statusBar.tsx"
12
+ import { ConfirmPrompt, PasswordPrompt, SelectPrompt } from "./prompts.tsx"
13
+
14
+ type PromptMode =
15
+ | { type: "chat" }
16
+ | {
17
+ type: "select"
18
+ message: string
19
+ options: Array<{ value: string; label: string; hint?: string }>
20
+ header?: string
21
+ }
22
+ | {
23
+ type: "password"
24
+ message: string
25
+ validate?: (v: string) => string | undefined
26
+ }
27
+ | { type: "confirm"; message: string }
28
+
11
29
  export async function interactive(
12
30
  agent: Agent,
13
31
  store: SessionStore,
14
32
  sessionId: string,
15
33
  ): Promise<void> {
16
- // Hide system cursor during session
17
34
  process.stdout.write("\x1B[?25l")
18
35
  const version = await getCurrentVersion()
19
36
  process.stdout.write(`${chalk.cyan.bold("⚡ novacode")} ${chalk.gray(`v${version}`)}\n`)
@@ -22,37 +39,12 @@ export async function interactive(
22
39
  const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />)
23
40
  await waitUntilExit()
24
41
  } finally {
25
- // Restore system cursor on exit
26
42
  process.stdout.write("\x1B[?25h")
27
43
  }
28
44
  }
29
45
 
30
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
31
-
32
- function Spinner() {
33
- const [frame, setFrame] = useState(0)
34
-
35
- useEffect(() => {
36
- const timer = setInterval(() => {
37
- setFrame((f) => (f + 1) % SPINNER_FRAMES.length)
38
- }, 80)
39
- return () => clearInterval(timer)
40
- }, [])
41
-
42
- return <Text color="yellow">{SPINNER_FRAMES[frame]}</Text>
43
- }
44
-
45
- function Cursor() {
46
- const [visible, setVisible] = useState(true)
47
- useEffect(() => {
48
- const timer = setInterval(() => setVisible((v) => !v), 530)
49
- return () => clearInterval(timer)
50
- }, [])
51
- return <Text color="green">{visible ? "│" : " "}</Text>
52
- }
53
-
54
46
  function App({
55
- agent: initialAgent,
47
+ agent,
56
48
  store,
57
49
  sessionId,
58
50
  }: {
@@ -60,8 +52,7 @@ function App({
60
52
  store: SessionStore
61
53
  sessionId: string
62
54
  }) {
63
- const [agent, _setAgent] = useState(initialAgent)
64
- const [msgs, setMsgs] = useState<Msg[]>(initialAgent.messages)
55
+ const [msgs, setMsgs] = useState<Msg[]>(agent.messages)
65
56
  const [stream, setStream] = useState("")
66
57
  const [thinkStream, setThinkStream] = useState("")
67
58
  const [busy, setBusy] = useState(false)
@@ -69,11 +60,15 @@ function App({
69
60
  const [status, setStatus] = useState("")
70
61
  const [usage, setUsage] = useState<{ in: number; out: number }>({ in: 0, out: 0 })
71
62
  const [selCmdIdx, setSelCmdIdx] = useState(0)
72
- const [cmdRunning, setCmdRunning] = useState(false)
63
+ const [mode, setMode] = useState<PromptMode>({ type: "chat" })
64
+ const resolveRef = useRef<((v: unknown) => void) | null>(null)
73
65
  const history = useRef<string[]>([])
74
66
  const hIdx = useRef(-1)
75
67
  const abortCtrl = useRef<AbortController | null>(null)
76
- const [updateInfo, setUpdateInfo] = useState<{ current: string; latest: string } | null>(null)
68
+ const [updateInfo, setUpdateInfo] = useState<{
69
+ current: string
70
+ latest: string
71
+ } | null>(null)
77
72
 
78
73
  useEffect(() => {
79
74
  const check = async () => {
@@ -94,19 +89,53 @@ function App({
94
89
  )
95
90
  : []
96
91
 
92
+ const prompts: Prompts = {
93
+ select: useCallback(
94
+ (config) =>
95
+ new Promise((resolve) => {
96
+ resolveRef.current = resolve as (v: unknown) => void
97
+ setMode({ type: "select", ...config })
98
+ }),
99
+ [],
100
+ ),
101
+ password: useCallback(
102
+ (config) =>
103
+ new Promise((resolve) => {
104
+ resolveRef.current = resolve as (v: unknown) => void
105
+ setMode({ type: "password", ...config })
106
+ }),
107
+ [],
108
+ ),
109
+ confirm: useCallback(
110
+ (config) =>
111
+ new Promise((resolve) => {
112
+ resolveRef.current = resolve as (v: unknown) => void
113
+ setMode({ type: "confirm", ...config })
114
+ }),
115
+ [],
116
+ ),
117
+ }
118
+
119
+ function resolvePrompt(value: unknown) {
120
+ const fn = resolveRef.current
121
+ resolveRef.current = null
122
+ setMode({ type: "chat" })
123
+ fn?.(value)
124
+ }
125
+
126
+ function commitMsg(msg: Msg) {
127
+ setMsgs((prev) => [...prev, msg])
128
+ agent.setMessages([...agent.messages, msg])
129
+ store.append(sessionId, msg)
130
+ }
131
+
97
132
  // biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
98
133
  useEffect(() => {
99
134
  setSelCmdIdx(0)
100
135
  }, [input])
101
136
 
102
- useEffect(() => {
103
- if (!cmdRunning) {
104
- process.stdout.write("\x1B[?25l")
105
- }
106
- }, [cmdRunning])
107
-
108
137
  useInput((ch, key) => {
109
- if (cmdRunning) return
138
+ if (mode.type !== "chat") return
110
139
 
111
140
  if (key.escape) {
112
141
  if (abortCtrl.current) {
@@ -147,7 +176,7 @@ function App({
147
176
  if (!key.return) {
148
177
  setInput((prev) => {
149
178
  if (key.backspace || key.delete) return prev.slice(0, -1)
150
- return prev + ch
179
+ return prev + (ch || "")
151
180
  })
152
181
  return
153
182
  }
@@ -169,71 +198,34 @@ function App({
169
198
  hIdx.current = -1
170
199
 
171
200
  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) => {
201
+ dispatch(line, agent, store, sessionId, prompts).then((r) => {
209
202
  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
203
+ commitMsg({
204
+ role: "assistant",
205
+ content: [{ type: "text", text: r }],
206
+ model: "system",
207
+ provider: "system",
208
+ usage: { in: 0, out: 0 },
209
+ stop: "stop",
210
+ ts: Date.now(),
226
211
  })
227
212
  }
228
213
  })
229
214
  return
230
215
  }
231
216
 
217
+ const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
218
+ commitMsg(userMsg)
219
+
232
220
  abortCtrl.current = new AbortController()
233
- const stream = agent.prompt(line, abortCtrl.current.signal)
221
+ const eventStream = agent.prompt(line, abortCtrl.current.signal)
222
+
223
+ runEventLoop(eventStream)
224
+ })
234
225
 
235
- ;(async () => {
236
- for await (const ev of stream) {
226
+ async function runEventLoop(eventStream: ReturnType<Agent["prompt"]>) {
227
+ try {
228
+ for await (const ev of eventStream) {
237
229
  switch (ev.type) {
238
230
  case "start":
239
231
  setBusy(true)
@@ -248,40 +240,21 @@ function App({
248
240
  if (ev.text) setThinkStream((prev) => prev + ev.text)
249
241
  break
250
242
  case "assistant_msg":
243
+ commitMsg(ev.msg)
251
244
  setStream("")
252
245
  setThinkStream("")
253
- setMsgs((prev) => {
254
- const updated = [...prev, ev.msg]
255
- agent.setMessages(updated)
256
- return updated
257
- })
258
- store.append(sessionId, ev.msg)
246
+
259
247
  break
260
248
  case "tool_call":
261
249
  setStatus(chalk.dim(`⏳ ${ev.call.name}…`))
262
250
  break
263
251
  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
- }
252
+ commitMsg(ev.result)
253
+ setStatus(
254
+ ev.result.isError
255
+ ? chalk.red(`✗ ${ev.result.tool}`)
256
+ : chalk.green(`✓ ${ev.result.tool}`),
257
+ )
285
258
  break
286
259
  case "turn_end":
287
260
  setStatus("")
@@ -290,77 +263,62 @@ function App({
290
263
  if (ev.usage) setUsage(ev.usage)
291
264
  }
292
265
  }
293
- abortCtrl.current = null
294
- setBusy(false)
295
- setStatus("")
296
- setStream("")
297
- setThinkStream("")
298
- })().catch((err) => {
266
+ } catch (err) {
299
267
  const errMsg: Msg = {
300
268
  role: "assistant",
301
269
  model: "system",
302
270
  provider: "system",
303
- content: [{ type: "text", text: chalk.red(`Error: ${err.message}`) }],
271
+ content: [{ type: "text", text: chalk.red(`Error: ${(err as Error).message}`) }],
304
272
  usage: { in: 0, out: 0 },
305
273
  stop: "error",
306
274
  ts: Date.now(),
307
275
  }
308
- setMsgs((prev) => [...prev, errMsg])
276
+ commitMsg(errMsg)
277
+ } finally {
278
+ abortCtrl.current = null
309
279
  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
- })
280
+ setStream("")
281
+ setThinkStream("")
282
+ setStatus("")
283
+ }
284
+ }
321
285
 
322
- if (cmdRunning) return null
286
+ if (mode.type === "select") {
287
+ return (
288
+ <SelectPrompt
289
+ message={mode.message}
290
+ options={mode.options}
291
+ header={mode.header}
292
+ onSelect={resolvePrompt}
293
+ />
294
+ )
295
+ }
296
+ if (mode.type === "password") {
297
+ return (
298
+ <PasswordPrompt message={mode.message} validate={mode.validate} onSubmit={resolvePrompt} />
299
+ )
300
+ }
301
+ if (mode.type === "confirm") {
302
+ return <ConfirmPrompt message={mode.message} onConfirm={resolvePrompt} />
303
+ }
323
304
 
324
305
  const visibleMsgs = msgs.filter(hasMeaningfulContent)
325
306
  const isLiveActive = !!(stream || thinkStream || busy)
326
307
 
327
308
  return (
328
309
  <Box flexDirection="column" paddingX={1}>
329
- {/* Messages - pushed to scrollback as they finish */}
330
310
  <Static items={visibleMsgs}>
331
311
  {(m, i) => <Message key={`${m.ts}-${i}`} msg={m} isFirst={i === 0} />}
332
312
  </Static>
333
313
 
334
- {/* Live Area (Streaming, active tool calls, working indicator) */}
335
- {isLiveActive && (
336
- <Box flexDirection="column" marginTop={visibleMsgs.length > 0 ? 1 : 0}>
337
- {thinkStream && (
338
- <Text dimColor italic>
339
- {thinkStream}
340
- </Text>
341
- )}
342
- {stream && (
343
- <Box flexDirection="row">
344
- <Box flexGrow={1} flexShrink={1}>
345
- <Text>
346
- {formatMarkdown(stream)}
347
- {!input && <Cursor />}
348
- </Text>
349
- </Box>
350
- </Box>
351
- )}
352
- {busy && !stream && (
353
- <Box flexDirection="row">
354
- <Box marginRight={1}>
355
- <Spinner />
356
- </Box>
357
- <Text dimColor>{status ? status.replace("⏳ ", "") : chalk.yellow("working…")}</Text>
358
- </Box>
359
- )}
360
- </Box>
361
- )}
314
+ <LiveArea
315
+ stream={stream}
316
+ thinkStream={thinkStream}
317
+ busy={busy}
318
+ status={status}
319
+ hasMessages={visibleMsgs.length > 0}
320
+ />
362
321
 
363
- {/* Input & Footer (Live) */}
364
322
  <Box flexDirection="column" marginTop={visibleMsgs.length > 0 || isLiveActive ? 1 : 0}>
365
323
  {updateInfo && (
366
324
  <Box
@@ -393,155 +351,14 @@ function App({
393
351
  </Box>
394
352
  </Box>
395
353
 
396
- {/* Dynamic Status / Info Line */}
397
- <Box justifyContent="space-between">
398
- <Box>
399
- {suggestions.length > 0 ? (
400
- <Box flexDirection="column" marginLeft={2}>
401
- {suggestions.map((s, i) => (
402
- <Box key={s.name}>
403
- <Text
404
- color={i === selCmdIdx ? "black" : "yellow"}
405
- backgroundColor={i === selCmdIdx ? "yellow" : undefined}
406
- >
407
- /{s.name.padEnd(10)}
408
- </Text>
409
- <Text dimColor> {s.desc}</Text>
410
- </Box>
411
- ))}
412
- </Box>
413
- ) : (
414
- <Text dimColor>Enter to send · /help for commands</Text>
415
- )}
416
- </Box>
417
-
418
- <Box>
419
- <Text dimColor>{formatTokenUsage(usage.in, agent.model.contextWindow)}</Text>
420
- <Text dimColor> │ </Text>
421
- <Text dimColor>{agent.model.id}</Text>
422
- {busy && <Text dimColor> │ Esc to stop</Text>}
423
- </Box>
424
- </Box>
354
+ <StatusBar
355
+ model={agent.model}
356
+ usage={usage}
357
+ busy={busy}
358
+ suggestions={suggestions}
359
+ selCmdIdx={selCmdIdx}
360
+ />
425
361
  </Box>
426
362
  </Box>
427
363
  )
428
364
  }
429
-
430
- function fmtK(n: number): string {
431
- const k = n / 1000
432
- return k >= 100 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`
433
- }
434
-
435
- function formatTokenUsage(used: number, contextWindow: number): string {
436
- if (used === 0) return `0/${fmtK(contextWindow)}`
437
- const pct = Math.round((used / contextWindow) * 100)
438
- return `${fmtK(used)}/${fmtK(contextWindow)} (${pct}%)`
439
- }
440
-
441
- const TOOL_STYLE: Record<string, string> = {
442
- read: "blue",
443
- write: "magenta",
444
- edit: "yellow",
445
- bash: "cyan",
446
- glob: "green",
447
- find: "green",
448
- grep: "green",
449
- }
450
-
451
- function hasMeaningfulContent(msg: Msg): boolean {
452
- if (msg.role === "user") return true
453
- if (msg.role === "tool_result") return true
454
- if (msg.role === "assistant") {
455
- if (msg.model === "system") return true
456
- return msg.content.some((c) => {
457
- if (c.type === "thinking") return c.text.trim().length > 0
458
- if (c.type === "text") return c.text.trim().length > 0
459
- return false
460
- })
461
- }
462
- return false
463
- }
464
-
465
- function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
466
- if (msg.role === "user") {
467
- return (
468
- <Box marginTop={isFirst ? 0 : 1} flexDirection="row">
469
- <Box flexShrink={0} marginRight={1}>
470
- <Text bold color="green">
471
- {">"}
472
- </Text>
473
- </Box>
474
- <Box flexGrow={1} flexShrink={1}>
475
- <Text>
476
- {typeof msg.content === "string"
477
- ? msg.content
478
- : msg.content.map((c) => (c.type === "text" ? c.text : "")).join("")}
479
- </Text>
480
- </Box>
481
- </Box>
482
- )
483
- }
484
-
485
- if (msg.role === "assistant") {
486
- if (msg.model === "system") {
487
- return (
488
- <Box flexDirection="column" marginTop={0}>
489
- {msg.content.map((c, i) =>
490
- // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
491
- c.type === "text" ? <Text key={i}>{formatMarkdown(c.text)}</Text> : null,
492
- )}
493
- </Box>
494
- )
495
- }
496
-
497
- // Don't render empty assistant messages (often just tool call containers)
498
- const hasVisibleContent = msg.content.some((c) => c.type === "text" || c.type === "thinking")
499
- if (!hasVisibleContent) return null
500
-
501
- return (
502
- <Box flexDirection="column" marginTop={0}>
503
- {msg.content.map((c, i) => {
504
- if (c.type === "thinking") {
505
- return (
506
- // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
507
- <Text key={i} dimColor italic>
508
- {c.text}
509
- </Text>
510
- )
511
- }
512
- if (c.type === "text") {
513
- // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
514
- return <Text key={i}>{formatMarkdown(c.text)}</Text>
515
- }
516
- return null
517
- })}
518
- </Box>
519
- )
520
- }
521
-
522
- if (msg.role === "tool_result") {
523
- const args = msg.args ? formatToolArgs(msg.args, true) : ""
524
-
525
- const resText = msg.content
526
- .map((c) => (c.type === "text" ? c.text : ""))
527
- .join("")
528
- .trim()
529
-
530
- const isRead = msg.tool === "read"
531
- const lineCount = isRead ? resText.split("\n").length : 0
532
- const color = TOOL_STYLE[msg.tool] || "white"
533
-
534
- return (
535
- <Box flexDirection="row">
536
- <Text color={msg.isError ? "red" : "green"}>{msg.isError ? "✗" : "✓"} </Text>
537
- <Text color={color} bold>
538
- {msg.tool}
539
- </Text>
540
- {args && <Text> {args}</Text>}
541
- {isRead && !msg.isError && <Text dimColor> ({lineCount} lines)</Text>}
542
- {msg.isError && resText && <Text color="red"> {resText.slice(0, 80)}</Text>}
543
- </Box>
544
- )
545
- }
546
- return null
547
- }
@@ -0,0 +1,73 @@
1
+ import chalk from "chalk"
2
+ import { Box, Text } from "ink"
3
+ import { useEffect, useState } from "react"
4
+ import { formatMarkdown } from "../markdown.ts"
5
+
6
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
7
+
8
+ export function Spinner() {
9
+ const [frame, setFrame] = useState(0)
10
+
11
+ useEffect(() => {
12
+ const timer = setInterval(() => {
13
+ setFrame((f) => (f + 1) % SPINNER_FRAMES.length)
14
+ }, 80)
15
+ return () => clearInterval(timer)
16
+ }, [])
17
+
18
+ return <Text color="yellow">{SPINNER_FRAMES[frame]}</Text>
19
+ }
20
+
21
+ export function Cursor() {
22
+ const [visible, setVisible] = useState(true)
23
+ useEffect(() => {
24
+ const timer = setInterval(() => setVisible((v) => !v), 530)
25
+ return () => clearInterval(timer)
26
+ }, [])
27
+ return <Text color="green">{visible ? "│" : " "}</Text>
28
+ }
29
+
30
+ export function LiveArea({
31
+ stream,
32
+ thinkStream,
33
+ busy,
34
+ status,
35
+ hasMessages,
36
+ }: {
37
+ stream: string
38
+ thinkStream: string
39
+ busy: boolean
40
+ status: string
41
+ hasMessages: boolean
42
+ }) {
43
+ const isActive = !!(stream || thinkStream || busy)
44
+ if (!isActive) return null
45
+
46
+ return (
47
+ <Box flexDirection="column" marginTop={hasMessages ? 1 : 0}>
48
+ {thinkStream && (
49
+ <Text dimColor italic>
50
+ {thinkStream}
51
+ </Text>
52
+ )}
53
+ {stream && (
54
+ <Box flexDirection="row">
55
+ <Box flexGrow={1} flexShrink={1}>
56
+ <Text>
57
+ {formatMarkdown(stream)}
58
+ <Cursor />
59
+ </Text>
60
+ </Box>
61
+ </Box>
62
+ )}
63
+ {busy && !stream && (
64
+ <Box flexDirection="row">
65
+ <Box marginRight={1}>
66
+ <Spinner />
67
+ </Box>
68
+ <Text dimColor>{status ? status.replace("⏳ ", "") : chalk.yellow("working…")}</Text>
69
+ </Box>
70
+ )}
71
+ </Box>
72
+ )
73
+ }