codeblog-app 2.0.2 → 2.1.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.
Files changed (67) 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 +71 -8
  5. package/src/ai/__tests__/chat.test.ts +110 -0
  6. package/src/ai/__tests__/provider.test.ts +184 -0
  7. package/src/ai/__tests__/tools.test.ts +90 -0
  8. package/src/ai/chat.ts +169 -0
  9. package/src/ai/configure.ts +134 -0
  10. package/src/ai/provider.ts +238 -0
  11. package/src/ai/tools.ts +336 -0
  12. package/src/auth/index.ts +47 -0
  13. package/src/auth/oauth.ts +94 -0
  14. package/src/cli/__tests__/commands.test.ts +225 -0
  15. package/src/cli/cmd/agent.ts +102 -0
  16. package/src/cli/cmd/chat.ts +190 -0
  17. package/src/cli/cmd/comment.ts +70 -0
  18. package/src/cli/cmd/config.ts +153 -0
  19. package/src/cli/cmd/feed.ts +57 -0
  20. package/src/cli/cmd/forum.ts +123 -0
  21. package/src/cli/cmd/login.ts +45 -0
  22. package/src/cli/cmd/logout.ts +12 -0
  23. package/src/cli/cmd/me.ts +202 -0
  24. package/src/cli/cmd/post.ts +29 -0
  25. package/src/cli/cmd/publish.ts +70 -0
  26. package/src/cli/cmd/scan.ts +80 -0
  27. package/src/cli/cmd/search.ts +40 -0
  28. package/src/cli/cmd/setup.ts +273 -0
  29. package/src/cli/cmd/tui.ts +20 -0
  30. package/src/cli/cmd/update.ts +78 -0
  31. package/src/cli/cmd/vote.ts +50 -0
  32. package/src/cli/cmd/whoami.ts +21 -0
  33. package/src/cli/ui.ts +195 -0
  34. package/src/config/index.ts +54 -0
  35. package/src/flag/index.ts +23 -0
  36. package/src/global/index.ts +38 -0
  37. package/src/id/index.ts +20 -0
  38. package/src/index.ts +197 -0
  39. package/src/mcp/__tests__/client.test.ts +149 -0
  40. package/src/mcp/__tests__/e2e.ts +327 -0
  41. package/src/mcp/__tests__/integration.ts +148 -0
  42. package/src/mcp/client.ts +148 -0
  43. package/src/server/index.ts +48 -0
  44. package/src/storage/chat.ts +92 -0
  45. package/src/storage/db.ts +85 -0
  46. package/src/storage/schema.sql.ts +39 -0
  47. package/src/storage/schema.ts +1 -0
  48. package/src/tui/app.tsx +163 -0
  49. package/src/tui/commands.ts +187 -0
  50. package/src/tui/context/exit.tsx +15 -0
  51. package/src/tui/context/helper.tsx +25 -0
  52. package/src/tui/context/route.tsx +24 -0
  53. package/src/tui/context/theme.tsx +470 -0
  54. package/src/tui/routes/home.tsx +508 -0
  55. package/src/tui/routes/model.tsx +209 -0
  56. package/src/tui/routes/notifications.tsx +85 -0
  57. package/src/tui/routes/post.tsx +108 -0
  58. package/src/tui/routes/search.tsx +104 -0
  59. package/src/tui/routes/setup.tsx +255 -0
  60. package/src/tui/routes/trending.tsx +107 -0
  61. package/src/util/__tests__/context.test.ts +31 -0
  62. package/src/util/__tests__/lazy.test.ts +37 -0
  63. package/src/util/context.ts +23 -0
  64. package/src/util/error.ts +46 -0
  65. package/src/util/lazy.ts +18 -0
  66. package/src/util/log.ts +142 -0
  67. package/tsconfig.json +11 -0
