codeblog-app 2.2.6 → 2.3.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 (69) hide show
  1. package/package.json +8 -71
  2. package/drizzle/0000_init.sql +0 -34
  3. package/drizzle/meta/_journal.json +0 -13
  4. package/drizzle.config.ts +0 -10
  5. package/src/ai/__tests__/chat.test.ts +0 -179
  6. package/src/ai/__tests__/provider.test.ts +0 -198
  7. package/src/ai/__tests__/tools.test.ts +0 -93
  8. package/src/ai/chat.ts +0 -224
  9. package/src/ai/configure.ts +0 -134
  10. package/src/ai/provider.ts +0 -302
  11. package/src/ai/tools.ts +0 -114
  12. package/src/auth/index.ts +0 -47
  13. package/src/auth/oauth.ts +0 -108
  14. package/src/cli/__tests__/commands.test.ts +0 -225
  15. package/src/cli/cmd/agent.ts +0 -97
  16. package/src/cli/cmd/chat.ts +0 -190
  17. package/src/cli/cmd/comment.ts +0 -67
  18. package/src/cli/cmd/config.ts +0 -153
  19. package/src/cli/cmd/feed.ts +0 -53
  20. package/src/cli/cmd/forum.ts +0 -106
  21. package/src/cli/cmd/login.ts +0 -45
  22. package/src/cli/cmd/logout.ts +0 -12
  23. package/src/cli/cmd/me.ts +0 -188
  24. package/src/cli/cmd/post.ts +0 -25
  25. package/src/cli/cmd/publish.ts +0 -64
  26. package/src/cli/cmd/scan.ts +0 -78
  27. package/src/cli/cmd/search.ts +0 -35
  28. package/src/cli/cmd/setup.ts +0 -352
  29. package/src/cli/cmd/tui.ts +0 -20
  30. package/src/cli/cmd/uninstall.ts +0 -281
  31. package/src/cli/cmd/update.ts +0 -123
  32. package/src/cli/cmd/vote.ts +0 -50
  33. package/src/cli/cmd/whoami.ts +0 -18
  34. package/src/cli/mcp-print.ts +0 -6
  35. package/src/cli/ui.ts +0 -250
  36. package/src/config/index.ts +0 -55
  37. package/src/flag/index.ts +0 -23
  38. package/src/global/index.ts +0 -38
  39. package/src/id/index.ts +0 -20
  40. package/src/index.ts +0 -200
  41. package/src/mcp/__tests__/client.test.ts +0 -149
  42. package/src/mcp/__tests__/e2e.ts +0 -327
  43. package/src/mcp/__tests__/integration.ts +0 -148
  44. package/src/mcp/client.ts +0 -148
  45. package/src/server/index.ts +0 -48
  46. package/src/storage/chat.ts +0 -71
  47. package/src/storage/db.ts +0 -85
  48. package/src/storage/schema.sql.ts +0 -39
  49. package/src/storage/schema.ts +0 -1
  50. package/src/tui/app.tsx +0 -184
  51. package/src/tui/commands.ts +0 -186
  52. package/src/tui/context/exit.tsx +0 -15
  53. package/src/tui/context/helper.tsx +0 -25
  54. package/src/tui/context/route.tsx +0 -24
  55. package/src/tui/context/theme.tsx +0 -470
  56. package/src/tui/routes/home.tsx +0 -660
  57. package/src/tui/routes/model.tsx +0 -210
  58. package/src/tui/routes/notifications.tsx +0 -87
  59. package/src/tui/routes/post.tsx +0 -102
  60. package/src/tui/routes/search.tsx +0 -105
  61. package/src/tui/routes/setup.tsx +0 -255
  62. package/src/tui/routes/trending.tsx +0 -107
  63. package/src/util/__tests__/context.test.ts +0 -31
  64. package/src/util/__tests__/lazy.test.ts +0 -37
  65. package/src/util/context.ts +0 -23
  66. package/src/util/error.ts +0 -46
  67. package/src/util/lazy.ts +0 -18
  68. package/src/util/log.ts +0 -142
  69. package/tsconfig.json +0 -11
