codeblog-app 0.2.0 → 0.4.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.
@@ -0,0 +1,171 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { AIChat } from "../../ai/chat"
3
+ import { AIProvider } from "../../ai/provider"
4
+ import { UI } from "../ui"
5
+ import readline from "readline"
6
+
7
+ export const ChatCommand: CommandModule = {
8
+ command: "chat",
9
+ aliases: ["c"],
10
+ describe: "Interactive AI chat — write posts, analyze code, browse the forum",
11
+ builder: (yargs) =>
12
+ yargs
13
+ .option("model", {
14
+ alias: "m",
15
+ describe: "Model to use (e.g. claude-sonnet-4-20250514, gpt-4o)",
16
+ type: "string",
17
+ })
18
+ .option("prompt", {
19
+ alias: "p",
20
+ describe: "Single prompt (non-interactive mode)",
21
+ type: "string",
22
+ }),
23
+ handler: async (args) => {
24
+ const modelID = args.model as string | undefined
25
+
26
+ // Non-interactive: single prompt
27
+ if (args.prompt) {
28
+ try {
29
+ await AIChat.stream(
30
+ [{ role: "user", content: args.prompt as string }],
31
+ {
32
+ onToken: (token) => process.stdout.write(token),
33
+ onFinish: () => process.stdout.write("\n"),
34
+ onError: (err) => UI.error(err.message),
35
+ },
36
+ modelID,
37
+ )
38
+ } catch (err) {
39
+ UI.error(err instanceof Error ? err.message : String(err))
40
+ process.exitCode = 1
41
+ }
42
+ return
43
+ }
44
+
45
+ // Interactive REPL
46
+ const modelInfo = AIProvider.BUILTIN_MODELS[modelID || AIProvider.DEFAULT_MODEL]
47
+ const modelName = modelInfo?.name || modelID || AIProvider.DEFAULT_MODEL
48
+
49
+ console.log("")
50
+ console.log(` ${UI.Style.TEXT_HIGHLIGHT_BOLD}CodeBlog AI Chat${UI.Style.TEXT_NORMAL}`)
51
+ console.log(` ${UI.Style.TEXT_DIM}Model: ${modelName}${UI.Style.TEXT_NORMAL}`)
52
+ console.log(` ${UI.Style.TEXT_DIM}Type your message. Commands: /help /model /clear /exit${UI.Style.TEXT_NORMAL}`)
53
+ console.log("")
54
+
55
+ const messages: AIChat.Message[] = []
56
+ const rl = readline.createInterface({
57
+ input: process.stdin,
58
+ output: process.stdout,
59
+ prompt: `${UI.Style.TEXT_HIGHLIGHT}❯ ${UI.Style.TEXT_NORMAL}`,
60
+ })
61
+
62
+ let currentModel = modelID
63
+
64
+ rl.prompt()
65
+
66
+ rl.on("line", async (line) => {
67
+ const input = line.trim()
68
+ if (!input) {
69
+ rl.prompt()
70
+ return
71
+ }
72
+
73
+ // Handle commands
74
+ if (input.startsWith("/")) {
75
+ const cmd = input.split(" ")[0]
76
+ const rest = input.slice(cmd.length).trim()
77
+
78
+ if (cmd === "/exit" || cmd === "/quit" || cmd === "/q") {
79
+ console.log("")
80
+ UI.info("Bye!")
81
+ rl.close()
82
+ return
83
+ }
84
+
85
+ if (cmd === "/clear") {
86
+ messages.length = 0
87
+ console.log(` ${UI.Style.TEXT_DIM}Chat history cleared${UI.Style.TEXT_NORMAL}`)
88
+ rl.prompt()
89
+ return
90
+ }
91
+
92
+ if (cmd === "/model") {
93
+ if (rest) {
94
+ currentModel = rest
95
+ console.log(` ${UI.Style.TEXT_SUCCESS}Model: ${rest}${UI.Style.TEXT_NORMAL}`)
96
+ } else {
97
+ const current = AIProvider.BUILTIN_MODELS[currentModel || AIProvider.DEFAULT_MODEL]
98
+ console.log(` ${UI.Style.TEXT_DIM}Current: ${current?.name || currentModel || AIProvider.DEFAULT_MODEL}${UI.Style.TEXT_NORMAL}`)
99
+ console.log(` ${UI.Style.TEXT_DIM}Built-in: ${Object.keys(AIProvider.BUILTIN_MODELS).join(", ")}${UI.Style.TEXT_NORMAL}`)
100
+ console.log(` ${UI.Style.TEXT_DIM}Any model from models.dev works too (e.g. anthropic/claude-sonnet-4-20250514)${UI.Style.TEXT_NORMAL}`)
101
+ }
102
+ rl.prompt()
103
+ return
104
+ }
105
+
106
+ if (cmd === "/help") {
107
+ console.log("")
108
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Commands${UI.Style.TEXT_NORMAL}`)
109
+ console.log(` ${UI.Style.TEXT_DIM}/model [id]${UI.Style.TEXT_NORMAL} Switch or show model`)
110
+ console.log(` ${UI.Style.TEXT_DIM}/clear${UI.Style.TEXT_NORMAL} Clear chat history`)
111
+ console.log(` ${UI.Style.TEXT_DIM}/exit${UI.Style.TEXT_NORMAL} Exit chat`)
112
+ console.log("")
113
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Tips${UI.Style.TEXT_NORMAL}`)
114
+ console.log(` ${UI.Style.TEXT_DIM}Ask me to write a blog post, analyze code, draft comments,${UI.Style.TEXT_NORMAL}`)
115
+ console.log(` ${UI.Style.TEXT_DIM}summarize discussions, or generate tags and titles.${UI.Style.TEXT_NORMAL}`)
116
+ console.log("")
117
+ rl.prompt()
118
+ return
119
+ }
120
+
121
+ console.log(` ${UI.Style.TEXT_DIM}Unknown command: ${cmd}. Type /help${UI.Style.TEXT_NORMAL}`)
122
+ rl.prompt()
123
+ return
124
+ }
125
+
126
+ // Send message to AI
127
+ messages.push({ role: "user", content: input })
128
+
129
+ console.log("")
130
+ process.stdout.write(` ${UI.Style.TEXT_INFO}`)
131
+
132
+ try {
133
+ let response = ""
134
+ await AIChat.stream(
135
+ messages,
136
+ {
137
+ onToken: (token) => {
138
+ process.stdout.write(token)
139
+ response += token
140
+ },
141
+ onFinish: () => {
142
+ process.stdout.write(UI.Style.TEXT_NORMAL)
143
+ console.log("")
144
+ console.log("")
145
+ },
146
+ onError: (err) => {
147
+ process.stdout.write(UI.Style.TEXT_NORMAL)
148
+ console.log("")
149
+ UI.error(err.message)
150
+ },
151
+ },
152
+ currentModel,
153
+ )
154
+ messages.push({ role: "assistant", content: response })
155
+ } catch (err) {
156
+ process.stdout.write(UI.Style.TEXT_NORMAL)
157
+ console.log("")
158
+ UI.error(err instanceof Error ? err.message : String(err))
159
+ }
160
+
161
+ rl.prompt()
162
+ })
163
+
164
+ rl.on("close", () => {
165
+ process.exit(0)
166
+ })
167
+
168
+ // Keep process alive
169
+ await new Promise(() => {})
170
+ },
171
+ }
@@ -0,0 +1,103 @@
1
+ import type { CommandModule } from "yargs"
2
+ import { Config } from "../../config"
3
+ import { AIProvider } from "../../ai/provider"
4
+ import { UI } from "../ui"
5
+
6
+ export const ConfigCommand: CommandModule = {
7
+ command: "config",
8
+ describe: "Configure AI provider and model settings",
9
+ builder: (yargs) =>
10
+ yargs
11
+ .option("provider", {
12
+ describe: "AI provider: anthropic, openai, google",
13
+ type: "string",
14
+ })
15
+ .option("api-key", {
16
+ describe: "API key for the provider",
17
+ type: "string",
18
+ })
19
+ .option("model", {
20
+ describe: "Default model ID",
21
+ type: "string",
22
+ })
23
+ .option("list", {
24
+ describe: "List available models and their status",
25
+ type: "boolean",
26
+ default: false,
27
+ }),
28
+ handler: async (args) => {
29
+ try {
30
+ if (args.list) {
31
+ const models = await AIProvider.available()
32
+ const providers = await AIProvider.listProviders()
33
+
34
+ console.log("")
35
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Providers${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(${Object.keys(providers).length} from models.dev)${UI.Style.TEXT_NORMAL}`)
36
+ console.log("")
37
+
38
+ const configured = Object.entries(providers).filter(([, p]) => p.hasKey)
39
+ const unconfigured = Object.entries(providers).filter(([, p]) => !p.hasKey)
40
+
41
+ if (configured.length > 0) {
42
+ console.log(` ${UI.Style.TEXT_SUCCESS}Configured:${UI.Style.TEXT_NORMAL}`)
43
+ for (const [id, p] of configured) {
44
+ console.log(` ${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_NORMAL_BOLD}${p.name}${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(${p.models.length} models)${UI.Style.TEXT_NORMAL}`)
45
+ }
46
+ console.log("")
47
+ }
48
+
49
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Built-in Models${UI.Style.TEXT_NORMAL}`)
50
+ console.log("")
51
+ for (const { model, hasKey } of models) {
52
+ const status = hasKey ? `${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL}` : `${UI.Style.TEXT_DIM}✗${UI.Style.TEXT_NORMAL}`
53
+ console.log(` ${status} ${UI.Style.TEXT_NORMAL_BOLD}${model.name}${UI.Style.TEXT_NORMAL} ${UI.Style.TEXT_DIM}(${model.id})${UI.Style.TEXT_NORMAL}`)
54
+ console.log(` ${UI.Style.TEXT_DIM}${model.providerID} · ${(model.contextWindow / 1000).toFixed(0)}k context${UI.Style.TEXT_NORMAL}`)
55
+ }
56
+ console.log("")
57
+ console.log(` ${UI.Style.TEXT_DIM}✓ = API key configured, ✗ = needs key${UI.Style.TEXT_NORMAL}`)
58
+ console.log(` ${UI.Style.TEXT_DIM}Set key: codeblog config --provider anthropic --api-key sk-...${UI.Style.TEXT_NORMAL}`)
59
+ console.log(` ${UI.Style.TEXT_DIM}Any model from models.dev can be used with provider/model format${UI.Style.TEXT_NORMAL}`)
60
+ console.log("")
61
+ return
62
+ }
63
+
64
+ if (args.provider && args.apiKey) {
65
+ const provider = args.provider as string
66
+ const cfg = await Config.load() as Record<string, unknown>
67
+ const providers = (cfg.providers || {}) as Record<string, Record<string, string>>
68
+ providers[provider] = { ...providers[provider], api_key: args.apiKey as string }
69
+ await Config.save({ ...cfg, providers } as unknown as Config.CodeblogConfig)
70
+ UI.success(`${provider} API key saved`)
71
+ return
72
+ }
73
+
74
+ if (args.model) {
75
+ const model = args.model as string
76
+ const cfg = await Config.load() as Record<string, unknown>
77
+ await Config.save({ ...cfg, model } as unknown as Config.CodeblogConfig)
78
+ UI.success(`Default model set to ${model}`)
79
+ return
80
+ }
81
+
82
+ // Show current config
83
+ const cfg = await Config.load() as Record<string, unknown>
84
+ const model = (cfg.model as string) || AIProvider.DEFAULT_MODEL
85
+ const providers = (cfg.providers || {}) as Record<string, Record<string, string>>
86
+
87
+ console.log("")
88
+ console.log(` ${UI.Style.TEXT_NORMAL_BOLD}Current Config${UI.Style.TEXT_NORMAL}`)
89
+ console.log("")
90
+ console.log(` Model: ${UI.Style.TEXT_HIGHLIGHT}${model}${UI.Style.TEXT_NORMAL}`)
91
+ console.log(` API URL: ${cfg.api_url || "https://codeblog.ai"}`)
92
+ console.log("")
93
+ for (const [id, p] of Object.entries(providers)) {
94
+ const masked = p.api_key ? p.api_key.slice(0, 8) + "..." : "not set"
95
+ console.log(` ${id}: ${UI.Style.TEXT_DIM}${masked}${UI.Style.TEXT_NORMAL}`)
96
+ }
97
+ console.log("")
98
+ } catch (err) {
99
+ UI.error(`Config failed: ${err instanceof Error ? err.message : String(err)}`)
100
+ process.exitCode = 1
101
+ }
102
+ },
103
+ }
@@ -0,0 +1,20 @@
1
+ import type { CommandModule } from "yargs"
2
+
3
+ export const TuiCommand: CommandModule = {
4
+ command: "tui",
5
+ aliases: ["ui"],
6
+ describe: "Launch interactive TUI — browse feed, chat with AI, manage posts",
7
+ builder: (yargs) =>
8
+ yargs
9
+ .option("model", {
10
+ alias: "m",
11
+ describe: "Default AI model",
12
+ type: "string",
13
+ }),
14
+ handler: async (args) => {
15
+ const { tui } = await import("../../tui/app")
16
+ await tui({
17
+ onExit: async () => {},
18
+ })
19
+ },
20
+ }
package/src/index.ts CHANGED
@@ -27,8 +27,12 @@ import { FollowCommand } from "./cli/cmd/follow"
27
27
  import { MyPostsCommand } from "./cli/cmd/myposts"
28
28
  import { EditCommand } from "./cli/cmd/edit"
29
29
  import { DeleteCommand } from "./cli/cmd/delete"
30
+ import { ChatCommand } from "./cli/cmd/chat"
31
+ import { ConfigCommand } from "./cli/cmd/config"
32
+ import { AIPublishCommand } from "./cli/cmd/ai-publish"
33
+ import { TuiCommand } from "./cli/cmd/tui"
30
34
 
31
- const VERSION = "0.2.0"
35
+ const VERSION = "0.4.0"
32
36
 
33
37
  process.on("unhandledRejection", (e) => {
34
38
  Log.Default.error("rejection", {
@@ -93,6 +97,12 @@ const cli = yargs(hideBin(process.argv))
93
97
  // Scan & Publish
94
98
  .command(ScanCommand)
95
99
  .command(PublishCommand)
100
+ .command(AIPublishCommand)
101
+ // AI
102
+ .command(ChatCommand)
103
+ .command(ConfigCommand)
104
+ // TUI
105
+ .command(TuiCommand)
96
106
  // Account
97
107
  .command(NotificationsCommand)
98
108
  .command(DashboardCommand)
@@ -0,0 +1,109 @@
1
+ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
2
+ import { Switch, Match, onMount } from "solid-js"
3
+ import { RouteProvider, useRoute } from "./context/route"
4
+ import { ExitProvider, useExit } from "./context/exit"
5
+ import { Home } from "./routes/home"
6
+ import { Chat } from "./routes/chat"
7
+ import { Trending } from "./routes/trending"
8
+ import { Search } from "./routes/search"
9
+
10
+ export function tui(input: { onExit?: () => Promise<void> }) {
11
+ return new Promise<void>(async (resolve) => {
12
+ render(
13
+ () => (
14
+ <ExitProvider onExit={async () => { await input.onExit?.(); resolve() }}>
15
+ <RouteProvider>
16
+ <App />
17
+ </RouteProvider>
18
+ </ExitProvider>
19
+ ),
20
+ {
21
+ targetFps: 30,
22
+ exitOnCtrlC: false,
23
+ autoFocus: false,
24
+ openConsoleOnError: false,
25
+ },
26
+ )
27
+ })
28
+ }
29
+
30
+ function App() {
31
+ const route = useRoute()
32
+ const exit = useExit()
33
+ const dimensions = useTerminalDimensions()
34
+ const renderer = useRenderer()
35
+
36
+ onMount(() => {
37
+ renderer.setTerminalTitle("CodeBlog")
38
+ })
39
+
40
+ useKeyboard((evt) => {
41
+ if (evt.ctrl && evt.name === "c") {
42
+ exit()
43
+ evt.preventDefault()
44
+ return
45
+ }
46
+
47
+ if (evt.name === "q" && !evt.ctrl && route.data.type === "home") {
48
+ exit()
49
+ evt.preventDefault()
50
+ return
51
+ }
52
+
53
+ if (evt.name === "c" && route.data.type === "home") {
54
+ route.navigate({ type: "chat" })
55
+ evt.preventDefault()
56
+ return
57
+ }
58
+
59
+ if (evt.name === "t" && route.data.type === "home") {
60
+ route.navigate({ type: "search", query: "" })
61
+ // reuse search route as trending for now
62
+ route.navigate({ type: "search", query: "__trending__" })
63
+ evt.preventDefault()
64
+ return
65
+ }
66
+
67
+ if (evt.name === "s" && route.data.type === "home") {
68
+ route.navigate({ type: "search", query: "" })
69
+ evt.preventDefault()
70
+ return
71
+ }
72
+
73
+ if (evt.name === "escape" && route.data.type !== "home") {
74
+ route.navigate({ type: "home" })
75
+ evt.preventDefault()
76
+ return
77
+ }
78
+ })
79
+
80
+ return (
81
+ <box flexDirection="column" width="100%" height="100%">
82
+ <Switch>
83
+ <Match when={route.data.type === "home"}>
84
+ <Home />
85
+ </Match>
86
+ <Match when={route.data.type === "chat"}>
87
+ <Chat />
88
+ </Match>
89
+ <Match when={route.data.type === "search" && (route.data as any).query === "__trending__"}>
90
+ <Trending />
91
+ </Match>
92
+ <Match when={route.data.type === "search"}>
93
+ <Search />
94
+ </Match>
95
+ </Switch>
96
+
97
+ {/* Status bar */}
98
+ <box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} flexShrink={0} flexDirection="row">
99
+ <text fg="#6a737c">
100
+ {route.data.type === "home"
101
+ ? "c:chat s:search t:trending q:quit"
102
+ : "esc:back ctrl+c:exit"}
103
+ </text>
104
+ <box flexGrow={1} />
105
+ <text fg="#6a737c">codeblog v0.4.0</text>
106
+ </box>
107
+ </box>
108
+ )
109
+ }
@@ -0,0 +1,15 @@
1
+ import { useRenderer } from "@opentui/solid"
2
+ import { createSimpleContext } from "./helper"
3
+
4
+ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
5
+ name: "Exit",
6
+ init: (input: { onExit?: () => Promise<void> }) => {
7
+ const renderer = useRenderer()
8
+ return async () => {
9
+ renderer.setTerminalTitle("")
10
+ renderer.destroy()
11
+ await input.onExit?.()
12
+ process.exit(0)
13
+ }
14
+ },
15
+ })
@@ -0,0 +1,25 @@
1
+ import { createContext, Show, useContext, type ParentProps } from "solid-js"
2
+
3
+ export function createSimpleContext<T, Props extends Record<string, any>>(input: {
4
+ name: string
5
+ init: ((input: Props) => T) | (() => T)
6
+ }) {
7
+ const ctx = createContext<T>()
8
+
9
+ return {
10
+ provider: (props: ParentProps<Props>) => {
11
+ const init = input.init(props)
12
+ return (
13
+ // @ts-expect-error
14
+ <Show when={init.ready === undefined || init.ready === true}>
15
+ <ctx.Provider value={init}>{props.children}</ctx.Provider>
16
+ </Show>
17
+ )
18
+ },
19
+ use() {
20
+ const value = useContext(ctx)
21
+ if (!value) throw new Error(`${input.name} context must be used within a context provider`)
22
+ return value
23
+ },
24
+ }
25
+ }
@@ -0,0 +1,20 @@
1
+ import { createStore } from "solid-js/store"
2
+ import { createSimpleContext } from "./helper"
3
+
4
+ export type HomeRoute = { type: "home" }
5
+ export type ChatRoute = { type: "chat"; sessionMessages?: Array<{ role: string; content: string }> }
6
+ export type PostRoute = { type: "post"; postId: string }
7
+ export type SearchRoute = { type: "search"; query: string }
8
+
9
+ export type Route = HomeRoute | ChatRoute | PostRoute | SearchRoute
10
+
11
+ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
12
+ name: "Route",
13
+ init: () => {
14
+ const [store, setStore] = createStore<Route>({ type: "home" })
15
+ return {
16
+ get data() { return store },
17
+ navigate(route: Route) { setStore(route) },
18
+ }
19
+ },
20
+ })
@@ -0,0 +1,136 @@
1
+ import { createSignal, For, Show } from "solid-js"
2
+ import { useKeyboard } from "@opentui/solid"
3
+ import { useRoute } from "../context/route"
4
+
5
+ interface Message {
6
+ role: "user" | "assistant"
7
+ content: string
8
+ }
9
+
10
+ export function Chat() {
11
+ const route = useRoute()
12
+ const [messages, setMessages] = createSignal<Message[]>([])
13
+ const [streaming, setStreaming] = createSignal(false)
14
+ const [streamText, setStreamText] = createSignal("")
15
+ const [model, setModel] = createSignal("claude-sonnet-4-20250514")
16
+ const [inputBuf, setInputBuf] = createSignal("")
17
+ const [inputMode, setInputMode] = createSignal(true)
18
+
19
+ async function send(text: string) {
20
+ if (!text.trim()) return
21
+ const userMsg: Message = { role: "user", content: text.trim() }
22
+ const prev = messages()
23
+ setMessages([...prev, userMsg])
24
+ setStreaming(true)
25
+ setStreamText("")
26
+
27
+ try {
28
+ const { AIChat } = await import("../../ai/chat")
29
+ const allMsgs = [...prev, userMsg].map((m) => ({
30
+ role: m.role as "user" | "assistant",
31
+ content: m.content,
32
+ }))
33
+
34
+ let full = ""
35
+ await AIChat.stream(
36
+ allMsgs,
37
+ {
38
+ onToken: (token) => {
39
+ full += token
40
+ setStreamText(full)
41
+ },
42
+ onFinish: (t) => {
43
+ setMessages((p) => [...p, { role: "assistant", content: t }])
44
+ setStreamText("")
45
+ setStreaming(false)
46
+ },
47
+ onError: (err) => {
48
+ setMessages((p) => [...p, { role: "assistant", content: `Error: ${err.message}` }])
49
+ setStreaming(false)
50
+ },
51
+ },
52
+ model(),
53
+ )
54
+ } catch (err) {
55
+ const msg = err instanceof Error ? err.message : String(err)
56
+ setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
57
+ setStreaming(false)
58
+ }
59
+ }
60
+
61
+ useKeyboard((evt) => {
62
+ if (!inputMode()) return
63
+
64
+ if (evt.name === "return" && !evt.shift) {
65
+ const text = inputBuf()
66
+ setInputBuf("")
67
+ send(text)
68
+ evt.preventDefault()
69
+ return
70
+ }
71
+
72
+ if (evt.name === "backspace") {
73
+ setInputBuf((s) => s.slice(0, -1))
74
+ evt.preventDefault()
75
+ return
76
+ }
77
+
78
+ if (evt.sequence && evt.sequence.length === 1 && !evt.ctrl && !evt.meta) {
79
+ setInputBuf((s) => s + evt.sequence)
80
+ evt.preventDefault()
81
+ return
82
+ }
83
+
84
+ if (evt.name === "space") {
85
+ setInputBuf((s) => s + " ")
86
+ evt.preventDefault()
87
+ return
88
+ }
89
+ })
90
+
91
+ return (
92
+ <box flexDirection="column" flexGrow={1}>
93
+ {/* Header */}
94
+ <box paddingLeft={2} paddingRight={2} paddingTop={1} flexShrink={0} flexDirection="row" gap={1}>
95
+ <text fg="#d946ef">
96
+ <span style={{ bold: true }}>AI Chat</span>
97
+ </text>
98
+ <text fg="#6a737c">{model()}</text>
99
+ <box flexGrow={1} />
100
+ <text fg="#6a737c">esc:back</text>
101
+ </box>
102
+
103
+ {/* Messages */}
104
+ <box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
105
+ <For each={messages()}>
106
+ {(msg) => (
107
+ <box flexDirection="row" paddingBottom={1}>
108
+ <text fg={msg.role === "user" ? "#0074cc" : "#48a868"}>
109
+ <span style={{ bold: true }}>{msg.role === "user" ? "❯ " : "◆ "}</span>
110
+ </text>
111
+ <text fg="#e7e9eb">{msg.content}</text>
112
+ </box>
113
+ )}
114
+ </For>
115
+
116
+ <Show when={streaming()}>
117
+ <box flexDirection="row" paddingBottom={1}>
118
+ <text fg="#48a868">
119
+ <span style={{ bold: true }}>{"◆ "}</span>
120
+ </text>
121
+ <text fg="#a0a0a0">{streamText() || "thinking..."}</text>
122
+ </box>
123
+ </Show>
124
+ </box>
125
+
126
+ {/* Input */}
127
+ <box paddingLeft={2} paddingRight={2} paddingBottom={1} flexShrink={0} flexDirection="row">
128
+ <text fg="#0074cc">
129
+ <span style={{ bold: true }}>{"❯ "}</span>
130
+ </text>
131
+ <text fg="#e7e9eb">{inputBuf()}</text>
132
+ <text fg="#6a737c">{"█"}</text>
133
+ </box>
134
+ </box>
135
+ )
136
+ }