markov-cli 1.0.6 → 1.0.8

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.
@@ -1,265 +1,19 @@
1
1
  import chalk from 'chalk';
2
+
2
3
  import gradient from 'gradient-string';
3
4
  import { homedir } from 'os';
4
- import { resolve, relative, isAbsolute } from 'path';
5
- import { mkdirSync, rmSync, writeFileSync, unlinkSync, existsSync } from 'fs';
6
- import { join } from 'path';
5
+ import { resolve } from 'path';
6
+ import { mkdirSync, writeFileSync } from 'fs';
7
7
  import { printLogo } from './ui/logo.js';
8
- import { streamChat, MODEL, MODELS, setModel } from './ollama.js';
8
+ import { chatWithTools, MODEL, MODELS, setModel } from './ollama.js';
9
9
  import { resolveFileRefs } from './files.js';
10
- import { execCommand } from './tools.js';
11
- import { parseEdits, applyEdit, renderDiff } from './editor.js';
10
+ import { execCommand, AGENT_TOOLS, runTool } from './tools.js';
11
+ import { parseEdits, renderDiff, applyEdit } from './editor.js';
12
12
  import { chatPrompt } from './input.js';
13
13
  import { getFiles, getFilesAndDirs } from './ui/picker.js';
14
+ import { getToken, login, clearToken } from './auth.js';
14
15
 