@@ -1,660 +0,0 @@
1
- import { createSignal, createMemo, createEffect, onCleanup, Show, For } from "solid-js"
2
- import { useKeyboard, usePaste } from "@opentui/solid"
3
- import { SyntaxStyle, type ThemeTokenStyle } from "@opentui/core"
4
- import { useRoute } from "../context/route"
5
- import { useExit } from "../context/exit"
6
- import { useTheme, type ThemeColors } from "../context/theme"
7
- import { createCommands, LOGO, TIPS, TIPS_NO_AI } from "../commands"
8
- import { TOOL_LABELS } from "../../ai/tools"
9
- import { mask, saveProvider } from "../../ai/configure"
10
- import { ChatHistory } from "../../storage/chat"
11
-
12
- function buildMarkdownSyntaxRules(colors: ThemeColors): ThemeTokenStyle[] {
13
- return [
14
- { scope: ["default"], style: { foreground: colors.text } },
15
- { scope: ["spell", "nospell"], style: { foreground: colors.text } },
16
- { scope: ["conceal"], style: { foreground: colors.textMuted } },
17
- { scope: ["markup.heading", "markup.heading.1", "markup.heading.2", "markup.heading.3", "markup.heading.4", "markup.heading.5", "markup.heading.6"], style: { foreground: colors.primary, bold: true } },
18
- { scope: ["markup.bold", "markup.strong"], style: { foreground: colors.text, bold: true } },
19
- { scope: ["markup.italic"], style: { foreground: colors.text, italic: true } },
20
- { scope: ["markup.list"], style: { foreground: colors.text } },
21
- { scope: ["markup.quote"], style: { foreground: colors.textMuted, italic: true } },
22
- { scope: ["markup.raw", "markup.raw.block", "markup.raw.inline"], style: { foreground: colors.accent } },
23
- { scope: ["markup.link", "markup.link.url"], style: { foreground: colors.primary, underline: true } },
24
- { scope: ["markup.link.label"], style: { foreground: colors.primary, underline: true } },
25
- { scope: ["label"], style: { foreground: colors.primary } },
26
- { scope: ["comment"], style: { foreground: colors.textMuted, italic: true } },
27
- { scope: ["string", "symbol"], style: { foreground: colors.success } },
28
- { scope: ["number", "boolean"], style: { foreground: colors.accent } },
29
- { scope: ["keyword"], style: { foreground: colors.primary, italic: true } },
30
- { scope: ["keyword.function", "function.method", "function", "constructor", "variable.member"], style: { foreground: colors.primary } },
31
- { scope: ["variable", "variable.parameter", "property", "parameter"], style: { foreground: colors.text } },
32
- { scope: ["type", "module", "class"], style: { foreground: colors.warning } },
33
- { scope: ["operator", "keyword.operator", "punctuation.delimiter"], style: { foreground: colors.textMuted } },
34
- { scope: ["punctuation", "punctuation.bracket"], style: { foreground: colors.textMuted } },
35
- ]
36
- }
37
-
38
- interface ChatMsg {
39
- role: "user" | "assistant" | "tool"
40
- content: string
41
- toolName?: string
42
- toolStatus?: "running" | "done" | "error"
43
- }
44
-
45
- export function Home(props: {
46
- loggedIn: boolean
47
- username: string
48
- activeAgent: string
49
- hasAI: boolean
50
- aiProvider: string
51
- modelName: string
52
- onLogin: () => Promise<void>
53
- onLogout: () => void
54
- onAIConfigured: () => void
55
- }) {
56
- const route = useRoute()
57
- const exit = useExit()
58
- const theme = useTheme()
59
- const [input, setInput] = createSignal("")
60
- const [message, setMessage] = createSignal("")
61
- const [messageColor, setMessageColor] = createSignal("#6a737c")
62
- const [selectedIdx, setSelectedIdx] = createSignal(0)
63
-
64
- const [messages, setMessages] = createSignal<ChatMsg[]>([])
65
- const [streaming, setStreaming] = createSignal(false)
66
- const [streamText, setStreamText] = createSignal("")
67
- let abortCtrl: AbortController | undefined
68
- let escCooldown = 0
69
- let sessionId = ""
70
- const chatting = createMemo(() => messages().length > 0 || streaming())
71
- const syntaxStyle = createMemo(() => SyntaxStyle.fromTheme(buildMarkdownSyntaxRules(theme.colors)))
72
-
73
- function ensureSession() {
74
- if (!sessionId) {
75
- sessionId = crypto.randomUUID()
76
- try { ChatHistory.create(sessionId) } catch {}
77
- }
78
- }
79
-
80
- function saveChat() {
81
- if (!sessionId) return
82
- try { ChatHistory.save(sessionId, messages()) } catch {}
83
- }
84
-
85
- function resumeSession(sid?: string) {
86
- try {
87
- if (!sid) {
88
- const sessions = ChatHistory.list(1)
89
- if (sessions.length === 0) { showMsg("No previous sessions", theme.colors.warning); return }
90
- sid = sessions[0].id
91
- }
92
- const msgs = ChatHistory.load(sid)
93
- if (msgs.length === 0) { showMsg("Session is empty", theme.colors.warning); return }
94
- sessionId = sid
95
- setMessages(msgs as ChatMsg[])
96
- showMsg("Resumed session", theme.colors.success)
97
- } catch { showMsg("Failed to resume", theme.colors.error) }
98
- }
99
-
100
- // Shimmer animation for thinking state (like Claude Code)
101
- const SHIMMER_WORDS = ["Thinking", "Reasoning", "Composing", "Reflecting", "Analyzing", "Processing"]
102
- const [shimmerIdx, setShimmerIdx] = createSignal(0)
103
- const [shimmerDots, setShimmerDots] = createSignal(0)
104
- createEffect(() => {
105
- if (!streaming()) return
106
- const id = setInterval(() => {
107
- setShimmerDots((d) => (d + 1) % 4)
108
- if (shimmerDots() === 0) setShimmerIdx((i) => (i + 1) % SHIMMER_WORDS.length)
109
- }, 500)
110
- onCleanup(() => clearInterval(id))
111
- })
112
- const shimmerText = () => SHIMMER_WORDS[shimmerIdx()] + ".".repeat(shimmerDots())
113
-
114
- const tipPool = () => props.hasAI ? TIPS : TIPS_NO_AI
115
- const tipIdx = Math.floor(Math.random() * TIPS.length)
116
- const [aiMode, setAiMode] = createSignal<"" | "url" | "key" | "testing">("")
117
- const [aiUrl, setAiUrl] = createSignal("")
118
- const [aiKey, setAiKey] = createSignal("")
119
-
120
- function showMsg(text: string, color = "#6a737c") {
121
- setMessage(text)
122
- setMessageColor(color)
123
- }
124
-
125
- function clearChat() {
126
- setMessages([]); setStreamText(""); setStreaming(false); setMessage("")
127
- sessionId = ""
128
- }
129
-
130
- const commands = createCommands({
131
- showMsg,
132
- navigate: route.navigate,
133
- exit,
134
- onLogin: props.onLogin,
135
- onLogout: props.onLogout,
136
- clearChat,
137
- startAIConfig: () => {
138
- setAiUrl(""); setAiKey(""); setAiMode("url")
139
- showMsg("Paste your API URL (or press Enter to skip):", theme.colors.primary)
140
- },
141
- setMode: theme.setMode,
142
- send,
143
- resume: resumeSession,
144
- listSessions: () => { try { return ChatHistory.list(10) } catch { return [] } },
145
- hasAI: props.hasAI,
146
- colors: theme.colors,
147
- })
148
-
149
- const filtered = createMemo(() => {
150
- const v = input()
151
- if (!v.startsWith("/")) return []
152
- const query = v.slice(1).toLowerCase()
153
- if (!query) return commands
154
- return commands.filter((c) => c.name.slice(1).toLowerCase().includes(query))
155
- })
156
-
157
- const showAutocomplete = createMemo(() => {
158
- const v = input()
159
- if (aiMode()) return false
160
- if (!v.startsWith("/")) return false
161
- if (v.includes(" ")) return false
162
- return filtered().length > 0
163
- })
164
-
165
- usePaste((evt) => {
166
- // For URL/key modes, strip newlines; for normal input, preserve them
167
- if (aiMode() === "url" || aiMode() === "key") {
168
- const text = evt.text.replace(/[\n\r]/g, "").trim()
169
- if (!text) return
170
- evt.preventDefault()
171
- if (aiMode() === "url") { setAiUrl(text); return }
172
- setAiKey(text)
173
- return
174
- }
175
- const text = evt.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
176
- if (!text) return
177
- evt.preventDefault()
178
- setInput((s) => s + text)
179
- })
180
-
181
- async function send(text: string) {
182
- if (!text.trim() || streaming()) return
183
- ensureSession()
184
- const userMsg: ChatMsg = { role: "user", content: text.trim() }
185
- const prev = messages()
186
- setMessages([...prev, userMsg])
187
- setStreaming(true)
188
- setStreamText("")
189
- setMessage("")
190
- let summaryStreamActive = false
191
-
192
- try {
193
- const { AIChat } = await import("../../ai/chat")
194
- const { Config } = await import("../../config")
195
- const { AIProvider } = await import("../../ai/provider")
196
- const { Log } = await import("../../util/log")
197
- const sendLog = Log.create({ service: "home-send" })
198
- const cfg = await Config.load()
199
- 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 = ""
205
- 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) => {
210
- 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 = ""
217
- setStreamText("")
218
- }
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 })
223
- 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 })
283
- setMessages((p) => {
284
- // Mark any running tools as error
285
- const updated = p.map((m) =>
286
- m.role === "tool" && m.toolStatus === "running"
287
- ? { ...m, toolStatus: "error" as const }
288
- : m
289
- )
290
- return [...updated, { role: "assistant" as const, content: `Error: ${err.message}` }]
291
- })
292
- setStreamText(""); setStreaming(false)
293
- saveChat()
294
- },
295
- }, mid, abortCtrl.signal, { maxSteps: 10 })
296
- sendLog.info("AIChat.stream returned normally")
297
- abortCtrl = undefined
298
- } catch (err) {
299
- const msg = err instanceof Error ? err.message : String(err)
300
- // Can't use sendLog here because it might not be in scope
301
- setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
302
- saveChat()
303
- } finally {
304
- // Clean up streaming state — but NOT if a summary stream is still running
305
- if (!summaryStreamActive) {
306
- setStreamText("")
307
- setStreaming(false)
308
- }
309
- }
310
- }
311
-
312
- async function saveAI() {
313
- setAiMode("testing")
314
- showMsg("Detecting API format...", theme.colors.primary)
315
- try {
316
- const result = await saveProvider(aiUrl().trim(), aiKey().trim())
317
- if (result.error) {
318
- showMsg(result.error, theme.colors.error)
319
- setAiMode("key")
320
- return
321
- }
322
- showMsg(`✓ AI configured! (${result.provider})`, theme.colors.success)
323
- setAiMode("")
324
- props.onAIConfigured()
325
- } catch (err) {
326
- showMsg(`Save failed: ${err instanceof Error ? err.message : String(err)}`, theme.colors.error)
327
- setAiMode("key")
328
- }
329
- }
330
-
331
- async function handleSubmit() {
332
- if (aiMode() === "url") {
333
- const v = aiUrl().trim()
334
- if (v && !v.startsWith("http")) { showMsg("URL must start with http:// or https://", theme.colors.error); return }
335
- setAiMode("key")
336
- showMsg("Now paste your API key (or press Esc to cancel):", theme.colors.primary)
337
- return
338
- }
339
- if (aiMode() === "key") {
340
- const url = aiUrl().trim()
341
- const key = aiKey().trim()
342
- // Both empty → friendly skip
343
- if (!url && !key) {
344
- showMsg("No AI configuration provided — skipped. Use /ai anytime to configure.", theme.colors.warning)
345
- setAiMode("")
346
- return
347
- }
348
- // Key empty but URL provided → friendly skip
349
- if (!key) {
350
- showMsg("No API key provided — skipped. Use /ai anytime to configure.", theme.colors.warning)
351
- setAiMode("")
352
- return
353
- }
354
- if (key.length < 5) { showMsg("API key too short", theme.colors.error); return }
355
- saveAI()
356
- return
357
- }
358
-
359
- if (showAutocomplete()) {
360
- const items = filtered()
361
- const sel = items[selectedIdx()]
362
- if (sel) {
363
- if (sel.needsAI && !props.hasAI) {
364
- showMsg(`${sel.name} requires AI. Type /ai to configure.`, theme.colors.warning)
365
- setInput("")
366
- setSelectedIdx(0)
367
- return
368
- }
369
- setInput("")
370
- setSelectedIdx(0)
371
- sel.action(sel.name.split(/\s+/))
372
- return
373
- }
374
- }
375
-
376
- const text = input().trim()
377
- setInput("")
378
- setSelectedIdx(0)
379
- if (!text) return
380
-
381
- if (text.startsWith("/")) {
382
- const parts = text.split(/\s+/)
383
- const cmd = parts[0]
384
- const match = commands.find((c) => c.name === cmd)
385
- if (match) {
386
- if (match.needsAI && !props.hasAI) {
387
- showMsg(`${cmd} requires AI. Type /ai to configure.`, theme.colors.warning)
388
- return
389
- }
390
- match.action(parts)
391
- return
392
- }
393
- if (cmd === "/quit" || cmd === "/q") { exit(); return }
394
- showMsg(`Unknown command: ${cmd}. Type / to see commands`, theme.colors.error)
395
- return
396
- }
397
-
398
- if (!props.hasAI) {
399
- showMsg("No AI configured. Type /ai to set up", theme.colors.error)
400
- return
401
- }
402
-
403
- send(text)
404
- }
405
-
406
- useKeyboard((evt) => {
407
- if (aiMode() === "url") {
408
- if (evt.name === "return") { handleSubmit(); evt.preventDefault(); return }
409
- if (evt.name === "escape") { setAiMode(""); setMessage(""); evt.preventDefault(); return }
410
- if (evt.name === "backspace") { setAiUrl((s) => s.slice(0, -1)); evt.preventDefault(); return }
411
- if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
412
- const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
413
- if (clean) { setAiUrl((s) => s + clean); evt.preventDefault(); return }
414
- }
415
- if (evt.name === "space") { evt.preventDefault(); return }
416
- return
417
- }
418
- if (aiMode() === "key") {
419
- if (evt.name === "return") { handleSubmit(); evt.preventDefault(); return }
420
- if (evt.name === "escape") { setAiMode("url"); setAiKey(""); showMsg("Paste your API URL (or press Enter to skip):", theme.colors.primary); evt.preventDefault(); return }
421
- if (evt.name === "backspace") { setAiKey((s) => s.slice(0, -1)); evt.preventDefault(); return }
422
- if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
423
- const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
424
- if (clean) { setAiKey((s) => s + clean); evt.preventDefault(); return }
425
- }
426
- if (evt.name === "space") { setAiKey((s) => s + " "); evt.preventDefault(); return }
427
- return
428
- }
429
-
430
- 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 }
433
- if (evt.name === "tab") {
434
- const sel = filtered()[selectedIdx()]
435
- if (sel) setInput(sel.name + " ")
436
- evt.preventDefault()
437
- return
438
- }
439
- if (evt.name === "escape") { setInput(""); setSelectedIdx(0); evt.preventDefault(); return }
440
- }
441
-
442
- // Escape while streaming → abort; while chatting → clear
443
- if (evt.name === "escape" && streaming()) {
444
- 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
- evt.preventDefault(); return
450
- }
451
- if (evt.name === "escape" && chatting() && !streaming()) { clearChat(); evt.preventDefault(); return }
452
-
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 }
455
- if (evt.name === "backspace") { setInput((s) => s.slice(0, -1)); setSelectedIdx(0); evt.preventDefault(); return }
456
- if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
457
- const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
458
- if (clean) { setInput((s) => s + clean); setSelectedIdx(0); evt.preventDefault(); return }
459
- }
460
- if (evt.name === "space") { setInput((s) => s + " "); evt.preventDefault(); return }
461
- })
462
-
463
- return (
464
- <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} />
468
- <box flexShrink={0} flexDirection="column" alignItems="center">
469
- {LOGO.map((line, i) => (
470
- <text fg={i < 4 ? theme.colors.logo1 : theme.colors.logo2}>{line}</text>
471
- ))}
472
- <box height={1} />
473
- <text fg={theme.colors.textMuted}>The AI-powered coding forum</text>
474
-
475
- {/* Status info below logo */}
476
- <box height={1} />
477
- <box flexDirection="column" alignItems="center" gap={0}>
478
- <box flexDirection="row" gap={1}>
479
- <text fg={props.hasAI ? theme.colors.success : theme.colors.warning}>
480
- {props.hasAI ? "●" : "○"}
481
- </text>
482
- <text fg={theme.colors.text}>
483
- {props.hasAI ? props.modelName : "No AI"}
484
- </text>
485
- <Show when={!props.hasAI}>
486
- <text fg={theme.colors.textMuted}> — type /ai</text>
487
- </Show>
488
- </box>
489
- <box flexDirection="row" gap={1}>
490
- <text fg={props.loggedIn ? theme.colors.success : theme.colors.warning}>
491
- {props.loggedIn ? "●" : "○"}
492
- </text>
493
- <text fg={theme.colors.text}>
494
- {props.loggedIn ? props.username : "Not logged in"}
495
- </text>
496
- <Show when={props.loggedIn && props.activeAgent}>
497
- <text fg={theme.colors.textMuted}> / {props.activeAgent}</text>
498
- </Show>
499
- <Show when={!props.loggedIn}>
500
- <text fg={theme.colors.textMuted}> — type /login</text>
501
- </Show>
502
- </box>
503
- </box>
504
- </box>
505
- </Show>
506
-
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) => (
512
- <box flexShrink={0}>
513
- {/* User message — bold with ❯ prefix */}
514
- <Show when={msg.role === "user"}>
515
- <box flexDirection="row" paddingBottom={1}>
516
- <text fg={theme.colors.primary} flexShrink={0}>
517
- <span style={{ bold: true }}>{"❯ "}</span>
518
- </text>
519
- <text fg={theme.colors.text} wrapMode="word" flexGrow={1} flexShrink={1}>
520
- <span style={{ bold: true }}>{msg.content}</span>
521
- </text>
522
- </box>
523
- </Show>
524
- {/* Tool execution — ⚙/✓ icon + tool name + status */}
525
- <Show when={msg.role === "tool"}>
526
- <box flexDirection="row" paddingLeft={2}>
527
- <text fg={msg.toolStatus === "done" ? theme.colors.success : msg.toolStatus === "error" ? theme.colors.error : theme.colors.warning} flexShrink={0}>
528
- {msg.toolStatus === "done" ? " ✓ " : msg.toolStatus === "error" ? " ✗ " : " ⚙ "}
529
- </text>
530
- <text fg={theme.colors.textMuted}>
531
- {msg.content}
532
- </text>
533
- </box>
534
- </Show>
535
- {/* Assistant message — ◆ prefix */}
536
- <Show when={msg.role === "assistant"}>
537
- <box paddingBottom={1} flexShrink={0}>
538
- <code
539
- filetype="markdown"
540
- drawUnstyledText={false}
541
- syntaxStyle={syntaxStyle()}
542
- content={msg.content}
543
- conceal={true}
544
- fg={theme.colors.text}
545
- />
546
- </box>
547
- </Show>
548
- </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>
576
-
577
- {/* Spacer when no chat and no autocomplete */}
578
- <Show when={!chatting()}>
579
- <box flexGrow={1} minHeight={0} />
580
- </Show>
581
-
582
- {/* Prompt — always at bottom */}
583
- <box flexShrink={0} paddingTop={1} paddingBottom={1}>
584
- <Show when={aiMode() === "url"}>
585
- <box flexDirection="column">
586
- <text fg={theme.colors.text}><span style={{ bold: true }}>API URL:</span></text>
587
- <box flexDirection="row">
588
- <text fg={theme.colors.primary}><span style={{ bold: true }}>{"❯ "}</span></text>
589
- <text fg={theme.colors.input}>{aiUrl()}</text>
590
- <text fg={theme.colors.cursor}>{"█"}</text>
591
- </box>
592
- </box>
593
- </Show>
594
- <Show when={aiMode() === "key"}>
595
- <box flexDirection="column">
596
- {aiUrl().trim() ? <text fg={theme.colors.textMuted}>{"URL: " + aiUrl().trim()}</text> : null}
597
- <text fg={theme.colors.text}><span style={{ bold: true }}>API Key:</span></text>
598
- <box flexDirection="row">
599
- <text fg={theme.colors.primary}><span style={{ bold: true }}>{"❯ "}</span></text>
600
- <text fg={theme.colors.input}>{mask(aiKey())}</text>
601
- <text fg={theme.colors.cursor}>{"█"}</text>
602
- </box>
603
- </box>
604
- </Show>
605
- <Show when={aiMode() === "testing"}>
606
- <text fg={theme.colors.primary}>Detecting API format...</text>
607
- </Show>
608
- <Show when={!aiMode()}>
609
- <box flexDirection="column">
610
- {/* Command autocomplete — above prompt */}
611
- <Show when={showAutocomplete()}>
612
- <box flexDirection="column" paddingBottom={1} maxHeight={8} overflow="hidden">
613
- <For each={filtered()}>
614
- {(cmd, i) => {
615
- const disabled = () => cmd.needsAI && !props.hasAI
616
- const selected = () => i() === selectedIdx()
617
- 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)}>
620
- {" " + cmd.name.padEnd(18)}
621
- </text>
622
- <text fg={selected() && !disabled() ? "#ffffff" : theme.colors.textMuted}>
623
- {disabled() ? cmd.description + " [needs /ai]" : cmd.description}
624
- </text>
625
- </box>
626
- )
627
- }}
628
- </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>
640
- </box>
641
- </Show>
642
- {/* Input line with blinking cursor */}
643
- <box flexDirection="column">
644
- {(() => {
645
- const lines = input().split("\n")
646
- return lines.map((line, i) => (
647
- <box flexDirection="row">
648
- <text fg={theme.colors.primary}><span style={{ bold: true }}>{i === 0 ? "❯ " : " "}</span></text>
649
- <text fg={theme.colors.input}>{line}</text>
650
- {i === lines.length - 1 && <text fg={theme.colors.cursor} style={{ bold: true }}>{"█"}</text>}
651
- </box>
652
- ))
653
- })()}
654
- </box>
655
- </box>
656
- </Show>
657
- </box>
658
- </box>
659
- )
660
- }