@cdoing/opentuicli 0.1.2

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.
@@ -0,0 +1,1186 @@
1
+ /**
2
+ * Session Route — active chat session with full agent integration
3
+ *
4
+ * Wires up the AgentRunner with streaming callbacks to display:
5
+ * - Token-by-token streaming with cursor
6
+ * - Tool call display with status icons
7
+ * - Permission prompts (via SDK context)
8
+ * - Token usage tracking
9
+ * - Full slash command handling
10
+ * - Session persistence (save/resume/fork/delete)
11
+ * - @mention context expansion
12
+ * - Background jobs (/bg, /jobs)
13
+ * - One-shot questions (/btw)
14
+ * - Shell command auto-detection
15
+ * - OAuth status (/auth-status)
16
+ * - /config set support
17
+ */
18
+
19
+ import { useState, useRef, useEffect } from "react";
20
+ import { TextAttributes } from "@opentui/core";
21
+ import { useKeyboard } from "@opentui/react";
22
+ import * as fs from "fs";
23
+ import * as path from "path";
24
+ import * as os from "os";
25
+ import { MessageList, type Message } from "../components/message-list";
26
+ import { InputArea } from "../components/input-area";
27
+ import { PermissionPrompt } from "../components/permission-prompt";
28
+ import { LoadingSpinner } from "../components/loading-spinner";
29
+ import { useSDK } from "../context/sdk";
30
+ import { useTheme } from "../context/theme";
31
+ import { useToast } from "../components/toast";
32
+ import { setTerminalTitle } from "../lib/terminal-title";
33
+ import { execSync } from "child_process";
34
+ import type { AgentCallbacks, ImageAttachment } from "@cdoing/ai";
35
+ import {
36
+ createConversation,
37
+ addMessage as addHistoryMessage,
38
+ listConversations,
39
+ loadConversation,
40
+ deleteConversation,
41
+ forkConversation,
42
+ formatRelativeDate,
43
+ type Conversation,
44
+ } from "../lib/history";
45
+ import {
46
+ resolveContextProviders,
47
+ hasContextMentions,
48
+ pushTerminalOutput,
49
+ } from "../lib/context-providers";
50
+
51
+ // ── Shell Command Detection ──────────────────────────
52
+
53
+ const SHELL_COMMANDS = new Set([
54
+ "ls", "ll", "la", "pwd", "cd", "mkdir", "rmdir", "rm", "cp", "mv",
55
+ "cat", "head", "tail", "touch", "echo", "env",
56
+ "git", "npm", "yarn", "pnpm", "npx", "node", "ts-node",
57
+ "python", "python3", "pip", "pip3",
58
+ "docker", "docker-compose",
59
+ "grep", "find", "which", "whereis",
60
+ "curl", "wget",
61
+ "chmod", "chown", "ln",
62
+ "ps", "kill", "df", "du",
63
+ "open", "code",
64
+ "vim", "vi", "nano", "less", "more", "man", "top", "htop",
65
+ ]);
66
+
67
+ function detectShellCommand(input: string): string | null {
68
+ const trimmed = input.trim();
69
+ const firstWord = trimmed.split(/\s+/)[0].toLowerCase();
70
+ return SHELL_COMMANDS.has(firstWord) ? trimmed : null;
71
+ }
72
+
73
+ // ── Background Job ──────────────────────────────────
74
+
75
+ interface BackgroundJob {
76
+ id: string;
77
+ prompt: string;
78
+ status: "running" | "done" | "error";
79
+ result?: string;
80
+ error?: string;
81
+ startedAt: number;
82
+ completedAt?: number;
83
+ }
84
+
85
+ // ── Interrupt/Queue Prompt ────────────────────────────
86
+
87
+ function InterruptPrompt(props: {
88
+ message: string;
89
+ onInterrupt: () => void;
90
+ onQueue: () => void;
91
+ onCancel: () => void;
92
+ }) {
93
+ const { theme } = useTheme();
94
+ const t = theme;
95
+ const [selected, setSelected] = useState(0);
96
+ const options = [
97
+ { label: "Interrupt — stop current response and send new message", action: props.onInterrupt },
98
+ { label: "Queue — wait for current response, then send", action: props.onQueue },
99
+ { label: "Cancel — discard new message", action: props.onCancel },
100
+ ];
101
+
102
+ useKeyboard((key: any) => {
103
+ if (key.name === "escape") { props.onCancel(); return; }
104
+ if (key.name === "up" || key.name === "k") { setSelected((s) => Math.max(0, s - 1)); return; }
105
+ if (key.name === "down" || key.name === "j") { setSelected((s) => Math.min(options.length - 1, s + 1)); return; }
106
+ if (key.name === "return") { options[selected].action(); return; }
107
+ // Quick keys
108
+ if (key.sequence === "1" || key.sequence === "i") { props.onInterrupt(); return; }
109
+ if (key.sequence === "2" || key.sequence === "q") { props.onQueue(); return; }
110
+ if (key.sequence === "3") { props.onCancel(); return; }
111
+ });
112
+
113
+ const preview = props.message.length > 50 ? props.message.slice(0, 47) + "..." : props.message;
114
+
115
+ return (
116
+ <box flexDirection="column" flexShrink={0} paddingX={1}>
117
+ <text fg={t.warning} attributes={TextAttributes.BOLD}>
118
+ {" Agent is streaming. What to do with your message?"}
119
+ </text>
120
+ <text fg={t.textDim}>{` "${preview}"`}</text>
121
+ <text>{""}</text>
122
+ {options.map((opt, i) => (
123
+ <text key={i} fg={i === selected ? t.primary : t.textMuted} attributes={i === selected ? TextAttributes.BOLD : undefined}>
124
+ {` ${i === selected ? "❯" : " "} ${i + 1}. ${opt.label}`}
125
+ </text>
126
+ ))}
127
+ <text fg={t.textDim}>{" ↑↓ Navigate Enter Select i/q/Esc Quick keys"}</text>
128
+ </box>
129
+ );
130
+ }
131
+
132
+ // ── Session View ────────────────────────────────────
133
+
134
+ export function SessionView(props: {
135
+ onStatus: (s: string) => void;
136
+ onTokens: (input: number, output: number) => void;
137
+ onActiveTool: (tool: string | undefined) => void;
138
+ onContextPercent: (pct: number) => void;
139
+ onOpenDialog?: (dialog: string) => void;
140
+ initialMessage?: { text: string; images?: ImageAttachment[] } | null;
141
+ }) {
142
+ const sdk = useSDK();
143
+ const { setMode } = useTheme();
144
+ const { toast } = useToast();
145
+
146
+ // Set terminal title to indicate active session
147
+ setTerminalTitle("cdoing - session");
148
+
149
+ const [messages, setMessages] = useState<Message[]>([]);
150
+ const [streamingText, setStreamingText] = useState("");
151
+ const streamingTextRef = useRef(streamingText);
152
+ streamingTextRef.current = streamingText;
153
+ const [isStreaming, setIsStreaming] = useState(false);
154
+ const [activeTool, setActiveTool] = useState<string | undefined>();
155
+ const [pendingPermission, setPendingPermission] = useState<{
156
+ toolName: string;
157
+ message: string;
158
+ resolve: (decision: "allow" | "always" | "deny") => void;
159
+ } | null>(null);
160
+
161
+ // Interrupt/queue prompt state
162
+ const [pendingInterrupt, setPendingInterrupt] = useState<{
163
+ text: string;
164
+ images?: ImageAttachment[];
165
+ } | null>(null);
166
+ const queuedMessagesRef = useRef<string[]>([]);
167
+
168
+ const totalInputRef = useRef(0);
169
+ const totalOutputRef = useRef(0);
170
+ const msgIdCounterRef = useRef(0);
171
+ const conversationRef = useRef<Conversation | null>(null);
172
+ const backgroundJobsRef = useRef<BackgroundJob[]>([]);
173
+ const bgIdCounterRef = useRef(0);
174
+
175
+ // Initialize conversation on first render
176
+ if (!conversationRef.current) {
177
+ conversationRef.current = createConversation(sdk.provider, sdk.model);
178
+ }
179
+
180
+ const addMessage = (role: Message["role"], content: string, extra?: Partial<Message>) => {
181
+ const msg: Message = {
182
+ id: `msg-${++msgIdCounterRef.current}`,
183
+ role,
184
+ content,
185
+ timestamp: Date.now(),
186
+ ...extra,
187
+ };
188
+ setMessages((prev) => [...prev, msg]);
189
+
190
+ // Persist to conversation history
191
+ if (conversationRef.current && (role === "user" || role === "assistant")) {
192
+ addHistoryMessage(conversationRef.current, role, content);
193
+ }
194
+
195
+ return msg.id;
196
+ };
197
+
198
+ const updateMessage = (id: string, updates: Partial<Message>) => {
199
+ setMessages((prev) =>
200
+ prev.map((m) => (m.id === id ? { ...m, ...updates } : m))
201
+ );
202
+ };
203
+
204
+ // ── Helpers ────────────────────────────────────────
205
+
206
+ const loadStoredConfig = (): Record<string, any> => {
207
+ try {
208
+ const configPath = path.join(os.homedir(), ".cdoing", "config.json");
209
+ if (fs.existsSync(configPath)) {
210
+ return JSON.parse(fs.readFileSync(configPath, "utf-8"));
211
+ }
212
+ } catch {}
213
+ return {};
214
+ };
215
+
216
+ const saveConfigKey = (key: string, value: any): void => {
217
+ const configDir = path.join(os.homedir(), ".cdoing");
218
+ const configPath = path.join(configDir, "config.json");
219
+ let config: Record<string, any> = {};
220
+ try {
221
+ if (fs.existsSync(configPath)) {
222
+ config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
223
+ }
224
+ } catch {}
225
+ config[key] = value;
226
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
227
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
228
+ };
229
+
230
+ const loadProjectSettings = (): Record<string, any> => {
231
+ try {
232
+ const settingsPath = path.join(sdk.workingDir, ".cdoing", "settings.json");
233
+ if (fs.existsSync(settingsPath)) {
234
+ return JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
235
+ }
236
+ const claudePath = path.join(sdk.workingDir, ".claude", "settings.json");
237
+ if (fs.existsSync(claudePath)) {
238
+ return JSON.parse(fs.readFileSync(claudePath, "utf-8"));
239
+ }
240
+ } catch {}
241
+ return {};
242
+ };
243
+
244
+ // ── Background Jobs ──────────────────────────────
245
+
246
+ const runBackgroundJob = (prompt: string) => {
247
+ const job: BackgroundJob = {
248
+ id: `bg-${++bgIdCounterRef.current}`,
249
+ prompt,
250
+ status: "running",
251
+ startedAt: Date.now(),
252
+ };
253
+ backgroundJobsRef.current = [...backgroundJobsRef.current, job];
254
+ addMessage("system", `Background job ${job.id} started: ${prompt.substring(0, 60)}${prompt.length > 60 ? "..." : ""}`);
255
+
256
+ // Run in background with a separate callback set
257
+ let bgResult = "";
258
+ const bgCallbacks: AgentCallbacks = {
259
+ onToken: (token) => { bgResult += token; },
260
+ onToolCall: () => {},
261
+ onToolResult: () => {},
262
+ onComplete: () => {
263
+ job.status = "done";
264
+ job.result = bgResult.trim();
265
+ job.completedAt = Date.now();
266
+ backgroundJobsRef.current = [...backgroundJobsRef.current];
267
+ addMessage("system", `Background job ${job.id} completed. Use /jobs ${job.id} to see results.`);
268
+ },
269
+ onError: (error) => {
270
+ job.status = "error";
271
+ job.error = error.message;
272
+ job.completedAt = Date.now();
273
+ backgroundJobsRef.current = [...backgroundJobsRef.current];
274
+ addMessage("system", `Background job ${job.id} failed: ${error.message}`);
275
+ },
276
+ };
277
+
278
+ // Fire and forget
279
+ sdk.agent.run(prompt, bgCallbacks).catch((err) => {
280
+ job.status = "error";
281
+ job.error = err instanceof Error ? err.message : String(err);
282
+ job.completedAt = Date.now();
283
+ });
284
+ };
285
+
286
+ // ── Slash Commands ──────────────────────────────────
287
+
288
+ const handleSlashCommand = (cmd: string) => {
289
+ const [command, ...args] = cmd.split(" ");
290
+ const arg = args.join(" ").trim();
291
+
292
+ switch (command) {
293
+ case "/clear":
294
+ setMessages([]);
295
+ sdk.agent.clearHistory();
296
+ addMessage("system", "Chat cleared.");
297
+ toast("success", "Chat cleared");
298
+ break;
299
+
300
+ case "/new":
301
+ setMessages([]);
302
+ sdk.agent.clearHistory();
303
+ totalInputRef.current = 0;
304
+ totalOutputRef.current = 0;
305
+ props.onTokens(0, 0);
306
+ props.onContextPercent(0);
307
+ conversationRef.current = createConversation(sdk.provider, sdk.model);
308
+ addMessage("system", "New conversation started.");
309
+ toast("success", "New conversation started");
310
+ break;
311
+
312
+ case "/model": {
313
+ if (arg && sdk.rebuildAgent) {
314
+ sdk.rebuildAgent(sdk.provider, arg);
315
+ addMessage("system", `Model switched to: ${arg}`);
316
+ toast("info", `Model: ${arg}`);
317
+ } else if (arg) {
318
+ addMessage("system", "Model switching not available.");
319
+ toast("warning", "Model switching not available");
320
+ } else {
321
+ addMessage("system", `Current model: ${sdk.model}`);
322
+ }
323
+ break;
324
+ }
325
+
326
+ case "/provider": {
327
+ if (arg && sdk.rebuildAgent) {
328
+ const { getDefaultModel } = require("@cdoing/ai");
329
+ const defaultModel = getDefaultModel(arg) || sdk.model;
330
+ sdk.rebuildAgent(arg, defaultModel);
331
+ addMessage("system", `Provider switched to: ${arg} (model: ${defaultModel})`);
332
+ toast("info", `Provider: ${arg} (${defaultModel})`);
333
+ } else if (arg) {
334
+ addMessage("system", "Provider switching not available.");
335
+ toast("warning", "Provider switching not available");
336
+ } else {
337
+ addMessage("system", `Current provider: ${sdk.provider}`);
338
+ }
339
+ break;
340
+ }
341
+
342
+ case "/mode": {
343
+ const currentMode = (sdk.permissionManager as any)?.mode || "ask";
344
+ addMessage("system", `Permission mode: ${currentMode}\nAvailable: ask, auto-edit, auto`);
345
+ break;
346
+ }
347
+
348
+ case "/dir": {
349
+ if (arg) {
350
+ const resolved = path.resolve(sdk.workingDir, arg);
351
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
352
+ if (sdk.setWorkingDir) sdk.setWorkingDir(resolved);
353
+ addMessage("system", `Working directory changed to: ${resolved}`);
354
+ } else {
355
+ addMessage("system", `Directory not found: ${resolved}`);
356
+ }
357
+ } else {
358
+ addMessage("system", `Working directory: ${sdk.workingDir}`);
359
+ }
360
+ break;
361
+ }
362
+
363
+ case "/config": {
364
+ if (arg.startsWith("set ")) {
365
+ const setParts = arg.substring(4).trim().split(/\s+/);
366
+ if (setParts.length >= 2) {
367
+ const [key, ...valParts] = setParts;
368
+ const value = valParts.join(" ");
369
+ if (key === "api-key") {
370
+ const config = loadStoredConfig();
371
+ if (!config.apiKeys) config.apiKeys = {};
372
+ config.apiKeys[sdk.provider] = value;
373
+ saveConfigKey("apiKeys", config.apiKeys);
374
+ addMessage("system", `API key saved for ${sdk.provider}.`);
375
+ } else if (key === "api-key-helper") {
376
+ saveConfigKey("apiKeyHelper", value);
377
+ addMessage("system", `API key helper set to: ${value}`);
378
+ } else {
379
+ saveConfigKey(key, value);
380
+ addMessage("system", `Config ${key} set to: ${value}`);
381
+ }
382
+ } else {
383
+ addMessage("system", "Usage: /config set <key> <value>");
384
+ }
385
+ } else if (arg === "show" || !arg) {
386
+ const config = loadStoredConfig();
387
+ const lines = Object.entries(config)
388
+ .filter(([k]) => k !== "apiKeys")
389
+ .map(([k, v]) => ` ${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`);
390
+ addMessage("system", lines.length > 0 ? "Configuration:\n" + lines.join("\n") : "No configuration found. Run /setup to configure.");
391
+ } else {
392
+ addMessage("system", "Usage: /config [show | set <key> <value>]");
393
+ }
394
+ break;
395
+ }
396
+
397
+ case "/theme": {
398
+ const validModes = ["dark", "light"] as const;
399
+ if (arg && (validModes as readonly string[]).includes(arg)) {
400
+ setMode(arg as "dark" | "light");
401
+ addMessage("system", `Theme mode switched to: ${arg}`);
402
+ toast("success", `Mode: ${arg}`);
403
+ } else {
404
+ addMessage("system", "Usage: /theme dark | light (or Ctrl+T for theme picker)");
405
+ toast("warning", "Usage: /theme dark | light");
406
+ }
407
+ break;
408
+ }
409
+
410
+ case "/compact":
411
+ if ((sdk.agent as any).compressContext) {
412
+ (sdk.agent as any).compressContext();
413
+ addMessage("system", "Context compressed.");
414
+ toast("success", "Context compressed");
415
+ } else {
416
+ addMessage("system", "Context compression not available.");
417
+ toast("warning", "Context compression not available");
418
+ }
419
+ break;
420
+
421
+ case "/effort": {
422
+ const levels = ["low", "medium", "high", "max"];
423
+ if (arg && levels.includes(arg)) {
424
+ addMessage("system", `Effort level set to: ${arg}`);
425
+ toast("info", `Effort: ${arg}`);
426
+ } else {
427
+ addMessage("system", `Usage: /effort <${levels.join("|")}>`);
428
+ }
429
+ break;
430
+ }
431
+
432
+ case "/plan": {
433
+ if (arg === "on") {
434
+ addMessage("system", "Plan mode enabled. Agent will propose a plan before executing.");
435
+ } else if (arg === "off") {
436
+ addMessage("system", "Plan mode disabled.");
437
+ } else if (arg === "show") {
438
+ addMessage("system", "No active plan.");
439
+ } else if (arg === "approve") {
440
+ addMessage("system", "No plan to approve.");
441
+ } else if (arg === "reject") {
442
+ addMessage("system", "No plan to reject.");
443
+ } else {
444
+ addMessage("system", "Usage: /plan <on|off|show|approve|reject>");
445
+ }
446
+ break;
447
+ }
448
+
449
+ case "/permissions": {
450
+ const settings = loadProjectSettings();
451
+ const allow = settings?.allow || [];
452
+ const deny = settings?.deny || [];
453
+ const lines: string[] = ["Permission rules:"];
454
+ if (deny.length > 0) lines.push(" Deny: " + deny.join(", "));
455
+ if (allow.length > 0) lines.push(" Allow: " + allow.join(", "));
456
+ if (deny.length === 0 && allow.length === 0) lines.push(" No custom rules configured.");
457
+ addMessage("system", lines.join("\n"));
458
+ break;
459
+ }
460
+
461
+ case "/hooks": {
462
+ try {
463
+ const hooksPath = path.join(sdk.workingDir, ".cdoing", "hooks.json");
464
+ if (fs.existsSync(hooksPath)) {
465
+ const hooks = JSON.parse(fs.readFileSync(hooksPath, "utf-8"));
466
+ const keys = Object.keys(hooks);
467
+ addMessage("system", keys.length > 0
468
+ ? "Configured hooks:\n" + keys.map((k) => ` ${k}: ${JSON.stringify(hooks[k])}`).join("\n")
469
+ : "No hooks configured.");
470
+ } else {
471
+ addMessage("system", "No hooks file found (.cdoing/hooks.json).");
472
+ }
473
+ } catch {
474
+ addMessage("system", "No hooks configured.");
475
+ }
476
+ break;
477
+ }
478
+
479
+ case "/rules": {
480
+ try {
481
+ const rulesDir = path.join(sdk.workingDir, ".cdoing", "rules");
482
+ if (fs.existsSync(rulesDir)) {
483
+ const files = fs.readdirSync(rulesDir).filter((f: string) => f.endsWith(".md"));
484
+ addMessage("system", files.length > 0
485
+ ? "Project rules:\n" + files.map((f: string) => ` ${f}`).join("\n")
486
+ : "No rules found in .cdoing/rules/.");
487
+ } else {
488
+ addMessage("system", "No rules directory found (.cdoing/rules/).");
489
+ }
490
+ } catch {
491
+ addMessage("system", "No rules configured.");
492
+ }
493
+ break;
494
+ }
495
+
496
+ case "/memory":
497
+ addMessage("system", "Memory store: not yet implemented in TUI. Coming soon.");
498
+ break;
499
+
500
+ case "/tasks":
501
+ addMessage("system", "Task list: not yet implemented in TUI. Coming soon.");
502
+ break;
503
+
504
+ case "/context":
505
+ addMessage("system", [
506
+ "Context providers (use @ to invoke):",
507
+ " @terminal — Recent terminal output",
508
+ " @url — Fetch URL content",
509
+ " @tree — Project file tree",
510
+ " @codebase — Full codebase context",
511
+ " @clip — Clipboard content",
512
+ " @file — Include a file",
513
+ ].join("\n"));
514
+ break;
515
+
516
+ case "/mcp":
517
+ addMessage("system", "MCP server management: not yet implemented in TUI. Coming soon.");
518
+ break;
519
+
520
+ case "/history":
521
+ case "/ls": {
522
+ const convs = listConversations().slice(0, 20);
523
+ if (convs.length > 0) {
524
+ const lines = convs.map((c) => {
525
+ const id = c.id.substring(0, 12);
526
+ const date = formatRelativeDate(c.updatedAt);
527
+ const msgCount = c.messages.filter((m) => m.role === "user").length;
528
+ return ` ${id} ${date.padEnd(10)} (${msgCount} msgs) ${c.title}`;
529
+ });
530
+ addMessage("system", "Conversations:\n" + lines.join("\n") + "\n\nUse /resume <id> to continue. Ctrl+S for interactive browser.");
531
+ } else {
532
+ addMessage("system", "No saved conversations found.");
533
+ }
534
+ break;
535
+ }
536
+
537
+ case "/resume": {
538
+ if (!arg) {
539
+ addMessage("system", "Usage: /resume <conversation-id>");
540
+ break;
541
+ }
542
+ const conv = loadConversation(arg);
543
+ if (!conv) {
544
+ addMessage("system", `Conversation not found: ${arg}`);
545
+ break;
546
+ }
547
+ // Restore conversation
548
+ conversationRef.current = conv;
549
+ setMessages([]);
550
+ sdk.agent.clearHistory();
551
+ totalInputRef.current = 0;
552
+ totalOutputRef.current = 0;
553
+ // Replay messages into UI
554
+ for (const m of conv.messages) {
555
+ if (m.role === "user" || m.role === "assistant") {
556
+ const msg: Message = {
557
+ id: `msg-${++msgIdCounterRef.current}`,
558
+ role: m.role,
559
+ content: m.content,
560
+ timestamp: m.timestamp,
561
+ };
562
+ setMessages((prev) => [...prev, msg]);
563
+ }
564
+ }
565
+ addMessage("system", `Resumed conversation: ${conv.title}`);
566
+ break;
567
+ }
568
+
569
+ case "/view": {
570
+ if (!arg) {
571
+ addMessage("system", "Usage: /view <conversation-id>");
572
+ break;
573
+ }
574
+ const conv = loadConversation(arg);
575
+ if (!conv) {
576
+ addMessage("system", `Conversation not found: ${arg}`);
577
+ break;
578
+ }
579
+ const viewMsgs = conv.messages
580
+ .filter((m) => m.role !== "tool")
581
+ .slice(-20)
582
+ .map((m) => {
583
+ const prefix = m.role === "user" ? "❯" : "◆";
584
+ const content = m.content.length > 100 ? m.content.substring(0, 97) + "..." : m.content;
585
+ return ` ${prefix} ${content.replace(/\n/g, " ")}`;
586
+ });
587
+ addMessage("system", `Conversation: ${conv.title}\n\n${viewMsgs.join("\n")}`);
588
+ break;
589
+ }
590
+
591
+ case "/fork": {
592
+ const sourceConv = arg ? loadConversation(arg) : conversationRef.current;
593
+ if (!sourceConv) {
594
+ addMessage("system", arg ? `Conversation not found: ${arg}` : "No active conversation to fork.");
595
+ break;
596
+ }
597
+ const forked = forkConversation(sourceConv);
598
+ if (forked) {
599
+ addMessage("system", `Forked conversation: ${forked.id.substring(0, 12)} — "${forked.title}"`);
600
+ } else {
601
+ addMessage("system", "Failed to fork conversation.");
602
+ }
603
+ break;
604
+ }
605
+
606
+ case "/delete": {
607
+ if (!arg) {
608
+ addMessage("system", "Usage: /delete <conversation-id>");
609
+ break;
610
+ }
611
+ if (deleteConversation(arg)) {
612
+ addMessage("system", `Conversation deleted: ${arg}`);
613
+ } else {
614
+ addMessage("system", `Conversation not found: ${arg}`);
615
+ }
616
+ break;
617
+ }
618
+
619
+ case "/bg": {
620
+ if (!arg) {
621
+ addMessage("system", "Usage: /bg <prompt>");
622
+ break;
623
+ }
624
+ runBackgroundJob(arg);
625
+ break;
626
+ }
627
+
628
+ case "/jobs": {
629
+ const jobs = backgroundJobsRef.current;
630
+ if (arg) {
631
+ // Show specific job
632
+ const job = jobs.find((j) => j.id === arg);
633
+ if (job) {
634
+ const elapsed = job.completedAt
635
+ ? `${((job.completedAt - job.startedAt) / 1000).toFixed(1)}s`
636
+ : `${((Date.now() - job.startedAt) / 1000).toFixed(1)}s (running)`;
637
+ const result = job.result
638
+ ? job.result.length > 500 ? job.result.substring(0, 497) + "..." : job.result
639
+ : job.error || "(no output)";
640
+ addMessage("system", `Job ${job.id} [${job.status}] (${elapsed}):\n Prompt: ${job.prompt}\n Result: ${result}`);
641
+ } else {
642
+ addMessage("system", `Job not found: ${arg}`);
643
+ }
644
+ } else if (jobs.length > 0) {
645
+ const lines = jobs.map((j) => {
646
+ const icon = j.status === "running" ? "⏳" : j.status === "done" ? "✓" : "✗";
647
+ return ` ${icon} ${j.id} ${j.status} ${j.prompt.substring(0, 50)}${j.prompt.length > 50 ? "..." : ""}`;
648
+ });
649
+ addMessage("system", "Background jobs:\n" + lines.join("\n"));
650
+ } else {
651
+ addMessage("system", "No background jobs.");
652
+ }
653
+ break;
654
+ }
655
+
656
+ case "/btw": {
657
+ if (!arg) {
658
+ addMessage("system", "Usage: /btw <question>");
659
+ break;
660
+ }
661
+ // Ephemeral question — don't add to conversation history
662
+ const btwMsg: Message = {
663
+ id: `msg-${++msgIdCounterRef.current}`,
664
+ role: "user",
665
+ content: `(btw) ${arg}`,
666
+ timestamp: Date.now(),
667
+ };
668
+ setMessages((prev) => [...prev, btwMsg]);
669
+ setIsStreaming(true);
670
+ setStreamingText("");
671
+ props.onStatus("Processing...");
672
+
673
+ let btwResult = "";
674
+ const btwCallbacks: AgentCallbacks = {
675
+ onToken: (token) => {
676
+ btwResult += token;
677
+ setStreamingText((prev) => prev + token);
678
+ },
679
+ onToolCall: () => {},
680
+ onToolResult: () => {},
681
+ onComplete: () => {
682
+ if (btwResult.trim()) {
683
+ const msg: Message = {
684
+ id: `msg-${++msgIdCounterRef.current}`,
685
+ role: "assistant",
686
+ content: btwResult.trim(),
687
+ timestamp: Date.now(),
688
+ };
689
+ setMessages((prev) => [...prev, msg]);
690
+ setStreamingText("");
691
+ }
692
+ setIsStreaming(false);
693
+ props.onStatus("Ready");
694
+ },
695
+ onError: (error) => {
696
+ addMessage("system", `Error: ${error.message}`);
697
+ setIsStreaming(false);
698
+ props.onStatus("Error");
699
+ },
700
+ };
701
+
702
+ sdk.agent.run(arg, btwCallbacks).catch((err) => {
703
+ addMessage("system", `Error: ${err instanceof Error ? err.message : String(err)}`);
704
+ setIsStreaming(false);
705
+ props.onStatus("Error");
706
+ });
707
+ break;
708
+ }
709
+
710
+ case "/login":
711
+ case "/setup":
712
+ if (props.onOpenDialog) {
713
+ props.onOpenDialog("setup");
714
+ } else {
715
+ addMessage("system", "Setup wizard: configure via ~/.cdoing/config.json or run the base CLI with --login.");
716
+ }
717
+ break;
718
+
719
+ case "/logout": {
720
+ try {
721
+ const { fullLogout } = require("@cdoing/core");
722
+ const msg = fullLogout(sdk.provider);
723
+
724
+ // Invalidate the in-memory agent so it can't make further API calls
725
+ sdk.agent.invalidate();
726
+
727
+ addMessage("system", msg);
728
+ } catch {
729
+ addMessage("system", "OAuth logout not available.");
730
+ }
731
+ break;
732
+ }
733
+
734
+ case "/auth-status": {
735
+ const config = loadStoredConfig();
736
+ const lines: string[] = ["Authentication Status:", ""];
737
+
738
+ // API keys
739
+ lines.push("Stored API keys:");
740
+ if (config.apiKeys && Object.keys(config.apiKeys).length > 0) {
741
+ for (const [prov, key] of Object.entries(config.apiKeys)) {
742
+ const k = String(key);
743
+ const masked = k.slice(0, 8) + "..." + k.slice(-4);
744
+ lines.push(` ✓ ${prov}: ${masked}`);
745
+ }
746
+ } else {
747
+ lines.push(" None");
748
+ }
749
+
750
+ lines.push("");
751
+ lines.push("Environment variables:");
752
+ const envVars: [string, string | undefined][] = [
753
+ ["ANTHROPIC_API_KEY", process.env.ANTHROPIC_API_KEY],
754
+ ["OPENAI_API_KEY", process.env.OPENAI_API_KEY],
755
+ ["GOOGLE_API_KEY", process.env.GOOGLE_API_KEY],
756
+ ];
757
+ let hasEnvKey = false;
758
+ for (const [name, value] of envVars) {
759
+ if (value) {
760
+ hasEnvKey = true;
761
+ const masked = value.slice(0, 8) + "..." + value.slice(-4);
762
+ lines.push(` ✓ ${name}: ${masked}`);
763
+ }
764
+ }
765
+ if (!hasEnvKey) lines.push(" None");
766
+
767
+ addMessage("system", lines.join("\n"));
768
+ break;
769
+ }
770
+
771
+ case "/doctor": {
772
+ const checks: string[] = ["System health check:"];
773
+ const config = loadStoredConfig();
774
+ const envKey = process.env[`${sdk.provider.toUpperCase()}_API_KEY`];
775
+ checks.push(` Provider: ${sdk.provider} ${config.apiKeys?.[sdk.provider] || envKey ? "✓ API key found" : "✗ No API key"}`);
776
+ checks.push(` Model: ${sdk.model}`);
777
+ checks.push(` Working dir: ${sdk.workingDir} ${fs.existsSync(sdk.workingDir) ? "✓" : "✗"}`);
778
+ const hasCdoing = fs.existsSync(path.join(sdk.workingDir, ".cdoing"));
779
+ const hasClaude = fs.existsSync(path.join(sdk.workingDir, ".claude"));
780
+ checks.push(` Project config: ${hasCdoing ? ".cdoing/ ✓" : hasClaude ? ".claude/ ✓" : "✗ none"}`);
781
+ checks.push(` Node: ${process.version}`);
782
+ checks.push(` Platform: ${process.platform} ${process.arch}`);
783
+
784
+ // Check conversation history
785
+ const convs = listConversations();
786
+ checks.push(` Conversations: ${convs.length} saved`);
787
+
788
+ // Check background jobs
789
+ const runningJobs = backgroundJobsRef.current.filter((j) => j.status === "running");
790
+ if (runningJobs.length > 0) {
791
+ checks.push(` Background jobs: ${runningJobs.length} running`);
792
+ }
793
+
794
+ addMessage("system", checks.join("\n"));
795
+ break;
796
+ }
797
+
798
+ case "/init": {
799
+ const cdoingDir = path.join(sdk.workingDir, ".cdoing");
800
+ if (fs.existsSync(cdoingDir)) {
801
+ addMessage("system", "Project already initialized (.cdoing/ exists).");
802
+ } else {
803
+ try {
804
+ fs.mkdirSync(cdoingDir, { recursive: true });
805
+ fs.mkdirSync(path.join(cdoingDir, "rules"), { recursive: true });
806
+ fs.writeFileSync(
807
+ path.join(cdoingDir, "config.md"),
808
+ "# Project Configuration\n\nDescribe your project here for the AI assistant.\n",
809
+ "utf-8"
810
+ );
811
+ addMessage("system", "Project initialized. Created .cdoing/ with config.md and rules/.");
812
+ } catch (err) {
813
+ addMessage("system", `Failed to initialize: ${err instanceof Error ? err.message : String(err)}`);
814
+ }
815
+ }
816
+ break;
817
+ }
818
+
819
+ case "/queue":
820
+ addMessage("system", "Message queue is empty.");
821
+ break;
822
+
823
+ case "/help":
824
+ addMessage("system", [
825
+ "Available commands:",
826
+ "",
827
+ " Session",
828
+ " /clear Clear chat history",
829
+ " /new Start new conversation",
830
+ " /compact Compress context window",
831
+ " /btw <question> Ask without adding to history",
832
+ "",
833
+ " Configuration",
834
+ " /model [name] Show/change model",
835
+ " /provider [name] Show/change provider",
836
+ " /mode Show permission mode",
837
+ " /dir [path] Show/change working directory",
838
+ " /config Show configuration",
839
+ " /config set k v Set a config value",
840
+ " /theme <mode> Switch theme (dark/light/auto)",
841
+ " /effort <level> Set effort (low/medium/high/max)",
842
+ " /plan <on|off> Toggle plan mode",
843
+ "",
844
+ " History",
845
+ " /history, /ls List saved conversations",
846
+ " /resume <id> Resume conversation",
847
+ " /view <id> View conversation messages",
848
+ " /fork [id] Fork current/specified conversation",
849
+ " /delete <id> Delete conversation",
850
+ "",
851
+ " Background",
852
+ " /bg <prompt> Run prompt in background",
853
+ " /jobs [id] List/inspect background jobs",
854
+ "",
855
+ " System",
856
+ " /permissions Show permission rules",
857
+ " /hooks Show configured hooks",
858
+ " /rules Show project rules",
859
+ " /context Show context providers",
860
+ " /mcp MCP server management",
861
+ " /doctor System health check",
862
+ " /usage Show token usage",
863
+ " /auth-status Show authentication status",
864
+ "",
865
+ " /setup Run setup wizard",
866
+ " /login Open setup wizard",
867
+ " /logout Clear OAuth tokens",
868
+ " /init Initialize project config",
869
+ " /exit Quit",
870
+ "",
871
+ "Keyboard shortcuts:",
872
+ " Ctrl+V Paste text or image",
873
+ " Ctrl+U Clear input line",
874
+ " Ctrl+W Delete last word",
875
+ " Ctrl+N New session",
876
+ " Ctrl+P Model picker",
877
+ " Ctrl+S Session browser",
878
+ " Tab/→ Accept autocomplete",
879
+ " ↑/↓ Navigate suggestions",
880
+ " Escape Close dropdown",
881
+ "",
882
+ "Shell: prefix with ! or type commands directly (ls, git, npm, etc.)",
883
+ "Context: use @terminal, @url, @tree, @codebase, @clip, @file in messages",
884
+ ].join("\n"));
885
+ break;
886
+
887
+ case "/usage":
888
+ addMessage("system", `Tokens: ${totalInputRef.current.toLocaleString()}→${totalOutputRef.current.toLocaleString()} (${(totalInputRef.current + totalOutputRef.current).toLocaleString()} total)`);
889
+ break;
890
+
891
+ case "/exit":
892
+ case "/quit":
893
+ process.exit(0);
894
+ break;
895
+
896
+ default:
897
+ addMessage("system", `Unknown command: ${command}. Type /help for available commands.`);
898
+ toast("error", `Unknown command: ${command}`);
899
+ }
900
+ };
901
+
902
+ // ── Shell Commands ────────────────────────────────
903
+
904
+ const runShellCommand = (shellCmd: string) => {
905
+ addMessage("user", `!${shellCmd}`);
906
+ let output = "";
907
+ let errorMsg = "";
908
+ try {
909
+ output = execSync(shellCmd, {
910
+ cwd: sdk.workingDir,
911
+ env: { ...process.env },
912
+ encoding: "utf-8",
913
+ timeout: 120000,
914
+ maxBuffer: 10 * 1024 * 1024,
915
+ });
916
+ } catch (err: any) {
917
+ if (err.stdout) output = String(err.stdout);
918
+ if (err.stderr) errorMsg = String(err.stderr);
919
+ if (err.status !== undefined && err.status !== 0) {
920
+ errorMsg += `\n[exited with code ${err.status}]`;
921
+ } else if (!err.stdout && !err.stderr && err.message) {
922
+ errorMsg = err.message;
923
+ }
924
+ }
925
+ const result = (output + (errorMsg ? `\n${errorMsg}` : "")).trim();
926
+ addMessage("system", `$ ${shellCmd}\n${result || "(no output)"}`);
927
+
928
+ // Push to terminal context provider
929
+ pushTerminalOutput(`$ ${shellCmd}\n${result}`);
930
+ };
931
+
932
+ // ── Interrupt: stop streaming, flush partial, send new message with context ──
933
+
934
+ const handleInterrupt = (text: string, images?: ImageAttachment[]) => {
935
+ // Capture partial response and interrupt — adds partial to agent history for context
936
+ const partialResponse = streamingTextRef.current.trim();
937
+ sdk.agent.interrupt(partialResponse);
938
+
939
+ // Flush partial streaming text as a message
940
+ if (partialResponse) {
941
+ addMessage("assistant", partialResponse + "\n\n*(interrupted)*");
942
+ setStreamingText("");
943
+ }
944
+
945
+ setIsStreaming(false);
946
+ props.onStatus("Ready");
947
+ setActiveTool(undefined);
948
+ props.onActiveTool(undefined);
949
+ setPendingInterrupt(null);
950
+
951
+ // Send the new message — agent.cancel() calls onComplete which resets state,
952
+ // so we use a small delay to let that settle
953
+ setTimeout(() => {
954
+ doSendMessage(text, images);
955
+ }, 100);
956
+ };
957
+
958
+ // ── Queue: add to queue, process after current stream finishes ──
959
+
960
+ const handleQueue = (text: string) => {
961
+ queuedMessagesRef.current.push(text);
962
+ addMessage("system", `📬 Queued message (${queuedMessagesRef.current.length} in queue)`);
963
+ setPendingInterrupt(null);
964
+ };
965
+
966
+ // ── Process queued messages after streaming completes ──
967
+
968
+ const processQueue = () => {
969
+ if (queuedMessagesRef.current.length > 0) {
970
+ const next = queuedMessagesRef.current.shift()!;
971
+ setTimeout(() => doSendMessage(next), 100);
972
+ }
973
+ };
974
+
975
+ // ── Send Message ────────────────────────────────────
976
+
977
+ const sendMessage = async (text: string, images?: ImageAttachment[]) => {
978
+ if (text.startsWith("/")) {
979
+ handleSlashCommand(text);
980
+ return;
981
+ }
982
+
983
+ // Shell commands always run immediately
984
+ const shellCmd = text.startsWith("!")
985
+ ? text.slice(1).trim()
986
+ : detectShellCommand(text);
987
+
988
+ if (shellCmd) {
989
+ // Intercept "cd" — execSync runs in a child process, so cd has no effect there
990
+ const shellParts = shellCmd.trim().split(/\s+/);
991
+ if (shellParts[0] === "cd") {
992
+ const target = shellParts.slice(1).join(" ") || process.env.HOME || "/";
993
+ const resolved = path.resolve(sdk.workingDir, target);
994
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
995
+ if (sdk.setWorkingDir) sdk.setWorkingDir(resolved);
996
+ addMessage("system", `Working directory changed to: ${resolved}`);
997
+ } else {
998
+ addMessage("system", `cd: no such directory: ${resolved}`);
999
+ }
1000
+ return;
1001
+ }
1002
+ runShellCommand(shellCmd);
1003
+ return;
1004
+ }
1005
+
1006
+ // If currently streaming, show interrupt/queue prompt
1007
+ if (isStreaming) {
1008
+ setPendingInterrupt({ text, images });
1009
+ return;
1010
+ }
1011
+
1012
+ await doSendMessage(text, images);
1013
+ };
1014
+
1015
+ // Auto-send initial message from home screen input
1016
+ const initialMessageSentRef = useRef(false);
1017
+ useEffect(() => {
1018
+ if (props.initialMessage && !initialMessageSentRef.current) {
1019
+ initialMessageSentRef.current = true;
1020
+ sendMessage(props.initialMessage.text, props.initialMessage.images);
1021
+ }
1022
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
1023
+
1024
+ const doSendMessage = async (text: string, images?: ImageAttachment[]) => {
1025
+ // Resolve @mention context providers
1026
+ let expandedText = text;
1027
+ if (hasContextMentions(text)) {
1028
+ try {
1029
+ expandedText = await resolveContextProviders(text, sdk.workingDir);
1030
+ } catch {
1031
+ // If expansion fails, send original text
1032
+ }
1033
+ }
1034
+
1035
+ addMessage("user", text + (images && images.length > 0 ? ` [${images.length} image${images.length > 1 ? "s" : ""}]` : ""));
1036
+ setIsStreaming(true);
1037
+ setStreamingText("");
1038
+ props.onStatus("Processing...");
1039
+
1040
+ let currentToolId: string | undefined;
1041
+ let turnInput = 0;
1042
+
1043
+ const callbacks: AgentCallbacks = {
1044
+ onToken: (token) => {
1045
+ setStreamingText((prev) => prev + token);
1046
+ },
1047
+
1048
+ onToolCall: (name, input) => {
1049
+ // Flush streaming text to a message
1050
+ const current = streamingTextRef.current.trim();
1051
+ if (current) {
1052
+ addMessage("assistant", current);
1053
+ setStreamingText("");
1054
+ }
1055
+
1056
+ const description = (input as any)?.description || "";
1057
+ currentToolId = addMessage("tool", description, {
1058
+ toolName: name,
1059
+ toolStatus: "running",
1060
+ });
1061
+ setActiveTool(name);
1062
+ props.onActiveTool(name);
1063
+ },
1064
+
1065
+ onToolResult: (_name, result, isError) => {
1066
+ if (currentToolId) {
1067
+ const summary = result.length > 80 ? result.substring(0, 77) + "..." : result;
1068
+ updateMessage(currentToolId, {
1069
+ content: summary,
1070
+ toolStatus: isError ? "error" : "done",
1071
+ isError,
1072
+ });
1073
+ currentToolId = undefined;
1074
+ }
1075
+ setActiveTool(undefined);
1076
+ props.onActiveTool(undefined);
1077
+ },
1078
+
1079
+ onComplete: () => {
1080
+ const current = streamingTextRef.current.trim();
1081
+ if (current) {
1082
+ addMessage("assistant", current);
1083
+ setStreamingText("");
1084
+ }
1085
+
1086
+ setIsStreaming(false);
1087
+ props.onStatus("Ready");
1088
+ setActiveTool(undefined);
1089
+ props.onActiveTool(undefined);
1090
+ props.onTokens(totalInputRef.current, totalOutputRef.current);
1091
+ // Process queued messages
1092
+ processQueue();
1093
+ },
1094
+
1095
+ onError: (error) => {
1096
+ const current = streamingTextRef.current.trim();
1097
+ if (current) {
1098
+ addMessage("assistant", current);
1099
+ setStreamingText("");
1100
+ }
1101
+ addMessage("system", `Error: ${error.message}`);
1102
+ toast("error", error.message);
1103
+ setIsStreaming(false);
1104
+ props.onStatus("Error");
1105
+ setActiveTool(undefined);
1106
+ props.onActiveTool(undefined);
1107
+ },
1108
+
1109
+ onUsage: (usage) => {
1110
+ turnInput += usage.inputTokens;
1111
+ totalInputRef.current += usage.inputTokens;
1112
+ totalOutputRef.current += usage.outputTokens;
1113
+ props.onTokens(totalInputRef.current, totalOutputRef.current);
1114
+ // Estimate context usage (rough: typical 200k window)
1115
+ const pct = Math.min(100, (turnInput / 200000) * 100);
1116
+ props.onContextPercent(pct);
1117
+ },
1118
+ };
1119
+
1120
+ try {
1121
+ await sdk.agent.run(expandedText, callbacks, images);
1122
+ } catch (err) {
1123
+ const msg = err instanceof Error ? err.message : String(err);
1124
+ addMessage("system", `Error: ${msg}`);
1125
+ toast("error", msg);
1126
+ setIsStreaming(false);
1127
+ props.onStatus("Error");
1128
+ }
1129
+ };
1130
+
1131
+ return (
1132
+ <box flexDirection="column" flexGrow={1} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} gap={1}>
1133
+ {/* Scrollable message area — directly in layout for proper flex height (like OpenCode) */}
1134
+ <scrollbox
1135
+ stickyScroll={true}
1136
+ stickyStart="bottom"
1137
+ flexGrow={1}
1138
+ scrollY={true}
1139
+ >
1140
+ <MessageList
1141
+ messages={messages}
1142
+ streamingText={streamingText}
1143
+ isStreaming={isStreaming}
1144
+ />
1145
+ </scrollbox>
1146
+
1147
+ {/* Fixed bottom area — never pushed off screen */}
1148
+ <box flexDirection="column" flexShrink={0}>
1149
+ {/* Permission prompt overlay */}
1150
+ {pendingPermission && (
1151
+ <PermissionPrompt
1152
+ toolName={pendingPermission.toolName}
1153
+ message={pendingPermission.message}
1154
+ onDecision={(decision) => {
1155
+ pendingPermission.resolve(decision);
1156
+ setPendingPermission(null);
1157
+ }}
1158
+ />
1159
+ )}
1160
+
1161
+ {/* Interrupt/Queue prompt */}
1162
+ {pendingInterrupt && (
1163
+ <InterruptPrompt
1164
+ message={pendingInterrupt.text}
1165
+ onInterrupt={() => handleInterrupt(pendingInterrupt.text, pendingInterrupt.images)}
1166
+ onQueue={() => handleQueue(pendingInterrupt.text)}
1167
+ onCancel={() => setPendingInterrupt(null)}
1168
+ />
1169
+ )}
1170
+
1171
+ {/* Loading spinner */}
1172
+ {isStreaming && !streamingText && !pendingInterrupt && (
1173
+ <LoadingSpinner label={activeTool || "Thinking..."} />
1174
+ )}
1175
+
1176
+ {/* Input area */}
1177
+ <InputArea
1178
+ onSubmit={sendMessage}
1179
+ disabled={false}
1180
+ placeholder={isStreaming ? "Type to interrupt or queue..." : undefined}
1181
+ workingDir={sdk.workingDir}
1182
+ />
1183
+ </box>
1184
+ </box>
1185
+ );
1186
+ }