markov-cli 1.0.15 → 1.0.17

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,21 +1,20 @@
1
1
  import chalk from 'chalk';
2
2
 
3
- import gradient from 'gradient-string';
4
3
  import { writeFileSync, readFileSync, existsSync } from 'fs';
5
4
  import { homedir } from 'os';
6
5
  import { resolve } from 'path';
7
6
  import { printLogo } from './ui/logo.js';
8
- import { chatWithTools, streamChat, streamChatWithTools, MODEL, MODEL_OPTIONS, setModelAndProvider, getModelDisplayName } from './ollama.js';
7
+ import { chatWithTools, streamChat, streamChatWithTools, MODEL, getModelOptions, setModelAndProvider, getModelDisplayName } from './ollama.js';
9
8
  import { resolveFileRefs } from './files.js';
10
- import { RUN_TERMINAL_COMMAND_TOOL, WEB_SEARCH_TOOL, runTool, execCommand } from './tools.js';
9
+ import { RUN_TERMINAL_COMMAND_TOOL, WEB_SEARCH_TOOL, runTool, execCommand, spawnCommand } from './tools.js';
11
10
  import { chatPrompt } from './input.js';
12
11
  import { getFilesAndDirs } from './ui/picker.js';
13
12
  import { getToken, login, clearToken, getClaudeKey, setClaudeKey, getOpenAIKey, setOpenAIKey, getOllamaKey, setOllamaKey } from './auth.js';
14
13
 
15
14
  // Extracted UI modules
16
- import { selectFrom, confirm, promptLine, promptSecret } from './ui/prompts.js';
15
+ import { selectFrom, confirm, promptLine, promptSecret, PROMPT_INTERRUPT } from './ui/prompts.js';
17
16
  import { formatResponseWithCodeBlocks, formatFileEditPreview, formatToolCallSummary, formatToolResultSummary, printTokenUsage } from './ui/formatting.js';
18
- import { createSpinner } from './ui/spinner.js';
17
+ import { createIdleSpinner } from './ui/spinner.js';
19
18
 
20
19
  // Extracted agent modules
21
20
  import { buildPlanSystemMessage, buildAgentSystemMessage, buildInitSystemMessage, getLsContext, getGrepContext } from './agent/context.js';
@@ -27,8 +26,6 @@ import { applyCodeBlockEdits } from './editor/codeBlockEdits.js';
27
26
  // Extracted command modules
28
27
  import { runSetupSteps, NEXTJS_STEPS, TANSTACK_STEPS, LARAVEL_STEPS, LARAVEL_BLOG_PROMPT } from './commands/setup.js';
29
28
 
30
- const agentGradient = gradient(['#22c55e', '#16a34a', '#4ade80']);
31
-
32
29
  const PLAN_FILE = 'plan.md';
33
30
  const getPlanPath = () => resolve(process.cwd(), PLAN_FILE);
34
31
 
@@ -44,35 +41,30 @@ const INTRO_TEXT =
44
41
  chalk.bold('Quick start:\n') +
45
42
  chalk.cyan(' /help') + chalk.dim(' show all commands\n') +
46
43
  chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
47
- chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
44
+ chalk.cyan(' /cmd') + chalk.dim(' [command] run a shell command in the current folder (default)\n') +
45
+ chalk.cyan(' /agent') + chalk.dim('[prompt] run an agent with the current folder context\n') +
48
46
  chalk.cyan(' /init') + chalk.dim(' [prompt] create markov.md with project summary\n') +
49
47
  chalk.cyan(' /plan') + chalk.dim(' [prompt] stream a plan and save to plan.md\n') +
50
48
  chalk.cyan(' /build') + chalk.dim(' execute plan from plan.md\n') +
51
- chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\n') +
52
- chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
49
+ chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\n\n') +
50
+ chalk.dim(' Tips: Use ') + chalk.cyan('@filename') + chalk.dim(' to add file to context\n') +
51
+ chalk.dim(' Press ') + chalk.cyan('CTRL + TAB') + chalk.dim(' to switch mode\n');
53
52
 
54
53
  const HELP_TEXT =
54
+ INTRO_TEXT +
55
55
  '\n' +
56
- chalk.bold('Commands:\n') +
56
+ chalk.bold('More commands:\n') +
57
57
  chalk.cyan(' /intro') + chalk.dim(' show quick start (same as on first load)\n') +
58
- chalk.cyan(' /help') + chalk.dim(' show this help\n') +
59
58
  chalk.cyan(' /setup-nextjs') + chalk.dim(' scaffold a Next.js app\n') +
60
59
  chalk.cyan(' /setup-tanstack') + chalk.dim(' scaffold a TanStack Start app\n') +
61
60
  chalk.cyan(' /setup-laravel') + chalk.dim(' scaffold a Laravel API\n') +
62
61
  chalk.cyan(' /laravel') + chalk.dim(' set up Laravel "my-blog" with blog route (agent)\n') +