@@ -0,0 +1,508 @@
1
+ import { createSignal, createMemo, createEffect, onCleanup, Show, For } from "solid-js"
2
+ import { useKeyboard, usePaste } from "@opentui/solid"
3
+ import { useRoute } from "../context/route"
4
+ import { useExit } from "../context/exit"
5
+ import { useTheme } 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
+
11
+ interface ChatMsg {
12
+ role: "user" | "assistant" | "tool"
13
+ content: string
14
+ toolName?: string
15
+ toolStatus?: "running" | "done" | "error"
16
+ }
17
+
18
+ export function Home(props: {
19
+ loggedIn: boolean
20
+ username: string
21
+ hasAI: boolean
22
+ aiProvider: string
23
+ modelName: string
24
+ onLogin: () => Promise<void>
25
+ onLogout: () => void
26
+ onAIConfigured: () => void
27
+ }) {
28
+ const route = useRoute()
29
+ const exit = useExit()
30
+ const theme = useTheme()
31
+ const [input, setInput] = createSignal("")
32
+ const [message, setMessage] = createSignal("")
33
+ const [messageColor, setMessageColor] = createSignal("#6a737c")
34
+ const [selectedIdx, setSelectedIdx] = createSignal(0)
35
+
36
+ const [messages, setMessages] = createSignal<ChatMsg[]>([])
37
+ const [streaming, setStreaming] = createSignal(false)
38
+ const [streamText, setStreamText] = createSignal("")
39
+ let abortCtrl: AbortController | undefined
40
+ let escCooldown = 0
41
+ let sessionId = ""
42
+ const chatting = createMemo(() => messages().length > 0 || streaming())
43
+
44
+ function ensureSession() {
45
+ if (!sessionId) {
46
+ sessionId = crypto.randomUUID()
47
+ try { ChatHistory.create(sessionId) } catch {}
48
+ }
49
+ }
50
+
51
+ function saveChat() {
52
+ if (!sessionId) return
53
+ try { ChatHistory.save(sessionId, messages()) } catch {}
54
+ }
55
+
56
+ function resumeSession(sid?: string) {
57
+ try {
58
+ if (!sid) {
59
+ const sessions = ChatHistory.list(1)
60
+ if (sessions.length === 0) { showMsg("No previous sessions", theme.colors.warning); return }
61
+ sid = sessions[0].id
62
+ }
63
+ const msgs = ChatHistory.load(sid)
64
+ if (msgs.length === 0) { showMsg("Session is empty", theme.colors.warning); return }
65
+ sessionId = sid
66
+ setMessages(msgs as ChatMsg[])
67
+ showMsg("Resumed session", theme.colors.success)
68
+ } catch { showMsg("Failed to resume", theme.colors.error) }
69
+ }
70
+
71
+ // Shimmer animation for thinking state (like Claude Code)
72
+ const SHIMMER_WORDS = ["Thinking", "Reasoning", "Composing", "Reflecting", "Analyzing", "Processing"]
73
+ const [shimmerIdx, setShimmerIdx] = createSignal(0)
74
+ const [shimmerDots, setShimmerDots] = createSignal(0)
75
+ createEffect(() => {
76
+ if (!streaming()) return
77
+ const id = setInterval(() => {
78
+ setShimmerDots((d) => (d + 1) % 4)
79
+ if (shimmerDots() === 0) setShimmerIdx((i) => (i + 1) % SHIMMER_WORDS.length)
80
+ }, 500)
81
+ onCleanup(() => clearInterval(id))
82
+ })
83
+ const shimmerText = () => SHIMMER_WORDS[shimmerIdx()] + ".".repeat(shimmerDots())
84
+
85
+ const tipPool = () => props.hasAI ? TIPS : TIPS_NO_AI
86
+ const tipIdx = Math.floor(Math.random() * TIPS.length)
87
+ const [aiMode, setAiMode] = createSignal<"" | "url" | "key" | "testing">("")
88
+ const [aiUrl, setAiUrl] = createSignal("")
89
+ const [aiKey, setAiKey] = createSignal("")
90
+
91
+ function showMsg(text: string, color = "#6a737c") {
92
+ setMessage(text)
93
+ setMessageColor(color)
94
+ }
95
+
96
+ function clearChat() {
97
+ setMessages([]); setStreamText(""); setStreaming(false); setMessage("")
98
+ sessionId = ""
99
+ }
100
+
101
+ const commands = createCommands({
102
+ showMsg,
103
+ navigate: route.navigate,
104
+ exit,
105
+ onLogin: props.onLogin,
106
+ onLogout: props.onLogout,
107
+ clearChat,
108
+ startAIConfig: () => {
109
+ setAiUrl(""); setAiKey(""); setAiMode("url")
110
+ showMsg("Paste your API URL (or press Enter to skip):", theme.colors.primary)
111
+ },
112
+ setMode: theme.setMode,
113
+ send,
114
+ resume: resumeSession,
115
+ listSessions: () => { try { return ChatHistory.list(10) } catch { return [] } },
116
+ hasAI: props.hasAI,
117
+ colors: theme.colors,
118
+ })
119
+
120
+ const filtered = createMemo(() => {
121
+ const v = input()
122
+ if (!v.startsWith("/")) return []
123
+ const query = v.slice(1).toLowerCase()
124
+ if (!query) return commands
125
+ return commands.filter((c) => c.name.slice(1).toLowerCase().includes(query))
126
+ })
127
+
128
+ const showAutocomplete = createMemo(() => {
129
+ const v = input()
130
+ if (aiMode()) return false
131
+ if (!v.startsWith("/")) return false
132
+ if (v.includes(" ")) return false
133
+ return filtered().length > 0
134
+ })
135
+
136
+ usePaste((evt) => {
137
+ const text = evt.text.replace(/[\n\r]/g, "").trim()
138
+ if (!text) return
139
+ evt.preventDefault()
140
+ if (aiMode() === "url") { setAiUrl(text); return }
141
+ if (aiMode() === "key") { setAiKey(text); return }
142
+ setInput((s) => s + text)
143
+ })
144
+
145
+ async function send(text: string) {
146
+ if (!text.trim() || streaming()) return
147
+ ensureSession()
148
+ const userMsg: ChatMsg = { role: "user", content: text.trim() }
149
+ const prev = messages()
150
+ setMessages([...prev, userMsg])
151
+ setStreaming(true)
152
+ setStreamText("")
153
+ setMessage("")
154
+
155
+ try {
156
+ const { AIChat } = await import("../../ai/chat")
157
+ const { Config } = await import("../../config")
158
+ const { AIProvider } = await import("../../ai/provider")
159
+ const cfg = await Config.load()
160
+ const mid = cfg.model || AIProvider.DEFAULT_MODEL
161
+ const allMsgs = [...prev, userMsg].filter((m) => m.role !== "tool").map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
162
+ let full = ""
163
+ abortCtrl = new AbortController()
164
+ await AIChat.stream(allMsgs, {
165
+ onToken: (token) => { full += token; setStreamText(full) },
166
+ onToolCall: (name) => {
167
+ // Save any accumulated text as assistant message before tool
168
+ if (full.trim()) {
169
+ setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
170
+ full = ""
171
+ setStreamText("")
172
+ }
173
+ setMessages((p) => [...p, { role: "tool", content: TOOL_LABELS[name] || name, toolName: name, toolStatus: "running" }])
174
+ },
175
+ onToolResult: (name) => {
176
+ setMessages((p) => p.map((m) =>
177
+ m.role === "tool" && m.toolName === name && m.toolStatus === "running"
178
+ ? { ...m, toolStatus: "done" as const }
179
+ : m
180
+ ))
181
+ },
182
+ onFinish: () => {
183
+ if (full.trim()) {
184
+ setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
185
+ }
186
+ setStreamText(""); setStreaming(false)
187
+ saveChat()
188
+ },
189
+ onError: (err) => {
190
+ setMessages((p) => {
191
+ // Mark any running tools as error
192
+ const updated = p.map((m) =>
193
+ m.role === "tool" && m.toolStatus === "running"
194
+ ? { ...m, toolStatus: "error" as const }
195
+ : m
196
+ )
197
+ return [...updated, { role: "assistant" as const, content: `Error: ${err.message}` }]
198
+ })
199
+ setStreamText(""); setStreaming(false)
200
+ saveChat()
201
+ },
202
+ }, mid, abortCtrl.signal)
203
+ abortCtrl = undefined
204
+ } catch (err) {
205
+ const msg = err instanceof Error ? err.message : String(err)
206
+ setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
207
+ setStreamText("")
208
+ setStreaming(false)
209
+ saveChat()
210
+ }
211
+ }
212
+
213
+ async function saveAI() {
214
+ setAiMode("testing")
215
+ showMsg("Detecting API format...", theme.colors.primary)
216
+ try {
217
+ const result = await saveProvider(aiUrl().trim(), aiKey().trim())
218
+ if (result.error) {
219
+ showMsg(result.error, theme.colors.error)
220
+ setAiMode("key")
221
+ return
222
+ }
223
+ showMsg(`✓ AI configured! (${result.provider})`, theme.colors.success)
224
+ setAiMode("")
225
+ props.onAIConfigured()
226
+ } catch (err) {
227
+ showMsg(`Save failed: ${err instanceof Error ? err.message : String(err)}`, theme.colors.error)
228
+ setAiMode("key")
229
+ }
230
+ }
231
+
232
+ async function handleSubmit() {
233
+ if (aiMode() === "url") {
234
+ const v = aiUrl().trim()
235
+ if (v && !v.startsWith("http")) { showMsg("URL must start with http:// or https://", theme.colors.error); return }
236
+ setAiMode("key")
237
+ showMsg("Now paste your API key:", theme.colors.primary)
238
+ return
239
+ }
240
+ if (aiMode() === "key") {
241
+ if (aiKey().trim().length < 5) { showMsg("API key too short", theme.colors.error); return }
242
+ saveAI()
243
+ return
244
+ }
245
+
246
+ if (showAutocomplete()) {
247
+ const items = filtered()
248
+ const sel = items[selectedIdx()]
249
+ if (sel) {
250
+ if (sel.needsAI && !props.hasAI) {
251
+ showMsg(`${sel.name} requires AI. Type /ai to configure.`, theme.colors.warning)
252
+ setInput("")
253
+ setSelectedIdx(0)
254
+ return
255
+ }
256
+ setInput("")
257
+ setSelectedIdx(0)
258
+ sel.action(sel.name.split(/\s+/))
259
+ return
260
+ }
261
+ }
262
+
263
+ const text = input().trim()
264
+ setInput("")
265
+ setSelectedIdx(0)
266
+ if (!text) return
267
+
268
+ if (text.startsWith("/")) {
269
+ const parts = text.split(/\s+/)
270
+ const cmd = parts[0]
271
+ const match = commands.find((c) => c.name === cmd)
272
+ if (match) {
273
+ if (match.needsAI && !props.hasAI) {
274
+ showMsg(`${cmd} requires AI. Type /ai to configure.`, theme.colors.warning)
275
+ return
276
+ }
277
+ match.action(parts)
278
+ return
279
+ }
280
+ if (cmd === "/quit" || cmd === "/q") { exit(); return }
281
+ showMsg(`Unknown command: ${cmd}. Type / to see commands`, theme.colors.error)
282
+ return
283
+ }
284
+
285
+ if (!props.hasAI) {
286
+ showMsg("No AI configured. Type /ai to set up", theme.colors.error)
287
+ return
288
+ }
289
+
290
+ send(text)
291
+ }
292
+
293
+ useKeyboard((evt) => {
294
+ if (aiMode() === "url") {
295
+ if (evt.name === "return") { handleSubmit(); evt.preventDefault(); return }
296
+ if (evt.name === "escape") { setAiMode(""); setMessage(""); evt.preventDefault(); return }
297
+ if (evt.name === "backspace") { setAiUrl((s) => s.slice(0, -1)); evt.preventDefault(); return }
298
+ if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
299
+ const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
300
+ if (clean) { setAiUrl((s) => s + clean); evt.preventDefault(); return }
301
+ }
302
+ if (evt.name === "space") { evt.preventDefault(); return }
303
+ return
304
+ }
305
+ if (aiMode() === "key") {
306
+ if (evt.name === "return") { handleSubmit(); evt.preventDefault(); return }
307
+ if (evt.name === "escape") { setAiMode("url"); setAiKey(""); showMsg("Paste your API URL (or press Enter to skip):", theme.colors.primary); evt.preventDefault(); return }
308
+ if (evt.name === "backspace") { setAiKey((s) => s.slice(0, -1)); evt.preventDefault(); return }
309
+ if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
310
+ const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
311
+ if (clean) { setAiKey((s) => s + clean); evt.preventDefault(); return }
312
+ }
313
+ if (evt.name === "space") { setAiKey((s) => s + " "); evt.preventDefault(); return }
314
+ return
315
+ }
316
+
317
+ if (showAutocomplete()) {
318
+ if (evt.name === "up") { setSelectedIdx((i) => (i - 1 + filtered().length) % filtered().length); evt.preventDefault(); return }
319
+ if (evt.name === "down") { setSelectedIdx((i) => (i + 1) % filtered().length); evt.preventDefault(); return }
320
+ if (evt.name === "tab") {
321
+ const sel = filtered()[selectedIdx()]
322
+ if (sel) setInput(sel.name + " ")
323
+ evt.preventDefault()
324
+ return
325
+ }
326
+ if (evt.name === "escape") { setInput(""); setSelectedIdx(0); evt.preventDefault(); return }
327
+ }
328
+
329
+ // Escape while streaming → abort; while chatting → clear
330
+ if (evt.name === "escape" && streaming()) {
331
+ abortCtrl?.abort()
332
+ const cur = streamText()
333
+ if (cur.trim()) setMessages((p) => [...p, { role: "assistant", content: cur.trim() + "\n\n(interrupted)" }])
334
+ setStreamText(""); setStreaming(false)
335
+ saveChat()
336
+ evt.preventDefault(); return
337
+ }
338
+ if (evt.name === "escape" && chatting() && !streaming()) { clearChat(); evt.preventDefault(); return }
339
+
340
+ if (evt.name === "return" && !evt.shift) { handleSubmit(); evt.preventDefault(); return }
341
+ if (evt.name === "backspace") { setInput((s) => s.slice(0, -1)); setSelectedIdx(0); evt.preventDefault(); return }
342
+ if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
343
+ const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
344
+ if (clean) { setInput((s) => s + clean); setSelectedIdx(0); evt.preventDefault(); return }
345
+ }
346
+ if (evt.name === "space") { setInput((s) => s + " "); evt.preventDefault(); return }
347
+ })
348
+
349
+ return (
350
+ <box flexDirection="column" flexGrow={1} paddingLeft={2} paddingRight={2}>
351
+ {/* When no chat: show logo centered */}
352
+ <Show when={!chatting()}>
353
+ <box flexGrow={1} minHeight={0} />
354
+ <box flexShrink={0} flexDirection="column" alignItems="center">
355
+ {LOGO.map((line, i) => (
356
+ <text fg={i < 4 ? theme.colors.logo1 : theme.colors.logo2}>{line}</text>
357
+ ))}
358
+ <box height={1} />
359
+ <text fg={theme.colors.textMuted}>The AI-powered coding forum</text>
360
+ </box>
361
+ <Show when={!props.loggedIn || !props.hasAI}>
362
+ <box flexShrink={0} flexDirection="column" paddingTop={1} alignItems="center">
363
+ <box flexDirection="row" gap={1}>
364
+ <text fg={props.hasAI ? theme.colors.success : theme.colors.warning}>{props.hasAI ? "✓" : "●"}</text>
365
+ <text fg={props.hasAI ? theme.colors.textMuted : theme.colors.text}>
366
+ {props.hasAI ? `AI: ${props.modelName}` : "Type /ai to configure AI"}
367
+ </text>
368
+ </box>
369
+ <box flexDirection="row" gap={1}>
370
+ <text fg={props.loggedIn ? theme.colors.success : theme.colors.warning}>{props.loggedIn ? "✓" : "●"}</text>
371
+ <text fg={props.loggedIn ? theme.colors.textMuted : theme.colors.text}>
372
+ {props.loggedIn ? `Logged in as ${props.username}` : "Type /login to sign in"}
373
+ </text>
374
+ </box>
375
+ </box>
376
+ </Show>
377
+ </Show>
378
+
379
+ {/* When chatting: messages fill the space */}
380
+ <Show when={chatting()}>
381
+ <box flexDirection="column" flexGrow={1} paddingTop={1} overflow="scroll">
382
+ <For each={messages()}>
383
+ {(msg) => (
384
+ <box flexShrink={0}>
385
+ {/* User message — bold with ❯ prefix */}
386
+ <Show when={msg.role === "user"}>
387
+ <box flexDirection="row" paddingBottom={1}>
388
+ <text fg={theme.colors.primary} flexShrink={0}>
389
+ <span style={{ bold: true }}>{"❯ "}</span>
390
+ </text>
391
+ <text fg={theme.colors.text}>
392
+ <span style={{ bold: true }}>{msg.content}</span>
393
+ </text>
394
+ </box>
395
+ </Show>
396
+ {/* Tool execution — ⚙/✓ icon + tool name + status */}
397
+ <Show when={msg.role === "tool"}>
398
+ <box flexDirection="row" paddingLeft={2}>
399
+ <text fg={msg.toolStatus === "done" ? theme.colors.success : msg.toolStatus === "error" ? theme.colors.error : theme.colors.warning} flexShrink={0}>
400
+ {msg.toolStatus === "done" ? " ✓ " : msg.toolStatus === "error" ? " ✗ " : " ⚙ "}
401
+ </text>
402
+ <text fg={theme.colors.textMuted}>
403
+ {msg.content}
404
+ </text>
405
+ </box>
406
+ </Show>
407
+ {/* Assistant message — ◆ prefix */}
408
+ <Show when={msg.role === "assistant"}>
409
+ <box flexDirection="row" paddingBottom={1}>
410
+ <text fg={theme.colors.success} flexShrink={0}>
411
+ <span style={{ bold: true }}>{"◆ "}</span>
412
+ </text>
413
+ <text fg={theme.colors.text}>{msg.content}</text>
414
+ </box>
415
+ </Show>
416
+ </box>
417
+ )}
418
+ </For>
419
+ <Show when={streaming()}>
420
+ <box flexDirection="row" paddingBottom={1} flexShrink={0}>
421
+ <text fg={theme.colors.success} flexShrink={0}>
422
+ <span style={{ bold: true }}>{"◆ "}</span>
423
+ </text>
424
+ <text fg={streamText() ? theme.colors.text : theme.colors.textMuted}>
425
+ {streamText() || shimmerText()}
426
+ </text>
427
+ </box>
428
+ </Show>
429
+ </box>
430
+ </Show>
431
+
432
+ {/* Spacer when no chat and no autocomplete */}
433
+ <Show when={!chatting()}>
434
+ <box flexGrow={1} minHeight={0} />
435
+ </Show>
436
+
437
+ {/* Prompt — always at bottom */}
438
+ <box flexShrink={0} paddingTop={1} paddingBottom={1}>
439
+ <Show when={aiMode() === "url"}>
440
+ <box flexDirection="column">
441
+ <text fg={theme.colors.text}><span style={{ bold: true }}>API URL:</span></text>
442
+ <box flexDirection="row">
443
+ <text fg={theme.colors.primary}><span style={{ bold: true }}>{"❯ "}</span></text>
444
+ <text fg={theme.colors.input}>{aiUrl()}</text>
445
+ <text fg={theme.colors.cursor}>{"█"}</text>
446
+ </box>
447
+ </box>
448
+ </Show>
449
+ <Show when={aiMode() === "key"}>
450
+ <box flexDirection="column">
451
+ {aiUrl().trim() ? <text fg={theme.colors.textMuted}>{"URL: " + aiUrl().trim()}</text> : null}
452
+ <text fg={theme.colors.text}><span style={{ bold: true }}>API Key:</span></text>
453
+ <box flexDirection="row">
454
+ <text fg={theme.colors.primary}><span style={{ bold: true }}>{"❯ "}</span></text>
455
+ <text fg={theme.colors.input}>{mask(aiKey())}</text>
456
+ <text fg={theme.colors.cursor}>{"█"}</text>
457
+ </box>
458
+ </box>
459
+ </Show>
460
+ <Show when={aiMode() === "testing"}>
461
+ <text fg={theme.colors.primary}>Detecting API format...</text>
462
+ </Show>
463
+ <Show when={!aiMode()}>
464
+ <box flexDirection="column">
465
+ {/* Command autocomplete — above prompt */}
466
+ <Show when={showAutocomplete()}>
467
+ <box flexDirection="column" paddingBottom={1}>
468
+ <For each={filtered()}>
469
+ {(cmd, i) => {
470
+ const disabled = () => cmd.needsAI && !props.hasAI
471
+ const selected = () => i() === selectedIdx()
472
+ return (
473
+ <box flexDirection="row" backgroundColor={selected() && !disabled() ? theme.colors.primary : undefined}>
474
+ <text fg={selected() && !disabled() ? "#ffffff" : (disabled() ? theme.colors.textMuted : theme.colors.primary)}>
475
+ {" " + cmd.name.padEnd(18)}
476
+ </text>
477
+ <text fg={selected() && !disabled() ? "#ffffff" : theme.colors.textMuted}>
478
+ {disabled() ? cmd.description + " [needs /ai]" : cmd.description}
479
+ </text>
480
+ </box>
481
+ )
482
+ }}
483
+ </For>
484
+ </box>
485
+ </Show>
486
+ {/* Message feedback */}
487
+ <Show when={message() && !showAutocomplete()}>
488
+ <text fg={messageColor()} flexShrink={0}>{message()}</text>
489
+ </Show>
490
+ {/* Tip */}
491
+ <Show when={!showAutocomplete() && !message() && !chatting() && props.loggedIn}>
492
+ <box flexDirection="row" paddingBottom={1}>
493
+ <text fg={theme.colors.warning} flexShrink={0}>● Tip </text>
494
+ <text fg={theme.colors.textMuted}>{tipPool()[tipIdx % tipPool().length]}</text>
495
+ </box>
496
+ </Show>
497
+ {/* Input line */}
498
+ <box flexDirection="row">
499
+ <text fg={theme.colors.primary}><span style={{ bold: true }}>{"❯ "}</span></text>
500
+ <text fg={theme.colors.input}>{input()}</text>
501
+ <text fg={theme.colors.cursor}>{"█"}</text>
502
+ </box>
503
+ </box>
504
+ </Show>
505
+ </box>
506
+ </box>
507
+ )
508
+ }