codeblog-app 2.2.6 → 2.3.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.
Files changed (39) hide show
  1. package/package.json +9 -7
  2. package/src/ai/__tests__/chat.test.ts +11 -2
  3. package/src/ai/__tests__/compat.test.ts +46 -0
  4. package/src/ai/__tests__/home.ai-stream.integration.test.ts +77 -0
  5. package/src/ai/__tests__/provider-registry.test.ts +61 -0
  6. package/src/ai/__tests__/provider.test.ts +58 -18
  7. package/src/ai/__tests__/stream-events.test.ts +152 -0
  8. package/src/ai/chat.ts +200 -88
  9. package/src/ai/configure.ts +13 -4
  10. package/src/ai/models.ts +26 -0
  11. package/src/ai/provider-registry.ts +150 -0
  12. package/src/ai/provider.ts +99 -137
  13. package/src/ai/stream-events.ts +64 -0
  14. package/src/ai/tools.ts +10 -6
  15. package/src/ai/types.ts +105 -0
  16. package/src/auth/index.ts +3 -1
  17. package/src/auth/oauth.ts +17 -2
  18. package/src/cli/__tests__/commands.test.ts +6 -2
  19. package/src/cli/cmd/ai.ts +10 -0
  20. package/src/cli/cmd/setup.ts +275 -5
  21. package/src/cli/ui.ts +131 -24
  22. package/src/config/index.ts +38 -1
  23. package/src/index.ts +4 -1
  24. package/src/mcp/__tests__/client.test.ts +2 -2
  25. package/src/mcp/__tests__/e2e.ts +10 -6
  26. package/src/mcp/client.ts +33 -63
  27. package/src/storage/chat.ts +3 -1
  28. package/src/tui/__tests__/input-intent.test.ts +27 -0
  29. package/src/tui/__tests__/stream-assembler.test.ts +33 -0
  30. package/src/tui/ai-stream.ts +28 -0
  31. package/src/tui/app.tsx +27 -1
  32. package/src/tui/commands.ts +41 -7
  33. package/src/tui/context/theme.tsx +2 -1
  34. package/src/tui/input-intent.ts +26 -0
  35. package/src/tui/routes/home.tsx +590 -190
  36. package/src/tui/routes/setup.tsx +20 -8
  37. package/src/tui/stream-assembler.ts +49 -0
  38. package/src/util/log.ts +3 -1
  39. package/tsconfig.json +1 -1
@@ -1,13 +1,16 @@
1
- import { createSignal, createMemo, createEffect, onCleanup, Show, For } from "solid-js"
1
+ import { createSignal, createMemo, createEffect, onCleanup, onMount, untrack, Show, For } from "solid-js"
2
2
  import { useKeyboard, usePaste } from "@opentui/solid"
3
3
  import { SyntaxStyle, type ThemeTokenStyle } from "@opentui/core"
4
- import { useRoute } from "../context/route"
5
4
  import { useExit } from "../context/exit"
6
5
  import { useTheme, type ThemeColors } from "../context/theme"
7
6
  import { createCommands, LOGO, TIPS, TIPS_NO_AI } from "../commands"
8
7
  import { TOOL_LABELS } from "../../ai/tools"
9
8
  import { mask, saveProvider } from "../../ai/configure"
10
9
  import { ChatHistory } from "../../storage/chat"
10
+ import { TuiStreamAssembler } from "../stream-assembler"
11
+ import { resolveAssistantContent } from "../ai-stream"
12
+ import { isShiftEnterSequence, onInputIntent } from "../input-intent"
13
+ import { Log } from "../../util/log"
11
14
 
