novacode 0.5.5 → 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/README.md +16 -10
- package/dist/app-CbJSUNmf.mjs +22 -0
- package/dist/app-CbJSUNmf.mjs.map +1 -0
- package/dist/main.mjs +51 -61
- package/dist/main.mjs.map +1 -1
- package/package.json +3 -5
- package/dist/app-QfQR2FN9.mjs +0 -21
- package/dist/app-QfQR2FN9.mjs.map +0 -1
- package/src/agent/agent.ts +0 -87
- package/src/agent/loop.ts +0 -237
- package/src/agent/prompt.ts +0 -50
- package/src/commands/compact.ts +0 -28
- package/src/commands/index.ts +0 -86
- package/src/commands/models.ts +0 -85
- package/src/commands/providers.ts +0 -213
- package/src/commands/session.ts +0 -40
- package/src/config/providers.ts +0 -207
- package/src/config/store.ts +0 -66
- package/src/main.ts +0 -175
- package/src/onboarding/wizard.ts +0 -54
- package/src/provider/gemini.ts +0 -261
- package/src/provider/openai.ts +0 -215
- package/src/provider/stream.ts +0 -138
- package/src/session/compact.ts +0 -126
- package/src/session/store.ts +0 -229
- package/src/tools/fs.ts +0 -189
- package/src/tools/git.ts +0 -99
- package/src/tools/index.ts +0 -33
- package/src/tools/search.ts +0 -274
- package/src/tools/shell.ts +0 -90
- package/src/tools/web.ts +0 -239
- package/src/tui/app.tsx +0 -364
- package/src/tui/components/liveArea.tsx +0 -73
- package/src/tui/components/message.tsx +0 -113
- package/src/tui/components/statusBar.tsx +0 -58
- package/src/tui/markdown.ts +0 -62
- package/src/tui/prompts.tsx +0 -205
- package/src/types.ts +0 -248
- package/src/update.ts +0 -89
- package/src/util.ts +0 -61
package/src/tools/web.ts
DELETED
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Web tools for searching and fetching internet content.
|
|
3
|
-
* Uses DuckDuckGo HTML for search (no API key needed) and Bun's built-in
|
|
4
|
-
* fetch for reading URLs.
|
|
5
|
-
*/
|
|
6
|
-
import type { Tool, ToolResult } from "../types.ts"
|
|
7
|
-
import { textPart } from "../util.ts"
|
|
8
|
-
|
|
9
|
-
const MAX_CONTENT = 50_000
|
|
10
|
-
|
|
11
|
-
// Minimal HTML → plaintext: strips tags, decodes entities, collapses whitespace
|
|
12
|
-
function htmlToText(html: string): string {
|
|
13
|
-
let text = html
|
|
14
|
-
// Remove HTML comments
|
|
15
|
-
.replace(/<!--[\s\S]*?-->/g, "")
|
|
16
|
-
// Remove script and style blocks entirely
|
|
17
|
-
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
18
|
-
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
19
|
-
// Keep link hrefs visible (supports single, double, or no quotes)
|
|
20
|
-
.replace(/<a[^>]*href=["']?([^"'>\s]*)["']?[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)")
|
|
21
|
-
// Block-level tags → newlines
|
|
22
|
-
.replace(/<\/?(p|div|br|h[1-6]|li|tr|blockquote|pre|hr)[^>]*>/gi, "\n")
|
|
23
|
-
// Remove remaining tags
|
|
24
|
-
.replace(/<[^>]+>/g, "")
|
|
25
|
-
// Decode common HTML entities (both named and numeric, single/double quotes)
|
|
26
|
-
.replace(/&/g, "&")
|
|
27
|
-
.replace(/</g, "<")
|
|
28
|
-
.replace(/>/g, ">")
|
|
29
|
-
.replace(/"/g, '"')
|
|
30
|
-
.replace(/"/g, '"')
|
|
31
|
-
.replace(/"/g, '"')
|
|
32
|
-
.replace(/'/g, "'")
|
|
33
|
-
.replace(/'/g, "'")
|
|
34
|
-
.replace(/'/g, "'")
|
|
35
|
-
.replace(/“/g, '"')
|
|
36
|
-
.replace(/”/g, '"')
|
|
37
|
-
.replace(/‘/g, "'")
|
|
38
|
-
.replace(/’/g, "'")
|
|
39
|
-
.replace(/ /g, " ")
|
|
40
|
-
// Collapse whitespace but keep paragraph breaks
|
|
41
|
-
.replace(/[ \t]+/g, " ")
|
|
42
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
43
|
-
.trim()
|
|
44
|
-
|
|
45
|
-
if (text.length > MAX_CONTENT) {
|
|
46
|
-
text = `${text.slice(0, MAX_CONTENT)}\n…truncated`
|
|
47
|
-
}
|
|
48
|
-
return text
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function webSearchTool(): Tool {
|
|
52
|
-
return {
|
|
53
|
-
def: {
|
|
54
|
-
name: "web_search",
|
|
55
|
-
description:
|
|
56
|
-
"Search the web using DuckDuckGo. Returns up to 10 results with titles, URLs, and snippets. Use this when you need information from the internet.",
|
|
57
|
-
parameters: {
|
|
58
|
-
type: "object",
|
|
59
|
-
properties: {
|
|
60
|
-
query: { type: "string", description: "Search query" },
|
|
61
|
-
},
|
|
62
|
-
required: ["query"],
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
async execute(args, signal): Promise<ToolResult> {
|
|
66
|
-
const query = args.query as string
|
|
67
|
-
if (!query.trim()) {
|
|
68
|
-
return { content: [textPart("Error: empty search query")], isError: true }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`
|
|
73
|
-
const resp = await fetch(url, {
|
|
74
|
-
signal: signal ?? undefined,
|
|
75
|
-
headers: {
|
|
76
|
-
"User-Agent":
|
|
77
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
78
|
-
},
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
if (!resp.ok) {
|
|
82
|
-
return {
|
|
83
|
-
content: [textPart(`Search failed: HTTP ${resp.status}`)],
|
|
84
|
-
isError: true,
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const html = await resp.text()
|
|
89
|
-
|
|
90
|
-
// Split HTML into blocks by result__body to isolate each search result safely
|
|
91
|
-
const blocks: string[] = []
|
|
92
|
-
const containerRegex = /<div[^>]*class="[^"]*result__body[^"]*"[^>]*>/gi
|
|
93
|
-
const indices: number[] = []
|
|
94
|
-
for (const match of html.matchAll(containerRegex)) {
|
|
95
|
-
if (match.index !== undefined) {
|
|
96
|
-
indices.push(match.index)
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (indices.length > 0) {
|
|
101
|
-
for (let i = 0; i < indices.length; i++) {
|
|
102
|
-
const start = indices[i]!
|
|
103
|
-
const end = indices[i + 1] ?? html.length
|
|
104
|
-
blocks.push(html.slice(start, end))
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const results: string[] = []
|
|
109
|
-
const titleRegex = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/i
|
|
110
|
-
const snippetRegex = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/i
|
|
111
|
-
|
|
112
|
-
for (const block of blocks) {
|
|
113
|
-
if (results.length >= 10) break
|
|
114
|
-
|
|
115
|
-
const titleMatch = titleRegex.exec(block)
|
|
116
|
-
if (!titleMatch) continue
|
|
117
|
-
|
|
118
|
-
const rawUrl = titleMatch[1]!
|
|
119
|
-
const title = htmlToText(titleMatch[2]!)
|
|
120
|
-
|
|
121
|
-
const snippetMatch = snippetRegex.exec(block)
|
|
122
|
-
const snippet = snippetMatch ? htmlToText(snippetMatch[1]!) : ""
|
|
123
|
-
|
|
124
|
-
// DuckDuckGo wraps URLs through a redirect; extract the actual URL
|
|
125
|
-
let cleanUrl = rawUrl
|
|
126
|
-
try {
|
|
127
|
-
// Prepend protocol/host if DuckDuckGo returns a relative path or protocol-relative URL
|
|
128
|
-
const urlToParse = rawUrl.startsWith("//")
|
|
129
|
-
? `https:${rawUrl}`
|
|
130
|
-
: rawUrl.startsWith("/")
|
|
131
|
-
? `https://duckduckgo.com${rawUrl}`
|
|
132
|
-
: rawUrl
|
|
133
|
-
const param = new URL(urlToParse).searchParams.get("uddg")
|
|
134
|
-
if (param) cleanUrl = param
|
|
135
|
-
} catch {
|
|
136
|
-
// Not a redirect URL, use as-is
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
results.push(`## ${title}\n${cleanUrl}\n${snippet}`)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
if (results.length === 0) {
|
|
143
|
-
return { content: [textPart("No results found.")], isError: false }
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
content: [textPart(results.join("\n\n"))],
|
|
148
|
-
isError: false,
|
|
149
|
-
}
|
|
150
|
-
} catch (e) {
|
|
151
|
-
const msg = (e as Error).message
|
|
152
|
-
if (msg.includes("abort")) {
|
|
153
|
-
return { content: [textPart("Search aborted.")], isError: true }
|
|
154
|
-
}
|
|
155
|
-
return {
|
|
156
|
-
content: [textPart(`Search error: ${msg}`)],
|
|
157
|
-
isError: true,
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
},
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function webFetchTool(): Tool {
|
|
165
|
-
return {
|
|
166
|
-
def: {
|
|
167
|
-
name: "web_fetch",
|
|
168
|
-
description:
|
|
169
|
-
"Fetch and read the content of a web page. Returns the page text with HTML tags stripped. Useful for reading documentation, articles, or API references.",
|
|
170
|
-
parameters: {
|
|
171
|
-
type: "object",
|
|
172
|
-
properties: {
|
|
173
|
-
url: { type: "string", description: "URL to fetch" },
|
|
174
|
-
},
|
|
175
|
-
required: ["url"],
|
|
176
|
-
},
|
|
177
|
-
},
|
|
178
|
-
async execute(args, signal): Promise<ToolResult> {
|
|
179
|
-
const url = args.url as string
|
|
180
|
-
if (!url.trim()) {
|
|
181
|
-
return { content: [textPart("Error: empty URL")], isError: true }
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
try {
|
|
185
|
-
new URL(url)
|
|
186
|
-
} catch {
|
|
187
|
-
return { content: [textPart(`Error: invalid URL: ${url}`)], isError: true }
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
const resp = await fetch(url, {
|
|
192
|
-
signal: signal ?? undefined,
|
|
193
|
-
headers: {
|
|
194
|
-
"User-Agent":
|
|
195
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
196
|
-
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
197
|
-
},
|
|
198
|
-
redirect: "follow",
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
if (!resp.ok) {
|
|
202
|
-
return {
|
|
203
|
-
content: [textPart(`Fetch failed: HTTP ${resp.status} ${resp.statusText}`)],
|
|
204
|
-
isError: true,
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const contentType = (resp.headers.get("content-type") ?? "").toLowerCase()
|
|
209
|
-
const body = await resp.text()
|
|
210
|
-
|
|
211
|
-
// Sniff HTML: check content-type or if body has HTML markup signature
|
|
212
|
-
const isHtml =
|
|
213
|
-
contentType.includes("text/html") ||
|
|
214
|
-
contentType.includes("application/xhtml+xml") ||
|
|
215
|
-
body.trim().toLowerCase().startsWith("<!doctype html") ||
|
|
216
|
-
body.trim().toLowerCase().startsWith("<html")
|
|
217
|
-
|
|
218
|
-
if (isHtml) {
|
|
219
|
-
const text = htmlToText(body)
|
|
220
|
-
return { content: [textPart(text)], isError: false }
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// For plain text, JSON, etc. return as-is (truncated if needed)
|
|
224
|
-
const truncated =
|
|
225
|
-
body.length > MAX_CONTENT ? `${body.slice(0, MAX_CONTENT)}\n…truncated` : body
|
|
226
|
-
return { content: [textPart(truncated)], isError: false }
|
|
227
|
-
} catch (e) {
|
|
228
|
-
const msg = (e as Error).message
|
|
229
|
-
if (msg.includes("abort")) {
|
|
230
|
-
return { content: [textPart("Fetch aborted.")], isError: true }
|
|
231
|
-
}
|
|
232
|
-
return {
|
|
233
|
-
content: [textPart(`Fetch error: ${msg}`)],
|
|
234
|
-
isError: true,
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
},
|
|
238
|
-
}
|
|
239
|
-
}
|
package/src/tui/app.tsx
DELETED
|
@@ -1,364 +0,0 @@
|
|
|
1
|
-
import chalk from "chalk"
|
|
2
|
-
import { Box, render, Static, Text, 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 type { SessionStore } from "../session/store.ts"
|
|
7
|
-
import type { Msg, Prompts } from "../types.ts"
|
|
8
|
-
import { checkForUpdate, getCurrentVersion } from "../update.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
|
-
|
|
29
|
-
export async function interactive(
|
|
30
|
-
agent: Agent,
|
|
31
|
-
store: SessionStore,
|
|
32
|
-
sessionId: string,
|
|
33
|
-
): Promise<void> {
|
|
34
|
-
process.stdout.write("\x1B[?25l")
|
|
35
|
-
const version = await getCurrentVersion()
|
|
36
|
-
process.stdout.write(`${chalk.cyan.bold("⚡ novacode")} ${chalk.gray(`v${version}`)}\n`)
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const { waitUntilExit } = render(<App agent={agent} store={store} sessionId={sessionId} />)
|
|
40
|
-
await waitUntilExit()
|
|
41
|
-
} finally {
|
|
42
|
-
process.stdout.write("\x1B[?25h")
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function App({
|
|
47
|
-
agent,
|
|
48
|
-
store,
|
|
49
|
-
sessionId,
|
|
50
|
-
}: {
|
|
51
|
-
agent: Agent
|
|
52
|
-
store: SessionStore
|
|
53
|
-
sessionId: string
|
|
54
|
-
}) {
|
|
55
|
-
const [msgs, setMsgs] = useState<Msg[]>(agent.messages)
|
|
56
|
-
const [stream, setStream] = useState("")
|
|
57
|
-
const [thinkStream, setThinkStream] = useState("")
|
|
58
|
-
const [busy, setBusy] = useState(false)
|
|
59
|
-
const [input, setInput] = useState("")
|
|
60
|
-
const [status, setStatus] = useState("")
|
|
61
|
-
const [usage, setUsage] = useState<{ in: number; out: number }>({ in: 0, out: 0 })
|
|
62
|
-
const [selCmdIdx, setSelCmdIdx] = useState(0)
|
|
63
|
-
const [mode, setMode] = useState<PromptMode>({ type: "chat" })
|
|
64
|
-
const resolveRef = useRef<((v: unknown) => void) | null>(null)
|
|
65
|
-
const history = useRef<string[]>([])
|
|
66
|
-
const hIdx = useRef(-1)
|
|
67
|
-
const abortCtrl = useRef<AbortController | null>(null)
|
|
68
|
-
const [updateInfo, setUpdateInfo] = useState<{
|
|
69
|
-
current: string
|
|
70
|
-
latest: string
|
|
71
|
-
} | null>(null)
|
|
72
|
-
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
const check = async () => {
|
|
75
|
-
const info = await checkForUpdate()
|
|
76
|
-
if (info?.hasUpdate) {
|
|
77
|
-
setUpdateInfo({ current: info.current, latest: info.latest })
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
check()
|
|
81
|
-
}, [])
|
|
82
|
-
|
|
83
|
-
const isTypingCmd = input.startsWith("/") && !input.includes(" ")
|
|
84
|
-
const suggestions = isTypingCmd
|
|
85
|
-
? COMMANDS.filter(
|
|
86
|
-
(c) =>
|
|
87
|
-
c.name.startsWith(input.slice(1).toLowerCase()) ||
|
|
88
|
-
c.aliases?.some((a) => a.startsWith(input.slice(1).toLowerCase())),
|
|
89
|
-
)
|
|
90
|
-
: []
|
|
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
|
-
|
|
132
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: reset selection on input change
|
|
133
|
-
useEffect(() => {
|
|
134
|
-
setSelCmdIdx(0)
|
|
135
|
-
}, [input])
|
|
136
|
-
|
|
137
|
-
useInput((ch, key) => {
|
|
138
|
-
if (mode.type !== "chat") return
|
|
139
|
-
|
|
140
|
-
if (key.escape) {
|
|
141
|
-
if (abortCtrl.current) {
|
|
142
|
-
abortCtrl.current.abort()
|
|
143
|
-
abortCtrl.current = null
|
|
144
|
-
}
|
|
145
|
-
return
|
|
146
|
-
}
|
|
147
|
-
if (key.upArrow) {
|
|
148
|
-
if (isTypingCmd && suggestions.length > 0) {
|
|
149
|
-
setSelCmdIdx((prev) => (prev > 0 ? prev - 1 : suggestions.length - 1))
|
|
150
|
-
return
|
|
151
|
-
}
|
|
152
|
-
if (history.current.length > 0) {
|
|
153
|
-
hIdx.current = Math.min(hIdx.current + 1, history.current.length - 1)
|
|
154
|
-
setInput(history.current[hIdx.current] ?? "")
|
|
155
|
-
}
|
|
156
|
-
return
|
|
157
|
-
}
|
|
158
|
-
if (key.downArrow) {
|
|
159
|
-
if (isTypingCmd && suggestions.length > 0) {
|
|
160
|
-
setSelCmdIdx((prev) => (prev < suggestions.length - 1 ? prev + 1 : 0))
|
|
161
|
-
return
|
|
162
|
-
}
|
|
163
|
-
hIdx.current = Math.max(hIdx.current - 1, -1)
|
|
164
|
-
setInput(hIdx.current >= 0 ? (history.current[hIdx.current] ?? "") : "")
|
|
165
|
-
return
|
|
166
|
-
}
|
|
167
|
-
if (key.tab) {
|
|
168
|
-
if (isTypingCmd && suggestions.length > 0) {
|
|
169
|
-
const match = suggestions[selCmdIdx]
|
|
170
|
-
if (match) {
|
|
171
|
-
setInput(`/${match.name} `)
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
return
|
|
175
|
-
}
|
|
176
|
-
if (!key.return) {
|
|
177
|
-
setInput((prev) => {
|
|
178
|
-
if (key.backspace || key.delete) return prev.slice(0, -1)
|
|
179
|
-
return prev + (ch || "")
|
|
180
|
-
})
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (busy) return
|
|
185
|
-
|
|
186
|
-
let line = input.trim()
|
|
187
|
-
if (!line) return
|
|
188
|
-
|
|
189
|
-
if (isTypingCmd && suggestions.length > 0) {
|
|
190
|
-
const match = suggestions[selCmdIdx]
|
|
191
|
-
if (match) {
|
|
192
|
-
line = `/${match.name}`
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
setInput("")
|
|
197
|
-
history.current.unshift(line)
|
|
198
|
-
hIdx.current = -1
|
|
199
|
-
|
|
200
|
-
if (line.startsWith("/")) {
|
|
201
|
-
dispatch(line, agent, store, sessionId, prompts).then((r) => {
|
|
202
|
-
if (r) {
|
|
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(),
|
|
211
|
-
})
|
|
212
|
-
}
|
|
213
|
-
})
|
|
214
|
-
return
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const userMsg: Msg = { role: "user", content: line, ts: Date.now() }
|
|
218
|
-
commitMsg(userMsg)
|
|
219
|
-
|
|
220
|
-
abortCtrl.current = new AbortController()
|
|
221
|
-
const eventStream = agent.prompt(line, abortCtrl.current.signal)
|
|
222
|
-
|
|
223
|
-
runEventLoop(eventStream)
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
async function runEventLoop(eventStream: ReturnType<Agent["prompt"]>) {
|
|
227
|
-
try {
|
|
228
|
-
for await (const ev of eventStream) {
|
|
229
|
-
switch (ev.type) {
|
|
230
|
-
case "start":
|
|
231
|
-
setBusy(true)
|
|
232
|
-
setStream("")
|
|
233
|
-
setThinkStream("")
|
|
234
|
-
setStatus("")
|
|
235
|
-
break
|
|
236
|
-
case "text_delta":
|
|
237
|
-
if (ev.text) setStream((prev) => prev + ev.text)
|
|
238
|
-
break
|
|
239
|
-
case "thinking_delta":
|
|
240
|
-
if (ev.text) setThinkStream((prev) => prev + ev.text)
|
|
241
|
-
break
|
|
242
|
-
case "assistant_msg":
|
|
243
|
-
commitMsg(ev.msg)
|
|
244
|
-
setStream("")
|
|
245
|
-
setThinkStream("")
|
|
246
|
-
|
|
247
|
-
break
|
|
248
|
-
case "tool_call":
|
|
249
|
-
setStatus(chalk.dim(`⏳ ${ev.call.name}…`))
|
|
250
|
-
break
|
|
251
|
-
case "tool_result":
|
|
252
|
-
commitMsg(ev.result)
|
|
253
|
-
setStatus(
|
|
254
|
-
ev.result.isError
|
|
255
|
-
? chalk.red(`✗ ${ev.result.tool}`)
|
|
256
|
-
: chalk.green(`✓ ${ev.result.tool}`),
|
|
257
|
-
)
|
|
258
|
-
break
|
|
259
|
-
case "turn_end":
|
|
260
|
-
setStatus("")
|
|
261
|
-
break
|
|
262
|
-
case "usage":
|
|
263
|
-
if (ev.usage) setUsage(ev.usage)
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
} catch (err) {
|
|
267
|
-
const errMsg: Msg = {
|
|
268
|
-
role: "assistant",
|
|
269
|
-
model: "system",
|
|
270
|
-
provider: "system",
|
|
271
|
-
content: [{ type: "text", text: chalk.red(`Error: ${(err as Error).message}`) }],
|
|
272
|
-
usage: { in: 0, out: 0 },
|
|
273
|
-
stop: "error",
|
|
274
|
-
ts: Date.now(),
|
|
275
|
-
}
|
|
276
|
-
commitMsg(errMsg)
|
|
277
|
-
} finally {
|
|
278
|
-
abortCtrl.current = null
|
|
279
|
-
setBusy(false)
|
|
280
|
-
setStream("")
|
|
281
|
-
setThinkStream("")
|
|
282
|
-
setStatus("")
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
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
|
-
}
|
|
304
|
-
|
|
305
|
-
const visibleMsgs = msgs.filter(hasMeaningfulContent)
|
|
306
|
-
const isLiveActive = !!(stream || thinkStream || busy)
|
|
307
|
-
|
|
308
|
-
return (
|
|
309
|
-
<Box flexDirection="column" paddingX={1}>
|
|
310
|
-
<Static items={visibleMsgs}>
|
|
311
|
-
{(m, i) => <Message key={`${m.ts}-${i}`} msg={m} isFirst={i === 0} />}
|
|
312
|
-
</Static>
|
|
313
|
-
|
|
314
|
-
<LiveArea
|
|
315
|
-
stream={stream}
|
|
316
|
-
thinkStream={thinkStream}
|
|
317
|
-
busy={busy}
|
|
318
|
-
status={status}
|
|
319
|
-
hasMessages={visibleMsgs.length > 0}
|
|
320
|
-
/>
|
|
321
|
-
|
|
322
|
-
<Box flexDirection="column" marginTop={visibleMsgs.length > 0 || isLiveActive ? 1 : 0}>
|
|
323
|
-
{updateInfo && (
|
|
324
|
-
<Box
|
|
325
|
-
borderStyle="round"
|
|
326
|
-
borderColor="yellow"
|
|
327
|
-
paddingX={1}
|
|
328
|
-
marginBottom={1}
|
|
329
|
-
flexDirection="column"
|
|
330
|
-
>
|
|
331
|
-
<Text color="yellow" bold>
|
|
332
|
-
⬆ Update Available (v{updateInfo.current} → v{updateInfo.latest})
|
|
333
|
-
</Text>
|
|
334
|
-
<Text dimColor>
|
|
335
|
-
Run <Text color="cyan">/update</Text> or <Text color="cyan">nova update</Text> to
|
|
336
|
-
upgrade.
|
|
337
|
-
</Text>
|
|
338
|
-
</Box>
|
|
339
|
-
)}
|
|
340
|
-
<Box flexDirection="row">
|
|
341
|
-
<Box flexShrink={0} marginRight={1}>
|
|
342
|
-
<Text bold color="green">
|
|
343
|
-
{">"}
|
|
344
|
-
</Text>
|
|
345
|
-
</Box>
|
|
346
|
-
<Box flexGrow={1} flexShrink={1}>
|
|
347
|
-
<Text>
|
|
348
|
-
{input}
|
|
349
|
-
<Cursor />
|
|
350
|
-
</Text>
|
|
351
|
-
</Box>
|
|
352
|
-
</Box>
|
|
353
|
-
|
|
354
|
-
<StatusBar
|
|
355
|
-
model={agent.model}
|
|
356
|
-
usage={usage}
|
|
357
|
-
busy={busy}
|
|
358
|
-
suggestions={suggestions}
|
|
359
|
-
selCmdIdx={selCmdIdx}
|
|
360
|
-
/>
|
|
361
|
-
</Box>
|
|
362
|
-
</Box>
|
|
363
|
-
)
|
|
364
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
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
|
-
}
|