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.
- package/package.json +8 -71
- package/drizzle/0000_init.sql +0 -34
- package/drizzle/meta/_journal.json +0 -13
- package/drizzle.config.ts +0 -10
- package/src/ai/__tests__/chat.test.ts +0 -179
- package/src/ai/__tests__/provider.test.ts +0 -198
- package/src/ai/__tests__/tools.test.ts +0 -93
- package/src/ai/chat.ts +0 -224
- package/src/ai/configure.ts +0 -134
- package/src/ai/provider.ts +0 -302
- package/src/ai/tools.ts +0 -114
- package/src/auth/index.ts +0 -47
- package/src/auth/oauth.ts +0 -108
- package/src/cli/__tests__/commands.test.ts +0 -225
- package/src/cli/cmd/agent.ts +0 -97
- package/src/cli/cmd/chat.ts +0 -190
- package/src/cli/cmd/comment.ts +0 -67
- package/src/cli/cmd/config.ts +0 -153
- package/src/cli/cmd/feed.ts +0 -53
- package/src/cli/cmd/forum.ts +0 -106
- package/src/cli/cmd/login.ts +0 -45
- package/src/cli/cmd/logout.ts +0 -12
- package/src/cli/cmd/me.ts +0 -188
- package/src/cli/cmd/post.ts +0 -25
- package/src/cli/cmd/publish.ts +0 -64
- package/src/cli/cmd/scan.ts +0 -78
- package/src/cli/cmd/search.ts +0 -35
- package/src/cli/cmd/setup.ts +0 -352
- package/src/cli/cmd/tui.ts +0 -20
- package/src/cli/cmd/uninstall.ts +0 -281
- package/src/cli/cmd/update.ts +0 -123
- package/src/cli/cmd/vote.ts +0 -50
- package/src/cli/cmd/whoami.ts +0 -18
- package/src/cli/mcp-print.ts +0 -6
- package/src/cli/ui.ts +0 -250
- package/src/config/index.ts +0 -55
- package/src/flag/index.ts +0 -23
- package/src/global/index.ts +0 -38
- package/src/id/index.ts +0 -20
- package/src/index.ts +0 -200
- package/src/mcp/__tests__/client.test.ts +0 -149
- package/src/mcp/__tests__/e2e.ts +0 -327
- package/src/mcp/__tests__/integration.ts +0 -148
- package/src/mcp/client.ts +0 -148
- package/src/server/index.ts +0 -48
- package/src/storage/chat.ts +0 -71
- package/src/storage/db.ts +0 -85
- package/src/storage/schema.sql.ts +0 -39
- package/src/storage/schema.ts +0 -1
- package/src/tui/app.tsx +0 -184
- package/src/tui/commands.ts +0 -186
- package/src/tui/context/exit.tsx +0 -15
- package/src/tui/context/helper.tsx +0 -25
- package/src/tui/context/route.tsx +0 -24
- package/src/tui/context/theme.tsx +0 -470
- package/src/tui/routes/home.tsx +0 -660
- package/src/tui/routes/model.tsx +0 -210
- package/src/tui/routes/notifications.tsx +0 -87
- package/src/tui/routes/post.tsx +0 -102
- package/src/tui/routes/search.tsx +0 -105
- package/src/tui/routes/setup.tsx +0 -255
- package/src/tui/routes/trending.tsx +0 -107
- package/src/util/__tests__/context.test.ts +0 -31
- package/src/util/__tests__/lazy.test.ts +0 -37
- package/src/util/context.ts +0 -23
- package/src/util/error.ts +0 -46
- package/src/util/lazy.ts +0 -18
- package/src/util/log.ts +0 -142
- package/tsconfig.json +0 -11
package/src/tui/routes/home.tsx
DELETED
|
@@ -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
|
-
}
|