@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
@@ -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
+ }