63
62
  chalk.cyan(' /models') + chalk.dim(' switch the active AI model\n') +
64
63
  chalk.cyan(' /cd [path]') + chalk.dim(' change working directory\n') +
65
- chalk.cyan(' /cmd [command]') + chalk.dim(' run a shell command in the current folder\n') +
66
- chalk.cyan(' /login') + chalk.dim(' authenticate with email & password\n') +
67
64
  chalk.cyan(' /logout') + chalk.dim(' clear saved auth token\n') +
68
65
  chalk.cyan(' /clear') + chalk.dim(' clear chat history and stored plan\n') +
69
66
  chalk.cyan(' /env') + chalk.dim(' show which .env vars are loaded (for debugging)\n') +
70
- chalk.cyan(' /debug') + chalk.dim(' toggle full payload dump (env MARKOV_DEBUG)\n') +
71
- chalk.cyan(' /init') + chalk.dim(' [prompt] create markov.md with project summary\n') +
72
- chalk.cyan(' /plan') + chalk.dim(' [prompt] stream a plan and save to plan.md\n') +
73
- chalk.cyan(' /build') + chalk.dim(' execute plan from plan.md\n') +
74
- chalk.cyan(' /yolo') + chalk.dim(' [prompt] plan in stream mode, then auto-run until done\n') +
75
- chalk.dim('\nType a message · ') + chalk.cyan('@filename') + chalk.dim(' to attach · ctrl+q to cancel\n');
67
+ chalk.cyan(' /debug') + chalk.dim(' toggle full payload dump (env MARKOV_DEBUG)\n');
76
68
 
77
69
  /** If MARKOV_DEBUG is set, print the raw model output after completion. */
