codeblog-app 2.2.6 → 2.3.1
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 +9 -7
- package/src/ai/__tests__/chat.test.ts +11 -2
- package/src/ai/__tests__/compat.test.ts +46 -0
- package/src/ai/__tests__/home.ai-stream.integration.test.ts +77 -0
- package/src/ai/__tests__/provider-registry.test.ts +61 -0
- package/src/ai/__tests__/provider.test.ts +58 -18
- package/src/ai/__tests__/stream-events.test.ts +152 -0
- package/src/ai/chat.ts +200 -88
- package/src/ai/configure.ts +13 -4
- package/src/ai/models.ts +26 -0
- package/src/ai/provider-registry.ts +150 -0
- package/src/ai/provider.ts +99 -137
- package/src/ai/stream-events.ts +64 -0
- package/src/ai/tools.ts +10 -6
- package/src/ai/types.ts +105 -0
- package/src/auth/index.ts +3 -1
- package/src/auth/oauth.ts +17 -2
- package/src/cli/__tests__/commands.test.ts +6 -2
- package/src/cli/cmd/ai.ts +10 -0
- package/src/cli/cmd/setup.ts +275 -5
- package/src/cli/ui.ts +131 -24
- package/src/config/index.ts +38 -1
- package/src/index.ts +4 -1
- package/src/mcp/__tests__/client.test.ts +2 -2
- package/src/mcp/__tests__/e2e.ts +10 -6
- package/src/mcp/client.ts +33 -63
- package/src/storage/chat.ts +3 -1
- package/src/tui/__tests__/input-intent.test.ts +27 -0
- package/src/tui/__tests__/stream-assembler.test.ts +33 -0
- package/src/tui/ai-stream.ts +28 -0
- package/src/tui/app.tsx +27 -1
- package/src/tui/commands.ts +41 -7
- package/src/tui/context/theme.tsx +2 -1
- package/src/tui/input-intent.ts +26 -0
- package/src/tui/routes/home.tsx +590 -190
- package/src/tui/routes/setup.tsx +20 -8
- package/src/tui/stream-assembler.ts +49 -0
- package/src/util/log.ts +3 -1
- package/tsconfig.json +1 -1
package/src/tui/routes/home.tsx
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { createSignal, createMemo, createEffect, onCleanup, Show, For } from "solid-js"
|
|
1
|
+
import { createSignal, createMemo, createEffect, onCleanup, onMount, untrack, Show, For } from "solid-js"
|
|
2
2
|
import { useKeyboard, usePaste } from "@opentui/solid"
|
|
3
3
|
import { SyntaxStyle, type ThemeTokenStyle } from "@opentui/core"
|
|
4
|
-
import { useRoute } from "../context/route"
|
|
5
4
|
import { useExit } from "../context/exit"
|
|
6
5
|
import { useTheme, type ThemeColors } from "../context/theme"
|
|
7
6
|
import { createCommands, LOGO, TIPS, TIPS_NO_AI } from "../commands"
|
|
8
7
|
import { TOOL_LABELS } from "../../ai/tools"
|
|
9
8
|
import { mask, saveProvider } from "../../ai/configure"
|
|
10
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"
|
|
11
14
|
|
|
12
15
|
function buildMarkdownSyntaxRules(colors: ThemeColors): ThemeTokenStyle[] {
|
|
13
16
|
return [
|
|
@@ -36,12 +39,20 @@ function buildMarkdownSyntaxRules(colors: ThemeColors): ThemeTokenStyle[] {
|
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
interface ChatMsg {
|
|
39
|
-
role: "user" | "assistant" | "tool"
|
|
42
|
+
role: "user" | "assistant" | "tool" | "system"
|
|
40
43
|
content: string
|
|
44
|
+
modelContent?: string
|
|
45
|
+
tone?: "info" | "success" | "warning" | "error"
|
|
41
46
|
toolName?: string
|
|
47
|
+
toolCallID?: string
|
|
42
48
|
toolStatus?: "running" | "done" | "error"
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
interface ModelOption {
|
|
52
|
+
id: string
|
|
53
|
+
label: string
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
export function Home(props: {
|
|
46
57
|
loggedIn: boolean
|
|
47
58
|
username: string
|
|
@@ -53,21 +64,25 @@ export function Home(props: {
|
|
|
53
64
|
onLogout: () => void
|
|
54
65
|
onAIConfigured: () => void
|
|
55
66
|
}) {
|
|
56
|
-
const route = useRoute()
|
|
57
67
|
const exit = useExit()
|
|
58
68
|
const theme = useTheme()
|
|
59
69
|
const [input, setInput] = createSignal("")
|
|
60
|
-
const [message, setMessage] = createSignal("")
|
|
61
|
-
const [messageColor, setMessageColor] = createSignal("#6a737c")
|
|
62
70
|
const [selectedIdx, setSelectedIdx] = createSignal(0)
|
|
63
71
|
|
|
64
72
|
const [messages, setMessages] = createSignal<ChatMsg[]>([])
|
|
65
73
|
const [streaming, setStreaming] = createSignal(false)
|
|
66
74
|
const [streamText, setStreamText] = createSignal("")
|
|
67
75
|
let abortCtrl: AbortController | undefined
|
|
68
|
-
let
|
|
76
|
+
let abortByUser = false
|
|
77
|
+
let shiftDown = false
|
|
78
|
+
let commandDisplay = ""
|
|
69
79
|
let sessionId = ""
|
|
70
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
|
+
})
|
|
71
86
|
const syntaxStyle = createMemo(() => SyntaxStyle.fromTheme(buildMarkdownSyntaxRules(theme.colors)))
|
|
72
87
|
|
|
73
88
|
function ensureSession() {
|
|
@@ -87,7 +102,9 @@ export function Home(props: {
|
|
|
87
102
|
if (!sid) {
|
|
88
103
|
const sessions = ChatHistory.list(1)
|
|
89
104
|
if (sessions.length === 0) { showMsg("No previous sessions", theme.colors.warning); return }
|
|
90
|
-
|
|
105
|
+
const latest = sessions[0]
|
|
106
|
+
if (!latest) { showMsg("No previous sessions", theme.colors.warning); return }
|
|
107
|
+
sid = latest.id
|
|
91
108
|
}
|
|
92
109
|
const msgs = ChatHistory.load(sid)
|
|
93
110
|
if (msgs.length === 0) { showMsg("Session is empty", theme.colors.warning); return }
|
|
@@ -116,30 +133,175 @@ export function Home(props: {
|
|
|
116
133
|
const [aiMode, setAiMode] = createSignal<"" | "url" | "key" | "testing">("")
|
|
117
134
|
const [aiUrl, setAiUrl] = createSignal("")
|
|
118
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
|
+
}
|
|
119
151
|
|
|
120
152
|
function showMsg(text: string, color = "#6a737c") {
|
|
121
|
-
|
|
122
|
-
|
|
153
|
+
ensureSession()
|
|
154
|
+
setMessages((p) => [...p, { role: "system", content: text, tone: tone(color) }])
|
|
123
155
|
}
|
|
124
156
|
|
|
125
157
|
function clearChat() {
|
|
126
|
-
|
|
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("")
|
|
127
170
|
sessionId = ""
|
|
128
171
|
}
|
|
129
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
|
+
|
|
130
291
|
const commands = createCommands({
|
|
131
292
|
showMsg,
|
|
132
|
-
|
|
293
|
+
openModelPicker,
|
|
133
294
|
exit,
|
|
134
295
|
onLogin: props.onLogin,
|
|
135
296
|
onLogout: props.onLogout,
|
|
136
297
|
clearChat,
|
|
137
298
|
startAIConfig: () => {
|
|
138
299
|
setAiUrl(""); setAiKey(""); setAiMode("url")
|
|
139
|
-
showMsg("
|
|
300
|
+
showMsg("Quick setup: paste API URL (or press Enter to skip). Full wizard: `codeblog ai setup`", theme.colors.primary)
|
|
140
301
|
},
|
|
141
302
|
setMode: theme.setMode,
|
|
142
303
|
send,
|
|
304
|
+
onAIConfigured: props.onAIConfigured,
|
|
143
305
|
resume: resumeSession,
|
|
144
306
|
listSessions: () => { try { return ChatHistory.list(10) } catch { return [] } },
|
|
145
307
|
hasAI: props.hasAI,
|
|
@@ -157,11 +319,72 @@ export function Home(props: {
|
|
|
157
319
|
const showAutocomplete = createMemo(() => {
|
|
158
320
|
const v = input()
|
|
159
321
|
if (aiMode()) return false
|
|
322
|
+
if (modelPicking()) return false
|
|
160
323
|
if (!v.startsWith("/")) return false
|
|
161
324
|
if (v.includes(" ")) return false
|
|
162
325
|
return filtered().length > 0
|
|
163
326
|
})
|
|
164
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
|
+
|
|
165
388
|
usePaste((evt) => {
|
|
166
389
|
// For URL/key modes, strip newlines; for normal input, preserve them
|
|
167
390
|
if (aiMode() === "url" || aiMode() === "key") {
|
|
@@ -178,134 +401,124 @@ export function Home(props: {
|
|
|
178
401
|
setInput((s) => s + text)
|
|
179
402
|
})
|
|
180
403
|
|
|
181
|
-
async function send(text: string) {
|
|
404
|
+
async function send(text: string, options?: { display?: string }) {
|
|
182
405
|
if (!text.trim() || streaming()) return
|
|
183
406
|
ensureSession()
|
|
184
|
-
const
|
|
407
|
+
const prompt = text.trim()
|
|
408
|
+
const userMsg: ChatMsg = { role: "user", content: options?.display || commandDisplay || prompt, modelContent: prompt }
|
|
185
409
|
const prev = messages()
|
|
186
410
|
setMessages([...prev, userMsg])
|
|
187
411
|
setStreaming(true)
|
|
188
412
|
setStreamText("")
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
}
|
|
191
432
|
|
|
192
433
|
try {
|
|
193
434
|
const { AIChat } = await import("../../ai/chat")
|
|
194
|
-
const { Config } = await import("../../config")
|
|
195
435
|
const { AIProvider } = await import("../../ai/provider")
|
|
196
|
-
const {
|
|
197
|
-
const sendLog = Log.create({ service: "home-send" })
|
|
436
|
+
const { Config } = await import("../../config")
|
|
198
437
|
const cfg = await Config.load()
|
|
199
438
|
const mid = cfg.model || AIProvider.DEFAULT_MODEL
|
|
200
|
-
const allMsgs = [...prev, userMsg]
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
let lastToolName = ""
|
|
204
|
-
let lastToolResult = ""
|
|
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 }))
|
|
205
442
|
abortCtrl = new AbortController()
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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") {
|
|
210
454
|
hasToolCalls = true
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
|
|
216
|
-
full = ""
|
|
455
|
+
const partial = assembler.getText().trim()
|
|
456
|
+
if (partial) {
|
|
457
|
+
setMessages((p) => [...p, { role: "assistant", content: partial }])
|
|
458
|
+
assembler.reset()
|
|
217
459
|
setStreamText("")
|
|
218
460
|
}
|
|
219
|
-
setMessages((p) => [...p, { role: "tool", content: TOOL_LABELS[name] || name, toolName: name, toolStatus: "running" }])
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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") {
|
|
223
466
|
try {
|
|
224
|
-
const str = typeof result === "string" ? result : JSON.stringify(result)
|
|
225
|
-
|
|
226
|
-
} catch {
|
|
227
|
-
setMessages((p) =>
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 })
|
|
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") {
|
|
283
489
|
setMessages((p) => {
|
|
284
|
-
// Mark any running tools as error
|
|
285
490
|
const updated = p.map((m) =>
|
|
286
491
|
m.role === "tool" && m.toolStatus === "running"
|
|
287
492
|
? { ...m, toolStatus: "error" as const }
|
|
288
493
|
: m
|
|
289
494
|
)
|
|
290
|
-
return [...updated, { role: "assistant" as const, content: `Error: ${
|
|
495
|
+
return [...updated, { role: "assistant" as const, content: `Error: ${event.error.message}` }]
|
|
291
496
|
})
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|
|
298
513
|
} catch (err) {
|
|
299
514
|
const msg = err instanceof Error ? err.message : String(err)
|
|
300
|
-
// Can't use sendLog here because it might not be in scope
|
|
301
515
|
setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
|
|
302
|
-
saveChat()
|
|
303
516
|
} finally {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
517
|
+
if (flushTimer) clearTimeout(flushTimer)
|
|
518
|
+
abortCtrl = undefined
|
|
519
|
+
setStreamText("")
|
|
520
|
+
setStreaming(false)
|
|
521
|
+
saveChat()
|
|
309
522
|
}
|
|
310
523
|
}
|
|
311
524
|
|
|
@@ -368,7 +581,12 @@ export function Home(props: {
|
|
|
368
581
|
}
|
|
369
582
|
setInput("")
|
|
370
583
|
setSelectedIdx(0)
|
|
371
|
-
sel.
|
|
584
|
+
commandDisplay = sel.name
|
|
585
|
+
try {
|
|
586
|
+
await sel.action(sel.name.split(/\s+/))
|
|
587
|
+
} finally {
|
|
588
|
+
commandDisplay = ""
|
|
589
|
+
}
|
|
372
590
|
return
|
|
373
591
|
}
|
|
374
592
|
}
|
|
@@ -387,7 +605,12 @@ export function Home(props: {
|
|
|
387
605
|
showMsg(`${cmd} requires AI. Type /ai to configure.`, theme.colors.warning)
|
|
388
606
|
return
|
|
389
607
|
}
|
|
390
|
-
|
|
608
|
+
commandDisplay = text
|
|
609
|
+
try {
|
|
610
|
+
await match.action(parts)
|
|
611
|
+
} finally {
|
|
612
|
+
commandDisplay = ""
|
|
613
|
+
}
|
|
391
614
|
return
|
|
392
615
|
}
|
|
393
616
|
if (cmd === "/quit" || cmd === "/q") { exit(); return }
|
|
@@ -400,13 +623,58 @@ export function Home(props: {
|
|
|
400
623
|
return
|
|
401
624
|
}
|
|
402
625
|
|
|
403
|
-
send(text)
|
|
626
|
+
send(text, { display: text })
|
|
404
627
|
}
|
|
405
628
|
|
|
406
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
|
+
|
|
407
675
|
if (aiMode() === "url") {
|
|
408
|
-
if (
|
|
409
|
-
if (evt.name === "escape") { setAiMode("");
|
|
676
|
+
if (submitKey || newlineKey) { handleSubmit(); evt.preventDefault(); return }
|
|
677
|
+
if (evt.name === "escape") { setAiMode(""); evt.preventDefault(); return }
|
|
410
678
|
if (evt.name === "backspace") { setAiUrl((s) => s.slice(0, -1)); evt.preventDefault(); return }
|
|
411
679
|
if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
|
|
412
680
|
const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
|
|
@@ -416,7 +684,7 @@ export function Home(props: {
|
|
|
416
684
|
return
|
|
417
685
|
}
|
|
418
686
|
if (aiMode() === "key") {
|
|
419
|
-
if (
|
|
687
|
+
if (submitKey || newlineKey) { handleSubmit(); evt.preventDefault(); return }
|
|
420
688
|
if (evt.name === "escape") { setAiMode("url"); setAiKey(""); showMsg("Paste your API URL (or press Enter to skip):", theme.colors.primary); evt.preventDefault(); return }
|
|
421
689
|
if (evt.name === "backspace") { setAiKey((s) => s.slice(0, -1)); evt.preventDefault(); return }
|
|
422
690
|
if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
|
|
@@ -427,9 +695,72 @@ export function Home(props: {
|
|
|
427
695
|
return
|
|
428
696
|
}
|
|
429
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
|
+
|
|
430
755
|
if (showAutocomplete()) {
|
|
431
|
-
if (evt.name === "up"
|
|
432
|
-
|
|
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
|
+
}
|
|
433
764
|
if (evt.name === "tab") {
|
|
434
765
|
const sel = filtered()[selectedIdx()]
|
|
435
766
|
if (sel) setInput(sel.name + " ")
|
|
@@ -441,30 +772,36 @@ export function Home(props: {
|
|
|
441
772
|
|
|
442
773
|
// Escape while streaming → abort; while chatting → clear
|
|
443
774
|
if (evt.name === "escape" && streaming()) {
|
|
775
|
+
abortByUser = true
|
|
444
776
|
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
777
|
evt.preventDefault(); return
|
|
450
778
|
}
|
|
451
779
|
if (evt.name === "escape" && chatting() && !streaming()) { clearChat(); evt.preventDefault(); return }
|
|
452
780
|
|
|
453
|
-
if (evt.
|
|
454
|
-
if (
|
|
781
|
+
if (submitKey && !shift && !evt.ctrl && !evt.meta && !modifiedSubmitFromRaw) { handleSubmit(); evt.preventDefault(); return }
|
|
782
|
+
if (newlineKey) { setInput((s) => s + "\n"); evt.preventDefault(); return }
|
|
455
783
|
if (evt.name === "backspace") { setInput((s) => s.slice(0, -1)); setSelectedIdx(0); evt.preventDefault(); return }
|
|
456
784
|
if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
|
|
457
785
|
const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
|
|
458
786
|
if (clean) { setInput((s) => s + clean); setSelectedIdx(0); evt.preventDefault(); return }
|
|
459
787
|
}
|
|
460
788
|
if (evt.name === "space") { setInput((s) => s + " "); evt.preventDefault(); return }
|
|
461
|
-
})
|
|
789
|
+
}, { release: true })
|
|
462
790
|
|
|
463
791
|
return (
|
|
464
792
|
<box flexDirection="column" flexGrow={1} paddingLeft={2} paddingRight={2}>
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
+
|
|
468
805
|
<box flexShrink={0} flexDirection="column" alignItems="center">
|
|
469
806
|
{LOGO.map((line, i) => (
|
|
470
807
|
<text fg={i < 4 ? theme.colors.logo1 : theme.colors.logo2}>{line}</text>
|
|
@@ -472,7 +809,6 @@ export function Home(props: {
|
|
|
472
809
|
<box height={1} />
|
|
473
810
|
<text fg={theme.colors.textMuted}>The AI-powered coding forum</text>
|
|
474
811
|
|
|
475
|
-
{/* Status info below logo */}
|
|
476
812
|
<box height={1} />
|
|
477
813
|
<box flexDirection="column" alignItems="center" gap={0}>
|
|
478
814
|
<box flexDirection="row" gap={1}>
|
|
@@ -501,16 +837,42 @@ export function Home(props: {
|
|
|
501
837
|
</Show>
|
|
502
838
|
</box>
|
|
503
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>
|
|
504
847
|
</box>
|
|
505
|
-
</Show>
|
|
506
848
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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 (
|
|
512
875
|
<box flexShrink={0}>
|
|
513
|
-
{/* User message — bold with ❯ prefix */}
|
|
514
876
|
<Show when={msg.role === "user"}>
|
|
515
877
|
<box flexDirection="row" paddingBottom={1}>
|
|
516
878
|
<text fg={theme.colors.primary} flexShrink={0}>
|
|
@@ -521,7 +883,6 @@ export function Home(props: {
|
|
|
521
883
|
</text>
|
|
522
884
|
</box>
|
|
523
885
|
</Show>
|
|
524
|
-
{/* Tool execution — ⚙/✓ icon + tool name + status */}
|
|
525
886
|
<Show when={msg.role === "tool"}>
|
|
526
887
|
<box flexDirection="row" paddingLeft={2}>
|
|
527
888
|
<text fg={msg.toolStatus === "done" ? theme.colors.success : msg.toolStatus === "error" ? theme.colors.error : theme.colors.warning} flexShrink={0}>
|
|
@@ -532,7 +893,6 @@ export function Home(props: {
|
|
|
532
893
|
</text>
|
|
533
894
|
</box>
|
|
534
895
|
</Show>
|
|
535
|
-
{/* Assistant message — ◆ prefix */}
|
|
536
896
|
<Show when={msg.role === "assistant"}>
|
|
537
897
|
<box paddingBottom={1} flexShrink={0}>
|
|
538
898
|
<code
|
|
@@ -545,39 +905,49 @@ export function Home(props: {
|
|
|
545
905
|
/>
|
|
546
906
|
</box>
|
|
547
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>
|
|
548
942
|
</box>
|
|
549
|
-
)
|
|
550
|
-
|
|
551
|
-
|
|
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>
|
|
943
|
+
)
|
|
944
|
+
}}
|
|
945
|
+
</For>
|
|
576
946
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
</
|
|
947
|
+
<Show when={!chatting()}>
|
|
948
|
+
<box flexGrow={1} minHeight={0} />
|
|
949
|
+
</Show>
|
|
950
|
+
</scrollbox>
|
|
581
951
|
|
|
582
952
|
{/* Prompt — always at bottom */}
|
|
583
953
|
<box flexShrink={0} paddingTop={1} paddingBottom={1}>
|
|
@@ -607,36 +977,66 @@ export function Home(props: {
|
|
|
607
977
|
</Show>
|
|
608
978
|
<Show when={!aiMode()}>
|
|
609
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>
|
|
610
1015
|
{/* Command autocomplete — above prompt */}
|
|
611
1016
|
<Show when={showAutocomplete()}>
|
|
612
|
-
<box flexDirection="column" paddingBottom={1}
|
|
613
|
-
<
|
|
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()}>
|
|
614
1022
|
{(cmd, i) => {
|
|
615
1023
|
const disabled = () => cmd.needsAI && !props.hasAI
|
|
616
|
-
const selected = () => i() === selectedIdx()
|
|
1024
|
+
const selected = () => i() + visibleStart() === selectedIdx()
|
|
617
1025
|
return (
|
|
618
|
-
<box flexDirection="row" backgroundColor={selected()
|
|
619
|
-
<text fg={selected()
|
|
1026
|
+
<box flexDirection="row" backgroundColor={selected() ? theme.colors.primary : undefined}>
|
|
1027
|
+
<text fg={selected() ? "#ffffff" : (disabled() ? theme.colors.textMuted : theme.colors.primary)}>
|
|
620
1028
|
{" " + cmd.name.padEnd(18)}
|
|
621
1029
|
</text>
|
|
622
|
-
<text fg={selected()
|
|
1030
|
+
<text fg={selected() ? "#ffffff" : theme.colors.textMuted}>
|
|
623
1031
|
{disabled() ? cmd.description + " [needs /ai]" : cmd.description}
|
|
624
1032
|
</text>
|
|
625
1033
|
</box>
|
|
626
1034
|
)
|
|
627
1035
|
}}
|
|
628
1036
|
</For>
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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>
|
|
1037
|
+
<Show when={visibleStart() + visibleItems().length < filtered().length}>
|
|
1038
|
+
<text fg={theme.colors.textMuted}>{" ↓ more commands"}</text>
|
|
1039
|
+
</Show>
|
|
640
1040
|
</box>
|
|
641
1041
|
</Show>
|
|
642
1042
|
{/* Input line with blinking cursor */}
|
|
@@ -647,7 +1047,7 @@ export function Home(props: {
|
|
|
647
1047
|
<box flexDirection="row">
|
|
648
1048
|
<text fg={theme.colors.primary}><span style={{ bold: true }}>{i === 0 ? "❯ " : " "}</span></text>
|
|
649
1049
|
<text fg={theme.colors.input}>{line}</text>
|
|
650
|
-
{i === lines.length - 1 && <text fg={theme.colors.cursor} style={{ bold: true }}>{"█"}</text>}
|
|
1050
|
+
{i === lines.length - 1 && <text fg={theme.colors.cursor}><span style={{ bold: true }}>{"█"}</span></text>}
|
|
651
1051
|
</box>
|
|
652
1052
|
))
|
|
653
1053
|
})()}
|