codeblog-app 2.1.6 → 2.2.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/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.6",
4
+ "version": "2.2.0",
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.6",
60
- "codeblog-app-darwin-x64": "2.1.6",
61
- "codeblog-app-linux-arm64": "2.1.6",
62
- "codeblog-app-linux-x64": "2.1.6",
63
- "codeblog-app-windows-x64": "2.1.6"
59
+ "codeblog-app-darwin-arm64": "2.2.0",
60
+ "codeblog-app-darwin-x64": "2.2.0",
61
+ "codeblog-app-linux-arm64": "2.2.0",
62
+ "codeblog-app-linux-x64": "2.2.0",
63
+ "codeblog-app-windows-x64": "2.2.0"
64
64
  },
65
65
  "dependencies": {
66
66
  "@ai-sdk/anthropic": "^3.0.44",
@@ -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"
@@ -21,6 +21,9 @@ After a tool returns results, summarize them naturally for the user.
21
21
  Write casually like a dev talking to another dev. Be specific, opinionated, and genuine.
22
22
  Use code examples when relevant. Think Juejin / HN / Linux.do vibes — not a conference paper.`
23
23
 
24
+ const MAX_TOOL_STEPS = 1
25
+ const IDLE_TIMEOUT_MS = 15_000 // 15s without any stream event → abort
26
+
24
27
  export namespace AIChat {
25
28
  export interface Message {
26
29
  role: "user" | "assistant" | "system"
@@ -38,95 +41,123 @@ export namespace AIChat {
38
41
  export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string, signal?: AbortSignal) {
39
42
  const model = await AIProvider.getModel(modelID)
40
43
  const tools = await getChatTools()
41
- log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length })
44
+ log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length, toolCount: Object.keys(tools).length })
42
45
 
43
- // Build history: only user/assistant text (tool context is added per-step below)
44
- const history: ModelMessage[] = messages
46
+ const history = messages
45
47
  .filter((m) => m.role === "user" || m.role === "assistant")
46
48
  .map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
47
49
  let full = ""
48
50
 
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
- }
51
+ // Create an internal AbortController that we can trigger on idle timeout
52
+ const internalAbort = new AbortController()
53
+ const onExternalAbort = () => {
54
+ log.info("external abort signal received")
55
+ internalAbort.abort()
56
+ }
57
+ signal?.addEventListener("abort", onExternalAbort)
58
+
59
+ const result = streamText({
60
+ model,
61
+ system: SYSTEM_PROMPT,
62
+ messages: history,
63
+ tools,
64
+ stopWhen: stepCountIs(MAX_TOOL_STEPS),
65
+ toolChoice: "auto",
66
+ abortSignal: internalAbort.signal,
67
+ onStepFinish: (stepResult) => {
68
+ log.info("onStepFinish", {
69
+ stepNumber: stepResult.stepNumber,
70
+ finishReason: stepResult.finishReason,
71
+ textLength: stepResult.text?.length ?? 0,
72
+ toolCallsCount: stepResult.toolCalls?.length ?? 0,
73
+ toolResultsCount: stepResult.toolResults?.length ?? 0,
74
+ })
75
+ },
76
+ })
77
+
78
+ let partCount = 0
79
+ let toolExecuting = false
80
+ try {
81
+ // Idle timeout: if no stream events arrive for IDLE_TIMEOUT_MS, abort.
82
+ // Paused during tool execution (tools can take longer than 15s).
83
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
84
+ const resetIdle = () => {
85
+ if (idleTimer) clearTimeout(idleTimer)
86
+ if (toolExecuting) return // Don't start timer while tool is running
87
+ idleTimer = setTimeout(() => {
88
+ log.info("IDLE TIMEOUT FIRED", { partCount, fullLength: full.length })
89
+ internalAbort.abort()
90
+ }, IDLE_TIMEOUT_MS)
91
+ }
92
+ resetIdle()
93
+
94
+ for await (const part of result.fullStream) {
95
+ partCount++
96
+ if (internalAbort.signal.aborted) {
97
+ log.info("abort detected in loop, breaking", { partCount })
98
+ break
99
+ }
100
+ resetIdle()
101
+
102
+ switch (part.type) {
103
+ case "text-delta": {
104
+ const delta = (part as any).text ?? (part as any).textDelta ?? ""
105
+ if (delta) { full += delta; callbacks.onToken?.(delta) }
106
+ break
107
+ }
108
+ case "tool-call": {
109
+ log.info("tool-call", { toolName: (part as any).toolName, partCount })
110
+ // Pause idle timer — tool execution happens between tool-call and tool-result
111
+ toolExecuting = true
112
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = undefined }
113
+ callbacks.onToolCall?.((part as any).toolName, (part as any).input ?? (part as any).args)
114
+ break
94
115
  }
116
+ case "tool-result": {
117
+ log.info("tool-result", { toolName: (part as any).toolName, partCount })
118
+ toolExecuting = false
119
+ callbacks.onToolResult?.((part as any).toolName, (part as any).output ?? (part as any).result ?? {})
120
+ break
121
+ }
122
+ case "tool-error" as any: {
123
+ log.error("tool-error", { toolName: (part as any).toolName, error: String((part as any).error).slice(0, 500) })
124
+ toolExecuting = false
125
+ break
126
+ }
127
+ case "error": {
128
+ const msg = (part as any).error instanceof Error ? (part as any).error.message : String((part as any).error)
129
+ log.error("stream part error", { error: msg })
130
+ callbacks.onError?.((part as any).error instanceof Error ? (part as any).error : new Error(msg))
131
+ break
132
+ }
133
+ default:
134
+ break
95
135
  }
96
- } catch (err) {
97
- const error = err instanceof Error ? err : new Error(String(err))
98
- log.error("stream error", { error: error.message })
136
+ }
137
+
138
+ if (idleTimer) clearTimeout(idleTimer)
139
+ log.info("for-await loop exited normally", { partCount, fullLength: full.length })
140
+ } catch (err) {
141
+ const error = err instanceof Error ? err : new Error(String(err))
142
+ log.info("catch block entered", { name: error.name, message: error.message.slice(0, 200), partCount })
143
+ // Don't treat abort as a real error
144
+ if (error.name !== "AbortError") {
145
+ log.error("stream error (non-abort)", { error: error.message })
99
146
  if (callbacks.onError) callbacks.onError(error)
100
147
  else throw error
101
- return full
148
+ } else {
149
+ log.info("AbortError caught — treating as normal completion")
102
150
  }
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) })
151
+ // On abort or error, still call onFinish so UI cleans up
152
+ log.info("calling onFinish from catch", { fullLength: full.length })
153
+ callbacks.onFinish?.(full || "(No response)")
154
+ return full
155
+ } finally {
156
+ log.info("finally block", { partCount, fullLength: full.length })
157
+ signal?.removeEventListener("abort", onExternalAbort)
128
158
  }
129
159
 
160
+ log.info("calling onFinish from normal path", { fullLength: full.length })
130
161
  callbacks.onFinish?.(full || "(No response)")
131
162
  return full
132
163
  }
@@ -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
  }
@@ -175,6 +177,26 @@ export namespace AIProvider {
175
177
  const clean = baseURL.replace(/\/+$/, "")
176
178
  opts.baseURL = clean.endsWith("/v1") ? clean : `${clean}/v1`
177
179
  }
180
+ // For openai-compatible providers, normalize request body for broader compatibility
181
+ if (pkg === "@ai-sdk/openai-compatible") {
182
+ opts.transformRequestBody = (body: Record<string, any>) => {
183
+ // Remove parallel_tool_calls — many proxies/providers don't support it
184
+ delete body.parallel_tool_calls
185
+
186
+ // Ensure all tool schemas have type: "object" (required by DeepSeek/Qwen/etc.)
187
+ if (Array.isArray(body.tools)) {
188
+ for (const t of body.tools) {
189
+ const params = t?.function?.parameters
190
+ if (params && !params.type) {
191
+ params.type = "object"
192
+ if (!params.properties) params.properties = {}
193
+ }
194
+ }
195
+ }
196
+
197
+ return body
198
+ }
199
+ }
178
200
  sdk = createFn(opts)
179
201
  sdkCache.set(cacheKey, sdk)
180
202
  }
@@ -188,6 +210,22 @@ export namespace AIProvider {
188
210
  return (sdk as any)(modelID)
189
211
  }
190
212
 
213
+ async function fetchRemoteModels(base: string, key: string): Promise<string[]> {
214
+ try {
215
+ const clean = base.replace(/\/+$/, "")
216
+ const url = clean.endsWith("/v1") ? `${clean}/models` : `${clean}/v1/models`
217
+ const r = await fetch(url, {
218
+ headers: { Authorization: `Bearer ${key}` },
219
+ signal: AbortSignal.timeout(8000),
220
+ })
221
+ if (!r.ok) return []
222
+ const data = await r.json() as { data?: Array<{ id: string }> }
223
+ return data.data?.map((m) => m.id) ?? []
224
+ } catch {
225
+ return []
226
+ }
227
+ }
228
+
191
229
  function noKeyError(providerID: string): Error {
192
230
  const envKeys = PROVIDER_ENV[providerID] || []
193
231
  const envHint = envKeys[0] || `${providerID.toUpperCase().replace(/-/g, "_")}_API_KEY`
@@ -225,6 +263,19 @@ export namespace AIProvider {
225
263
  const apiKey = await getApiKey(model.providerID)
226
264
  result.push({ model, hasKey: !!apiKey })
227
265
  }
266
+ // Include remote models from openai-compatible provider
267
+ const compatKey = await getApiKey("openai-compatible")
268
+ const compatBase = await getBaseUrl("openai-compatible")
269
+ if (compatKey && compatBase) {
270
+ const remoteModels = await fetchRemoteModels(compatBase, compatKey)
271
+ for (const id of remoteModels) {
272
+ if (BUILTIN_MODELS[id]) continue
273
+ result.push({
274
+ model: { id, providerID: "openai-compatible", name: id, contextWindow: 0, outputTokens: 0 },
275
+ hasKey: true,
276
+ })
277
+ }
278
+ }
228
279
  return result
229
280
  }
230
281
 
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,29 @@ 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
+ try {
95
+ const result = await mcp(name, clean(args))
96
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result)
97
+ log.info("execute tool result", { name, resultType: typeof result, resultLength: resultStr.length, resultPreview: resultStr.slice(0, 300) })
98
+ // Truncate very large tool results to avoid overwhelming the LLM context
99
+ if (resultStr.length > 8000) {
100
+ log.info("truncating large tool result", { name, originalLength: resultStr.length })
101
+ return resultStr.slice(0, 8000) + "\n...(truncated)"
102
+ }
103
+ return resultStr
104
+ } catch (err) {
105
+ const msg = err instanceof Error ? err.message : String(err)
106
+ log.error("execute tool error", { name, error: msg })
107
+ return JSON.stringify({ error: msg })
108
+ }
109
+ },
81
110
  })
82
111
  }
83
112
 
@@ -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 () => {
@@ -5,6 +5,39 @@ import fs from "fs/promises"
5
5
  import path from "path"
6
6
  import os from "os"
7
7
 
8
+ const DIM = "\x1b[90m"
9
+ const RESET = "\x1b[0m"
10
+ const BOLD = "\x1b[1m"
11
+ const RED = "\x1b[91m"
12
+ const GREEN = "\x1b[92m"
13
+ const YELLOW = "\x1b[93m"
14
+ const CYAN = "\x1b[36m"
15
+
16
+ const W = 60 // inner width of the box
17
+ const BAR = `${DIM}│${RESET}`
18
+
19
+ /** Strip ANSI escape sequences to get visible character length */
20
+ function visLen(s: string): number {
21
+ return s.replace(/\x1b\[[0-9;]*m/g, "").length
22
+ }
23
+
24
+ function line(text = "") {
25
+ const pad = Math.max(0, W - visLen(text) - 1)
26
+ console.log(` ${BAR} ${text}${" ".repeat(pad)}${BAR}`)
27
+ }
28
+
29
+ function lineSuccess(text: string) {
30
+ line(`${GREEN}✓${RESET} ${text}`)
31
+ }
32
+
33
+ function lineWarn(text: string) {
34
+ line(`${YELLOW}⚠${RESET} ${text}`)
35
+ }
36
+
37
+ function lineInfo(text: string) {
38
+ line(`${DIM}${text}${RESET}`)
39
+ }
40
+
8
41
  export const UninstallCommand: CommandModule = {
9
42
  command: "uninstall",
10
43
  describe: "Uninstall codeblog CLI and remove all local data",
@@ -15,35 +48,86 @@ export const UninstallCommand: CommandModule = {
15
48
  default: false,
16
49
  }),
17
50
  handler: async (args) => {
18
- UI.println("")
19
- UI.warn("This will uninstall codeblog from your system.")
20
-
21
- if (!(args["keep-data"] as boolean)) {
22
- UI.println(` The following directories will be removed:`)
23
- UI.println(` ${UI.Style.TEXT_DIM}${Global.Path.config}${UI.Style.TEXT_NORMAL}`)
24
- UI.println(` ${UI.Style.TEXT_DIM}${Global.Path.data}${UI.Style.TEXT_NORMAL}`)
25
- UI.println(` ${UI.Style.TEXT_DIM}${Global.Path.cache}${UI.Style.TEXT_NORMAL}`)
26
- UI.println(` ${UI.Style.TEXT_DIM}${Global.Path.state}${UI.Style.TEXT_NORMAL}`)
51
+ const keepData = args["keep-data"] as boolean
52
+ const binPath = process.execPath
53
+ const pkg = await import("../../../package.json")
54
+
55
+ console.log(UI.logo())
56
+
57
+ // Top border
58
+ console.log(` ${DIM}┌${"─".repeat(W)}┐${RESET}`)
59
+ line()
60
+ line(`${RED}${BOLD}Uninstall CodeBlog${RESET} ${DIM}v${pkg.version}${RESET}`)
61
+ line()
62
+
63
+ // Show what will be removed
64
+ line(`${BOLD}The following will be removed:${RESET}`)
65
+ line()
66
+ line(` ${DIM}Binary${RESET} ${binPath}`)
67
+
68
+ if (!keepData) {
69
+ const dirs = [Global.Path.config, Global.Path.data, Global.Path.cache, Global.Path.state]
70
+ for (const dir of dirs) {
71
+ const label = dir.includes("config") ? "Config" : dir.includes("data") || dir.includes("share") ? "Data" : dir.includes("cache") ? "Cache" : "State"
72
+ try {
73
+ await fs.access(dir)
74
+ line(` ${DIM}${label.padEnd(10)}${RESET}${dir}`)
75
+ } catch {
76
+ // dir doesn't exist, skip
77
+ }
78
+ }
27
79
  }
28
- UI.println("")
29
80
 
30
- const answer = await UI.input(` Type "yes" to confirm: `)
81
+ if (os.platform() !== "win32") {
82
+ const rcFiles = getShellRcFiles()
83
+ for (const rc of rcFiles) {
84
+ try {
85
+ const content = await fs.readFile(rc, "utf-8")
86
+ if (content.includes("# codeblog")) {
87
+ line(` ${DIM}Shell RC${RESET} ${rc} ${DIM}(PATH entry)${RESET}`)
88
+ }
89
+ } catch {}
90
+ }
91
+ }
92
+
93
+ line()
94
+
95
+ // Separator
96
+ console.log(` ${DIM}├${"─".repeat(W)}┤${RESET}`)
97
+ line()
98
+
99
+ // Confirm
100
+ line(`${BOLD}Type "yes" to confirm uninstall:${RESET}`)
101
+ process.stderr.write(` ${BAR} ${DIM}> ${RESET}`)
102
+ const answer = await readLine()
103
+ // Print the line with right border after input
104
+ const inputDisplay = answer || ""
105
+ const inputLine = `${DIM}> ${RESET}${inputDisplay}`
106
+ const inputPad = Math.max(0, W - visLen(inputLine) - 1)
107
+ process.stderr.write(`\x1b[A\r ${BAR} ${inputLine}${" ".repeat(inputPad)}${BAR}\n`)
108
+
31
109
  if (answer.toLowerCase() !== "yes") {
32
- UI.info("Uninstall cancelled.")
110
+ line()
111
+ line(`Uninstall cancelled.`)
112
+ line()
113
+ console.log(` ${DIM}└${"─".repeat(W)}┘${RESET}`)
114
+ console.log("")
33
115
  return
34
116
  }
35
117
 
36
- UI.println("")
118
+ line()
37
119
 
120
+ // Execute uninstall steps
38
121
  // 1. Remove data directories
39
- if (!(args["keep-data"] as boolean)) {
122
+ if (!keepData) {
40
123
  const dirs = [Global.Path.config, Global.Path.data, Global.Path.cache, Global.Path.state]
41
124
  for (const dir of dirs) {
42
125
  try {
126
+ await fs.access(dir)
43
127
  await fs.rm(dir, { recursive: true, force: true })
44
- UI.success(`Removed ${dir}`)
128
+ lineSuccess(`Removed ${dir}`)
45
129
  } catch {
46
- // ignore if already gone
130
+ // dir doesn't exist
47
131
  }
48
132
  }
49
133
  }
@@ -53,67 +137,109 @@ export const UninstallCommand: CommandModule = {
53
137
  await cleanShellRc()
54
138
  }
55
139
 
56
- // 3. Remove the binary itself
57
- const binPath = process.execPath
140
+ // 3. Remove the binary
58
141
  const binDir = path.dirname(binPath)
59
142
 
60
143
  if (os.platform() === "win32") {
61
- // Windows: can't delete running exe, schedule removal
62
- UI.info(`Binary at ${binPath}`)
63
- UI.info("On Windows, please delete the binary manually after this process exits:")
64
- UI.println(` ${UI.Style.TEXT_HIGHLIGHT}del "${binPath}"${UI.Style.TEXT_NORMAL}`)
65
-
66
- // Try to remove from PATH
144
+ lineInfo(`Binary at ${binPath}`)
145
+ lineWarn(`On Windows, delete manually after exit:`)
146
+ line(` ${CYAN}del "${binPath}"${RESET}`)
67
147
  await cleanWindowsPath(binDir)
68
148
  } else {
69
149
  try {
70
150
  await fs.unlink(binPath)
71
- UI.success(`Removed binary: ${binPath}`)
151
+ lineSuccess(`Removed ${binPath}`)
72
152
  } catch (e: any) {
73
153
  if (e.code === "EBUSY" || e.code === "ETXTBSY") {
74
- // Binary is running, schedule delete via shell
75
154
  const { spawn } = await import("child_process")
76
155
  spawn("sh", ["-c", `sleep 1 && rm -f "${binPath}"`], {
77
156
  detached: true,
78
157
  stdio: "ignore",
79
158
  }).unref()
80
- UI.success(`Binary will be removed: ${binPath}`)
159
+ lineSuccess(`Binary will be removed: ${binPath}`)
81
160
  } else {
82
- UI.warn(`Could not remove binary: ${e.message}`)
83
- UI.println(` Remove it manually: ${UI.Style.TEXT_HIGHLIGHT}rm "${binPath}"${UI.Style.TEXT_NORMAL}`)
161
+ lineWarn(`Could not remove binary: ${e.message}`)
162
+ line(` Run manually: ${CYAN}rm "${binPath}"${RESET}`)
84
163
  }
85
164
  }
86
165
  }
87
166
 
88
- UI.println("")
89
- UI.success("codeblog has been uninstalled. Goodbye!")
90
- UI.println("")
167
+ line()
168
+
169
+ // Separator
170
+ console.log(` ${DIM}├${"─".repeat(W)}┤${RESET}`)
171
+ line()
172
+ line(`${GREEN}${BOLD}CodeBlog has been uninstalled.${RESET} Goodbye!`)
173
+ line()
174
+
175
+ // Bottom border
176
+ console.log(` ${DIM}└${"─".repeat(W)}┘${RESET}`)
177
+ console.log("")
91
178
  },
92
179
  }
93
180
 
94
- async function cleanShellRc() {
181
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
182
+
183
+ function readLine(): Promise<string> {
184
+ const stdin = process.stdin
185
+ return new Promise((resolve) => {
186
+ const wasRaw = stdin.isRaw
187
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
188
+
189
+ let buf = ""
190
+ const onData = (ch: Buffer) => {
191
+ const c = ch.toString("utf8")
192
+ if (c === "\u0003") {
193
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
194
+ stdin.removeListener("data", onData)
195
+ process.exit(130)
196
+ }
197
+ if (c === "\r" || c === "\n") {
198
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
199
+ stdin.removeListener("data", onData)
200
+ process.stderr.write("\n")
201
+ resolve(buf)
202
+ return
203
+ }
204
+ if (c === "\u007f" || c === "\b") {
205
+ if (buf.length > 0) {
206
+ buf = buf.slice(0, -1)
207
+ process.stderr.write("\b \b")
208
+ }
209
+ return
210
+ }
211
+ const clean = c.replace(/[\x00-\x1f\x7f]/g, "")
212
+ if (clean) {
213
+ buf += clean
214
+ process.stderr.write(clean)
215
+ }
216
+ }
217
+ stdin.on("data", onData)
218
+ })
219
+ }
220
+
221
+ function getShellRcFiles(): string[] {
95
222
  const home = os.homedir()
96
- const rcFiles = [
223
+ return [
97
224
  path.join(home, ".zshrc"),
98
225
  path.join(home, ".bashrc"),
99
226
  path.join(home, ".profile"),
100
227
  ]
228
+ }
101
229
 
102
- for (const rc of rcFiles) {
230
+ async function cleanShellRc() {
231
+ for (const rc of getShellRcFiles()) {
103
232
  try {
104
233
  const content = await fs.readFile(rc, "utf-8")
105
234
  if (!content.includes("# codeblog")) continue
106
235
 
107
- // Remove the "# codeblog" line and the export PATH line that follows
108
236
  const lines = content.split("\n")
109
237
  const filtered: string[] = []
110
238
  for (let i = 0; i < lines.length; i++) {
111
239
  if (lines[i]!.trim() === "# codeblog") {
112
- // Skip this line and the next export PATH line
113
240
  if (i + 1 < lines.length && lines[i + 1]!.includes("export PATH=")) {
114
- i++ // skip next line too
241
+ i++
115
242
  }
116
- // Also skip a preceding blank line if present
117
243
  if (filtered.length > 0 && filtered[filtered.length - 1]!.trim() === "") {
118
244
  filtered.pop()
119
245
  }
@@ -123,7 +249,7 @@ async function cleanShellRc() {
123
249
  }
124
250
 
125
251
  await fs.writeFile(rc, filtered.join("\n"), "utf-8")
126
- UI.success(`Cleaned PATH entry from ${rc}`)
252
+ lineSuccess(`Cleaned PATH from ${rc}`)
127
253
  } catch {
128
254
  // file doesn't exist or not readable
129
255
  }
@@ -136,7 +262,6 @@ async function cleanWindowsPath(binDir: string) {
136
262
  const { promisify } = await import("util")
137
263
  const execAsync = promisify(exec)
138
264
 
139
- // Read current user PATH
140
265
  const { stdout } = await execAsync(
141
266
  `powershell -Command "[Environment]::GetEnvironmentVariable('Path','User')"`,
142
267
  )
@@ -148,9 +273,9 @@ async function cleanWindowsPath(binDir: string) {
148
273
  await execAsync(
149
274
  `powershell -Command "[Environment]::SetEnvironmentVariable('Path','${newPath}','User')"`,
150
275
  )
151
- UI.success(`Removed ${binDir} from user PATH`)
276
+ lineSuccess(`Removed ${binDir} from user PATH`)
152
277
  }
153
278
  } catch {
154
- UI.warn("Could not clean PATH. You may need to remove it manually from System Settings.")
279
+ lineWarn("Could not clean PATH. Remove manually from System Settings.")
155
280
  }
156
281
  }
@@ -156,14 +156,24 @@ export function Home(props: {
156
156
  const { AIChat } = await import("../../ai/chat")
157
157
  const { Config } = await import("../../config")
158
158
  const { AIProvider } = await import("../../ai/provider")
159
+ const { Log } = await import("../../util/log")
160
+ const sendLog = Log.create({ service: "home-send" })
159
161
  const cfg = await Config.load()
160
162
  const mid = cfg.model || AIProvider.DEFAULT_MODEL
161
163
  const allMsgs = [...prev, userMsg].filter((m) => m.role !== "tool").map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
162
164
  let full = ""
165
+ let hasToolCalls = false
166
+ let lastToolName = ""
167
+ let lastToolResult = ""
168
+ let summaryStreamActive = false
163
169
  abortCtrl = new AbortController()
170
+ sendLog.info("calling AIChat.stream", { model: mid, msgCount: allMsgs.length })
164
171
  await AIChat.stream(allMsgs, {
165
172
  onToken: (token) => { full += token; setStreamText(full) },
166
173
  onToolCall: (name) => {
174
+ hasToolCalls = true
175
+ lastToolName = name
176
+ sendLog.info("onToolCall", { name })
167
177
  // Save any accumulated text as assistant message before tool
168
178
  if (full.trim()) {
169
179
  setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
@@ -172,7 +182,12 @@ export function Home(props: {
172
182
  }
173
183
  setMessages((p) => [...p, { role: "tool", content: TOOL_LABELS[name] || name, toolName: name, toolStatus: "running" }])
174
184
  },
175
- onToolResult: (name) => {
185
+ onToolResult: (name, result) => {
186
+ sendLog.info("onToolResult", { name })
187
+ try {
188
+ const str = typeof result === "string" ? result : JSON.stringify(result)
189
+ lastToolResult = str.slice(0, 6000)
190
+ } catch { lastToolResult = "" }
176
191
  setMessages((p) => p.map((m) =>
177
192
  m.role === "tool" && m.toolName === name && m.toolStatus === "running"
178
193
  ? { ...m, toolStatus: "done" as const }
@@ -180,13 +195,55 @@ export function Home(props: {
180
195
  ))
181
196
  },
182
197
  onFinish: () => {
198
+ sendLog.info("onFinish", { fullLength: full.length, hasToolCalls, hasToolResult: !!lastToolResult })
183
199
  if (full.trim()) {
184
200
  setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
201
+ setStreamText(""); setStreaming(false)
202
+ saveChat()
203
+ } else if (hasToolCalls && lastToolResult) {
204
+ // Tool executed but model didn't summarize — send a follow-up request
205
+ // to have the model produce a natural-language summary
206
+ sendLog.info("auto-summarizing tool result", { tool: lastToolName })
207
+ full = ""
208
+ setStreamText("")
209
+ const summaryMsgs = [
210
+ ...allMsgs,
211
+ { role: "assistant" as const, content: `I used the ${lastToolName} tool. Here are the results:\n${lastToolResult}` },
212
+ { role: "user" as const, content: "Please summarize these results in a helpful, natural way." },
213
+ ]
214
+ // NOTE: intentionally not awaited — the outer await resolves here,
215
+ // but streaming state is managed by the inner callbacks.
216
+ // The finally block must NOT reset streaming in this path.
217
+ summaryStreamActive = true
218
+ AIChat.stream(summaryMsgs, {
219
+ onToken: (token) => { full += token; setStreamText(full) },
220
+ onFinish: () => {
221
+ if (full.trim()) {
222
+ setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
223
+ } else {
224
+ setMessages((p) => [...p, { role: "assistant", content: "(Tool executed — model did not respond)" }])
225
+ }
226
+ setStreamText(""); setStreaming(false)
227
+ saveChat()
228
+ },
229
+ onError: (err) => {
230
+ sendLog.info("summary stream error", { message: err.message })
231
+ setMessages((p) => [...p, { role: "assistant", content: `Tool result received but summary failed: ${err.message}` }])
232
+ setStreamText(""); setStreaming(false)
233
+ saveChat()
234
+ },
235
+ }, mid, abortCtrl?.signal)
236
+ } else if (hasToolCalls) {
237
+ setMessages((p) => [...p, { role: "assistant", content: "(Tool executed — no response from model)" }])
238
+ setStreamText(""); setStreaming(false)
239
+ saveChat()
240
+ } else {
241
+ setStreamText(""); setStreaming(false)
242
+ saveChat()
185
243
  }
186
- setStreamText(""); setStreaming(false)
187
- saveChat()
188
244
  },
189
245
  onError: (err) => {
246
+ sendLog.info("onError", { message: err.message })
190
247
  setMessages((p) => {
191
248
  // Mark any running tools as error
192
249
  const updated = p.map((m) =>
@@ -200,13 +257,19 @@ export function Home(props: {
200
257
  saveChat()
201
258
  },
202
259
  }, mid, abortCtrl.signal)
260
+ sendLog.info("AIChat.stream returned normally")
203
261
  abortCtrl = undefined
204
262
  } catch (err) {
205
263
  const msg = err instanceof Error ? err.message : String(err)
264
+ // Can't use sendLog here because it might not be in scope
206
265
  setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
207
- setStreamText("")
208
- setStreaming(false)
209
266
  saveChat()
267
+ } finally {
268
+ // Clean up streaming state — but NOT if a summary stream is still running
269
+ if (!summaryStreamActive) {
270
+ setStreamText("")
271
+ setStreaming(false)
272
+ }
210
273
  }
211
274
  }
212
275
 
@@ -392,7 +455,7 @@ export function Home(props: {
392
455
 
393
456
  {/* When chatting: messages fill the space */}
394
457
  <Show when={chatting()}>
395
- <box flexDirection="column" flexGrow={1} paddingTop={1} overflow="scroll">
458
+ <scrollbox flexGrow={1} paddingTop={1} stickyScroll={true} stickyStart="bottom">
396
459
  <For each={messages()}>
397
460
  {(msg) => (
398
461
  <box flexShrink={0}>
@@ -402,7 +465,7 @@ export function Home(props: {
402
465
  <text fg={theme.colors.primary} flexShrink={0}>
403
466
  <span style={{ bold: true }}>{"❯ "}</span>
404
467
  </text>
405
- <text fg={theme.colors.text}>
468
+ <text fg={theme.colors.text} wrapMode="word" flexGrow={1} flexShrink={1}>
406
469
  <span style={{ bold: true }}>{msg.content}</span>
407
470
  </text>
408
471
  </box>
@@ -424,23 +487,27 @@ export function Home(props: {
424
487
  <text fg={theme.colors.success} flexShrink={0}>
425
488
  <span style={{ bold: true }}>{"◆ "}</span>
426
489
  </text>
427
- <text fg={theme.colors.text}>{msg.content}</text>
490
+ <text fg={theme.colors.text} wrapMode="word" flexGrow={1} flexShrink={1}>{msg.content}</text>
428
491
  </box>
429
492
  </Show>
430
493
  </box>
431
494
  )}
432
495
  </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()}
440
- </text>
441
- </box>
442
- </Show>
443
- </box>
496
+ <box
497
+ flexDirection="row"
498
+ paddingBottom={streaming() ? 1 : 0}
499
+ flexShrink={0}
500
+ height={streaming() ? undefined : 0}
501
+ overflow="hidden"
502
+ >
503
+ <text fg={theme.colors.success} flexShrink={0}>
504
+ <span style={{ bold: true }}>{streaming() ? "◆ " : ""}</span>
505
+ </text>
506
+ <text fg={streamText() ? theme.colors.text : theme.colors.textMuted} wrapMode="word" flexGrow={1} flexShrink={1}>
507
+ {streaming() ? (streamText() || shimmerText()) : ""}
508
+ </text>
509
+ </box>
510
+ </scrollbox>
444
511
  </Show>
445
512
 
446
513
  {/* Spacer when no chat and no autocomplete */}
@@ -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>