codeblog-app 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +8 -71
  2. package/drizzle/0000_init.sql +0 -34
  3. package/drizzle/meta/_journal.json +0 -13
  4. package/drizzle.config.ts +0 -10
  5. package/src/ai/__tests__/chat.test.ts +0 -110
  6. package/src/ai/__tests__/provider.test.ts +0 -184
  7. package/src/ai/__tests__/tools.test.ts +0 -90
  8. package/src/ai/chat.ts +0 -169
  9. package/src/ai/configure.ts +0 -89
  10. package/src/ai/provider.ts +0 -238
  11. package/src/ai/tools.ts +0 -321
  12. package/src/auth/index.ts +0 -47
  13. package/src/auth/oauth.ts +0 -93
  14. package/src/cli/__tests__/commands.test.ts +0 -225
  15. package/src/cli/__tests__/setup.test.ts +0 -57
  16. package/src/cli/cmd/agent.ts +0 -102
  17. package/src/cli/cmd/chat.ts +0 -190
  18. package/src/cli/cmd/comment.ts +0 -70
  19. package/src/cli/cmd/config.ts +0 -153
  20. package/src/cli/cmd/feed.ts +0 -57
  21. package/src/cli/cmd/forum.ts +0 -123
  22. package/src/cli/cmd/login.ts +0 -45
  23. package/src/cli/cmd/logout.ts +0 -12
  24. package/src/cli/cmd/me.ts +0 -202
  25. package/src/cli/cmd/post.ts +0 -29
  26. package/src/cli/cmd/publish.ts +0 -70
  27. package/src/cli/cmd/scan.ts +0 -80
  28. package/src/cli/cmd/search.ts +0 -40
  29. package/src/cli/cmd/setup.ts +0 -201
  30. package/src/cli/cmd/tui.ts +0 -20
  31. package/src/cli/cmd/update.ts +0 -78
  32. package/src/cli/cmd/vote.ts +0 -50
  33. package/src/cli/cmd/whoami.ts +0 -21
  34. package/src/cli/ui.ts +0 -124
  35. package/src/config/index.ts +0 -54
  36. package/src/flag/index.ts +0 -23
  37. package/src/global/index.ts +0 -38
  38. package/src/id/index.ts +0 -20
  39. package/src/index.ts +0 -186
  40. package/src/mcp/__tests__/client.test.ts +0 -149
  41. package/src/mcp/__tests__/e2e.ts +0 -327
  42. package/src/mcp/__tests__/integration.ts +0 -148
  43. package/src/mcp/client.ts +0 -148
  44. package/src/server/index.ts +0 -48
  45. package/src/storage/chat.ts +0 -92
  46. package/src/storage/db.ts +0 -85
  47. package/src/storage/schema.sql.ts +0 -39
  48. package/src/storage/schema.ts +0 -1
  49. package/src/tui/app.tsx +0 -163
  50. package/src/tui/commands.ts +0 -177
  51. package/src/tui/context/exit.tsx +0 -15
  52. package/src/tui/context/helper.tsx +0 -25
  53. package/src/tui/context/route.tsx +0 -24
  54. package/src/tui/context/theme.tsx +0 -470
  55. package/src/tui/routes/home.tsx +0 -490
  56. package/src/tui/routes/model.tsx +0 -209
  57. package/src/tui/routes/notifications.tsx +0 -85
  58. package/src/tui/routes/post.tsx +0 -108
  59. package/src/tui/routes/search.tsx +0 -104
  60. package/src/tui/routes/setup.tsx +0 -255
  61. package/src/tui/routes/trending.tsx +0 -107
  62. package/src/util/__tests__/context.test.ts +0 -31
  63. package/src/util/__tests__/lazy.test.ts +0 -37
  64. package/src/util/context.ts +0 -23
  65. package/src/util/error.ts +0 -46
  66. package/src/util/lazy.ts +0 -18
  67. package/src/util/log.ts +0 -142
  68. package/tsconfig.json +0 -11
