codeblog-app 2.1.7 → 2.2.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "codeblog-app",
4
- "version": "2.1.7",
4
+ "version": "2.2.1",
5
5
  "description": "CLI client for CodeBlog — the forum where AI writes the posts",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -56,11 +56,11 @@
56
56
  "typescript": "5.8.2"
57
57
  },
58
58
  "optionalDependencies": {
59
- "codeblog-app-darwin-arm64": "2.1.7",
60
- "codeblog-app-darwin-x64": "2.1.7",
61
- "codeblog-app-linux-arm64": "2.1.7",
62
- "codeblog-app-linux-x64": "2.1.7",
63
- "codeblog-app-windows-x64": "2.1.7"
59
+ "codeblog-app-darwin-arm64": "2.2.1",
60
+ "codeblog-app-darwin-x64": "2.2.1",
61
+ "codeblog-app-linux-arm64": "2.2.1",
62
+ "codeblog-app-linux-x64": "2.2.1",
63
+ "codeblog-app-windows-x64": "2.2.1"
64
64
  },
65
65
  "dependencies": {
66
66
  "@ai-sdk/anthropic": "^3.0.44",
@@ -71,7 +71,7 @@
71
71
  "@opentui/core": "^0.1.79",
72
72
  "@opentui/solid": "^0.1.79",
73
73
  "ai": "^6.0.86",
74
- "codeblog-mcp": "^2.1.2",
74
+ "codeblog-mcp": "^2.1.3",
75
75
  "drizzle-orm": "1.0.0-beta.12-a5629fb",
76
76
  "fuzzysort": "^3.1.0",
77
77
  "hono": "4.10.7",
@@ -5,12 +5,17 @@ const mockCallToolJSON = mock((name: string, args: Record<string, unknown>) =>
5
5
  Promise.resolve({ ok: true, tool: name }),
6
6
  )
7
7
 
8
+ const mockListTools = mock(() =>
9
+ Promise.resolve({ tools: [] }),
10
+ )
11
+
8
12
  mock.module("../../mcp/client", () => ({
9
13
  McpBridge: {
10
14
  callTool: mock((name: string, args: Record<string, unknown>) =>
11
15
  Promise.resolve(JSON.stringify({ ok: true, tool: name })),
12
16
  ),
13
17
  callToolJSON: mockCallToolJSON,
18
+ listTools: mockListTools,
14
19
  disconnect: mock(() => Promise.resolve()),
15
20
  },
16
21
  }))
@@ -25,10 +30,23 @@ function makeStreamResult() {
25
30
  }
26
31
  }
27
32
 
33
+ function makeToolCallStreamResult() {
34
+ return {
35
+ fullStream: (async function* () {
36
+ yield { type: "tool-call", toolName: "scan_sessions", args: { limit: 5 } }
37
+ yield { type: "tool-result", toolName: "scan_sessions", result: { sessions: [] } }
38
+ yield { type: "text-delta", textDelta: "Done scanning." }
39
+ })(),
40
+ }
41
+ }
42
+
43
+ let streamFactory = () => makeStreamResult()
44
+
28
45
  mock.module("ai", () => ({
29
- streamText: () => makeStreamResult(),
30
- ModelMessage: class {},
46
+ streamText: () => streamFactory(),
47
+ stepCountIs: (n: number) => ({ type: "step-count", count: n }),
31
48
  tool: (config: any) => config,
49
+ jsonSchema: (schema: any) => schema,
32
50
  }))
33
51
 
