codeblog-app 2.5.0 → 2.6.0

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/src/cli/ui.ts CHANGED
@@ -40,7 +40,7 @@ export namespace UI {
40
40
  `${orange} ╚██████╗╚██████╔╝██████╔╝███████╗${cyan}██████╔╝███████╗╚██████╔╝╚██████╔╝${reset}`,
41
41
  `${orange} ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝${cyan}╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ${reset}`,
42
42
  "",
43
- ` ${Style.TEXT_DIM}The AI-powered coding forum in your terminal${Style.TEXT_NORMAL}`,
43
+ ` ${Style.TEXT_DIM}Agent Only Coding Society${Style.TEXT_NORMAL}`,
44
44
  "",
45
45
  ].join(EOL)
46
46
  }
@@ -337,6 +337,107 @@ export namespace UI {
337
337
  })
338
338
  }
339
339
 
340
+ export async function multiSelect(prompt: string, options: string[]): Promise<number[]> {
341
+ if (options.length === 0) return []
342
+
343
+ const stdin = process.stdin
344
+ const wasRaw = stdin.isRaw
345
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true)
346
+ process.stderr.write("\x1b[?25l")
347
+
348
+ let idx = 0
349
+ const selected = new Set<number>()
350
+ let drawnRows = 0
351
+ const maxRows = 12
352
+ let onData: ((ch: Buffer) => void) = () => {}
353
+
354
+ const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b./g, "")
355
+ const rowCount = (line: string) => {
356
+ const cols = Math.max(20, process.stderr.columns || 80)
357
+ const len = Array.from(stripAnsi(line)).length
358
+ return Math.max(1, Math.ceil((len || 1) / cols))
359
+ }
360
+
361
+ const draw = () => {
362
+ if (drawnRows > 1) process.stderr.write(`\x1b[${drawnRows - 1}F`)
363
+ process.stderr.write("\x1b[J")
364
+
365
+ const count = options.length
366
+ const start = count <= maxRows ? 0 : Math.max(0, Math.min(idx - 4, count - maxRows))
367
+ const items = options.slice(start, start + maxRows)
368
+ const lines = [
369
+ prompt,
370
+ ...items.map((label, i) => {
371
+ const realIdx = start + i
372
+ const active = realIdx === idx
373
+ const checked = selected.has(realIdx)
374
+ const box = checked ? `${Style.TEXT_SUCCESS}◉${Style.TEXT_NORMAL}` : "○"
375
+ const cursor = active ? `${Style.TEXT_HIGHLIGHT}❯${Style.TEXT_NORMAL}` : " "
376
+ const text = active ? `${Style.TEXT_NORMAL_BOLD}${label}${Style.TEXT_NORMAL}` : label
377
+ return ` ${cursor} ${box} ${text}`
378
+ }),
379
+ count > maxRows
380
+ ? ` ${Style.TEXT_DIM}${start > 0 ? "↑ more " : ""}${start + maxRows < count ? "↓ more" : ""}${Style.TEXT_NORMAL}`
381
+ : ` ${Style.TEXT_DIM}${Style.TEXT_NORMAL}`,
382
+ ` ${Style.TEXT_DIM}↑/↓ move · Space toggle · a all · Enter confirm · Esc cancel${Style.TEXT_NORMAL}`,
383
+ ]
384
+ process.stderr.write(lines.map((line) => `\x1b[2K\r${line}`).join("\n"))
385
+ drawnRows = lines.reduce((sum, line) => sum + rowCount(line), 0)
386
+ }
387
+
388
+ const restore = () => {
389
+ process.stderr.write("\x1b[?25h")
390
+ if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(wasRaw ?? false)
391
+ stdin.removeListener("data", onData)
392
+ process.stderr.write("\n")
393
+ }
394
+
395
+ draw()
396
+
397
+ return new Promise((resolve) => {
398
+ onData = (ch: Buffer) => {
399
+ const c = ch.toString("utf8")
400
+ if (c === "\u0003") {
401
+ restore()
402
+ process.exit(130)
403
+ }
404
+ if (c === "\r" || c === "\n") {
405
+ restore()
406
+ resolve([...selected].sort((a, b) => a - b))
407
+ return
408
+ }
409
+ if (c === "\x1b") {
410
+ restore()
411
+ resolve([])
412
+ return
413
+ }
414
+ if (c === " ") {
415
+ if (selected.has(idx)) selected.delete(idx)
416
+ else selected.add(idx)
417
+ draw()
418
+ return
419
+ }
420
+ if (c === "a") {
421
+ if (selected.size === options.length) selected.clear()
422
+ else options.forEach((_, i) => selected.add(i))
423
+ draw()
424
+ return
425
+ }
426
+ if (c.includes("\x1b[A") || c.includes("\x1bOA") || c === "k") {
427
+ idx = (idx - 1 + options.length) % options.length
428
+ draw()
429
+ return
430
+ }
431
+ if (c.includes("\x1b[B") || c.includes("\x1bOB") || c === "j") {
432
+ idx = (idx + 1) % options.length
433
+ draw()
434
+ return
435
+ }
436
+ }
437
+ stdin.on("data", onData)
438
+ })
439
+ }
440
+
340
441
  export async function waitKey(prompt: string, keys: string[]): Promise<string> {
341
442
  const stdin = process.stdin
342
443
  process.stderr.write(` ${Style.TEXT_DIM}${prompt}${Style.TEXT_NORMAL}`)
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
@@ -28,6 +30,7 @@ import { MeCommand } from "./cli/cmd/me"
28
30
  import { AgentCommand } from "./cli/cmd/agent"
29
31
  import { ForumCommand } from "./cli/cmd/forum"
30
32
  import { UninstallCommand } from "./cli/cmd/uninstall"
33
+ import { McpCommand } from "./cli/cmd/mcp"
31
34
 
32
35
  const VERSION = (await import("../package.json")).version
33
36
 
@@ -81,7 +84,7 @@ const cli = yargs(hideBin(process.argv))
81
84
  })
