@cdoing/cli 0.1.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.
Files changed (118) hide show
  1. package/.cdoing/permissions.json +8 -0
  2. package/dist/callbacks.d.ts +17 -0
  3. package/dist/callbacks.d.ts.map +1 -0
  4. package/dist/callbacks.js +265 -0
  5. package/dist/callbacks.js.map +1 -0
  6. package/dist/chat.d.ts +27 -0
  7. package/dist/chat.d.ts.map +1 -0
  8. package/dist/chat.js +57 -0
  9. package/dist/chat.js.map +1 -0
  10. package/dist/commands.d.ts +22 -0
  11. package/dist/commands.d.ts.map +1 -0
  12. package/dist/commands.js +452 -0
  13. package/dist/commands.js.map +1 -0
  14. package/dist/config.d.ts +84 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +427 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/help.d.ts +9 -0
  19. package/dist/help.d.ts.map +1 -0
  20. package/dist/help.js +167 -0
  21. package/dist/help.js.map +1 -0
  22. package/dist/history.d.ts +51 -0
  23. package/dist/history.d.ts.map +1 -0
  24. package/dist/history.js +207 -0
  25. package/dist/history.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +220 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/oauth.d.ts +13 -0
  31. package/dist/oauth.d.ts.map +1 -0
  32. package/dist/oauth.js +182 -0
  33. package/dist/oauth.js.map +1 -0
  34. package/dist/review.d.ts +26 -0
  35. package/dist/review.d.ts.map +1 -0
  36. package/dist/review.js +198 -0
  37. package/dist/review.js.map +1 -0
  38. package/dist/serve.d.ts +23 -0
  39. package/dist/serve.d.ts.map +1 -0
  40. package/dist/serve.js +293 -0
  41. package/dist/serve.js.map +1 -0
  42. package/dist/tools.d.ts +14 -0
  43. package/dist/tools.d.ts.map +1 -0
  44. package/dist/tools.js +57 -0
  45. package/dist/tools.js.map +1 -0
  46. package/dist/ui/App.d.ts +24 -0
  47. package/dist/ui/App.d.ts.map +1 -0
  48. package/dist/ui/App.js +321 -0
  49. package/dist/ui/App.js.map +1 -0
  50. package/dist/ui/MessageList.d.ts +14 -0
  51. package/dist/ui/MessageList.d.ts.map +1 -0
  52. package/dist/ui/MessageList.js +147 -0
  53. package/dist/ui/MessageList.js.map +1 -0
  54. package/dist/ui/SessionBrowser.d.ts +18 -0
  55. package/dist/ui/SessionBrowser.d.ts.map +1 -0
  56. package/dist/ui/SessionBrowser.js +149 -0
  57. package/dist/ui/SessionBrowser.js.map +1 -0
  58. package/dist/ui/SetupWizard.d.ts +23 -0
  59. package/dist/ui/SetupWizard.d.ts.map +1 -0
  60. package/dist/ui/SetupWizard.js +402 -0
  61. package/dist/ui/SetupWizard.js.map +1 -0
  62. package/dist/ui/Spinner.d.ts +15 -0
  63. package/dist/ui/Spinner.d.ts.map +1 -0
  64. package/dist/ui/Spinner.js +111 -0
  65. package/dist/ui/Spinner.js.map +1 -0
  66. package/dist/ui/StatusBar.d.ts +16 -0
  67. package/dist/ui/StatusBar.d.ts.map +1 -0
  68. package/dist/ui/StatusBar.js +56 -0
  69. package/dist/ui/StatusBar.js.map +1 -0
  70. package/dist/ui/UserInput.d.ts +13 -0
  71. package/dist/ui/UserInput.d.ts.map +1 -0
  72. package/dist/ui/UserInput.js +872 -0
  73. package/dist/ui/UserInput.js.map +1 -0
  74. package/dist/ui/hooks/helpers.d.ts +55 -0
  75. package/dist/ui/hooks/helpers.d.ts.map +1 -0
  76. package/dist/ui/hooks/helpers.js +304 -0
  77. package/dist/ui/hooks/helpers.js.map +1 -0
  78. package/dist/ui/hooks/useAgent.d.ts +60 -0
  79. package/dist/ui/hooks/useAgent.d.ts.map +1 -0
  80. package/dist/ui/hooks/useAgent.js +213 -0
  81. package/dist/ui/hooks/useAgent.js.map +1 -0
  82. package/dist/ui/hooks/useChat.d.ts +74 -0
  83. package/dist/ui/hooks/useChat.d.ts.map +1 -0
  84. package/dist/ui/hooks/useChat.js +819 -0
  85. package/dist/ui/hooks/useChat.js.map +1 -0
  86. package/dist/ui/theme.d.ts +73 -0
  87. package/dist/ui/theme.d.ts.map +1 -0
  88. package/dist/ui/theme.js +214 -0
  89. package/dist/ui/theme.js.map +1 -0
  90. package/dist/ui/types.d.ts +37 -0
  91. package/dist/ui/types.d.ts.map +1 -0
  92. package/dist/ui/types.js +3 -0
  93. package/dist/ui/types.js.map +1 -0
  94. package/package.json +33 -0
  95. package/src/callbacks.ts +294 -0
  96. package/src/chat.ts +72 -0
  97. package/src/commands.ts +425 -0
  98. package/src/config.ts +462 -0
  99. package/src/help.ts +182 -0
  100. package/src/history.ts +205 -0
  101. package/src/index.ts +248 -0
  102. package/src/oauth.ts +164 -0
  103. package/src/review.ts +233 -0
  104. package/src/serve.ts +290 -0
  105. package/src/tools.ts +104 -0
  106. package/src/ui/App.tsx +426 -0
  107. package/src/ui/MessageList.tsx +222 -0
  108. package/src/ui/SessionBrowser.tsx +161 -0
  109. package/src/ui/SetupWizard.tsx +412 -0
  110. package/src/ui/Spinner.tsx +103 -0
  111. package/src/ui/StatusBar.tsx +106 -0
  112. package/src/ui/UserInput.tsx +954 -0
  113. package/src/ui/hooks/helpers.ts +271 -0
  114. package/src/ui/hooks/useAgent.ts +270 -0
  115. package/src/ui/hooks/useChat.ts +943 -0
  116. package/src/ui/theme.ts +326 -0
  117. package/src/ui/types.ts +41 -0
  118. package/tsconfig.json +18 -0
