codeblog-app 2.3.2 → 2.3.3

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