novacode 0.6.0 → 0.7.0

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 DELETED
@@ -1,454 +0,0 @@
1
- import chalk from "chalk"
2
- import { Box, render, Static, Text, useApp, useInput } from "ink"
3
- import { useCallback, useEffect, useRef, useState } from "react"
4
- import type { Agent } from "../agent/agent.ts"
5
- import { COMMANDS, dispatch } from "../commands/index.ts"
6
- import { getProvider, MODELS } from "../config/providers.ts"
7
- import { loadAuth } from "../config/store.ts"
8
- import { generateSessionTitle } from "../session/compact.ts"
9
- import type { SessionStore } from "../session/store.ts"
10
- import type { Msg, Prompts } from "../types.ts"
11
- import { checkForUpdate, getCurrentVersion } from "../update.ts"
12
- import { Cursor, LiveArea } from "./components/liveArea.tsx"
13
- import { hasMeaningfulContent, Message } from "./components/message.tsx"
14
- import { StatusBar } from "./components/statusBar.tsx"
15
- import { ConfirmPrompt, PasswordPrompt, SelectPrompt } from "./prompts.tsx"
16
-
17
- type PromptMode =
18
- | { type: "chat" }
19
- | {
20
- type: "select"
21
- message: string
22
- options: Array<{ value: string; label: string; hint?: string }>
23
- header?: string
24
- }
25
- | {
26
- type: "password"
27
- message: string
28
- validate?: (v: string) => string | undefined
29
- }
30
- | { type: "confirm"; message: string }
31
-
32
- export async function interactive(
33
- agent: Agent,
34
- store: SessionStore,
35
- sessionId: string,
36
- ): Promise<void> {
37
- process.stdout.write("\x1B[?25l")
38
- const version = await getCurrentVersion()
39
- process.stdout.write(`${chalk.cyan.bold("⚡ novacode")} ${chalk.gray(`v${version}`)}\n`)
40
-
41
- try {
42
- const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />, {
43
- exitOnCtrlC: false,
44
- })
45
- await waitUntilExit()
46
- } finally {
47
- process.stdout.write("\x1B[?25h")
48
- await store.prune()
49
- }
50
- }
51
-
52
- function App({
53
- agent,
54
- store,
55
- sessionId: initialSessionId,
56
- }: {
57
- agent: Agent
58
- store: SessionStore
59
- sessionId: string
60
- }) {
61
- const [currSessionId, setCurrSessionId] = useState(initialSessionId)
62
- const [msgs, setMsgs] = useState<Msg[]>(agent.messages)
63
-
64
- const handleSwitchSession = useCallback(
65
- async (newSessionId: string) => {
66
- const s = await store.get(newSessionId)
67
- if (!s) return
68
-
69
- const provider = getProvider(s.provider)
70
- const model =
71
- MODELS.find((m) => m.id === s.model && m.provider === s.provider) ||
72
- MODELS.find((m) => m.id === s.model)
73
- if (provider && model) {
74
- const auth = await loadAuth()
75
- const apiKey = auth.apiKeys[s.provider] || ""
76
- agent.updateConfig({
77
- api: provider.api,
78
- model,
79
- apiKey,
80
- baseUrl: provider.baseUrl,
81
- })
82
- }
83
-
84
- const newMsgs = await store.messages(newSessionId)
85
- agent.setMessages(newMsgs)
86
- setMsgs(newMsgs)
87
- setCurrSessionId(newSessionId)
88
- },
89
- [store, agent],
90
- )
91
- const [stream, setStream] = useState("")
92
- const [thinkStream, setThinkStream] = useState("")
93
- const [busy, setBusy] = useState(false)
94
- const [input, setInput] = useState("")
95
- const [status, setStatus] = useState("")
96
- const [usage, setUsage] = useState<{ in: number; out: number }>({ in: 0, out: 0 })
97
- const [selCmdIdx, setSelCmdIdx] = useState(0)
98
- const [mode, setMode] = useState<PromptMode>({ type: "chat" })
99
- const resolveRef = useRef<((v: unknown) => void) | null>(null)
100
- const history = useRef<string[]>([])
101
- const hIdx = useRef(-1)
102
- const abortCtrl = useRef<AbortController | null>(null)
103
- const [updateInfo, setUpdateInfo] = useState<{
104
- current: string
105
- latest: string
106
- } | null>(null)
107
- const { exit } = useApp()
108
- const lastExitPress = useRef<{ key: "C"; ts: number } | null>(null)
109
- const [exitConfirmKey, setExitConfirmKey] = useState<"C" | null>(null)
110
-
111
- useEffect(() => {
112
- const check = async () => {
113
- const info = await checkForUpdate()
114
- if (info?.hasUpdate) {
115
- setUpdateInfo({ current: info.current, latest: info.latest })
116
- }
117
- }
118
- check()
119
- }, [])
120
-
121
- const isTypingCmd = input.startsWith("/") && !input.includes(" ")
122
- const suggestions = isTypingCmd
123
- ? COMMANDS.filter(
124
- (c) =>
125
- c.name.startsWith(input.slice(1).toLowerCase()) ||
126
- c.aliases?.some((a) => a.startsWith(input.slice(1).toLowerCase())),
127
- )
128
- : []
129
-
130
- const prompts: Prompts = {
131
- select: useCallback(
132
- (config) =>
133
- new Promise((resolve) => {
134
- resolveRef.current = resolve as (v: unknown) => void
135
- setMode({ type: "select", ...config })
136
- }),
137
- [],
138
- ),
139
- password: useCallback(
140
- (config) =>
141
- new Promise((resolve) => {
142
- resolveRef.current = resolve as (v: unknown) => void
143
- setMode({ type: "password", ...config })
144
- }),
145
- [],
146
- ),
147
- confirm: useCallback(
148
- (config) =>
149
- new Promise((resolve) => {
150
- resolveRef.current = resolve as (v: unknown) => void
151
- setMode({ type: "confirm", ...config })
152
- }),
153
- [],
154
- ),
155
- }
156
-
157
- function resolvePrompt(value: unknown) {
158
- const fn = resolveRef.current
159
- resolveRef.current = null
160
- setMode({ type: "chat" })
161
- fn?.(value)
162
- }
163
-
164
- function commitMsg(msg: Msg) {
165
- setMsgs((prev) => [...prev, msg])
166
- agent.setMessages([...agent.messages, msg])
167
- store.append(currSessionId, msg).catch((err) => {
168
- console.error("Error appending message to session store:", err)
169
- })
170
- }
171
-
172
- // biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
173
- useEffect(() => {
174
- setSelCmdIdx(0)
175
- }, [input])
176
-
177
- useInput((ch, key) => {
178
- if (key.ctrl && (ch === "c" || ch === "d")) {
179
- if (busy) {
180
- if (ch === "c") {
181
- if (abortCtrl.current) {
182
- abortCtrl.current.abort()
183
- abortCtrl.current = null
184
- }
185
- }
186
- return
187
- }
188
-
189
- // Idle state - handle exit
190
- if (ch === "d") {
191
- exit()
192
- return
193
- }
194
-
195
- // Ctrl+C double-press exit logic
196
- const now = Date.now()
197
- if (
198
- lastExitPress.current &&
199
- lastExitPress.current.key === "C" &&
200
- now - lastExitPress.current.ts < 2000
201
- ) {
202
- exit()
203
- } else {
204
- lastExitPress.current = { key: "C", ts: now }
205
- setExitConfirmKey("C")
206
- // Clear the temporary status after 2 seconds
207
- setTimeout(() => {
208
- if (lastExitPress.current?.key === "C" && Date.now() - lastExitPress.current.ts >= 2000) {
209
- lastExitPress.current = null
210
- setExitConfirmKey(null)
211
- }
212
- }, 2000)
213
- }
214
- return
215
- }
216
-
217
- if (mode.type !== "chat") return
218
-
219
- if (key.escape) {
220
- if (abortCtrl.current) {
221
- abortCtrl.current.abort()
222
- abortCtrl.current = null
223
- } else if (input) {
224
- setInput("")
225
- }
226
- return
227
- }
228
- if (key.upArrow) {
229
- if (isTypingCmd && suggestions.length > 0) {
230
- setSelCmdIdx((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1))
231
- return
232
- }
233
- if (history.current.length > 0) {
234
- hIdx.current = Math.min(hIdx.current + 1, history.current.length - 1)
235
- setInput(history.current[hIdx.current] ?? "")
236
- }
237
- return
238
- }
239
- if (key.downArrow) {
240
- if (isTypingCmd && suggestions.length > 0) {
241
- setSelCmdIdx((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0))
242
- return
243
- }
244
- hIdx.current = Math.max(hIdx.current - 1, -1)
245
- setInput(hIdx.current >= 0 ? (history.current[hIdx.current] ?? "") : "")
246
- return
247
- }
248
- if (key.tab) {
249
- if (isTypingCmd && suggestions.length > 0) {
250
- const match = suggestions[selCmdIdx]
251
- if (match) {
252
- setInput(`/${match.name} `)
253
- }
254
- }
255
- return
256
- }
257
- if (!key.return) {
258
- setInput((prev) => {
259
- if (key.backspace || key.delete) return prev.slice(0, -1)
260
- return prev + (ch || "")
261
- })
262
- return
263
- }
264
-
265
- if (busy) return
266
-
267
- let line = input.trim()
268
- if (!line) return
269
-
270
- if (isTypingCmd && suggestions.length > 0) {
271
- const match = suggestions[selCmdIdx]
272
- if (match) {
273
- line = `/${match.name}`
274
- }
275
- }
276
-
277
- setInput("")
278
- history.current.unshift(line)
279
- hIdx.current = -1
280
-
281
- if (line.startsWith("/")) {
282
- dispatch(line, agent, store, currSessionId, prompts, exit, handleSwitchSession).then((r) => {
283
- if (r) {
284
- commitMsg({
285
- role: "assistant",
286
- content: [{ type: "text", text: r }],
287
- model: "system",
288
- provider: "system",
289
- usage: { in: 0, out: 0 },
290
- stop: "stop",
291
- ts: Date.now(),
292
- })
293
- }
294
- })
295
- return
296
- }
297
-
298
- const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
299
- commitMsg(userMsg)
300
-
301
- abortCtrl.current = new AbortController()
302
- const eventStream = agent.prompt(line, abortCtrl.current.signal)
303
-
304
- runEventLoop(eventStream)
305
- })
306
-
307
- async function runEventLoop(eventStream: ReturnType<Agent["prompt"]>) {
308
- try {
309
- for await (const ev of eventStream) {
310
- switch (ev.type) {
311
- case "start":
312
- setBusy(true)
313
- setStream("")
314
- setThinkStream("")
315
- setStatus("")
316
- break
317
- case "text_delta":
318
- if (ev.text) setStream((prev) => prev + ev.text)
319
- break
320
- case "thinking_delta":
321
- if (ev.text) setThinkStream((prev) => prev + ev.text)
322
- break
323
- case "assistant_msg":
324
- commitMsg(ev.msg)
325
- setStream("")
326
- setThinkStream("")
327
-
328
- break
329
- case "tool_call":
330
- setStatus(chalk.dim(`⏳ ${ev.call.name}…`))
331
- break
332
- case "tool_result":
333
- commitMsg(ev.result)
334
- setStatus(
335
- ev.result.isError
336
- ? chalk.red(`✗ ${ev.result.tool}`)
337
- : chalk.green(`✓ ${ev.result.tool}`),
338
- )
339
- break
340
- case "turn_end":
341
- setStatus("")
342
- store
343
- .get(currSessionId)
344
- .then((s) => {
345
- if (s && !s.title && agent.messages.length >= 2) {
346
- generateSessionTitle(agent.messages, agent.model, agent.apiKey, agent.baseUrl)
347
- .then((title) => {
348
- if (title) {
349
- store.setTitle(currSessionId, title).catch(() => {})
350
- }
351
- })
352
- .catch(() => {})
353
- }
354
- })
355
- .catch(() => {})
356
- break
357
- case "usage":
358
- if (ev.usage) setUsage(ev.usage)
359
- }
360
- }
361
- } catch (err) {
362
- const errMsg: Msg = {
363
- role: "assistant",
364
- model: "system",
365
- provider: "system",
366
- content: [{ type: "text", text: chalk.red(`Error: ${(err as Error).message}`) }],
367
- usage: { in: 0, out: 0 },
368
- stop: "error",
369
- ts: Date.now(),
370
- }
371
- commitMsg(errMsg)
372
- } finally {
373
- abortCtrl.current = null
374
- setBusy(false)
375
- setStream("")
376
- setThinkStream("")
377
- setStatus("")
378
- }
379
- }
380
-
381
- if (mode.type === "select") {
382
- return (
383
- <SelectPrompt
384
- message={mode.message}
385
- options={mode.options}
386
- header={mode.header}
387
- onSelect={resolvePrompt}
388
- />
389
- )
390
- }
391
- if (mode.type === "password") {
392
- return (
393
- <PasswordPrompt message={mode.message} validate={mode.validate} onSubmit={resolvePrompt} />
394
- )
395
- }
396
- if (mode.type === "confirm") {
397
- return <ConfirmPrompt message={mode.message} onConfirm={resolvePrompt} />
398
- }
399
-
400
- const visibleMsgs = msgs.filter(hasMeaningfulContent)
401
- const isLiveActive = !!(stream || thinkStream || busy)
402
-
403
- return (
404
- <Box flexDirection="column" paddingX={1}>
405
- <Static items={visibleMsgs}>
406
- {(m, i) => <Message key={`${m.ts}-${i}`} msg={m} isFirst={i === 0} />}
407
- </Static>
408
-
409
- <LiveArea stream={stream} thinkStream={thinkStream} busy={busy} status={status} />
410
-
411
- <Box flexDirection="column" marginTop={visibleMsgs.length > 0 || isLiveActive ? 1 : 0}>
412
- {updateInfo && (
413
- <Box
414
- borderStyle="round"
415
- borderColor="yellow"
416
- paddingX={1}
417
- marginBottom={1}
418
- flexDirection="column"
419
- >
420
- <Text color="yellow" bold>
421
- ⬆ Update Available (v{updateInfo.current} → v{updateInfo.latest})
422
- </Text>
423
- <Text dimColor>
424
- Run <Text color="cyan">/update</Text> or <Text color="cyan">nova update</Text> to
425
- upgrade.
426
- </Text>
427
- </Box>
428
- )}
429
- <Box flexDirection="row">
430
- <Box flexShrink={0} marginRight={1}>
431
- <Text bold color="green">
432
- {">"}
433
- </Text>
434
- </Box>
435
- <Box flexGrow={1} flexShrink={1}>
436
- <Text>
437
- {input}
438
- <Cursor />
439
- </Text>
440
- </Box>
441
- </Box>
442
-
443
- <StatusBar
444
- model={agent.model}
445
- usage={usage}
446
- busy={busy}
447
- suggestions={suggestions}
448
- selCmdIdx={selCmdIdx}
449
- exitConfirmKey={exitConfirmKey}
450
- />
451
- </Box>
452
- </Box>
453
- )
454
- }
@@ -1,70 +0,0 @@
1
- import chalk from "chalk"
2
- import { Box, Text } from "ink"
3
- import { useEffect, useState } from "react"
4
- import { SPINNER_FRAMES } from "../constants.ts"
5
- import { formatMarkdown } from "../markdown.ts"
6
-
7
- export function Spinner() {
8
- const [frame, setFrame] = useState(0)
9
-
10
- useEffect(() => {
11
- const timer = setInterval(() => {
12
- setFrame((f) => (f + 1) % SPINNER_FRAMES.length)
13
- }, 80)
14
- return () => clearInterval(timer)
15
- }, [])
16
-
17
- return <Text color="yellow">{SPINNER_FRAMES[frame]}</Text>
18
- }
19
-
20
- export function Cursor() {
21
- const [visible, setVisible] = useState(true)
22
- useEffect(() => {
23
- const timer = setInterval(() => setVisible((v) => !v), 530)
24
- return () => clearInterval(timer)
25
- }, [])
26
- return <Text color="green">{visible ? "│" : " "}</Text>
27
- }
28
-
29
- export function LiveArea({
30
- stream,
31
- thinkStream,
32
- busy,
33
- status,
34
- }: {
35
- stream: string
36
- thinkStream: string
37
- busy: boolean
38
- status: string
39
- }) {
40
- const isActive = !!(stream || thinkStream || busy)
41
- if (!isActive) return null
42
-
43
- return (
44
- <Box flexDirection="column" marginTop={0}>
45
- {thinkStream && (
46
- <Text dimColor italic>
47
- {thinkStream}
48
- </Text>
49
- )}
50
- {stream && (
51
- <Box flexDirection="row">
52
- <Box flexGrow={1} flexShrink={1}>
53
- <Text>
54
- {formatMarkdown(stream)}
55
- <Cursor />
56
- </Text>
57
- </Box>
58
- </Box>
59
- )}
60
- {busy && !stream && (
61
- <Box flexDirection="row">
62
- <Box marginRight={1}>
63
- <Spinner />
64
- </Box>
65
- <Text dimColor>{status ? status.replace("⏳ ", "") : chalk.yellow("working…")}</Text>
66
- </Box>
67
- )}
68
- </Box>
69
- )
70
- }
@@ -1,117 +0,0 @@
1
- import { Box, Text } from "ink"
2
- import type { Msg } from "../../types.ts"
3
- import { formatToolArgs } from "../../util.ts"
4
- import { TERMINATION_PHRASES, TOOL_STYLE } from "../constants.ts"
5
- import { formatMarkdown } from "../markdown.ts"
6
-
7
- export function hasMeaningfulContent(msg: Msg): boolean {
8
- if (msg.role === "user") return true
9
- if (msg.role === "tool_result") return true
10
- if (msg.role === "assistant") {
11
- if (msg.model === "system") return true
12
- if (msg.stop === "aborted") return true
13
- return msg.content.some((c) => {
14
- if (c.type === "thinking") return c.text.trim().length > 0
15
- if (c.type === "text") return c.text.trim().length > 0
16
- return false
17
- })
18
- }
19
- return false
20
- }
21
-
22
- export function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
23
- if (msg.role === "user") {
24
- return (
25
- <Box marginTop={isFirst ? 0 : 1} flexDirection="row">
26
- <Box flexShrink={0} marginRight={1}>
27
- <Text bold color="green">
28
- {">"}
29
- </Text>
30
- </Box>
31
- <Box flexGrow={1} flexShrink={1}>
32
- <Text>
33
- {typeof msg.content === "string"
34
- ? msg.content
35
- : msg.content.map((c) => (c.type === "text" ? c.text : "")).join("")}
36
- </Text>
37
- </Box>
38
- </Box>
39
- )
40
- }
41
-
42
- if (msg.role === "assistant") {
43
- if (msg.model === "system") {
44
- return (
45
- <Box flexDirection="column" marginTop={0}>
46
- {msg.content.map((c, i) =>
47
- // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
48
- c.type === "text" ? <Text key={i}>{formatMarkdown(c.text)}</Text> : null,
49
- )}
50
- </Box>
51
- )
52
- }
53
-
54
- const isAborted = msg.stop === "aborted"
55
- const hasVisibleContent =
56
- isAborted || msg.content.some((c) => c.type === "text" || c.type === "thinking")
57
- if (!hasVisibleContent) return null
58
-
59
- const termPhrase = isAborted
60
- ? (TERMINATION_PHRASES[msg.ts % TERMINATION_PHRASES.length] ?? "Terminated by user")
61
- : ""
62
-
63
- return (
64
- <Box flexDirection="column" marginTop={0}>
65
- {msg.content.map((c, i) => {
66
- if (c.type === "thinking") {
67
- return (
68
- // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
69
- <Text key={i} dimColor italic>
70
- {c.text}
71
- </Text>
72
- )
73
- }
74
- if (c.type === "text") {
75
- // biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
76
- return <Text key={i}>{formatMarkdown(c.text)}</Text>
77
- }
78
- return null
79
- })}
80
- {isAborted && (
81
- <Box marginTop={0}>
82
- <Text color="red" italic>
83
- ▲ {termPhrase}
84
- </Text>
85
- </Box>
86
- )}
87
- </Box>
88
- )
89
- }
90
-
91
- if (msg.role === "tool_result") {
92
- const args = msg.args ? formatToolArgs(msg.args, true) : ""
93
-
94
- const resText = msg.content
95
- .map((c) => (c.type === "text" ? c.text : ""))
96
- .join("")
97
- .trim()
98
-
99
- const isRead = msg.tool === "read"
100
- const lineCount = isRead ? resText.split("\n").length : 0
101
- const color = TOOL_STYLE[msg.tool] || "white"
102
-
103
- return (
104
- <Box flexDirection="row">
105
- <Text color={msg.isError ? "red" : "green"}>{msg.isError ? "✗" : "✓"} </Text>
106
- <Text color={color} bold>
107
- {msg.tool}
108
- </Text>
109
- {args && <Text> {args}</Text>}
110
- {isRead && !msg.isError && <Text dimColor> ({lineCount} lines)</Text>}
111
- {msg.isError && resText && <Text color="red"> {resText.slice(0, 80)}</Text>}
112
- </Box>
113
- )
114
- }
115
-
116
- return null
117
- }
@@ -1,64 +0,0 @@
1
- import { Box, Text } from "ink"
2
- import type { Model } from "../../types.ts"
3
-
4
- function fmtK(n: number): string {
5
- const k = n / 1000
6
- return k >= 100 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`
7
- }
8
-
9
- function formatTokenUsage(used: number, contextWindow: number): string {
10
- if (used === 0) return `0/${fmtK(contextWindow)}`
11
- const pct = Math.round((used / contextWindow) * 100)
12
- return `${fmtK(used)}/${fmtK(contextWindow)} (${pct}%)`
13
- }
14
-
15
- export function StatusBar({
16
- model,
17
- usage,
18
- busy,
19
- suggestions,
20
- selCmdIdx,
21
- exitConfirmKey,
22
- }: {
23
- model: Model
24
- usage: { in: number; out: number }
25
- busy: boolean
26
- suggestions: Array<{ name: string; desc: string }>
27
- selCmdIdx: number
28
- exitConfirmKey: "C" | null
29
- }) {
30
- return (
31
- <Box justifyContent="space-between">
32
- <Box>
33
- {suggestions.length > 0 ? (
34
- <Box flexDirection="column" marginLeft={2}>
35
- {suggestions.map((s, i) => (
36
- <Box key={s.name}>
37
- <Text
38
- color={i === selCmdIdx ? "black" : "yellow"}
39
- backgroundColor={i === selCmdIdx ? "yellow" : undefined}
40
- >
41
- /{s.name.padEnd(10)}
42
- </Text>
43
- <Text dimColor> {s.desc}</Text>
44
- </Box>
45
- ))}
46
- </Box>
47
- ) : exitConfirmKey === "C" ? (
48
- <Text color="yellow">Press Ctrl+C again to exit</Text>
49
- ) : busy ? (
50
- <Text dimColor>Press Esc to abort or terminate</Text>
51
- ) : (
52
- <Text dimColor>Enter to send · /help for commands</Text>
53
- )}
54
- </Box>
55
-
56
- <Box>
57
- <Text dimColor>{formatTokenUsage(usage.in, model.contextWindow)}</Text>
58
- <Text dimColor> │ </Text>
59
- <Text dimColor>{model.id}</Text>
60
- {busy && <Text dimColor> │ Esc to stop</Text>}
61
- </Box>
62
- </Box>
63
- )
64
- }