15
- const markovGradient = gradient(['#6366f1', '#a855f7', '#ec4899']);
16
-
17
- /** If path is absolute and under cwd, return relative path; otherwise return as-is. */
18
- function toRelativePath(p) {
19
- const cwd = process.cwd();
20
- if (!isAbsolute(p)) return p;
21
- const rel = relative(cwd, p);
22
- if (!rel.startsWith('..') && !isAbsolute(rel)) return rel;
23
- return p;
24
- }
25
-
26
- /** Extract every !!run: command from a model response. */
27
- function parseRunCommands(text) {
28
- const commands = [];
29
- for (const line of text.split('\n')) {
30
- const match = line.match(/^!!run:\s*(.+)$/);
31
- if (match) commands.push(match[1].trim());
32
- }
33
- return commands;
34
- }
35
-
36
- /** Extract every !!mkdir: path from a model response. */
37
- function parseMkdirCommands(text) {
38
- const paths = [];
39
- for (const line of text.split('\n')) {
40
- const match = line.match(/^!!mkdir:\s*(.+)$/);
41
- if (match) paths.push(match[1].trim());
42
- }
43
- return paths;
44
- }
45
-
46
- /** Extract every !!rmdir: path from a model response. */
47
- function parseRmdirCommands(text) {
48
- const paths = [];
49
- for (const line of text.split('\n')) {
50
- const match = line.match(/^!!rmdir:\s*(.+)$/);
51
- if (match) paths.push(match[1].trim());
52
- }
53
- return paths;
54
- }
55
-
56
- /** Extract every !!touch: path from a model response. */
57
- function parseTouchCommands(text) {
58
- const paths = [];
59
- for (const line of text.split('\n')) {
60
- const match = line.match(/^!!touch:\s*(.+)$/);
61
- if (match) paths.push(match[1].trim());
62
- }
63
- return paths;
64
- }
65
-
66
- /** Extract every !!delete: path from a model response. */
67
- function parseDeleteCommands(text) {
68
- const paths = [];
69
- for (const line of text.split('\n')) {
70
- const match = line.match(/^!!delete:\s*(.+)$/);
71
- if (match) paths.push(match[1].trim());
72
- }
73
- return paths;
74
- }
75
-
76
- /** Extract every !!write: path and its following fenced code block from a model response. */
77
- function parseWriteCommands(text) {
78
- const edits = [];
79
- const lines = text.split('\n');
80
- let i = 0;
81
- const blockRegex = /```(?:[\w./\-]*)\n([\s\S]*?)```/;
82
- while (i < lines.length) {
83
- const line = lines[i];
84
- const writeMatch = line.match(/^!!write:\s*(.+)$/);
85
- if (writeMatch) {
86
- const filepath = writeMatch[1].trim();
87
- if (filepath) {
88
- const rest = lines.slice(i + 1).join('\n');
89
- const blockMatch = rest.match(blockRegex);
90
- if (blockMatch) {
91
- edits.push({ filepath, content: blockMatch[1] });
92
- const blockText = blockMatch[0];
93
- const beforeBlock = rest.substring(0, rest.indexOf(blockText));
94
- const linesBeforeBlock = beforeBlock.split('\n').length;
95
- const linesInBlock = blockText.split('\n').length;
96
- i += 1 + linesBeforeBlock + linesInBlock;
97
- continue;
98
- }
99
- }
100
- }
101
- i++;
102
- }
103
- return edits;
104
- }
105
-
106
- /**
107
- * Parse and apply all file operations from a model reply.
108
- * Shows diffs/confirmations for each op. Returns updated allFiles list.
109
- * options.autoConfirm: if true, skip y/n prompts and apply all ops.
110
- */
111
- async function handleFileOps(reply, loadedFiles, options = {}) {
112
- const autoConfirm = options.autoConfirm === true;
113
- let allFiles = getFilesAndDirs();
114
-
115
- // Create folders first (so !!run: cd <folder> can succeed later)
116
- for (const folderPath of parseMkdirCommands(reply)) {
117
- const path = toRelativePath(folderPath);
118
- process.stdout.write(chalk.dim(` mkdir: ${path} — `));
119
- const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Create folder ${chalk.cyan(path)}? [y/N] `));
120
- if (confirmed) {
121
- try {
122
- mkdirSync(resolve(process.cwd(), path), { recursive: true });
123
- allFiles = getFilesAndDirs();
124
- console.log(chalk.green(`✓ created ${path}\n`));
125
- } catch (err) {
126
- console.log(chalk.red(`✗ could not create ${path}: ${err.message}\n`));
127
- }
128
- } else {
129
- console.log(chalk.dim('skipped\n'));
130
- }
131
- }
132
-
133
- // Create empty files
134
- for (const filePath of parseTouchCommands(reply)) {
135
- const path = toRelativePath(filePath);
136
- const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Create file ${chalk.cyan(path)}? [y/N] `));
137
- if (confirmed) {
138
- try {
139
- const abs = resolve(process.cwd(), path);
140
- const parentDir = abs.split('/').slice(0, -1).join('/');
141
- mkdirSync(parentDir, { recursive: true });
142
- writeFileSync(abs, '', { flag: 'wx' });
143
- allFiles = getFilesAndDirs();
144
- console.log(chalk.green(`✓ created ${path}\n`));
145
- } catch (err) {
146
- console.log(chalk.red(`✗ could not create ${path}: ${err.message}\n`));
147
- }
148
- } else {
149
- console.log(chalk.dim('skipped\n'));
150
- }
151
- }
152
-
153
- // Write files via !!write: path + fenced block
154
- for (const { filepath, content } of parseWriteCommands(reply)) {
155
- const path = toRelativePath(filepath);
156
- renderDiff(path, content);
157
- const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Write ${chalk.cyan(path)}? [y/N] `));
158
- if (confirmed) {
159
- try {
160
- applyEdit(path, content);
161
- allFiles = getFilesAndDirs();
162
- console.log(chalk.green(`✓ wrote ${path}\n`));
163
- } catch (err) {
164
- console.log(chalk.red(`✗ could not write ${path}: ${err.message}\n`));
165
- }
166
- } else {
167
- console.log(chalk.dim('skipped\n'));
168
- }
169
- }
170
-
171
- // Write / edit files (fenced blocks with path/language or single attached file)
172
- const edits = parseEdits(reply, loadedFiles);
173
- for (const { filepath, content } of edits) {
174
- const path = toRelativePath(filepath);
175
- renderDiff(path, content);
176
- const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Write ${chalk.cyan(path)}? [y/N] `));
177
- if (confirmed) {
178
- try {
179
- applyEdit(path, content);
180
- allFiles = getFilesAndDirs();
181
- console.log(chalk.green(`✓ wrote ${path}\n`));
182
- } catch (err) {
183
- console.log(chalk.red(`✗ could not write ${path}: ${err.message}\n`));
184
- }
185
- } else {
186
- console.log(chalk.dim('skipped\n'));
187
- }
188
- }
189
-
190
- // Run terminal commands (after folders/files exist, so cd works)
191
- for (const cmd of parseRunCommands(reply)) {
192
- const ok = autoConfirm ? true : await confirm(chalk.bold(`Run: ${chalk.cyan(cmd)}? [y/N] `));
193
- if (ok) {
194
- // cd must be handled in-process — child processes can't change the parent's cwd
195
- const cdMatch = cmd.match(/^cd\s+(.+)$/);
196
- if (cdMatch) {
197
- const target = resolve(process.cwd(), cdMatch[1].trim().replace(/^~/, homedir()));
198
- try {
199
- process.chdir(target);
200
- allFiles = getFilesAndDirs();
201
- console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
202
- } catch (err) {
203
- console.log(chalk.red(` no such directory: ${target}\n`));
204
- }
205
- continue;
206
- }
207
- process.stdout.write(chalk.dim(` running: ${cmd}\n`));
208
- const { stdout, stderr, exitCode } = await execCommand(cmd);
209
- const output = [stdout, stderr].filter(Boolean).join('\n').trim();
210
- console.log(output ? chalk.dim(output) + '\n' : chalk.dim(` (exit ${exitCode})\n`));
211
- allFiles = getFilesAndDirs();
212
- } else {
213
- console.log(chalk.dim('skipped\n'));
214
- }
215
- }
216
-
217
- // Remove directories
218
- for (const dirPath of parseRmdirCommands(reply)) {
219
- const path = toRelativePath(dirPath);
220
- const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Remove directory ${chalk.cyan(path)}? [y/N] `));
221
- if (confirmed) {
222
- const abs = resolve(process.cwd(), path);
223
- if (existsSync(abs)) {
224
- try {
225
- rmSync(abs, { recursive: true, force: true });
226
- allFiles = getFilesAndDirs();
227
- console.log(chalk.green(`✓ removed ${path}\n`));
228
- } catch (err) {
229
- console.log(chalk.red(`✗ could not remove ${path}: ${err.message}\n`));
230
- }
231
- } else {
232
- console.log(chalk.yellow(`⚠ not found: ${path}\n`));
233
- }
234
- } else {
235
- console.log(chalk.dim('skipped\n'));
236
- }
237
- }
238
-
239
- // Delete files
240
- for (const filePath of parseDeleteCommands(reply)) {
241
- const path = toRelativePath(filePath);
242
- const confirmed = autoConfirm ? true : await confirm(chalk.bold(`Delete ${chalk.cyan(path)}? [y/N] `));
243
- if (confirmed) {
244
- const abs = resolve(process.cwd(), path);
245
- if (existsSync(abs)) {
246
- try {
247
- unlinkSync(abs);
248
- allFiles = getFilesAndDirs();
249
- console.log(chalk.green(`✓ deleted ${path}\n`));
250
- } catch (err) {
251
- console.log(chalk.red(`✗ could not delete ${path}: ${err.message}\n`));
252
- }
253
- } else {
254
- console.log(chalk.yellow(`⚠ not found: ${path}\n`));
255
- }
256
- } else {
257
- console.log(chalk.dim('skipped\n'));
258
- }
259
- }
260
-
261
- return allFiles;
262
- }
16
+ const agentGradient = gradient(['#22c55e', '#16a34a', '#4ade80']);
263
17
 
264
18
  /** Arrow-key selector. Returns the chosen string or null if cancelled. */
265
19
  function selectFrom(options, label) {
@@ -376,7 +130,6 @@ function promptSecret(label) {
376
130
  });
377
131
  }
378
132
 
379
- const VIEWPORT_LINES = 5;
380
133
  const TERM_WIDTH = 80;
381
134
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
382
135
  const visLen = (s) => s.replace(ANSI_RE, '').length;
@@ -402,148 +155,375 @@ function wrapText(text, width) {
402
155
  }).join('\n');
403
156
  }
404
157
 
405
- /** Count the number of terminal rows a list of lines occupies, accounting for wrapping. */
406
- function countRows(lines, w) {
407
- return lines.reduce((sum, l) => sum + Math.max(1, Math.ceil(visLen(l) / w)), 0);
158
+ /** Extract numbered options (1. foo 2. bar 3. baz) from AI response text. Returns display strings or []. */
159
+ function extractNumberedOptions(text) {
160
+ if (!text) return [];
161
+ const re = /^\s*(\d+)\.\s+(.+)$/gm;
162
+ const items = [];
163
+ let match;
164
+ while ((match = re.exec(text)) !== null) {
165
+ items.push({ num: parseInt(match[1], 10), label: match[2].trim() });
166
+ }
167
+ let best = [];
168
+ for (let i = 0; i < items.length; i++) {
169
+ if (items[i].num !== 1) continue;
170
+ const run = [items[i]];
171
+ for (let j = i + 1; j < items.length; j++) {
172
+ if (items[j].num === run.length + 1) run.push(items[j]);
173
+ else break;
174
+ }
175
+ if (run.length > best.length) best = run;
176
+ }
177
+ return best.length >= 2 ? best.map(r => `${r.num}. ${r.label}`) : [];
178
+ }
179
+
180
+ /** Parse fenced code blocks (```lang\n...\n```) and render them with plain styling. Non-code segments are wrapped. */
181
+ function formatResponseWithCodeBlocks(text, width) {
182
+ if (!text || typeof text !== 'string') return '';
183
+ const re = /```(\w*)\n([\s\S]*?)```/g;
184
+ const parts = [];
185
+ let lastIndex = 0;
186
+ let m;
187
+ while ((m = re.exec(text)) !== null) {
188
+ if (m.index > lastIndex) {
189
+ const textSegment = text.slice(lastIndex, m.index);
190
+ if (textSegment) parts.push({ type: 'text', content: textSegment });
191
+ }
192
+ parts.push({ type: 'code', lang: m[1], content: m[2].trim() });
193
+ lastIndex = re.lastIndex;
194
+ }
195
+ if (lastIndex < text.length) {
196
+ const textSegment = text.slice(lastIndex);
197
+ if (textSegment) parts.push({ type: 'text', content: textSegment });
198
+ }
199
+ if (parts.length === 0) return wrapText(text, width);
200
+ return parts.map((p) => {
201
+ if (p.type === 'text') return wrapText(p.content, width);
202
+ const label = p.lang ? p.lang : 'code';
203
+ const header = chalk.dim('─── ') + chalk.cyan(label) + chalk.dim(' ' + '─'.repeat(Math.max(0, width - label.length - 5)));
204
+ const code = p.content.split('\n').map(l => chalk.dim(' ') + l).join('\n');
205
+ return header + '\n' + code + '\n' + chalk.dim('─'.repeat(width));
206
+ }).join('\n\n');
408
207
  }
409
208
 
410
209
  /**
411
- * Stream a chat response with a live 5-line viewport.
412
- * While streaming, only the last VIEWPORT_LINES lines are shown in-place.
413
- * After streaming finishes, the viewport is replaced with the full response.
414
- * Returns the full reply string.
210
+ * Scan AI response text for fenced code blocks that reference files.
211
+ * Show a diff preview for each and apply on user confirmation.
212
+ * @param {string} responseText - The AI's response content
213
+ * @param {string[]} loadedFiles - Files attached via @ref
415
214
  */
416
- function buildSystemMessage() {
215
+ async function applyCodeBlockEdits(responseText, loadedFiles = []) {
216
+ const edits = parseEdits(responseText, loadedFiles);
217
+ if (edits.length === 0) return;
218
+
219
+ console.log(chalk.dim(`\n📝 Detected ${edits.length} file edit(s) in response:\n`));
220
+
221
+ for (const edit of edits) {
222
+ renderDiff(edit.filepath, edit.content);
223
+ const ok = await confirm(chalk.bold(`Apply changes to ${chalk.cyan(edit.filepath)}? [y/N] `));
224
+ if (ok) {
225
+ applyEdit(edit.filepath, edit.content);
226
+ console.log(chalk.green(` ✓ ${edit.filepath} updated\n`));
227
+ } else {
228
+ console.log(chalk.dim(` ✗ skipped ${edit.filepath}\n`));
229
+ }
230
+ }
231
+ }
232
+
233
+ /** Shared system message base: Markov intro, cwd, file list. */
234
+ function getSystemMessageBase() {
417
235
  const files = getFiles();
418
236
  const fileList = files.length > 0 ? `\nFiles in working directory:\n${files.map(f => ` ${f}`).join('\n')}\n` : '';
419
- const fileOpsInstructions =
420
- `\nFILE OPERATIONS — use these exact syntaxes when needed:\n` +
421
- `- Always use RELATIVE paths (e.g. path/to/file or markov/next-app/README.md), never absolute paths like /Users/...\n` +
422
- `- Run a terminal command: output exactly on its own line: !!run: <command>\n` +
423
- `- Write or edit a file: output exactly on its own line !!write: path/to/file, then a fenced code block with the full file content.\n` +
424
- `- Create an empty file: output exactly on its own line: !!touch: path/to/file\n` +
425
- `- Create a folder: output exactly on its own line: !!mkdir: path/to/folder\n` +
426
- `- Remove a folder: output exactly on its own line: !!rmdir: path/to/folder\n` +
427
- `- Delete a file: output exactly on its own line: !!delete: path/to/file\n` +
428
- `- You may combine multiple operations in one response.\n` +
429
- `- NEVER put commands in fenced code blocks — always use !!run: syntax for commands.\n` +
430
- `- NEVER use && inside !!run: — use one !!run: per command. For "cd then do X", output !!run: cd <path> on one line, then !!run: <command> on the next (cd changes the working directory for all following commands).\n` +
431
- `\nSETUP NEXT.JS APP (or any new project in a subfolder):\n` +
432
- `1. Create the folder first: !!mkdir: next-app (or the requested name).\n` +
433
- `2. Change into it on its own line: !!run: cd next-app (nothing after the path).\n` +
434
- `3. Run each following command on its own !!run: line: e.g. !!run: npx create-next-app@latest . --yes, then !!run: git init, !!run: git add ., !!run: git commit -m "Initial commit".\n`;
435
- return { role: 'system', content: `You are Markov, an AI coding assistant.\nWorking directory: ${process.cwd()}\n${fileList}${fileOpsInstructions}` };
237
+ return `You are Markov, an AI coding assistant.\nWorking directory: ${process.cwd()}\n${fileList}`;
436
238
  }
437
239
 
438
- async function streamWithViewport(chatMessages, signal) {
439
- // Re-resolve @file refs fresh on every request so the model always sees the latest file contents.
440
- const resolvedMessages = chatMessages.map(msg => {
441
- if (msg.role !== 'user') return msg;
442
- const { content } = resolveFileRefs(msg.content);
443
- return { ...msg, content };
444
- });
445
-
446
- const DOTS = ['.', '..', '...'];
447
- let dotIdx = 0;
448
- let firstToken = true;
449
- let fullText = '';
450
- let viewportRows = 0; // actual terminal rows currently rendered in the viewport
240
+ /** System message for agent mode: tool-only instructions (no !!run / !!write). */
241
+ function buildAgentSystemMessage() {
242
+ const toolInstructions =
243
+ `\nTOOL MODE you have tools; use them. \n` +
244
+ `- run_terminal_command: run shell commands (npm install, npx create-next-app, etc.). One command per call.\n` +
245
+ `- create_folder: create directories.\n` +
246
+ `- read_file: read file contents before editing.\n` +
247
+ `- write_file: create or overwrite a file with full content.\n` +
248
+ `- search_replace: replace first occurrence of text in a file.\n` +
249
+ `- delete_file: delete a file.\n` +
250
+ `- list_dir: list directory contents (path optional, defaults to current dir).\n` +
251
+ `When the user asks to run commands, create/edit/delete files, scaffold projects, or to "plan" or "start" an app (e.g. npm run dev), call these tools — do not only list commands or describe steps in your reply. Execute with tool calls.\n` +
252
+ `When the user has ATTACHED FILES (FILE: path ... in the message), any edit, add, or fix they ask for in those files MUST be done with write_file or search_replace — never by pasting the modified file content in your reply.\n` +
253
+ `Use RELATIVE paths.\n`;
254
+ return { role: 'system', content: getSystemMessageBase() + toolInstructions };
255
+ }
451
256
 
452
- process.stdout.write(chalk.dim('\nMarkov '));
453
- const spinner = setInterval(() => {
454
- process.stdout.write('\r' + chalk.dim('Markov ') + markovGradient(DOTS[dotIdx % DOTS.length]) + ' ');
455
- dotIdx++;
456
- }, 400);
257
+ const AGENT_LOOP_MAX_ITERATIONS = 20;
258
+
259
+ /** Preview of a file edit for confirmation (write_file / search_replace) */
260
+ function formatFileEditPreview(name, args) {
261
+ const path = args?.path ?? '(no path)';
262
+ if (name === 'search_replace') {
263
+ const oldStr = String(args?.old_string ?? '');
264
+ const newStr = String(args?.new_string ?? '');
265
+ const max = 120;
266
+ const oldPreview = oldStr.length > max ? oldStr.slice(0, max) + '…' : oldStr;
267
+ const newPreview = newStr.length > max ? newStr.slice(0, max) + '…' : newStr;
268
+ return (
269
+ chalk.cyan(path) + '\n' +
270
+ chalk.red(' - ' + (oldPreview || '(empty)').replace(/\n/g, '\n ')) + '\n' +
271
+ chalk.green(' + ' + (newPreview || '(empty)').replace(/\n/g, '\n '))
272
+ );
273
+ }
274
+ if (name === 'write_file') {
275
+ const content = String(args?.content ?? '');
276
+ const lines = content.split('\n');
277
+ const previewLines = lines.slice(0, 25);
278
+ const more = lines.length > 25 ? chalk.dim(` ... ${lines.length - 25} more lines`) : '';
279
+ return chalk.cyan(path) + '\n' + previewLines.map(l => ' ' + l).join('\n') + (more ? '\n' + more : '');
280
+ }
281
+ return path;
282
+ }
457
283
 
458
- const onCancel = (data) => { if (data.toString() === '\x11') signal.abort(); };
459
- process.stdin.setRawMode(true);
460
- process.stdin.resume();
461
- process.stdin.setEncoding('utf8');
462
- process.stdin.on('data', onCancel);
284
+ /** One-line summary of tool args for display */
285
+ function formatToolCallSummary(name, args) {
286
+ const a = args ?? {};
287
+ if (name === 'run_terminal_command') return (a.command ?? '').trim() || '(empty)';
288
+ if (name === 'write_file') return a.path ?? '(no path)';
289
+ if (name === 'search_replace') return (a.path ?? '') + (a.old_string ? ` "${String(a.old_string).slice(0, 30)}…"` : '');
290
+ if (name === 'read_file' || name === 'delete_file') return a.path ?? '(no path)';
291
+ if (name === 'create_folder') return a.path ?? '(no path)';
292
+ if (name === 'list_dir') return (a.path ?? '.') || '.';
293
+ return JSON.stringify(a).slice(0, 50);
294
+ }
463
295
 
296
+ /** One-line summary of tool result for display */
297
+ function formatToolResultSummary(name, resultJson) {
298
+ let obj;
464
299
  try {
465
- const reply = await streamChat([buildSystemMessage(), ...resolvedMessages], (token) => {
466
- if (firstToken) {
467
- clearInterval(spinner);
468
- firstToken = false;
469
- process.stdout.write('\r\x1b[0J' + chalk.dim('Markov ›\n\n'));
470
- }
300
+ obj = typeof resultJson === 'string' ? JSON.parse(resultJson) : resultJson;
301
+ } catch {
302
+ return resultJson?.slice(0, 60) ?? '—';
303
+ }
304
+ if (obj.error) return chalk.red('' + obj.error);
305
+ if (obj.declined) return chalk.yellow('✗ declined');
306
+ if (name === 'run_terminal_command') {
307
+ const code = obj.exitCode ?? obj.exit_code;
308
+ if (code === 0) return chalk.green('✓ exit 0') + (obj.stdout ? chalk.dim(' ' + String(obj.stdout).trim().slice(0, 80).replace(/\n/g, ' ')) : '');
309
+ return chalk.red(`✗ exit ${code}`) + (obj.stderr ? chalk.dim(' ' + String(obj.stderr).trim().slice(0, 80)) : '');
310
+ }
311
+ if (name === 'write_file' || name === 'search_replace' || name === 'delete_file' || name === 'create_folder') {
312
+ return obj.success !== false ? chalk.green('✓ ' + (obj.path ? obj.path : 'ok')) : chalk.red('✗ ' + (obj.error || 'failed'));
313
+ }
314
+ if (name === 'read_file') return obj.content != null ? chalk.green('✓ ' + (obj.path ?? '') + chalk.dim(` (${String(obj.content).length} chars)`)) : chalk.red('✗ ' + (obj.error || ''));
315
+ if (name === 'list_dir') return obj.entries ? chalk.green('✓ ' + (obj.entries.length ?? 0) + ' entries') : chalk.red('✗ ' + (obj.error || ''));
316
+ return chalk.dim(JSON.stringify(obj).slice(0, 60));
317
+ }
471
318
 
472
- fullText += token;
319
+ /**
320
+ * Run the agent loop: call chatWithTools, run any tool_calls locally, append results, repeat until the model returns a final response.
321
+ * For write_file and search_replace, confirmFileEdit is called first; if it returns false, the change is skipped and the model is told the user declined.
322
+ * @param {Array<{ role: string; content?: string; tool_calls?: unknown[]; tool_name?: string }>} messages - Full message list (system + conversation)
323
+ * @param {{ signal?: AbortSignal; cwd?: string; confirmFn?: (command: string) => Promise<boolean>; confirmFileEdit?: (name: string, args: object) => Promise<boolean>; onBeforeToolRun?: () => void; onToolCall?: (name: string, args: object) => void; onToolResult?: (name: string, result: string) => void }} opts
324
+ * @returns {Promise<{ content: string; finalMessage: object } | null>} Final assistant content and message, or null if cancelled/error
325
+ */
326
+ async function runAgentLoop(messages, opts = {}) {
327
+ const cwd = opts.cwd ?? process.cwd();
328
+ const confirmFn = opts.confirmFn;
329
+ const confirmFileEdit = opts.confirmFileEdit;
330
+ const onBeforeToolRun = opts.onBeforeToolRun;
331
+ const onToolCall = opts.onToolCall;
332
+ const onToolResult = opts.onToolResult;
333
+ const onIteration = opts.onIteration;
334
+ const onThinking = opts.onThinking;
335
+ let iteration = 0;
336
+
337
+ while (iteration < AGENT_LOOP_MAX_ITERATIONS) {
338
+ iteration += 1;
339
+ onThinking?.(iteration);
340
+ const data = await chatWithTools(messages, AGENT_TOOLS, MODEL, opts.signal ?? null);
341
+
342
+ const message = data?.message;
343
+ if (!message) {
344
+ return { content: '', finalMessage: { role: 'assistant', content: '' } };
345
+ }
473
346
 
474
- // Build the viewport: last VIEWPORT_LINES lines of buffered text.
475
- const w = process.stdout.columns || TERM_WIDTH;
476
- const displayWidth = Math.min(w, TERM_WIDTH);
477
- const lines = wrapText(fullText, displayWidth).split('\n');
478
- const viewLines = lines;
479
- const rendered = viewLines.join('\n');
347
+ const toolCalls = message.tool_calls;
348
+ if (!toolCalls || toolCalls.length === 0) {
349
+ return { content: message.content ?? '', finalMessage: message };
350
+ }
480
351
 
481
- // Count actual terminal rows rendered (accounts for line wrapping).
482
- const newRows = countRows(viewLines, w) - 1;
352
+ // Append assistant message with tool_calls
353
+ messages.push({
354
+ role: 'assistant',
355
+ content: message.content ?? '',
356
+ tool_calls: toolCalls,
357
+ });
358
+
359
+ onBeforeToolRun?.();
360
+ onIteration?.(iteration, AGENT_LOOP_MAX_ITERATIONS, toolCalls.length);
361
+
362
+ for (const tc of toolCalls) {
363
+ const name = tc?.function?.name;
364
+ const rawArgs = tc?.function?.arguments;
365
+ let args = rawArgs;
366
+ if (typeof rawArgs === 'string') {
367
+ try {
368
+ args = JSON.parse(rawArgs);
369
+ } catch {
370
+ messages.push({
371
+ role: 'tool',
372
+ tool_name: name ?? 'unknown',
373
+ content: JSON.stringify({ error: 'Invalid JSON in arguments' }),
374
+ });
375
+ if (onToolCall) onToolCall(name ?? 'unknown', {});
376
+ if (onToolResult) onToolResult(name ?? 'unknown', JSON.stringify({ error: 'Invalid JSON in arguments' }));
377
+ continue;
378
+ }
379
+ }
483
380
 
484
- // Move cursor up to top of current viewport, clear down, rewrite.
485
- if (viewportRows > 0) {
486
- process.stdout.write(`\x1b[${viewportRows}A\r\x1b[0J`);
381
+ if (onToolCall) onToolCall(name ?? 'unknown', args ?? {});
382
+
383
+ const isFileEdit = name === 'write_file' || name === 'search_replace';
384
+ let result;
385
+ if (isFileEdit && confirmFileEdit) {
386
+ const ok = await confirmFileEdit(name, args ?? {});
387
+ if (!ok) {
388
+ result = JSON.stringify({ declined: true, message: 'User declined the change' });
389
+ if (onToolResult) onToolResult(name ?? 'unknown', result);
390
+ messages.push({
391
+ role: 'tool',
392
+ tool_name: name ?? 'unknown',
393
+ content: result,
394
+ });
395
+ continue;
396
+ }
487
397
  }
488
- process.stdout.write(rendered);
489
- viewportRows = newRows;
490
- }, undefined, signal);
491
-
492
- process.stdin.removeListener('data', onCancel);
493
- process.stdin.setRawMode(false);
494
- process.stdin.pause();
495
- clearInterval(spinner);
496
-
497
- if (signal.aborted) {
498
- if (viewportRows > 0) process.stdout.write(`\x1b[${viewportRows}A\r\x1b[0J`);
499
- console.log(chalk.dim('(cancelled)\n'));
500
- return null;
501
- }
502
398
 
503
- // Replace viewport with the full response.
504
- if (viewportRows > 0) process.stdout.write(`\x1b[${viewportRows}A\r\x1b[0J`);
505
- const finalWidth = Math.min(process.stdout.columns || TERM_WIDTH, TERM_WIDTH);
506
- process.stdout.write(wrapText(fullText, finalWidth) + '\n\n');
507
-
508
- return reply;
509
- } catch (err) {
510
- clearInterval(spinner);
511
- process.stdin.removeListener('data', onCancel);
512
- process.stdin.setRawMode(false);
513
- process.stdin.pause();
514
- if (!signal.aborted) throw err;
515
- return null;
399
+ result = await runTool(name, args ?? {}, { cwd, confirmFn });
400
+ if (onToolResult) onToolResult(name ?? 'unknown', typeof result === 'string' ? result : JSON.stringify(result));
401
+ messages.push({
402
+ role: 'tool',
403
+ tool_name: name ?? 'unknown',
404
+ content: typeof result === 'string' ? result : JSON.stringify(result),
405
+ });
406
+ }
516
407
  }
408
+
409
+ return {
410
+ content: '(agent loop reached max iterations)',
411
+ finalMessage: { role: 'assistant', content: '(max iterations)' },
412
+ };
517
413
  }
518
414
 
519
415
  const HELP_TEXT =
520
416
  '\n' +
521
417
  chalk.bold('Commands:\n') +
522
418
  chalk.cyan(' /help') + chalk.dim(' show this help\n') +
523
- chalk.cyan(' /build') + chalk.dim(' execute the stored plan\n') +
419
+ chalk.cyan(' /agent') + chalk.dim(' [prompt] run with tools (run commands, create folders)\n') +
524
420
  chalk.cyan(' /setup-nextjs') + chalk.dim(' scaffold a Next.js app\n') +
525
421
  chalk.cyan(' /setup-laravel') + chalk.dim(' scaffold a Laravel API\n') +
526
422
  chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
527
423
  chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
528
- chalk.dim('\nNormal chat: generates a plan · /build to execute · ') + chalk.cyan('@filename') + chalk.dim(' to attach a file · ctrl+q to cancel\n');
424
+ chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
425
+ chalk.cyan(' /logout') + chalk.dim(' clear saved auth token\n') +
426
+ chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
427
+
428
+ /**
429
+ * Run a list of setup steps (mkdir / cd / run / write) in sequence.
430
+ * Returns true if all steps succeeded, false if any failed.
431
+ */
432
+ async function runSetupSteps(steps) {
433
+ for (const step of steps) {
434
+ if (step.type === 'mkdir') {
435
+ process.stdout.write(chalk.dim(` mkdir: ${step.path}\n`));
436
+ try {
437
+ mkdirSync(resolve(process.cwd(), step.path), { recursive: true });
438
+ console.log(chalk.green(` ✓ created ${step.path}\n`));
439
+ } catch (err) {
440
+ console.log(chalk.red(` ✗ ${err.message}\n`));
441
+ return false;
442
+ }
443
+ } else if (step.type === 'cd') {
444
+ try {
445
+ process.chdir(resolve(process.cwd(), step.path));
446
+ console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
447
+ } catch (err) {
448
+ console.log(chalk.red(` ✗ no such directory: ${step.path}\n`));
449
+ return false;
450
+ }
451
+ } else if (step.type === 'run') {
452
+ process.stdout.write(chalk.dim(` running: ${step.cmd}\n`));
453
+ const { stdout, stderr, exitCode } = await execCommand(step.cmd);
454
+ const output = [stdout, stderr].filter(Boolean).join('\n').trim();
455
+ if (output) console.log(chalk.dim(output));
456
+ if (exitCode !== 0) {
457
+ console.log(chalk.red(` ✗ command failed (exit ${exitCode})\n`));
458
+ return false;
459
+ }
460
+ console.log(chalk.green(` ✓ done\n`));
461
+ } else if (step.type === 'write') {
462
+ process.stdout.write(chalk.dim(` write: ${step.path}\n`));
463
+ try {
464
+ const abs = resolve(process.cwd(), step.path);
465
+ const dir = abs.split('/').slice(0, -1).join('/');
466
+ mkdirSync(dir, { recursive: true });
467
+ writeFileSync(abs, step.content);
468
+ console.log(chalk.green(` ✓ wrote ${step.path}\n`));
469
+ } catch (err) {
470
+ console.log(chalk.red(` ✗ ${err.message}\n`));
471
+ return false;
472
+ }
473
+ }
474
+ }
475
+ return true;
476
+ }
529
477
 
530
478
  export async function startInteractive() {
531
479
  printLogo();
532
480
 
533
481
  let allFiles = getFilesAndDirs();
534
482
  const chatMessages = [];
535
- let currentPlan = null; // { text: string } | null
536
483
 
537
484
  console.log(chalk.dim(`Chat with Markov (${MODEL}).`));
538
485
  console.log(HELP_TEXT);
539
486
 
487
+ if (!getToken()) {
488
+ console.log(chalk.yellow('⚠ Not logged in. Use /login to authenticate.\n'));
489
+ }
490
+
491
+ let pendingMessage = null;
492
+
540
493
  while (true) {
541
- const raw = await chatPrompt(chalk.magenta('you> '), allFiles);
494
+ let raw;
495
+ if (pendingMessage) {
496
+ raw = pendingMessage;
497
+ pendingMessage = null;
498
+ console.log(chalk.magenta('you> ') + raw + '\n');
499
+ } else {
500
+ raw = await chatPrompt(chalk.magenta('you> '), allFiles);
501
+ }
542
502
  if (raw === null) continue;
543
503
  const trimmed = raw.trim();
544
504
 
545
505
  if (!trimmed) continue;
546
506
 
507
+ // /login — authenticate and save token
508
+ if (trimmed === '/login') {
509
+ const email = await promptLine('Email: ');
510
+ const password = await promptSecret('Password: ');
511
+ try {
512
+ await login(email, password);
513
+ console.log(chalk.green('✓ logged in\n'));
514
+ } catch (err) {
515
+ console.log(chalk.red(`✗ ${err.message}\n`));
516
+ }
517
+ continue;
518
+ }
519
+
520
+ // /logout — clear saved token
521
+ if (trimmed === '/logout') {
522
+ clearToken();
523
+ console.log(chalk.green('✓ logged out\n'));
524
+ continue;
525
+ }
526
+
547
527
  // /help — list all commands
548
528
  if (trimmed === '/help') {
549
529
  console.log(HELP_TEXT);
@@ -576,137 +556,208 @@ export async function startInteractive() {
576
556
  continue;
577
557
  }
578
558
 
579
- // /setup-nextjs — scaffold a Next.js app
559
+ // /setup-nextjs — scaffold a Next.js app via script
580
560
  if (trimmed === '/setup-nextjs') {
581
- const msg = 'Set up a new Next.js app in a subfolder. Follow the SETUP NEXT.JS APP instructions exactly.';
582
- chatMessages.push({ role: 'user', content: msg });
583
- const abortController = new AbortController();
584
- try {
585
- const reply = await streamWithViewport(chatMessages, abortController.signal);
586
- if (reply === null) { chatMessages.pop(); }
587
- else {
588
- chatMessages.push({ role: 'assistant', content: reply });
589
- allFiles = await handleFileOps(reply, [], { autoConfirm: true });
590
- console.log(chalk.green('✓ Next.js app created.\n'));
591
- }
592
- } catch (err) {
593
- if (!abortController.signal.aborted) console.log(chalk.red(`\n${err.message}\n`));
594
- }
561
+ const name = (await promptLine(chalk.bold('App folder name: '))).trim() || 'next-app';
562
+ const steps = [
563
+ { type: 'mkdir', path: name },
564
+ { type: 'cd', path: name },
565
+ { type: 'run', cmd: 'npx create-next-app@latest . --yes' },
566
+ { type: 'run', cmd: 'npm install sass' },
567
+ ];
568
+ const ok = await runSetupSteps(steps);
569
+ allFiles = getFilesAndDirs();
570
+ if (ok) console.log(chalk.green(`✓ Next.js app created in ${name}.\n`));
595
571
  continue;
596
572
  }
597
573
 
598
- // /setup-laravel — scaffold a Laravel API (hardcoded, no AI)
574
+ // /setup-laravel — scaffold a Laravel API via script
599
575
  if (trimmed === '/setup-laravel') {
576
+ const name = (await promptLine(chalk.bold('App folder name: '))).trim() || 'laravel-api';
600
577
  const steps = [
601
- { type: 'mkdir', path: 'laravel-api' },
602
- { type: 'cd', path: 'laravel-api' },
603
- { type: 'run', cmd: 'composer create-project --prefer-dist laravel/laravel .' },
604
- { type: 'run', cmd: 'php artisan serve' },
578
+ { type: 'mkdir', path: name },
579
+ { type: 'cd', path: name },
580
+ { type: 'run', cmd: 'composer create-project --prefer-dist laravel/laravel .' },
581
+ { type: 'run', cmd: 'php artisan serve' },
605
582
  ];
606
- for (const step of steps) {
607
- if (step.type === 'mkdir') {
608
- process.stdout.write(chalk.dim(` mkdir: ${step.path}\n`));
609
- try {
610
- mkdirSync(resolve(process.cwd(), step.path), { recursive: true });
611
- allFiles = getFilesAndDirs();
612
- console.log(chalk.green(` ✓ created ${step.path}\n`));
613
- } catch (err) {
614
- console.log(chalk.red(` ✗ ${err.message}\n`));
615
- break;
616
- }
617
- } else if (step.type === 'cd') {
618
- try {
619
- process.chdir(resolve(process.cwd(), step.path));
620
- allFiles = getFilesAndDirs();
621
- console.log(chalk.dim(` 📁 ${process.cwd()}\n`));
622
- } catch (err) {
623
- console.log(chalk.red(` ✗ no such directory: ${step.path}\n`));
624
- break;
625
- }
626
- } else if (step.type === 'run') {
627
- process.stdout.write(chalk.dim(` running: ${step.cmd}\n`));
628
- const { stdout, stderr, exitCode } = await execCommand(step.cmd);
629
- const output = [stdout, stderr].filter(Boolean).join('\n').trim();
630
- if (output) console.log(chalk.dim(output));
631
- if (exitCode !== 0) {
632
- console.log(chalk.red(` ✗ command failed (exit ${exitCode})\n`));
633
- break;
634
- }
635
- console.log(chalk.green(` ✓ done\n`));
636
- allFiles = getFilesAndDirs();
637
- }
638
- }
639
- console.log(chalk.green('✓ Laravel API created.\n'));
583
+ const ok = await runSetupSteps(steps);
584
+ allFiles = getFilesAndDirs();
585
+ if (ok) console.log(chalk.green(`✓ Laravel API created in ${name}.\n`));
640
586
  continue;
641
587
  }
642
588
 
643
-
644
- // /build execute the stored plan with file ops
645
- if (trimmed === '/build') {
646
- if (!currentPlan) {
647
- console.log(chalk.yellow('\n⚠ No plan stored. Describe what you want first.\n'));
589
+ // /agent [prompt] — run with tools (run_terminal_command, create_folder, file tools), loop until final response
590
+ if (trimmed === '/agent' || trimmed.startsWith('/agent ')) {
591
+ const userContent = trimmed.startsWith('/agent ')
592
+ ? trimmed.slice(7).trim()
593
+ : (await promptLine(chalk.bold('Agent prompt: '))).trim();
594
+ if (!userContent) {
595
+ console.log(chalk.yellow('No prompt given.\n'));
648
596
  continue;
649
597
  }
650
-
651
- const autoConfirm = await confirm(chalk.bold('Auto-confirm all operations? [y/N] '));
652
-
653
- const buildPrompt =
654
- `Execute the following plan. Use !!write: path then a fenced code block for file writes, !!mkdir: for folders, !!delete: for deletions.\n\n` +
655
- `Plan:\n${currentPlan.text}`;
656
-
657
- chatMessages.push({ role: 'user', content: buildPrompt });
598
+ chatMessages.push({ role: 'user', content: userContent });
599
+ const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
658
600
  const abortController = new AbortController();
601
+ const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
602
+ const confirmFileEdit = async (name, args) => {
603
+ console.log(chalk.dim('\n Proposed change:\n'));
604
+ console.log(formatFileEditPreview(name, args));
605
+ console.log('');
606
+ return confirm(chalk.bold('Apply this change? [y/N] '));
607
+ };
608
+
609
+ const startTime = Date.now();
610
+ const DOTS = ['.', '..', '...'];
611
+ let dotIdx = 0;
612
+ let spinner = null;
613
+
614
+ const startSpinner = () => {
615
+ if (spinner) { clearInterval(spinner); spinner = null; }
616
+ dotIdx = 0;
617
+ process.stdout.write(chalk.dim('\nAgent › '));
618
+ spinner = setInterval(() => {
619
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
620
+ process.stdout.write('\r' + chalk.dim('Agent › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
621
+ dotIdx++;
622
+ }, 400);
623
+ };
624
+ const stopSpinner = () => {
625
+ if (spinner) { clearInterval(spinner); spinner = null; }
626
+ process.stdout.write('\r\x1b[0J');
627
+ };
628
+
659
629
  try {
660
- const reply = await streamWithViewport(chatMessages, abortController.signal);
661
- if (reply === null) {
662
- chatMessages.pop();
663
- } else {
664
- chatMessages.push({ role: 'assistant', content: reply });
665
- allFiles = await handleFileOps(reply, [], { autoConfirm });
666
- currentPlan = null;
667
- console.log(chalk.green('✓ Plan executed.\n'));
630
+ const result = await runAgentLoop(agentMessages, {
631
+ signal: abortController.signal,
632
+ cwd: process.cwd(),
633
+ confirmFn,
634
+ confirmFileEdit,
635
+ onThinking: () => {
636
+ startSpinner();
637
+ },
638
+ onBeforeToolRun: () => {
639
+ stopSpinner();
640
+ },
641
+ onIteration: (iter, max, toolCount) => {
642
+ const w = process.stdout.columns || 80;
643
+ const label = ` Step ${iter}/${max} `;
644
+ const line = chalk.dim('──') + chalk.bold.white(label) + chalk.dim('─'.repeat(Math.max(0, w - label.length - 2)));
645
+ console.log(line);
646
+ },
647
+ onToolCall: (name, args) => {
648
+ const summary = formatToolCallSummary(name, args);
649
+ console.log(chalk.cyan(' ▶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
650
+ },
651
+ onToolResult: (name, resultStr) => {
652
+ console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
653
+ },
654
+ });
655
+ stopSpinner();
656
+ if (result) {
657
+ chatMessages.push(result.finalMessage);
658
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
659
+ const width = Math.min(process.stdout.columns || 80, 80);
660
+ process.stdout.write(formatResponseWithCodeBlocks(result.content, width) + '\n\n');
661
+ await applyCodeBlockEdits(result.content, []);
662
+ allFiles = getFilesAndDirs();
663
+ console.log(chalk.green(`✓ Agent done.`) + chalk.dim(` (${elapsed}s)\n`));
664
+ const opts1 = extractNumberedOptions(result.content);
665
+ if (opts1.length >= 2) {
666
+ const chosen = await selectFrom(opts1, 'Select an option:');
667
+ if (chosen) pendingMessage = chosen;
668
+ }
668
669
  }
669
670
  } catch (err) {
670
- if (!abortController.signal.aborted) console.log(chalk.red(`\n${err.message}\n`));
671
+ stopSpinner();
672
+ if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
671
673
  }
672
674
  continue;
673
675
  }
674
676
 
675
- // Normal chat generate a plan, store it, require /build to execute
676
- const { loaded, failed } = resolveFileRefs(trimmed);
677
-
677
+ // Handle message with agent (tools)
678
+ const { loaded, failed, content: resolvedContent } = await resolveFileRefs(trimmed);
678
679
  if (loaded.length > 0) {
679
680
  console.log(chalk.dim(`\n📎 attached: ${loaded.map(f => chalk.cyan(`@${f}`)).join(', ')}`));
680
681
  }
681
682
  if (failed.length > 0) {
682
683
  console.log(chalk.yellow(`\n⚠ not found: ${failed.map(f => `@${f}`).join(', ')}`));
683
684
  }
685
+ const userContent = resolvedContent ?? trimmed;
686
+ chatMessages.push({ role: 'user', content: userContent });
684
687
 
685
- const planPrompt =
686
- `Create a detailed, numbered plan for the following task:\n\n${trimmed}\n\n` +
687
- `For each step, specify exactly what will happen and which syntax will be used:\n` +
688
- `- Writing or editing a file → !!write: path/to/file then fenced code block\n` +
689
- `- Creating an empty file → !!touch: path/to/file\n` +
690
- `- Creating a folder → !!mkdir: path/to/folder\n` +
691
- `- Removing a folder → !!rmdir: path/to/folder\n` +
692
- `- Deleting a file → !!delete: path/to/file\n\n` +
693
- `Do NOT output any actual file contents or commands yet — only the plan.`;
694
-
695
- chatMessages.push({ role: 'user', content: planPrompt });
688
+ const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
696
689
  const abortController = new AbortController();
690
+ const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
691
+ const confirmFileEdit = async (name, args) => {
692
+ console.log(chalk.dim('\n Proposed change:\n'));
693
+ console.log(formatFileEditPreview(name, args));
694
+ console.log('');
695
+ return confirm(chalk.bold('Apply this change? [y/N] '));
696
+ };
697
+ const startTime = Date.now();
698
+ const DOTS = ['.', '..', '...'];
699
+ let dotIdx = 0;
700
+ let spinner = null;
701
+
702
+ const startSpinner = () => {
703
+ if (spinner) { clearInterval(spinner); spinner = null; }
704
+ dotIdx = 0;
705
+ process.stdout.write(chalk.dim('\nAgent › '));
706
+ spinner = setInterval(() => {
707
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
708
+ process.stdout.write('\r' + chalk.dim('Agent › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
709
+ dotIdx++;
710
+ }, 400);
711
+ };
712
+ const stopSpinner = () => {
713
+ if (spinner) { clearInterval(spinner); spinner = null; }
714
+ process.stdout.write('\r\x1b[0J');
715
+ };
716
+
697
717
  try {
698
- const reply = await streamWithViewport(chatMessages, abortController.signal);
699
- if (reply === null) {
700
- chatMessages.pop();
701
- } else {
702
- chatMessages.push({ role: 'assistant', content: reply });
703
- currentPlan = { text: reply };
704
- console.log(chalk.green('\n✓ Plan stored. Use /build to execute it.\n'));
718
+ const result = await runAgentLoop(agentMessages, {
719
+ signal: abortController.signal,
720
+ cwd: process.cwd(),
721
+ confirmFn,
722
+ confirmFileEdit,
723
+ onThinking: () => {
724
+ startSpinner();
725
+ },
726
+ onBeforeToolRun: () => {
727
+ stopSpinner();
728
+ },
729
+ onIteration: (iter, max, toolCount) => {
730
+ const w = process.stdout.columns || 80;
731
+ const label = ` Step ${iter}/${max} `;
732
+ const line = chalk.dim('──') + chalk.bold.white(label) + chalk.dim('─'.repeat(Math.max(0, w - label.length - 2)));
733
+ console.log(line);
734
+ },
735
+ onToolCall: (name, args) => {
736
+ const summary = formatToolCallSummary(name, args);
737
+ console.log(chalk.cyan(' ▶ ') + chalk.bold(name) + chalk.dim(' ') + chalk.white(summary));
738
+ },
739
+ onToolResult: (name, resultStr) => {
740
+ console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
741
+ },
742
+ });
743
+ stopSpinner();
744
+ if (result) {
745
+ chatMessages.push(result.finalMessage);
746
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
747
+ const width = Math.min(process.stdout.columns || 80, 80);
748
+ process.stdout.write(formatResponseWithCodeBlocks(result.content, width) + '\n\n');
749
+ await applyCodeBlockEdits(result.content, loaded);
750
+ allFiles = getFilesAndDirs();
751
+ console.log(chalk.green(`✓ Agent done.`) + chalk.dim(` (${elapsed}s)\n`));
752
+ const opts2 = extractNumberedOptions(result.content);
753
+ if (opts2.length >= 2) {
754
+ const chosen = await selectFrom(opts2, 'Select an option:');
755
+ if (chosen) pendingMessage = chosen;
756
+ }
705
757
  }
706
758
  } catch (err) {
707
- if (!abortController.signal.aborted) {
708
- console.log(chalk.red(`\n${err.message}\n`));
709
- }
759
+ stopSpinner();
760
+ if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
710
761
  }
711
762
  }
712
763
  }