82
85
  .middleware(async (argv) => {
83
86
  const cmd = argv._[0] as string | undefined
84
- const skipAuth = ["setup", "ai", "login", "logout", "config", "update", "uninstall"]
87
+ const skipAuth = ["setup", "ai", "login", "logout", "config", "mcp", "update", "uninstall"]
85
88
  if (cmd && !skipAuth.includes(cmd)) {
86
89
  const authed = await Auth.authenticated()
87
90
  if (!authed) {
@@ -112,6 +115,7 @@ const cli = yargs(hideBin(process.argv))
112
115
  " AI & Config:\n" +
113
116
  " chat Interactive AI chat with tool use\n" +
114
117
  " config Configure AI provider, model, server\n" +
118
+ " mcp init Configure MCP server in your IDEs\n" +
115
119
  " whoami Show current auth status\n" +
116
120
  " tui Launch interactive Terminal UI\n" +
117
121
  " update Update CLI to latest version\n" +
@@ -141,6 +145,7 @@ const cli = yargs(hideBin(process.argv))
141
145
  .command({ ...TuiCommand, describe: false })
142
146
  .command({ ...UpdateCommand, describe: false })
143
147
  .command({ ...UninstallCommand, describe: false })
148
+ .command({ ...McpCommand, describe: false })
144
149
 
145
150
  .fail((msg, err) => {
146
151
  if (
@@ -171,10 +176,24 @@ if (!hasSubcommand && !isHelp && !isVersion) {
171
176
  await Log.init({ print: false })
172
177
  Log.Default.info("codeblog", { version: VERSION, args: [] })
173
178
 
179
+ // Theme setup — must happen before anything else so all UI is readable
180
+ const themePath = path.join(Global.Path.config, "theme.json")
181
+ let hasTheme = false
182
+ try { await Bun.file(themePath).text(); hasTheme = true } catch {}
183
+ if (!hasTheme) {
184
+ const { themeSetupTui } = await import("./tui/app")
185
+ await themeSetupTui()
186
+ process.stdout.write("\x1b[2J\x1b[H")
187
+ process.stderr.write("\x1b[2J\x1b[H")
188
+ }
189
+
174
190
  const authed = await Auth.authenticated()
175
191
  if (!authed) {
176
- console.log("")
177
- // Use the statically imported SetupCommand
192
+ // Push content to bottom of terminal so logo appears near the bottom
193
+ const rows = process.stdout.rows || 24
194
+ const setupLines = 15
195
+ const padding = Math.max(0, rows - setupLines)
196
+ if (padding > 0) process.stdout.write("\n".repeat(padding))
178
197
  await (SetupCommand.handler as Function)({})
179
198
 
180
199
  // 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
  }
@@ -75,6 +119,7 @@ function App() {
75
119
  const [hasAI, setHasAI] = createSignal(false)
76
120
  const [aiProvider, setAiProvider] = createSignal("")
77
121
  const [modelName, setModelName] = createSignal("")
122
+ const [creditBalance, setCreditBalance] = createSignal<string | undefined>(undefined)
78
123
 
79
124
  async function refreshAuth() {
80
125
  try {
@@ -154,6 +199,21 @@ function App() {
154
199
  setModelName(model)
155
200
  const info = AIProvider.BUILTIN_MODELS[model]
156
201
  setAiProvider(info?.providerID || model.split("/")[0] || "ai")
202
+
203
+ // Fetch credit balance if using codeblog provider
204
+ if (cfg.default_provider === "codeblog") {
205
+ try {
206
+ const { fetchCreditBalance } = await import("../ai/codeblog-provider")
207
+ const balance = await fetchCreditBalance()
208
+ setCreditBalance(`$${balance.balance_usd}`)
209
+ } catch {
210
+ setCreditBalance(undefined)
211
+ }
212
+ } else {
213
+ setCreditBalance(undefined)
214
+ }
215
+ } else {
216
+ setCreditBalance(undefined)
157
217
  }
158
218
  } catch {}
159
219
  }
@@ -181,61 +241,64 @@ function App() {
181
241
 
182
242
  return (
183
243
  <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>
244
+ <Show when={!theme.needsSetup} fallback={<ThemeSetup />}>
245
+ <Switch>
246
+ <Match when={route.data.type === "home"}>
247
+ <Home
248
+ loggedIn={loggedIn()}
249
+ username={username()}
250
+ activeAgent={activeAgent()}
251
+ agentCount={agentCount()}
252
+ hasAI={hasAI()}
253
+ aiProvider={aiProvider()}
254
+ modelName={modelName()}
255
+ creditBalance={creditBalance()}
256
+ onLogin={async () => {
257
+ try {
258
+ const { OAuth } = await import("../auth/oauth")
259
+ await OAuth.login()
260
+ await refreshAuth()
261
+ return { ok: true }
262
+ } catch (err) {
263
+ const msg = err instanceof Error ? err.message : String(err)
264
+ await refreshAuth()
265
+ return { ok: false, error: `Login failed: ${msg}` }
266
+ }
267
+ }}
268
+ onLogout={() => { setLoggedIn(false); setUsername(""); setActiveAgent("") }}
269
+ onAIConfigured={refreshAI}
270
+ />
271
+ </Match>
272
+ <Match when={route.data.type === "theme"}>
273
+ <ThemePicker onDone={() => route.navigate({ type: "home" })} />
274
+ </Match>
275
+ <Match when={route.data.type === "model"}>
276
+ <ModelPicker onDone={async (model) => {
277
+ if (model) setModelName(model)
278
+ await refreshAI()
279
+ route.navigate({ type: "home" })
280
+ }} />
281
+ </Match>
282
+ <Match when={route.data.type === "post"}>
283
+ <Post />
284
+ </Match>
285
+ <Match when={route.data.type === "search"}>
286
+ <Search />
287
+ </Match>
288
+ <Match when={route.data.type === "trending"}>
289
+ <Trending />
290
+ </Match>
291
+ <Match when={route.data.type === "notifications"}>
292
+ <Notifications />
293
+ </Match>
294
+ </Switch>
295
+
296
+ {/* Status bar — only version */}
297
+ <box paddingLeft={2} paddingRight={2} flexShrink={0} flexDirection="row" gap={2}>
298
+ <box flexGrow={1} />
299
+ <text fg={theme.colors.textMuted}>v{VERSION}</text>
300
+ </box>
301
+ </Show>
239
302
  </box>
240
303
  )
241
304
  }
@@ -33,7 +33,7 @@ export interface CommandDeps {
33
33
  export function createCommands(deps: CommandDeps): CmdDef[] {
34
34
  return [
35
35
  // === Configuration & Setup ===
36
- { name: "/ai", description: "Quick AI setup (URL + key)", action: () => deps.startAIConfig() },
36
+ { name: "/ai", description: "AI setup wizard (provider + key)", action: () => deps.startAIConfig() },
37
37
  { name: "/model", description: "Switch model (picker or /model <id>)", action: async (parts) => {
38
38
  const query = parts.slice(1).join(" ").trim()
39
39
  if (!query) {