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/README.md +15 -4
- package/dist/app-QfQR2FN9.mjs +21 -0
- package/dist/app-QfQR2FN9.mjs.map +1 -0
- package/dist/main.mjs +110 -0
- package/dist/main.mjs.map +1 -0
- package/package.json +25 -18
- package/src/agent/loop.ts +27 -8
- package/src/commands/index.ts +4 -3
- package/src/commands/models.ts +8 -9
- package/src/commands/providers.ts +63 -72
- package/src/config/providers.ts +12 -4
- package/src/config/store.ts +6 -7
- package/src/main.ts +7 -6
- package/src/onboarding/wizard.ts +26 -30
- package/src/provider/gemini.ts +12 -5
- package/src/provider/openai.ts +6 -9
- package/src/provider/stream.ts +64 -3
- package/src/session/compact.ts +1 -1
- package/src/session/store.ts +59 -36
- package/src/tools/fs.ts +10 -16
- package/src/tools/git.ts +26 -9
- package/src/tools/search.ts +33 -11
- package/src/tools/shell.ts +26 -25
- package/src/tui/app.tsx +141 -324
- package/src/tui/components/liveArea.tsx +73 -0
- package/src/tui/components/message.tsx +113 -0
- package/src/tui/components/statusBar.tsx +58 -0
- package/src/tui/prompts.tsx +205 -0
- package/src/types.ts +15 -0
- package/src/update.ts +44 -23
- package/src/util.ts +1 -28
- package/src/provider/registry.ts +0 -62
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Box, Text } from "ink"
|
|
2
|
+
import type { Msg } from "../../types.ts"
|
|
3
|
+
import { formatToolArgs } from "../../util.ts"
|
|
4
|
+
import { formatMarkdown } from "../markdown.ts"
|
|
5
|
+
|
|
6
|
+
const TOOL_STYLE: Record<string, string> = {
|
|
7
|
+
read: "blue",
|
|
8
|
+
write: "magenta",
|
|
9
|
+
edit: "yellow",
|
|
10
|
+
bash: "cyan",
|
|
11
|
+
glob: "green",
|
|
12
|
+
find: "green",
|
|
13
|
+
grep: "green",
|
|
14
|
+
tree: "green",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function hasMeaningfulContent(msg: Msg): boolean {
|
|
18
|
+
if (msg.role === "user") return true
|
|
19
|
+
if (msg.role === "tool_result") return true
|
|
20
|
+
if (msg.role === "assistant") {
|
|
21
|
+
if (msg.model === "system") return true
|
|
22
|
+
return msg.content.some((c) => {
|
|
23
|
+
if (c.type === "thinking") return c.text.trim().length > 0
|
|
24
|
+
if (c.type === "text") return c.text.trim().length > 0
|
|
25
|
+
return false
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function Message({ msg, isFirst }: { msg: Msg; isFirst: boolean }) {
|
|
32
|
+
if (msg.role === "user") {
|
|
33
|
+
return (
|
|
34
|
+
<Box marginTop={isFirst ? 0 : 1} flexDirection="row">
|
|
35
|
+
<Box flexShrink={0} marginRight={1}>
|
|
36
|
+
<Text bold color="green">
|
|
37
|
+
{">"}
|
|
38
|
+
</Text>
|
|
39
|
+
</Box>
|
|
40
|
+
<Box flexGrow={1} flexShrink={1}>
|
|
41
|
+
<Text>
|
|
42
|
+
{typeof msg.content === "string"
|
|
43
|
+
? msg.content
|
|
44
|
+
: msg.content.map((c) => (c.type === "text" ? c.text : "")).join("")}
|
|
45
|
+
</Text>
|
|
46
|
+
</Box>
|
|
47
|
+
</Box>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (msg.role === "assistant") {
|
|
52
|
+
if (msg.model === "system") {
|
|
53
|
+
return (
|
|
54
|
+
<Box flexDirection="column" marginTop={0}>
|
|
55
|
+
{msg.content.map((c, i) =>
|
|
56
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
57
|
+
c.type === "text" ? <Text key={i}>{formatMarkdown(c.text)}</Text> : null,
|
|
58
|
+
)}
|
|
59
|
+
</Box>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const hasVisibleContent = msg.content.some((c) => c.type === "text" || c.type === "thinking")
|
|
64
|
+
if (!hasVisibleContent) return null
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Box flexDirection="column" marginTop={0}>
|
|
68
|
+
{msg.content.map((c, i) => {
|
|
69
|
+
if (c.type === "thinking") {
|
|
70
|
+
return (
|
|
71
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
72
|
+
<Text key={i} dimColor italic>
|
|
73
|
+
{c.text}
|
|
74
|
+
</Text>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
if (c.type === "text") {
|
|
78
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: stable turn content
|
|
79
|
+
return <Text key={i}>{formatMarkdown(c.text)}</Text>
|
|
80
|
+
}
|
|
81
|
+
return null
|
|
82
|
+
})}
|
|
83
|
+
</Box>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (msg.role === "tool_result") {
|
|
88
|
+
const args = msg.args ? formatToolArgs(msg.args, true) : ""
|
|
89
|
+
|
|
90
|
+
const resText = msg.content
|
|
91
|
+
.map((c) => (c.type === "text" ? c.text : ""))
|
|
92
|
+
.join("")
|
|
93
|
+
.trim()
|
|
94
|
+
|
|
95
|
+
const isRead = msg.tool === "read"
|
|
96
|
+
const lineCount = isRead ? resText.split("\n").length : 0
|
|
97
|
+
const color = TOOL_STYLE[msg.tool] || "white"
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Box flexDirection="row">
|
|
101
|
+
<Text color={msg.isError ? "red" : "green"}>{msg.isError ? "✗" : "✓"} </Text>
|
|
102
|
+
<Text color={color} bold>
|
|
103
|
+
{msg.tool}
|
|
104
|
+
</Text>
|
|
105
|
+
{args && <Text> {args}</Text>}
|
|
106
|
+
{isRead && !msg.isError && <Text dimColor> ({lineCount} lines)</Text>}
|
|
107
|
+
{msg.isError && resText && <Text color="red"> {resText.slice(0, 80)}</Text>}
|
|
108
|
+
</Box>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
}: {
|
|
22
|
+
model: Model
|
|
23
|
+
usage: { in: number; out: number }
|
|
24
|
+
busy: boolean
|
|
25
|
+
suggestions: Array<{ name: string; desc: string }>
|
|
26
|
+
selCmdIdx: number
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<Box justifyContent="space-between">
|
|
30
|
+
<Box>
|
|
31
|
+
{suggestions.length > 0 ? (
|
|
32
|
+
<Box flexDirection="column" marginLeft={2}>
|
|
33
|
+
{suggestions.map((s, i) => (
|
|
34
|
+
<Box key={s.name}>
|
|
35
|
+
<Text
|
|
36
|
+
color={i === selCmdIdx ? "black" : "yellow"}
|
|
37
|
+
backgroundColor={i === selCmdIdx ? "yellow" : undefined}
|
|
38
|
+
>
|
|
39
|
+
/{s.name.padEnd(10)}
|
|
40
|
+
</Text>
|
|
41
|
+
<Text dimColor> {s.desc}</Text>
|
|
42
|
+
</Box>
|
|
43
|
+
))}
|
|
44
|
+
</Box>
|
|
45
|
+
) : (
|
|
46
|
+
<Text dimColor>Enter to send · /help for commands</Text>
|
|
47
|
+
)}
|
|
48
|
+
</Box>
|
|
49
|
+
|
|
50
|
+
<Box>
|
|
51
|
+
<Text dimColor>{formatTokenUsage(usage.in, model.contextWindow)}</Text>
|
|
52
|
+
<Text dimColor> │ </Text>
|
|
53
|
+
<Text dimColor>{model.id}</Text>
|
|
54
|
+
{busy && <Text dimColor> │ Esc to stop</Text>}
|
|
55
|
+
</Box>
|
|
56
|
+
</Box>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -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 {
|
|
2
|
-
import {
|
|
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
|
|
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 =
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
} else {
|
|
82
|
+
} catch (e) {
|
|
62
83
|
if (!silent) {
|
|
63
|
-
console.error(`Update failed (
|
|
84
|
+
console.error(`Update failed: ${(e as Error).message}`)
|
|
64
85
|
process.exit(1)
|
|
65
86
|
}
|
|
66
87
|
return false
|
package/src/util.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isAbsolute, relative } from "node:path"
|
|
2
2
|
import chalk from "chalk"
|
|
3
|
-
import type {
|
|
3
|
+
import type { Msg, TextPart } from "./types.ts"
|
|
4
4
|
|
|
5
5
|
// ~4 chars per token for English/code. Close enough for capacity warnings.
|
|
6
6
|
export function estimateTokens(messages: Msg[]): number {
|
|
@@ -21,33 +21,6 @@ export function textPart(s: string): TextPart {
|
|
|
21
21
|
return { type: "text", text: s }
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function consolidate(parts: ContentPart[]): ContentPart[] {
|
|
25
|
-
if (parts.length === 0) return parts
|
|
26
|
-
const out: ContentPart[] = []
|
|
27
|
-
for (const p of parts) {
|
|
28
|
-
const last = out[out.length - 1]
|
|
29
|
-
if (last?.type === "text" && p.type === "text") {
|
|
30
|
-
last.text += p.text
|
|
31
|
-
} else if (last?.type === "thinking" && p.type === "thinking") {
|
|
32
|
-
last.text += p.text
|
|
33
|
-
} else {
|
|
34
|
-
out.push({ ...p })
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const hasTool = out.some((p) => p.type === "tool_call")
|
|
39
|
-
if (hasTool) {
|
|
40
|
-
return out.filter((p) => {
|
|
41
|
-
if (p.type === "text") {
|
|
42
|
-
return p.text.trim().length > 0
|
|
43
|
-
}
|
|
44
|
-
return true
|
|
45
|
-
})
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return out
|
|
49
|
-
}
|
|
50
|
-
|
|
51
24
|
export function getRelativeIfInside(cwd: string, filePath: string): string {
|
|
52
25
|
if (filePath === cwd || filePath.startsWith(`${cwd}/`)) {
|
|
53
26
|
return relative(cwd, filePath) || "."
|
package/src/provider/registry.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Registry for different model providers (OpenAI, Anthropic, etc.).
|
|
3
|
-
* Provides a unified streaming interface for the agent loop.
|
|
4
|
-
*/
|
|
5
|
-
import type { AgentEvent, ApiFormat, AssistantResult, StreamFn, StreamOpts } from "../types.ts"
|
|
6
|
-
import { EventStream } from "./stream.ts"
|
|
7
|
-
|
|
8
|
-
export type { AssistantResult, StreamEvent, StreamFn, StreamOpts } from "../types.ts"
|
|
9
|
-
|
|
10
|
-
// Internal map of registered provider implementations
|
|
11
|
-
const registry = new Map<ApiFormat, StreamFn>()
|
|
12
|
-
|
|
13
|
-
export function register(api: ApiFormat, fn: StreamFn): void {
|
|
14
|
-
registry.set(api, fn)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// Bridges provider-specific StreamEvents into AgentEvents so the loop and TUI deal with one type.
|
|
18
|
-
export function stream(opts: StreamOpts): EventStream<AgentEvent, AssistantResult> {
|
|
19
|
-
const fn = registry.get(opts.api)
|
|
20
|
-
if (!fn) throw new Error(`No provider registered for API format: ${opts.api}`)
|
|
21
|
-
|
|
22
|
-
// Bridge layer: converts provider-specific StreamEvents into the agent's
|
|
23
|
-
// AgentEvent shape, so the loop and TUI only deal with one event type.
|
|
24
|
-
const providerStream = fn(opts)
|
|
25
|
-
const agentStream = new EventStream<AgentEvent, AssistantResult>()
|
|
26
|
-
|
|
27
|
-
;(async () => {
|
|
28
|
-
for await (const event of providerStream) {
|
|
29
|
-
if (event.type === "text_delta") {
|
|
30
|
-
agentStream.push({ type: "text_delta", text: event.text ?? "" })
|
|
31
|
-
} else if (event.type === "thinking_delta") {
|
|
32
|
-
agentStream.push({ type: "thinking_delta", text: event.text ?? "" })
|
|
33
|
-
} else if (event.type === "tool_call" && event.call) {
|
|
34
|
-
agentStream.push({
|
|
35
|
-
type: "tool_call",
|
|
36
|
-
call: {
|
|
37
|
-
type: "tool_call",
|
|
38
|
-
id: event.call.id,
|
|
39
|
-
name: event.call.name,
|
|
40
|
-
args: event.call.args,
|
|
41
|
-
},
|
|
42
|
-
})
|
|
43
|
-
} else if (event.type === "usage" && event.usage) {
|
|
44
|
-
agentStream.push({ type: "usage", usage: event.usage })
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const res = providerStream.result
|
|
49
|
-
if (res) {
|
|
50
|
-
agentStream.finish(res)
|
|
51
|
-
} else {
|
|
52
|
-
// Fallback for unexpected closure
|
|
53
|
-
agentStream.finish({ content: [], usage: { in: 0, out: 0 }, stop: "stop" })
|
|
54
|
-
}
|
|
55
|
-
})()
|
|
56
|
-
|
|
57
|
-
return agentStream
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function getRegisteredApis(): ApiFormat[] {
|
|
61
|
-
return [...registry.keys()]
|
|
62
|
-
}
|