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