78
70
  function maybePrintRawModelOutput(rawText) {
@@ -89,8 +81,6 @@ export async function startInteractive() {
89
81
 
90
82
  let allFiles = getFilesAndDirs();
91
83
  const chatMessages = [];
92
-
93
- console.log(chalk.dim(`Chat with Markov (${getModelDisplayName()}).`));
94
84
  console.log(INTRO_TEXT);
95
85
 
96
86
  if (!getToken()) {
@@ -100,6 +90,77 @@ export async function startInteractive() {
100
90
  let pendingMessage = null;
101
91
  let lastPlan = null;
102
92
  const inputHistory = [];
93
+ const availableModes = ['/cmd', '/agent', '/plan', '/build', '/yolo'];
94
+ let currentMode = '/cmd';
95
+ let interruptArmed = false;
96
+ let activeInterruptHandler = null;
97
+
98
+ const isInterrupted = (value) => value === PROMPT_INTERRUPT || (typeof value === 'object' && value !== null && value.type === 'interrupt');
99
+ const resetInterruptState = () => {
100
+ interruptArmed = false;
101
+ };
102
+ const exitInteractive = () => {
103
+ process.stdout.write('\n');
104
+ process.exit(0);
105
+ };
106
+ const registerInterruptHandler = (handler) => {
107
+ activeInterruptHandler = handler;
108
+ return () => {
109
+ if (activeInterruptHandler === handler) activeInterruptHandler = null;
110
+ };
111
+ };
112
+ const handleInterrupt = ({ alreadyPrinted = false } = {}) => {
113
+ if (activeInterruptHandler) {
114
+ const handler = activeInterruptHandler;
115
+ activeInterruptHandler = null;
116
+ handler();
117
+ return;
118
+ }
119
+ if (interruptArmed) exitInteractive();
120
+ interruptArmed = true;
121
+ if (!alreadyPrinted) console.log(chalk.dim('\n(interrupted)\n'));
122
+ };
123
+ const registerAbortController = (abortController, onAbort) => registerInterruptHandler(() => {
124
+ onAbort?.();
125
+ if (!abortController.signal.aborted) abortController.abort();
126
+ console.log(chalk.dim('\n(interrupted)\n'));
127
+ });
128
+ const askLine = async (label) => {
129
+ const value = await promptLine(label);
130
+ if (isInterrupted(value)) {
131
+ handleInterrupt({ alreadyPrinted: true });
132
+ return null;
133
+ }
134
+ return value;
135
+ };
136
+ const askSecret = async (label) => {
137
+ const value = await promptSecret(label);
138
+ if (isInterrupted(value)) {
139
+ handleInterrupt({ alreadyPrinted: true });
140
+ return null;
141
+ }
142
+ return value;
143
+ };
144
+ const askSelect = async (options, label) => {
145
+ const value = await selectFrom(options, label);
146
+ if (isInterrupted(value)) {
147
+ handleInterrupt();
148
+ return null;
149
+ }
150
+ return value;
151
+ };
152
+ const askConfirm = async (question) => {
153
+ const value = await confirm(question);
154
+ if (isInterrupted(value)) {
155
+ handleInterrupt({ alreadyPrinted: true });
156
+ return false;
157
+ }
158
+ return value;
159
+ };
160
+
161
+ process.on('SIGINT', () => {
162
+ handleInterrupt();
163
+ });
103
164
 
104
165
  while (true) {
105
166
  let raw;
@@ -108,17 +169,38 @@ export async function startInteractive() {
108
169
  pendingMessage = null;
109
170
  console.log(chalk.magenta('you> ') + raw + '\n');
110
171
  } else {
111
- raw = await chatPrompt(chalk.magenta('you> '), allFiles, inputHistory);
172
+ const result = await chatPrompt(chalk.magenta('you> '), allFiles, inputHistory, availableModes, currentMode);
173
+ if (isInterrupted(result)) {
174
+ handleInterrupt({ alreadyPrinted: true });
175
+ continue;
176
+ }
177
+ if (result === null) continue;
178
+ resetInterruptState();
179
+ if (typeof result === 'object' && result !== null) {
180
+ raw = result.text;
181
+ currentMode = result.mode;
182
+ } else {
183
+ raw = result;
184
+ }
112
185
  }
113
186
  if (raw === null) continue;
114
- const trimmed = raw.trim();
187
+ let trimmed = raw.trim();
188
+
189
+ if (!trimmed) {
190
+ if (currentMode === '/build') trimmed = '/build';
191
+ else continue;
192
+ } else if (!trimmed.startsWith('/') && currentMode !== '/agent') {
193
+ trimmed = `${currentMode} ${trimmed}`;
194
+ }
115
195
 
116
196
  if (!trimmed) continue;
117
197
 
118
198
  // /login — authenticate and save token
119
199
  if (trimmed === '/login') {
120
- const email = await promptLine('Email: ');
121
- const password = await promptSecret('Password: ');
200
+ const email = await askLine('Email: ');
201
+ if (email === null) continue;
202
+ const password = await askSecret('Password: ');
203
+ if (password === null) continue;
122
204
  try {
123
205
  await login(email, password);
124
206
  console.log(chalk.green('✓ logged in\n'));
@@ -190,9 +272,14 @@ export async function startInteractive() {
190
272
 
191
273
  // /plan [prompt] — stream a plan (no tools), store as lastPlan
192
274
  if (trimmed === '/plan' || trimmed.startsWith('/plan ')) {
193
- const rawUserContent = trimmed.startsWith('/plan ')
194
- ? trimmed.slice(6).trim()
195
- : (await promptLine(chalk.bold('What do you want to plan? '))).trim();
275
+ let rawUserContent;
276
+ if (trimmed.startsWith('/plan ')) {
277
+ rawUserContent = trimmed.slice(6).trim();
278
+ } else {
279
+ const prompted = await askLine(chalk.bold('What do you want to plan? '));
280
+ if (prompted === null) continue;
281
+ rawUserContent = prompted.trim();
282
+ }
196
283
  if (!rawUserContent) {
197
284
  console.log(chalk.yellow('No prompt given.\n'));
198
285
  continue;
@@ -202,24 +289,11 @@ export async function startInteractive() {
202
289
  chatMessages.push({ role: 'user', content: userContent });
203
290
  const planMessages = [buildPlanSystemMessage(), ...chatMessages];
204
291
  const planAbort = new AbortController();
205
- process.stdout.write(chalk.dim('\nPlan › '));
206
- const DOTS = ['.', '..', '...'];
207
- let dotIdx = 0;
208
292
  const planStartTime = Date.now();
209
- let planSpinner = setInterval(() => {
210
- const elapsed = ((Date.now() - planStartTime) / 1000).toFixed(1);
211
- process.stdout.write('\r' + chalk.dim('Plan › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
212
- dotIdx++;
213
- }, 400);
293
+ const planSpinner = createIdleSpinner('Squirming ', { startTime: planStartTime });
294
+ const clearInterruptHandler = registerAbortController(planAbort, () => planSpinner.stop());
295
+ planSpinner.bump();
214
296
  let thinkingStarted = false;
215
- let firstContent = true;
216
- const clearPlanSpinner = () => {
217
- if (planSpinner) {
218
- clearInterval(planSpinner);
219
- planSpinner = null;
220
- process.stdout.write('\r\x1b[0J');
221
- }
222
- };
223
297
  try {
224
298
  let currentPlanMessages = planMessages;
225
299
  let fullPlanText = '';
@@ -232,15 +306,12 @@ export async function startInteractive() {
232
306
  {
233
307
  think: true, // plan mode only: request thinking from backend
234
308
  onContent: (token) => {
235
- if (firstContent) {
236
- clearPlanSpinner();
237
- firstContent = false;
238
- }
309
+ planSpinner.bump();
239
310
  process.stdout.write(token);
240
311
  },
241
312
  onThinking: (token) => {
313
+ planSpinner.bump();
242
314
  if (!thinkingStarted) {
243
- clearPlanSpinner();
244
315
  process.stdout.write(chalk.dim('Thinking: '));
245
316
  thinkingStarted = true;
246
317
  }
@@ -267,6 +338,7 @@ export async function startInteractive() {
267
338
  continue;
268
339
  }
269
340
  }
341
+ planSpinner.pause();
270
342
  console.log(chalk.cyan('\n ▶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
271
343
  const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
272
344
  currentPlanMessages.push({
@@ -277,6 +349,7 @@ export async function startInteractive() {
277
349
  }
278
350
  }
279
351
  chatMessages.push({ role: 'assistant', content: fullPlanText });
352
+ planSpinner.stop();
280
353
  // Store only plan (stream content), not thinking; write to plan.md for /build.
281
354
  if ((fullPlanText ?? '').trim()) {
282
355
  lastPlan = fullPlanText;
@@ -285,21 +358,24 @@ export async function startInteractive() {
285
358
  console.log('\n' + chalk.dim('Plan saved to plan.md. Use ') + chalk.green('/build') + chalk.dim(' to execute.\n'));
286
359
  maybePrintRawModelOutput(fullPlanText);
287
360
  } catch (err) {
288
- if (planSpinner) {
289
- clearInterval(planSpinner);
290
- planSpinner = null;
291
- process.stdout.write('\r\x1b[0J');
292
- }
361
+ planSpinner.stop();
293
362
  if (!planAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
363
+ } finally {
364
+ clearInterruptHandler();
294
365
  }
295
366
  continue;
296
367
  }
297
368
 
298
369
  // /yolo [prompt] — one plan in stream mode, then auto-accept and run agent until done
299
370
  if (trimmed === '/yolo' || trimmed.startsWith('/yolo ')) {
300
- const rawUserContent = trimmed.startsWith('/yolo ')
301
- ? trimmed.slice(6).trim()
302
- : (await promptLine(chalk.bold('What do you want to yolo? '))).trim();
371
+ let rawUserContent;
372
+ if (trimmed.startsWith('/yolo ')) {
373
+ rawUserContent = trimmed.slice(6).trim();
374
+ } else {
375
+ const prompted = await askLine(chalk.bold('What do you want to yolo? '));
376
+ if (prompted === null) continue;
377
+ rawUserContent = prompted.trim();
378
+ }
303
379
  if (!rawUserContent) {
304
380
  console.log(chalk.yellow('No prompt given.\n'));
305
381
  continue;
@@ -309,9 +385,11 @@ export async function startInteractive() {
309
385
  chatMessages.push({ role: 'user', content: planUserContent });
310
386
  const planMessages = [buildPlanSystemMessage(), ...chatMessages];
311
387
  const yoloAbort = new AbortController();
312
- process.stdout.write(chalk.dim('\nYolo › Plan › '));
313
388
  let thinkingStarted = false;
314
389
  let fullPlanText = '';
390
+ const yoloPlanSpinner = createIdleSpinner('Squirming ');
391
+ const clearYoloPlanInterruptHandler = registerAbortController(yoloAbort, () => yoloPlanSpinner.stop());
392
+ yoloPlanSpinner.bump();
315
393
  try {
316
394
  let currentPlanMessages = planMessages;
317
395
  const yoloPlanMaxIter = 10;
@@ -322,8 +400,12 @@ export async function startInteractive() {
322
400
  MODEL,
323
401
  {
324
402
  think: true, // plan phase: request thinking from backend
325
- onContent: (token) => process.stdout.write(token),
403
+ onContent: (token) => {
404
+ yoloPlanSpinner.bump();
405
+ process.stdout.write(token);
406
+ },
326
407
  onThinking: (token) => {
408
+ yoloPlanSpinner.bump();
327
409
  if (!thinkingStarted) {
328
410
  process.stdout.write(chalk.dim('Thinking: '));
329
411
  thinkingStarted = true;
@@ -351,6 +433,7 @@ export async function startInteractive() {
351
433
  continue;
352
434
  }
353
435
  }
436
+ yoloPlanSpinner.pause();
354
437
  console.log(chalk.cyan('\n ▶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
355
438
  const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
356
439
  currentPlanMessages.push({
@@ -361,12 +444,16 @@ export async function startInteractive() {
361
444
  }
362
445
  }
363
446
  chatMessages.push({ role: 'assistant', content: fullPlanText });
447
+ yoloPlanSpinner.stop();
364
448
  // Store only plan (stream content), not thinking, so build phase uses exactly this.
365
449
  if ((fullPlanText ?? '').trim()) lastPlan = fullPlanText;
366
450
  maybePrintRawModelOutput(fullPlanText);
367
451
  } catch (err) {
452
+ yoloPlanSpinner.stop();
368
453
  if (!yoloAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
369
454
  continue;
455
+ } finally {
456
+ clearYoloPlanInterruptHandler();
370
457
  }
371
458
  const buildContent = (await getLsContext()) + (await getGrepContext()) +
372
459
  '\n\nPlan:\n' + lastPlan + '\n\nExecute this plan using your tools. Run commands and edit files as needed.';
@@ -377,31 +464,16 @@ export async function startInteractive() {
377
464
  const confirmFn = () => Promise.resolve(true);
378
465
  const confirmFileEdit = async () => true;
379
466
  const startTime = Date.now();
380
- const DOTS = ['.', '..', '...'];
381
- let dotIdx = 0;
382
- let spinner = null;
383
- const startSpinner = () => {
384
- if (spinner) { clearInterval(spinner); spinner = null; }
385
- dotIdx = 0;
386
- process.stdout.write(chalk.dim('\nYolo › Run › '));
387
- spinner = setInterval(() => {
388
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
389
- process.stdout.write('\r' + chalk.dim('Yolo › Run › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
390
- dotIdx++;
391
- }, 400);
392
- };
393
- const stopSpinner = () => {
394
- if (spinner) { clearInterval(spinner); spinner = null; }
395
- process.stdout.write('\r\x1b[0J');
396
- };
467
+ const idleSpinner = createIdleSpinner('Squirming ', { startTime });
468
+ const clearYoloBuildInterruptHandler = registerAbortController(abortController, () => idleSpinner.stop());
397
469
  try {
398
470
  const result = await runAgentLoop(agentMessages, {
399
471
  signal: abortController.signal,
400
472
  cwd: process.cwd(),
401
473
  confirmFn,
402
474
  confirmFileEdit,
403
- onThinking: () => { startSpinner(); },
404
- onBeforeToolRun: () => { stopSpinner(); },
475
+ onThinking: () => { idleSpinner.bump(); },
476
+ onBeforeToolRun: () => { idleSpinner.pause(); },
405
477
  onIteration: (iter, max, toolCount) => {
406
478
  const w = process.stdout.columns || 80;
407
479
  const label = ` Step ${iter} `;
@@ -416,7 +488,7 @@ export async function startInteractive() {
416
488
  console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
417
489
  },
418
490
  });
419
- stopSpinner();
491
+ idleSpinner.stop();
420
492
  if (result) {
421
493
  chatMessages.push(result.finalMessage);
422
494
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -428,39 +500,33 @@ export async function startInteractive() {
428
500
  maybePrintRawModelOutput(result.content);
429
501
  }
430
502
  } catch (err) {
431
- stopSpinner();
503
+ idleSpinner.stop();
432
504
  if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
505
+ } finally {
506
+ clearYoloBuildInterruptHandler();
433
507
  }
434
508
  continue;
435
509
  }
436
510
 
437
511
  // /init [prompt] — create markov.md with project summary (agent writes file via tools)
438
512
  if (trimmed === '/init' || trimmed.startsWith('/init ')) {
439
- const rawUserContent = trimmed.startsWith('/init ')
440
- ? trimmed.slice(6).trim()
441
- : (await promptLine(chalk.bold('Describe the project to summarize (optional): '))).trim();
513
+ let rawUserContent;
514
+ if (trimmed.startsWith('/init ')) {
515
+ rawUserContent = trimmed.slice(6).trim();
516
+ } else {
517
+ const prompted = await askLine(chalk.bold('Describe the project to summarize (optional): '));
518
+ if (prompted === null) continue;
519
+ rawUserContent = prompted.trim();
520
+ }
442
521
  const userContent = (await getLsContext()) + (await getGrepContext()) +
443
522
  (rawUserContent ? `Create markov.md with a project summary. Focus on: ${rawUserContent}` : 'Create markov.md with a concise project summary.');
444
523
  const initMessages = [buildInitSystemMessage(), { role: 'user', content: userContent }];
445
524
  const initAbort = new AbortController();
446
- process.stdout.write(chalk.dim('\nInit › '));
447
- const DOTS = ['.', '..', '...'];
448
- let dotIdx = 0;
449
525
  const initStartTime = Date.now();
450
- let initSpinner = setInterval(() => {
451
- const elapsed = ((Date.now() - initStartTime) / 1000).toFixed(1);
452
- process.stdout.write('\r' + chalk.dim('Init › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
453
- dotIdx++;
454
- }, 400);
526
+ const initSpinner = createIdleSpinner('Squirming ', { startTime: initStartTime });
527
+ const clearInitInterruptHandler = registerAbortController(initAbort, () => initSpinner.stop());
528
+ initSpinner.bump();
455
529
  let thinkingStarted = false;
456
- let firstContent = true;
457
- const clearInitSpinner = () => {
458
- if (initSpinner) {
459
- clearInterval(initSpinner);
460
- initSpinner = null;
461
- process.stdout.write('\r\x1b[0J');
462
- }
463
- };
464
530
  try {
465
531
  let currentInitMessages = initMessages;
466
532
  const initMaxIter = 10;
@@ -472,15 +538,12 @@ export async function startInteractive() {
472
538
  {
473
539
  think: true,
474
540
  onContent: (token) => {
475
- if (firstContent) {
476
- clearInitSpinner();
477
- firstContent = false;
478
- }
541
+ initSpinner.bump();
479
542
  process.stdout.write(token);
480
543
  },
481
544
  onThinking: (token) => {
545
+ initSpinner.bump();
482
546
  if (!thinkingStarted) {
483
- clearInitSpinner();
484
547
  process.stdout.write(chalk.dim('Thinking: '));
485
548
  thinkingStarted = true;
486
549
  }
@@ -506,6 +569,7 @@ export async function startInteractive() {
506
569
  continue;
507
570
  }
508
571
  }
572
+ initSpinner.pause();
509
573
  console.log(chalk.cyan('\n ▶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
510
574
  const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
511
575
  currentInitMessages.push({
@@ -515,18 +579,17 @@ export async function startInteractive() {
515
579
  });
516
580
  }
517
581
  }
582
+ initSpinner.stop();
518
583
  if (existsSync(getMarkovPath())) {
519
584
  console.log('\n' + chalk.green('✓ markov.md created. It will be included in the system message from now on.\n'));
520
585
  } else {
521
586
  console.log('\n' + chalk.yellow('Init finished but markov.md was not created. The agent may need another run or a clearer prompt.\n'));
522
587
  }
523
588
  } catch (err) {
524
- if (initSpinner) {
525
- clearInterval(initSpinner);
526
- initSpinner = null;
527
- process.stdout.write('\r\x1b[0J');
528
- }
589
+ initSpinner.stop();
529
590
  if (!initAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
591
+ } finally {
592
+ clearInitInterruptHandler();
530
593
  }
531
594
  continue;
532
595
  }
@@ -549,39 +612,24 @@ export async function startInteractive() {
549
612
  const agentMessages = [buildAgentSystemMessage(), { role: 'user', content: buildContent }];
550
613
  maybePrintFullPayload(agentMessages);
551
614
  const abortController = new AbortController();
552
- const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
615
+ const confirmFn = async (cmd) => (await askConfirm(chalk.bold(`Run: ${cmd}? [y/N] `))) === true;
553
616
  const confirmFileEdit = async (name, args) => {
554
617
  console.log(chalk.dim('\n Proposed change:\n'));
555
618
  console.log(formatFileEditPreview(name, args));
556
619
  console.log('');
557
- return confirm(chalk.bold('Apply this change? [y/N] '));
620
+ return (await askConfirm(chalk.bold('Apply this change? [y/N] '))) === true;
558
621
  };
559
622
  const startTime = Date.now();
560
- const DOTS = ['.', '..', '...'];
561
- let dotIdx = 0;
562
- let spinner = null;
563
- const startSpinner = () => {
564
- if (spinner) { clearInterval(spinner); spinner = null; }
565
- dotIdx = 0;
566
- process.stdout.write(chalk.dim('\nAgent › '));
567
- spinner = setInterval(() => {
568
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
569
- process.stdout.write('\r' + chalk.dim('Agent › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
570
- dotIdx++;
571
- }, 400);
572
- };
573
- const stopSpinner = () => {
574
- if (spinner) { clearInterval(spinner); spinner = null; }
575
- process.stdout.write('\r\x1b[0J');
576
- };
623
+ const idleSpinner = createIdleSpinner('Squirming ', { startTime });
624
+ const clearBuildInterruptHandler = registerAbortController(abortController, () => idleSpinner.stop());
577
625
  try {
578
626
  const result = await runAgentLoop(agentMessages, {
579
627
  signal: abortController.signal,
580
628
  cwd: process.cwd(),
581
629
  confirmFn,
582
630
  confirmFileEdit,
583
- onThinking: () => { startSpinner(); },
584
- onBeforeToolRun: () => { stopSpinner(); },
631
+ onThinking: () => { idleSpinner.bump(); },
632
+ onBeforeToolRun: () => { idleSpinner.pause(); },
585
633
  onIteration: (iter, max, toolCount) => {
586
634
  const w = process.stdout.columns || 80;
587
635
  const label = ` Step ${iter} `;
@@ -596,7 +644,7 @@ export async function startInteractive() {
596
644
  console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
597
645
  },
598
646
  });
599
- stopSpinner();
647
+ idleSpinner.stop();
600
648
  if (result) {
601
649
  chatMessages.push(result.finalMessage);
602
650
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -608,21 +656,27 @@ export async function startInteractive() {
608
656
  maybePrintRawModelOutput(result.content);
609
657
  }
610
658
  } catch (err) {
611
- stopSpinner();
659
+ idleSpinner.stop();
612
660
  if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
661
+ } finally {
662
+ clearBuildInterruptHandler();
613
663
  }
614
664
  continue;
615
665
  }
616
666
 
617
667
  // /models — pick active model (Claude or Ollama)
618
668
  if (trimmed === '/models') {
619
- const labels = MODEL_OPTIONS.map((o) => o.label);
620
- const chosen = await selectFrom(labels, 'Select model:');
669
+ const { options, warning } = await getModelOptions();
670
+ if (warning) console.log(chalk.yellow(`\n${warning}\n`));
671
+ const labels = options.map((o) => o.label);
672
+ const chosen = await askSelect(labels, 'Select model:');
621
673
  if (chosen) {
622
- const opt = MODEL_OPTIONS.find((o) => o.label === chosen);
674
+ const opt = options.find((o) => o.label === chosen);
623
675
  if (opt) {
624
676
  if (opt.provider === 'claude' && !getClaudeKey()) {
625
- const enteredKey = (await promptSecret('Claude API key (paste then Enter): ')).trim();
677
+ const prompted = await askSecret('Claude API key (paste then Enter): ');
678
+ if (prompted === null) continue;
679
+ const enteredKey = prompted.trim();
626
680
  if (!enteredKey) {
627
681
  console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
628
682
  continue;
@@ -630,7 +684,9 @@ export async function startInteractive() {
630
684
  setClaudeKey(enteredKey);
631
685
  }
632
686
  if (opt.provider === 'openai' && !getOpenAIKey()) {
633
- const enteredKey = (await promptSecret('OpenAI API key (paste then Enter): ')).trim();
687
+ const prompted = await askSecret('OpenAI API key (paste then Enter): ');
688
+ if (prompted === null) continue;
689
+ const enteredKey = prompted.trim();
634
690
  if (!enteredKey) {
635
691
  console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
636
692
  continue;
@@ -638,7 +694,9 @@ export async function startInteractive() {
638
694
  setOpenAIKey(enteredKey);
639
695
  }
640
696
  if (opt.provider === 'ollama' && opt.model.endsWith('-cloud') && !getOllamaKey()) {
641
- const enteredKey = (await promptSecret('Ollama API key for cloud models (paste then Enter): ')).trim();
697
+ const prompted = await askSecret('Ollama API key for cloud models (paste then Enter): ');
698
+ if (prompted === null) continue;
699
+ const enteredKey = prompted.trim();
642
700
  if (!enteredKey) {
643
701
  console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
644
702
  continue;
@@ -670,18 +728,39 @@ export async function startInteractive() {
670
728
 
671
729
  // /cmd [command] — run a shell command in the current folder
672
730
  if (trimmed === '/cmd' || trimmed.startsWith('/cmd ')) {
673
- const command = trimmed.startsWith('/cmd ')
674
- ? trimmed.slice(5).trim()
675
- : (await promptLine(chalk.bold('Command: '))).trim();
731
+ let command;
732
+ if (trimmed.startsWith('/cmd ')) {
733
+ command = trimmed.slice(5).trim();
734
+ } else {
735
+ const prompted = await askLine(chalk.bold('Command: '));
736
+ if (prompted === null) continue;
737
+ command = prompted.trim();
738
+ }
676
739
  if (!command) {
677
740
  console.log(chalk.yellow('No command given. Use /cmd <command> e.g. /cmd ls -la\n'));
678
741
  continue;
679
742
  }
743
+
744
+ // Intercept 'cd' to act like native /cd
745
+ if (command === 'cd' || command.startsWith('cd ')) {
746
+ const arg = command.startsWith('cd ') ? command.slice(3).trim() : '';
747
+ const target = arg
748
+ ? resolve(process.cwd(), arg.replace(/^~/, homedir()))
749
+ : homedir();
750
+ try {
751
+ process.chdir(target);
752
+ allFiles = getFilesAndDirs();
753
+ console.log(chalk.dim(`\n📁 ${process.cwd()}\n`));
754
+ } catch {
755
+ console.log(chalk.red(`\nno such directory: ${target}\n`));
756
+ }
757
+ continue;
758
+ }
759
+
680
760
  try {
681
761
  const cwd = process.cwd();
682
- const { stdout, stderr, exitCode } = await execCommand(command, cwd);
683
- const out = [stdout, stderr].filter(Boolean).join(stderr ? '\n' : '').trim();
684
- if (out) console.log(out);
762
+ console.log(''); // Add a newline before output
763
+ const exitCode = await spawnCommand(command, cwd);
685
764
  if (exitCode !== 0) {
686
765
  console.log(chalk.red(`\nExit code: ${exitCode}\n`));
687
766
  } else {
@@ -727,31 +806,16 @@ export async function startInteractive() {
727
806
  const confirmFn = () => Promise.resolve(true);
728
807
  const confirmFileEdit = async () => true;
729
808
  const startTime = Date.now();
730
- const DOTS = ['.', '..', '...'];
731
- let dotIdx = 0;
732
- let spinner = null;
733
- const startSpinner = () => {
734
- if (spinner) { clearInterval(spinner); spinner = null; }
735
- dotIdx = 0;
736
- process.stdout.write(chalk.dim('\nLaravel › '));
737
- spinner = setInterval(() => {
738
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
739
- process.stdout.write('\r' + chalk.dim('Laravel › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
740
- dotIdx++;
741
- }, 400);
742
- };
743
- const stopSpinner = () => {
744
- if (spinner) { clearInterval(spinner); spinner = null; }
745
- process.stdout.write('\r\x1b[0J');
746
- };
809
+ const idleSpinner = createIdleSpinner('Squirming ', { startTime });
810
+ const clearLaravelInterruptHandler = registerAbortController(abortController, () => idleSpinner.stop());
747
811
  try {
748
812
  const result = await runAgentLoop(agentMessages, {
749
813
  signal: abortController.signal,
750
814
  cwd: process.cwd(),
751
815
  confirmFn,
752
816
  confirmFileEdit,
753
- onThinking: () => { startSpinner(); },
754
- onBeforeToolRun: () => { stopSpinner(); },
817
+ onThinking: () => { idleSpinner.bump(); },
818
+ onBeforeToolRun: () => { idleSpinner.pause(); },
755
819
  onIteration: (iter, max, toolCount) => {
756
820
  const w = process.stdout.columns || 80;
757
821
  const label = ` Step ${iter} `;
@@ -766,7 +830,7 @@ export async function startInteractive() {
766
830
  console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
767
831
  },
768
832
  });
769
- stopSpinner();
833
+ idleSpinner.stop();
770
834
  if (result) {
771
835
  chatMessages.push(result.finalMessage);
772
836
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -778,8 +842,10 @@ export async function startInteractive() {
778
842
  maybePrintRawModelOutput(result.content);
779
843
  }
780
844
  } catch (err) {
781
- stopSpinner();
845
+ idleSpinner.stop();
782
846
  if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
847
+ } finally {
848
+ clearLaravelInterruptHandler();
783
849
  }
784
850
  continue;
785
851
  }
@@ -800,32 +866,16 @@ export async function startInteractive() {
800
866
  const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
801
867
  maybePrintFullPayload(agentMessages);
802
868
  const abortController = new AbortController();
803
- const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
869
+ const confirmFn = async (cmd) => (await askConfirm(chalk.bold(`Run: ${cmd}? [y/N] `))) === true;
804
870
  const confirmFileEdit = async (name, args) => {
805
871
  console.log(chalk.dim('\n Proposed change:\n'));
806
872
  console.log(formatFileEditPreview(name, args));
807
873
  console.log('');
808
- return confirm(chalk.bold('Apply this change? [y/N] '));
874
+ return (await askConfirm(chalk.bold('Apply this change? [y/N] '))) === true;
809
875
  };
810
876
  const startTime = Date.now();
811
- const DOTS = ['.', '..', '...'];
812
- let dotIdx = 0;
813
- let spinner = null;
814
-
815
- const startSpinner = () => {
816
- if (spinner) { clearInterval(spinner); spinner = null; }
817
- dotIdx = 0;
818
- process.stdout.write(chalk.dim('\nAgent › '));
819
- spinner = setInterval(() => {
820
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
821
- process.stdout.write('\r' + chalk.dim('Agent › ') + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
822
- dotIdx++;
823
- }, 400);
824
- };
825
- const stopSpinner = () => {
826
- if (spinner) { clearInterval(spinner); spinner = null; }
827
- process.stdout.write('\r\x1b[0J');
828
- };
877
+ const idleSpinner = createIdleSpinner('Agent ', { startTime });
878
+ const clearAgentInterruptHandler = registerAbortController(abortController, () => idleSpinner.stop());
829
879
 
830
880
  try {
831
881
  const result = await runAgentLoop(agentMessages, {
@@ -834,10 +884,10 @@ export async function startInteractive() {
834
884
  confirmFn,
835
885
  confirmFileEdit,
836
886
  onThinking: () => {
837
- startSpinner();
887
+ idleSpinner.bump();
838
888
  },
839
889
  onBeforeToolRun: () => {
840
- stopSpinner();
890
+ idleSpinner.pause();
841
891
  },
842
892
  onIteration: (iter, max, toolCount) => {
843
893
  const w = process.stdout.columns || 80;
@@ -853,7 +903,7 @@ export async function startInteractive() {
853
903
  console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
854
904
  },
855
905
  });
856
- stopSpinner();
906
+ idleSpinner.stop();
857
907
  if (result) {
858
908
  chatMessages.push(result.finalMessage);
859
909
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -865,8 +915,10 @@ export async function startInteractive() {
865
915
  maybePrintRawModelOutput(result.content);
866
916
  }
867
917
  } catch (err) {
868
- stopSpinner();
918
+ idleSpinner.stop();
869
919
  if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
920
+ } finally {
921
+ clearAgentInterruptHandler();
870
922
  }
871
923
  }
872
924
  }