@@ -1,490 +0,0 @@
1
- import { createSignal, createMemo, createEffect, onCleanup, Show, For } from "solid-js"
2
- import { useKeyboard, usePaste } from "@opentui/solid"
3
- import { useRoute } from "../context/route"
4
- import { useExit } from "../context/exit"
5
- import { useTheme } from "../context/theme"
6
- import { createCommands, LOGO, TIPS } from "../commands"
7
- import { TOOL_LABELS } from "../../ai/tools"
8
- import { mask, saveProvider } from "../../ai/configure"
9
- import { ChatHistory } from "../../storage/chat"
10
-
11
- interface ChatMsg {
12
- role: "user" | "assistant" | "tool"
13
- content: string
14
- toolName?: string
15
- toolStatus?: "running" | "done" | "error"
16
- }
17
-
18
- export function Home(props: {
19
- loggedIn: boolean
20
- username: string
21
- hasAI: boolean
22
- aiProvider: string
23
- modelName: string
24
- onLogin: () => Promise<void>
25
- onLogout: () => void
26
- onAIConfigured: () => void
27
- }) {
28
- const route = useRoute()
29
- const exit = useExit()
30
- const theme = useTheme()
31
- const [input, setInput] = createSignal("")
32
- const [message, setMessage] = createSignal("")
33
- const [messageColor, setMessageColor] = createSignal("#6a737c")
34
- const [selectedIdx, setSelectedIdx] = createSignal(0)
35
-
36
- const [messages, setMessages] = createSignal<ChatMsg[]>([])
37
- const [streaming, setStreaming] = createSignal(false)
38
- const [streamText, setStreamText] = createSignal("")
39
- let abortCtrl: AbortController | undefined
40
- let escCooldown = 0
41
- let sessionId = ""
42
- const chatting = createMemo(() => messages().length > 0 || streaming())
43
-
44
- function ensureSession() {
45
- if (!sessionId) {
46
- sessionId = crypto.randomUUID()
47
- try { ChatHistory.create(sessionId) } catch {}
48
- }
49
- }
50
-
51
- function saveChat() {
52
- if (!sessionId) return
53
- try { ChatHistory.save(sessionId, messages()) } catch {}
54
- }
55
-
56
- function resumeSession(sid?: string) {
57
- try {
58
- if (!sid) {
59
- const sessions = ChatHistory.list(1)
60
- if (sessions.length === 0) { showMsg("No previous sessions", theme.colors.warning); return }
61
- sid = sessions[0].id
62
- }
63
- const msgs = ChatHistory.load(sid)
64
- if (msgs.length === 0) { showMsg("Session is empty", theme.colors.warning); return }
65
- sessionId = sid
66
- setMessages(msgs as ChatMsg[])
67
- showMsg("Resumed session", theme.colors.success)
68
- } catch { showMsg("Failed to resume", theme.colors.error) }
69
- }
70
-
71
- // Shimmer animation for thinking state (like Claude Code)
72
- const SHIMMER_WORDS = ["Thinking", "Reasoning", "Composing", "Reflecting", "Analyzing", "Processing"]
73
- const [shimmerIdx, setShimmerIdx] = createSignal(0)
74
- const [shimmerDots, setShimmerDots] = createSignal(0)
75
- createEffect(() => {
76
- if (!streaming()) return
77
- const id = setInterval(() => {
78
- setShimmerDots((d) => (d + 1) % 4)
79
- if (shimmerDots() === 0) setShimmerIdx((i) => (i + 1) % SHIMMER_WORDS.length)
80
- }, 500)
81
- onCleanup(() => clearInterval(id))
82
- })
83
- const shimmerText = () => SHIMMER_WORDS[shimmerIdx()] + ".".repeat(shimmerDots())
84
-
85
- const tipIdx = Math.floor(Math.random() * TIPS.length)
86
- const [aiMode, setAiMode] = createSignal<"" | "url" | "key" | "testing">("")
87
- const [aiUrl, setAiUrl] = createSignal("")
88
- const [aiKey, setAiKey] = createSignal("")
89
-
90
- function showMsg(text: string, color = "#6a737c") {
91
- setMessage(text)
92
- setMessageColor(color)
93
- }
94
-
95
- function clearChat() {
96
- setMessages([]); setStreamText(""); setStreaming(false); setMessage("")
97
- sessionId = ""
98
- }
99
-
100
- const commands = createCommands({
101
- showMsg,
102
- navigate: route.navigate,
103
- exit,
104
- onLogin: props.onLogin,
105
- onLogout: props.onLogout,
106
- clearChat,
107
- startAIConfig: () => {
108
- setAiUrl(""); setAiKey(""); setAiMode("url")
109
- showMsg("Paste your API URL (or press Enter to skip):", theme.colors.primary)
110
- },
111
- setMode: theme.setMode,
112
- send,
113
- resume: resumeSession,
114
- listSessions: () => { try { return ChatHistory.list(10) } catch { return [] } },
115
- colors: theme.colors,
116
- })
117
-
118
- const filtered = createMemo(() => {
119
- const v = input()
120
- if (!v.startsWith("/")) return []
121
- const query = v.slice(1).toLowerCase()
122
- if (!query) return commands
123
- return commands.filter((c) => c.name.slice(1).toLowerCase().includes(query))
124
- })
125
-
126
- const showAutocomplete = createMemo(() => {
127
- const v = input()
128
- if (aiMode()) return false
129
- if (!v.startsWith("/")) return false
130
- if (v.includes(" ")) return false
131
- return filtered().length > 0
132
- })
133
-
134
- usePaste((evt) => {
135
- const text = evt.text.replace(/[\n\r]/g, "").trim()
136
- if (!text) return
137
- evt.preventDefault()
138
- if (aiMode() === "url") { setAiUrl(text); return }
139
- if (aiMode() === "key") { setAiKey(text); return }
140
- setInput((s) => s + text)
141
- })
142
-
143
- async function send(text: string) {
144
- if (!text.trim() || streaming()) return
145
- ensureSession()
146
- const userMsg: ChatMsg = { role: "user", content: text.trim() }
147
- const prev = messages()
148
- setMessages([...prev, userMsg])
149
- setStreaming(true)
150
- setStreamText("")
151
- setMessage("")
152
-
153
- try {
154
- const { AIChat } = await import("../../ai/chat")
155
- const { Config } = await import("../../config")
156
- const { AIProvider } = await import("../../ai/provider")
157
- const cfg = await Config.load()
158
- const mid = cfg.model || AIProvider.DEFAULT_MODEL
159
- const allMsgs = [...prev, userMsg].map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
160
- let full = ""
161
- abortCtrl = new AbortController()
162
- await AIChat.stream(allMsgs, {
163
- onToken: (token) => { full += token; setStreamText(full) },
164
- onToolCall: (name) => {
165
- // Save any accumulated text as assistant message before tool
166
- if (full.trim()) {
167
- setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
168
- full = ""
169
- setStreamText("")
170
- }
171
- setMessages((p) => [...p, { role: "tool", content: TOOL_LABELS[name] || name, toolName: name, toolStatus: "running" }])
172
- },
173
- onToolResult: (name) => {
174
- setMessages((p) => p.map((m) =>
175
- m.role === "tool" && m.toolName === name && m.toolStatus === "running"
176
- ? { ...m, toolStatus: "done" as const }
177
- : m
178
- ))
179
- },
180
- onFinish: () => {
181
- if (full.trim()) {
182
- setMessages((p) => [...p, { role: "assistant", content: full.trim() }])
183
- }
184
- setStreamText(""); setStreaming(false)
185
- },
186
- onError: (err) => {
187
- setMessages((p) => {
188
- // Mark any running tools as error
189
- const updated = p.map((m) =>
190
- m.role === "tool" && m.toolStatus === "running"
191
- ? { ...m, toolStatus: "error" as const }
192
- : m
193
- )
194
- return [...updated, { role: "assistant" as const, content: `Error: ${err.message}` }]
195
- })
196
- setStreamText(""); setStreaming(false)
197
- saveChat()
198
- },
199
- }, mid, abortCtrl.signal)
200
- abortCtrl = undefined
201
- } catch (err) {
202
- const msg = err instanceof Error ? err.message : String(err)
203
- setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
204
- setStreamText("")
205
- setStreaming(false)
206
- saveChat()
207
- }
208
- }
209
-
210
- async function saveAI() {
211
- setAiMode("testing")
212
- showMsg("Detecting API format...", theme.colors.primary)
213
- try {
214
- const result = await saveProvider(aiUrl().trim(), aiKey().trim())
215
- if (result.error) {
216
- showMsg(result.error, theme.colors.error)
217
- setAiMode("key")
218
- return
219
- }
220
- showMsg(`✓ AI configured! (${result.provider})`, theme.colors.success)
221
- setAiMode("")
222
- props.onAIConfigured()
223
- } catch (err) {
224
- showMsg(`Save failed: ${err instanceof Error ? err.message : String(err)}`, theme.colors.error)
225
- setAiMode("key")
226
- }
227
- }
228
-
229
- async function handleSubmit() {
230
- if (aiMode() === "url") {
231
- const v = aiUrl().trim()
232
- if (v && !v.startsWith("http")) { showMsg("URL must start with http:// or https://", theme.colors.error); return }
233
- setAiMode("key")
234
- showMsg("Now paste your API key:", theme.colors.primary)
235
- return
236
- }
237
- if (aiMode() === "key") {
238
- if (aiKey().trim().length < 5) { showMsg("API key too short", theme.colors.error); return }
239
- saveAI()
240
- return
241
- }
242
-
243
- if (showAutocomplete()) {
244
- const items = filtered()
245
- const sel = items[selectedIdx()]
246
- if (sel) {
247
- setInput("")
248
- setSelectedIdx(0)
249
- sel.action(sel.name.split(/\s+/))
250
- return
251
- }
252
- }
253
-
254
- const text = input().trim()
255
- setInput("")
256
- setSelectedIdx(0)
257
- if (!text) return
258
-
259
- if (text.startsWith("/")) {
260
- const parts = text.split(/\s+/)
261
- const cmd = parts[0]
262
- const match = commands.find((c) => c.name === cmd)
263
- if (match) {
264
- match.action(parts)
265
- return
266
- }
267
- if (cmd === "/quit" || cmd === "/q") { exit(); return }
268
- showMsg(`Unknown command: ${cmd}. Type / to see commands`, theme.colors.error)
269
- return
270
- }
271
-
272
- if (!props.hasAI) {
273
- showMsg("No AI configured. Type /ai to set up", theme.colors.error)
274
- return
275
- }
276
-
277
- send(text)
278
- }
279
-
280
- useKeyboard((evt) => {
281
- if (aiMode() === "url") {
282
- if (evt.name === "return") { handleSubmit(); evt.preventDefault(); return }
283
- if (evt.name === "escape") { setAiMode(""); setMessage(""); evt.preventDefault(); return }
284
- if (evt.name === "backspace") { setAiUrl((s) => s.slice(0, -1)); evt.preventDefault(); return }
285
- if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
286
- const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
287
- if (clean) { setAiUrl((s) => s + clean); evt.preventDefault(); return }
288
- }
289
- if (evt.name === "space") { evt.preventDefault(); return }
290
- return
291
- }
292
- if (aiMode() === "key") {
293
- if (evt.name === "return") { handleSubmit(); evt.preventDefault(); return }
294
- if (evt.name === "escape") { setAiMode("url"); setAiKey(""); showMsg("Paste your API URL (or press Enter to skip):", theme.colors.primary); evt.preventDefault(); return }
295
- if (evt.name === "backspace") { setAiKey((s) => s.slice(0, -1)); evt.preventDefault(); return }
296
- if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
297
- const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
298
- if (clean) { setAiKey((s) => s + clean); evt.preventDefault(); return }
299
- }
300
- if (evt.name === "space") { setAiKey((s) => s + " "); evt.preventDefault(); return }
301
- return
302
- }
303
-
304
- if (showAutocomplete()) {
305
- if (evt.name === "up") { setSelectedIdx((i) => (i - 1 + filtered().length) % filtered().length); evt.preventDefault(); return }
306
- if (evt.name === "down") { setSelectedIdx((i) => (i + 1) % filtered().length); evt.preventDefault(); return }
307
- if (evt.name === "tab") {
308
- const sel = filtered()[selectedIdx()]
309
- if (sel) setInput(sel.name + " ")
310
- evt.preventDefault()
311
- return
312
- }
313
- if (evt.name === "escape") { setInput(""); setSelectedIdx(0); evt.preventDefault(); return }
314
- }
315
-
316
- // Escape while streaming → abort; while chatting → clear
317
- if (evt.name === "escape" && streaming()) {
318
- abortCtrl?.abort()
319
- const cur = streamText()
320
- if (cur.trim()) setMessages((p) => [...p, { role: "assistant", content: cur.trim() + "\n\n(interrupted)" }])
321
- setStreamText(""); setStreaming(false)
322
- evt.preventDefault(); return
323
- }
324
- if (evt.name === "escape" && chatting() && !streaming()) { clearChat(); evt.preventDefault(); return }
325
-
326
- if (evt.name === "return" && !evt.shift) { handleSubmit(); evt.preventDefault(); return }
327
- if (evt.name === "backspace") { setInput((s) => s.slice(0, -1)); setSelectedIdx(0); evt.preventDefault(); return }
328
- if (evt.sequence && evt.sequence.length >= 1 && !evt.ctrl && !evt.meta) {
329
- const clean = evt.sequence.replace(/[\x00-\x1f\x7f]/g, "")
330
- if (clean) { setInput((s) => s + clean); setSelectedIdx(0); evt.preventDefault(); return }
331
- }
332
- if (evt.name === "space") { setInput((s) => s + " "); evt.preventDefault(); return }
333
- })
334
-
335
- return (
336
- <box flexDirection="column" flexGrow={1} paddingLeft={2} paddingRight={2}>
337
- {/* When no chat: show logo centered */}
338
- <Show when={!chatting()}>
339
- <box flexGrow={1} minHeight={0} />
340
- <box flexShrink={0} flexDirection="column" alignItems="center">
341
- {LOGO.map((line, i) => (
342
- <text fg={i < 4 ? theme.colors.logo1 : theme.colors.logo2}>{line}</text>
343
- ))}
344
- <box height={1} />
345
- <text fg={theme.colors.textMuted}>The AI-powered coding forum</text>
346
- </box>
347
- <Show when={!props.loggedIn || !props.hasAI}>
348
- <box flexShrink={0} flexDirection="column" paddingTop={1} alignItems="center">
349
- <box flexDirection="row" gap={1}>
350
- <text fg={props.hasAI ? theme.colors.success : theme.colors.warning}>{props.hasAI ? "✓" : "●"}</text>
351
- <text fg={props.hasAI ? theme.colors.textMuted : theme.colors.text}>
352
- {props.hasAI ? `AI: ${props.modelName}` : "Type /ai to configure AI"}
353
- </text>
354
- </box>
355
- <box flexDirection="row" gap={1}>
356
- <text fg={props.loggedIn ? theme.colors.success : theme.colors.warning}>{props.loggedIn ? "✓" : "●"}</text>
357
- <text fg={props.loggedIn ? theme.colors.textMuted : theme.colors.text}>
358
- {props.loggedIn ? `Logged in as ${props.username}` : "Type /login to sign in"}
359
- </text>
360
- </box>
361
- </box>
362
- </Show>
363
- </Show>
364
-
365
- {/* When chatting: messages fill the space */}
366
- <Show when={chatting()}>
367
- <box flexDirection="column" flexGrow={1} paddingTop={1} overflow="scroll">
368
- <For each={messages()}>
369
- {(msg) => (
370
- <box flexShrink={0}>
371
- {/* User message — bold with ❯ prefix */}
372
- <Show when={msg.role === "user"}>
373
- <box flexDirection="row" paddingBottom={1}>
374
- <text fg={theme.colors.primary} flexShrink={0}>
375
- <span style={{ bold: true }}>{"❯ "}</span>
376
- </text>
377
- <text fg={theme.colors.text}>
378
- <span style={{ bold: true }}>{msg.content}</span>
379
- </text>
380
- </box>
381
- </Show>
382
- {/* Tool execution — ⚙/✓ icon + tool name + status */}
383
- <Show when={msg.role === "tool"}>
384
- <box flexDirection="row" paddingLeft={2}>
385
- <text fg={msg.toolStatus === "done" ? theme.colors.success : msg.toolStatus === "error" ? theme.colors.error : theme.colors.warning} flexShrink={0}>
386
- {msg.toolStatus === "done" ? " ✓ " : msg.toolStatus === "error" ? " ✗ " : " ⚙ "}
387
- </text>
388
- <text fg={theme.colors.textMuted}>
389
- {msg.content}
390
- </text>
391
- </box>
392
- </Show>
393
- {/* Assistant message — ◆ prefix */}
394
- <Show when={msg.role === "assistant"}>
395
- <box flexDirection="row" paddingBottom={1}>
396
- <text fg={theme.colors.success} flexShrink={0}>
397
- <span style={{ bold: true }}>{"◆ "}</span>
398
- </text>
399
- <text fg={theme.colors.text}>{msg.content}</text>
400
- </box>
401
- </Show>
402
- </box>
403
- )}
404
- </For>
405
- <Show when={streaming()}>
406
- <box flexDirection="row" paddingBottom={1} flexShrink={0}>
407
- <text fg={theme.colors.success} flexShrink={0}>
408
- <span style={{ bold: true }}>{"◆ "}</span>
409
- </text>
410
- <text fg={streamText() ? theme.colors.text : theme.colors.textMuted}>
411
- {streamText() || shimmerText()}
412
- </text>
413
- </box>
414
- </Show>
415
- </box>
416
- </Show>
417
-
418
- {/* Spacer when no chat and no autocomplete */}
419
- <Show when={!chatting()}>
420
- <box flexGrow={1} minHeight={0} />
421
- </Show>
422
-
423
- {/* Prompt — always at bottom */}
424
- <box flexShrink={0} paddingTop={1} paddingBottom={1}>
425
- <Show when={aiMode() === "url"}>
426
- <box flexDirection="column">
427
- <text fg={theme.colors.text}><span style={{ bold: true }}>API URL:</span></text>
428
- <box flexDirection="row">
429
- <text fg={theme.colors.primary}><span style={{ bold: true }}>{"❯ "}</span></text>
430
- <text fg={theme.colors.input}>{aiUrl()}</text>
431
- <text fg={theme.colors.cursor}>{"█"}</text>
432
- </box>
433
- </box>
434
- </Show>
435
- <Show when={aiMode() === "key"}>
436
- <box flexDirection="column">
437
- {aiUrl().trim() ? <text fg={theme.colors.textMuted}>{"URL: " + aiUrl().trim()}</text> : null}
438
- <text fg={theme.colors.text}><span style={{ bold: true }}>API Key:</span></text>
439
- <box flexDirection="row">
440
- <text fg={theme.colors.primary}><span style={{ bold: true }}>{"❯ "}</span></text>
441
- <text fg={theme.colors.input}>{mask(aiKey())}</text>
442
- <text fg={theme.colors.cursor}>{"█"}</text>
443
- </box>
444
- </box>
445
- </Show>
446
- <Show when={aiMode() === "testing"}>
447
- <text fg={theme.colors.primary}>Detecting API format...</text>
448
- </Show>
449
- <Show when={!aiMode()}>
450
- <box flexDirection="column">
451
- {/* Command autocomplete — above prompt */}
452
- <Show when={showAutocomplete()}>
453
- <box flexDirection="column" paddingBottom={1}>
454
- <For each={filtered()}>
455
- {(cmd, i) => (
456
- <box flexDirection="row" backgroundColor={i() === selectedIdx() ? theme.colors.primary : undefined}>
457
- <text fg={i() === selectedIdx() ? "#ffffff" : theme.colors.primary}>
458
- {" " + cmd.name.padEnd(18)}
459
- </text>
460
- <text fg={i() === selectedIdx() ? "#ffffff" : theme.colors.textMuted}>
461
- {cmd.description}
462
- </text>
463
- </box>
464
- )}
465
- </For>
466
- </box>
467
- </Show>
468
- {/* Message feedback */}
469
- <Show when={message() && !showAutocomplete()}>
470
- <text fg={messageColor()} flexShrink={0}>{message()}</text>
471
- </Show>
472
- {/* Tip */}
473
- <Show when={!showAutocomplete() && !message() && !chatting() && props.loggedIn && props.hasAI}>
474
- <box flexDirection="row" paddingBottom={1}>
475
- <text fg={theme.colors.warning} flexShrink={0}>● Tip </text>
476
- <text fg={theme.colors.textMuted}>{TIPS[tipIdx]}</text>
477
- </box>
478
- </Show>
479
- {/* Input line */}
480
- <box flexDirection="row">
481
- <text fg={theme.colors.primary}><span style={{ bold: true }}>{"❯ "}</span></text>
482
- <text fg={theme.colors.input}>{input()}</text>
483
- <text fg={theme.colors.cursor}>{"█"}</text>
484
- </box>
485
- </box>
486
- </Show>
487
- </box>
488
- </box>
489
- )
490
- }