codeblog-app 2.5.0 → 2.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "codeblog-app",
4
- "version": "2.5.0",
4
+ "version": "2.5.1",
5
5
  "description": "CLI client for CodeBlog — the forum where AI writes the posts",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -58,11 +58,11 @@
58
58
  "typescript": "5.8.2"
59
59
  },
60
60
  "optionalDependencies": {
61
- "codeblog-app-darwin-arm64": "2.5.0",
62
- "codeblog-app-darwin-x64": "2.5.0",
63
- "codeblog-app-linux-arm64": "2.5.0",
64
- "codeblog-app-linux-x64": "2.5.0",
65
- "codeblog-app-windows-x64": "2.5.0"
61
+ "codeblog-app-darwin-arm64": "2.5.1",
62
+ "codeblog-app-darwin-x64": "2.5.1",
63
+ "codeblog-app-linux-arm64": "2.5.1",
64
+ "codeblog-app-linux-x64": "2.5.1",
65
+ "codeblog-app-windows-x64": "2.5.1"
66
66
  },
67
67
  "dependencies": {
68
68
  "@ai-sdk/anthropic": "^3.0.44",
@@ -73,7 +73,7 @@
73
73
  "@opentui/core": "^0.1.79",
74
74
  "@opentui/solid": "^0.1.79",
75
75
  "ai": "^6.0.86",
76
- "codeblog-mcp": "2.5.0",
76
+ "codeblog-mcp": "2.5.1",
77
77
  "drizzle-orm": "1.0.0-beta.12-a5629fb",
78
78
  "fuzzysort": "^3.1.0",
79
79
  "hono": "4.10.7",
@@ -831,10 +831,10 @@ export const SetupCommand: CommandModule = {
831
831
  describe: "First-time setup wizard: authenticate, scan, publish, configure AI",
832
832
  handler: async () => {
833
833
  // Phase 1: Welcome
834
- console.log(UI.logo())
834
+ Bun.stderr.write(UI.logo() + "\n")
835
835
  await UI.typeText("Welcome to CodeBlog!", { charDelay: 20 })
836
836
  await UI.typeText("The AI-powered coding forum in your terminal.", { charDelay: 15 })
837
- console.log("")
837
+ Bun.stderr.write("\n")
838
838
 
839
839
  // Phase 2: Authentication
840
840
  const alreadyAuthed = await Auth.authenticated()
package/src/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import yargs from "yargs"
2
+ import path from "path"
2
3
  import { hideBin } from "yargs/helpers"
3
4
  import { Log } from "./util/log"
4
5
  import { UI } from "./cli/ui"
5
6
  import { EOL } from "os"
6
7
  import { McpBridge } from "./mcp/client"
7
8
  import { Auth } from "./auth"
9
+ import { Global } from "./global"
8
10
  import { checkAndAutoUpdate } from "./cli/auto-update"
9
11
 
10
12
  // Commands
@@ -171,10 +173,21 @@ if (!hasSubcommand && !isHelp && !isVersion) {
171
173
  await Log.init({ print: false })
172
174
  Log.Default.info("codeblog", { version: VERSION, args: [] })
173
175
 
176
+ // Theme setup — must happen before anything else so all UI is readable
177
+ const themePath = path.join(Global.Path.config, "theme.json")
178
+ let hasTheme = false
179
+ try { await Bun.file(themePath).text(); hasTheme = true } catch {}
180
+ if (!hasTheme) {
181
+ const { themeSetupTui } = await import("./tui/app")
182
+ await themeSetupTui()
183
+ // Clear screen on both stdout and stderr to remove renderer cleanup artifacts
184
+ process.stdout.write("\x1b[2J\x1b[H")
185
+ process.stderr.write("\x1b[2J\x1b[H")
186
+ }
187
+
174
188
  const authed = await Auth.authenticated()
175
189
  if (!authed) {
176
190
  console.log("")
177
- // Use the statically imported SetupCommand
178
191
  await (SetupCommand.handler as Function)({})
179
192
 
180
193
  // Check if setup completed successfully
package/src/tui/app.tsx CHANGED
@@ -4,7 +4,7 @@ import { RouteProvider, useRoute } from "./context/route"
4
4
  import { ExitProvider, useExit } from "./context/exit"
5
5
  import { ThemeProvider, useTheme } from "./context/theme"
6
6
  import { Home } from "./routes/home"
7
- import { ThemePicker } from "./routes/setup"
7
+ import { ThemeSetup, ThemePicker } from "./routes/setup"
8
8
  import { ModelPicker } from "./routes/model"
9
9
  import { Post } from "./routes/post"
10
10
  import { Search } from "./routes/search"
@@ -15,16 +15,79 @@ import { emitInputIntent, isShiftEnterSequence } from "./input-intent"
15
15
  import pkg from "../../package.json"
16
16
  const VERSION = pkg.version
17
17
 
18
+ const RENDER_OPTS = {
19
+ targetFps: 30,
20
+ exitOnCtrlC: false,
21
+ autoFocus: false,
22
+ openConsoleOnError: false,
23
+ useKittyKeyboard: {
24
+ disambiguate: true,
25
+ alternateKeys: true,
26
+ events: true,
27
+ allKeysAsEscapes: true,
28
+ reportText: true,
29
+ },
30
+ prependInputHandlers: [
31
+ (sequence: string) => {
32
+ if (!isShiftEnterSequence(sequence)) return false
33
+ emitInputIntent("newline")
34
+ return true
35
+ },
36
+ ],
37
+ }
38
+
18
39
  function enableModifyOtherKeys() {
19
40
  if (!process.stdout.isTTY) return () => {}
20
- // Ask xterm-compatible terminals to include modifier info for keys like Enter.
21
41
  process.stdout.write("\x1b[>4;2m")
22
42
  return () => {
23
- // Disable modifyOtherKeys on exit.
24
43
  process.stdout.write("\x1b[>4m")
25
44
  }
26
45
  }
27
46
 
47
+ /**
48
+ * Standalone theme setup TUI — runs before the main app for first-time users.
49
+ * Renders ThemeSetup full-screen, then destroys itself when done.
50
+ */
51
+ export function themeSetupTui() {
52
+ return new Promise<void>((resolve) => {
53
+ const restoreModifiers = enableModifyOtherKeys()
54
+
55
+ function ThemeSetupApp() {
56
+ const renderer = useRenderer()
57
+ const dimensions = useTerminalDimensions()
58
+
59
+ useKeyboard((evt) => {
60
+ if (evt.ctrl && evt.name === "c") {
61
+ renderer.setTerminalTitle("")
62
+ renderer.destroy()
63
+ restoreModifiers()
64
+ process.exit(0)
65
+ }
66
+ })
67
+
68
+ return (
69
+ <box flexDirection="column" width={dimensions().width} height={dimensions().height}>
70
+ <ThemeSetup onDone={() => {
71
+ renderer.setTerminalTitle("")
72
+ renderer.destroy()
73
+ restoreModifiers()
74
+ resolve()
75
+ }} />
76
+ </box>
77
+ )
78
+ }
79
+
80
+ render(
81
+ () => (
82
+ <ThemeProvider>
83
+ <ThemeSetupApp />
84
+ </ThemeProvider>
85
+ ),
86
+ RENDER_OPTS,
87
+ )
88
+ })
89
+ }
90
+
28
91
  export function tui(input: { onExit?: () => Promise<void> }) {
29
92
  return new Promise<void>(async (resolve) => {
30
93
  const restoreModifiers = enableModifyOtherKeys()
@@ -38,26 +101,7 @@ export function tui(input: { onExit?: () => Promise<void> }) {
38
101
  </ThemeProvider>
39
102
  </ExitProvider>
40
103
  ),
41
- {
42
- targetFps: 30,
43
- exitOnCtrlC: false,
44
- autoFocus: false,
45
- openConsoleOnError: false,
46
- useKittyKeyboard: {
47
- disambiguate: true,
48
- alternateKeys: true,
49
- events: true,
50
- allKeysAsEscapes: true,
51
- reportText: true,
52
- },
53
- prependInputHandlers: [
54
- (sequence) => {
55
- if (!isShiftEnterSequence(sequence)) return false
56
- emitInputIntent("newline")
57
- return true
58
- },
59
- ],
60
- },
104
+ RENDER_OPTS,
61
105
  )
62
106
  })
63
107
  }
@@ -181,61 +225,63 @@ function App() {
181
225
 
182
226
  return (
183
227
  <box flexDirection="column" width={dimensions().width} height={dimensions().height}>
184
- <Switch>
185
- <Match when={route.data.type === "home"}>
186
- <Home
187
- loggedIn={loggedIn()}
188
- username={username()}
189
- activeAgent={activeAgent()}
190
- agentCount={agentCount()}
191
- hasAI={hasAI()}
192
- aiProvider={aiProvider()}
193
- modelName={modelName()}
194
- onLogin={async () => {
195
- try {
196
- const { OAuth } = await import("../auth/oauth")
197
- await OAuth.login()
198
- await refreshAuth()
199
- return { ok: true }
200
- } catch (err) {
201
- const msg = err instanceof Error ? err.message : String(err)
202
- await refreshAuth()
203
- return { ok: false, error: `Login failed: ${msg}` }
204
- }
205
- }}
206
- onLogout={() => { setLoggedIn(false); setUsername(""); setActiveAgent("") }}
207
- onAIConfigured={refreshAI}
208
- />
209
- </Match>
210
- <Match when={route.data.type === "theme"}>
211
- <ThemePicker onDone={() => route.navigate({ type: "home" })} />
212
- </Match>
213
- <Match when={route.data.type === "model"}>
214
- <ModelPicker onDone={async (model) => {
215
- if (model) setModelName(model)
216
- await refreshAI()
217
- route.navigate({ type: "home" })
218
- }} />
219
- </Match>
220
- <Match when={route.data.type === "post"}>
221
- <Post />
222
- </Match>
223
- <Match when={route.data.type === "search"}>
224
- <Search />
225
- </Match>
226
- <Match when={route.data.type === "trending"}>
227
- <Trending />
228
- </Match>
229
- <Match when={route.data.type === "notifications"}>
230
- <Notifications />
231
- </Match>
232
- </Switch>
233
-
234
- {/* Status bar — only version */}
235
- <box paddingLeft={2} paddingRight={2} flexShrink={0} flexDirection="row" gap={2}>
236
- <box flexGrow={1} />
237
- <text fg={theme.colors.textMuted}>v{VERSION}</text>
238
- </box>
228
+ <Show when={!theme.needsSetup} fallback={<ThemeSetup />}>
229
+ <Switch>
230
+ <Match when={route.data.type === "home"}>
231
+ <Home
232
+ loggedIn={loggedIn()}
233
+ username={username()}
234
+ activeAgent={activeAgent()}
235
+ agentCount={agentCount()}
236
+ hasAI={hasAI()}
237
+ aiProvider={aiProvider()}
238
+ modelName={modelName()}
239
+ onLogin={async () => {
240
+ try {
241
+ const { OAuth } = await import("../auth/oauth")
242
+ await OAuth.login()
243
+ await refreshAuth()
244
+ return { ok: true }
245
+ } catch (err) {
246
+ const msg = err instanceof Error ? err.message : String(err)
247
+ await refreshAuth()
248
+ return { ok: false, error: `Login failed: ${msg}` }
249
+ }
250
+ }}
251
+ onLogout={() => { setLoggedIn(false); setUsername(""); setActiveAgent("") }}
252
+ onAIConfigured={refreshAI}
253
+ />
254
+ </Match>
255
+ <Match when={route.data.type === "theme"}>
256
+ <ThemePicker onDone={() => route.navigate({ type: "home" })} />
257
+ </Match>
258
+ <Match when={route.data.type === "model"}>
259
+ <ModelPicker onDone={async (model) => {
260
+ if (model) setModelName(model)
261
+ await refreshAI()
262
+ route.navigate({ type: "home" })
263
+ }} />
264
+ </Match>
265
+ <Match when={route.data.type === "post"}>
266
+ <Post />
267
+ </Match>
268
+ <Match when={route.data.type === "search"}>
269
+ <Search />
270
+ </Match>
271
+ <Match when={route.data.type === "trending"}>
272
+ <Trending />
273
+ </Match>
274
+ <Match when={route.data.type === "notifications"}>
275
+ <Notifications />
276
+ </Match>
277
+ </Switch>
278
+
279
+ {/* Status bar only version */}
280
+ <box paddingLeft={2} paddingRight={2} flexShrink={0} flexDirection="row" gap={2}>
281
+ <box flexGrow={1} />
282
+ <text fg={theme.colors.textMuted}>v{VERSION}</text>
283
+ </box>
284
+ </Show>
239
285
  </box>
240
286
  )
241
287
  }
@@ -157,7 +157,7 @@ export function Home(props: {
157
157
  return "info"
158
158
  }
159
159
 
160
- function showMsg(text: string, color = "#6a737c") {
160
+ function showMsg(text: string, color = theme.colors.textMuted) {
161
161
  ensureSession()
162
162
  setMessages((p) => [...p, { role: "system", content: text, tone: tone(color) }])
163
163
  }
@@ -1,200 +1,223 @@
1
- import { createSignal } from "solid-js"
2
- import { useKeyboard } from "@opentui/solid"
3
- import { useTheme, THEME_NAMES, THEMES } from "../context/theme"
1
+ import { createSignal, createMemo } from "solid-js"
2
+ import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
3
+ import { useTheme, THEME_NAMES, THEMES, type ThemeColors } from "../context/theme"
4
4
 
5
- // High-contrast colors that are visible on ANY terminal background
6
5
  const HC = {
7
6
  title: "#ff6600",
8
- text: "#888888",
9
- selected: "#00cc00",
10
- dim: "#999999",
7
+ text: "#aaaaaa",
8
+ dim: "#aaaaaa",
11
9
  }
12
10
 
11
+ const LOGO_ORANGE = "#f48225"
12
+ const LOGO_CYAN = "#00c8ff"
13
+
14
+ const LOGO = [
15
+ " ██████╗ ██████╗ ██████╗ ███████╗██████╗ ██╗ ██████╗ ██████╗ ",
16
+ "██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗██║ ██╔═══██╗██╔════╝ ",
17
+ "██║ ██║ ██║██║ ██║█████╗ ██████╔╝██║ ██║ ██║██║ ███╗",
18
+ "██║ ██║ ██║██║ ██║██╔══╝ ██╔══██╗██║ ██║ ██║██║ ██║",
19
+ "╚██████╗╚██████╔╝██████╔╝███████╗██████╔╝███████╗╚██████╔╝╚██████╔╝",
20
+ " ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ",
21
+ ]
22
+
13
23
  function resolveThemeDef(name: string) {
14
24
  const fallback = THEMES.codeblog ?? Object.values(THEMES).find(Boolean)
15
- if (!fallback) {
16
- throw new Error("No themes available")
17
- }
25
+ if (!fallback) throw new Error("No themes available")
18
26
  return THEMES[name] ?? fallback
19
27
  }
20
28
 
21
- export function ThemeSetup() {
29
+ type ThemeOption = { name: string; mode: "dark" | "light"; label: string; colors: ThemeColors }
30
+
31
+ const SETUP_OPTIONS: ThemeOption[] = [
32
+ { name: "codeblog", mode: "dark", label: "Dark mode", colors: resolveThemeDef("codeblog").dark },
33
+ { name: "codeblog", mode: "light", label: "Light mode", colors: resolveThemeDef("codeblog").light },
34
+ { name: "dracula", mode: "dark", label: "Dark — Dracula", colors: resolveThemeDef("dracula").dark },
35
+ { name: "tokyonight", mode: "dark", label: "Dark — Tokyo Night", colors: resolveThemeDef("tokyonight").dark },
36
+ { name: "catppuccin", mode: "dark", label: "Dark — Catppuccin", colors: resolveThemeDef("catppuccin").dark },
37
+ { name: "github", mode: "dark", label: "Dark — GitHub", colors: resolveThemeDef("github").dark },
38
+ { name: "gruvbox", mode: "dark", label: "Dark — Gruvbox", colors: resolveThemeDef("gruvbox").dark },
39
+ { name: "github", mode: "light", label: "Light — GitHub", colors: resolveThemeDef("github").light },
40
+ { name: "catppuccin", mode: "light", label: "Light — Catppuccin", colors: resolveThemeDef("catppuccin").light },
41
+ { name: "solarized", mode: "light", label: "Light — Solarized", colors: resolveThemeDef("solarized").light },
42
+ ]
43
+
44
+ export function ThemeSetup(props: { onDone?: () => void }) {
22
45
  const theme = useTheme()
23
- const modes = ["dark", "light"] as const
24
- const [step, setStep] = createSignal<"mode" | "theme">("mode")
25
- const [modeIdx, setModeIdx] = createSignal(0)
26
- const [themeIdx, setThemeIdx] = createSignal(0)
27
- const getThemeName = (index: number) => THEME_NAMES[index] ?? "codeblog"
28
- const getThemeColors = (name: string) => resolveThemeDef(name)[theme.mode]
46
+ const dimensions = useTerminalDimensions()
47
+ const [idx, setIdx] = createSignal(0)
48
+
49
+ const current = createMemo(() => SETUP_OPTIONS[idx()] ?? SETUP_OPTIONS[0]!)
50
+
51
+ function apply(i: number) {
52
+ const opt = SETUP_OPTIONS[i]
53
+ if (!opt) return
54
+ theme.set(opt.name)
55
+ theme.setMode(opt.mode)
56
+ }
57
+
58
+ apply(0)
29
59
 
30
60
  useKeyboard((evt) => {
31
- if (step() === "mode") {
32
- if (evt.name === "up" || evt.name === "k") {
33
- setModeIdx((i) => (i - 1 + modes.length) % modes.length)
34
- evt.preventDefault()
35
- return
36
- }
37
- if (evt.name === "down" || evt.name === "j") {
38
- setModeIdx((i) => (i + 1) % modes.length)
39
- evt.preventDefault()
40
- return
41
- }
42
- if (evt.name === "return") {
43
- theme.setMode(modes[modeIdx()] ?? "dark")
44
- setStep("theme")
45
- evt.preventDefault()
46
- return
47
- }
61
+ if (evt.name === "up" || evt.name === "k") {
62
+ const next = (idx() - 1 + SETUP_OPTIONS.length) % SETUP_OPTIONS.length
63
+ setIdx(next)
64
+ apply(next)
65
+ evt.preventDefault()
66
+ return
48
67
  }
49
-
50
- if (step() === "theme") {
51
- if (evt.name === "up" || evt.name === "k") {
52
- const next = (themeIdx() - 1 + THEME_NAMES.length) % THEME_NAMES.length
53
- setThemeIdx(next)
54
- theme.set(getThemeName(next))
55
- evt.preventDefault()
56
- return
57
- }
58
- if (evt.name === "down" || evt.name === "j") {
59
- const next = (themeIdx() + 1) % THEME_NAMES.length
60
- setThemeIdx(next)
61
- theme.set(getThemeName(next))
62
- evt.preventDefault()
63
- return
64
- }
65
- if (evt.name === "return") {
66
- theme.finishSetup()
67
- evt.preventDefault()
68
- return
69
- }
70
- if (evt.name === "escape") {
71
- setStep("mode")
72
- evt.preventDefault()
73
- return
74
- }
68
+ if (evt.name === "down" || evt.name === "j") {
69
+ const next = (idx() + 1) % SETUP_OPTIONS.length
70
+ setIdx(next)
71
+ apply(next)
72
+ evt.preventDefault()
73
+ return
74
+ }
75
+ if (evt.name === "return") {
76
+ theme.finishSetup()
77
+ props.onDone?.()
78
+ evt.preventDefault()
79
+ return
75
80
  }
76
81
  })
77
82
 
83
+ const wide = createMemo(() => (dimensions().width ?? 80) >= 90)
84
+ const c = createMemo(() => current().colors)
85
+
78
86
  return (
79
- <box flexDirection="column" flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
87
+ <box flexDirection="column" flexGrow={1} paddingLeft={2} paddingRight={2}>
80
88
  <box flexGrow={1} minHeight={0} />
81
89
 
90
+ {/* Logo */}
91
+ <box flexShrink={0} flexDirection="column" alignItems="center">
92
+ {LOGO.map((line, i) => (
93
+ <text fg={i < 3 ? LOGO_ORANGE : LOGO_CYAN}>{line}</text>
94
+ ))}
95
+ <box height={1} />
96
+ <text fg={HC.text}>{"The AI-powered coding forum in your terminal"}</text>
97
+ <box height={1} />
98
+ </box>
99
+
100
+ {/* Main content */}
82
101
  <box flexShrink={0} flexDirection="column" alignItems="center">
83
102
  <text fg={HC.title}>
84
- <span style={{ bold: true }}>{" Welcome to CodeBlog "}</span>
103
+ <span style={{ bold: true }}>{"Choose the text style that looks best with your terminal:"}</span>
85
104
  </text>
105
+ <text fg={HC.dim}>{"To change this later, run /theme"}</text>
86
106
  <box height={1} />
87
- </box>
88
107
 
89
- {step() === "mode" ? (
90
- <box flexShrink={0} flexDirection="column" width="100%" maxWidth={50}>
91
- <text fg={HC.title}>
92
- <span style={{ bold: true }}>{"What is your terminal background color?"}</span>
93
- </text>
94
- <box height={1} />
95
- {modes.map((m, i) => (
96
- <box flexDirection="row" paddingLeft={2}>
97
- <text fg={modeIdx() === i ? HC.selected : HC.dim}>
98
- {modeIdx() === i ? "❯ " : " "}
99
- </text>
100
- <text fg={modeIdx() === i ? HC.selected : HC.dim}>
101
- <span style={{ bold: modeIdx() === i }}>
102
- {m === "dark" ? "Dark background (black/dark terminal)" : "Light background (white/light terminal)"}
103
- </span>
104
- </text>
105
- </box>
106
- ))}
107
- <box height={1} />
108
- <text fg={HC.text}>{"↑↓ select · Enter confirm"}</text>
109
- </box>
110
- ) : (
111
- <box flexShrink={0} flexDirection="column" width="100%" maxWidth={60}>
112
- <text fg={theme.colors.text}>
113
- <span style={{ bold: true }}>{"Choose a color theme:"}</span>
114
- </text>
115
- <box height={1} />
116
- {THEME_NAMES.map((name, i) => {
117
- const c = getThemeColors(name)
118
- return (
119
- <box flexDirection="row" paddingLeft={2}>
120
- <text fg={themeIdx() === i ? c.primary : theme.colors.textMuted}>
121
- {themeIdx() === i ? "❯ " : " "}
108
+ <box flexDirection="row" justifyContent="center" gap={wide() ? 6 : 3}>
109
+ {/* Options list */}
110
+ <box flexDirection="column" width={wide() ? 28 : 26}>
111
+ {SETUP_OPTIONS.map((opt, i) => (
112
+ <box flexDirection="row">
113
+ <text fg={idx() === i ? opt.colors.primary : HC.dim}>
114
+ {idx() === i ? "❯ " : " "}
122
115
  </text>
123
- <text fg={themeIdx() === i ? c.text : theme.colors.textMuted}>
124
- <span style={{ bold: themeIdx() === i }}>
125
- {name.padEnd(14)}
116
+ <text fg={idx() === i ? opt.colors.text : HC.dim}>
117
+ <span style={{ bold: idx() === i }}>
118
+ {`${(i + 1).toString().padStart(2)}. ${opt.label}`}
126
119
  </span>
127
120
  </text>
128
- <text fg={c.logo1}>{""}</text>
129
- <text fg={c.logo2}>{"●"}</text>
130
- <text fg={c.primary}>{"●"}</text>
131
- <text fg={c.accent}>{"●"}</text>
132
- <text fg={c.success}>{"●"}</text>
133
- <text fg={c.error}>{"●"}</text>
121
+ {idx() === i && <text fg={opt.colors.success}>{""}</text>}
134
122
  </box>
135
- )
136
- })}
137
- <box height={1} />
138
- <text fg={theme.colors.textMuted}>{"↑↓ select · Enter confirm · Esc back"}</text>
123
+ ))}
124
+ </box>
125
+
126
+ {/* Live preview */}
127
+ <box flexDirection="column" width={wide() ? 44 : 38}>
128
+ <text fg={c().text}><span style={{ bold: true }}>{"Preview"}</span></text>
129
+ <box height={1} />
130
+ <box flexDirection="column" paddingLeft={2}>
131
+ <text fg={c().textMuted}>{"// A coding conversation"}</text>
132
+ <box height={1} />
133
+ <box flexDirection="row">
134
+ <text fg={c().primary}><span style={{ bold: true }}>{"You: "}</span></text>
135
+ <text fg={c().text}>{"Refactor the auth module"}</text>
136
+ </box>
137
+ <box flexDirection="row">
138
+ <text fg={c().accent}><span style={{ bold: true }}>{"AI: "}</span></text>
139
+ <text fg={c().text}>{"I'll update 3 files..."}</text>
140
+ </box>
141
+ <box height={1} />
142
+ <text fg={c().textMuted}>{" src/auth.ts"}</text>
143
+ <box flexDirection="row">
144
+ <text fg={c().error}>{" - "}</text>
145
+ <text fg={c().error}>{"const token = getOld()"}</text>
146
+ </box>
147
+ <box flexDirection="row">
148
+ <text fg={c().success}>{" + "}</text>
149
+ <text fg={c().success}>{"const token = getNew()"}</text>
150
+ </box>
151
+ <box height={1} />
152
+ <box flexDirection="row">
153
+ <text fg={c().success}>{"✓ "}</text>
154
+ <text fg={c().text}>{"Changes applied"}</text>
155
+ </box>
156
+ <box flexDirection="row">
157
+ <text fg={c().warning}>{"⚠ "}</text>
158
+ <text fg={c().textMuted}>{"3 tests need updating"}</text>
159
+ </box>
160
+ </box>
161
+ </box>
139
162
  </box>
140
- )}
163
+
164
+ <box height={1} />
165
+ <text fg={HC.text}>{"↑↓ select · Enter confirm"}</text>
166
+ </box>
141
167
 
142
168
  <box flexGrow={1} minHeight={0} />
143
169
  </box>
144
170
  )
145
171
  }
146
172
 
173
+ // Full theme picker (all themes × dark/light) for /theme command in main TUI
174
+ function buildAllOptions(): ThemeOption[] {
175
+ const out: ThemeOption[] = []
176
+ for (const name of THEME_NAMES) {
177
+ const def = resolveThemeDef(name)
178
+ out.push({ name, mode: "dark", label: `${name} — dark`, colors: def.dark })
179
+ out.push({ name, mode: "light", label: `${name} — light`, colors: def.light })
180
+ }
181
+ return out
182
+ }
183
+
184
+ const ALL_OPTIONS = buildAllOptions()
185
+
147
186
  export function ThemePicker(props: { onDone: () => void }) {
148
187
  const theme = useTheme()
149
- const [idx, setIdx] = createSignal(Math.max(0, THEME_NAMES.indexOf(theme.name)))
150
- const [tab, setTab] = createSignal<"theme" | "mode">("theme")
151
- const getThemeName = (index: number) => THEME_NAMES[index] ?? "codeblog"
152
- const getThemeColors = (name: string) => resolveThemeDef(name)[theme.mode]
188
+ const [idx, setIdx] = createSignal(
189
+ Math.max(0, ALL_OPTIONS.findIndex((o) => o.name === theme.name && o.mode === theme.mode))
190
+ )
191
+ const current = createMemo(() => ALL_OPTIONS[idx()] ?? ALL_OPTIONS[0]!)
192
+
193
+ function apply(i: number) {
194
+ const opt = ALL_OPTIONS[i]
195
+ if (!opt) return
196
+ theme.set(opt.name)
197
+ theme.setMode(opt.mode)
198
+ }
199
+
200
+ const c = createMemo(() => current().colors)
153
201
 
154
202
  useKeyboard((evt) => {
155
- if (tab() === "theme") {
156
- if (evt.name === "up" || evt.name === "k") {
157
- const next = (idx() - 1 + THEME_NAMES.length) % THEME_NAMES.length
158
- setIdx(next)
159
- theme.set(getThemeName(next))
160
- evt.preventDefault()
161
- return
162
- }
163
- if (evt.name === "down" || evt.name === "j") {
164
- const next = (idx() + 1) % THEME_NAMES.length
165
- setIdx(next)
166
- theme.set(getThemeName(next))
167
- evt.preventDefault()
168
- return
169
- }
170
- if (evt.name === "tab") {
171
- setTab("mode")
172
- evt.preventDefault()
173
- return
174
- }
175
- if (evt.name === "return" || evt.name === "escape") {
176
- props.onDone()
177
- evt.preventDefault()
178
- return
179
- }
203
+ if (evt.name === "up" || evt.name === "k") {
204
+ const next = (idx() - 1 + ALL_OPTIONS.length) % ALL_OPTIONS.length
205
+ setIdx(next)
206
+ apply(next)
207
+ evt.preventDefault()
208
+ return
180
209
  }
181
-
182
- if (tab() === "mode") {
183
- if (evt.name === "up" || evt.name === "down" || evt.name === "k" || evt.name === "j") {
184
- theme.setMode(theme.mode === "dark" ? "light" : "dark")
185
- evt.preventDefault()
186
- return
187
- }
188
- if (evt.name === "tab") {
189
- setTab("theme")
190
- evt.preventDefault()
191
- return
192
- }
193
- if (evt.name === "return" || evt.name === "escape") {
194
- props.onDone()
195
- evt.preventDefault()
196
- return
197
- }
210
+ if (evt.name === "down" || evt.name === "j") {
211
+ const next = (idx() + 1) % ALL_OPTIONS.length
212
+ setIdx(next)
213
+ apply(next)
214
+ evt.preventDefault()
215
+ return
216
+ }
217
+ if (evt.name === "return" || evt.name === "escape") {
218
+ props.onDone()
219
+ evt.preventDefault()
220
+ return
198
221
  }
199
222
  })
200
223
 
@@ -205,60 +228,52 @@ export function ThemePicker(props: { onDone: () => void }) {
205
228
  <span style={{ bold: true }}>Theme Settings</span>
206
229
  </text>
207
230
  <box flexGrow={1} />
208
- <text fg={theme.colors.textMuted}>{"Tab: switch section · Enter/Esc: done"}</text>
231
+ <text fg={theme.colors.textMuted}>{"Enter/Esc: done"}</text>
209
232
  </box>
210
233
 
211
234
  <box flexDirection="row" flexGrow={1} paddingTop={1} gap={4}>
212
- {/* Theme list */}
213
- <box flexDirection="column" width={40}>
214
- <text fg={tab() === "theme" ? theme.colors.text : theme.colors.textMuted}>
215
- <span style={{ bold: true }}>{"Color Theme"}</span>
216
- </text>
217
- <box height={1} />
218
- {THEME_NAMES.map((name, i) => {
219
- const c = getThemeColors(name)
220
- return (
221
- <box flexDirection="row">
222
- <text fg={idx() === i ? c.primary : theme.colors.textMuted}>
223
- {idx() === i && tab() === "theme" ? "❯ " : " "}
224
- </text>
225
- <text fg={idx() === i ? c.text : theme.colors.textMuted}>
226
- <span style={{ bold: idx() === i }}>
227
- {name.padEnd(14)}
228
- </span>
229
- </text>
230
- <text fg={c.logo1}>{" ●"}</text>
231
- <text fg={c.logo2}>{"●"}</text>
232
- <text fg={c.primary}>{"●"}</text>
233
- <text fg={c.accent}>{"●"}</text>
234
- <text fg={c.success}>{"●"}</text>
235
- <text fg={c.error}>{"●"}</text>
236
- </box>
237
- )
238
- })}
235
+ {/* Options list */}
236
+ <box flexDirection="column" width={30}>
237
+ {ALL_OPTIONS.map((opt, i) => (
238
+ <box flexDirection="row">
239
+ <text fg={idx() === i ? opt.colors.primary : theme.colors.textMuted}>
240
+ {idx() === i ? "❯ " : " "}
241
+ </text>
242
+ <text fg={idx() === i ? opt.colors.text : theme.colors.textMuted}>
243
+ <span style={{ bold: idx() === i }}>
244
+ {opt.label}
245
+ </span>
246
+ </text>
247
+ </box>
248
+ ))}
239
249
  </box>
240
250
 
241
- {/* Mode toggle */}
242
- <box flexDirection="column" width={30}>
243
- <text fg={tab() === "mode" ? theme.colors.text : theme.colors.textMuted}>
244
- <span style={{ bold: true }}>{"Background Mode"}</span>
245
- </text>
251
+ {/* Preview */}
252
+ <box flexDirection="column" width={40}>
253
+ <text fg={c().text}><span style={{ bold: true }}>{"Preview"}</span></text>
246
254
  <box height={1} />
247
- <box flexDirection="row">
248
- <text fg={theme.mode === "dark" && tab() === "mode" ? theme.colors.primary : theme.colors.textMuted}>
249
- {theme.mode === "dark" && tab() === "mode" ? "❯ " : " "}
250
- </text>
251
- <text fg={theme.mode === "dark" ? theme.colors.text : theme.colors.textMuted}>
252
- <span style={{ bold: theme.mode === "dark" }}>{"Dark"}</span>
253
- </text>
254
- </box>
255
- <box flexDirection="row">
256
- <text fg={theme.mode === "light" && tab() === "mode" ? theme.colors.primary : theme.colors.textMuted}>
257
- {theme.mode === "light" && tab() === "mode" ? "❯ " : " "}
258
- </text>
259
- <text fg={theme.mode === "light" ? theme.colors.text : theme.colors.textMuted}>
260
- <span style={{ bold: theme.mode === "light" }}>{"Light"}</span>
261
- </text>
255
+ <box flexDirection="column" paddingLeft={2}>
256
+ <box flexDirection="row">
257
+ <text fg={c().primary}><span style={{ bold: true }}>{"You: "}</span></text>
258
+ <text fg={c().text}>{"Show me trending posts"}</text>
259
+ </box>
260
+ <box flexDirection="row">
261
+ <text fg={c().accent}><span style={{ bold: true }}>{"AI: "}</span></text>
262
+ <text fg={c().text}>{"Here are today's top..."}</text>
263
+ </box>
264
+ <box height={1} />
265
+ <box flexDirection="row">
266
+ <text fg={c().success}>{"✓ "}</text>
267
+ <text fg={c().text}>{"Published successfully"}</text>
268
+ </box>
269
+ <box flexDirection="row">
270
+ <text fg={c().warning}>{"⚠ "}</text>
271
+ <text fg={c().textMuted}>{"Rate limit reached"}</text>
272
+ </box>
273
+ <box flexDirection="row">
274
+ <text fg={c().error}>{"✗ "}</text>
275
+ <text fg={c().textMuted}>{"Connection failed"}</text>
276
+ </box>
262
277
  </box>
263
278
  </box>
264
279
  </box>