@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.
- package/.cdoing/permissions.json +8 -0
- package/dist/callbacks.d.ts +17 -0
- package/dist/callbacks.d.ts.map +1 -0
- package/dist/callbacks.js +265 -0
- package/dist/callbacks.js.map +1 -0
- package/dist/chat.d.ts +27 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +57 -0
- package/dist/chat.js.map +1 -0
- package/dist/commands.d.ts +22 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +452 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +84 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +427 -0
- package/dist/config.js.map +1 -0
- package/dist/help.d.ts +9 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +167 -0
- package/dist/help.js.map +1 -0
- package/dist/history.d.ts +51 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +207 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +220 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth.d.ts +13 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +182 -0
- package/dist/oauth.js.map +1 -0
- package/dist/review.d.ts +26 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +198 -0
- package/dist/review.js.map +1 -0
- package/dist/serve.d.ts +23 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +293 -0
- package/dist/serve.js.map +1 -0
- package/dist/tools.d.ts +14 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +57 -0
- package/dist/tools.js.map +1 -0
- package/dist/ui/App.d.ts +24 -0
- package/dist/ui/App.d.ts.map +1 -0
- package/dist/ui/App.js +321 -0
- package/dist/ui/App.js.map +1 -0
- package/dist/ui/MessageList.d.ts +14 -0
- package/dist/ui/MessageList.d.ts.map +1 -0
- package/dist/ui/MessageList.js +147 -0
- package/dist/ui/MessageList.js.map +1 -0
- package/dist/ui/SessionBrowser.d.ts +18 -0
- package/dist/ui/SessionBrowser.d.ts.map +1 -0
- package/dist/ui/SessionBrowser.js +149 -0
- package/dist/ui/SessionBrowser.js.map +1 -0
- package/dist/ui/SetupWizard.d.ts +23 -0
- package/dist/ui/SetupWizard.d.ts.map +1 -0
- package/dist/ui/SetupWizard.js +402 -0
- package/dist/ui/SetupWizard.js.map +1 -0
- package/dist/ui/Spinner.d.ts +15 -0
- package/dist/ui/Spinner.d.ts.map +1 -0
- package/dist/ui/Spinner.js +111 -0
- package/dist/ui/Spinner.js.map +1 -0
- package/dist/ui/StatusBar.d.ts +16 -0
- package/dist/ui/StatusBar.d.ts.map +1 -0
- package/dist/ui/StatusBar.js +56 -0
- package/dist/ui/StatusBar.js.map +1 -0
- package/dist/ui/UserInput.d.ts +13 -0
- package/dist/ui/UserInput.d.ts.map +1 -0
- package/dist/ui/UserInput.js +872 -0
- package/dist/ui/UserInput.js.map +1 -0
- package/dist/ui/hooks/helpers.d.ts +55 -0
- package/dist/ui/hooks/helpers.d.ts.map +1 -0
- package/dist/ui/hooks/helpers.js +304 -0
- package/dist/ui/hooks/helpers.js.map +1 -0
- package/dist/ui/hooks/useAgent.d.ts +60 -0
- package/dist/ui/hooks/useAgent.d.ts.map +1 -0
- package/dist/ui/hooks/useAgent.js +213 -0
- package/dist/ui/hooks/useAgent.js.map +1 -0
- package/dist/ui/hooks/useChat.d.ts +74 -0
- package/dist/ui/hooks/useChat.d.ts.map +1 -0
- package/dist/ui/hooks/useChat.js +819 -0
- package/dist/ui/hooks/useChat.js.map +1 -0
- package/dist/ui/theme.d.ts +73 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +214 -0
- package/dist/ui/theme.js.map +1 -0
- package/dist/ui/types.d.ts +37 -0
- package/dist/ui/types.d.ts.map +1 -0
- package/dist/ui/types.js +3 -0
- package/dist/ui/types.js.map +1 -0
- package/package.json +33 -0
- package/src/callbacks.ts +294 -0
- package/src/chat.ts +72 -0
- package/src/commands.ts +425 -0
- package/src/config.ts +462 -0
- package/src/help.ts +182 -0
- package/src/history.ts +205 -0
- package/src/index.ts +248 -0
- package/src/oauth.ts +164 -0
- package/src/review.ts +233 -0
- package/src/serve.ts +290 -0
- package/src/tools.ts +104 -0
- package/src/ui/App.tsx +426 -0
- package/src/ui/MessageList.tsx +222 -0
- package/src/ui/SessionBrowser.tsx +161 -0
- package/src/ui/SetupWizard.tsx +412 -0
- package/src/ui/Spinner.tsx +103 -0
- package/src/ui/StatusBar.tsx +106 -0
- package/src/ui/UserInput.tsx +954 -0
- package/src/ui/hooks/helpers.ts +271 -0
- package/src/ui/hooks/useAgent.ts +270 -0
- package/src/ui/hooks/useChat.ts +943 -0
- package/src/ui/theme.ts +326 -0
- package/src/ui/types.ts +41 -0
- 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
|
+
);
|