34
52
  mock.module("../provider", () => ({
@@ -43,6 +61,7 @@ const { AIChat } = await import("../chat")
43
61
  describe("AIChat", () => {
44
62
  beforeEach(() => {
45
63
  mockCallToolJSON.mockClear()
64
+ streamFactory = () => makeStreamResult()
46
65
  })
47
66
 
48
67
  // ---------------------------------------------------------------------------
@@ -99,6 +118,56 @@ describe("AIChat", () => {
99
118
  // Should not throw — system messages are filtered
100
119
  })
101
120
 
121
+ // ---------------------------------------------------------------------------
122
+ // stream() with tool calls
123
+ // ---------------------------------------------------------------------------
124
+
125
+ test("stream dispatches onToolCall and onToolResult callbacks", async () => {
126
+ streamFactory = () => makeToolCallStreamResult()
127
+
128
+ const toolCalls: Array<{ name: string; args: unknown }> = []
129
+ const toolResults: Array<{ name: string; result: unknown }> = []
130
+ const tokens: string[] = []
131
+
132
+ await AIChat.stream(
133
+ [{ role: "user", content: "scan my sessions" }],
134
+ {
135
+ onToken: (t) => tokens.push(t),
136
+ onToolCall: (name, args) => toolCalls.push({ name, args }),
137
+ onToolResult: (name, result) => toolResults.push({ name, result }),
138
+ onFinish: () => {},
139
+ },
140
+ )
141
+
142
+ expect(toolCalls).toEqual([{ name: "scan_sessions", args: { limit: 5 } }])
143
+ expect(toolResults).toEqual([{ name: "scan_sessions", result: { sessions: [] } }])
144
+ expect(tokens).toEqual(["Done scanning."])
145
+ })
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // stream() error handling
149
+ // ---------------------------------------------------------------------------
150
+
151
+ test("stream calls onError when error event is received", async () => {
152
+ streamFactory = () => ({
153
+ fullStream: (async function* () {
154
+ yield { type: "error", error: new Error("test error") }
155
+ })(),
156
+ })
157
+
158
+ const errors: Error[] = []
159
+ await AIChat.stream(
160
+ [{ role: "user", content: "test" }],
161
+ {
162
+ onError: (err) => errors.push(err),
163
+ onFinish: () => {},
164
+ },
165
+ )
166
+
167
+ expect(errors).toHaveLength(1)
168
+ expect(errors[0]!.message).toBe("test error")
169
+ })
170
+
102
171
  // ---------------------------------------------------------------------------
103
172
  // generate()
104
173
  // ---------------------------------------------------------------------------
@@ -143,13 +143,27 @@ describe("AIProvider", () => {
143
143
  // available
144
144
  // ---------------------------------------------------------------------------
145
145
 
146
- test("available returns all builtin models with hasKey status", async () => {
146
+ test("available returns at least builtin models with hasKey status", async () => {
147
147
  const models = await AIProvider.available()
148
- expect(models).toHaveLength(7)
148
+ expect(models.length).toBeGreaterThanOrEqual(7)
149
149
  for (const entry of models) {
150
150
  expect(entry.model).toBeDefined()
151
151
  expect(typeof entry.hasKey).toBe("boolean")
152
152
  }
153
+ // The first 7 should always be builtins
154
+ const builtinCount = models.filter((m) => AIProvider.BUILTIN_MODELS[m.model.id]).length
155
+ expect(builtinCount).toBe(7)
156
+ })
157
+
158
+ test("available includes openai-compatible remote models when configured", async () => {
159
+ // With a valid key and base URL from config, remote models should be fetched.
160
+ // This test verifies that available() attempts to include openai-compatible models.
161
+ // Use localhost to ensure fast failure (connection refused) instead of DNS timeout.
162
+ process.env.OPENAI_COMPATIBLE_API_KEY = "sk-test"
163
+ process.env.OPENAI_COMPATIBLE_BASE_URL = "http://127.0.0.1:1"
164
+ const models = await AIProvider.available()
165
+ // Should still return at least builtins even if remote fetch fails
166
+ expect(models.length).toBeGreaterThanOrEqual(7)
153
167
  })
154
168
 
155
169
  // ---------------------------------------------------------------------------
@@ -1,23 +1,55 @@
1
- import { describe, test, expect } from "bun:test"
2
- import { getChatTools, TOOL_LABELS, clearChatToolsCache } from "../tools"
1
+ import { describe, test, expect, mock } from "bun:test"
3
2
 
4
- describe("AI Tools (dynamic MCP discovery)", () => {
5
- // These tests require a running MCP server subprocess.
6
- // getChatTools() connects to codeblog-mcp via stdio and calls listTools().
3
+ // Mock MCP bridge so tests don't need a running MCP server
4
+ const MOCK_MCP_TOOLS = [
5
+ {
6
+ name: "scan_sessions",
7
+ description: "Scan IDE sessions for coding activity",
8
+ inputSchema: { type: "object", properties: { limit: { type: "number" } } },
9
+ },
10
+ {
11
+ name: "my_dashboard",
12
+ description: "Show the user dashboard with stats",
13
+ inputSchema: {},
14
+ },
15
+ {
16
+ name: "browse_posts",
17
+ description: "Browse posts on the forum with filters",
18
+ inputSchema: { type: "object", properties: { page: { type: "number" }, tag: { type: "string" } } },
19
+ },
20
+ ]
21
+
22
+ mock.module("../../mcp/client", () => ({
23
+ McpBridge: {
24
+ listTools: mock(() => Promise.resolve({ tools: MOCK_MCP_TOOLS })),
25
+ callToolJSON: mock((name: string) => Promise.resolve({ ok: true, tool: name })),
26
+ },
27
+ }))
28
+
29
+ mock.module("ai", () => ({
30
+ tool: (config: any) => config,
31
+ jsonSchema: (schema: any) => schema,
32
+ }))
33
+
34
+ const { getChatTools, TOOL_LABELS, clearChatToolsCache } = await import("../tools")
7
35
 
36
+ describe("AI Tools (dynamic MCP discovery)", () => {
8
37
  let chatTools: Record<string, any>
9
38
 
10
39
  test("getChatTools() discovers tools from MCP server", async () => {
11
40
  clearChatToolsCache()
12
41
  chatTools = await getChatTools()
13
42
  const names = Object.keys(chatTools)
14
- expect(names.length).toBeGreaterThanOrEqual(20)
43
+ expect(names.length).toBe(MOCK_MCP_TOOLS.length)
44
+ expect(names).toContain("scan_sessions")
45
+ expect(names).toContain("my_dashboard")
46
+ expect(names).toContain("browse_posts")
15
47
  })
16
48
 
17
- test("each tool has parameters and execute", () => {
49
+ test("each tool has inputSchema and execute", () => {
18
50
  for (const [name, t] of Object.entries(chatTools)) {
19
51
  const tool = t as any
20
- expect(tool.parameters).toBeDefined()
52
+ expect(tool.inputSchema).toBeDefined()
21
53
  expect(tool.execute).toBeDefined()
22
54
  expect(typeof tool.execute).toBe("function")
23
55
  }
@@ -32,6 +64,13 @@ describe("AI Tools (dynamic MCP discovery)", () => {
32
64
  }
33
65
  })
34
66
 
67
+ test("normalizeToolSchema adds type:object to empty schemas", async () => {
68
+ // my_dashboard has empty inputSchema {} — should be normalized
69
+ const dashboard = chatTools["my_dashboard"] as any
70
+ expect(dashboard.inputSchema.type).toBe("object")
71
+ expect(dashboard.inputSchema.properties).toEqual({})
72
+ })
73
+
35
74
  // ---------------------------------------------------------------------------
36
75
  // TOOL_LABELS tests (static fallback map)
37
76
  // ---------------------------------------------------------------------------
package/src/ai/chat.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { streamText, type ModelMessage } from "ai"
1
+ import { streamText, stepCountIs } from "ai"
2
2
  import { AIProvider } from "./provider"
3
3
  import { getChatTools } from "./tools"
4
4
  import { Log } from "../util/log"
@@ -18,9 +18,17 @@ You help developers with everything on the platform:
18
18
  You have 20+ tools. Use them whenever the user's request matches. Chain multiple tools if needed.
19
19
  After a tool returns results, summarize them naturally for the user.
20
20
 
21
+ CRITICAL: When using tools, ALWAYS use the EXACT data returned by previous tool calls.
22
+ - If scan_sessions returns a path like "/Users/zhaoyifei/...", use that EXACT path
23
+ - NEVER modify, guess, or infer file paths — use them exactly as returned
24
+ - If a tool call fails with "file not found", the path is wrong — check the scan results again
25
+
21
26
  Write casually like a dev talking to another dev. Be specific, opinionated, and genuine.
22
27
  Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a conference paper.`
23
28
 
29
+ const IDLE_TIMEOUT_MS = 15_000 // 15s without any stream event → abort
30
+ const DEFAULT_MAX_STEPS = 10 // Allow AI to retry tools up to 10 steps (each tool call + result = 1 step)
31
+
24
32
  export namespace AIChat {
25
33
  export interface Message {
26
34
  role: "user" | "assistant" | "system"
@@ -35,98 +43,144 @@ export namespace AIChat {
35
43
  onToolResult?: (name: string, result: unknown) => void
36
44
  }
37
45
 
38
- export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string, signal?: AbortSignal) {
46
+ export interface StreamOptions {
47
+ maxSteps?: number
48
+ }
49
+
50
+ export async function stream(
51
+ messages: Message[],
52
+ callbacks: StreamCallbacks,
53
+ modelID?: string,
54
+ signal?: AbortSignal,
55
+ options?: StreamOptions
56
+ ) {
39
57
  const model = await AIProvider.getModel(modelID)
40
58
  const tools = await getChatTools()
41
- log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length })
59
+ const maxSteps = options?.maxSteps ?? DEFAULT_MAX_STEPS
60
+ log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length, toolCount: Object.keys(tools).length, maxSteps })
42
61
 
43
- // Build history: only user/assistant text (tool context is added per-step below)
44
- const history: ModelMessage[] = messages
62
+ const history = messages
45
63
  .filter((m) => m.role === "user" || m.role === "assistant")
46
64
  .map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
47
65
  let full = ""
48
66
 
49
- for (let step = 0; step < 5; step++) {
50
- if (signal?.aborted) break
51
-
52
- const result = streamText({
53
- model,
54
- system: SYSTEM_PROMPT,
55
- messages: history,
56
- tools,
57
- maxSteps: 1,
58
- abortSignal: signal,
59
- } as any)
60
-
61
- const calls: Array<{ id: string; name: string; input: unknown; output: unknown }> = []
62
-
63
- try {
64
- log.info("starting fullStream iteration")
65
- for await (const part of (result as any).fullStream) {
66
- log.info("stream part", { type: part.type })
67
- if (signal?.aborted) break
68
- switch (part.type) {
69
- case "text-delta": {
70
- const delta = part.text ?? part.textDelta ?? ""
71
- if (delta) { full += delta; callbacks.onToken?.(delta) }
72
- break
73
- }
74
- case "tool-call": {
75
- const input = part.input ?? part.args
76
- callbacks.onToolCall?.(part.toolName, input)
77
- calls.push({ id: part.toolCallId, name: part.toolName, input, output: undefined })
78
- break
79
- }
80
- case "tool-result": {
81
- const output = part.output ?? part.result ?? {}
82
- const name = part.toolName
83
- callbacks.onToolResult?.(name, output)
84
- const match = calls.find((c: any) => c.id === part.toolCallId && c.output === undefined)
85
- if (match) match.output = output
86
- break
87
- }
88
- case "error": {
89
- const msg = part.error instanceof Error ? part.error.message : String(part.error)
90
- log.error("stream part error", { error: msg })
91
- callbacks.onError?.(part.error instanceof Error ? part.error : new Error(msg))
92
- break
93
- }
67
+ // Create an internal AbortController that we can trigger on idle timeout
68
+ const internalAbort = new AbortController()
69
+ const onExternalAbort = () => {
70
+ log.info("external abort signal received")
71
+ internalAbort.abort()
72
+ }
73
+ signal?.addEventListener("abort", onExternalAbort)
74
+
75
+ const result = streamText({
76
+ model,
77
+ system: SYSTEM_PROMPT,
78
+ messages: history,
79
+ tools,
80
+ stopWhen: stepCountIs(maxSteps),
81
+ toolChoice: "auto",
82
+ abortSignal: internalAbort.signal,
83
+ experimental_toolCallStreaming: false, // Disable streaming tool calls to avoid incomplete arguments bug
84
+ onStepFinish: (stepResult) => {
85
+ log.info("onStepFinish", {
86
+ stepNumber: stepResult.stepNumber,
87
+ finishReason: stepResult.finishReason,
88
+ textLength: stepResult.text?.length ?? 0,
89
+ toolCallsCount: stepResult.toolCalls?.length ?? 0,
90
+ toolResultsCount: stepResult.toolResults?.length ?? 0,
91
+ })
92
+ },
93
+ })
94
+
95
+ let partCount = 0
96
+ let toolExecuting = false
97
+ try {
98
+ // Idle timeout: if no stream events arrive for IDLE_TIMEOUT_MS, abort.
99
+ // Paused during tool execution (tools can take longer than 15s).
100
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
101
+ const resetIdle = () => {
102
+ if (idleTimer) clearTimeout(idleTimer)
103
+ if (toolExecuting) return // Don't start timer while tool is running
104
+ idleTimer = setTimeout(() => {
105
+ log.info("IDLE TIMEOUT FIRED", { partCount, fullLength: full.length })
106
+ internalAbort.abort()
107
+ }, IDLE_TIMEOUT_MS)
108
+ }
109
+ resetIdle()
110
+
111
+ for await (const part of result.fullStream) {
112
+ partCount++
113
+ if (internalAbort.signal.aborted) {
114
+ log.info("abort detected in loop, breaking", { partCount })
115
+ break
116
+ }
117
+ resetIdle()
118
+
119
+ switch (part.type) {
120
+ case "text-delta": {
121
+ const delta = (part as any).text ?? (part as any).textDelta ?? ""
122
+ if (delta) { full += delta; callbacks.onToken?.(delta) }
123
+ break
124
+ }
125
+ case "tool-call": {
126
+ const toolName = (part as any).toolName
127
+ const toolArgs = (part as any).args ?? (part as any).input ?? {}
128
+ log.info("tool-call", { toolName, args: toolArgs, partCount })
129
+ // Pause idle timer — tool execution happens between tool-call and tool-result
130
+ toolExecuting = true
131
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = undefined }
132
+ callbacks.onToolCall?.(toolName, toolArgs)
133
+ break
134
+ }
135
+ case "tool-result": {
136
+ log.info("tool-result", { toolName: (part as any).toolName, partCount })
137
+ toolExecuting = false
138
+ callbacks.onToolResult?.((part as any).toolName, (part as any).output ?? (part as any).result ?? {})
139
+ break
140
+ }
141
+ case "tool-error" as any: {
142
+ const errorMsg = String((part as any).error).slice(0, 500)
143
+ log.error("tool-error", { toolName: (part as any).toolName, error: errorMsg })
144
+ toolExecuting = false
145
+ // Abort the stream on tool error to prevent infinite retry loops
146
+ log.info("aborting stream due to tool error")
147
+ internalAbort.abort()
148
+ break
94
149
  }
150
+ case "error": {
151
+ const msg = (part as any).error instanceof Error ? (part as any).error.message : String((part as any).error)
152
+ log.error("stream part error", { error: msg })
153
+ callbacks.onError?.((part as any).error instanceof Error ? (part as any).error : new Error(msg))
154
+ break
155
+ }
156
+ default:
157
+ break
95
158
  }
96
- } catch (err) {
97
- const error = err instanceof Error ? err : new Error(String(err))
98
- log.error("stream error", { error: error.message })
159
+ }
160
+
161
+ if (idleTimer) clearTimeout(idleTimer)
162
+ log.info("for-await loop exited normally", { partCount, fullLength: full.length })
163
+ } catch (err) {
164
+ const error = err instanceof Error ? err : new Error(String(err))
165
+ log.info("catch block entered", { name: error.name, message: error.message.slice(0, 200), partCount })
166
+ // Don't treat abort as a real error
167
+ if (error.name !== "AbortError") {
168
+ log.error("stream error (non-abort)", { error: error.message })
99
169
  if (callbacks.onError) callbacks.onError(error)
100
170
  else throw error
101
- return full
171
+ } else {
172
+ log.info("AbortError caught — treating as normal completion")
102
173
  }
103
-
104
- if (calls.length === 0) break
105
-
106
- // AI SDK v6 ModelMessage format
107
- history.push({
108
- role: "assistant",
109
- content: calls.map((c) => ({
110
- type: "tool-call" as const,
111
- toolCallId: c.id,
112
- toolName: c.name,
113
- input: c.input,
114
- })),
115
- } as ModelMessage)
116
-
117
- history.push({
118
- role: "tool",
119
- content: calls.map((c) => ({
120
- type: "tool-result" as const,
121
- toolCallId: c.id,
122
- toolName: c.name,
123
- output: { type: "json" as const, value: c.output ?? {} },
124
- })),
125
- } as ModelMessage)
126
-
127
- log.info("tool step done", { step, tools: calls.map((c) => c.name) })
174
+ // On abort or error, still call onFinish so UI cleans up
175
+ log.info("calling onFinish from catch", { fullLength: full.length })
176
+ callbacks.onFinish?.(full || "(No response)")
177
+ return full
178
+ } finally {
179
+ log.info("finally block", { partCount, fullLength: full.length })
180
+ signal?.removeEventListener("abort", onExternalAbort)
128
181
  }
129
182
 
183
+ log.info("calling onFinish from normal path", { fullLength: full.length })
130
184
  callbacks.onFinish?.(full || "(No response)")
131
185
  return full
132
186
  }
@@ -115,7 +115,9 @@ export namespace AIProvider {
115
115
  }
116
116
  const compatKey = await getApiKey("openai-compatible")
117
117
  if (compatKey) {
118
- result["openai-compatible"] = { name: "OpenAI Compatible", models: [], hasKey: true }
118
+ const compatBase = await getBaseUrl("openai-compatible")
119
+ const remoteModels = compatBase ? await fetchRemoteModels(compatBase, compatKey) : []
120
+ result["openai-compatible"] = { name: "OpenAI Compatible", models: remoteModels, hasKey: true }
119
121
  }
120
122
  return result
121
123
  }
@@ -161,7 +163,20 @@ export namespace AIProvider {
161
163
  }
162
164
 
163
165
  function getLanguageModel(providerID: string, modelID: string, apiKey: string, npm?: string, baseURL?: string): LanguageModel {
164
- const pkg = npm || PROVIDER_NPM[providerID] || "@ai-sdk/openai-compatible"
166
+ // Auto-detect Anthropic models and use @ai-sdk/anthropic instead of openai-compatible
167
+ // This fixes streaming tool call argument parsing issues with openai-compatible provider
168
+ let pkg = npm || PROVIDER_NPM[providerID]
169
+
170
+ // Force Anthropic SDK for Claude models, even if provider is openai-compatible
171
+ if (modelID.startsWith("claude-") && pkg === "@ai-sdk/openai-compatible") {
172
+ pkg = "@ai-sdk/anthropic"
173
+ log.info("auto-detected Claude model, switching from openai-compatible to @ai-sdk/anthropic", { model: modelID })
174
+ }
175
+
176
+ if (!pkg) {
177
+ pkg = "@ai-sdk/openai-compatible"
178
+ }
179
+
165
180
  const cacheKey = `${providerID}:${pkg}:${apiKey.slice(0, 8)}`
166
181
 
167
182
  log.info("loading model", { provider: providerID, model: modelID, pkg })
@@ -175,6 +190,26 @@ export namespace AIProvider {
175
190
  const clean = baseURL.replace(/\/+$/, "")
176
191
  opts.baseURL = clean.endsWith("/v1") ? clean : `${clean}/v1`
177
192
  }
193
+ // For openai-compatible providers, normalize request body for broader compatibility
194
+ if (pkg === "@ai-sdk/openai-compatible") {
195
+ opts.transformRequestBody = (body: Record<string, any>) => {
196
+ // Remove parallel_tool_calls — many proxies/providers don't support it
197
+ delete body.parallel_tool_calls
198
+
199
+ // Ensure all tool schemas have type: "object" (required by DeepSeek/Qwen/etc.)
200
+ if (Array.isArray(body.tools)) {
201
+ for (const t of body.tools) {
202
+ const params = t?.function?.parameters
203
+ if (params && !params.type) {
204
+ params.type = "object"
205
+ if (!params.properties) params.properties = {}
206
+ }
207
+ }
208
+ }
209
+
210
+ return body
211
+ }
212
+ }
178
213
  sdk = createFn(opts)
179
214
  sdkCache.set(cacheKey, sdk)
180
215
  }
@@ -188,6 +223,22 @@ export namespace AIProvider {
188
223
  return (sdk as any)(modelID)
189
224
  }
190
225
 
226
+ async function fetchRemoteModels(base: string, key: string): Promise<string[]> {
227
+ try {
228
+ const clean = base.replace(/\/+$/, "")
229
+ const url = clean.endsWith("/v1") ? `${clean}/models` : `${clean}/v1/models`
230
+ const r = await fetch(url, {
231
+ headers: { Authorization: `Bearer ${key}` },
232
+ signal: AbortSignal.timeout(8000),
233
+ })
234
+ if (!r.ok) return []
235
+ const data = await r.json() as { data?: Array<{ id: string }> }
236
+ return data.data?.map((m) => m.id) ?? []
237
+ } catch {
238
+ return []
239
+ }
240
+ }
241
+
191
242
  function noKeyError(providerID: string): Error {
192
243
  const envKeys = PROVIDER_ENV[providerID] || []
193
244
  const envHint = envKeys[0] || `${providerID.toUpperCase().replace(/-/g, "_")}_API_KEY`
@@ -225,6 +276,19 @@ export namespace AIProvider {
225
276
  const apiKey = await getApiKey(model.providerID)
226
277
  result.push({ model, hasKey: !!apiKey })
227
278
  }
279
+ // Include remote models from openai-compatible provider
280
+ const compatKey = await getApiKey("openai-compatible")
281
+ const compatBase = await getBaseUrl("openai-compatible")
282
+ if (compatKey && compatBase) {
283
+ const remoteModels = await fetchRemoteModels(compatBase, compatKey)
284
+ for (const id of remoteModels) {
285
+ if (BUILTIN_MODELS[id]) continue
286
+ result.push({
287
+ model: { id, providerID: "openai-compatible", name: id, contextWindow: 0, outputTokens: 0 },
288
+ hasKey: true,
289
+ })
290
+ }
291
+ }
228
292
  return result
229
293
  }
230
294
 
package/src/ai/tools.ts CHANGED
@@ -53,6 +53,18 @@ function clean(obj: Record<string, unknown>): Record<string, unknown> {
53
53
  return result
54
54
  }
55
55
 
56
+ // ---------------------------------------------------------------------------
57
+ // Schema normalization: ensure all JSON schemas are valid tool input schemas.
58
+ // Some MCP tools have empty inputSchema ({}) which produces schemas without
59
+ // "type": "object", causing providers like DeepSeek/Qwen to reject them.
60
+ // ---------------------------------------------------------------------------
61
+ function normalizeToolSchema(schema: Record<string, unknown>): Record<string, unknown> {
62
+ const normalized = { ...schema }
63
+ if (!normalized.type) normalized.type = "object"
64
+ if (normalized.type === "object" && !normalized.properties) normalized.properties = {}
65
+ return normalized
66
+ }
67
+
56
68
  // ---------------------------------------------------------------------------
57
69
  // Dynamic tool discovery from MCP server
58
70
  // ---------------------------------------------------------------------------
@@ -72,12 +84,23 @@ export async function getChatTools(): Promise<Record<string, any>> {
72
84
 
73
85
  for (const t of mcpTools) {
74
86
  const name = t.name
75
- const schema = t.inputSchema as Record<string, unknown>
87
+ const rawSchema = (t.inputSchema ?? {}) as Record<string, unknown>
76
88
 
77
- tools[name] = (tool as any)({
89
+ tools[name] = tool({
78
90
  description: t.description || name,
79
- parameters: jsonSchema(schema),
80
- execute: async (args: any) => mcp(name, clean(args)),
91
+ inputSchema: jsonSchema(normalizeToolSchema(rawSchema)),
92
+ execute: async (args: any) => {
93
+ log.info("execute tool", { name, args })
94
+ const result = await mcp(name, clean(args))
95
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result)
96
+ log.info("execute tool result", { name, resultType: typeof result, resultLength: resultStr.length, resultPreview: resultStr.slice(0, 300) })
97
+ // Truncate very large tool results to avoid overwhelming the LLM context
98
+ if (resultStr.length > 8000) {
99
+ log.info("truncating large tool result", { name, originalLength: resultStr.length })
100
+ return resultStr.slice(0, 8000) + "\n...(truncated)"
101
+ }
102
+ return resultStr
103
+ },
81
104
  })
82
105
  }
83
106
 
@@ -74,7 +74,7 @@ describe("CLI Commands", () => {
74
74
  test("handler calls codeblog_status when --status flag", async () => {
75
75
  mockCallTool.mockImplementationOnce(() => Promise.resolve("Status: OK"))
76
76
  await (ScanCommand.handler as any)({ status: true, limit: 20 })
77
- expect(mockCallTool).toHaveBeenCalledWith("codeblog_status")
77
+ expect(mockCallTool).toHaveBeenCalledWith("codeblog_status", {})
78
78
  })
79
79
 
80
80
  test("handler passes source when provided", async () => {
package/src/tui/app.tsx CHANGED
@@ -44,6 +44,7 @@ function App() {
44
44
  const renderer = useRenderer()
45
45
  const [loggedIn, setLoggedIn] = createSignal(false)
46
46
  const [username, setUsername] = createSignal("")
47
+ const [activeAgent, setActiveAgent] = createSignal("")
47
48
  const [hasAI, setHasAI] = createSignal(false)
48
49
  const [aiProvider, setAiProvider] = createSignal("")
49
50
  const [modelName, setModelName] = createSignal("")
@@ -78,6 +79,15 @@ function App() {
78
79
  }
79
80
  } catch {}
80
81
 
82
+ // Get active agent
83
+ try {
84
+ const { Config } = await import("../config")
85
+ const cfg = await Config.load()
86
+ if (cfg.activeAgent) {
87
+ setActiveAgent(cfg.activeAgent)
88
+ }
89
+ } catch {}
90
+
81
91
  await refreshAI()
82
92
  })
83
93
 
@@ -103,6 +113,7 @@ function App() {
103
113
  <Home
104
114
  loggedIn={loggedIn()}
105
115
  username={username()}
116
+ activeAgent={activeAgent()}
106
117
  hasAI={hasAI()}
107
118
  aiProvider={aiProvider()}
108
119
  modelName={modelName()}
@@ -144,34 +155,9 @@ function App() {
144
155
  </Match>
145
156
  </Switch>
146
157
 
147
- {/* Status bar — like OpenCode */}
158
+ {/* Status bar — only version */}
148
159
  <box paddingLeft={2} paddingRight={2} flexShrink={0} flexDirection="row" gap={2}>
149
- <text fg={theme.colors.textMuted}>{process.cwd()}</text>
150
160
  <box flexGrow={1} />
151
- <Show when={hasAI()}>
152
- <text fg={theme.colors.text}>
153
- <span style={{ fg: theme.colors.success }}>● </span>
154
- {modelName()}
155
- </text>
156
- </Show>
157
- <Show when={!hasAI()}>
158
- <text fg={theme.colors.text}>
159
- <span style={{ fg: theme.colors.error }}>○ </span>
160
- no AI <span style={{ fg: theme.colors.textMuted }}>/ai</span>
161
- </text>
162
- </Show>
163
- <Show when={loggedIn()}>
164
- <text fg={theme.colors.text}>
165
- <span style={{ fg: theme.colors.success }}>● </span>
166
- {username() || "logged in"}
167
- </text>
168
- </Show>
169
- <Show when={!loggedIn()}>
170
- <text fg={theme.colors.text}>
171
- <span style={{ fg: theme.colors.error }}>○ </span>
172
- <span style={{ fg: theme.colors.textMuted }}>/login</span>
173
- </text>
174
- </Show>
175
161
  <text fg={theme.colors.textMuted}>v{VERSION}</text>
176
162
  </box>
177
163
  </box>
@@ -1,13 +1,40 @@
1
1
  import { createSignal, createMemo, createEffect, onCleanup, Show, For } from "solid-js"
2
2
  import { useKeyboard, usePaste } from "@opentui/solid"
3
+ import { SyntaxStyle, type ThemeTokenStyle } from "@opentui/core"
3
4
  import { useRoute } from "../context/route"
4
5
  import { useExit } from "../context/exit"
5
- import { useTheme } from "../context/theme"
6
+ import { useTheme, type ThemeColors } from "../context/theme"
6
7
  import { createCommands, LOGO, TIPS, TIPS_NO_AI } from "../commands"
7
8
  import { TOOL_LABELS } from "../../ai/tools"
8
9
  import { mask, saveProvider } from "../../ai/configure"
9
10
  import { ChatHistory } from "../../storage/chat"
10
11
 
12
+ function buildMarkdownSyntaxRules(colors: ThemeColors): ThemeTokenStyle[] {
13
+ return [
14
+ { scope: ["default"], style: { foreground: colors.text } },
15
+ { scope: ["spell", "nospell"], style: { foreground: colors.text } },
16
+ { scope: ["conceal"], style: { foreground: colors.textMuted } },
17
+ { scope: ["markup.heading", "markup.heading.1", "markup.heading.2", "markup.heading.3", "markup.heading.4", "markup.heading.5", "markup.heading.6"], style: { foreground: colors.primary, bold: true } },
18
+ { scope: ["markup.bold", "markup.strong"], style: { foreground: colors.text, bold: true } },
19
+ { scope: ["markup.italic"], style: { foreground: colors.text, italic: true } },
20
+ { scope: ["markup.list"], style: { foreground: colors.text } },
21
+ { scope: ["markup.quote"], style: { foreground: colors.textMuted, italic: true } },
22
+ { scope: ["markup.raw", "markup.raw.block", "markup.raw.inline"], style: { foreground: colors.accent } },
23
+ { scope: ["markup.link", "markup.link.url"], style: { foreground: colors.primary, underline: true } },
24
+ { scope: ["markup.link.label"], style: { foreground: colors.primary, underline: true } },
25
+ { scope: ["label"], style: { foreground: colors.primary } },
26
+ { scope: ["comment"], style: { foreground: colors.textMuted, italic: true } },
27
+ { scope: ["string", "symbol"], style: { foreground: colors.success } },
28
+ { scope: ["number", "boolean"], style: { foreground: colors.accent } },
29
+ { scope: ["keyword"], style: { foreground: colors.primary, italic: true } },
30
+ { scope: ["keyword.function", "function.method", "function", "constructor", "variable.member"], style: { foreground: colors.primary } },
31
+ { scope: ["variable", "variable.parameter", "property", "parameter"], style: { foreground: colors.text } },
32
+ { scope: ["type", "module", "class"], style: { foreground: colors.warning } },
33
+ { scope: ["operator", "keyword.operator", "punctuation.delimiter"], style: { foreground: colors.textMuted } },
34
+ { scope: ["punctuation", "punctuation.bracket"], style: { foreground: colors.textMuted } },
35
+ ]
36
+ }
37
+
11
38
  interface ChatMsg {
12
39
  role: "user" | "assistant" | "tool"
13
40
  content: string
@@ -18,6 +45,7 @@ interface ChatMsg {
18
45
  export function Home(props: {
19
46
  loggedIn: boolean
20
47
  username: string
48
+ activeAgent: string
21
49
  hasAI: boolean
22
50
  aiProvider: string
23
51
  modelName: string
@@ -40,6 +68,7 @@ export function Home(props: {
40
68
  let escCooldown = 0
41
69
  let sessionId = ""
42
70
  const chatting = createMemo(() => messages().length > 0 || streaming())
71
+ const syntaxStyle = createMemo(() => SyntaxStyle.fromTheme(buildMarkdownSyntaxRules(theme.colors)))
43
72
 
44
73
  function ensureSession() {
45
74
  if (!sessionId) {
@@ -151,19 +180,29 @@ export function Home(props: {
151
180
  setStreaming(true)
152
181
  setStreamText("")
153
182
  setMessage("")
183
+ let summaryStreamActive = false
154
184
 
155
185
  try {
156
186
  const { AIChat } = await import("../../ai/chat")
157
187
  const { Config } = await import("../../config")
158
188
  const { AIProvider } = await import("../../ai/provider")
189
+ const { Log } = await import("../../util/log")
190
+ const sendLog = Log.create({ service: "home-send" })
159
191
  const cfg = await Config.load()
160
192
  const mid = cfg.model || AIProvider.DEFAULT_MODEL
161
193
  const allMsgs = [...prev, userMsg].filter((m) => m.role !== "tool").map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
162
194
  let full = ""
195
+ let hasToolCalls = false
196
+ let lastToolName = ""
197
+ let lastToolResult = ""
163
198
  abortCtrl = new AbortController()
199
+ sendLog.info("calling AIChat.stream", { model: mid, msgCount: allMsgs.length })
164
200
  await AIChat.stream(allMsgs, {
165
201
  onToken: (token) => { full += token; setStreamText(full) },
166
202
  onToolCall: (name) => {
203
+ hasToolCalls = true
204
+ lastToolName = name
205
+ sendLog.info("onToolCall", { name })
167
206
  // Save any accumulated text as assistant message before tool
168
207
  if (full.trim()) {
169
208
  setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
@@ -172,7 +211,12 @@ export function Home(props: {
172
211
  }
173
212
  setMessages((p) => [...p, { role: "tool", content: TOOL_LABELS[name] || name, toolName: name, toolStatus: "running" }])
174
213
  },
175
- onToolResult: (name) => {
214
+ onToolResult: (name, result) => {
215
+ sendLog.info("onToolResult", { name })
216
+ try {
217
+ const str = typeof result === "string" ? result : JSON.stringify(result)
218
+ lastToolResult = str.slice(0, 6000)
219
+ } catch { lastToolResult = "" }
176
220
  setMessages((p) => p.map((m) =>
177
221
  m.role === "tool" && m.toolName === name && m.toolStatus === "running"
178
222
  ? { ...m, toolStatus: "done" as const }
@@ -180,13 +224,55 @@ export function Home(props: {
180
224
  ))
181
225
  },
182
226
  onFinish: () => {
227
+ sendLog.info("onFinish", { fullLength: full.length, hasToolCalls, hasToolResult: !!lastToolResult })
183
228
  if (full.trim()) {
184
229
  setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
230
+ setStreamText(""); setStreaming(false)
231
+ saveChat()
232
+ } else if (hasToolCalls && lastToolResult) {
233
+ // Tool executed but model didn't summarize — send a follow-up request
234
+ // to have the model produce a natural-language summary
235
+ sendLog.info("auto-summarizing tool result", { tool: lastToolName })
236
+ full = ""
237
+ setStreamText("")
238
+ const summaryMsgs = [
239
+ ...allMsgs,
240
+ { role: "assistant" as const, content: `I used the ${lastToolName} tool. Here are the results:\n${lastToolResult}` },
241
+ { role: "user" as const, content: "Please summarize these results in a helpful, natural way." },
242
+ ]
243
+ // NOTE: intentionally not awaited — the outer await resolves here,
244
+ // but streaming state is managed by the inner callbacks.
245
+ // The finally block must NOT reset streaming in this path.
246
+ summaryStreamActive = true
247
+ AIChat.stream(summaryMsgs, {
248
+ onToken: (token) => { full += token; setStreamText(full) },
249
+ onFinish: () => {
250
+ if (full.trim()) {
251
+ setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
252
+ } else {
253
+ setMessages((p) => [...p, { role: "assistant", content: "(Tool executed — model did not respond)" }])
254
+ }
255
+ setStreamText(""); setStreaming(false)
256
+ saveChat()
257
+ },
258
+ onError: (err) => {
259
+ sendLog.info("summary stream error", { message: err.message })
260
+ setMessages((p) => [...p, { role: "assistant", content: `Tool result received but summary failed: ${err.message}` }])
261
+ setStreamText(""); setStreaming(false)
262
+ saveChat()
263
+ },
264
+ }, mid, abortCtrl?.signal)
265
+ } else if (hasToolCalls) {
266
+ setMessages((p) => [...p, { role: "assistant", content: "(Tool executed — no response from model)" }])
267
+ setStreamText(""); setStreaming(false)
268
+ saveChat()
269
+ } else {
270
+ setStreamText(""); setStreaming(false)
271
+ saveChat()
185
272
  }
186
- setStreamText(""); setStreaming(false)
187
- saveChat()
188
273
  },
189
274
  onError: (err) => {
275
+ sendLog.info("onError", { message: err.message })
190
276
  setMessages((p) => {
191
277
  // Mark any running tools as error
192
278
  const updated = p.map((m) =>
@@ -199,14 +285,20 @@ export function Home(props: {
199
285
  setStreamText(""); setStreaming(false)
200
286
  saveChat()
201
287
  },
202
- }, mid, abortCtrl.signal)
288
+ }, mid, abortCtrl.signal, { maxSteps: 10 })
289
+ sendLog.info("AIChat.stream returned normally")
203
290
  abortCtrl = undefined
204
291
  } catch (err) {
205
292
  const msg = err instanceof Error ? err.message : String(err)
293
+ // Can't use sendLog here because it might not be in scope
206
294
  setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
207
- setStreamText("")
208
- setStreaming(false)
209
295
  saveChat()
296
+ } finally {
297
+ // Clean up streaming state — but NOT if a summary stream is still running
298
+ if (!summaryStreamActive) {
299
+ setStreamText("")
300
+ setStreaming(false)
301
+ }
210
302
  }
211
303
  }
212
304
 
@@ -371,28 +463,42 @@ export function Home(props: {
371
463
  ))}
372
464
  <box height={1} />
373
465
  <text fg={theme.colors.textMuted}>The AI-powered coding forum</text>
374
- </box>
375
- <Show when={!props.loggedIn || !props.hasAI}>
376
- <box flexShrink={0} flexDirection="column" paddingTop={1} alignItems="center">
466
+
467
+ {/* Status info below logo */}
468
+ <box height={1} />
469
+ <box flexDirection="column" alignItems="center" gap={0}>
377
470
  <box flexDirection="row" gap={1}>
378
- <text fg={props.hasAI ? theme.colors.success : theme.colors.warning}>{props.hasAI ? "✓" : "●"}</text>
379
- <text fg={props.hasAI ? theme.colors.textMuted : theme.colors.text}>
380
- {props.hasAI ? `AI: ${props.modelName}` : "Type /ai to configure AI"}
471
+ <text fg={props.hasAI ? theme.colors.success : theme.colors.warning}>
472
+ {props.hasAI ? "●" : "○"}
473
+ </text>
474
+ <text fg={theme.colors.text}>
475
+ {props.hasAI ? props.modelName : "No AI"}
381
476
  </text>
477
+ <Show when={!props.hasAI}>
478
+ <text fg={theme.colors.textMuted}> — type /ai</text>
479
+ </Show>
382
480
  </box>
383
481
  <box flexDirection="row" gap={1}>
384
- <text fg={props.loggedIn ? theme.colors.success : theme.colors.warning}>{props.loggedIn ? "✓" : "●"}</text>
385
- <text fg={props.loggedIn ? theme.colors.textMuted : theme.colors.text}>
386
- {props.loggedIn ? `Logged in as ${props.username}` : "Type /login to sign in"}
482
+ <text fg={props.loggedIn ? theme.colors.success : theme.colors.warning}>
483
+ {props.loggedIn ? "●" : "○"}
484
+ </text>
485
+ <text fg={theme.colors.text}>
486
+ {props.loggedIn ? props.username : "Not logged in"}
387
487
  </text>
488
+ <Show when={props.loggedIn && props.activeAgent}>
489
+ <text fg={theme.colors.textMuted}> / {props.activeAgent}</text>
490
+ </Show>
491
+ <Show when={!props.loggedIn}>
492
+ <text fg={theme.colors.textMuted}> — type /login</text>
493
+ </Show>
388
494
  </box>
389
495
  </box>
390
- </Show>
496
+ </box>
391
497
  </Show>
392
498
 
393
499
  {/* When chatting: messages fill the space */}
394
500
  <Show when={chatting()}>
395
- <box flexDirection="column" flexGrow={1} paddingTop={1} overflow="scroll">
501
+ <scrollbox flexGrow={1} paddingTop={1} stickyScroll={true} stickyStart="bottom">
396
502
  <For each={messages()}>
397
503
  {(msg) => (
398
504
  <box flexShrink={0}>
@@ -402,7 +508,7 @@ export function Home(props: {
402
508
  <text fg={theme.colors.primary} flexShrink={0}>
403
509
  <span style={{ bold: true }}>{"❯ "}</span>
404
510
  </text>
405
- <text fg={theme.colors.text}>
511
+ <text fg={theme.colors.text} wrapMode="word" flexGrow={1} flexShrink={1}>
406
512
  <span style={{ bold: true }}>{msg.content}</span>
407
513
  </text>
408
514
  </box>
@@ -420,27 +526,44 @@ export function Home(props: {
420
526
  </Show>
421
527
  {/* Assistant message — ◆ prefix */}
422
528
  <Show when={msg.role === "assistant"}>
423
- <box flexDirection="row" paddingBottom={1}>
424
- <text fg={theme.colors.success} flexShrink={0}>
425
- <span style={{ bold: true }}>{""}</span>
426
- </text>
427
- <text fg={theme.colors.text}>{msg.content}</text>
529
+ <box paddingBottom={1} flexShrink={0}>
530
+ <code
531
+ filetype="markdown"
532
+ drawUnstyledText={false}
533
+ syntaxStyle={syntaxStyle()}
534
+ content={msg.content}
535
+ conceal={true}
536
+ fg={theme.colors.text}
537
+ />
428
538
  </box>
429
539
  </Show>
430
540
  </box>
431
541
  )}
432
542
  </For>
433
- <Show when={streaming()}>
434
- <box flexDirection="row" paddingBottom={1} flexShrink={0}>
435
- <text fg={theme.colors.success} flexShrink={0}>
436
- <span style={{ bold: true }}>{"◆ "}</span>
437
- </text>
438
- <text fg={streamText() ? theme.colors.text : theme.colors.textMuted}>
439
- {streamText() || shimmerText()}
543
+ <box
544
+ flexShrink={0}
545
+ paddingBottom={streaming() ? 1 : 0}
546
+ height={streaming() ? undefined : 0}
547
+ overflow="hidden"
548
+ >
549
+ <Show when={streaming() && streamText()}>
550
+ <code
551
+ filetype="markdown"
552
+ drawUnstyledText={false}
553
+ streaming={true}
554
+ syntaxStyle={syntaxStyle()}
555
+ content={streamText()}
556
+ conceal={true}
557
+ fg={theme.colors.text}
558
+ />
559
+ </Show>
560
+ <Show when={streaming() && !streamText()}>
561
+ <text fg={theme.colors.textMuted} wrapMode="word">
562
+ {"◆ " + shimmerText()}
440
563
  </text>
441
- </box>
442
- </Show>
443
- </box>
564
+ </Show>
565
+ </box>
566
+ </scrollbox>
444
567
  </Show>
445
568
 
446
569
  {/* Spacer when no chat and no autocomplete */}
@@ -508,11 +631,11 @@ export function Home(props: {
508
631
  <text fg={theme.colors.textMuted}>{tipPool()[tipIdx % tipPool().length]}</text>
509
632
  </box>
510
633
  </Show>
511
- {/* Input line */}
634
+ {/* Input line with blinking cursor */}
512
635
  <box flexDirection="row">
513
636
  <text fg={theme.colors.primary}><span style={{ bold: true }}>{"❯ "}</span></text>
514
637
  <text fg={theme.colors.input}>{input()}</text>
515
- <text fg={theme.colors.cursor}>{"█"}</text>
638
+ <text fg={theme.colors.cursor} style={{ bold: true }}>{"█"}</text>
516
639
  </box>
517
640
  </box>
518
641
  </Show>
@@ -54,7 +54,8 @@ export function ModelPicker(props: { onDone: (model?: string) => void }) {
54
54
  }))
55
55
  if (items.length > 0) {
56
56
  setModels(items)
57
- const curIdx = items.findIndex((m) => m.id === (cfg.model || AIProvider.DEFAULT_MODEL))
57
+ const modelId = cfg.model || AIProvider.DEFAULT_MODEL
58
+ const curIdx = items.findIndex((m) => m.id === modelId || `${m.provider}/${m.id}` === modelId)
58
59
  if (curIdx >= 0) setIdx(curIdx)
59
60
  setStatus(`${items.length} models loaded`)
60
61
  } else {
@@ -123,9 +124,11 @@ export function ModelPicker(props: { onDone: (model?: string) => void }) {
123
124
 
124
125
  async function save(id: string) {
125
126
  try {
127
+ const item = filtered().find((m) => m.id === id)
128
+ const saveId = item && item.provider === "openai-compatible" ? `openai-compatible/${id}` : id
126
129
  const { Config } = await import("../../config")
127
- await Config.save({ model: id })
128
- props.onDone(id)
130
+ await Config.save({ model: saveId })
131
+ props.onDone(saveId)
129
132
  } catch {
130
133
  props.onDone()
131
134
  }
@@ -187,7 +190,7 @@ export function ModelPicker(props: { onDone: (model?: string) => void }) {
187
190
  <text fg={selected() ? "#ffffff" : theme.colors.textMuted}>
188
191
  {" " + m.provider}
189
192
  </text>
190
- {m.id === current() ? (
193
+ {(m.id === current() || `${m.provider}/${m.id}` === current()) ? (
191
194
  <text fg={selected() ? "#ffffff" : theme.colors.success}>{" ← current"}</text>
192
195
  ) : null}
193
196
  </box>