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.
- package/drizzle/0000_init.sql +34 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/package.json +71 -8
- package/src/ai/__tests__/chat.test.ts +110 -0
- package/src/ai/__tests__/provider.test.ts +184 -0
- package/src/ai/__tests__/tools.test.ts +90 -0
- package/src/ai/chat.ts +169 -0
- package/src/ai/configure.ts +134 -0
- package/src/ai/provider.ts +238 -0
- package/src/ai/tools.ts +336 -0
- package/src/auth/index.ts +47 -0
- package/src/auth/oauth.ts +94 -0
- package/src/cli/__tests__/commands.test.ts +225 -0
- package/src/cli/cmd/agent.ts +102 -0
- package/src/cli/cmd/chat.ts +190 -0
- package/src/cli/cmd/comment.ts +70 -0
- package/src/cli/cmd/config.ts +153 -0
- package/src/cli/cmd/feed.ts +57 -0
- package/src/cli/cmd/forum.ts +123 -0
- package/src/cli/cmd/login.ts +45 -0
- package/src/cli/cmd/logout.ts +12 -0
- package/src/cli/cmd/me.ts +202 -0
- package/src/cli/cmd/post.ts +29 -0
- package/src/cli/cmd/publish.ts +70 -0
- package/src/cli/cmd/scan.ts +80 -0
- package/src/cli/cmd/search.ts +40 -0
- package/src/cli/cmd/setup.ts +273 -0
- package/src/cli/cmd/tui.ts +20 -0
- package/src/cli/cmd/update.ts +78 -0
- package/src/cli/cmd/vote.ts +50 -0
- package/src/cli/cmd/whoami.ts +21 -0
- package/src/cli/ui.ts +195 -0
- package/src/config/index.ts +54 -0
- package/src/flag/index.ts +23 -0
- package/src/global/index.ts +38 -0
- package/src/id/index.ts +20 -0
- package/src/index.ts +197 -0
- package/src/mcp/__tests__/client.test.ts +149 -0
- package/src/mcp/__tests__/e2e.ts +327 -0
- package/src/mcp/__tests__/integration.ts +148 -0
- package/src/mcp/client.ts +148 -0
- package/src/server/index.ts +48 -0
- package/src/storage/chat.ts +92 -0
- package/src/storage/db.ts +85 -0
- package/src/storage/schema.sql.ts +39 -0
- package/src/storage/schema.ts +1 -0
- package/src/tui/app.tsx +163 -0
- package/src/tui/commands.ts +187 -0
- package/src/tui/context/exit.tsx +15 -0
- package/src/tui/context/helper.tsx +25 -0
- package/src/tui/context/route.tsx +24 -0
- package/src/tui/context/theme.tsx +470 -0
- package/src/tui/routes/home.tsx +508 -0
- package/src/tui/routes/model.tsx +209 -0
- package/src/tui/routes/notifications.tsx +85 -0
- package/src/tui/routes/post.tsx +108 -0
- package/src/tui/routes/search.tsx +104 -0
- package/src/tui/routes/setup.tsx +255 -0
- package/src/tui/routes/trending.tsx +107 -0
- package/src/util/__tests__/context.test.ts +31 -0
- package/src/util/__tests__/lazy.test.ts +37 -0
- package/src/util/context.ts +23 -0
- package/src/util/error.ts +46 -0
- package/src/util/lazy.ts +18 -0
- package/src/util/log.ts +142 -0
- 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
|
+
}
|