package/src/ui/App.tsx ADDED
@@ -0,0 +1,426 @@
1
+ /**
2
+ * App — root Ink component.
3
+ *
4
+ * Key design: past messages are printed directly to stdout (so they scroll
5
+ * naturally above the terminal), while Ink only manages the small, fixed-height
6
+ * bottom section: streaming content + tool activity + input + status bar.
7
+ *
8
+ * This prevents Ink from miscalculating its height on every keypress and
9
+ * scrolling the terminal.
10
+ */
11
+
12
+ import React, { useCallback, useEffect, useRef, useState } from "react";
13
+ import { Box, Text, Static, useApp } from "ink";
14
+ import chalk from "chalk";
15
+
16
+ import type { ModelConfig } from "@cdoing/ai";
17
+ import type {
18
+ ToolRegistry,
19
+ PermissionManager,
20
+ HookManager,
21
+ MemoryStore,
22
+ TodoStore,
23
+ } from "@cdoing/core";
24
+
25
+ import { StreamingMessage } from "./MessageList";
26
+ import { Spinner, ToolSpinner } from "./Spinner";
27
+ import { UserInput } from "./UserInput";
28
+ import { StatusBar } from "./StatusBar";
29
+ import { SessionBrowser } from "./SessionBrowser";
30
+ import { SetupWizard } from "./SetupWizard";
31
+ import { useChat } from "./hooks/useChat";
32
+ import { getTheme } from "./theme";
33
+ import type { ChatMessage } from "./types";
34
+
35
+ // ── Static message renderer ─────────────────────────────────────────────────
36
+
37
+ function renderStaticMessage(msg: ChatMessage): React.ReactElement {
38
+ const t = getTheme();
39
+ switch (msg.role) {
40
+ case "user":
41
+ return (
42
+ <Box key={msg.id} flexDirection="column">
43
+ <Text>{" "}</Text>
44
+ <Box>
45
+ <Text color={t.prompt} bold>{"❯ "}</Text>
46
+ <Text color={t.text}>{msg.content}</Text>
47
+ </Box>
48
+ </Box>
49
+ );
50
+ case "assistant":
51
+ return (
52
+ <Box key={msg.id} flexDirection="column">
53
+ <Text>{" "}</Text>
54
+ <Text>{msg.content}</Text>
55
+ <Text color={t.separator}>{"─".repeat(process.stdout.columns > 0 ? Math.min(process.stdout.columns, 60) : 40)}</Text>
56
+ </Box>
57
+ );
58
+ case "system":
59
+ return (
60
+ <Box key={msg.id}>
61
+ {msg.isError ? <Text color={t.error}>{" ❌ "}</Text> : <Text color={t.info}>{" ▸ "}</Text>}
62
+ <Text>{msg.content}</Text>
63
+ </Box>
64
+ );
65
+ case "shell":
66
+ return <Text key={msg.id}>{msg.content.trimEnd()}</Text>;
67
+ default:
68
+ return <Text key={msg.id}>{msg.content}</Text>;
69
+ }
70
+ }
71
+
72
+ // ── App component ──────────────────────────────────────────────────────────
73
+
74
+ export interface AppProps {
75
+ modelConfig: Partial<ModelConfig>;
76
+ toolRegistry: ToolRegistry;
77
+ permissionManager: PermissionManager;
78
+ hookManager: HookManager;
79
+ memoryStore: MemoryStore;
80
+ todoStore?: TodoStore;
81
+ initialPrompt?: string;
82
+ }
83
+
84
+ export const App: React.FC<AppProps> = ({
85
+ modelConfig,
86
+ toolRegistry,
87
+ permissionManager,
88
+ hookManager,
89
+ memoryStore,
90
+ todoStore,
91
+ initialPrompt,
92
+ }) => {
93
+ const { exit } = useApp();
94
+
95
+ const processingStartRef = useRef<number | null>(null);
96
+ // Track background shell processes so Ctrl+C can kill them
97
+ const bgProcessRef = useRef<import("child_process").ChildProcess | null>(null);
98
+ // Live shell command output (streams in dynamic area, flushed to Static on complete)
99
+ const [shellLive, setShellLive] = useState("");
100
+ const shellLiveRef = useRef("");
101
+ const [showSetupWizard, setShowSetupWizard] = useState(false);
102
+
103
+ const {
104
+ messages,
105
+ setMessages,
106
+ streamingContent,
107
+ isProcessing,
108
+ toolActivity,
109
+ lastUsage,
110
+ workingDir,
111
+ contextUsage,
112
+ backgroundJobs,
113
+ showSessionBrowser,
114
+ setShowSessionBrowser,
115
+ conversations,
116
+ sendMessage,
117
+ handleSlashCommand,
118
+ cancelCurrent,
119
+ addSystemMessage,
120
+ modelConfig: liveModelConfig,
121
+ } = useChat({
122
+ modelConfig,
123
+ toolRegistry,
124
+ permissionManager,
125
+ hookManager,
126
+ memoryStore,
127
+ todoStore,
128
+ });
129
+
130
+ // Track when processing starts for the elapsed timer
131
+ useEffect(() => {
132
+ if (isProcessing && processingStartRef.current === null) {
133
+ processingStartRef.current = Date.now();
134
+ } else if (!isProcessing) {
135
+ processingStartRef.current = null;
136
+ }
137
+ }, [isProcessing]);
138
+
139
+ // Clear terminal when /clear resets the messages array
140
+ const prevMsgLenRef = useRef(0);
141
+ useEffect(() => {
142
+ if (messages.length === 0 && prevMsgLenRef.current > 0) {
143
+ process.stdout.write("\x1b[2J\x1b[H");
144
+ }
145
+ prevMsgLenRef.current = messages.length;
146
+ }, [messages.length]);
147
+
148
+ // Send initial prompt on mount
149
+ useEffect(() => {
150
+ if (initialPrompt) {
151
+ sendMessage(initialPrompt);
152
+ }
153
+ // eslint-disable-next-line react-hooks/exhaustive-deps
154
+ }, []);
155
+
156
+ // Ctrl+C: kill bg process if running, otherwise double-tap to exit
157
+ const ctrlCRef = useRef(0);
158
+ useEffect(() => {
159
+ const handler = () => {
160
+ // If a background shell process is running, kill it
161
+ if (bgProcessRef.current) {
162
+ bgProcessRef.current.kill("SIGINT");
163
+ bgProcessRef.current = null;
164
+ process.stdout.write(chalk.yellow("\n[process killed]\n"));
165
+ return;
166
+ }
167
+ const now = Date.now();
168
+ if (now - ctrlCRef.current < 1000) {
169
+ exit();
170
+ process.exit(0);
171
+ }
172
+ ctrlCRef.current = now;
173
+ process.stdout.write(chalk.gray("Press Ctrl+C again to exit, or type /exit.\n"));
174
+ };
175
+ process.on("SIGINT", handler);
176
+ return () => { process.off("SIGINT", handler); };
177
+ }, [exit]);
178
+
179
+ const handleSubmit = useCallback(
180
+ async (value: string) => {
181
+ if (!value.trim()) return;
182
+
183
+ // Determine the raw shell command — either explicit `!cmd` or auto-detected
184
+ const shellCmd = value.startsWith("!")
185
+ ? value.slice(1).trim()
186
+ : detectShellCommand(value);
187
+
188
+ if (shellCmd !== null) {
189
+ // Intercept `cd` — exec can't change the parent process directory
190
+ if (shellCmd === "cd" || shellCmd.startsWith("cd ") || shellCmd.startsWith("cd\t")) {
191
+ const target = shellCmd.slice(2).trim() || process.env.HOME || "/";
192
+ const result = await handleSlashCommand(`/dir ${target}`);
193
+ if (result !== null) {
194
+ process.stdout.write(chalk.gray(`$ ${shellCmd}`) + "\n" + (result ? chalk.white(result) + "\n" : ""));
195
+ }
196
+ return;
197
+ }
198
+
199
+ // Interactive commands (vim, nano, less…) need full TTY — use spawnSync
200
+ if (isInteractiveCommand(shellCmd)) {
201
+ const { spawnSync } = require("child_process") as typeof import("child_process");
202
+ const parts = shellCmd.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [shellCmd];
203
+ const [bin, ...args] = parts;
204
+ // Let the subprocess own the terminal completely
205
+ spawnSync(bin, args, { stdio: "inherit", cwd: workingDir, env: { ...process.env } });
206
+ return;
207
+ }
208
+
209
+ // All other shell commands — stream live in dynamic area, flush to Static on done
210
+ {
211
+ const { spawn } = require("child_process") as typeof import("child_process");
212
+ addSystemMessage(`$ ${shellCmd}`);
213
+ shellLiveRef.current = "";
214
+ setShellLive("");
215
+
216
+ const child = spawn(shellCmd, [], {
217
+ shell: true,
218
+ cwd: workingDir,
219
+ env: { ...process.env },
220
+ });
221
+ bgProcessRef.current = child;
222
+
223
+ const onData = (chunk: Buffer) => {
224
+ shellLiveRef.current += chunk.toString();
225
+ setShellLive(shellLiveRef.current);
226
+ };
227
+ child.stdout?.on("data", onData);
228
+ child.stderr?.on("data", onData);
229
+
230
+ child.on("close", (code) => {
231
+ bgProcessRef.current = null;
232
+ const output = shellLiveRef.current;
233
+ shellLiveRef.current = "";
234
+ setShellLive("");
235
+ if (output.trim()) {
236
+ setMessages((prev) => [
237
+ ...prev,
238
+ { id: String(Date.now()), role: "shell" as const, content: output.trimEnd() },
239
+ ]);
240
+ }
241
+ if (code !== null && code !== 0) {
242
+ addSystemMessage(chalk.red(`[exited with code ${code}]`));
243
+ }
244
+ });
245
+ child.on("error", (err) => {
246
+ bgProcessRef.current = null;
247
+ shellLiveRef.current = "";
248
+ setShellLive("");
249
+ addSystemMessage(chalk.red(`[error: ${err.message}]`));
250
+ });
251
+ }
252
+ return;
253
+ }
254
+
255
+ if (value.startsWith("/")) {
256
+ if (value.trim() === "/setup") {
257
+ setShowSetupWizard(true);
258
+ return;
259
+ }
260
+ const result = await handleSlashCommand(value);
261
+ if (result !== null) {
262
+ addSystemMessage(result);
263
+ }
264
+ return;
265
+ }
266
+
267
+ await sendMessage(value);
268
+ },
269
+ [workingDir, handleSlashCommand, sendMessage, addSystemMessage],
270
+ );
271
+
272
+ const runningJobs = backgroundJobs.filter((j) => j.status === "running").length;
273
+
274
+ // ── Setup wizard overlay ─────────────────────────────────────────────────
275
+ if (showSetupWizard) {
276
+ return (
277
+ <Box flexDirection="column">
278
+ <SetupWizard
279
+ currentProvider={String(liveModelConfig.provider || "anthropic")}
280
+ currentModel={String(liveModelConfig.model || "")}
281
+ onDone={({ provider, model, apiKey, oauthToken }) => {
282
+ setShowSetupWizard(false);
283
+ handleSlashCommand(`/provider ${provider}`);
284
+ if (model) handleSlashCommand(`/model ${model}`);
285
+ if (apiKey) handleSlashCommand(`/config set api-key ${apiKey}`);
286
+ if (oauthToken) handleSlashCommand(`/config set oauth-token ${oauthToken}`);
287
+ const authNote = oauthToken ? "OAuth ✓" : apiKey ? "API key ✓" : "no key";
288
+ addSystemMessage(`✓ Setup saved — provider: ${provider} model: ${model || "default"} auth: ${authNote}`);
289
+ }}
290
+ onCancel={() => {
291
+ setShowSetupWizard(false);
292
+ addSystemMessage("Setup cancelled.");
293
+ }}
294
+ />
295
+ </Box>
296
+ );
297
+ }
298
+
299
+ // ── Session browser overlay ──────────────────────────────────────────────
300
+ if (showSessionBrowser) {
301
+ const convList = conversations();
302
+ return (
303
+ <Box flexDirection="column">
304
+ <SessionBrowser
305
+ conversations={convList}
306
+ onSelect={async (id) => {
307
+ setShowSessionBrowser(false);
308
+ const result = await handleSlashCommand(`/resume ${id}`);
309
+ if (result) addSystemMessage(result);
310
+ }}
311
+ onDelete={async (id) => {
312
+ await handleSlashCommand(`/delete ${id}`);
313
+ }}
314
+ onFork={async (id) => {
315
+ setShowSessionBrowser(false);
316
+ const result = await handleSlashCommand(`/fork ${id}`);
317
+ if (result) addSystemMessage(result);
318
+ }}
319
+ onClose={() => setShowSessionBrowser(false)}
320
+ />
321
+ </Box>
322
+ );
323
+ }
324
+
325
+ // Ink only renders this small fixed section — no scrolling issues
326
+ return (
327
+ <Box flexDirection="column">
328
+ {/* Static: past messages scroll permanently above the dynamic area */}
329
+ <Static items={messages}>
330
+ {(msg) => renderStaticMessage(msg)}
331
+ </Static>
332
+
333
+ {/* Live shell command output — streams here, moves to Static when done */}
334
+ {shellLive ? <Text>{shellLive.trimEnd()}</Text> : null}
335
+
336
+ {/* Animated tool activity */}
337
+ {toolActivity ? (
338
+ <ToolSpinner
339
+ name={toolActivity.name}
340
+ preview={toolActivity.preview}
341
+ status={toolActivity.status}
342
+ />
343
+ ) : null}
344
+
345
+ {/* Streaming response tokens */}
346
+ {streamingContent ? <StreamingMessage content={streamingContent} /> : null}
347
+
348
+ {/* Animated thinking spinner (shown before first token arrives) */}
349
+ {isProcessing && !streamingContent && !toolActivity ? (
350
+ <Spinner
351
+ label="Thinking…"
352
+ startTime={processingStartRef.current ?? undefined}
353
+ />
354
+ ) : null}
355
+
356
+ <UserInput
357
+ isProcessing={isProcessing}
358
+ queueLength={0}
359
+ workingDir={workingDir}
360
+ permissionMode={permissionManager.getMode()}
361
+ onSubmit={handleSubmit}
362
+ onCancel={cancelCurrent}
363
+ onModeChange={(mode) => {
364
+ const { parsePermissionMode } = require("../config") as typeof import("../config");
365
+ permissionManager.setMode(parsePermissionMode(mode) as any);
366
+ }}
367
+ />
368
+
369
+ <StatusBar
370
+ provider={String(liveModelConfig.provider || "anthropic")}
371
+ model={String(liveModelConfig.model || "")}
372
+ mode={permissionManager.getMode()}
373
+ workingDir={workingDir}
374
+ isProcessing={isProcessing}
375
+ lastUsage={lastUsage}
376
+ queueLength={0}
377
+ contextUsage={contextUsage}
378
+ backgroundJobs={runningJobs}
379
+ />
380
+ </Box>
381
+ );
382
+ };
383
+
384
+ // ── Shell command auto-detection ────────────────────────────────────────────
385
+
386
+ // Commands that run non-interactively (exec is fine)
387
+ const SHELL_COMMANDS = new Set([
388
+ "ls", "ll", "la", "pwd", "cd", "mkdir", "rmdir", "rm", "cp", "mv",
389
+ "cat", "head", "tail", "touch", "echo", "env",
390
+ "git", "npm", "yarn", "pnpm", "npx", "node", "ts-node",
391
+ "python", "python3", "pip", "pip3",
392
+ "docker", "docker-compose",
393
+ "grep", "find", "which", "whereis",
394
+ "curl", "wget",
395
+ "chmod", "chown", "ln",
396
+ "ps", "kill", "df", "du",
397
+ "open", "code",
398
+ // interactive ones below are handled separately
399
+ "vim", "vi", "nano", "less", "more", "man", "top", "htop",
400
+ ]);
401
+
402
+ // Commands that require full TTY control — spawned with stdio:'inherit'
403
+ const INTERACTIVE_COMMANDS = new Set([
404
+ "vim", "vi", "nvim", "nano", "pico",
405
+ "less", "more", "man", "info",
406
+ "top", "htop", "btop",
407
+ "ssh", "fzf", "ranger", "mc",
408
+ ]);
409
+
410
+ /** Returns the command string if input looks like a shell command, else null. */
411
+ function detectShellCommand(input: string): string | null {
412
+ const trimmed = input.trim();
413
+ const firstWord = trimmed.split(/\s+/)[0].toLowerCase();
414
+ return SHELL_COMMANDS.has(firstWord) ? trimmed : null;
415
+ }
416
+
417
+ // Dev server / watcher patterns — need a real TTY so their UI renders correctly
418
+ const SERVER_PATTERNS = /\b(run\s+(dev|start|serve|watch|preview)|nodemon|ts-node-dev|live-server|concurrently|turbo\s+dev|next\s+dev|vite|astro\s+dev|nuxt\s+dev|remix\s+dev)\b/i;
419
+
420
+ /** Returns true if this command needs full TTY (vim, nano, less, dev servers…). */
421
+ function isInteractiveCommand(cmd: string): boolean {
422
+ const firstWord = cmd.trim().split(/\s+/)[0].toLowerCase();
423
+ if (INTERACTIVE_COMMANDS.has(firstWord)) return true;
424
+ // Dev servers / watchers: need real TTY so their dashboard/colors work correctly
425
+ return SERVER_PATTERNS.test(cmd);
426
+ }
@@ -0,0 +1,222 @@
1
+ import React from "react";
2
+ import { Box, Text, Static } from "ink";
3
+ import type { ChatMessage, ToolActivity } from "./types";
4
+ import { getTheme } from "./theme";
5
+
6
+ // ── Tool icons ─────────────────────────────────────────────────────────────
7
+
8
+ const TOOL_ICONS: Record<string, string> = {
9
+ file_read: "📖",
10
+ file_write: "✏️ ",
11
+ file_edit: "🔧",
12
+ multi_edit: "🔧",
13
+ file_delete: "🗑️",
14
+ ast_edit: "🌳",
15
+ notebook_edit: "📓",
16
+ glob_search: "🔍",
17
+ grep_search: "🔎",
18
+ codebase_search: "🔎",
19
+ shell_exec: "💻",
20
+ file_run: "▶",
21
+ web_fetch: "🌐",
22
+ web_search: "🔮",
23
+ sub_agent: "🤖",
24
+ todo: "📋",
25
+ list_dir: "📁",
26
+ view_diff: "📊",
27
+ view_repo_map: "🗺️",
28
+ code_verify: "✅",
29
+ system_info: "ℹ️",
30
+ };
31
+
32
+ function toolIcon(name: string) {
33
+ return TOOL_ICONS[name] || "⚡";
34
+ }
35
+
36
+ // ── Individual message renderers ────────────────────────────────────────────
37
+
38
+ const UserMessage: React.FC<{ content: string }> = ({ content }) => {
39
+ const t = getTheme();
40
+ return (
41
+ <Box marginY={0} flexDirection="row">
42
+ <Text color={t.prompt} bold>
43
+ {"❯ "}
44
+ </Text>
45
+ <Text color={t.text}>{content}</Text>
46
+ </Box>
47
+ );
48
+ };
49
+
50
+ const AssistantMessage: React.FC<{ content: string }> = ({ content }) => {
51
+ const t = getTheme();
52
+ return (
53
+ <Box flexDirection="column" marginTop={1} paddingLeft={2}>
54
+ <RenderMarkdown text={content} />
55
+ <Box marginTop={0}>
56
+ <Text color={t.separator}>{"─".repeat(40)}</Text>
57
+ </Box>
58
+ </Box>
59
+ );
60
+ };
61
+
62
+ const SystemMessage: React.FC<{ content: string; isError?: boolean }> = ({
63
+ content,
64
+ isError,
65
+ }) => {
66
+ const t = getTheme();
67
+ return (
68
+ <Box marginY={0} paddingLeft={2}>
69
+ <Text color={isError ? t.error : t.info}>{content}</Text>
70
+ </Box>
71
+ );
72
+ };
73
+
74
+ // ── Simple inline markdown renderer ────────────────────────────────────────
75
+
76
+ const RenderMarkdown: React.FC<{ text: string }> = ({ text }) => {
77
+ const t = getTheme();
78
+ const lines = text.split("\n");
79
+ return (
80
+ <Box flexDirection="column">
81
+ {lines.map((line, i) => {
82
+ // Code block fence
83
+ if (line.startsWith("```")) {
84
+ return (
85
+ <Text key={i} color={t.codeBlock}>
86
+ {line}
87
+ </Text>
88
+ );
89
+ }
90
+ // Headers
91
+ if (line.startsWith("### ")) {
92
+ return (
93
+ <Text key={i} color={t.heading2} bold>
94
+ {" ▸ "}
95
+ {line.slice(4)}
96
+ </Text>
97
+ );
98
+ }
99
+ if (line.startsWith("## ")) {
100
+ return (
101
+ <Text key={i} color={t.heading2} bold>
102
+ {" ▸▸ "}
103
+ {line.slice(3)}
104
+ </Text>
105
+ );
106
+ }
107
+ if (line.startsWith("# ")) {
108
+ return (
109
+ <Text key={i} color={t.heading1} bold>
110
+ {"▸▸▸ "}
111
+ {line.slice(2)}
112
+ </Text>
113
+ );
114
+ }
115
+ // Bullet
116
+ if (line.match(/^(\s*)[-*] /)) {
117
+ const indent = line.match(/^(\s*)/)?.[1] || "";
118
+ const content = line.replace(/^(\s*)[-*] /, "");
119
+ return (
120
+ <Text key={i}>
121
+ {indent}
122
+ <Text color={t.bullet}>{"● "}</Text>
123
+ {content}
124
+ </Text>
125
+ );
126
+ }
127
+ // Numbered list
128
+ const numMatch = line.match(/^(\s*)(\d+)\. (.*)/);
129
+ if (numMatch) {
130
+ return (
131
+ <Text key={i}>
132
+ {numMatch[1]}
133
+ <Text color={t.listNumber}>{numMatch[2] + ". "}</Text>
134
+ {numMatch[3]}
135
+ </Text>
136
+ );
137
+ }
138
+ // Horizontal rule
139
+ if (line.match(/^---+$/)) {
140
+ return (
141
+ <Text key={i} color={t.horizontalRule}>
142
+ {"═".repeat(40)}
143
+ </Text>
144
+ );
145
+ }
146
+ // Plain line
147
+ const cleaned = line
148
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
149
+ .replace(/\*([^*]+)\*/g, "$1")
150
+ .replace(/`([^`]+)`/g, "$1");
151
+ return <Text key={i}>{cleaned}</Text>;
152
+ })}
153
+ </Box>
154
+ );
155
+ };
156
+
157
+ // ── Streaming message (live, mutable) ──────────────────────────────────────
158
+
159
+ export const StreamingMessage: React.FC<{ content: string }> = ({
160
+ content,
161
+ }) => {
162
+ const t = getTheme();
163
+ if (!content) return null;
164
+ return (
165
+ <Box flexDirection="column" paddingLeft={2} marginTop={1}>
166
+ <RenderMarkdown text={content} />
167
+ <Text color={t.accent}>{"▊"}</Text>
168
+ </Box>
169
+ );
170
+ };
171
+
172
+ // ── Tool activity bar ───────────────────────────────────────────────────────
173
+
174
+ export const ToolActivityBar: React.FC<{ tool: ToolActivity }> = ({
175
+ tool,
176
+ }) => {
177
+ const t = getTheme();
178
+ const icon = toolIcon(tool.name);
179
+ const color =
180
+ tool.status === "error"
181
+ ? t.toolError
182
+ : tool.status === "done"
183
+ ? t.toolDone
184
+ : t.toolRunning;
185
+ const statusChar =
186
+ tool.status === "error" ? "✗" : tool.status === "done" ? "✓" : "…";
187
+ return (
188
+ <Box paddingLeft={2}>
189
+ <Text color={color}>
190
+ {`${statusChar} ${icon} ${tool.name}`}
191
+ <Text color={t.toolPreview}>{tool.preview ? ` ${tool.preview}` : ""}</Text>
192
+ </Text>
193
+ </Box>
194
+ );
195
+ };
196
+
197
+ // ── Message list (committed messages go into <Static>) ─────────────────────
198
+
199
+ interface MessageListProps {
200
+ messages: ChatMessage[];
201
+ }
202
+
203
+ export const MessageList: React.FC<MessageListProps> = ({ messages }) => (
204
+ <Static items={messages}>
205
+ {(msg) => {
206
+ switch (msg.role) {
207
+ case "user":
208
+ return <UserMessage key={msg.id} content={msg.content} />;
209
+ case "assistant":
210
+ return <AssistantMessage key={msg.id} content={msg.content} />;
211
+ default:
212
+ return (
213
+ <SystemMessage
214
+ key={msg.id}
215
+ content={msg.content}
216
+ isError={msg.isError}
217
+ />
218
+ );
219
+ }
220
+ }}
221
+ </Static>
222
+ );