@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
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* helpers.ts — Pure utility functions for the chat UI.
|
|
3
|
+
*
|
|
4
|
+
* "Pure" means these functions have NO React state or hooks — they only take
|
|
5
|
+
* arguments and return values (or write to stdout). Keeping them here makes
|
|
6
|
+
* them easy to test in isolation and keeps the hook files focused on state.
|
|
7
|
+
*
|
|
8
|
+
* Contents:
|
|
9
|
+
* 1. Context-window size look-up
|
|
10
|
+
* 2. Terminal output for tool calls (printToolCall / printToolResult / printFileDiff)
|
|
11
|
+
* 3. Help text and conversation list formatters
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import chalk from "chalk";
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import { printConversationList } from "../../history";
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// 1. Context-window sizes
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Maximum input-token capacity for each AI provider / model family.
|
|
24
|
+
*
|
|
25
|
+
* Used to calculate what percentage of the context window is already filled
|
|
26
|
+
* so the UI can warn the user and auto-compact when approaching the limit.
|
|
27
|
+
*/
|
|
28
|
+
export function getContextWindowMax(provider: string, model: string): number {
|
|
29
|
+
if (provider === "google") return 1_000_000; // Gemini 1M context
|
|
30
|
+
if (provider === "anthropic") return 200_000; // Claude 200k
|
|
31
|
+
if (provider === "openai") {
|
|
32
|
+
if (model.includes("o3") || model.includes("o1")) return 200_000;
|
|
33
|
+
return 128_000; // GPT-4o 128k
|
|
34
|
+
}
|
|
35
|
+
if (provider === "ollama") return 32_000; // Local models vary
|
|
36
|
+
return 100_000; // Safe default
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
// 2. Tool-call terminal output
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Emoji icon shown next to each tool name in the terminal scrollback.
|
|
45
|
+
* Keeping this as a plain object (not a Map) makes it easy to extend.
|
|
46
|
+
*/
|
|
47
|
+
const TOOL_ICONS: Record<string, string> = {
|
|
48
|
+
file_read: "📖",
|
|
49
|
+
file_write: "✏️ ",
|
|
50
|
+
file_edit: "🔧",
|
|
51
|
+
multi_edit: "🔧",
|
|
52
|
+
file_delete: "🗑️",
|
|
53
|
+
ast_edit: "🌳",
|
|
54
|
+
notebook_edit: "📓",
|
|
55
|
+
glob_search: "🔍",
|
|
56
|
+
grep_search: "🔎",
|
|
57
|
+
codebase_search: "🔎",
|
|
58
|
+
shell_exec: "💻",
|
|
59
|
+
file_run: "▶",
|
|
60
|
+
web_fetch: "🌐",
|
|
61
|
+
web_search: "🔮",
|
|
62
|
+
sub_agent: "🤖",
|
|
63
|
+
todo: "📋",
|
|
64
|
+
list_dir: "📁",
|
|
65
|
+
view_diff: "📊",
|
|
66
|
+
view_repo_map: "🗺️",
|
|
67
|
+
code_verify: "✅",
|
|
68
|
+
system_info: "ℹ️",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract a human-readable hint (file path, query, etc.) from the tool's
|
|
73
|
+
* input object. Shown in dim text next to the tool name while it runs.
|
|
74
|
+
*/
|
|
75
|
+
export function getToolHint(name: string, input: Record<string, unknown>): string {
|
|
76
|
+
// Helper: get a string value from input, strip the cwd prefix for brevity
|
|
77
|
+
const p = (k: string) => String(input[k] || "").replace(process.cwd() + "/", "");
|
|
78
|
+
switch (name) {
|
|
79
|
+
case "file_read": return p("file_path") || p("path");
|
|
80
|
+
case "file_write": return p("file_path") || p("path");
|
|
81
|
+
case "file_edit": return p("file_path") || p("path");
|
|
82
|
+
case "glob_search": return String(input.pattern || "");
|
|
83
|
+
case "grep_search": return String(input.pattern || "");
|
|
84
|
+
case "shell_exec": return String(input.command || "").substring(0, 50);
|
|
85
|
+
case "web_fetch": return String(input.url || "").substring(0, 60);
|
|
86
|
+
case "web_search": return String(input.query || "").substring(0, 60);
|
|
87
|
+
default: return "";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Print a "▶ tool_name hint" line to stdout the moment the agent invokes a
|
|
93
|
+
* tool. This stays in the terminal scrollback permanently (unlike Ink's
|
|
94
|
+
* live render area which gets overwritten on each frame).
|
|
95
|
+
*/
|
|
96
|
+
export function printToolCall(name: string, input: Record<string, unknown>): void {
|
|
97
|
+
const icon = TOOL_ICONS[name] || "⚡";
|
|
98
|
+
const hint = getToolHint(name, input);
|
|
99
|
+
process.stdout.write(
|
|
100
|
+
chalk.yellow(" ▶ ") + chalk.yellow(`${icon} ${name}`) +
|
|
101
|
+
(hint ? chalk.gray(" " + hint) : "") + "\n",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Print a "✓ tool_name" or "✗ tool_name" line once the tool finishes.
|
|
107
|
+
* For file edits / writes, also prints a diff so the user can see what changed.
|
|
108
|
+
*/
|
|
109
|
+
export function printToolResult(
|
|
110
|
+
name: string,
|
|
111
|
+
isError: boolean,
|
|
112
|
+
input: Record<string, unknown>,
|
|
113
|
+
): void {
|
|
114
|
+
const icon = TOOL_ICONS[name] || "⚡";
|
|
115
|
+
if (isError) {
|
|
116
|
+
process.stdout.write(chalk.red(` ✗ ${icon} ${name}`) + "\n");
|
|
117
|
+
} else {
|
|
118
|
+
process.stdout.write(chalk.green(" ✓ ") + chalk.cyan(`${icon} ${name}`) + "\n");
|
|
119
|
+
}
|
|
120
|
+
if (!isError && (name === "file_edit" || name === "file_write")) {
|
|
121
|
+
printFileDiff(name, input);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Print a compact diff of what changed after a file_edit or file_write call.
|
|
127
|
+
*
|
|
128
|
+
* For file_edit: compares old_string → new_string (both come from the tool input).
|
|
129
|
+
* For file_write: reads the file from disk before writing to get the old content.
|
|
130
|
+
*
|
|
131
|
+
* Uses the `diff` package for line-level and word-level hunks.
|
|
132
|
+
*/
|
|
133
|
+
export function printFileDiff(toolName: string, input: Record<string, unknown>): void {
|
|
134
|
+
try {
|
|
135
|
+
let oldContent = "";
|
|
136
|
+
let newContent = "";
|
|
137
|
+
let filePath = "";
|
|
138
|
+
|
|
139
|
+
if (toolName === "file_edit") {
|
|
140
|
+
filePath = String(input.file_path || input.path || "");
|
|
141
|
+
oldContent = String(input.old_string || "");
|
|
142
|
+
newContent = String(input.new_string || "");
|
|
143
|
+
} else if (toolName === "file_write") {
|
|
144
|
+
filePath = String(input.file_path || input.path || "");
|
|
145
|
+
newContent = String(input.content || "");
|
|
146
|
+
try { oldContent = fs.readFileSync(filePath, "utf-8"); } catch { /* new file */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!oldContent && !newContent) return;
|
|
150
|
+
|
|
151
|
+
const shortPath = filePath.replace(process.cwd() + "/", "");
|
|
152
|
+
process.stdout.write(chalk.bold.white(`\n 📄 ${shortPath}\n`));
|
|
153
|
+
|
|
154
|
+
if (!oldContent) {
|
|
155
|
+
// Brand-new file — show the first 20 lines as additions
|
|
156
|
+
const lines = newContent.split("\n");
|
|
157
|
+
const preview = lines.slice(0, 20);
|
|
158
|
+
for (const line of preview) {
|
|
159
|
+
process.stdout.write(chalk.green(" + ") + chalk.green(line) + "\n");
|
|
160
|
+
}
|
|
161
|
+
if (lines.length > 20) {
|
|
162
|
+
process.stdout.write(chalk.gray(` ... +${lines.length - 20} more lines\n`));
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
// Existing file — compute a line diff and print added / removed hunks
|
|
166
|
+
const { diffLines, diffWords } =
|
|
167
|
+
require("diff") as typeof import("diff");
|
|
168
|
+
const lineHunks = diffLines(oldContent, newContent);
|
|
169
|
+
let shownLines = 0;
|
|
170
|
+
|
|
171
|
+
for (const hunk of lineHunks) {
|
|
172
|
+
if (!hunk.added && !hunk.removed) continue;
|
|
173
|
+
const lines = (hunk.value || "").split("\n")
|
|
174
|
+
.filter((l, i, arr) => i < arr.length - 1 || l);
|
|
175
|
+
|
|
176
|
+
for (const line of lines) {
|
|
177
|
+
if (shownLines >= 40) {
|
|
178
|
+
process.stdout.write(chalk.gray(" ... (diff truncated)\n"));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
process.stdout.write(
|
|
182
|
+
hunk.added
|
|
183
|
+
? chalk.green(" + ") + chalk.green(line) + "\n"
|
|
184
|
+
: chalk.red(" - ") + chalk.red(line) + "\n",
|
|
185
|
+
);
|
|
186
|
+
shownLines++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// For edits: also show a word-level inline diff for the changed region
|
|
191
|
+
if (toolName === "file_edit" && oldContent && newContent) {
|
|
192
|
+
const wordDiff = diffWords(oldContent, newContent);
|
|
193
|
+
const hasChanges = wordDiff.some((p) => p.added || p.removed);
|
|
194
|
+
if (hasChanges) {
|
|
195
|
+
process.stdout.write(chalk.gray(" ── word diff ──\n "));
|
|
196
|
+
for (const part of wordDiff) {
|
|
197
|
+
if (part.added) process.stdout.write(chalk.bgGreen.black(part.value));
|
|
198
|
+
else if (part.removed) process.stdout.write(chalk.bgRed.white(part.value));
|
|
199
|
+
else {
|
|
200
|
+
// Context: show only up to 15 chars on each side to avoid walls of text
|
|
201
|
+
const ctx = part.value.length > 30
|
|
202
|
+
? part.value.substring(0, 15) + chalk.gray("…") + part.value.slice(-15)
|
|
203
|
+
: part.value;
|
|
204
|
+
process.stdout.write(chalk.gray(ctx));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
process.stdout.write("\n");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
process.stdout.write("\n");
|
|
212
|
+
} catch {
|
|
213
|
+
// If the diff fails for any reason, just skip it silently
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
218
|
+
// 3. Help text and conversation list
|
|
219
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/** Full help string shown by /help. Update this whenever you add a command. */
|
|
222
|
+
export function getHelpText(): string {
|
|
223
|
+
return [
|
|
224
|
+
"Available commands:",
|
|
225
|
+
" /help — this message",
|
|
226
|
+
" /clear — clear conversation",
|
|
227
|
+
" /new — new conversation",
|
|
228
|
+
" /ls — browse sessions (interactive TUI)",
|
|
229
|
+
" /history — list saved conversations (text)",
|
|
230
|
+
" /resume <id> — resume a conversation",
|
|
231
|
+
" /fork [id] — fork current or given conversation",
|
|
232
|
+
" /delete <id> — delete a conversation",
|
|
233
|
+
" /config — view config",
|
|
234
|
+
" /model <name> — switch model",
|
|
235
|
+
" /provider <n> — switch provider",
|
|
236
|
+
" /mode <mode> — change permission mode",
|
|
237
|
+
" /dir <path> — change working directory",
|
|
238
|
+
" /usage — token usage",
|
|
239
|
+
" /plan — toggle plan mode",
|
|
240
|
+
" /effort <lvl> — set effort level (low/medium/high/max)",
|
|
241
|
+
" /btw <q> — ask without adding to history",
|
|
242
|
+
" /bg <prompt> — run prompt as background job",
|
|
243
|
+
" /jobs [id] — list / inspect background jobs",
|
|
244
|
+
" /rules — show project rules",
|
|
245
|
+
" /mcp — MCP server status",
|
|
246
|
+
" /context — list context providers",
|
|
247
|
+
" /tasks — show task list",
|
|
248
|
+
" /compact — compact context",
|
|
249
|
+
" /exit — quit",
|
|
250
|
+
"",
|
|
251
|
+
"Prefix with ! to run a shell command directly.",
|
|
252
|
+
"Use @terminal, @tree, @url <u>, @codebase, @file <path> in messages.",
|
|
253
|
+
"Ctrl+V to paste clipboard · Shift+Tab to cycle mode · Ctrl+L to clear",
|
|
254
|
+
].join("\n");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Capture the output of printConversationList() as a string so it can be
|
|
259
|
+
* displayed inside the TUI instead of being written directly to stdout.
|
|
260
|
+
*
|
|
261
|
+
* We temporarily replace console.log with a collector, then restore it.
|
|
262
|
+
* This is a simple (if hacky) way to reuse the existing printer.
|
|
263
|
+
*/
|
|
264
|
+
export function getConversationListText(): string {
|
|
265
|
+
const lines: string[] = [];
|
|
266
|
+
const orig = console.log;
|
|
267
|
+
console.log = (...args: unknown[]) => lines.push(args.join(" "));
|
|
268
|
+
printConversationList();
|
|
269
|
+
console.log = orig;
|
|
270
|
+
return lines.join("\n") || "No saved conversations.";
|
|
271
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useAgent.ts — React hook that owns the AgentRunner lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Single responsibility: build, rebuild, and run the AI agent.
|
|
5
|
+
* It knows nothing about conversations, sessions, slash commands, or UI
|
|
6
|
+
* messages — those live in useChat.ts.
|
|
7
|
+
*
|
|
8
|
+
* Why a separate hook?
|
|
9
|
+
* - The agent can be rebuilt (e.g. when the user changes the model) without
|
|
10
|
+
* touching any message or session state.
|
|
11
|
+
* - The streaming callbacks (onToken, onToolCall…) are pure side-effects of
|
|
12
|
+
* the agent run. Keeping them here makes them easy to read end-to-end.
|
|
13
|
+
* - useChat.ts can stay focused on slash commands and message history.
|
|
14
|
+
*
|
|
15
|
+
* What this hook exposes:
|
|
16
|
+
* - agentRef — the live AgentRunner instance
|
|
17
|
+
* - modelConfigRef — mutable model settings (mutated by /model, /provider)
|
|
18
|
+
* - toolRegistryRef — mutable tool list (mutated by /dir)
|
|
19
|
+
* - workingDirRef — current working directory as a ref
|
|
20
|
+
* - planManagerRef — manages plan-mode state
|
|
21
|
+
* - rulesManagerRef — loads .cdoing/rules.md
|
|
22
|
+
* - effortManagerRef — tracks effort level
|
|
23
|
+
* - mcpManagerRef — MCP server configuration
|
|
24
|
+
* - contextProvidersRef — @mention context providers
|
|
25
|
+
* - rebuildAgent() — recreate the agent after config changes
|
|
26
|
+
* - resolveContextProviders() — expand @mentions in a message
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { useRef } from "react";
|
|
30
|
+
import { AgentRunner } from "@cdoing/ai";
|
|
31
|
+
import type { ModelConfig } from "@cdoing/ai";
|
|
32
|
+
import type { ToolRegistry, HookManager, MemoryStore, PermissionManager } from "@cdoing/core";
|
|
33
|
+
import {
|
|
34
|
+
loadProjectConfig,
|
|
35
|
+
PlanManager,
|
|
36
|
+
RulesManager,
|
|
37
|
+
McpManager,
|
|
38
|
+
EffortManager,
|
|
39
|
+
ContextProviderRegistry,
|
|
40
|
+
TerminalContextProvider,
|
|
41
|
+
UrlContextProvider,
|
|
42
|
+
TreeContextProvider,
|
|
43
|
+
CodebaseContextProvider,
|
|
44
|
+
ClipboardContextProvider,
|
|
45
|
+
FileIncludeContextProvider,
|
|
46
|
+
} from "@cdoing/core";
|
|
47
|
+
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
// Types
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** Everything useAgent needs from the parent component props. */
|
|
53
|
+
export interface UseAgentOptions {
|
|
54
|
+
modelConfig: Partial<ModelConfig>;
|
|
55
|
+
toolRegistry: ToolRegistry;
|
|
56
|
+
permissionManager: PermissionManager;
|
|
57
|
+
hookManager: HookManager;
|
|
58
|
+
memoryStore: MemoryStore;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// Hook
|
|
63
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Manages the AgentRunner and all mutable config references.
|
|
67
|
+
*
|
|
68
|
+
* All returned refs are intentionally mutable — slash commands in useChat.ts
|
|
69
|
+
* mutate them (e.g. modelConfigRef.current.model = "gpt-4o") and then call
|
|
70
|
+
* rebuildAgent() to create a fresh AgentRunner with the new settings.
|
|
71
|
+
*/
|
|
72
|
+
export function useAgent(opts: UseAgentOptions) {
|
|
73
|
+
|
|
74
|
+
// ── Mutable config refs ───────────────────────────────────────────────────
|
|
75
|
+
// These use useRef (not useState) because changing them should NOT trigger
|
|
76
|
+
// a re-render. Re-renders are triggered by state variables in useChat.ts.
|
|
77
|
+
|
|
78
|
+
/** Current working directory — changes when the user runs /dir */
|
|
79
|
+
const workingDirRef = useRef(process.cwd());
|
|
80
|
+
|
|
81
|
+
/** Model + provider + API key settings — changes on /model, /provider, /config set */
|
|
82
|
+
const modelConfigRef = useRef<Partial<ModelConfig>>({ ...opts.modelConfig });
|
|
83
|
+
|
|
84
|
+
/** The set of tools available to the agent — swapped on /dir to match the new cwd */
|
|
85
|
+
const toolRegistryRef = useRef<ToolRegistry>(opts.toolRegistry);
|
|
86
|
+
|
|
87
|
+
// ── Feature-manager refs ─────────────────────────────────────────────────
|
|
88
|
+
// Each manager encapsulates one feature. They are ref-stored so the agent
|
|
89
|
+
// can query them synchronously inside buildAgent() without going through state.
|
|
90
|
+
|
|
91
|
+
/** Plan mode: the agent proposes a plan before executing */
|
|
92
|
+
const planManagerRef = useRef(new PlanManager());
|
|
93
|
+
|
|
94
|
+
/** Project-specific rules loaded from .cdoing/rules.md */
|
|
95
|
+
const rulesManagerRef = useRef(new RulesManager(process.cwd()));
|
|
96
|
+
|
|
97
|
+
/** Effort level — low / medium / high / max — affects the system prompt */
|
|
98
|
+
const effortManagerRef = useRef(new EffortManager());
|
|
99
|
+
|
|
100
|
+
/** MCP (Model Context Protocol) server configuration */
|
|
101
|
+
const mcpManagerRef = useRef(new McpManager(process.cwd()));
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Registry of @mention context providers.
|
|
105
|
+
* Each provider has a trigger (e.g. "@file") and a resolve() function that
|
|
106
|
+
* returns context to inject into the user's message before it reaches the LLM.
|
|
107
|
+
*/
|
|
108
|
+
const contextProvidersRef = useRef<ContextProviderRegistry>(
|
|
109
|
+
buildContextProviders(),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// ── Agent ref ────────────────────────────────────────────────────────────
|
|
113
|
+
/**
|
|
114
|
+
* The live AgentRunner instance. Wrapped in a ref so the same object is
|
|
115
|
+
* accessible inside async callbacks without capturing a stale closure.
|
|
116
|
+
*
|
|
117
|
+
* Initialized with buildAgentInternal() immediately, then replaced via
|
|
118
|
+
* rebuildAgent() whenever settings change.
|
|
119
|
+
*/
|
|
120
|
+
const agentRef = useRef<AgentRunner | null>((() => {
|
|
121
|
+
try { return buildAgentInternal(); } catch { return null; }
|
|
122
|
+
})());
|
|
123
|
+
|
|
124
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
125
|
+
// Agent builder
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Construct a new AgentRunner from the current refs.
|
|
130
|
+
*
|
|
131
|
+
* Called once on mount and again via rebuildAgent() after any config change.
|
|
132
|
+
* Combines project config, rules, and effort hints into a single system prompt.
|
|
133
|
+
*/
|
|
134
|
+
function buildAgentInternal(): AgentRunner {
|
|
135
|
+
const dir = workingDirRef.current;
|
|
136
|
+
const projectConfig = loadProjectConfig(dir); // .cdoing/config.md
|
|
137
|
+
const rulesText = rulesManagerRef.current?.formatForPrompt() || "";
|
|
138
|
+
const effortHint = effortManagerRef.current?.getSystemPromptAddition() || "";
|
|
139
|
+
|
|
140
|
+
// Merge all system-prompt additions, filtering out empty strings
|
|
141
|
+
const systemPrompt = [projectConfig || "", rulesText, effortHint]
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.join("\n\n");
|
|
144
|
+
|
|
145
|
+
return new AgentRunner(
|
|
146
|
+
modelConfigRef.current,
|
|
147
|
+
toolRegistryRef.current,
|
|
148
|
+
opts.permissionManager,
|
|
149
|
+
opts.hookManager,
|
|
150
|
+
{
|
|
151
|
+
workingDir: dir,
|
|
152
|
+
projectConfig: systemPrompt || undefined,
|
|
153
|
+
memory: opts.memoryStore.formatForPrompt() || undefined,
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Replace the live agent with a freshly built one.
|
|
160
|
+
* Call this any time you mutate modelConfigRef, toolRegistryRef, or workingDirRef.
|
|
161
|
+
*/
|
|
162
|
+
function rebuildAgent(): void {
|
|
163
|
+
try {
|
|
164
|
+
agentRef.current = buildAgentInternal();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// Don't crash — agentRef keeps the previous agent (or null).
|
|
167
|
+
// The error will surface as a friendly message on the next send.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
172
|
+
// Context provider resolver
|
|
173
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Expand any @mention triggers in the user's message.
|
|
177
|
+
*
|
|
178
|
+
* Example: "@file src/main.ts tell me what this does"
|
|
179
|
+
* → strips "@file src/main.ts"
|
|
180
|
+
* → reads the file
|
|
181
|
+
* → returns "tell me what this does\n\n---\n\n<file path="src/main.ts">…</file>"
|
|
182
|
+
*
|
|
183
|
+
* @param message Raw message as typed by the user
|
|
184
|
+
* @param workingDir Current working directory (for relative file paths)
|
|
185
|
+
* @param lastTerminalOutput Last captured terminal output (for @terminal)
|
|
186
|
+
*/
|
|
187
|
+
async function resolveContextProviders(
|
|
188
|
+
message: string,
|
|
189
|
+
workingDir: string,
|
|
190
|
+
lastTerminalOutput: string,
|
|
191
|
+
): Promise<string> {
|
|
192
|
+
const providers = contextProvidersRef.current.getAll();
|
|
193
|
+
if (!providers.length) return message;
|
|
194
|
+
|
|
195
|
+
const injected: string[] = [];
|
|
196
|
+
let clean = message;
|
|
197
|
+
|
|
198
|
+
for (const provider of providers) {
|
|
199
|
+
const idx = message.indexOf(provider.trigger);
|
|
200
|
+
if (idx < 0) continue; // trigger not present
|
|
201
|
+
|
|
202
|
+
// Extract the argument (text after the trigger on the same line)
|
|
203
|
+
const after = message.substring(idx + provider.trigger.length);
|
|
204
|
+
let arg: string | undefined;
|
|
205
|
+
if (provider.requiresArg) {
|
|
206
|
+
const end = after.indexOf("\n");
|
|
207
|
+
arg = (end >= 0 ? after.substring(0, end) : after).trim();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Remove the trigger (+ optional arg) from the clean message
|
|
211
|
+
const fullTrigger = provider.requiresArg && arg
|
|
212
|
+
? `${provider.trigger} ${arg}`
|
|
213
|
+
: provider.trigger;
|
|
214
|
+
clean = clean.replace(fullTrigger, "").trim();
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const result = await provider.resolve(arg, { workingDir, terminalOutput: lastTerminalOutput });
|
|
218
|
+
if (result.content) injected.push(result.content);
|
|
219
|
+
} catch {
|
|
220
|
+
// If a provider fails, skip it silently — the message still goes through
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Append all injected context blocks separated by a horizontal rule
|
|
225
|
+
return injected.length
|
|
226
|
+
? `${clean}\n\n---\n\n${injected.join("\n\n---\n\n")}`
|
|
227
|
+
: clean;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
231
|
+
// Public API
|
|
232
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
// Refs exposed so slash commands in useChat.ts can mutate them
|
|
236
|
+
agentRef,
|
|
237
|
+
modelConfigRef,
|
|
238
|
+
toolRegistryRef,
|
|
239
|
+
workingDirRef,
|
|
240
|
+
planManagerRef,
|
|
241
|
+
rulesManagerRef,
|
|
242
|
+
effortManagerRef,
|
|
243
|
+
mcpManagerRef,
|
|
244
|
+
contextProvidersRef,
|
|
245
|
+
// Actions
|
|
246
|
+
rebuildAgent,
|
|
247
|
+
resolveContextProviders,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
252
|
+
// Private helpers (module-level, not exported)
|
|
253
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Register all built-in @mention context providers.
|
|
257
|
+
*
|
|
258
|
+
* The Strategy pattern: each provider is interchangeable and can be added or
|
|
259
|
+
* removed without changing the chat hook.
|
|
260
|
+
*/
|
|
261
|
+
function buildContextProviders(): ContextProviderRegistry {
|
|
262
|
+
const reg = new ContextProviderRegistry();
|
|
263
|
+
reg.register(new TerminalContextProvider()); // @terminal — recent shell output
|
|
264
|
+
reg.register(new UrlContextProvider()); // @url <link> — fetch a webpage
|
|
265
|
+
reg.register(new TreeContextProvider()); // @tree — project file tree
|
|
266
|
+
reg.register(new CodebaseContextProvider()); // @codebase — full codebase
|
|
267
|
+
reg.register(new ClipboardContextProvider()); // @clip — clipboard content
|
|
268
|
+
reg.register(new FileIncludeContextProvider());// @file <path> — include a file
|
|
269
|
+
return reg;
|
|
270
|
+
}
|