codeblog-app 2.3.1 → 2.3.2

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