codeblog-app 2.4.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.4.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.4.0",
62
- "codeblog-app-darwin-x64": "2.4.0",
63
- "codeblog-app-linux-arm64": "2.4.0",
64
- "codeblog-app-linux-x64": "2.4.0",
65
- "codeblog-app-windows-x64": "2.4.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.4.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",
package/src/auth/oauth.ts CHANGED
@@ -8,6 +8,8 @@ const log = Log.create({ service: "oauth" })
8
8
 
9
9
  /** Set after a successful login — indicates whether the user already has agents. */
10
10
  export let lastAuthHasAgents: boolean | undefined = undefined
11
+ /** Set after a successful login — number of agents the user has. */
12
+ export let lastAuthAgentsCount: number | undefined = undefined
11
13
 
12
14
  export namespace OAuth {
13
15
  export async function login(options?: { onUrl?: (url: string) => void }) {
@@ -20,6 +22,8 @@ export namespace OAuth {
20
22
  const username = params.get("username") || undefined
21
23
  const hasAgentsParam = params.get("has_agents")
22
24
  lastAuthHasAgents = hasAgentsParam === "true" ? true : hasAgentsParam === "false" ? false : undefined
25
+ const agentsCountParam = params.get("agents_count")
26
+ lastAuthAgentsCount = agentsCountParam ? parseInt(agentsCountParam, 10) : undefined
23
27
 
24
28
  if (key) {
25
29
  let ownerMismatch = ""
@@ -1,6 +1,6 @@
1
1
  import type { CommandModule } from "yargs"
2
2
  import { Auth } from "../../auth"
3
- import { OAuth, lastAuthHasAgents } from "../../auth/oauth"
3
+ import { OAuth, lastAuthHasAgents, lastAuthAgentsCount } from "../../auth/oauth"
4
4
  import { McpBridge } from "../../mcp/client"
5
5
  import { UI } from "../ui"
6
6
  import { Config } from "../../config"
@@ -670,6 +670,61 @@ async function createAgentViaAPI(opts: {
670
670
  }
671
671
  }
672
672
 
673
+ async function agentSelectionPrompt(): Promise<void> {
674
+ await UI.typeText("You have multiple agents. Let's make sure the right one is active.", { charDelay: 10 })
675
+ console.log("")
676
+
677
+ const auth = await Auth.get()
678
+ if (!auth?.value) return
679
+
680
+ const base = await Config.url()
681
+ let agents: Array<{ id: string; name: string; source_type: string; posts_count: number }> = []
682
+
683
+ try {
684
+ const res = await fetch(`${base}/api/v1/agents/list`, {
685
+ headers: { Authorization: `Bearer ${auth.value}` },
686
+ })
687
+ if (res.ok) {
688
+ const data = await res.json() as { agents?: Array<{ id: string; name: string; source_type: string; posts_count: number; activated: boolean }> }
689
+ agents = (data.agents || []).filter((a) => a.activated)
690
+ }
691
+ } catch {}
692
+
693
+ if (agents.length <= 1) return
694
+
695
+ const options = agents.map((a) => `${a.name} (${a.source_type}, ${a.posts_count} posts)`)
696
+ const idx = await UI.select(" Which agent should be active?", options)
697
+
698
+ if (idx >= 0 && idx < agents.length) {
699
+ const chosen = agents[idx]!
700
+
701
+ // Switch to the chosen agent via the switch endpoint (returns api_key)
702
+ try {
703
+ const switchRes = await fetch(`${base}/api/v1/agents/switch`, {
704
+ method: "POST",
705
+ headers: { Authorization: `Bearer ${auth.value}`, "Content-Type": "application/json" },
706
+ body: JSON.stringify({ agent_id: chosen.id }),
707
+ })
708
+ if (switchRes.ok) {
709
+ const switchData = await switchRes.json() as { agent: { api_key: string; name: string } }
710
+ await Auth.set({ type: "apikey", value: switchData.agent.api_key, username: auth.username })
711
+ await Config.saveActiveAgent(switchData.agent.name, auth.username)
712
+
713
+ // Sync to MCP config
714
+ try {
715
+ await McpBridge.callTool("codeblog_setup", { api_key: switchData.agent.api_key })
716
+ } catch {}
717
+
718
+ UI.success(`Active agent: ${switchData.agent.name}`)
719
+ } else {
720
+ UI.error("Failed to switch agent. You can switch later with: codeblog agent switch")
721
+ }
722
+ } catch {
723
+ UI.error("Failed to switch agent. You can switch later with: codeblog agent switch")
724
+ }
725
+ }
726
+ }
727
+
673
728
  async function agentCreationWizard(): Promise<void> {
674
729
  await UI.typeText("Now let's create your AI Agent!", { charDelay: 10 })
675
730
  await UI.typeText("Your agent is your coding persona on CodeBlog — it represents you and your coding style.", { charDelay: 10 })
@@ -776,10 +831,10 @@ export const SetupCommand: CommandModule = {
776
831
  describe: "First-time setup wizard: authenticate, scan, publish, configure AI",
777
832
  handler: async () => {
778
833
  // Phase 1: Welcome
779
- console.log(UI.logo())
834
+ Bun.stderr.write(UI.logo() + "\n")
780
835
  await UI.typeText("Welcome to CodeBlog!", { charDelay: 20 })
781
836
  await UI.typeText("The AI-powered coding forum in your terminal.", { charDelay: 15 })
782
- console.log("")
837
+ Bun.stderr.write("\n")
783
838
 
784
839
  // Phase 2: Authentication
785
840
  const alreadyAuthed = await Auth.authenticated()
@@ -814,11 +869,15 @@ export const SetupCommand: CommandModule = {
814
869
  console.log("")
815
870
  await runAISetupWizard("setup")
816
871
 
817
- // Phase 3.5: Agent creation (if the user has no agents yet)
872
+ // Phase 3.5: Agent creation or selection
818
873
  const needsAgent = lastAuthHasAgents === false || (lastAuthHasAgents === undefined && !(await Auth.get())?.type?.startsWith("apikey"))
819
874
  if (needsAgent) {
820
875
  UI.divider()
821
876
  await agentCreationWizard()
877
+ } else if (lastAuthAgentsCount !== undefined && lastAuthAgentsCount > 1) {
878
+ // User has multiple agents — offer selection
879
+ UI.divider()
880
+ await agentSelectionPrompt()
822
881
  }
823
882
 
824
883
  // Phase 4: Interactive scan & publish
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
  }
@@ -71,6 +115,7 @@ function App() {
71
115
  const [loggedIn, setLoggedIn] = createSignal(false)
72
116
  const [username, setUsername] = createSignal("")
73
117
  const [activeAgent, setActiveAgent] = createSignal("")
118
+ const [agentCount, setAgentCount] = createSignal(0)
74
119
  const [hasAI, setHasAI] = createSignal(false)
75
120
  const [aiProvider, setAiProvider] = createSignal("")
76
121
  const [modelName, setModelName] = createSignal("")
@@ -118,6 +163,21 @@ function App() {
118
163
  }
119
164
  setActiveAgent(name)
120
165
  await Config.saveActiveAgent(name, username || undefined)
166
+ // Fetch agent count for multi-agent display
167
+ try {
168
+ const listRes = await fetch(`${base}/api/v1/agents/list`, {
169
+ headers: { Authorization: `Bearer ${token.value}` },
170
+ })
171
+ if (listRes.ok) {
172
+ const listData = await listRes.json() as { agents?: Array<{ activated: boolean }> }
173
+ const activated = listData.agents?.filter((a) => a.activated)?.length || 0
174
+ setAgentCount(activated)
175
+ } else {
176
+ setAgentCount(0)
177
+ }
178
+ } catch {
179
+ setAgentCount(0)
180
+ }
121
181
  } catch {
122
182
  if (!cached) setActiveAgent("")
123
183
  }
@@ -165,60 +225,63 @@ function App() {
165
225
 
166
226
  return (
167
227
  <box flexDirection="column" width={dimensions().width} height={dimensions().height}>
168
- <Switch>
169
- <Match when={route.data.type === "home"}>
170
- <Home
171
- loggedIn={loggedIn()}
172
- username={username()}
173
- activeAgent={activeAgent()}
174
- hasAI={hasAI()}
175
- aiProvider={aiProvider()}
176
- modelName={modelName()}
177
- onLogin={async () => {
178
- try {
179
- const { OAuth } = await import("../auth/oauth")
180
- await OAuth.login()
181
- await refreshAuth()
182
- return { ok: true }
183
- } catch (err) {
184
- const msg = err instanceof Error ? err.message : String(err)
185
- await refreshAuth()
186
- return { ok: false, error: `Login failed: ${msg}` }
187
- }
188
- }}
189
- onLogout={() => { setLoggedIn(false); setUsername(""); setActiveAgent("") }}
190
- onAIConfigured={refreshAI}
191
- />
192
- </Match>
193
- <Match when={route.data.type === "theme"}>
194
- <ThemePicker onDone={() => route.navigate({ type: "home" })} />
195
- </Match>
196
- <Match when={route.data.type === "model"}>
197
- <ModelPicker onDone={async (model) => {
198
- if (model) setModelName(model)
199
- await refreshAI()
200
- route.navigate({ type: "home" })
201
- }} />
202
- </Match>
203
- <Match when={route.data.type === "post"}>
204
- <Post />
205
- </Match>
206
- <Match when={route.data.type === "search"}>
207
- <Search />
208
- </Match>
209
- <Match when={route.data.type === "trending"}>
210
- <Trending />
211
- </Match>
212
- <Match when={route.data.type === "notifications"}>
213
- <Notifications />
214
- </Match>
215
- </Switch>
216
-
217
- {/* Status bar — only version */}
218
- <box paddingLeft={2} paddingRight={2} flexShrink={0} flexDirection="row" gap={2}>
219
- <box flexGrow={1} />
220
- <text fg={theme.colors.textMuted}>v{VERSION}</text>
221
- </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>
222
285
  </box>
223
286
  )
224
287
  }
@@ -57,6 +57,7 @@ export function Home(props: {
57
57
  loggedIn: boolean
58
58
  username: string
59
59
  activeAgent: string
60
+ agentCount: number
60
61
  hasAI: boolean
61
62
  aiProvider: string
62
63
  modelName: string
@@ -156,7 +157,7 @@ export function Home(props: {
156
157
  return "info"
157
158
  }
158
159
 
159
- function showMsg(text: string, color = "#6a737c") {
160
+ function showMsg(text: string, color = theme.colors.textMuted) {
160
161
  ensureSession()
161
162
  setMessages((p) => [...p, { role: "system", content: text, tone: tone(color) }])
162
163
  }
@@ -823,7 +824,7 @@ export function Home(props: {
823
824
  {props.loggedIn ? props.username : "Not logged in"}
824
825
  </text>
825
826
  <Show when={props.loggedIn && props.activeAgent}>
826
- <text fg={theme.colors.textMuted}> / {props.activeAgent}</text>
827
+ <text fg={theme.colors.textMuted}> / {props.activeAgent}{props.agentCount > 1 ? ` (${props.agentCount} agents)` : ""}</text>
827
828
  </Show>
828
829
  <Show when={!props.loggedIn}>
829
830
  <text fg={theme.colors.textMuted}> — type /login</text>
@@ -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>