codeblog-app 2.5.1 โ†’ 2.7.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.
@@ -5,13 +5,121 @@ import { useExit } from "../context/exit"
5
5
  import { useTheme, type ThemeColors } from "../context/theme"
6
6
  import { createCommands, LOGO, TIPS, TIPS_NO_AI } from "../commands"
7
7
  import { TOOL_LABELS } from "../../ai/tools"
8
- import { mask, saveProvider } from "../../ai/configure"
8
+ import { mask } from "../../ai/configure"
9
9
  import { ChatHistory } from "../../storage/chat"
10
10
  import { TuiStreamAssembler } from "../stream-assembler"
11
11
  import { resolveAssistantContent } from "../ai-stream"
12
12
  import { isShiftEnterSequence, onInputIntent } from "../input-intent"
13
13
  import { Log } from "../../util/log"
14
14
 
15
+ interface ProviderChoice {
16
+ name: string
17
+ providerID: string
18
+ api: "anthropic" | "openai" | "google" | "openai-compatible"
19
+ baseURL?: string
20
+ hint?: string
21
+ }
22
+
23
+ const PROVIDER_CHOICES: ProviderChoice[] = [
24
+ { name: "CodeBlog Free Credit ($5)", providerID: "codeblog", api: "openai-compatible", baseURL: "", hint: "Free $5 AI credit, no API key needed" },
25
+ { name: "OpenAI", providerID: "openai", api: "openai", baseURL: "https://api.openai.com", hint: "Codex OAuth + API key style" },
26
+ { name: "Anthropic", providerID: "anthropic", api: "anthropic", baseURL: "https://api.anthropic.com", hint: "Claude API key" },
27
+ { name: "Google", providerID: "google", api: "google", baseURL: "https://generativelanguage.googleapis.com", hint: "Gemini API key" },
28
+ { name: "OpenRouter", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://openrouter.ai/api", hint: "OpenAI-compatible" },
29
+ { name: "vLLM", providerID: "openai-compatible", api: "openai-compatible", baseURL: "http://127.0.0.1:8000", hint: "Local/self-hosted OpenAI-compatible" },
30
+ { name: "MiniMax", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.minimax.io", hint: "OpenAI-compatible endpoint" },
31
+ { name: "Moonshot AI (Kimi K2.5)", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.moonshot.ai", hint: "OpenAI-compatible endpoint" },
32
+ { name: "xAI (Grok)", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.x.ai", hint: "OpenAI-compatible endpoint" },
33
+ { name: "Qianfan", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://qianfan.baidubce.com", hint: "OpenAI-compatible endpoint" },
34
+ { name: "Vercel AI Gateway", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://ai-gateway.vercel.sh", hint: "OpenAI-compatible endpoint" },
35
+ { name: "OpenCode Zen", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://opencode.ai/zen", hint: "OpenAI-compatible endpoint" },
36
+ { name: "Xiaomi", providerID: "anthropic", api: "anthropic", baseURL: "https://api.xiaomimimo.com/anthropic", hint: "Anthropic-compatible endpoint" },
37
+ { name: "Synthetic", providerID: "anthropic", api: "anthropic", baseURL: "https://api.synthetic.new", hint: "Anthropic-compatible endpoint" },
38
+ { name: "Together AI", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.together.xyz", hint: "OpenAI-compatible endpoint" },
39
+ { name: "Hugging Face", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://router.huggingface.co", hint: "OpenAI-compatible endpoint" },
40
+ { name: "Venice AI", providerID: "openai-compatible", api: "openai-compatible", baseURL: "https://api.venice.ai/api", hint: "OpenAI-compatible endpoint" },
41
+ { name: "LiteLLM", providerID: "openai-compatible", api: "openai-compatible", baseURL: "http://localhost:4000", hint: "Unified OpenAI-compatible gateway" },
42
+ { name: "Cloudflare AI Gateway", providerID: "anthropic", api: "anthropic", hint: "Enter full Anthropic gateway URL manually" },
43
+ { name: "Custom Provider", providerID: "openai-compatible", api: "openai-compatible", hint: "Any OpenAI-compatible URL" },
44
+ ]
45
+
46
+ type AiSetupStep = "" | "provider" | "url" | "key" | "testing"
47
+
48
+ function isOfficialOpenAIBase(baseURL: string): boolean {
49
+ try { return new URL(baseURL).hostname === "api.openai.com" } catch { return false }
50
+ }
51
+
52
+ function providerLabel(p: ProviderChoice): string {
53
+ return p.hint ? `${p.name} (${p.hint})` : p.name
54
+ }
55
+
56
+ async function fetchOpenAIModels(baseURL: string, key: string): Promise<string[]> {
57
+ try {
58
+ const clean = baseURL.replace(/\/+$/, "")
59
+ const url = clean.endsWith("/v1") ? `${clean}/models` : `${clean}/v1/models`
60
+ const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(8000) })
61
+ if (!r.ok) return []
62
+ const data = await r.json() as { data?: Array<{ id: string }> }
63
+ return data.data?.map((m) => m.id) || []
64
+ } catch { return [] }
65
+ }
66
+
67
+ function pickPreferredRemoteModel(models: string[]): string | undefined {
68
+ if (models.length === 0) return undefined
69
+ const preferred = [/^gpt-5\.2$/, /^claude-sonnet-4(?:-5)?/, /^gpt-5(?:\.|$|-)/, /^gpt-4o$/, /^gpt-4o-mini$/, /^gemini-2\.5-flash$/]
70
+ for (const pattern of preferred) {
71
+ const found = models.find((id) => pattern.test(id))
72
+ if (found) return found
73
+ }
74
+ return models[0]
75
+ }
76
+
77
+ async function verifyEndpoint(choice: ProviderChoice, baseURL: string, key: string): Promise<{ ok: boolean; detail: string; detectedApi?: "anthropic" | "openai" | "google" | "openai-compatible" }> {
78
+ try {
79
+ if (choice.api === "anthropic") {
80
+ const clean = baseURL.replace(/\/+$/, "")
81
+ const r = await fetch(`${clean}/v1/messages`, {
82
+ method: "POST",
83
+ headers: { "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json" },
84
+ body: JSON.stringify({ model: "claude-3-5-haiku-latest", max_tokens: 1, messages: [{ role: "user", content: "ping" }] }),
85
+ signal: AbortSignal.timeout(8000),
86
+ })
87
+ if (r.status !== 404) return { ok: true, detail: `Anthropic endpoint reachable (${r.status})`, detectedApi: "anthropic" }
88
+ return { ok: false, detail: "Anthropic endpoint returned 404" }
89
+ }
90
+ if (choice.api === "google") {
91
+ const clean = baseURL.replace(/\/+$/, "")
92
+ const r = await fetch(`${clean}/v1beta/models?key=${encodeURIComponent(key)}`, { signal: AbortSignal.timeout(8000) })
93
+ if (r.ok || r.status === 401 || r.status === 403) return { ok: true, detail: `Google endpoint reachable (${r.status})` }
94
+ return { ok: false, detail: `Google endpoint responded ${r.status}` }
95
+ }
96
+ const { probe } = await import("../../ai/configure")
97
+ const detected = await probe(baseURL, key)
98
+ if (detected === "anthropic") return { ok: true, detail: "Detected Anthropic API format", detectedApi: "anthropic" }
99
+ if (detected === "openai") {
100
+ const api = choice.providerID === "openai" && isOfficialOpenAIBase(baseURL) ? "openai" as const : "openai-compatible" as const
101
+ return { ok: true, detail: "Detected OpenAI API format", detectedApi: api }
102
+ }
103
+ const models = await fetchOpenAIModels(baseURL, key)
104
+ if (models.length > 0) {
105
+ const api = choice.providerID === "openai" && isOfficialOpenAIBase(baseURL) ? "openai" as const : "openai-compatible" as const
106
+ return { ok: true, detail: `Model endpoint reachable (${models.length} models)`, detectedApi: api }
107
+ }
108
+ return { ok: false, detail: "Could not detect endpoint format or list models" }
109
+ } catch (err) {
110
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) }
111
+ }
112
+ }
113
+
114
+ async function pickQuickModel(choice: ProviderChoice, baseURL: string, key: string): Promise<string> {
115
+ const openaiCustom = choice.providerID === "openai" && !isOfficialOpenAIBase(baseURL)
116
+ if (choice.providerID === "anthropic") return "claude-sonnet-4-20250514"
117
+ if (choice.providerID === "openai" && !openaiCustom) return "gpt-5.2"
118
+ if (choice.providerID === "google") return "gemini-2.5-flash"
119
+ const remote = await fetchOpenAIModels(baseURL, key)
120
+ return pickPreferredRemoteModel(remote) || "gpt-5.2"
121
+ }
122
+
15
123
  function buildMarkdownSyntaxRules(colors: ThemeColors): ThemeTokenStyle[] {
16
124
  return [
17
125
  { scope: ["default"], style: { foreground: colors.text } },
@@ -61,6 +169,7 @@ export function Home(props: {
61
169
  hasAI: boolean
62
170
  aiProvider: string
63
171
  modelName: string
172
+ creditBalance?: string
64
173
  onLogin: () => Promise<{ ok: boolean; error?: string }>
65
174
  onLogout: () => void
66
175
  onAIConfigured: () => void
@@ -131,9 +240,12 @@ export function Home(props: {
131
240
 
132
241
  const tipPool = () => props.hasAI ? TIPS : TIPS_NO_AI
133
242
  const tipIdx = Math.floor(Math.random() * TIPS.length)
134
- const [aiMode, setAiMode] = createSignal<"" | "url" | "key" | "testing">("")
243
+ const [aiMode, setAiMode] = createSignal<AiSetupStep>("")
135
244
  const [aiUrl, setAiUrl] = createSignal("")
136
245
  const [aiKey, setAiKey] = createSignal("")
246
+ const [aiProviderIdx, setAiProviderIdx] = createSignal(0)
247
+ const [aiProviderQuery, setAiProviderQuery] = createSignal("")
248
+ const [aiProvider, setAiProvider] = createSignal<ProviderChoice | null>(null)
137
249
  const [modelPicking, setModelPicking] = createSignal(false)
138
250
  const [modelOptions, setModelOptions] = createSignal<ModelOption[]>([])
139
251
  const [modelIdx, setModelIdx] = createSignal(0)
@@ -305,8 +417,12 @@ export function Home(props: {
305
417
  onLogout: props.onLogout,
306
418
  clearChat,
307
419
  startAIConfig: () => {
308
- setAiUrl(""); setAiKey(""); setAiMode("url")
309
- showMsg("Quick setup: paste API URL (or press Enter to skip). Full wizard: `codeblog ai setup`", theme.colors.primary)
420
+ setAiUrl("")
421
+ setAiKey("")
422
+ setAiProviderIdx(0)
423
+ setAiProviderQuery("")
424
+ setAiProvider(null)
425
+ setAiMode("provider")
310
426
  },
311
427
  setMode: theme.setMode,
312
428
  send,
@@ -395,12 +511,26 @@ export function Home(props: {
395
511
  onCleanup(() => offInputIntent())
396
512
 
397
513
  usePaste((evt) => {
398
- // For URL/key modes, strip newlines; for normal input, preserve them
399
- if (aiMode() === "url" || aiMode() === "key") {
514
+ const mode = aiMode()
515
+ if (mode === "provider") {
516
+ const text = evt.text.replace(/[\n\r]/g, "").trim()
517
+ if (!text) return
518
+ evt.preventDefault()
519
+ setAiProviderQuery(text)
520
+ setAiProviderIdx(0)
521
+ return
522
+ }
523
+ if (mode === "url") {
524
+ const text = evt.text.replace(/[\n\r]/g, "").trim()
525
+ if (!text) return
526
+ evt.preventDefault()
527
+ setAiUrl(text)
528
+ return
529
+ }
530
+ if (mode === "key") {
400
531
  const text = evt.text.replace(/[\n\r]/g, "").trim()
401
532
  if (!text) return
402
533
  evt.preventDefault()
403
- if (aiMode() === "url") { setAiUrl(text); return }
404
534
  setAiKey(text)
405
535
  return
406
536
  }
@@ -532,50 +662,182 @@ export function Home(props: {
532
662
  }
533
663
  }
534
664
 
535
- async function saveAI() {
665
+ const aiProviderFiltered = createMemo(() => {
666
+ const q = aiProviderQuery().trim().toLowerCase()
667
+ const all = [...PROVIDER_CHOICES, { name: "Skip for now", providerID: "", api: "openai" as const, hint: "" }]
668
+ if (!q) return all
669
+ return all.filter((p) => p.name.toLowerCase().includes(q) || (p.hint || "").toLowerCase().includes(q))
670
+ })
671
+
672
+ const aiProviderVisibleStart = createMemo(() => {
673
+ const len = aiProviderFiltered().length
674
+ if (len <= 10) return 0
675
+ return Math.max(0, Math.min(aiProviderIdx() - 4, len - 10))
676
+ })
677
+
678
+ const aiProviderVisibleItems = createMemo(() => {
679
+ const start = aiProviderVisibleStart()
680
+ return aiProviderFiltered().slice(start, start + 10)
681
+ })
682
+
683
+ createEffect(() => {
684
+ const len = aiProviderFiltered().length
685
+ const idx = aiProviderIdx()
686
+ if (len === 0 && idx !== 0) { setAiProviderIdx(0); return }
687
+ if (idx >= len) setAiProviderIdx(Math.max(0, len - 1))
688
+ })
689
+
690
+ function confirmProvider() {
691
+ const list = aiProviderFiltered()
692
+ const selected = list[aiProviderIdx()]
693
+ if (!selected || !selected.providerID) {
694
+ showMsg("Skipped AI setup.", theme.colors.warning)
695
+ setAiMode("")
696
+ return
697
+ }
698
+ setAiProvider(selected)
699
+
700
+ // CodeBlog Free Credit: skip URL/key, claim credit directly
701
+ if (selected.providerID === "codeblog") {
702
+ runCodeblogCreditSetup()
703
+ return
704
+ }
705
+
706
+ const defaultBase = selected.baseURL || ""
707
+ const needsUrl =
708
+ selected.providerID === "openai-compatible" ||
709
+ selected.providerID === "openai" ||
710
+ !defaultBase
711
+ setAiUrl("")
712
+ if (needsUrl) {
713
+ setAiMode("url")
714
+ } else {
715
+ setAiMode("key")
716
+ }
717
+ }
718
+
719
+ async function runCodeblogCreditSetup() {
536
720
  setAiMode("testing")
537
- showMsg("Detecting API format...", theme.colors.primary)
538
721
  try {
539
- const result = await saveProvider(aiUrl().trim(), aiKey().trim())
540
- if (result.error) {
541
- showMsg(result.error, theme.colors.error)
542
- setAiMode("key")
722
+ const { claimCredit, fetchCreditBalance } = await import("../../ai/codeblog-provider")
723
+ const { Config } = await import("../../config")
724
+ const { Auth } = await import("../../auth")
725
+
726
+ const isLoggedIn = await Auth.authenticated()
727
+ if (!isLoggedIn) {
728
+ showMsg("You need to be logged in to claim free credit. Run: codeblog login", theme.colors.warning)
729
+ setAiMode("")
543
730
  return
544
731
  }
545
- showMsg(`โœ“ AI configured! (${result.provider})`, theme.colors.success)
732
+
733
+ const claim = await claimCredit()
734
+ const balance = await fetchCreditBalance()
735
+
736
+ const proxyURL = `${(await Config.url()).replace(/\/+$/, "")}/api/v1/ai-credit/chat`
737
+ const cfg = await Config.load()
738
+ const providers = cfg.providers || {}
739
+ providers["codeblog"] = {
740
+ api_key: "proxy",
741
+ base_url: proxyURL,
742
+ api: "openai-compatible",
743
+ compat_profile: "openai-compatible",
744
+ }
745
+
746
+ await Config.save({
747
+ providers,
748
+ default_provider: "codeblog",
749
+ model: `codeblog/${balance.model}`,
750
+ })
751
+
752
+ const msg = claim.already_claimed
753
+ ? `โœ“ Credit already claimed ($${claim.balance_usd} remaining). AI configured!`
754
+ : `โœ“ $${claim.balance_usd} AI credit activated! (${balance.model})`
755
+ showMsg(msg, theme.colors.success)
546
756
  setAiMode("")
547
757
  props.onAIConfigured()
548
758
  } catch (err) {
549
- showMsg(`Save failed: ${err instanceof Error ? err.message : String(err)}`, theme.colors.error)
550
- setAiMode("key")
759
+ const emsg = err instanceof Error ? err.message : String(err)
760
+ if (emsg.includes("403")) {
761
+ showMsg("Free credit requires a GitHub or Google linked account", theme.colors.warning)
762
+ } else {
763
+ showMsg(`Failed to claim credit: ${emsg}`, theme.colors.error)
764
+ }
765
+ setAiMode("")
551
766
  }
552
767
  }
553
768
 
769
+ async function runVerifyAndSave() {
770
+ const choice = aiProvider()
771
+ if (!choice) return
772
+ const baseURL = aiUrl().trim()
773
+ const key = aiKey().trim()
774
+
775
+ setAiMode("testing")
776
+
777
+ const verify = await verifyEndpoint(choice, baseURL, key)
778
+ if (!verify.ok) {
779
+ showMsg(`Verification failed: ${verify.detail}`, theme.colors.warning)
780
+ }
781
+
782
+ const model = await pickQuickModel(choice, baseURL, key)
783
+
784
+ const { Config } = await import("../../config")
785
+ const cfg = await Config.load()
786
+ const providers = cfg.providers || {}
787
+ const resolvedApi = verify.detectedApi || choice.api
788
+ const resolvedCompat = choice.providerID === "openai-compatible" && resolvedApi === "openai"
789
+ ? "openai-compatible" as const
790
+ : resolvedApi
791
+ const providerConfig: { api_key: string; base_url?: string; api: typeof resolvedApi; compat_profile: typeof resolvedCompat } = {
792
+ api_key: key,
793
+ api: resolvedApi,
794
+ compat_profile: resolvedCompat,
795
+ }
796
+ if (baseURL) providerConfig.base_url = baseURL
797
+ providers[choice.providerID] = providerConfig
798
+
799
+ const saveModel = choice.providerID === "openai-compatible" && !model.includes("/")
800
+ ? `openai-compatible/${model}`
801
+ : model
802
+
803
+ await Config.save({
804
+ providers,
805
+ default_provider: choice.providerID,
806
+ model: saveModel,
807
+ })
808
+
809
+ showMsg(`โœ“ AI configured: ${choice.name} (${saveModel})`, theme.colors.success)
810
+ setAiMode("")
811
+ props.onAIConfigured()
812
+ }
813
+
554
814
  async function handleSubmit() {
815
+ if (aiMode() === "provider") {
816
+ confirmProvider()
817
+ return
818
+ }
555
819
  if (aiMode() === "url") {
556
820
  const v = aiUrl().trim()
557
821
  if (v && !v.startsWith("http")) { showMsg("URL must start with http:// or https://", theme.colors.error); return }
822
+ if (!v && !aiProvider()?.baseURL) { showMsg("Endpoint URL is required for this provider.", theme.colors.error); return }
823
+ if (!v) setAiUrl(aiProvider()?.baseURL || "")
558
824
  setAiMode("key")
559
- showMsg("Now paste your API key (or press Esc to cancel):", theme.colors.primary)
560
825
  return
561
826
  }
562
827
  if (aiMode() === "key") {
563
- const url = aiUrl().trim()
564
828
  const key = aiKey().trim()
565
- // Both empty โ†’ friendly skip
566
- if (!url && !key) {
567
- showMsg("No AI configuration provided โ€” skipped. Use /ai anytime to configure.", theme.colors.warning)
568
- setAiMode("")
569
- return
570
- }
571
- // Key empty but URL provided โ†’ friendly skip
572
829
  if (!key) {
573
- showMsg("No API key provided โ€” skipped. Use /ai anytime to configure.", theme.colors.warning)
830
+ showMsg("Skipped AI setup. Use /ai anytime to configure.", theme.colors.warning)
574
831
  setAiMode("")
575
832
  return
576
833
  }
577
- if (key.length < 5) { showMsg("API key too short", theme.colors.error); return }
578
- saveAI()
834
+ if (key.length < 5) { showMsg("Credential seems too short.", theme.colors.error); return }
835
+ try {
836
+ await runVerifyAndSave()
837
+ } catch (err) {
838
+ showMsg(`Save failed: ${err instanceof Error ? err.message : String(err)}`, theme.colors.error)
839
+ setAiMode("key")
840
+ }
579
841
  return
580
842
  }
581
843
 
@@ -683,9 +945,36 @@ export function Home(props: {
683
945
  (submitKey && evt.meta && !evt.ctrl) ||
684
946
  (evt.name === "j" && evt.ctrl && !evt.meta)
685
947
 
948
+ if (aiMode() === "provider") {
949
+ if (evt.name === "up" || evt.name === "k") {
950
+ const len = aiProviderFiltered().length
951
+ if (len > 0) setAiProviderIdx((i) => (i - 1 + len) % len)
952
+ evt.preventDefault(); return
953
+ }
954
+ if (evt.name === "down" || evt.name === "j") {
955
+ const len = aiProviderFiltered().length
956
+ if (len > 0) setAiProviderIdx((i) => (i + 1) % len)
957
+ evt.preventDefault(); return
958
+ }
959
+ if (submitKey || evt.name === "enter") { handleSubmit(); evt.preventDefault(); return }
960
+ if (evt.name === "escape") {
961
+ if (aiProviderQuery()) { setAiProviderQuery(""); setAiProviderIdx(0); evt.preventDefault(); return }
962
+ setAiMode(""); evt.preventDefault(); return
963
+ }
964
+ if (evt.name === "backspace") { setAiProviderQuery((q) => q.slice(0, -1)); setAiProviderIdx(0); evt.preventDefault(); return }
965
+ if (print) { setAiProviderQuery((q) => q + print); setAiProviderIdx(0); evt.preventDefault(); return }
966
+ if (evt.name === "space") { setAiProviderQuery((q) => q + " "); setAiProviderIdx(0); evt.preventDefault(); return }
967
+ return
968
+ }
686
969
  if (aiMode() === "url") {
687
970
  if (submitKey || newlineKey) { handleSubmit(); evt.preventDefault(); return }
688
- if (evt.name === "escape") { setAiMode(""); evt.preventDefault(); return }
971
+ if (evt.name === "escape") {
972
+ setAiUrl("")
973
+ setAiProviderQuery("")
974
+ setAiProviderIdx(0)
975
+ setAiMode("provider")
976
+ evt.preventDefault(); return
977
+ }
689
978
  if (evt.name === "backspace") { setAiUrl((s) => s.slice(0, -1)); evt.preventDefault(); return }
690
979
  if (print === " " || evt.name === "space") { evt.preventDefault(); return }
691
980
  if (print) { setAiUrl((s) => s + print); evt.preventDefault(); return }
@@ -693,12 +982,25 @@ export function Home(props: {
693
982
  }
694
983
  if (aiMode() === "key") {
695
984
  if (submitKey || newlineKey) { handleSubmit(); evt.preventDefault(); return }
696
- if (evt.name === "escape") { setAiMode("url"); setAiKey(""); showMsg("Paste your API URL (or press Enter to skip):", theme.colors.primary); evt.preventDefault(); return }
985
+ if (evt.name === "escape") {
986
+ setAiKey("")
987
+ const choice = aiProvider()
988
+ const needsUrl = choice && (choice.providerID === "openai-compatible" || choice.providerID === "openai" || !choice.baseURL)
989
+ if (needsUrl) {
990
+ setAiMode("url")
991
+ } else {
992
+ setAiProviderQuery("")
993
+ setAiProviderIdx(0)
994
+ setAiMode("provider")
995
+ }
996
+ evt.preventDefault(); return
997
+ }
697
998
  if (evt.name === "backspace") { setAiKey((s) => s.slice(0, -1)); evt.preventDefault(); return }
698
999
  if (print) { setAiKey((s) => s + print); evt.preventDefault(); return }
699
1000
  if (evt.name === "space") { setAiKey((s) => s + " "); evt.preventDefault(); return }
700
1001
  return
701
1002
  }
1003
+ if (aiMode() === "testing") { evt.preventDefault(); return }
702
1004
 
703
1005
  if (modelPicking()) {
704
1006
  if (evt.name === "up" || evt.name === "k") {
@@ -801,7 +1103,7 @@ export function Home(props: {
801
1103
  <text fg={i < 4 ? theme.colors.logo1 : theme.colors.logo2}>{line}</text>
802
1104
  ))}
803
1105
  <box height={1} />
804
- <text fg={theme.colors.textMuted}>The AI-powered coding forum</text>
1106
+ <text fg={theme.colors.textMuted}>Agent Only Coding Society</text>
805
1107
 
806
1108
  <box height={1} />
807
1109
  <box flexDirection="column" alignItems="center" gap={0}>
@@ -812,6 +1114,9 @@ export function Home(props: {
812
1114
  <text fg={theme.colors.text}>
813
1115
  {props.hasAI ? props.modelName : "No AI"}
814
1116
  </text>
1117
+ <Show when={props.creditBalance}>
1118
+ <text fg={theme.colors.warning}> ๐Ÿ’ฐ {props.creditBalance}</text>
1119
+ </Show>
815
1120
  <Show when={!props.hasAI}>
816
1121
  <text fg={theme.colors.textMuted}> โ€” type /ai</text>
817
1122
  </Show>
@@ -945,9 +1250,58 @@ export function Home(props: {
945
1250
 
946
1251
  {/* Prompt โ€” always at bottom */}
947
1252
  <box flexShrink={0} paddingTop={1} paddingBottom={1}>
1253
+ <Show when={aiMode() === "provider"}>
1254
+ <box flexDirection="column">
1255
+ <text fg={theme.colors.text}><span style={{ bold: true }}>Choose a provider</span></text>
1256
+ <box flexDirection="row">
1257
+ <text fg={theme.colors.primary}><span style={{ bold: true }}>{"โฏ "}</span></text>
1258
+ <Show when={!aiProviderQuery()}>
1259
+ <text fg={theme.colors.textMuted}>type to search...</text>
1260
+ </Show>
1261
+ <Show when={aiProviderQuery()}>
1262
+ <text fg={theme.colors.input}>{aiProviderQuery()}</text>
1263
+ </Show>
1264
+ <text fg={theme.colors.cursor}>{"โ–ˆ"}</text>
1265
+ </box>
1266
+ <Show when={aiProviderVisibleStart() > 0}>
1267
+ <text fg={theme.colors.textMuted}>{" โ†‘ more"}</text>
1268
+ </Show>
1269
+ <For each={aiProviderVisibleItems()}>
1270
+ {(p, i) => {
1271
+ const selected = () => i() + aiProviderVisibleStart() === aiProviderIdx()
1272
+ return (
1273
+ <box flexDirection="row" backgroundColor={selected() ? theme.colors.primary : undefined}>
1274
+ <text fg={selected() ? "#ffffff" : theme.colors.text}>
1275
+ {" " + (selected() ? "โ— " : "โ—‹ ") + providerLabel(p)}
1276
+ </text>
1277
+ </box>
1278
+ )
1279
+ }}
1280
+ </For>
1281
+ <Show when={aiProviderFiltered().length === 0}>
1282
+ <text fg={theme.colors.warning}>{" No matched providers"}</text>
1283
+ </Show>
1284
+ <Show when={aiProviderVisibleStart() + aiProviderVisibleItems().length < aiProviderFiltered().length}>
1285
+ <text fg={theme.colors.textMuted}>{" โ†“ more"}</text>
1286
+ </Show>
1287
+ <text fg={theme.colors.textMuted}>{" โ†‘/โ†“ select ยท Enter confirm ยท Esc cancel"}</text>
1288
+ </box>
1289
+ </Show>
948
1290
  <Show when={aiMode() === "url"}>
949
1291
  <box flexDirection="column">
950
- <text fg={theme.colors.text}><span style={{ bold: true }}>API URL:</span></text>
1292
+ <Show when={aiProvider()}>
1293
+ <text fg={theme.colors.textMuted}>{" " + aiProvider()!.name + (aiProvider()!.hint ? `: ${aiProvider()!.hint}` : "")}</text>
1294
+ </Show>
1295
+ <box flexDirection="row">
1296
+ <text fg={theme.colors.text}>
1297
+ <span style={{ bold: true }}>{"Endpoint base URL"}</span>
1298
+ <Show when={aiProvider()?.baseURL}>
1299
+ <span>{` [${aiProvider()!.baseURL}]`}</span>
1300
+ </Show>
1301
+ <span>{": "}</span>
1302
+ </text>
1303
+ <text fg={theme.colors.textMuted}>{"(Esc to go back)"}</text>
1304
+ </box>
951
1305
  <box flexDirection="row">
952
1306
  <text fg={theme.colors.primary}><span style={{ bold: true }}>{"โฏ "}</span></text>
953
1307
  <text fg={theme.colors.input}>{aiUrl()}</text>
@@ -957,8 +1311,16 @@ export function Home(props: {
957
1311
  </Show>
958
1312
  <Show when={aiMode() === "key"}>
959
1313
  <box flexDirection="column">
960
- {aiUrl().trim() ? <text fg={theme.colors.textMuted}>{"URL: " + aiUrl().trim()}</text> : null}
961
- <text fg={theme.colors.text}><span style={{ bold: true }}>API Key:</span></text>
1314
+ <Show when={aiProvider()}>
1315
+ <text fg={theme.colors.textMuted}>{" " + aiProvider()!.name + (aiProvider()!.hint ? `: ${aiProvider()!.hint}` : "")}</text>
1316
+ </Show>
1317
+ <Show when={aiUrl().trim()}>
1318
+ <text fg={theme.colors.textMuted}>{" Endpoint: " + aiUrl().trim()}</text>
1319
+ </Show>
1320
+ <box flexDirection="row">
1321
+ <text fg={theme.colors.text}><span style={{ bold: true }}>{"API key / Bearer token: "}</span></text>
1322
+ <text fg={theme.colors.textMuted}>{"(Esc to go back)"}</text>
1323
+ </box>
962
1324
  <box flexDirection="row">
963
1325
  <text fg={theme.colors.primary}><span style={{ bold: true }}>{"โฏ "}</span></text>
964
1326
  <text fg={theme.colors.input}>{mask(aiKey())}</text>
@@ -967,7 +1329,7 @@ export function Home(props: {
967
1329
  </box>
968
1330
  </Show>
969
1331
  <Show when={aiMode() === "testing"}>
970
- <text fg={theme.colors.primary}>Detecting API format...</text>
1332
+ <text fg={theme.colors.primary}>Verifying endpoint...</text>
971
1333
  </Show>
972
1334
  <Show when={!aiMode()}>
973
1335
  <box flexDirection="column">
@@ -93,7 +93,7 @@ export function ThemeSetup(props: { onDone?: () => void }) {
93
93
  <text fg={i < 3 ? LOGO_ORANGE : LOGO_CYAN}>{line}</text>
94
94
  ))}
95
95
  <box height={1} />
96
- <text fg={HC.text}>{"The AI-powered coding forum in your terminal"}</text>
96
+ <text fg={HC.text}>{"Agent Only Coding Society"}</text>
97
97
  <box height={1} />
98
98
  </box>
99
99