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.
@@ -1,33 +1,33 @@
1
- import * as clack from "@clack/prompts"
2
1
  import chalk from "chalk"
3
2
  import type { Agent } from "../agent/agent.ts"
4
3
  import { getProvider, MODELS, PROVIDERS } from "../config/providers.ts"
5
4
  import { loadAuth, loadConfig, saveAuth, saveConfig } from "../config/store.ts"
5
+ import type { Prompts } from "../types.ts"
6
+
7
+ export async function handleProviders(agent: Agent, prompts?: Prompts): Promise<string> {
8
+ if (!prompts) return chalk.red("Prompts not available in this context")
6
9
 
7
- export async function handleProviders(agent: Agent): Promise<string> {
8
10
  const config = await loadConfig()
9
11
  const auth = await loadAuth()
10
-
11
12
  const configured = PROVIDERS.filter((p) => !!auth.apiKeys[p.id])
12
13
 
13
- console.log(chalk.bold("\n ⚙ Configured Providers:\n"))
14
- if (configured.length === 0) {
15
- console.log(chalk.dim(" No providers configured. Use 'Add Provider' below.\n"))
16
- } else {
17
- for (const p of configured) {
18
- const isDefault = p.id === config.provider
19
- const active = isDefault ? chalk.green(" ●") : ""
20
- const key = chalk.green("✅")
21
- const currentModel = isDefault
22
- ? config.model
23
- : (MODELS.find((m) => m.provider === p.id)?.id ?? "")
24
- console.log(` ${key} ${p.name.padEnd(24)} ${currentModel}${active}`)
25
- }
26
- console.log("") // Spacer
27
- }
28
-
29
- const act = await clack.select({
14
+ const headerLines =
15
+ configured.length === 0
16
+ ? chalk.dim("No providers configured. Use 'Add Provider' below.")
17
+ : configured
18
+ .map((p) => {
19
+ const isDefault = p.id === config.provider
20
+ const active = isDefault ? chalk.green(" ●") : ""
21
+ const currentModel = isDefault
22
+ ? config.model
23
+ : (MODELS.find((m) => m.provider === p.id)?.id ?? "")
24
+ return ` ✅ ${p.name.padEnd(24)} ${currentModel}${active}`
25
+ })
26
+ .join("\n")
27
+
28
+ const act = await prompts.select({
30
29
  message: "Action",
30
+ header: headerLines,
31
31
  options: [
32
32
  { value: "add", label: "Add Provider" },
33
33
  { value: "update", label: "Update API Key" },
@@ -36,16 +36,16 @@ export async function handleProviders(agent: Agent): Promise<string> {
36
36
  { value: "back", label: "Back" },
37
37
  ],
38
38
  })
39
- if (clack.isCancel(act) || act === "back") return ""
39
+ if (!act || act === "back") return ""
40
40
 
41
- if (act === "add") return addProvider(agent)
42
- if (act === "update") return updateKey(agent)
43
- if (act === "remove") return removeKey(agent)
44
- if (act === "default") return setDefault(agent)
41
+ if (act === "add") return addProvider(agent, prompts)
42
+ if (act === "update") return updateKey(agent, prompts)
43
+ if (act === "remove") return removeKey(agent, prompts)
44
+ if (act === "default") return setDefault(agent, prompts)
45
45
  return ""
46
46
  }
47
47
 
48
- async function addProvider(agent: Agent): Promise<string> {
48
+ async function addProvider(agent: Agent, prompts: Prompts): Promise<string> {
49
49
  const auth = await loadAuth()
50
50
  const config = await loadConfig()
51
51
 
@@ -54,25 +54,24 @@ async function addProvider(agent: Agent): Promise<string> {
54
54
  return chalk.yellow("All providers already have API keys configured.")
55
55
  }
56
56
 
57
- const pick = await clack.select({
57
+ const pick = await prompts.select({
58
58
  message: "Add Provider",
59
59
  options: available.map((p) => ({ value: p.id, label: p.name })),
60
60
  })
61
- if (clack.isCancel(pick)) return ""
61
+ if (!pick) return ""
62
62
 
63
- const pDef = getProvider(pick as string)
63
+ const pDef = getProvider(pick)
64
64
  if (!pDef) return chalk.red("Error: Provider not found")
65
65
 
66
- const key = await clack.password({
66
+ const key = await prompts.password({
67
67
  message: `${pDef.name} API Key`,
68
68
  validate: (v) => (!v || v.length < 8 ? "Enter a valid key" : undefined),
69
69
  })
70
- if (clack.isCancel(key)) return ""
70
+ if (!key) return ""
71
71
 
72
- auth.apiKeys[pDef.id] = key as string
72
+ auth.apiKeys[pDef.id] = key
73
73
  await saveAuth(auth)
74
74
 
75
- // Set as active if no provider is currently set
76
75
  if (!config.provider) {
77
76
  config.provider = pDef.id
78
77
  const mDef = MODELS.find((m) => m.provider === pDef.id)
@@ -83,7 +82,7 @@ async function addProvider(agent: Agent): Promise<string> {
83
82
  agent.updateConfig({
84
83
  api: pDef.api,
85
84
  model: MODELS.find((m) => m.id === config.model)!,
86
- apiKey: key as string,
85
+ apiKey: key,
87
86
  baseUrl: pDef.baseUrl,
88
87
  })
89
88
  }
@@ -91,7 +90,7 @@ async function addProvider(agent: Agent): Promise<string> {
91
90
  return chalk.green(`✓ ${pDef.name} configured`)
92
91
  }
93
92
 
94
- async function updateKey(agent: Agent): Promise<string> {
93
+ async function updateKey(agent: Agent, prompts: Prompts): Promise<string> {
95
94
  const auth = await loadAuth()
96
95
 
97
96
  const configured = PROVIDERS.filter((p) => !!auth.apiKeys[p.id])
@@ -99,22 +98,21 @@ async function updateKey(agent: Agent): Promise<string> {
99
98
  return chalk.yellow("No providers configured. Use 'Add Provider' first.")
100
99
  }
101
100
 
102
- const pick = await clack.select({
101
+ const pick = await prompts.select({
103
102
  message: "Update API Key",
104
103
  options: configured.map((p) => ({ value: p.id, label: p.name })),
105
104
  })
106
- if (clack.isCancel(pick)) return ""
105
+ if (!pick) return ""
107
106
 
108
- const pDef = getProvider(pick as string)
107
+ const pDef = getProvider(pick)
109
108
  if (!pDef) return chalk.red("Error: Provider not found")
110
109
 
111
- const key = await clack.password({ message: `New key for ${pDef.name}` })
112
- if (clack.isCancel(key)) return ""
110
+ const key = await prompts.password({ message: `New key for ${pDef.name}` })
111
+ if (!key) return ""
113
112
 
114
- auth.apiKeys[pDef.id] = key as string
113
+ auth.apiKeys[pDef.id] = key
115
114
  await saveAuth(auth)
116
115
 
117
- // If this is the active provider, update the agent's key
118
116
  const config = await loadConfig()
119
117
  if (config.provider === pDef.id) {
120
118
  const currentModel = MODELS.find((m) => m.id === config.model && m.provider === config.provider)
@@ -122,7 +120,7 @@ async function updateKey(agent: Agent): Promise<string> {
122
120
  agent.updateConfig({
123
121
  api: pDef.api,
124
122
  model: currentModel,
125
- apiKey: key as string,
123
+ apiKey: key,
126
124
  baseUrl: pDef.baseUrl,
127
125
  })
128
126
  }
@@ -131,7 +129,7 @@ async function updateKey(agent: Agent): Promise<string> {
131
129
  return chalk.green("✓ Key updated")
132
130
  }
133
131
 
134
- async function removeKey(agent: Agent): Promise<string> {
132
+ async function removeKey(agent: Agent, prompts: Prompts): Promise<string> {
135
133
  const auth = await loadAuth()
136
134
  const config = await loadConfig()
137
135
 
@@ -140,26 +138,23 @@ async function removeKey(agent: Agent): Promise<string> {
140
138
  return chalk.yellow("No configured providers to remove.")
141
139
  }
142
140
 
143
- const pick = await clack.select({
141
+ const pick = await prompts.select({
144
142
  message: "Remove API Key",
145
143
  options: configured.map((p) => ({ value: p.id, label: p.name })),
146
144
  })
147
- if (clack.isCancel(pick)) return ""
145
+ if (!pick) return ""
148
146
 
149
- const pId = pick as string
150
- const confirm = await clack.confirm({
151
- message: `Are you sure you want to remove the API key for ${pId}?`,
147
+ const confirm = await prompts.confirm({
148
+ message: `Are you sure you want to remove the API key for ${pick}?`,
152
149
  })
153
- if (clack.isCancel(confirm) || !confirm) return ""
150
+ if (!confirm) return ""
154
151
 
155
- delete auth.apiKeys[pId]
152
+ delete auth.apiKeys[pick]
156
153
  await saveAuth(auth)
157
154
 
158
- // If removing the active provider's key
159
- if (config.provider === pId) {
155
+ if (config.provider === pick) {
160
156
  config.provider = ""
161
157
  config.model = ""
162
- // Try to find another configured provider
163
158
  const next = Object.keys(auth.apiKeys)[0]
164
159
  if (next) {
165
160
  const pDef = getProvider(next)
@@ -178,43 +173,39 @@ async function removeKey(agent: Agent): Promise<string> {
178
173
  await saveConfig(config)
179
174
  }
180
175
 
181
- return chalk.green(`✓ Removed API key for ${pId}`)
176
+ return chalk.green(`✓ Removed API key for ${pick}`)
182
177
  }
183
178
 
184
- async function setDefault(agent: Agent): Promise<string> {
179
+ async function setDefault(agent: Agent, prompts: Prompts): Promise<string> {
185
180
  const config = await loadConfig()
186
181
  const auth = await loadAuth()
187
182
 
188
- const pick = await clack.select({
183
+ const pick = await prompts.select({
189
184
  message: "Default Provider",
190
- options: PROVIDERS.map((p) => {
191
- const hasKey = !!auth.apiKeys[p.id]
192
- return {
193
- value: p.id,
194
- label: `${hasKey ? "✅" : "❌"} ${p.name}`,
195
- }
196
- }),
185
+ options: PROVIDERS.map((p) => ({
186
+ value: p.id,
187
+ label: `${auth.apiKeys[p.id] ? "✅" : "❌"} ${p.name}`,
188
+ })),
197
189
  })
198
- if (clack.isCancel(pick)) return ""
190
+ if (!pick) return ""
199
191
 
200
- const pId = pick as string
201
- if (!auth.apiKeys[pId]) {
202
- return chalk.yellow(`No API key for ${pId}. Please set one first.`)
192
+ if (!auth.apiKeys[pick]) {
193
+ return chalk.yellow(`No API key for ${pick}. Please set one first.`)
203
194
  }
204
195
 
205
- const pDef = getProvider(pId)
206
- const mDef = MODELS.find((m) => m.provider === pId)
196
+ const pDef = getProvider(pick)
197
+ const mDef = MODELS.find((m) => m.provider === pick)
207
198
 
208
199
  if (!pDef || !mDef) return chalk.red("Error: Provider or model not found")
209
200
 
210
- config.provider = pId
201
+ config.provider = pick
211
202
  config.model = mDef.id
212
203
  await saveConfig(config)
213
204
 
214
205
  agent.updateConfig({
215
206
  api: pDef.api,
216
207
  model: mDef,
217
- apiKey: auth.apiKeys[pId],
208
+ apiKey: auth.apiKeys[pick],
218
209
  baseUrl: pDef.baseUrl,
219
210
  })
220
211
 
@@ -82,6 +82,14 @@ export const MODELS: Model[] = [
82
82
  supportsThinking: false,
83
83
  },
84
84
  // Gemini
85
+ {
86
+ id: "gemini-3.5-flash",
87
+ name: "Gemini 3.5 Flash",
88
+ provider: "gemini",
89
+ contextWindow: 1_000_000,
90
+ maxTokens: 65_536,
91
+ supportsThinking: true,
92
+ },
85
93
  {
86
94
  id: "gemini-3.1-pro-preview",
87
95
  name: "Gemini 3.1 Pro Preview",
@@ -128,7 +136,7 @@ export const MODELS: Model[] = [
128
136
  provider: "gemini",
129
137
  contextWindow: 2_000_000,
130
138
  maxTokens: 65_536,
131
- supportsThinking: true,
139
+ supportsThinking: false,
132
140
  },
133
141
  {
134
142
  id: "gemini-2.5-flash",
@@ -136,7 +144,7 @@ export const MODELS: Model[] = [
136
144
  provider: "gemini",
137
145
  contextWindow: 1_000_000,
138
146
  maxTokens: 65_536,
139
- supportsThinking: true,
147
+ supportsThinking: false,
140
148
  },
141
149
  {
142
150
  id: "gemini-2.5-flash-lite",
@@ -144,7 +152,7 @@ export const MODELS: Model[] = [
144
152
  provider: "gemini",
145
153
  contextWindow: 1_000_000,
146
154
  maxTokens: 65_536,
147
- supportsThinking: true,
155
+ supportsThinking: false,
148
156
  },
149
157
  {
150
158
  id: "gemini-2.5-computer-use-preview-10-2025",
@@ -152,7 +160,7 @@ export const MODELS: Model[] = [
152
160
  provider: "gemini",
153
161
  contextWindow: 1_000_000,
154
162
  maxTokens: 65_536,
155
- supportsThinking: true,
163
+ supportsThinking: false,
156
164
  },
157
165
  // DeepSeek
158
166
  {
@@ -1,3 +1,4 @@
1
+ import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises"
1
2
  import { join } from "node:path"
2
3
  import type { NovaAuth, NovaConfig } from "../types.ts"
3
4
 
@@ -16,7 +17,7 @@ const defaultAuth: NovaAuth = {
16
17
 
17
18
  export async function configExists(): Promise<boolean> {
18
19
  try {
19
- await Bun.file(CONFIG_PATH()).stat()
20
+ await stat(CONFIG_PATH())
20
21
  return true
21
22
  } catch {
22
23
  return false
@@ -25,7 +26,7 @@ export async function configExists(): Promise<boolean> {
25
26
 
26
27
  export async function loadConfig(): Promise<NovaConfig> {
27
28
  try {
28
- const raw = await Bun.file(CONFIG_PATH()).json()
29
+ const raw = JSON.parse(await readFile(CONFIG_PATH(), "utf-8"))
29
30
  return { ...defaultConfig, ...raw }
30
31
  } catch {
31
32
  return { ...defaultConfig }
@@ -34,7 +35,7 @@ export async function loadConfig(): Promise<NovaConfig> {
34
35
 
35
36
  export async function loadAuth(): Promise<NovaAuth> {
36
37
  try {
37
- const raw = await Bun.file(AUTH_PATH()).json()
38
+ const raw = JSON.parse(await readFile(AUTH_PATH(), "utf-8"))
38
39
  return { ...defaultAuth, ...raw }
39
40
  } catch {
40
41
  return { ...defaultAuth }
@@ -42,20 +43,18 @@ export async function loadAuth(): Promise<NovaAuth> {
42
43
  }
43
44
 
44
45
  async function ensureDir(): Promise<void> {
45
- const { mkdir } = await import("node:fs/promises")
46
46
  await mkdir(NOVA_DIR(), { recursive: true })
47
47
  }
48
48
 
49
49
  export async function saveConfig(config: NovaConfig): Promise<void> {
50
50
  await ensureDir()
51
- await Bun.write(CONFIG_PATH(), JSON.stringify(config, null, 2))
51
+ await writeFile(CONFIG_PATH(), JSON.stringify(config, null, 2))
52
52
  }
53
53
 
54
54
  export async function saveAuth(auth: NovaAuth): Promise<void> {
55
55
  await ensureDir()
56
- await Bun.write(AUTH_PATH(), JSON.stringify(auth, null, 2))
56
+ await writeFile(AUTH_PATH(), JSON.stringify(auth, null, 2))
57
57
  try {
58
- const { chmod } = await import("node:fs/promises")
59
58
  await chmod(AUTH_PATH(), 0o600)
60
59
  } catch {
61
60
  // chmod may fail on some platforms, non-fatal
package/src/main.ts CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util"
3
3
  /**
4
4
  * Entry point for the nova CLI.
@@ -15,10 +15,6 @@ import { getSessionStore } from "./session/store.ts"
15
15
  import { getAllTools } from "./tools/index.ts"
16
16
  import { getCurrentVersion, runUpdate } from "./update.ts"
17
17
 
18
- // Ensure providers are registered
19
- import "./provider/openai.ts"
20
- import "./provider/gemini.ts"
21
-
22
18
  function parseCli() {
23
19
  const { values, positionals } = parseArgs({
24
20
  options: {
@@ -168,7 +164,12 @@ Options:
168
164
  await interactive(agent, store, sessionId)
169
165
  }
170
166
 
167
+ process.on("unhandledRejection", (reason) => {
168
+ console.error("Unhandled rejection:", reason)
169
+ process.exit(1)
170
+ })
171
+
171
172
  main().catch((e) => {
172
- console.error("Fatal:", e.message)
173
+ console.error("Fatal:", e)
173
174
  process.exit(1)
174
175
  })
@@ -1,58 +1,54 @@
1
- import * as clack from "@clack/prompts"
1
+ import chalk from "chalk"
2
2
  import { getModelsForProvider, getProvider, PROVIDERS } from "../config/providers.ts"
3
3
  import { saveAuth, saveConfig } from "../config/store.ts"
4
+ import { standalonePassword, standaloneSelect } from "../tui/prompts.tsx"
4
5
  import type { NovaConfig } from "../types.ts"
5
6
 
6
7
  export async function runOnboarding(): Promise<NovaConfig> {
7
- clack.intro("⚡ Nova — your coding companion")
8
-
9
- const providerId = await clack.select({
10
- message: "Pick a provider",
11
- options: PROVIDERS.map((p) => ({ value: p.id, label: p.name })),
12
- })
13
-
14
- if (clack.isCancel(providerId)) {
15
- clack.cancel("Cancelled")
8
+ console.log(chalk.bold.cyan("\n⚡ Nova — your coding companion\n"))
9
+
10
+ const providerId = await standaloneSelect(
11
+ "Pick a provider",
12
+ PROVIDERS.map((p) => ({ value: p.id, label: p.name })),
13
+ )
14
+ if (!providerId) {
15
+ console.log(chalk.dim("Cancelled"))
16
16
  process.exit(0)
17
17
  }
18
18
 
19
- const provider = getProvider(providerId as string)
19
+ const provider = getProvider(providerId)
20
20
  if (!provider) {
21
- clack.cancel("Unknown provider")
21
+ console.log(chalk.red("Unknown provider"))
22
22
  process.exit(1)
23
23
  }
24
24
 
25
- const apiKey = await clack.password({
26
- message: `Enter ${provider.name} API key`,
27
- })
28
-
29
- if (clack.isCancel(apiKey)) {
30
- clack.cancel("Cancelled")
25
+ const apiKey = await standalonePassword(`Enter ${provider.name} API key`)
26
+ if (!apiKey) {
27
+ console.log(chalk.dim("Cancelled"))
31
28
  process.exit(0)
32
29
  }
33
30
 
34
- const models = getModelsForProvider(providerId as string)
35
- const modelId = await clack.select({
36
- message: "Pick a default model",
37
- options: models.map((m) => ({
31
+ const models = getModelsForProvider(providerId)
32
+ const modelId = await standaloneSelect(
33
+ "Pick a default model",
34
+ models.map((m) => ({
38
35
  value: m.id,
39
36
  label: `${m.name} (${(m.contextWindow / 1000).toFixed(0)}k ctx)`,
40
37
  })),
41
- })
42
-
43
- if (clack.isCancel(modelId)) {
44
- clack.cancel("Cancelled")
38
+ )
39
+ if (!modelId) {
40
+ console.log(chalk.dim("Cancelled"))
45
41
  process.exit(0)
46
42
  }
47
43
 
48
44
  const config: NovaConfig = {
49
- provider: providerId as string,
50
- model: modelId as string,
45
+ provider: providerId,
46
+ model: modelId,
51
47
  }
52
48
 
53
49
  await saveConfig(config)
54
- await saveAuth({ apiKeys: { [providerId as string]: apiKey as string } })
50
+ await saveAuth({ apiKeys: { [providerId]: apiKey } })
55
51
 
56
- clack.note("Ready. Type your prompt or /help for commands")
52
+ console.log(chalk.green("\n✓ Ready. Type your prompt or /help for commands\n"))
57
53
  return config
58
54
  }
@@ -9,7 +9,6 @@ import type {
9
9
  ToolDef,
10
10
  Usage,
11
11
  } from "../types.ts"
12
- import { register } from "./registry.ts"
13
12
  import { EventStream } from "./stream.ts"
14
13
 
15
14
  interface GeminiPart {
@@ -200,10 +199,20 @@ export const streamGemini: StreamFn = (
200
199
  if (part.thought === true || typeof part.thought === "string") {
201
200
  const thoughtText = typeof part.thought === "string" ? part.thought : part.text
202
201
  es.push({ type: "thinking_delta", text: thoughtText })
203
- content.push({ type: "thinking", text: thoughtText, signature: sig })
202
+ const last = content[content.length - 1]
203
+ if (last?.type === "thinking") {
204
+ last.text += thoughtText
205
+ } else {
206
+ content.push({ type: "thinking", text: thoughtText, signature: sig })
207
+ }
204
208
  } else {
205
209
  es.push({ type: "text_delta", text: part.text })
206
- content.push({ type: "text", text: part.text, signature: sig })
210
+ const last = content[content.length - 1]
211
+ if (last?.type === "text") {
212
+ last.text += part.text
213
+ } else {
214
+ content.push({ type: "text", text: part.text, signature: sig })
215
+ }
207
216
  }
208
217
  }
209
218
 
@@ -250,5 +259,3 @@ export const streamGemini: StreamFn = (
250
259
 
251
260
  return es
252
261
  }
253
-
254
- register("gemini", streamGemini)
@@ -1,6 +1,5 @@
1
1
  import type {
2
2
  AssistantResult,
3
- ContentPart,
4
3
  Msg,
5
4
  StopReason,
6
5
  StreamEvent,
@@ -9,8 +8,6 @@ import type {
9
8
  ToolDef,
10
9
  Usage,
11
10
  } from "../types.ts"
12
- import { consolidate } from "../util.ts"
13
- import { register } from "./registry.ts"
14
11
  import { EventStream } from "./stream.ts"
15
12
 
16
13
  function msgToOpenAI(msg: Msg): Record<string, unknown> {
@@ -118,8 +115,8 @@ export const streamOpenAI: StreamFn = (
118
115
  let buffer = ""
119
116
  const currentToolCalls = new Map<number, { id: string; name: string; args: string }>()
120
117
  let usage: Usage = { in: 0, out: 0 }
118
+ let textContent = ""
121
119
  let stop = "stop"
122
- const textParts: ContentPart[] = []
123
120
 
124
121
  while (true) {
125
122
  const { done, value } = await reader.read()
@@ -142,7 +139,7 @@ export const streamOpenAI: StreamFn = (
142
139
 
143
140
  if (delta.content) {
144
141
  es.push({ type: "text_delta", text: delta.content })
145
- textParts.push({ type: "text", text: delta.content })
142
+ textContent += delta.content
146
143
  }
147
144
 
148
145
  if (delta.tool_calls) {
@@ -178,7 +175,10 @@ export const streamOpenAI: StreamFn = (
178
175
  }
179
176
  }
180
177
 
181
- const content: AssistantResult["content"] = consolidate([...textParts])
178
+ const content: AssistantResult["content"] = []
179
+ if (textContent) {
180
+ content.push({ type: "text", text: textContent })
181
+ }
182
182
  for (const [, tc] of currentToolCalls) {
183
183
  content.push({
184
184
  type: "tool_call",
@@ -213,6 +213,3 @@ export const streamOpenAI: StreamFn = (
213
213
 
214
214
  return es
215
215
  }
216
-
217
- // Auto-register
218
- register("openai", streamOpenAI)
@@ -1,3 +1,9 @@
1
+ import type { AgentEvent, ApiFormat, AssistantResult, StreamFn, StreamOpts } from "../types.ts"
2
+ import { streamGemini } from "./gemini.ts"
3
+ import { streamOpenAI } from "./openai.ts"
4
+
5
+ export type { AssistantResult, StreamEvent, StreamFn, StreamOpts } from "../types.ts"
6
+
1
7
  /*
2
8
  * Push-based async event stream.
3
9
  *
@@ -59,10 +65,8 @@ export class EventStream<T, R> {
59
65
  const item = await new Promise<T | undefined>((resolve) => {
60
66
  this.#resolve = resolve as (value: T) => void
61
67
  })
62
- if (item !== undefined && this.#events.length === 0) {
68
+ if (item !== undefined) {
63
69
  yield item
64
- } else if (this.#events.length > 0) {
65
- yield this.#events.shift() as T
66
70
  }
67
71
  }
68
72
  }
@@ -75,3 +79,60 @@ export class EventStream<T, R> {
75
79
  return this.#done
76
80
  }
77
81
  }
82
+
83
+ // Internal map of registered provider implementations
84
+ const registry = new Map<ApiFormat, StreamFn>([
85
+ ["openai", streamOpenAI],
86
+ ["gemini", streamGemini],
87
+ ])
88
+
89
+ export function register(api: ApiFormat, fn: StreamFn): void {
90
+ registry.set(api, fn)
91
+ }
92
+
93
+ // Bridges provider-specific StreamEvents into AgentEvents so the loop and TUI deal with one type.
94
+ export function stream(opts: StreamOpts): EventStream<AgentEvent, AssistantResult> {
95
+ const fn = registry.get(opts.api)
96
+ if (!fn) throw new Error(`No provider registered for API format: ${opts.api}`)
97
+
98
+ // Bridge layer: converts provider-specific StreamEvents into the agent's
99
+ // AgentEvent shape, so the loop and TUI only deal with one event type.
100
+ const providerStream = fn(opts)
101
+ const agentStream = new EventStream<AgentEvent, AssistantResult>()
102
+
103
+ ;(async () => {
104
+ for await (const event of providerStream) {
105
+ if (event.type === "text_delta") {
106
+ agentStream.push({ type: "text_delta", text: event.text ?? "" })
107
+ } else if (event.type === "thinking_delta") {
108
+ agentStream.push({ type: "thinking_delta", text: event.text ?? "" })
109
+ } else if (event.type === "tool_call" && event.call) {
110
+ agentStream.push({
111
+ type: "tool_call",
112
+ call: {
113
+ type: "tool_call",
114
+ id: event.call.id,
115
+ name: event.call.name,
116
+ args: event.call.args,
117
+ },
118
+ })
119
+ } else if (event.type === "usage" && event.usage) {
120
+ agentStream.push({ type: "usage", usage: event.usage })
121
+ }
122
+ }
123
+
124
+ const res = providerStream.result
125
+ if (res) {
126
+ agentStream.finish(res)
127
+ } else {
128
+ // Fallback for unexpected closure
129
+ agentStream.finish({ content: [], usage: { in: 0, out: 0 }, stop: "stop" })
130
+ }
131
+ })()
132
+
133
+ return agentStream
134
+ }
135
+
136
+ export function getRegisteredApis(): ApiFormat[] {
137
+ return [...registry.keys()]
138
+ }
@@ -1,5 +1,5 @@
1
1
  import { getProvider } from "../config/providers.ts"
2
- import { stream } from "../provider/registry.ts"
2
+ import { stream } from "../provider/stream.ts"
3
3
  import type { Model, Msg } from "../types.ts"
4
4
  import { estimateTokens } from "../util.ts"
5
5
  import type { SessionStore } from "./store.ts"