@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,943 @@
1
+ /**
2
+ * useChat.ts — Main chat state hook.
3
+ *
4
+ * This is the top-level hook consumed by App.tsx. It wires together three
5
+ * concerns that intentionally live in separate modules:
6
+ *
7
+ * 1. Agent lifecycle → ./useAgent.ts
8
+ * Build / rebuild the AgentRunner when settings change.
9
+ *
10
+ * 2. Pure utilities → ./helpers.ts
11
+ * Terminal output, diff printing, help text — no React.
12
+ *
13
+ * 3. This file (useChat.ts)
14
+ * - Message history (what's displayed in the chat window)
15
+ * - Session / conversation persistence
16
+ * - Background jobs (/bg, /jobs)
17
+ * - Slash command dispatch (/model, /dir, /clear, …)
18
+ * - The sendMessage() function that runs the agent and streams tokens
19
+ *
20
+ * Learning note — why split at all?
21
+ * A single 1000-line file works but becomes hard to navigate. Splitting by
22
+ * responsibility means you can read useAgent.ts to understand "how is the
23
+ * AI agent built?" without wading through session management, and vice versa.
24
+ *
25
+ * Data flow:
26
+ * User types → UserInput.tsx → onSubmit → App.tsx → sendMessage()
27
+ * ↓
28
+ * agentRef.current.run()
29
+ * ↓
30
+ * onToken / onToolCall / onComplete
31
+ * ↓
32
+ * setStreamingContent / setMessages
33
+ */
34
+
35
+ import { useState, useCallback, useRef, useMemo } from "react";
36
+ import * as path from "path";
37
+ import * as fs from "fs";
38
+ import chalk from "chalk";
39
+ import { getDefaultModel } from "@cdoing/ai";
40
+ import type { ModelConfig } from "@cdoing/ai";
41
+ import type {
42
+ ToolRegistry,
43
+ PermissionManager,
44
+ PermissionMode,
45
+ HookManager,
46
+ MemoryStore,
47
+ TodoStore,
48
+ } from "@cdoing/core";
49
+ import type { EffortLevel } from "@cdoing/core";
50
+
51
+ // History helpers — read / write conversations to ~/.cdoing/history/
52
+ import {
53
+ createConversation,
54
+ addMessage,
55
+ loadConversation,
56
+ listConversations,
57
+ deleteConversation,
58
+ forkConversation,
59
+ updateConversationTitle,
60
+ type Conversation,
61
+ } from "../../history";
62
+
63
+ import { createToolRegistry } from "../../tools";
64
+ import {
65
+ parsePermissionMode,
66
+ updateStoredConfig,
67
+ getStoredConfigDisplay,
68
+ } from "../../config";
69
+ import { oauthLogout, oauthStatus } from "../../oauth";
70
+ import { handleInit, handleDoctor } from "../../commands";
71
+ import { setTheme, getThemeName, getAvailableThemes } from "../theme";
72
+
73
+ // Split modules
74
+ import { useAgent } from "./useAgent";
75
+ import {
76
+ getContextWindowMax,
77
+ printToolCall,
78
+ printToolResult,
79
+ getHelpText,
80
+ getConversationListText,
81
+ } from "./helpers";
82
+
83
+ import type { ChatMessage, ToolActivity, UsageInfo, ContextUsage, BackgroundJob } from "../types";
84
+
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+ // Small utilities local to this file
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+
89
+ /** Auto-incrementing message ID — keeps React list keys stable */
90
+ let _msgId = 0;
91
+ function nextId(): string { return String(++_msgId); }
92
+
93
+ /** Short unique ID for background jobs — e.g. "bg-1a2b" */
94
+ function jobId(): string { return `bg-${Date.now().toString(36).slice(-4)}`; }
95
+
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+ // Public interface
98
+ // ─────────────────────────────────────────────────────────────────────────────
99
+
100
+ /** Props passed by the parent component (App.tsx → chat.ts → here) */
101
+ export interface UseChatOptions {
102
+ modelConfig: Partial<ModelConfig>;
103
+ toolRegistry: ToolRegistry;
104
+ permissionManager: PermissionManager;
105
+ hookManager: HookManager;
106
+ memoryStore: MemoryStore;
107
+ todoStore?: TodoStore;
108
+ }
109
+
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // The hook
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+
114
+ export function useChat(opts: UseChatOptions) {
115
+
116
+ // ── 1. Agent infrastructure (from useAgent.ts) ───────────────────────────
117
+ //
118
+ // useAgent owns: agentRef, modelConfigRef, toolRegistryRef, workingDirRef,
119
+ // planManagerRef, rulesManagerRef, effortManagerRef, …
120
+ // rebuildAgent(), resolveContextProviders()
121
+ //
122
+ // We destructure everything we need from it.
123
+ const agent = useAgent(opts);
124
+ const {
125
+ agentRef, modelConfigRef, toolRegistryRef, workingDirRef,
126
+ planManagerRef, rulesManagerRef, effortManagerRef,
127
+ mcpManagerRef, contextProvidersRef,
128
+ rebuildAgent, resolveContextProviders,
129
+ } = agent;
130
+
131
+ // ── 2. UI state — these drive React re-renders ───────────────────────────
132
+
133
+ /** All committed messages shown in the chat window (user + assistant + system) */
134
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
135
+
136
+ /** Token stream currently being received — shown in the live streaming area */
137
+ const [streamingContent, setStreamingContent] = useState("");
138
+
139
+ /** Whether the agent is processing a request (disables input, shows spinner) */
140
+ const [isProcessing, setIsProcessing] = useState(false);
141
+
142
+ /** Tool currently executing (shows animated spinner with tool name) */
143
+ const [toolActivity, setToolActivity] = useState<ToolActivity | null>(null);
144
+
145
+ /** Last token-usage report from the LLM (shown in the status bar) */
146
+ const [lastUsage, setLastUsage] = useState<UsageInfo | null>(null);
147
+
148
+ /** Current working directory — shown in the status bar, changed by /dir */
149
+ const [workingDir, setWorkingDir] = useState(process.cwd());
150
+
151
+ /** Context-window fill percentage — used to auto-compact at 80% */
152
+ const [contextUsage, setContextUsage] = useState<ContextUsage | null>(null);
153
+
154
+ /** List of /bg background jobs and their statuses */
155
+ const [backgroundJobs, setBackgroundJobs] = useState<BackgroundJob[]>([]);
156
+
157
+ /** Whether the /ls session browser overlay is open */
158
+ const [showSessionBrowser, setShowSessionBrowser] = useState(false);
159
+
160
+ /** Bumped whenever modelConfigRef is mutated — forces a re-render so StatusBar reflects changes */
161
+ const [_configVersion, _bumpConfigVersion] = useState(0);
162
+
163
+ /** Snapshot of the live model config — recalculated whenever _configVersion bumps */
164
+ const liveModelConfig = useMemo(
165
+ () => ({ ...modelConfigRef.current }),
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ [_configVersion],
168
+ );
169
+
170
+ /** Rebuild agent and trigger a re-render so UI (StatusBar, etc.) shows updated config */
171
+ const rebuildAndRefresh = useCallback(() => {
172
+ rebuildAgent();
173
+ _bumpConfigVersion((v) => v + 1);
174
+ }, [rebuildAgent]);
175
+
176
+ // ── 3. Mutable refs — data that changes without triggering a re-render ───
177
+
178
+ /** The AbortController for the current agent run — set to cancel in-flight requests */
179
+ const abortRef = useRef<AbortController | null>(null);
180
+
181
+ /**
182
+ * Message queue — when a message arrives while isProcessing is true it's
183
+ * pushed here. The next message in the queue is dequeued in onComplete.
184
+ */
185
+ const queueRef = useRef<string[]>([]);
186
+
187
+ /**
188
+ * Last tool input — saved so printToolResult() can show the diff after the
189
+ * tool finishes. Cleared after each tool result.
190
+ */
191
+ const lastToolInputRef = useRef<Record<string, unknown>>({});
192
+
193
+ /** Active conversation — persisted to disk on every message */
194
+ const conversationRef = useRef<Conversation>(
195
+ createConversation(
196
+ String(opts.modelConfig.provider || "anthropic"),
197
+ String(opts.modelConfig.model || "default"),
198
+ ),
199
+ );
200
+
201
+ /**
202
+ * Last captured terminal output — injected when the user types @terminal.
203
+ * Set by App.tsx after shell commands finish.
204
+ */
205
+ const lastTerminalOutputRef = useRef("");
206
+
207
+ /** Whether plan-mode is active (agent proposes a plan before acting) */
208
+ const planModeActiveRef = useRef(false);
209
+
210
+ // ─────────────────────────────────────────────────────────────────────────
211
+ // Helper: add a system notification to the message list
212
+ // ─────────────────────────────────────────────────────────────────────────
213
+
214
+ function addSystemMessage(content: string): void {
215
+ setMessages((prev) => [
216
+ ...prev,
217
+ { id: nextId(), role: "system", content },
218
+ ]);
219
+ }
220
+
221
+ // ─────────────────────────────────────────────────────────────────────────
222
+ // Helper: auto-generate a session title after the first exchange
223
+ // ─────────────────────────────────────────────────────────────────────────
224
+
225
+ /**
226
+ * Fires a lightweight LLM call in the background to produce a short title
227
+ * for the conversation (e.g. "Fix TypeScript build errors").
228
+ * Saved to disk via updateConversationTitle — never blocks the main UI.
229
+ */
230
+ function generateSessionTitle(conv: Conversation): void {
231
+ const firstUser = conv.messages.find((m) => m.role === "user");
232
+ const firstAssistant = conv.messages.find((m) => m.role === "assistant");
233
+ if (!firstUser) return;
234
+
235
+ const snippet = [
236
+ `User: ${firstUser.content.substring(0, 200)}`,
237
+ firstAssistant ? `Assistant: ${firstAssistant.content.substring(0, 200)}` : "",
238
+ ].filter(Boolean).join("\n");
239
+
240
+ // Build a throwaway agent with no tools — we only need a text response
241
+ const titleAgent = new (agentRef.current!.constructor as any)(
242
+ modelConfigRef.current,
243
+ toolRegistryRef.current,
244
+ opts.permissionManager,
245
+ opts.hookManager,
246
+ );
247
+ let title = "";
248
+ titleAgent.run(
249
+ `Generate a concise session title (5–8 words max, no quotes) for this conversation:\n\n${snippet}\n\nTitle:`,
250
+ {
251
+ onToken: (t: string) => { title += t; },
252
+ onToolCall: () => {},
253
+ onToolResult: () => {},
254
+ onComplete: () => {
255
+ const clean = title.trim().replace(/^["']|["']$/g, "").replace(/\.$/, "");
256
+ if (clean) updateConversationTitle(conv.id, clean);
257
+ },
258
+ onError: () => {},
259
+ },
260
+ ).catch(() => {});
261
+ }
262
+
263
+ // ─────────────────────────────────────────────────────────────────────────
264
+ // cancelCurrent — abort the running agent
265
+ // ─────────────────────────────────────────────────────────────────────────
266
+
267
+ const cancelCurrent = useCallback(() => {
268
+ if (abortRef.current) {
269
+ abortRef.current.abort();
270
+ abortRef.current = null;
271
+ }
272
+ setIsProcessing(false);
273
+ setStreamingContent("");
274
+ setToolActivity(null);
275
+ addSystemMessage("⏹ Cancelled.");
276
+ }, []);
277
+
278
+ // ─────────────────────────────────────────────────────────────────────────
279
+ // sendMessage — the core function that runs the agent
280
+ // ─────────────────────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * Send a message to the agent and stream the response back into the UI.
284
+ *
285
+ * Flow:
286
+ * 1. If already processing → queue the message and return.
287
+ * 2. Expand any @mention context providers in the text.
288
+ * 3. Add the user message to the visible chat history.
289
+ * 4. Run the agent; wire up streaming callbacks:
290
+ * onToken → update the live streaming area
291
+ * onToolCall → flush text to stdout, show tool spinner
292
+ * onToolResult → clear spinner, print diff
293
+ * onComplete → commit reply, dequeue next message
294
+ * onError → show error message
295
+ * onUsage → update token counter and auto-compact if needed
296
+ */
297
+ const sendMessage = useCallback(
298
+ async (text: string) => {
299
+ // ── Guard: no agent (no key configured) ─────────────────────────────
300
+ if (!agentRef.current) {
301
+ addSystemMessage("No API key configured. Run /setup to authenticate.");
302
+ return;
303
+ }
304
+
305
+ // ── Queue if busy ────────────────────────────────────────────────────
306
+ if (isProcessing) {
307
+ queueRef.current.push(text);
308
+ addSystemMessage(`📬 Queued (${queueRef.current.length} waiting)`);
309
+ return;
310
+ }
311
+
312
+ // ── Resolve @mentions ────────────────────────────────────────────────
313
+ const enriched = await resolveContextProviders(
314
+ text,
315
+ workingDirRef.current,
316
+ lastTerminalOutputRef.current,
317
+ );
318
+
319
+ // ── Optimistic UI update ─────────────────────────────────────────────
320
+ setIsProcessing(true);
321
+ setMessages((prev) => [
322
+ ...prev,
323
+ { id: nextId(), role: "user", content: text },
324
+ ]);
325
+ addMessage(conversationRef.current, "user", text);
326
+
327
+ const ctrl = new AbortController();
328
+ abortRef.current = ctrl;
329
+
330
+ /**
331
+ * Track how much of fullReply has already been written directly to
332
+ * stdout (to avoid double-printing it when it's committed to messages).
333
+ */
334
+ let fullReply = "";
335
+ let flushedPos = 0;
336
+
337
+ /**
338
+ * Flush any un-printed streaming text to stdout BEFORE showing a tool
339
+ * call. Without this, the text would disappear when Ink clears the
340
+ * live area to render the tool spinner.
341
+ */
342
+ function flushStreamingText(): void {
343
+ const pending = fullReply.slice(flushedPos);
344
+ if (pending.trim()) process.stdout.write("\n" + pending + "\n");
345
+ flushedPos = fullReply.length;
346
+ setStreamingContent("");
347
+ }
348
+
349
+ // ── Agent run ────────────────────────────────────────────────────────
350
+ await agentRef.current.run(enriched, {
351
+
352
+ onToken: (token) => {
353
+ fullReply += token;
354
+ // Show only the part not yet flushed to stdout
355
+ setStreamingContent(fullReply.slice(flushedPos));
356
+ },
357
+
358
+ onToolCall: (name, input) => {
359
+ flushStreamingText();
360
+ lastToolInputRef.current = input;
361
+ printToolCall(name, input); // permanent stdout line
362
+ const preview = JSON.stringify(input).substring(0, 60);
363
+ setToolActivity({ name, preview, status: "running" });
364
+ },
365
+
366
+ onToolResult: (_name, _result, isError) => {
367
+ printToolResult(_name, isError, lastToolInputRef.current);
368
+ lastToolInputRef.current = {};
369
+ setToolActivity(null);
370
+ },
371
+
372
+ onComplete: () => {
373
+ // Commit only the portion NOT already flushed to stdout
374
+ const remaining = fullReply.slice(flushedPos);
375
+ if (remaining.trim()) {
376
+ setMessages((prev) => [
377
+ ...prev,
378
+ { id: nextId(), role: "assistant", content: remaining },
379
+ ]);
380
+ addMessage(conversationRef.current, "assistant", remaining);
381
+ } else if (flushedPos > 0) {
382
+ // All text was flushed — print a separator to mark the end
383
+ process.stdout.write(chalk.gray("─".repeat(40)) + "\n");
384
+ }
385
+ // Save the full reply to the conversation for the non-flushed case
386
+ if (flushedPos === 0 && fullReply.trim()) {
387
+ addMessage(conversationRef.current, "assistant", fullReply);
388
+ }
389
+
390
+ setStreamingContent("");
391
+ setIsProcessing(false);
392
+ abortRef.current = null;
393
+
394
+ // Auto-generate a title after the first exchange
395
+ const conv = conversationRef.current;
396
+ if (conv.title === "New conversation" && conv.messages.length >= 2) {
397
+ generateSessionTitle(conv);
398
+ }
399
+
400
+ // Dequeue next message
401
+ const next = queueRef.current.shift();
402
+ if (next) sendMessage(next);
403
+ },
404
+
405
+ onError: (err) => {
406
+ setMessages((prev) => [
407
+ ...prev,
408
+ { id: nextId(), role: "system", content: `❌ Error: ${err.message}`, isError: true },
409
+ ]);
410
+ setStreamingContent("");
411
+ setIsProcessing(false);
412
+ abortRef.current = null;
413
+ },
414
+
415
+ onUsage: (usage) => {
416
+ const u = usage as UsageInfo;
417
+ setLastUsage(u);
418
+
419
+ // Calculate context-window fill % and auto-compact at 80%
420
+ const provider = String(modelConfigRef.current.provider || "anthropic");
421
+ const model = String(modelConfigRef.current.model || "");
422
+ const maxTokens = getContextWindowMax(provider, model);
423
+ const percent = Math.min(100, (u.inputTokens / maxTokens) * 100);
424
+ setContextUsage({ inputTokens: u.inputTokens, maxTokens, percent });
425
+
426
+ if (percent >= 80) {
427
+ const ag = agentRef.current as unknown as Record<string, (...a: unknown[]) => unknown>;
428
+ if (typeof ag.compactHistory === "function") {
429
+ ag.compactHistory();
430
+ addSystemMessage("📦 Context compacted automatically (reached 80%).");
431
+ }
432
+ }
433
+ },
434
+ });
435
+ },
436
+ // eslint-disable-next-line react-hooks/exhaustive-deps
437
+ [isProcessing, resolveContextProviders],
438
+ );
439
+
440
+ // ─────────────────────────────────────────────────────────────────────────
441
+ // handleSlashCommand — dispatch /commands typed in the input
442
+ // ─────────────────────────────────────────────────────────────────────────
443
+
444
+ /**
445
+ * Process a slash command string (e.g. "/model gpt-4o").
446
+ *
447
+ * Returns a string to display in the UI, or null if the command takes over
448
+ * (e.g. /ls opens the session browser, /exit terminates the process).
449
+ *
450
+ * Adding a new command? Just add a new case here.
451
+ */
452
+ const handleSlashCommand = useCallback(
453
+ async (command: string): Promise<string | null> => {
454
+ const parts = command.split(/\s+/);
455
+ const cmd = parts[0]; // e.g. "/model"
456
+ const arg = parts.slice(1).join(" "); // e.g. "gpt-4o"
457
+
458
+ switch (cmd) {
459
+
460
+ // ── Conversation management ─────────────────────────────────────────
461
+
462
+ case "/help":
463
+ return getHelpText();
464
+
465
+ case "/clear":
466
+ // Reset the agent's internal history AND the visible message list
467
+ agentRef.current?.clearHistory();
468
+ setMessages([]);
469
+ return "Conversation cleared.";
470
+
471
+ case "/new":
472
+ agentRef.current?.clearHistory();
473
+ conversationRef.current = createConversation(
474
+ String(modelConfigRef.current.provider || "anthropic"),
475
+ String(modelConfigRef.current.model || "default"),
476
+ );
477
+ setMessages([]);
478
+ return "New conversation started.";
479
+
480
+ case "/history":
481
+ return getConversationListText();
482
+
483
+ case "/ls":
484
+ // Open the interactive TUI session browser (App.tsx renders it)
485
+ setShowSessionBrowser(true);
486
+ return null;
487
+
488
+ case "/resume": {
489
+ if (!arg) return "Usage: /resume <id>";
490
+ const conv = loadConversation(arg);
491
+ if (!conv) return `Conversation not found: ${arg}`;
492
+ agentRef.current?.clearHistory();
493
+ for (const m of conv.messages) {
494
+ if (m.role === "user") agentRef.current?.addToHistory("user", m.content);
495
+ else if (m.role === "assistant") agentRef.current?.addToHistory("assistant", m.content);
496
+ }
497
+ conversationRef.current = conv;
498
+ setMessages(conv.messages.map((m) => ({
499
+ id: nextId(),
500
+ role: m.role as "user" | "assistant",
501
+ content: m.content,
502
+ })));
503
+ return `Resumed conversation: ${arg}`;
504
+ }
505
+
506
+ case "/delete": {
507
+ if (!arg) return "Usage: /delete <id>";
508
+ const ok = deleteConversation(arg);
509
+ return ok ? `Deleted: ${arg}` : `Not found: ${arg}`;
510
+ }
511
+
512
+ case "/fork": {
513
+ // Fork creates a copy of a conversation so you can explore a divergent path
514
+ const sourceId = arg || conversationRef.current.id;
515
+ const forked = forkConversation(sourceId);
516
+ if (!forked) return `Not found: ${sourceId}`;
517
+ return `Forked → new session: ${forked.id}\nTitle: ${forked.title}\nUse /resume ${forked.id} to switch to it.`;
518
+ }
519
+
520
+ // ── Background jobs ─────────────────────────────────────────────────
521
+
522
+ case "/bg": {
523
+ // Run a prompt in the background without blocking the main chat
524
+ if (!arg) return "Usage: /bg <prompt> — run a prompt as a background job";
525
+ const id = jobId();
526
+ const bgJob: BackgroundJob = { id, prompt: arg, status: "running", startedAt: Date.now() };
527
+ setBackgroundJobs((prev) => [...prev, bgJob]);
528
+ addSystemMessage(`⚡ Background job started: ${id}`);
529
+
530
+ // Build a fresh ephemeral agent so it doesn't share history with main chat
531
+ if (!agentRef.current) return "No API key configured. Run /setup first.";
532
+ const bgAgent = new (agentRef.current.constructor as any)(
533
+ modelConfigRef.current, toolRegistryRef.current,
534
+ opts.permissionManager, opts.hookManager,
535
+ );
536
+ let result = "";
537
+ bgAgent.run(arg, {
538
+ onToken: (t: string) => { result += t; },
539
+ onToolCall: () => {},
540
+ onToolResult: () => {},
541
+ onComplete: () => {
542
+ setBackgroundJobs((prev) =>
543
+ prev.map((j) => j.id === id
544
+ ? { ...j, status: "done", result, completedAt: Date.now() }
545
+ : j));
546
+ addSystemMessage(`✅ Background job done: ${id}`);
547
+ },
548
+ onError: (e: Error) => {
549
+ setBackgroundJobs((prev) =>
550
+ prev.map((j) => j.id === id
551
+ ? { ...j, status: "error", error: e.message, completedAt: Date.now() }
552
+ : j));
553
+ addSystemMessage(`❌ Background job failed: ${id} — ${e.message}`);
554
+ },
555
+ }).catch(() => {});
556
+ return `Job ${id} started in background.`;
557
+ }
558
+
559
+ case "/jobs": {
560
+ if (!backgroundJobs.length) return "No background jobs.";
561
+ const jobArg = arg.trim();
562
+ if (jobArg) {
563
+ // /jobs <id> — show full result for one job
564
+ const job = backgroundJobs.find((j) => j.id === jobArg);
565
+ if (!job) return `Job not found: ${jobArg}`;
566
+ const elapsed = job.completedAt
567
+ ? `${((job.completedAt - job.startedAt) / 1000).toFixed(1)}s`
568
+ : "running";
569
+ return [
570
+ `Job: ${job.id} [${job.status}] ${elapsed}`,
571
+ `Prompt: ${job.prompt.substring(0, 100)}`,
572
+ job.result ? `\nResult:\n${job.result}` : "",
573
+ job.error ? `\nError: ${job.error}` : "",
574
+ ].filter(Boolean).join("\n");
575
+ }
576
+ // /jobs — list all jobs
577
+ return backgroundJobs.map((j) => {
578
+ const elapsed = j.completedAt
579
+ ? `${((j.completedAt - j.startedAt) / 1000).toFixed(1)}s`
580
+ : "running…";
581
+ const icon = j.status === "done" ? "✅" : j.status === "error" ? "❌" : "⚡";
582
+ return `${icon} ${j.id} ${j.status.padEnd(8)} ${elapsed} ${j.prompt.substring(0, 50)}`;
583
+ }).join("\n");
584
+ }
585
+
586
+ // ── Model / provider configuration ──────────────────────────────────
587
+
588
+ case "/config": {
589
+ if (arg === "show") {
590
+ return ["Stored Config:", ...getStoredConfigDisplay()].join("\n ");
591
+ }
592
+ if (arg.startsWith("set ")) {
593
+ const sp = arg.slice(4).trim().split(/\s+/);
594
+ const key = sp[0];
595
+ const val = sp.slice(1).join(" ");
596
+ if (!key || !val)
597
+ return "Usage: /config set <key> <value>\nKeys: provider, model, mode, api-key, base-url, oauth-token";
598
+
599
+ // oauth-token is stored in keychain (not config.json) — handle separately
600
+ if (key === "oauth-token") {
601
+ modelConfigRef.current.oauthToken = val;
602
+ modelConfigRef.current.apiKey = undefined;
603
+ rebuildAndRefresh();
604
+ return `OAuth token set (${val.slice(0, 8)}...)`;
605
+ }
606
+
607
+ const res = updateStoredConfig(key, val);
608
+ if (res.success) {
609
+ // Mutate the ref and rebuild so changes take effect immediately
610
+ if (key === "provider") { modelConfigRef.current.provider = val; rebuildAndRefresh(); }
611
+ if (key === "model") { modelConfigRef.current.model = val; rebuildAndRefresh(); }
612
+ if (key === "mode") opts.permissionManager.setMode(parsePermissionMode(val) as PermissionMode);
613
+ if (key === "api-key") { modelConfigRef.current.apiKey = val; modelConfigRef.current.oauthToken = undefined; rebuildAndRefresh(); }
614
+ if (key === "base-url") { modelConfigRef.current.baseURL = val; rebuildAndRefresh(); }
615
+ const masked = key === "api-key" ? val.slice(0, 8) + "..." : val;
616
+ return `Saved: ${key} = ${masked}`;
617
+ }
618
+ return res.error || "Error saving config";
619
+ }
620
+ return [
621
+ `Provider: ${modelConfigRef.current.provider || "anthropic"}`,
622
+ `Model: ${modelConfigRef.current.model || "(default)"}`,
623
+ `Mode: ${opts.permissionManager.getMode()}`,
624
+ `Dir: ${workingDir}`,
625
+ `Chat ID: ${conversationRef.current.id}`,
626
+ ].join("\n");
627
+ }
628
+
629
+ case "/model": {
630
+ if (!arg) {
631
+ const provider = String(modelConfigRef.current.provider || "anthropic");
632
+ const def = getDefaultModel(provider) || "(none)";
633
+ const cur = modelConfigRef.current.model || `(default: ${def})`;
634
+ return [
635
+ `Current model: ${cur}`,
636
+ `Usage: /model <name> — switch to a specific model`,
637
+ ` /model default — reset to provider default (${def})`,
638
+ `Provider models:`,
639
+ ` anthropic: claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5`,
640
+ ` openai: gpt-4o, gpt-4o-mini, o3-mini`,
641
+ ` google: gemini-2.0-flash, gemini-1.5-pro`,
642
+ ` ollama: llama3.1, mistral, codellama`,
643
+ ].join("\n");
644
+ }
645
+ if (arg === "default") {
646
+ modelConfigRef.current.model = undefined;
647
+ rebuildAndRefresh();
648
+ const def = getDefaultModel(String(modelConfigRef.current.provider || "anthropic")) || "provider default";
649
+ return `Model reset to default: ${def}`;
650
+ }
651
+ modelConfigRef.current.model = arg;
652
+ rebuildAndRefresh();
653
+ return `Model switched to: ${arg}`;
654
+ }
655
+
656
+ case "/provider": {
657
+ if (!arg) {
658
+ return [
659
+ `Current provider: ${modelConfigRef.current.provider || "anthropic"}`,
660
+ `Usage: /provider <name> — switch provider`,
661
+ ` /provider default — reset to anthropic + default model`,
662
+ `Options: anthropic, openai, google, ollama`,
663
+ ].join("\n");
664
+ }
665
+ if (arg === "default") {
666
+ modelConfigRef.current.provider = "anthropic";
667
+ modelConfigRef.current.model = undefined;
668
+ modelConfigRef.current.apiKey = undefined;
669
+ rebuildAndRefresh();
670
+ return `Reset to default: anthropic / ${getDefaultModel("anthropic")}`;
671
+ }
672
+ modelConfigRef.current.provider = arg.toLowerCase();
673
+ modelConfigRef.current.model = undefined;
674
+ rebuildAndRefresh();
675
+ return `Provider switched to: ${arg}\nTip: use /model to pick a model`;
676
+ }
677
+
678
+ case "/mode": {
679
+ if (!arg)
680
+ return `Current mode: ${opts.permissionManager.getMode()}\nUsage: /mode <ask|auto-edit|auto>`;
681
+ opts.permissionManager.setMode(parsePermissionMode(arg) as PermissionMode);
682
+ return `Permission mode: ${arg}`;
683
+ }
684
+
685
+ // ── Working directory ────────────────────────────────────────────────
686
+
687
+ case "/dir": {
688
+ if (!arg) return `Working directory: ${workingDir}`;
689
+ const newDir = path.resolve(workingDir, arg);
690
+ if (!fs.existsSync(newDir) || !fs.statSync(newDir).isDirectory())
691
+ return `Not a valid directory: ${newDir}`;
692
+ // Sync all references to the new directory
693
+ workingDirRef.current = newDir;
694
+ setWorkingDir(newDir);
695
+ toolRegistryRef.current = createToolRegistry(newDir);
696
+ opts.permissionManager.setProjectDir(newDir);
697
+ opts.hookManager.setWorkingDir(newDir);
698
+ rebuildAndRefresh();
699
+ return `Working directory: ${newDir}`;
700
+ }
701
+
702
+ // ── Context window & compaction ──────────────────────────────────────
703
+
704
+ case "/compact": {
705
+ const ag = agentRef.current as unknown as Record<string, (...a: unknown[]) => unknown>;
706
+ ag.compactHistory?.();
707
+ return "Context compacted.";
708
+ }
709
+
710
+ // ── Permissions, memory, hooks ───────────────────────────────────────
711
+
712
+ case "/permissions": {
713
+ const pm = opts.permissionManager as unknown as Record<string, (...a: unknown[]) => unknown>;
714
+ if (arg === "clear") { pm.clearStored?.(); return "Stored permissions cleared."; }
715
+ const perms = (pm.getAllStored?.() as Record<string, unknown>) || {};
716
+ const lines = Object.entries(perms);
717
+ return lines.length ? lines.map(([k, v]) => `${k}: ${v}`).join("\n") : "No stored permissions.";
718
+ }
719
+
720
+ case "/memory": {
721
+ const ms = opts.memoryStore as unknown as Record<string, (...a: unknown[]) => unknown>;
722
+ if (arg === "clear") { ms.clear?.(); return "Memory cleared."; }
723
+ return opts.memoryStore.formatForPrompt() || "No memory stored.";
724
+ }
725
+
726
+ case "/hooks": {
727
+ const hm = opts.hookManager as unknown as Record<string, (...a: unknown[]) => unknown>;
728
+ return JSON.stringify(hm.getConfig?.() || {}, null, 2);
729
+ }
730
+
731
+ // ── Usage stats ──────────────────────────────────────────────────────
732
+
733
+ case "/usage": {
734
+ if (!lastUsage) return "No usage data yet.";
735
+ return [
736
+ `Input tokens: ${lastUsage.inputTokens.toLocaleString()}`,
737
+ `Output tokens: ${lastUsage.outputTokens.toLocaleString()}`,
738
+ `Total tokens: ${lastUsage.totalTokens.toLocaleString()}`,
739
+ lastUsage.cost !== undefined ? `Cost: $${lastUsage.cost.toFixed(4)}` : "",
740
+ ].filter(Boolean).join("\n");
741
+ }
742
+
743
+ // ── Tasks & todos ────────────────────────────────────────────────────
744
+
745
+ case "/tasks": {
746
+ const todos = opts.todoStore?.getAll?.() || [];
747
+ if (!todos.length) return "No tasks.";
748
+ return todos
749
+ .map((t: { id: string; status: string; subject: string }) =>
750
+ `[${t.status}] ${t.id}: ${t.subject}`)
751
+ .join("\n");
752
+ }
753
+
754
+ // ── Plan mode ────────────────────────────────────────────────────────
755
+
756
+ case "/plan": {
757
+ if (arg === "off" || arg === "cancel") {
758
+ planModeActiveRef.current = false;
759
+ planManagerRef.current.clearPlan();
760
+ return "Plan mode disabled.";
761
+ }
762
+ if (arg === "show") {
763
+ const plan = planManagerRef.current.getCurrentPlan();
764
+ return plan ? planManagerRef.current.formatPlan() : "No active plan.";
765
+ }
766
+ if (arg === "approve" || arg === "yes") {
767
+ if (planManagerRef.current.approvePlan()) {
768
+ planModeActiveRef.current = false;
769
+ const plan = planManagerRef.current.getCurrentPlan();
770
+ if (plan) {
771
+ planManagerRef.current.startExecution();
772
+ sendMessage(`Execute this plan:\n\n${planManagerRef.current.formatPlan()}\n\nOriginal request: ${plan.originalRequest}`);
773
+ }
774
+ return "Plan approved! Executing...";
775
+ }
776
+ return "No plan to approve.";
777
+ }
778
+ if (arg === "reject" || arg === "no") {
779
+ planManagerRef.current.rejectPlan();
780
+ planModeActiveRef.current = false;
781
+ return "Plan rejected.";
782
+ }
783
+ if (!arg) {
784
+ planModeActiveRef.current = !planModeActiveRef.current;
785
+ return planModeActiveRef.current
786
+ ? "Plan mode ON — next message will generate a plan."
787
+ : "Plan mode OFF.";
788
+ }
789
+ // /plan <request> — immediately generate a plan for the given request
790
+ planModeActiveRef.current = true;
791
+ sendMessage(`[PLAN MODE] Analyze this request and create a step-by-step plan. Do NOT modify files.\n\nRequest: ${arg}`);
792
+ return "Generating plan...";
793
+ }
794
+
795
+ // ── Effort level ─────────────────────────────────────────────────────
796
+
797
+ case "/effort": {
798
+ if (!arg)
799
+ return `Current effort: ${effortManagerRef.current.getLevel()}\nUsage: /effort <low|medium|high|max>`;
800
+ effortManagerRef.current.setLevel(arg as EffortLevel);
801
+ rebuildAndRefresh();
802
+ return `Effort level: ${arg}`;
803
+ }
804
+
805
+ // ── Ephemeral / one-shot questions ───────────────────────────────────
806
+
807
+ case "/btw": {
808
+ // Ask a question that doesn't get added to the conversation history
809
+ if (!arg) return "Usage: /btw <question> (ask without adding to history)";
810
+ setIsProcessing(true);
811
+ if (!agentRef.current) { setIsProcessing(false); return "No API key configured. Run /setup first."; }
812
+ const ephemeralAgent = new (agentRef.current.constructor as any)(
813
+ modelConfigRef.current, toolRegistryRef.current,
814
+ opts.permissionManager, opts.hookManager,
815
+ );
816
+ let reply = "";
817
+ await ephemeralAgent.run(arg, {
818
+ onToken: (t: string) => { reply += t; setStreamingContent(reply); },
819
+ onToolCall: () => {},
820
+ onToolResult: () => {},
821
+ onComplete: () => {
822
+ setMessages((prev) => [...prev, { id: nextId(), role: "assistant", content: reply }]);
823
+ setStreamingContent("");
824
+ setIsProcessing(false);
825
+ },
826
+ onError: (e: Error) => {
827
+ addSystemMessage(`❌ ${e.message}`);
828
+ setStreamingContent("");
829
+ setIsProcessing(false);
830
+ },
831
+ });
832
+ return null; // streaming has taken over the display
833
+ }
834
+
835
+ // ── Misc ─────────────────────────────────────────────────────────────
836
+
837
+ case "/rules":
838
+ return rulesManagerRef.current.formatForDisplay();
839
+
840
+ case "/mcp": {
841
+ const mcp = mcpManagerRef.current as unknown as Record<string, (...a: unknown[]) => unknown>;
842
+ return (mcp.getStatus?.() as string) || "No MCP servers configured.";
843
+ }
844
+
845
+ case "/context": {
846
+ const ps = contextProvidersRef.current.getAll();
847
+ if (!ps.length) return "No context providers registered.";
848
+ return ps.map((p) => `${p.trigger} — ${p.description || ""}`).join("\n");
849
+ }
850
+
851
+ case "/queue": {
852
+ if (!queueRef.current.length) return "No messages in queue.";
853
+ return queueRef.current.map((m, i) => `${i + 1}. ${m.substring(0, 60)}`).join("\n");
854
+ }
855
+
856
+ case "/theme": {
857
+ if (!arg) {
858
+ const current = getThemeName();
859
+ const available = getAvailableThemes().join(", ");
860
+ return `Current theme: ${current}\nUsage: /theme <${available}>`;
861
+ }
862
+ const validThemes = getAvailableThemes();
863
+ if (!validThemes.includes(arg)) {
864
+ return `Unknown theme "${arg}". Available: ${validThemes.join(", ")}`;
865
+ }
866
+ const result = setTheme(arg as any);
867
+ return result;
868
+ }
869
+
870
+ case "/doctor":
871
+ handleDoctor();
872
+ return "Doctor check complete.";
873
+
874
+ case "/init":
875
+ handleInit();
876
+ return "Project initialized.";
877
+
878
+ case "/logout":
879
+ modelConfigRef.current.oauthToken = undefined;
880
+ modelConfigRef.current.apiKey = undefined;
881
+ return oauthLogout() + "\nRun /setup to configure a new API key or log in again.";
882
+
883
+ case "/login":
884
+ return "Use /setup to configure provider, model, and authentication.";
885
+
886
+ case "/auth-status":
887
+ return oauthStatus();
888
+
889
+ case "/exit":
890
+ case "/quit":
891
+ process.exit(0);
892
+
893
+ default:
894
+ return `Unknown command: ${cmd}\nType /help for available commands.`;
895
+ }
896
+ },
897
+ // The deps array includes everything the callback closes over that changes
898
+ [workingDir, lastUsage, sendMessage, backgroundJobs, opts],
899
+ );
900
+
901
+ // ─────────────────────────────────────────────────────────────────────────
902
+ // Public API — what App.tsx destructures from useChat()
903
+ // ─────────────────────────────────────────────────────────────────────────
904
+
905
+ return {
906
+ // Message list (drives the <Static> / stdout rendering in App.tsx)
907
+ messages,
908
+ setMessages,
909
+
910
+ // Live streaming area
911
+ streamingContent,
912
+
913
+ // Processing state
914
+ isProcessing,
915
+ toolActivity,
916
+
917
+ // Token usage (status bar)
918
+ lastUsage,
919
+ contextUsage,
920
+
921
+ // Working directory (status bar + /dir command)
922
+ workingDir,
923
+
924
+ // Background jobs (status bar badge)
925
+ backgroundJobs,
926
+
927
+ // Session browser overlay (/ls command)
928
+ showSessionBrowser,
929
+ setShowSessionBrowser,
930
+
931
+ // Returns all saved conversations (used by SessionBrowser)
932
+ conversations: listConversations,
933
+
934
+ // Current model config (read by StatusBar) — live snapshot, updates on every config change
935
+ modelConfig: liveModelConfig,
936
+
937
+ // Actions
938
+ sendMessage,
939
+ handleSlashCommand,
940
+ cancelCurrent,
941
+ addSystemMessage,
942
+ };
943
+ }