12
15
  function buildMarkdownSyntaxRules(colors: ThemeColors): ThemeTokenStyle[] {
13
16
  return [
@@ -36,12 +39,20 @@ function buildMarkdownSyntaxRules(colors: ThemeColors): ThemeTokenStyle[] {
36
39
  }
37
40
 
38
41
  interface ChatMsg {
39
- role: "user" | "assistant" | "tool"
42
+ role: "user" | "assistant" | "tool" | "system"
40
43
  content: string
44
+ modelContent?: string
45
+ tone?: "info" | "success" | "warning" | "error"
41
46
  toolName?: string
47
+ toolCallID?: string
42
48
  toolStatus?: "running" | "done" | "error"
43
49
  }
44
50
 
51
+ interface ModelOption {
52
+ id: string
53
+ label: string
54
+ }
55
+
45
56
  export function Home(props: {
46
57
  loggedIn: boolean
47
58
  username: string
@@ -53,21 +64,25 @@ export function Home(props: {
53
64
  onLogout: () => void
54
65
  onAIConfigured: () => void
55
66
  }) {
56
- const route = useRoute()
57
67
  const exit = useExit()
58
68
  const theme = useTheme()
59
69
  const [input, setInput] = createSignal("")
60
- const [message, setMessage] = createSignal("")
61
- const [messageColor, setMessageColor] = createSignal("#6a737c")
62
70
  const [selectedIdx, setSelectedIdx] = createSignal(0)
63
71
 
64
72
  const [messages, setMessages] = createSignal<ChatMsg[]>([])
65
73
  const [streaming, setStreaming] = createSignal(false)
66
74
  const [streamText, setStreamText] = createSignal("")
67
75
  let abortCtrl: AbortController | undefined
68
- let escCooldown = 0
76
+ let abortByUser = false
77
+ let shiftDown = false
78
+ let commandDisplay = ""
69
79
  let sessionId = ""
70
80
  const chatting = createMemo(() => messages().length > 0 || streaming())
81
+ const renderRows = createMemo<Array<ChatMsg | { role: "stream" }>>(() => {
82
+ const rows = messages()
83
+ if (!streaming()) return rows
84
+ return [...rows, { role: "stream" as const }]
85
+ })
71
86
  const syntaxStyle = createMemo(() => SyntaxStyle.fromTheme(buildMarkdownSyntaxRules(theme.colors)))
72
87
 
73
88
  function ensureSession() {
@@ -87,7 +102,9 @@ export function Home(props: {
87
102
  if (!sid) {
88
103
  const sessions = ChatHistory.list(1)
89
104
  if (sessions.length === 0) { showMsg("No previous sessions", theme.colors.warning); return }
90
- sid = sessions[0].id
105
+ const latest = sessions[0]
106
+ if (!latest) { showMsg("No previous sessions", theme.colors.warning); return }
107
+ sid = latest.id
91
108
  }
92
109
  const msgs = ChatHistory.load(sid)
93
110
  if (msgs.length === 0) { showMsg("Session is empty", theme.colors.warning); return }
@@ -116,30 +133,175 @@ export function Home(props: {
116
133
  const [aiMode, setAiMode] = createSignal<"" | "url" | "key" | "testing">("")
117
134
  const [aiUrl, setAiUrl] = createSignal("")
118
135
  const [aiKey, setAiKey] = createSignal("")
136
+ const [modelPicking, setModelPicking] = createSignal(false)
137
+ const [modelOptions, setModelOptions] = createSignal<ModelOption[]>([])
138
+ const [modelIdx, setModelIdx] = createSignal(0)
139
+ const [modelQuery, setModelQuery] = createSignal("")
140
+ const [modelLoading, setModelLoading] = createSignal(false)
141
+ let modelPreload: Promise<ModelOption[]> | undefined
142
+ const keyLog = process.env.CODEBLOG_DEBUG_KEYS === "1" ? Log.create({ service: "tui-key" }) : undefined
143
+ const toHex = (value: string) => Array.from(value).map((ch) => ch.charCodeAt(0).toString(16).padStart(2, "0")).join(" ")
144
+
145
+ function tone(color: string): ChatMsg["tone"] {
146
+ if (color === theme.colors.success) return "success"
147
+ if (color === theme.colors.warning) return "warning"
148
+ if (color === theme.colors.error) return "error"
149
+ return "info"
150
+ }
119
151
 
120
152
  function showMsg(text: string, color = "#6a737c") {
121
- setMessage(text)
122
- setMessageColor(color)
153
+ ensureSession()
154
+ setMessages((p) => [...p, { role: "system", content: text, tone: tone(color) }])
123
155
  }
124
156
 
125
157
  function clearChat() {
126
- setMessages([]); setStreamText(""); setStreaming(false); setMessage("")
158
+ abortByUser = true
159
+ abortCtrl?.abort()
160
+ abortCtrl = undefined
161
+ setMessages([])
162
+ setStreamText("")
163
+ setStreaming(false)
164
+ setInput("")
165
+ setSelectedIdx(0)
166
+ setModelPicking(false)
167
+ setModelOptions([])
168
+ setModelIdx(0)
169
+ setModelQuery("")
127
170
  sessionId = ""
128
171
  }
129
172
 
173
+ async function loadModelOptions(force = false): Promise<ModelOption[]> {
174
+ if (!props.hasAI) return []
175
+ const cached = untrack(() => modelOptions())
176
+ if (!force && cached.length > 0) return cached
177
+ if (modelPreload) return modelPreload
178
+
179
+ modelPreload = (async () => {
180
+ try {
181
+ setModelLoading(true)
182
+ const { AIProvider } = await import("../../ai/provider")
183
+ const { Config } = await import("../../config")
184
+ const cfg = await Config.load()
185
+ const current = cfg.model || AIProvider.DEFAULT_MODEL
186
+ const currentBuiltin = AIProvider.BUILTIN_MODELS[current]
187
+ const currentProvider =
188
+ cfg.default_provider ||
189
+ (current.includes("/") ? current.split("/")[0] : currentBuiltin?.providerID) ||
190
+ "openai"
191
+ const providerCfg = cfg.providers?.[currentProvider]
192
+ const providerApi = providerCfg?.api || providerCfg?.compat_profile || (currentProvider === "openai" ? "openai" : "openai-compatible")
193
+ const providerKey = providerCfg?.api_key
194
+ const providerBase = providerCfg?.base_url || (currentProvider === "openai" ? "https://api.openai.com" : "")
195
+
196
+ const remote = await (async () => {
197
+ if (!providerKey || !providerBase) return [] as string[]
198
+ if (providerApi !== "openai" && providerApi !== "openai-compatible") return [] as string[]
199
+ try {
200
+ const clean = providerBase.replace(/\/+$/, "")
201
+ const url = clean.endsWith("/v1") ? `${clean}/models` : `${clean}/v1/models`
202
+ const r = await fetch(url, {
203
+ headers: { Authorization: `Bearer ${providerKey}` },
204
+ signal: AbortSignal.timeout(8000),
205
+ })
206
+ if (!r.ok) return []
207
+ const data = await r.json() as { data?: Array<{ id: string }> }
208
+ return data.data?.map((m) => m.id).filter(Boolean) || []
209
+ } catch {
210
+ return []
211
+ }
212
+ })()
213
+
214
+ const fromRemote = remote.map((id) => {
215
+ const saveId = currentProvider === "openai-compatible" && !id.includes("/") ? `openai-compatible/${id}` : id
216
+ return { id: saveId, label: `${saveId} [${currentProvider}]` }
217
+ })
218
+ const list = await AIProvider.available()
219
+ const fromAvailable = list
220
+ .filter((m) => m.hasKey)
221
+ .map((m) => {
222
+ const id = m.model.providerID === "openai-compatible" ? `openai-compatible/${m.model.id}` : m.model.id
223
+ const provider = m.model.providerID
224
+ return { id, label: `${id}${provider ? ` [${provider}]` : ""}` }
225
+ })
226
+ const unique = Array.from(new Map([...fromRemote, ...fromAvailable].map((m) => [m.id, m])).values())
227
+ setModelOptions(unique)
228
+ return unique
229
+ } finally {
230
+ setModelLoading(false)
231
+ }
232
+ })()
233
+
234
+ const out = await modelPreload
235
+ modelPreload = undefined
236
+ return out
237
+ }
238
+
239
+ async function openModelPicker() {
240
+ if (!props.hasAI) {
241
+ showMsg("/model requires AI. Type /ai to configure.", theme.colors.warning)
242
+ return
243
+ }
244
+ setModelQuery("")
245
+ setModelPicking(true)
246
+ const cached = modelOptions()
247
+ if (cached.length === 0) await loadModelOptions(true)
248
+ else void loadModelOptions(true)
249
+
250
+ const current = props.modelName
251
+ const next = modelOptions()
252
+ if (next.length === 0) {
253
+ setModelPicking(false)
254
+ showMsg("No available models. Configure provider with /ai first.", theme.colors.warning)
255
+ return
256
+ }
257
+ setModelIdx(Math.max(0, next.findIndex((m) => m.id === current)))
258
+ showMsg("Model picker: use ↑/↓ then Enter (Esc to cancel), type to search", theme.colors.textMuted)
259
+ }
260
+
261
+ async function pickModel(id: string) {
262
+ try {
263
+ const { Config } = await import("../../config")
264
+ await Config.save({ model: id })
265
+ props.onAIConfigured()
266
+ showMsg(`Set model to ${id}`, theme.colors.success)
267
+ } catch (err) {
268
+ showMsg(`Failed to switch model: ${err instanceof Error ? err.message : String(err)}`, theme.colors.error)
269
+ } finally {
270
+ setModelPicking(false)
271
+ setModelIdx(0)
272
+ setModelQuery("")
273
+ void loadModelOptions(true)
274
+ }
275
+ }
276
+
277
+ onMount(() => {
278
+ if (!props.hasAI) return
279
+ void loadModelOptions(true)
280
+ })
281
+
282
+ createEffect(() => {
283
+ if (!props.hasAI) {
284
+ setModelOptions([])
285
+ return
286
+ }
287
+ props.modelName
288
+ void loadModelOptions(false)
289
+ })
290
+
130
291
  const commands = createCommands({
131
292
  showMsg,
132
- navigate: route.navigate,
293
+ openModelPicker,
133
294
  exit,
134
295
  onLogin: props.onLogin,
135
296
  onLogout: props.onLogout,
136
297
  clearChat,
137
298
  startAIConfig: () => {
138
299
  setAiUrl(""); setAiKey(""); setAiMode("url")
139
- showMsg("Paste your API URL (or press Enter to skip):", theme.colors.primary)
300
+ showMsg("Quick setup: paste API URL (or press Enter to skip). Full wizard: `codeblog ai setup`", theme.colors.primary)
140
301
  },
141
302
  setMode: theme.setMode,
142
303
  send,
304
+ onAIConfigured: props.onAIConfigured,
143
305
  resume: resumeSession,
144
306
  listSessions: () => { try { return ChatHistory.list(10) } catch { return [] } },
145
307
  hasAI: props.hasAI,
@@ -157,11 +319,72 @@ export function Home(props: {
157
319
  const showAutocomplete = createMemo(() => {
158
320
  const v = input()
159
321
  if (aiMode()) return false
322
+ if (modelPicking()) return false
160
323
  if (!v.startsWith("/")) return false
161
324
  if (v.includes(" ")) return false
162
325
  return filtered().length > 0
163
326
  })
164
327
 
328
+ createEffect(() => {
329
+ const len = filtered().length
330
+ const idx = selectedIdx()
331
+ if (len === 0 && idx !== 0) {
332
+ setSelectedIdx(0)
333
+ return
334
+ }
335
+ if (idx >= len) setSelectedIdx(len - 1)
336
+ })
337
+
338
+ const visibleStart = createMemo(() => {
339
+ const len = filtered().length
340
+ if (len <= 8) return 0
341
+ const max = len - 8
342
+ const idx = selectedIdx()
343
+ return Math.max(0, Math.min(idx - 3, max))
344
+ })
345
+
346
+ const visibleItems = createMemo(() => {
347
+ const start = visibleStart()
348
+ return filtered().slice(start, start + 8)
349
+ })
350
+
351
+ const modelFiltered = createMemo(() => {
352
+ const list = modelOptions()
353
+ const query = modelQuery().trim().toLowerCase()
354
+ if (!query) return list
355
+ return list.filter((m) => m.id.toLowerCase().includes(query) || m.label.toLowerCase().includes(query))
356
+ })
357
+
358
+ const modelVisibleStart = createMemo(() => {
359
+ const len = modelFiltered().length
360
+ if (len <= 8) return 0
361
+ const max = len - 8
362
+ const idx = modelIdx()
363
+ return Math.max(0, Math.min(idx - 3, max))
364
+ })
365
+
366
+ const modelVisibleItems = createMemo(() => {
367
+ const start = modelVisibleStart()
368
+ return modelFiltered().slice(start, start + 8)
369
+ })
370
+
371
+ createEffect(() => {
372
+ const len = modelFiltered().length
373
+ const idx = modelIdx()
374
+ if (len === 0 && idx !== 0) {
375
+ setModelIdx(0)
376
+ return
377
+ }
378
+ if (idx >= len) setModelIdx(len - 1)
379
+ })
380
+
381
+ const offInputIntent = onInputIntent((intent) => {
382
+ if (intent !== "newline" || aiMode()) return
383
+ setInput((s) => s + "\n")
384
+ setSelectedIdx(0)
385
+ })
386
+ onCleanup(() => offInputIntent())
387
+
165
388
  usePaste((evt) => {
166
389
  // For URL/key modes, strip newlines; for normal input, preserve them
167
390
  if (aiMode() === "url" || aiMode() === "key") {
@@ -178,134 +401,124 @@ export function Home(props: {
178
401
  setInput((s) => s + text)
179
402
  })
180
403
 
181
- async function send(text: string) {
404
+ async function send(text: string, options?: { display?: string }) {
182
405
  if (!text.trim() || streaming()) return
183
406
  ensureSession()
184
- const userMsg: ChatMsg = { role: "user", content: text.trim() }
407
+ const prompt = text.trim()
408
+ const userMsg: ChatMsg = { role: "user", content: options?.display || commandDisplay || prompt, modelContent: prompt }
185
409
  const prev = messages()
186
410
  setMessages([...prev, userMsg])
187
411
  setStreaming(true)
188
412
  setStreamText("")
189
- setMessage("")
190
- let summaryStreamActive = false
413
+ abortByUser = false
414
+ const assembler = new TuiStreamAssembler()
415
+ const toolResults: Array<{ name: string; result: string }> = []
416
+ const flushMs = 60
417
+ let flushTimer: ReturnType<typeof setTimeout> | undefined
418
+
419
+ const flushStream = (force = false) => {
420
+ if (force) {
421
+ if (flushTimer) clearTimeout(flushTimer)
422
+ flushTimer = undefined
423
+ setStreamText(assembler.getText())
424
+ return
425
+ }
426
+ if (flushTimer) return
427
+ flushTimer = setTimeout(() => {
428
+ flushTimer = undefined
429
+ setStreamText(assembler.getText())
430
+ }, flushMs)
431
+ }
191
432
 
192
433
  try {
193
434
  const { AIChat } = await import("../../ai/chat")
194
- const { Config } = await import("../../config")
195
435
  const { AIProvider } = await import("../../ai/provider")
196
- const { Log } = await import("../../util/log")
197
- const sendLog = Log.create({ service: "home-send" })
436
+ const { Config } = await import("../../config")
198
437
  const cfg = await Config.load()
199
438
  const mid = cfg.model || AIProvider.DEFAULT_MODEL
200
- const allMsgs = [...prev, userMsg].filter((m) => m.role !== "tool").map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
201
- let full = ""
202
- let hasToolCalls = false
203
- let lastToolName = ""
204
- let lastToolResult = ""
439
+ const allMsgs = [...prev, userMsg]
440
+ .filter((m): m is ChatMsg & { role: "user" | "assistant" } => m.role === "user" || m.role === "assistant")
441
+ .map((m) => ({ role: m.role, content: m.modelContent || m.content }))
205
442
  abortCtrl = new AbortController()
206
- sendLog.info("calling AIChat.stream", { model: mid, msgCount: allMsgs.length })
207
- await AIChat.stream(allMsgs, {
208
- onToken: (token) => { full += token; setStreamText(full) },
209
- onToolCall: (name) => {
443
+
444
+ let hasToolCalls = false
445
+ let finalizedText = ""
446
+ for await (const event of AIChat.streamEvents(allMsgs, mid, abortCtrl.signal, { maxSteps: 10 })) {
447
+ if (event.type === "text-delta") {
448
+ assembler.pushDelta(event.text, event.seq)
449
+ flushStream()
450
+ continue
451
+ }
452
+
453
+ if (event.type === "tool-start") {
210
454
  hasToolCalls = true
211
- lastToolName = name
212
- sendLog.info("onToolCall", { name })
213
- // Save any accumulated text as assistant message before tool
214
- if (full.trim()) {
215
- setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
216
- full = ""
455
+ const partial = assembler.getText().trim()
456
+ if (partial) {
457
+ setMessages((p) => [...p, { role: "assistant", content: partial }])
458
+ assembler.reset()
217
459
  setStreamText("")
218
460
  }
219
- setMessages((p) => [...p, { role: "tool", content: TOOL_LABELS[name] || name, toolName: name, toolStatus: "running" }])
220
- },
221
- onToolResult: (name, result) => {
222
- sendLog.info("onToolResult", { name })
461
+ setMessages((p) => [...p, { role: "tool", content: TOOL_LABELS[event.name] || event.name, toolName: event.name, toolCallID: event.callID, toolStatus: "running" }])
462
+ continue
463
+ }
464
+
465
+ if (event.type === "tool-result") {
223
466
  try {
224
- const str = typeof result === "string" ? result : JSON.stringify(result)
225
- lastToolResult = str.slice(0, 6000)
226
- } catch { lastToolResult = "" }
227
- setMessages((p) => p.map((m) =>
228
- m.role === "tool" && m.toolName === name && m.toolStatus === "running"
229
- ? { ...m, toolStatus: "done" as const }
230
- : m
231
- ))
232
- },
233
- onFinish: () => {
234
- sendLog.info("onFinish", { fullLength: full.length, hasToolCalls, hasToolResult: !!lastToolResult })
235
- if (full.trim()) {
236
- setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
237
- setStreamText(""); setStreaming(false)
238
- saveChat()
239
- } else if (hasToolCalls && lastToolResult) {
240
- // Tool executed but model didn't summarize — send a follow-up request
241
- // to have the model produce a natural-language summary
242
- sendLog.info("auto-summarizing tool result", { tool: lastToolName })
243
- full = ""
244
- setStreamText("")
245
- const summaryMsgs = [
246
- ...allMsgs,
247
- { role: "assistant" as const, content: `I used the ${lastToolName} tool. Here are the results:\n${lastToolResult}` },
248
- { role: "user" as const, content: "Please summarize these results in a helpful, natural way." },
249
- ]
250
- // NOTE: intentionally not awaited — the outer await resolves here,
251
- // but streaming state is managed by the inner callbacks.
252
- // The finally block must NOT reset streaming in this path.
253
- summaryStreamActive = true
254
- AIChat.stream(summaryMsgs, {
255
- onToken: (token) => { full += token; setStreamText(full) },
256
- onFinish: () => {
257
- if (full.trim()) {
258
- setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
259
- } else {
260
- setMessages((p) => [...p, { role: "assistant", content: "(Tool executed — model did not respond)" }])
261
- }
262
- setStreamText(""); setStreaming(false)
263
- saveChat()
264
- },
265
- onError: (err) => {
266
- sendLog.info("summary stream error", { message: err.message })
267
- setMessages((p) => [...p, { role: "assistant", content: `Tool result received but summary failed: ${err.message}` }])
268
- setStreamText(""); setStreaming(false)
269
- saveChat()
270
- },
271
- }, mid, abortCtrl?.signal)
272
- } else if (hasToolCalls) {
273
- setMessages((p) => [...p, { role: "assistant", content: "(Tool executed — no response from model)" }])
274
- setStreamText(""); setStreaming(false)
275
- saveChat()
276
- } else {
277
- setStreamText(""); setStreaming(false)
278
- saveChat()
279
- }
280
- },
281
- onError: (err) => {
282
- sendLog.info("onError", { message: err.message })
467
+ const str = typeof event.result === "string" ? event.result : JSON.stringify(event.result)
468
+ toolResults.push({ name: event.name, result: str.slice(0, 1200) })
469
+ } catch {}
470
+ setMessages((p) => {
471
+ let matched = false
472
+ const next = p.map((m) => {
473
+ if (m.role !== "tool" || m.toolStatus !== "running") return m
474
+ if (m.toolCallID !== event.callID) return m
475
+ matched = true
476
+ return { ...m, toolStatus: "done" as const }
477
+ })
478
+ if (matched) return next
479
+ return p.map((m) =>
480
+ m.role === "tool" && m.toolName === event.name && m.toolStatus === "running"
481
+ ? { ...m, toolStatus: "done" as const }
482
+ : m
483
+ )
484
+ })
485
+ continue
486
+ }
487
+
488
+ if (event.type === "error") {
283
489
  setMessages((p) => {
284
- // Mark any running tools as error
285
490
  const updated = p.map((m) =>
286
491
  m.role === "tool" && m.toolStatus === "running"
287
492
  ? { ...m, toolStatus: "error" as const }
288
493
  : m
289
494
  )
290
- return [...updated, { role: "assistant" as const, content: `Error: ${err.message}` }]
495
+ return [...updated, { role: "assistant" as const, content: `Error: ${event.error.message}` }]
291
496
  })
292
- setStreamText(""); setStreaming(false)
293
- saveChat()
294
- },
295
- }, mid, abortCtrl.signal, { maxSteps: 10 })
296
- sendLog.info("AIChat.stream returned normally")
297
- abortCtrl = undefined
497
+ continue
498
+ }
499
+
500
+ if (event.type === "run-finish") {
501
+ finalizedText = assembler.pushFinal(event.text).trim()
502
+ flushStream(true)
503
+ const content = resolveAssistantContent({
504
+ finalText: finalizedText,
505
+ aborted: event.aborted,
506
+ abortByUser,
507
+ hasToolCalls,
508
+ toolResults,
509
+ })
510
+ if (content) setMessages((p) => [...p, { role: "assistant", content }])
511
+ }
512
+ }
298
513
  } catch (err) {
299
514
  const msg = err instanceof Error ? err.message : String(err)
300
- // Can't use sendLog here because it might not be in scope
301
515
  setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
302
- saveChat()
303
516
  } finally {
304
- // Clean up streaming state — but NOT if a summary stream is still running
305
- if (!summaryStreamActive) {
306
- setStreamText("")
307
- setStreaming(false)
308
- }
517
+ if (flushTimer) clearTimeout(flushTimer)
518
+ abortCtrl = undefined
519
+ setStreamText("")
520
+ setStreaming(false)
521
+ saveChat()
309
522
  }
310
523
  }
311
524
 
@@ -368,7 +581,12 @@ export function Home(props: {
368
581
  }
369
582
  setInput("")
370
583
  setSelectedIdx(0)
371
- sel.action(sel.name.split(/\s+/))
584
+ commandDisplay = sel.name
585
+ try {
586
+ await sel.action(sel.name.split(/\s+/))
587
+ } finally {
588
+ commandDisplay = ""
589
+ }
372
590
  return
373
591
  }
374
592
  }
@@ -387,7 +605,12 @@ export function Home(props: {
387
605
  showMsg(`${cmd} requires AI. Type /ai to configure.`, theme.colors.warning)
388
606
  return
389
607
  }
390
- match.action(parts)
608
+ commandDisplay = text
609
+ try {
610
+ await match.action(parts)
611
+ } finally {
612
+ commandDisplay = ""
613
+ }
391
614
  return
392
615
  }
393
616
  if (cmd === "/quit" || cmd === "/q") { exit(); return }
@@ -400,13 +623,58 @@ export function Home(props: {
400
623
  return
401
624
  }
402
625
 
403
- send(text)
626
+ send(text, { display: text })
404
627
  }
405
628
 
406
629
  useKeyboard((evt) => {
630
+ if (evt.name === "leftshift" || evt.name === "rightshift" || evt.name === "shift") {
631
+ shiftDown = evt.eventType !== "release"
632
+ evt.preventDefault()
633
+ return
634
+ }
635
+ if (evt.eventType === "release") return
636
+
637
+ const submitKey = evt.name === "return" || evt.name === "enter"
638
+ const raw = evt.raw || ""
639
+ const seq = evt.sequence || ""
640
+ const rawReturn = raw === "\r" || seq === "\r" || raw.endsWith("\r") || seq.endsWith("\r")
641
+ const submitFromRawOnly = !submitKey && rawReturn
642
+ if (keyLog && (submitKey || evt.name === "linefeed" || evt.name === "" || evt.name === "leftshift" || evt.name === "rightshift" || evt.name === "shift")) {
643
+ keyLog.info("submit-key", {
644
+ name: evt.name,
645
+ eventType: evt.eventType,
646
+ shift: evt.shift,
647
+ ctrl: evt.ctrl,
648
+ meta: evt.meta,
649
+ source: evt.source,
650
+ raw,
651
+ sequence: seq,
652
+ rawHex: toHex(raw),
653
+ sequenceHex: toHex(seq),
654
+ })
655
+ }
656
+ const shiftFromRaw = isShiftEnterSequence(raw) || isShiftEnterSequence(seq) || raw.includes(";2u") || seq.includes(";2u")
657
+ const shift = evt.shift || shiftDown || shiftFromRaw
658
+ const newlineFromRaw = raw === "\n" || seq === "\n" || raw === "\r\n" || seq === "\r\n"
659
+ const modifiedSubmitFromRaw =
660
+ (submitKey && raw.startsWith("\x1b[") && raw.endsWith("~")) ||
661
+ (submitKey && seq.startsWith("\x1b[") && seq.endsWith("~")) ||
662
+ (submitKey && raw.includes(";13")) ||
663
+ (submitKey && seq.includes(";13"))
664
+ const newlineKey =
665
+ evt.name === "linefeed" ||
666
+ newlineFromRaw ||
667
+ isShiftEnterSequence(raw) ||
668
+ isShiftEnterSequence(seq) ||
669
+ (submitFromRawOnly && (shiftDown || raw.startsWith("\x1b") || seq.startsWith("\x1b")) && !evt.ctrl) ||
670
+ (modifiedSubmitFromRaw && !evt.ctrl) ||
671
+ (submitKey && shift && !evt.ctrl && !evt.meta) ||
672
+ (submitKey && evt.meta && !evt.ctrl) ||
673
+ (evt.name === "j" && evt.ctrl && !evt.meta)
674
+
407
675
  if (aiMode() === "url") {
408
- if (evt.name === "return") { handleSubmit(); evt.preventDefault(); return }
409
- if (evt.name === "escape") { setAiMode(""); setMessage(""); evt.preventDefault(); return }
676
+ if (submitKey || newlineKey) { handleSubmit(); evt.preventDefault(); return }
677
+ if (evt.name === "escape") { setAiMode(""); evt.preventDefault(); return }
410
678
  if (evt.name === "backspace") { setAiUrl((s) => s.slice(0, -1)); evt.preventDefault(); return }
411
679
  if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
412
680
  const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
@@ -416,7 +684,7 @@ export function Home(props: {
416
684
  return
417
685
  }
418
686
  if (aiMode() === "key") {
419
- if (evt.name === "return") { handleSubmit(); evt.preventDefault(); return }
687
+ if (submitKey || newlineKey) { handleSubmit(); evt.preventDefault(); return }
420
688
  if (evt.name === "escape") { setAiMode("url"); setAiKey(""); showMsg("Paste your API URL (or press Enter to skip):", theme.colors.primary); evt.preventDefault(); return }
421
689
  if (evt.name === "backspace") { setAiKey((s) => s.slice(0, -1)); evt.preventDefault(); return }
422
690
  if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
@@ -427,9 +695,72 @@ export function Home(props: {
427
695
  return
428
696
  }
429
697
 
698
+ if (modelPicking()) {
699
+ if (evt.name === "up" || evt.name === "k") {
700
+ const len = modelFiltered().length
701
+ if (len > 0) setModelIdx((i) => (i - 1 + len) % len)
702
+ evt.preventDefault()
703
+ return
704
+ }
705
+ if (evt.name === "down" || evt.name === "j") {
706
+ const len = modelFiltered().length
707
+ if (len > 0) setModelIdx((i) => (i + 1) % len)
708
+ evt.preventDefault()
709
+ return
710
+ }
711
+ if (submitKey || evt.name === "enter") {
712
+ const selected = modelFiltered()[modelIdx()]
713
+ if (selected) void pickModel(selected.id)
714
+ evt.preventDefault()
715
+ return
716
+ }
717
+ if (evt.name === "backspace") {
718
+ setModelQuery((q) => q.slice(0, -1))
719
+ setModelIdx(0)
720
+ evt.preventDefault()
721
+ return
722
+ }
723
+ if (evt.name === "escape") {
724
+ if (modelQuery()) {
725
+ setModelQuery("")
726
+ setModelIdx(0)
727
+ evt.preventDefault()
728
+ return
729
+ }
730
+ setModelPicking(false)
731
+ setModelIdx(0)
732
+ setModelQuery("")
733
+ showMsg("Model switch canceled", theme.colors.textMuted)
734
+ evt.preventDefault()
735
+ return
736
+ }
737
+ if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
738
+ const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
739
+ if (clean) {
740
+ setModelQuery((q) => q + clean)
741
+ setModelIdx(0)
742
+ evt.preventDefault()
743
+ return
744
+ }
745
+ }
746
+ if (evt.name === "space") {
747
+ setModelQuery((q) => q + " ")
748
+ setModelIdx(0)
749
+ evt.preventDefault()
750
+ return
751
+ }
752
+ return
753
+ }
754
+
430
755
  if (showAutocomplete()) {
431
- if (evt.name === "up") { setSelectedIdx((i) => (i - 1 + filtered().length) % filtered().length); evt.preventDefault(); return }
432
- if (evt.name === "down") { setSelectedIdx((i) => (i + 1) % filtered().length); evt.preventDefault(); return }
756
+ if (evt.name === "up" || evt.name === "down") {
757
+ const len = filtered().length
758
+ if (len === 0) { evt.preventDefault(); return }
759
+ if (evt.name === "up") setSelectedIdx((i) => (i - 1 + len) % len)
760
+ else setSelectedIdx((i) => (i + 1) % len)
761
+ evt.preventDefault()
762
+ return
763
+ }
433
764
  if (evt.name === "tab") {
434
765
  const sel = filtered()[selectedIdx()]
435
766
  if (sel) setInput(sel.name + " ")
@@ -441,30 +772,36 @@ export function Home(props: {
441
772
 
442
773
  // Escape while streaming → abort; while chatting → clear
443
774
  if (evt.name === "escape" && streaming()) {
775
+ abortByUser = true
444
776
  abortCtrl?.abort()
445
- const cur = streamText()
446
- if (cur.trim()) setMessages((p) => [...p, { role: "assistant", content: cur.trim() + "\n\n(interrupted)" }])
447
- setStreamText(""); setStreaming(false)
448
- saveChat()
449
777
  evt.preventDefault(); return
450
778
  }
451
779
  if (evt.name === "escape" && chatting() && !streaming()) { clearChat(); evt.preventDefault(); return }
452
780
 
453
- if (evt.name === "return" && !evt.shift) { handleSubmit(); evt.preventDefault(); return }
454
- if (evt.name === "return" && evt.shift) { setInput((s) => s + "\n"); evt.preventDefault(); return }
781
+ if (submitKey && !shift && !evt.ctrl && !evt.meta && !modifiedSubmitFromRaw) { handleSubmit(); evt.preventDefault(); return }
782
+ if (newlineKey) { setInput((s) => s + "\n"); evt.preventDefault(); return }
455
783
  if (evt.name === "backspace") { setInput((s) => s.slice(0, -1)); setSelectedIdx(0); evt.preventDefault(); return }
456
784
  if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
457
785
  const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
458
786
  if (clean) { setInput((s) => s + clean); setSelectedIdx(0); evt.preventDefault(); return }
459
787
  }
460
788
  if (evt.name === "space") { setInput((s) => s + " "); evt.preventDefault(); return }
461
- })
789
+ }, { release: true })
462
790
 
463
791
  return (
464
792
  <box flexDirection="column" flexGrow={1} paddingLeft={2} paddingRight={2}>
465
- {/* When no chat: show logo centered */}
466
- <Show when={!chatting()}>
467
- <box flexGrow={1} minHeight={0} />
793
+ <scrollbox
794
+ flexGrow={1}
795
+ paddingTop={1}
796
+ stickyScroll={true}
797
+ stickyStart="bottom"
798
+ verticalScrollbarOptions={{ visible: false }}
799
+ horizontalScrollbarOptions={{ visible: false }}
800
+ >
801
+ <Show when={!chatting()}>
802
+ <box flexGrow={1} minHeight={0} />
803
+ </Show>
804
+
468
805
  <box flexShrink={0} flexDirection="column" alignItems="center">
469
806
  {LOGO.map((line, i) => (
470
807
  <text fg={i < 4 ? theme.colors.logo1 : theme.colors.logo2}>{line}</text>
@@ -472,7 +809,6 @@ export function Home(props: {
472
809
  <box height={1} />
473
810
  <text fg={theme.colors.textMuted}>The AI-powered coding forum</text>
474
811
 
475
- {/* Status info below logo */}
476
812
  <box height={1} />
477
813
  <box flexDirection="column" alignItems="center" gap={0}>
478
814
  <box flexDirection="row" gap={1}>
@@ -501,16 +837,42 @@ export function Home(props: {
501
837
  </Show>
502
838
  </box>
503
839
  </box>
840
+
841
+ <Show when={props.loggedIn}>
842
+ <box flexDirection="row" paddingTop={1}>
843
+ <text fg={theme.colors.warning} flexShrink={0}>● Tip </text>
844
+ <text fg={theme.colors.textMuted}>{tipPool()[tipIdx % tipPool().length]}</text>
845
+ </box>
846
+ </Show>
504
847
  </box>
505
- </Show>
506
848
 
507
- {/* When chatting: messages fill the space */}
508
- <Show when={chatting()}>
509
- <scrollbox flexGrow={1} paddingTop={1} stickyScroll={true} stickyStart="bottom">
510
- <For each={messages()}>
511
- {(msg) => (
849
+ <For each={renderRows()}>
850
+ {(row) => {
851
+ if (row.role === "stream") {
852
+ return (
853
+ <box flexShrink={0} paddingBottom={1}>
854
+ <Show when={streamText()}>
855
+ <code
856
+ filetype="markdown"
857
+ drawUnstyledText={false}
858
+ streaming={true}
859
+ syntaxStyle={syntaxStyle()}
860
+ content={streamText()}
861
+ conceal={true}
862
+ fg={theme.colors.text}
863
+ />
864
+ </Show>
865
+ <Show when={!streamText()}>
866
+ <text fg={theme.colors.textMuted} wrapMode="word">
867
+ {"◆ " + shimmerText()}
868
+ </text>
869
+ </Show>
870
+ </box>
871
+ )
872
+ }
873
+ const msg = row
874
+ return (
512
875
  <box flexShrink={0}>
513
- {/* User message — bold with ❯ prefix */}
514
876
  <Show when={msg.role === "user"}>
515
877
  <box flexDirection="row" paddingBottom={1}>
516
878
  <text fg={theme.colors.primary} flexShrink={0}>
@@ -521,7 +883,6 @@ export function Home(props: {
521
883
  </text>
522
884
  </box>
523
885
  </Show>
524
- {/* Tool execution — ⚙/✓ icon + tool name + status */}
525
886
  <Show when={msg.role === "tool"}>
526
887
  <box flexDirection="row" paddingLeft={2}>
527
888
  <text fg={msg.toolStatus === "done" ? theme.colors.success : msg.toolStatus === "error" ? theme.colors.error : theme.colors.warning} flexShrink={0}>
@@ -532,7 +893,6 @@ export function Home(props: {
532
893
  </text>
533
894
  </box>
534
895
  </Show>
535
- {/* Assistant message — ◆ prefix */}
536
896
  <Show when={msg.role === "assistant"}>
537
897
  <box paddingBottom={1} flexShrink={0}>
538
898
  <code
@@ -545,39 +905,49 @@ export function Home(props: {
545
905
  />
546
906
  </box>
547
907
  </Show>
908
+ <Show when={msg.role === "system"}>
909
+ <box flexDirection="row" paddingLeft={2} paddingBottom={1}>
910
+ <text
911
+ fg={
912
+ msg.tone === "success"
913
+ ? theme.colors.success
914
+ : msg.tone === "warning"
915
+ ? theme.colors.warning
916
+ : msg.tone === "error"
917
+ ? theme.colors.error
918
+ : theme.colors.textMuted
919
+ }
920
+ flexShrink={0}
921
+ >
922
+ {"└ "}
923
+ </text>
924
+ <text
925
+ fg={
926
+ msg.tone === "success"
927
+ ? theme.colors.success
928
+ : msg.tone === "warning"
929
+ ? theme.colors.warning
930
+ : msg.tone === "error"
931
+ ? theme.colors.error
932
+ : theme.colors.textMuted
933
+ }
934
+ wrapMode="word"
935
+ flexGrow={1}
936
+ flexShrink={1}
937
+ >
938
+ {msg.content}
939
+ </text>
940
+ </box>
941
+ </Show>
548
942
  </box>
549
- )}
550
- </For>
551
- <box
552
- flexShrink={0}
553
- paddingBottom={streaming() ? 1 : 0}
554
- height={streaming() ? undefined : 0}
555
- overflow="hidden"
556
- >
557
- <Show when={streaming() && streamText()}>
558
- <code
559
- filetype="markdown"
560
- drawUnstyledText={false}
561
- streaming={true}
562
- syntaxStyle={syntaxStyle()}
563
- content={streamText()}
564
- conceal={true}
565
- fg={theme.colors.text}
566
- />
567
- </Show>
568
- <Show when={streaming() && !streamText()}>
569
- <text fg={theme.colors.textMuted} wrapMode="word">
570
- {"◆ " + shimmerText()}
571
- </text>
572
- </Show>
573
- </box>
574
- </scrollbox>
575
- </Show>
943
+ )
944
+ }}
945
+ </For>
576
946
 
577
- {/* Spacer when no chat and no autocomplete */}
578
- <Show when={!chatting()}>
579
- <box flexGrow={1} minHeight={0} />
580
- </Show>
947
+ <Show when={!chatting()}>
948
+ <box flexGrow={1} minHeight={0} />
949
+ </Show>
950
+ </scrollbox>
581
951
 
582
952
  {/* Prompt — always at bottom */}
583
953
  <box flexShrink={0} paddingTop={1} paddingBottom={1}>
@@ -607,36 +977,66 @@ export function Home(props: {
607
977
  </Show>
608
978
  <Show when={!aiMode()}>
609
979
  <box flexDirection="column">
980
+ <Show when={modelPicking()}>
981
+ <box flexDirection="column" paddingBottom={1}>
982
+ <text fg={theme.colors.textMuted}>{" /model — choose with ↑/↓, Enter to confirm, Esc to cancel"}</text>
983
+ <box flexDirection="row">
984
+ <text fg={theme.colors.textMuted}>{" search: "}</text>
985
+ <text fg={theme.colors.input}>{modelQuery()}</text>
986
+ <text fg={theme.colors.cursor}>{"█"}</text>
987
+ </box>
988
+ <Show when={modelLoading()}>
989
+ <text fg={theme.colors.textMuted}>{" Loading models..."}</text>
990
+ </Show>
991
+ <Show when={modelVisibleStart() > 0}>
992
+ <text fg={theme.colors.textMuted}>{" ↑ more models"}</text>
993
+ </Show>
994
+ <For each={modelVisibleItems()}>
995
+ {(option, i) => {
996
+ const selected = () => i() + modelVisibleStart() === modelIdx()
997
+ const current = () => option.id === props.modelName
998
+ return (
999
+ <box flexDirection="row" backgroundColor={selected() ? theme.colors.primary : undefined}>
1000
+ <text fg={selected() ? "#ffffff" : theme.colors.text}>
1001
+ {" " + (selected() ? "● " : "○ ") + option.label + (current() ? " (current)" : "")}
1002
+ </text>
1003
+ </box>
1004
+ )
1005
+ }}
1006
+ </For>
1007
+ <Show when={modelFiltered().length === 0 && !modelLoading()}>
1008
+ <text fg={theme.colors.warning}>{" No matched models"}</text>
1009
+ </Show>
1010
+ <Show when={modelVisibleStart() + modelVisibleItems().length < modelFiltered().length}>
1011
+ <text fg={theme.colors.textMuted}>{" ↓ more models"}</text>
1012
+ </Show>
1013
+ </box>
1014
+ </Show>
610
1015
  {/* Command autocomplete — above prompt */}
611
1016
  <Show when={showAutocomplete()}>
612
- <box flexDirection="column" paddingBottom={1} maxHeight={8} overflow="hidden">
613
- <For each={filtered()}>
1017
+ <box flexDirection="column" paddingBottom={1}>
1018
+ <Show when={visibleStart() > 0}>
1019
+ <text fg={theme.colors.textMuted}>{" ↑ more commands"}</text>
1020
+ </Show>
1021
+ <For each={visibleItems()}>
614
1022
  {(cmd, i) => {
615
1023
  const disabled = () => cmd.needsAI && !props.hasAI
616
- const selected = () => i() === selectedIdx()
1024
+ const selected = () => i() + visibleStart() === selectedIdx()
617
1025
  return (
618
- <box flexDirection="row" backgroundColor={selected() && !disabled() ? theme.colors.primary : undefined}>
619
- <text fg={selected() && !disabled() ? "#ffffff" : (disabled() ? theme.colors.textMuted : theme.colors.primary)}>
1026
+ <box flexDirection="row" backgroundColor={selected() ? theme.colors.primary : undefined}>
1027
+ <text fg={selected() ? "#ffffff" : (disabled() ? theme.colors.textMuted : theme.colors.primary)}>
620
1028
  {" " + cmd.name.padEnd(18)}
621
1029
  </text>
622
- <text fg={selected() && !disabled() ? "#ffffff" : theme.colors.textMuted}>
1030
+ <text fg={selected() ? "#ffffff" : theme.colors.textMuted}>
623
1031
  {disabled() ? cmd.description + " [needs /ai]" : cmd.description}
624
1032
  </text>
625
1033
  </box>
626
1034
  )
627
1035
  }}
628
1036
  </For>
629
- </box>
630
- </Show>
631
- {/* Message feedback */}
632
- <Show when={message() && !showAutocomplete()}>
633
- <text fg={messageColor()} flexShrink={0}>{message()}</text>
634
- </Show>
635
- {/* Tip */}
636
- <Show when={!showAutocomplete() && !message() && !chatting() && props.loggedIn}>
637
- <box flexDirection="row" paddingBottom={1}>
638
- <text fg={theme.colors.warning} flexShrink={0}>● Tip </text>
639
- <text fg={theme.colors.textMuted}>{tipPool()[tipIdx % tipPool().length]}</text>
1037
+ <Show when={visibleStart() + visibleItems().length < filtered().length}>
1038
+ <text fg={theme.colors.textMuted}>{" ↓ more commands"}</text>
1039
+ </Show>
640
1040
  </box>
641
1041
  </Show>
642
1042
  {/* Input line with blinking cursor */}
@@ -647,7 +1047,7 @@ export function Home(props: {
647
1047
  <box flexDirection="row">
648
1048
  <text fg={theme.colors.primary}><span style={{ bold: true }}>{i === 0 ? "❯ " : " "}</span></text>
649
1049
  <text fg={theme.colors.input}>{line}</text>
650
- {i === lines.length - 1 && <text fg={theme.colors.cursor} style={{ bold: true }}>{"█"}</text>}
1050
+ {i === lines.length - 1 && <text fg={theme.colors.cursor}><span style={{ bold: true }}>{"█"}</span></text>}
651
1051
  </box>
652
1052
  ))
653
1053
  })()}