markov-cli 1.0.15 → 1.0.16

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.
package/.env.example CHANGED
@@ -10,3 +10,5 @@
10
10
  # Optional: use OpenAI (ChatGPT) instead of the backend. Used when ANTHROPIC_API_KEY is not set.
11
11
  # OPENAI_API_KEY=sk-your_openai_api_key
12
12
  # OPENAI_MODEL=gpt-4o-mini
13
+
14
+ # OLLAMA_API_KEY=
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "markov-cli",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "Markov CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@ export async function applyCodeBlockEdits(responseText, loadedFiles = []) {
17
17
  for (const edit of edits) {
18
18
  renderDiff(edit.filepath, edit.content);
19
19
  const ok = await confirm(chalk.bold(`Apply changes to ${chalk.cyan(edit.filepath)}? [y/N] `));
20
- if (ok) {
20
+ if (ok === true) {
21
21
  applyEdit(edit.filepath, edit.content);
22
22
  console.log(chalk.green(` ✓ ${edit.filepath} updated\n`));
23
23
  } else {
package/src/input.js CHANGED
@@ -7,7 +7,7 @@ const visibleLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
7
7
 
8
8
  const PREFIX = '❯ ';
9
9
  const HINT = chalk.dim(' Ask Markov anything...');
10
- const STATUS_LEFT = chalk.dim('/help');
10
+ const STATUS_LEFT = chalk.dim('ctrl tab to switch mode');
11
11
  const PICKER_MAX = 6;
12
12
 
13
13
  function border() {
@@ -25,12 +25,13 @@ const INPUT_HISTORY_MAX = 100;
25
25
 
26
26
  /**
27
27
  * Show an interactive raw-mode prompt that supports @file autocomplete and Up/Down input history.
28
- * Returns a Promise<string|null> — null means the prompt was cancelled (Ctrl+Q).
28
+ * Returns a Promise<string|object|null> — null means the prompt was cancelled (Ctrl+Q),
29
+ * and { type: 'interrupt' } means the prompt was interrupted with Ctrl+C.
29
30
  * @param {string} _promptStr
30
31
  * @param {string[]} allFiles
31
32
  * @param {string[]} inputHistory - Mutable array of previous inputs; newest last. Up/Down navigate this; Enter pushes current input.
32
33
  */
33
- export function chatPrompt(_promptStr, allFiles, inputHistory = []) {
34
+ export function chatPrompt(_promptStr, allFiles, inputHistory = [], modes = [], initialMode = null) {
34
35
  return new Promise((resolve) => {
35
36
  const stdin = process.stdin;
36
37
  const stdout = process.stdout;
@@ -41,6 +42,7 @@ export function chatPrompt(_promptStr, allFiles, inputHistory = []) {
41
42
  let pickerIndex = 0;
42
43
  let historyIndex = -1; // -1 = not navigating history
43
44
  let cursorLineOffset = 0; // lines from top of drawn block to where cursor sits
45
+ let currentMode = initialMode;
44
46
 
45
47
  const getAtPos = () => {
46
48
  for (let i = buffer.length - 1; i >= 0; i--) {
@@ -58,7 +60,18 @@ export function chatPrompt(_promptStr, allFiles, inputHistory = []) {
58
60
  };
59
61
 
60
62
  const redraw = () => {
61
- const inputLine = chalk.cyan(PREFIX) + (buffer || HINT);
63
+ let modePrefix = '';
64
+ if (currentMode) {
65
+ switch (currentMode) {
66
+ case '/cmd': modePrefix = chalk.yellow(`[${currentMode}] `); break;
67
+ case '/agent': modePrefix = chalk.green(`[${currentMode}] `); break;
68
+ case '/plan': modePrefix = chalk.blue(`[${currentMode}] `); break;
69
+ case '/build': modePrefix = chalk.cyan(`[${currentMode}] `); break;
70
+ case '/yolo': modePrefix = chalk.red(`[${currentMode}] `); break;
71
+ default: modePrefix = chalk.magenta(`[${currentMode}] `); break;
72
+ }
73
+ }
74
+ const inputLine = modePrefix + chalk.cyan(PREFIX) + (buffer || HINT);
62
75
 
63
76
  // Build scrollable picker window of PICKER_MAX items.
64
77
  const total = pickerFiles.length;
@@ -99,12 +112,12 @@ export function chatPrompt(_promptStr, allFiles, inputHistory = []) {
99
112
  // it takes multiple visual lines and we must move up by that many to reach
100
113
  // the top border on the next redraw.
101
114
  const w = process.stdout.columns || 80;
102
- const inputVisualLen = visibleLen(chalk.cyan(PREFIX)) + visibleLen(buffer || HINT);
115
+ const inputVisualLen = visibleLen(modePrefix) + visibleLen(chalk.cyan(PREFIX)) + visibleLen(buffer || HINT);
103
116
  const inputVisualLines = Math.max(1, Math.ceil(inputVisualLen / w));
104
117
  cursorLineOffset = inputVisualLines;
105
118
 
106
119
  // Position cursor: beforeCursorLen is the character offset where the cursor sits.
107
- const prefixLen = visibleLen(chalk.cyan(PREFIX));
120
+ const prefixLen = visibleLen(modePrefix) + visibleLen(chalk.cyan(PREFIX));
108
121
  const beforeCursorLen = prefixLen + visibleLen(buffer.slice(0, cursorPos));
109
122
  const lineIdx = Math.floor(beforeCursorLen / w);
110
123
  const col = (beforeCursorLen % w) + 1;
@@ -119,16 +132,22 @@ export function chatPrompt(_promptStr, allFiles, inputHistory = []) {
119
132
  cursorLineOffset = 0;
120
133
  };
121
134
 
122
- const onSigint = () => {
135
+ const onExit = () => {
123
136
  clearPanel();
124
137
  cleanup();
125
138
  stdout.write('\n');
126
139
  process.exit(0);
127
140
  };
128
141
 
142
+ const onInterrupt = () => {
143
+ clearPanel();
144
+ cleanup();
145
+ stdout.write(chalk.dim('(interrupted)\n'));
146
+ resolve({ type: 'interrupt' });
147
+ };
148
+
129
149
  const cleanup = () => {
130
150
  stdin.removeListener('data', onData);
131
- process.removeListener('SIGINT', onSigint);
132
151
  stdin.setRawMode(false);
133
152
  stdin.pause();
134
153
  };
@@ -136,8 +155,8 @@ export function chatPrompt(_promptStr, allFiles, inputHistory = []) {
136
155
  const onData = (data) => {
137
156
  const key = data.toString();
138
157
 
139
- // Ctrl+C → exit
140
- if (key === '\x03') { onSigint(); return; }
158
+ // Ctrl+C → interrupt prompt
159
+ if (key === '\x03') { onInterrupt(); return; }
141
160
 
142
161
  // Ctrl+Q → cancel prompt
143
162
  if (key === '\x11') {
@@ -149,7 +168,7 @@ export function chatPrompt(_promptStr, allFiles, inputHistory = []) {
149
168
  }
150
169
 
151
170
  // Ctrl+D → exit
152
- if (key === '\x04') { onSigint(); return; }
171
+ if (key === '\x04') { onExit(); return; }
153
172
 
154
173
  // Enter
155
174
  if (key === '\r' || key === '\n') {
@@ -171,7 +190,15 @@ export function chatPrompt(_promptStr, allFiles, inputHistory = []) {
171
190
  clearPanel();
172
191
  cleanup();
173
192
  stdout.write('\n');
174
- resolve(buffer);
193
+ resolve(modes.length > 0 ? { text: buffer, mode: currentMode } : buffer);
194
+ return;
195
+ }
196
+
197
+ // Shift+Tab → toggle mode
198
+ if (key === '\x1b[Z' && modes.length > 0) {
199
+ const idx = modes.indexOf(currentMode);
200
+ currentMode = modes[(idx + 1) % modes.length];
201
+ redraw();
175
202
  return;
176
203
  }
177
204
 
@@ -251,7 +278,6 @@ export function chatPrompt(_promptStr, allFiles, inputHistory = []) {
251
278
  stdin.resume();
252
279
  stdin.setEncoding('utf8');
253
280
  stdin.on('data', onData);
254
- process.on('SIGINT', onSigint);
255
281
  redraw();
256
282
  });
257
283
  }
@@ -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
7
  import { chatWithTools, streamChat, streamChatWithTools, MODEL, MODEL_OPTIONS, 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
 
@@ -100,6 +97,77 @@ export async function startInteractive() {
100
97
  let pendingMessage = null;
101
98
  let lastPlan = null;
102
99
  const inputHistory = [];
100
+ const availableModes = ['/cmd', '/agent', '/plan', '/build', '/yolo'];
101
+ let currentMode = '/cmd';
102
+ let interruptArmed = false;
103
+ let activeInterruptHandler = null;
104
+
105
+ const isInterrupted = (value) => value === PROMPT_INTERRUPT || (typeof value === 'object' && value !== null && value.type === 'interrupt');
106
+ const resetInterruptState = () => {
107
+ interruptArmed = false;
108
+ };
109
+ const exitInteractive = () => {
110
+ process.stdout.write('\n');
111
+ process.exit(0);
112
+ };
113
+ const registerInterruptHandler = (handler) => {
114
+ activeInterruptHandler = handler;
115
+ return () => {
116
+ if (activeInterruptHandler === handler) activeInterruptHandler = null;
117
+ };
118
+ };
119
+ const handleInterrupt = ({ alreadyPrinted = false } = {}) => {
120
+ if (activeInterruptHandler) {
121
+ const handler = activeInterruptHandler;
122
+ activeInterruptHandler = null;
123
+ handler();
124
+ return;
125
+ }
126
+ if (interruptArmed) exitInteractive();
127
+ interruptArmed = true;
128
+ if (!alreadyPrinted) console.log(chalk.dim('\n(interrupted)\n'));
129
+ };
130
+ const registerAbortController = (abortController, onAbort) => registerInterruptHandler(() => {
131
+ onAbort?.();
132
+ if (!abortController.signal.aborted) abortController.abort();
133
+ console.log(chalk.dim('\n(interrupted)\n'));
134
+ });
135
+ const askLine = async (label) => {
136
+ const value = await promptLine(label);
137
+ if (isInterrupted(value)) {
138
+ handleInterrupt({ alreadyPrinted: true });
139
+ return null;
140
+ }
141
+ return value;
142
+ };
143
+ const askSecret = async (label) => {
144
+ const value = await promptSecret(label);
145
+ if (isInterrupted(value)) {
146
+ handleInterrupt({ alreadyPrinted: true });
147
+ return null;
148
+ }
149
+ return value;
150
+ };
151
+ const askSelect = async (options, label) => {
152
+ const value = await selectFrom(options, label);
153
+ if (isInterrupted(value)) {
154
+ handleInterrupt();
155
+ return null;
156
+ }
157
+ return value;
158
+ };
159
+ const askConfirm = async (question) => {
160
+ const value = await confirm(question);
161
+ if (isInterrupted(value)) {
162
+ handleInterrupt({ alreadyPrinted: true });
163
+ return false;
164
+ }
165
+ return value;
166
+ };
167
+
168
+ process.on('SIGINT', () => {
169
+ handleInterrupt();
170
+ });
103
171
 
104
172
  while (true) {
105
173
  let raw;
@@ -108,17 +176,38 @@ export async function startInteractive() {
108
176
  pendingMessage = null;
109
177
  console.log(chalk.magenta('you> ') + raw + '\n');
110
178
  } else {
111
- raw = await chatPrompt(chalk.magenta('you> '), allFiles, inputHistory);
179
+ const result = await chatPrompt(chalk.magenta('you> '), allFiles, inputHistory, availableModes, currentMode);
180
+ if (isInterrupted(result)) {
181
+ handleInterrupt({ alreadyPrinted: true });
182
+ continue;
183
+ }
184
+ if (result === null) continue;
185
+ resetInterruptState();
186
+ if (typeof result === 'object' && result !== null) {
187
+ raw = result.text;
188
+ currentMode = result.mode;
189
+ } else {
190
+ raw = result;
191
+ }
112
192
  }
113
193
  if (raw === null) continue;
114
- const trimmed = raw.trim();
194
+ let trimmed = raw.trim();
195
+
196
+ if (!trimmed) {
197
+ if (currentMode === '/build') trimmed = '/build';
198
+ else continue;
199
+ } else if (!trimmed.startsWith('/') && currentMode !== '/agent') {
200
+ trimmed = `${currentMode} ${trimmed}`;
201
+ }
115
202
 
116
203
  if (!trimmed) continue;
117
204
 
118
205
  // /login — authenticate and save token
119
206
  if (trimmed === '/login') {
120
- const email = await promptLine('Email: ');
121
- const password = await promptSecret('Password: ');
207
+ const email = await askLine('Email: ');
208
+ if (email === null) continue;
209
+ const password = await askSecret('Password: ');
210
+ if (password === null) continue;
122
211
  try {
123
212
  await login(email, password);
124
213
  console.log(chalk.green('✓ logged in\n'));
@@ -190,9 +279,14 @@ export async function startInteractive() {
190
279
 
191
280
  // /plan [prompt] — stream a plan (no tools), store as lastPlan
192
281
  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();
282
+ let rawUserContent;
283
+ if (trimmed.startsWith('/plan ')) {
284
+ rawUserContent = trimmed.slice(6).trim();
285
+ } else {
286
+ const prompted = await askLine(chalk.bold('What do you want to plan? '));
287
+ if (prompted === null) continue;
288
+ rawUserContent = prompted.trim();
289
+ }
196
290
  if (!rawUserContent) {
197
291
  console.log(chalk.yellow('No prompt given.\n'));
198
292
  continue;
@@ -202,24 +296,11 @@ export async function startInteractive() {
202
296
  chatMessages.push({ role: 'user', content: userContent });
203
297
  const planMessages = [buildPlanSystemMessage(), ...chatMessages];
204
298
  const planAbort = new AbortController();
205
- process.stdout.write(chalk.dim('\nPlan › '));
206
- const DOTS = ['.', '..', '...'];
207
- let dotIdx = 0;
208
299
  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);
300
+ const planSpinner = createIdleSpinner('Squirming ', { startTime: planStartTime });
301
+ const clearInterruptHandler = registerAbortController(planAbort, () => planSpinner.stop());
302
+ planSpinner.bump();
214
303
  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
304
  try {
224
305
  let currentPlanMessages = planMessages;
225
306
  let fullPlanText = '';
@@ -232,15 +313,12 @@ export async function startInteractive() {
232
313
  {
233
314
  think: true, // plan mode only: request thinking from backend
234
315
  onContent: (token) => {
235
- if (firstContent) {
236
- clearPlanSpinner();
237
- firstContent = false;
238
- }
316
+ planSpinner.bump();
239
317
  process.stdout.write(token);
240
318
  },
241
319
  onThinking: (token) => {
320
+ planSpinner.bump();
242
321
  if (!thinkingStarted) {
243
- clearPlanSpinner();
244
322
  process.stdout.write(chalk.dim('Thinking: '));
245
323
  thinkingStarted = true;
246
324
  }
@@ -267,6 +345,7 @@ export async function startInteractive() {
267
345
  continue;
268
346
  }
269
347
  }
348
+ planSpinner.pause();
270
349
  console.log(chalk.cyan('\n ▶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
271
350
  const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
272
351
  currentPlanMessages.push({
@@ -277,6 +356,7 @@ export async function startInteractive() {
277
356
  }
278
357
  }
279
358
  chatMessages.push({ role: 'assistant', content: fullPlanText });
359
+ planSpinner.stop();
280
360
  // Store only plan (stream content), not thinking; write to plan.md for /build.
281
361
  if ((fullPlanText ?? '').trim()) {
282
362
  lastPlan = fullPlanText;
@@ -285,21 +365,24 @@ export async function startInteractive() {
285
365
  console.log('\n' + chalk.dim('Plan saved to plan.md. Use ') + chalk.green('/build') + chalk.dim(' to execute.\n'));
286
366
  maybePrintRawModelOutput(fullPlanText);
287
367
  } catch (err) {
288
- if (planSpinner) {
289
- clearInterval(planSpinner);
290
- planSpinner = null;
291
- process.stdout.write('\r\x1b[0J');
292
- }
368
+ planSpinner.stop();
293
369
  if (!planAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
370
+ } finally {
371
+ clearInterruptHandler();
294
372
  }
295
373
  continue;
296
374
  }
297
375
 
298
376
  // /yolo [prompt] — one plan in stream mode, then auto-accept and run agent until done
299
377
  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();
378
+ let rawUserContent;
379
+ if (trimmed.startsWith('/yolo ')) {
380
+ rawUserContent = trimmed.slice(6).trim();
381
+ } else {
382
+ const prompted = await askLine(chalk.bold('What do you want to yolo? '));
383
+ if (prompted === null) continue;
384
+ rawUserContent = prompted.trim();
385
+ }
303
386
  if (!rawUserContent) {
304
387
  console.log(chalk.yellow('No prompt given.\n'));
305
388
  continue;
@@ -309,9 +392,11 @@ export async function startInteractive() {
309
392
  chatMessages.push({ role: 'user', content: planUserContent });
310
393
  const planMessages = [buildPlanSystemMessage(), ...chatMessages];
311
394
  const yoloAbort = new AbortController();
312
- process.stdout.write(chalk.dim('\nYolo › Plan › '));
313
395
  let thinkingStarted = false;
314
396
  let fullPlanText = '';
397
+ const yoloPlanSpinner = createIdleSpinner('Squirming ');
398
+ const clearYoloPlanInterruptHandler = registerAbortController(yoloAbort, () => yoloPlanSpinner.stop());
399
+ yoloPlanSpinner.bump();
315
400
  try {
316
401
  let currentPlanMessages = planMessages;
317
402
  const yoloPlanMaxIter = 10;
@@ -322,8 +407,12 @@ export async function startInteractive() {
322
407
  MODEL,
323
408
  {
324
409
  think: true, // plan phase: request thinking from backend
325
- onContent: (token) => process.stdout.write(token),
410
+ onContent: (token) => {
411
+ yoloPlanSpinner.bump();
412
+ process.stdout.write(token);
413
+ },
326
414
  onThinking: (token) => {
415
+ yoloPlanSpinner.bump();
327
416
  if (!thinkingStarted) {
328
417
  process.stdout.write(chalk.dim('Thinking: '));
329
418
  thinkingStarted = true;
@@ -351,6 +440,7 @@ export async function startInteractive() {
351
440
  continue;
352
441
  }
353
442
  }
443
+ yoloPlanSpinner.pause();
354
444
  console.log(chalk.cyan('\n ▶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
355
445
  const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
356
446
  currentPlanMessages.push({
@@ -361,12 +451,16 @@ export async function startInteractive() {
361
451
  }
362
452
  }
363
453
  chatMessages.push({ role: 'assistant', content: fullPlanText });
454
+ yoloPlanSpinner.stop();
364
455
  // Store only plan (stream content), not thinking, so build phase uses exactly this.
365
456
  if ((fullPlanText ?? '').trim()) lastPlan = fullPlanText;
366
457
  maybePrintRawModelOutput(fullPlanText);
367
458
  } catch (err) {
459
+ yoloPlanSpinner.stop();
368
460
  if (!yoloAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
369
461
  continue;
462
+ } finally {
463
+ clearYoloPlanInterruptHandler();
370
464
  }
371
465
  const buildContent = (await getLsContext()) + (await getGrepContext()) +
372
466
  '\n\nPlan:\n' + lastPlan + '\n\nExecute this plan using your tools. Run commands and edit files as needed.';
@@ -377,31 +471,16 @@ export async function startInteractive() {
377
471
  const confirmFn = () => Promise.resolve(true);
378
472
  const confirmFileEdit = async () => true;
379
473
  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
- };
474
+ const idleSpinner = createIdleSpinner('Squirming ', { startTime });
475
+ const clearYoloBuildInterruptHandler = registerAbortController(abortController, () => idleSpinner.stop());
397
476
  try {
398
477
  const result = await runAgentLoop(agentMessages, {
399
478
  signal: abortController.signal,
400
479
  cwd: process.cwd(),
401
480
  confirmFn,
402
481
  confirmFileEdit,
403
- onThinking: () => { startSpinner(); },
404
- onBeforeToolRun: () => { stopSpinner(); },
482
+ onThinking: () => { idleSpinner.bump(); },
483
+ onBeforeToolRun: () => { idleSpinner.pause(); },
405
484
  onIteration: (iter, max, toolCount) => {
406
485
  const w = process.stdout.columns || 80;
407
486
  const label = ` Step ${iter} `;
@@ -416,7 +495,7 @@ export async function startInteractive() {
416
495
  console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
417
496
  },
418
497
  });
419
- stopSpinner();
498
+ idleSpinner.stop();
420
499
  if (result) {
421
500
  chatMessages.push(result.finalMessage);
422
501
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -428,39 +507,33 @@ export async function startInteractive() {
428
507
  maybePrintRawModelOutput(result.content);
429
508
  }
430
509
  } catch (err) {
431
- stopSpinner();
510
+ idleSpinner.stop();
432
511
  if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
512
+ } finally {
513
+ clearYoloBuildInterruptHandler();
433
514
  }
434
515
  continue;
435
516
  }
436
517
 
437
518
  // /init [prompt] — create markov.md with project summary (agent writes file via tools)
438
519
  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();
520
+ let rawUserContent;
521
+ if (trimmed.startsWith('/init ')) {
522
+ rawUserContent = trimmed.slice(6).trim();
523
+ } else {
524
+ const prompted = await askLine(chalk.bold('Describe the project to summarize (optional): '));
525
+ if (prompted === null) continue;
526
+ rawUserContent = prompted.trim();
527
+ }
442
528
  const userContent = (await getLsContext()) + (await getGrepContext()) +
443
529
  (rawUserContent ? `Create markov.md with a project summary. Focus on: ${rawUserContent}` : 'Create markov.md with a concise project summary.');
444
530
  const initMessages = [buildInitSystemMessage(), { role: 'user', content: userContent }];
445
531
  const initAbort = new AbortController();
446
- process.stdout.write(chalk.dim('\nInit › '));
447
- const DOTS = ['.', '..', '...'];
448
- let dotIdx = 0;
449
532
  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);
533
+ const initSpinner = createIdleSpinner('Squirming ', { startTime: initStartTime });
534
+ const clearInitInterruptHandler = registerAbortController(initAbort, () => initSpinner.stop());
535
+ initSpinner.bump();
455
536
  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
537
  try {
465
538
  let currentInitMessages = initMessages;
466
539
  const initMaxIter = 10;
@@ -472,15 +545,12 @@ export async function startInteractive() {
472
545
  {
473
546
  think: true,
474
547
  onContent: (token) => {
475
- if (firstContent) {
476
- clearInitSpinner();
477
- firstContent = false;
478
- }
548
+ initSpinner.bump();
479
549
  process.stdout.write(token);
480
550
  },
481
551
  onThinking: (token) => {
552
+ initSpinner.bump();
482
553
  if (!thinkingStarted) {
483
- clearInitSpinner();
484
554
  process.stdout.write(chalk.dim('Thinking: '));
485
555
  thinkingStarted = true;
486
556
  }
@@ -506,6 +576,7 @@ export async function startInteractive() {
506
576
  continue;
507
577
  }
508
578
  }
579
+ initSpinner.pause();
509
580
  console.log(chalk.cyan('\n ▶ ') + chalk.bold(name) + chalk.dim(' ') + (name === 'web_search' ? (args?.query ?? '') : (args?.command ?? '')));
510
581
  const result = await runTool(name, args ?? {}, { cwd: process.cwd(), confirmFn: () => Promise.resolve(true) });
511
582
  currentInitMessages.push({
@@ -515,18 +586,17 @@ export async function startInteractive() {
515
586
  });
516
587
  }
517
588
  }
589
+ initSpinner.stop();
518
590
  if (existsSync(getMarkovPath())) {
519
591
  console.log('\n' + chalk.green('✓ markov.md created. It will be included in the system message from now on.\n'));
520
592
  } else {
521
593
  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
594
  }
523
595
  } catch (err) {
524
- if (initSpinner) {
525
- clearInterval(initSpinner);
526
- initSpinner = null;
527
- process.stdout.write('\r\x1b[0J');
528
- }
596
+ initSpinner.stop();
529
597
  if (!initAbort.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
598
+ } finally {
599
+ clearInitInterruptHandler();
530
600
  }
531
601
  continue;
532
602
  }
@@ -549,39 +619,24 @@ export async function startInteractive() {
549
619
  const agentMessages = [buildAgentSystemMessage(), { role: 'user', content: buildContent }];
550
620
  maybePrintFullPayload(agentMessages);
551
621
  const abortController = new AbortController();
552
- const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
622
+ const confirmFn = async (cmd) => (await askConfirm(chalk.bold(`Run: ${cmd}? [y/N] `))) === true;
553
623
  const confirmFileEdit = async (name, args) => {
554
624
  console.log(chalk.dim('\n Proposed change:\n'));
555
625
  console.log(formatFileEditPreview(name, args));
556
626
  console.log('');
557
- return confirm(chalk.bold('Apply this change? [y/N] '));
627
+ return (await askConfirm(chalk.bold('Apply this change? [y/N] '))) === true;
558
628
  };
559
629
  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
- };
630
+ const idleSpinner = createIdleSpinner('Squirming ', { startTime });
631
+ const clearBuildInterruptHandler = registerAbortController(abortController, () => idleSpinner.stop());
577
632
  try {
578
633
  const result = await runAgentLoop(agentMessages, {
579
634
  signal: abortController.signal,
580
635
  cwd: process.cwd(),
581
636
  confirmFn,
582
637
  confirmFileEdit,
583
- onThinking: () => { startSpinner(); },
584
- onBeforeToolRun: () => { stopSpinner(); },
638
+ onThinking: () => { idleSpinner.bump(); },
639
+ onBeforeToolRun: () => { idleSpinner.pause(); },
585
640
  onIteration: (iter, max, toolCount) => {
586
641
  const w = process.stdout.columns || 80;
587
642
  const label = ` Step ${iter} `;
@@ -596,7 +651,7 @@ export async function startInteractive() {
596
651
  console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
597
652
  },
598
653
  });
599
- stopSpinner();
654
+ idleSpinner.stop();
600
655
  if (result) {
601
656
  chatMessages.push(result.finalMessage);
602
657
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -608,8 +663,10 @@ export async function startInteractive() {
608
663
  maybePrintRawModelOutput(result.content);
609
664
  }
610
665
  } catch (err) {
611
- stopSpinner();
666
+ idleSpinner.stop();
612
667
  if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
668
+ } finally {
669
+ clearBuildInterruptHandler();
613
670
  }
614
671
  continue;
615
672
  }
@@ -617,12 +674,14 @@ export async function startInteractive() {
617
674
  // /models — pick active model (Claude or Ollama)
618
675
  if (trimmed === '/models') {
619
676
  const labels = MODEL_OPTIONS.map((o) => o.label);
620
- const chosen = await selectFrom(labels, 'Select model:');
677
+ const chosen = await askSelect(labels, 'Select model:');
621
678
  if (chosen) {
622
679
  const opt = MODEL_OPTIONS.find((o) => o.label === chosen);
623
680
  if (opt) {
624
681
  if (opt.provider === 'claude' && !getClaudeKey()) {
625
- const enteredKey = (await promptSecret('Claude API key (paste then Enter): ')).trim();
682
+ const prompted = await askSecret('Claude API key (paste then Enter): ');
683
+ if (prompted === null) continue;
684
+ const enteredKey = prompted.trim();
626
685
  if (!enteredKey) {
627
686
  console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
628
687
  continue;
@@ -630,7 +689,9 @@ export async function startInteractive() {
630
689
  setClaudeKey(enteredKey);
631
690
  }
632
691
  if (opt.provider === 'openai' && !getOpenAIKey()) {
633
- const enteredKey = (await promptSecret('OpenAI API key (paste then Enter): ')).trim();
692
+ const prompted = await askSecret('OpenAI API key (paste then Enter): ');
693
+ if (prompted === null) continue;
694
+ const enteredKey = prompted.trim();
634
695
  if (!enteredKey) {
635
696
  console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
636
697
  continue;
@@ -638,7 +699,9 @@ export async function startInteractive() {
638
699
  setOpenAIKey(enteredKey);
639
700
  }
640
701
  if (opt.provider === 'ollama' && opt.model.endsWith('-cloud') && !getOllamaKey()) {
641
- const enteredKey = (await promptSecret('Ollama API key for cloud models (paste then Enter): ')).trim();
702
+ const prompted = await askSecret('Ollama API key for cloud models (paste then Enter): ');
703
+ if (prompted === null) continue;
704
+ const enteredKey = prompted.trim();
642
705
  if (!enteredKey) {
643
706
  console.log(chalk.yellow('\nNo key entered. Model not switched.\n'));
644
707
  continue;
@@ -670,18 +733,39 @@ export async function startInteractive() {
670
733
 
671
734
  // /cmd [command] — run a shell command in the current folder
672
735
  if (trimmed === '/cmd' || trimmed.startsWith('/cmd ')) {
673
- const command = trimmed.startsWith('/cmd ')
674
- ? trimmed.slice(5).trim()
675
- : (await promptLine(chalk.bold('Command: '))).trim();
736
+ let command;
737
+ if (trimmed.startsWith('/cmd ')) {
738
+ command = trimmed.slice(5).trim();
739
+ } else {
740
+ const prompted = await askLine(chalk.bold('Command: '));
741
+ if (prompted === null) continue;
742
+ command = prompted.trim();
743
+ }
676
744
  if (!command) {
677
745
  console.log(chalk.yellow('No command given. Use /cmd <command> e.g. /cmd ls -la\n'));
678
746
  continue;
679
747
  }
748
+
749
+ // Intercept 'cd' to act like native /cd
750
+ if (command === 'cd' || command.startsWith('cd ')) {
751
+ const arg = command.startsWith('cd ') ? command.slice(3).trim() : '';
752
+ const target = arg
753
+ ? resolve(process.cwd(), arg.replace(/^~/, homedir()))
754
+ : homedir();
755
+ try {
756
+ process.chdir(target);
757
+ allFiles = getFilesAndDirs();
758
+ console.log(chalk.dim(`\n📁 ${process.cwd()}\n`));
759
+ } catch {
760
+ console.log(chalk.red(`\nno such directory: ${target}\n`));
761
+ }
762
+ continue;
763
+ }
764
+
680
765
  try {
681
766
  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);
767
+ console.log(''); // Add a newline before output
768
+ const exitCode = await spawnCommand(command, cwd);
685
769
  if (exitCode !== 0) {
686
770
  console.log(chalk.red(`\nExit code: ${exitCode}\n`));
687
771
  } else {
@@ -727,31 +811,16 @@ export async function startInteractive() {
727
811
  const confirmFn = () => Promise.resolve(true);
728
812
  const confirmFileEdit = async () => true;
729
813
  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
- };
814
+ const idleSpinner = createIdleSpinner('Squirming ', { startTime });
815
+ const clearLaravelInterruptHandler = registerAbortController(abortController, () => idleSpinner.stop());
747
816
  try {
748
817
  const result = await runAgentLoop(agentMessages, {
749
818
  signal: abortController.signal,
750
819
  cwd: process.cwd(),
751
820
  confirmFn,
752
821
  confirmFileEdit,
753
- onThinking: () => { startSpinner(); },
754
- onBeforeToolRun: () => { stopSpinner(); },
822
+ onThinking: () => { idleSpinner.bump(); },
823
+ onBeforeToolRun: () => { idleSpinner.pause(); },
755
824
  onIteration: (iter, max, toolCount) => {
756
825
  const w = process.stdout.columns || 80;
757
826
  const label = ` Step ${iter} `;
@@ -766,7 +835,7 @@ export async function startInteractive() {
766
835
  console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
767
836
  },
768
837
  });
769
- stopSpinner();
838
+ idleSpinner.stop();
770
839
  if (result) {
771
840
  chatMessages.push(result.finalMessage);
772
841
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -778,8 +847,10 @@ export async function startInteractive() {
778
847
  maybePrintRawModelOutput(result.content);
779
848
  }
780
849
  } catch (err) {
781
- stopSpinner();
850
+ idleSpinner.stop();
782
851
  if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
852
+ } finally {
853
+ clearLaravelInterruptHandler();
783
854
  }
784
855
  continue;
785
856
  }
@@ -800,32 +871,16 @@ export async function startInteractive() {
800
871
  const agentMessages = [buildAgentSystemMessage(), ...chatMessages];
801
872
  maybePrintFullPayload(agentMessages);
802
873
  const abortController = new AbortController();
803
- const confirmFn = (cmd) => confirm(chalk.bold(`Run: ${cmd}? [y/N] `));
874
+ const confirmFn = async (cmd) => (await askConfirm(chalk.bold(`Run: ${cmd}? [y/N] `))) === true;
804
875
  const confirmFileEdit = async (name, args) => {
805
876
  console.log(chalk.dim('\n Proposed change:\n'));
806
877
  console.log(formatFileEditPreview(name, args));
807
878
  console.log('');
808
- return confirm(chalk.bold('Apply this change? [y/N] '));
879
+ return (await askConfirm(chalk.bold('Apply this change? [y/N] '))) === true;
809
880
  };
810
881
  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
- };
882
+ const idleSpinner = createIdleSpinner('Agent ', { startTime });
883
+ const clearAgentInterruptHandler = registerAbortController(abortController, () => idleSpinner.stop());
829
884
 
830
885
  try {
831
886
  const result = await runAgentLoop(agentMessages, {
@@ -834,10 +889,10 @@ export async function startInteractive() {
834
889
  confirmFn,
835
890
  confirmFileEdit,
836
891
  onThinking: () => {
837
- startSpinner();
892
+ idleSpinner.bump();
838
893
  },
839
894
  onBeforeToolRun: () => {
840
- stopSpinner();
895
+ idleSpinner.pause();
841
896
  },
842
897
  onIteration: (iter, max, toolCount) => {
843
898
  const w = process.stdout.columns || 80;
@@ -853,7 +908,7 @@ export async function startInteractive() {
853
908
  console.log(chalk.dim(' ') + formatToolResultSummary(name, resultStr));
854
909
  },
855
910
  });
856
- stopSpinner();
911
+ idleSpinner.stop();
857
912
  if (result) {
858
913
  chatMessages.push(result.finalMessage);
859
914
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -865,8 +920,10 @@ export async function startInteractive() {
865
920
  maybePrintRawModelOutput(result.content);
866
921
  }
867
922
  } catch (err) {
868
- stopSpinner();
923
+ idleSpinner.stop();
869
924
  if (!abortController.signal?.aborted) console.log(chalk.red(`\n${err.message}\n`));
925
+ } finally {
926
+ clearAgentInterruptHandler();
870
927
  }
871
928
  }
872
929
  }
package/src/tools.js CHANGED
@@ -1,4 +1,4 @@
1
- import { exec } from 'child_process';
1
+ import { exec, spawn } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { mkdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, readdirSync, statSync } from 'fs';
4
4
  import { resolve, dirname } from 'path';
@@ -425,6 +425,35 @@ export async function execCommand(command, cwd = process.cwd()) {
425
425
  }
426
426
  }
427
427
 
428
+ /**
429
+ * Execute a shell command and stream its output.
430
+ * @param {string} command
431
+ * @param {string} [cwd]
432
+ * @returns {Promise<number>} exit code
433
+ */
434
+ export function spawnCommand(command, cwd = process.cwd()) {
435
+ return new Promise((resolve) => {
436
+ if (command == null || typeof command !== 'string' || !command.trim()) {
437
+ return resolve(1);
438
+ }
439
+
440
+ const child = spawn(command, {
441
+ cwd,
442
+ shell: true,
443
+ stdio: 'inherit' // This pipes stdout and stderr directly to the terminal
444
+ });
445
+
446
+ child.on('error', (err) => {
447
+ console.error(`\nFailed to start command: ${err.message}`);
448
+ resolve(1);
449
+ });
450
+
451
+ child.on('close', (code) => {
452
+ resolve(code ?? 1);
453
+ });
454
+ });
455
+ }
456
+
428
457
  /**
429
458
  * Run a tool by name with the given arguments.
430
459
  * @param {string} name - Tool name (e.g. 'run_terminal_command')
package/src/ui/logo.js CHANGED
@@ -6,14 +6,9 @@ const ASCII_ART = `
6
6
  ██╔████╔██║███████║██████╔╝█████╔╝ ██║ ██║██║ ██║
7
7
  ██║╚██╔╝██║██╔══██║██╔══██╗██╔═██╗ ██║ ██║╚██╗ ██╔╝
8
8
  ██║ ╚═╝ ██║██║ ██║██║ ██║██║ ██╗╚██████╔╝ ╚████╔╝
9
- ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═══╝
10
-
11
- ██████╗ ██████╗ ██████╗ ███████╗
12
- ██╔════╝██╔═══██╗██╔══██╗██╔════╝
13
- ██║ ██║ ██║██║ ██║█████╗
14
- ██║ ██║ ██║██║ ██║██╔══╝
15
- ╚██████╗╚██████╔╝██████╔╝███████╗
16
- ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
9
+ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═══╝
10
+ ▜▘█▌▛▘▛▛▌▌▛▌▀▌▐ ▜▘▛▌▛▌▐
11
+ ▐▖▙▖▌ ▌▌▌▌▌▌█▌▐▖ ▐▖▙▌▙▌▐▖
17
12
  `;
18
13
 
19
14
  const ASCII_ART4 = `
@@ -32,13 +27,8 @@ const ASCII_ART3 = `
32
27
  ██║╚██╔╝██║██╔══██║██╔══██╗██╔═██╗ ██║ ██║╚██╗ ██╔╝
33
28
  ██║ ╚═╝ ██║██║ ██║██║ ██║██║ ██╗╚██████╔╝ ╚████╔╝
34
29
  ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═══╝
35
-
36
- ██████╗ ██████╗ ██████╗ ███████╗
37
- ██╔════╝██╔═══██╗██╔══██╗██╔════╝
38
- ██║ ██║ ██║██║ ██║█████╗
39
- ██║ ██║ ██║██║ ██║██╔══╝
40
- ╚██████╗╚██████╔╝██████╔╝███████╗
41
- ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
30
+ ▜▘█▌▛▘▛▛▌▌▛▌▀▌▐
31
+ ▐▖▙▖▌ ▌▌▌▌▌▌█▌▐▖
42
32
  `;
43
33
 
44
34
  const ASCII_ART2 = `
@@ -50,8 +40,10 @@ C8888 888 e Y8b Y8b "8" 888 888 " 888 P d888 888b Y8b Y8P
50
40
  `;
51
41
 
52
42
 
53
- const markovGradient = gradient(['#4ade80', '#6ee7b7', '#38bdf8']);
43
+ const markovGradient = gradient(['#6ee7b7','#6ee7b7']);
44
+ // const markovGradient = gradient(['#4ade80', '#6ee7b7', '#38bdf8']);
45
+
54
46
 
55
47
  export function printLogo() {
56
- console.log(markovGradient.multiline(ASCII_ART4));
48
+ console.log(markovGradient.multiline(ASCII_ART));
57
49
  }
package/src/ui/prompts.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import chalk from 'chalk';
2
2
 
3
+ export const PROMPT_INTERRUPT = { type: 'interrupt' };
4
+ const CTRL_C = '\x03';
5
+ const CTRL_Q = '\x11';
6
+
3
7
  /** Arrow-key selector. Returns the chosen string or null if cancelled. */
4
8
  export function selectFrom(options, label) {
5
9
  return new Promise((resolve) => {
@@ -32,7 +36,8 @@ export function selectFrom(options, label) {
32
36
  if (key === '\x1b[A') { idx = (idx - 1 + options.length) % options.length; draw(); return; }
33
37
  if (key === '\x1b[B') { idx = (idx + 1) % options.length; draw(); return; }
34
38
  if (key === '\r' || key === '\n') { cleanup(); resolve(options[idx]); return; }
35
- if (key === '\x03' || key === '\x11') { cleanup(); resolve(null); return; }
39
+ if (key === CTRL_C) { cleanup(); resolve(PROMPT_INTERRUPT); return; }
40
+ if (key === CTRL_Q) { cleanup(); resolve(null); return; }
36
41
  };
37
42
 
38
43
  process.stdin.setRawMode(true);
@@ -43,17 +48,30 @@ export function selectFrom(options, label) {
43
48
  });
44
49
  }
45
50
 
46
- /** Prompt y/n in raw mode, returns true for y/Y. */
51
+ /** Prompt y/n in raw mode, returns true for y/Y, false for no/cancel, or PROMPT_INTERRUPT on Ctrl+C. */
47
52
  export function confirm(question) {
48
53
  return new Promise((resolve) => {
49
54
  process.stdout.write(question);
50
55
  process.stdin.setRawMode(true);
51
56
  process.stdin.resume();
52
57
  process.stdin.setEncoding('utf8');
53
- const onKey = (key) => {
58
+ const cleanup = () => {
54
59
  process.stdin.removeListener('data', onKey);
55
60
  process.stdin.setRawMode(false);
56
61
  process.stdin.pause();
62
+ };
63
+ const onKey = (key) => {
64
+ cleanup();
65
+ if (key === CTRL_C) {
66
+ process.stdout.write(chalk.dim('(cancelled)\n'));
67
+ resolve(PROMPT_INTERRUPT);
68
+ return;
69
+ }
70
+ if (key === CTRL_Q) {
71
+ process.stdout.write(chalk.dim('(cancelled)\n'));
72
+ resolve(false);
73
+ return;
74
+ }
57
75
  const answer = key.toLowerCase() === 'y';
58
76
  process.stdout.write(answer ? chalk.green('y\n') : chalk.dim('n\n'));
59
77
  resolve(answer);
@@ -67,12 +85,27 @@ export function promptLine(label) {
67
85
  return new Promise((resolve) => {
68
86
  process.stdout.write(label);
69
87
  let buf = '';
88
+ const cleanup = () => {
89
+ process.stdin.removeListener('data', onData);
90
+ process.stdin.setRawMode(false);
91
+ process.stdin.pause();
92
+ };
70
93
  const onData = (data) => {
71
94
  const key = data.toString();
95
+ if (key === CTRL_C) {
96
+ cleanup();
97
+ process.stdout.write(chalk.dim('(cancelled)\n'));
98
+ resolve(PROMPT_INTERRUPT);
99
+ return;
100
+ }
101
+ if (key === CTRL_Q) {
102
+ cleanup();
103
+ process.stdout.write(chalk.dim('(cancelled)\n'));
104
+ resolve(null);
105
+ return;
106
+ }
72
107
  if (key === '\r' || key === '\n') {
73
- process.stdin.removeListener('data', onData);
74
- process.stdin.setRawMode(false);
75
- process.stdin.pause();
108
+ cleanup();
76
109
  process.stdout.write('\n');
77
110
  resolve(buf);
78
111
  } else if (key === '\x7f' || key === '\b') {
@@ -94,12 +127,27 @@ export function promptSecret(label) {
94
127
  return new Promise((resolve) => {
95
128
  process.stdout.write(label);
96
129
  let buf = '';
130
+ const cleanup = () => {
131
+ process.stdin.removeListener('data', onData);
132
+ process.stdin.setRawMode(false);
133
+ process.stdin.pause();
134
+ };
97
135
  const onData = (data) => {
98
136
  const key = data.toString();
137
+ if (key === CTRL_C) {
138
+ cleanup();
139
+ process.stdout.write(chalk.dim('(cancelled)\n'));
140
+ resolve(PROMPT_INTERRUPT);
141
+ return;
142
+ }
143
+ if (key === CTRL_Q) {
144
+ cleanup();
145
+ process.stdout.write(chalk.dim('(cancelled)\n'));
146
+ resolve(null);
147
+ return;
148
+ }
99
149
  if (key === '\r' || key === '\n') {
100
- process.stdin.removeListener('data', onData);
101
- process.stdin.setRawMode(false);
102
- process.stdin.pause();
150
+ cleanup();
103
151
  process.stdout.write('\n');
104
152
  resolve(buf);
105
153
  } else if (key === '\x7f' || key === '\b') {
package/src/ui/spinner.js CHANGED
@@ -2,6 +2,33 @@ import chalk from 'chalk';
2
2
  import gradient from 'gradient-string';
3
3
 
4
4
  const agentGradient = gradient(['#22c55e', '#16a34a', '#4ade80']);
5
+ export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
6
+ const SPINNER_INTERVAL_MS = 200;
7
+ const IDLE_SPINNER_DELAY_MS = 250;
8
+ const SPINNER_LABELS = [
9
+ 'Squirming',
10
+ 'Shadoodeling',
11
+ 'Braincrunching',
12
+ 'Brewing',
13
+ 'Hacking',
14
+ 'Debugging',
15
+ 'Refactoring',
16
+ 'Tinkering',
17
+ 'Sweating',
18
+ 'Brainstorming',
19
+ 'Spellcasting',
20
+ ];
21
+
22
+ function pickSpinnerLabel() {
23
+ const randomLabel = SPINNER_LABELS[Math.floor(Math.random() * SPINNER_LABELS.length)];
24
+ return `${randomLabel} `;
25
+ }
26
+
27
+ function renderFrame(label, startTime, frameIdx, opts = {}) {
28
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
29
+ const labelText = opts.gradientLabel ? agentGradient(label) : chalk.dim(label);
30
+ process.stdout.write('\r' + agentGradient(SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length]) + ' ' + labelText + chalk.dim(elapsed + 's ') + ' ');
31
+ }
5
32
 
6
33
  /**
7
34
  * Create a spinner with a given label.
@@ -10,20 +37,22 @@ const agentGradient = gradient(['#22c55e', '#16a34a', '#4ade80']);
10
37
  * @returns {{ stop: () => void }} A spinner handle with a stop() method
11
38
  */
12
39
  export function createSpinner(label) {
13
- const DOTS = ['.', '..', '...'];
40
+ const resolvedLabel = pickSpinnerLabel();
14
41
  let dotIdx = 0;
15
42
  let interval = null;
16
43
  const startTime = Date.now();
44
+ const renderOpts = { gradientLabel: true };
17
45
 
18
46
  const start = () => {
19
47
  if (interval) clearInterval(interval);
20
48
  dotIdx = 0;
21
- process.stdout.write(chalk.dim(`\n${label}`));
49
+ process.stdout.write('\n\n');
50
+ renderFrame(resolvedLabel, startTime, dotIdx, renderOpts);
51
+ dotIdx++;
22
52
  interval = setInterval(() => {
23
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
24
- process.stdout.write('\r' + chalk.dim(label) + chalk.dim(elapsed + 's ') + agentGradient(DOTS[dotIdx % DOTS.length]) + ' ');
53
+ renderFrame(resolvedLabel, startTime, dotIdx, renderOpts);
25
54
  dotIdx++;
26
- }, 400);
55
+ }, SPINNER_INTERVAL_MS);
27
56
  };
28
57
 
29
58
  const stop = () => {
@@ -38,3 +67,60 @@ export function createSpinner(label) {
38
67
 
39
68
  return { stop };
40
69
  }
70
+
71
+ /**
72
+ * Create a spinner that only appears after a quiet period.
73
+ * Call bump() whenever output is streamed to hide/snooze it.
74
+ * @param {string} label - The label to display before the spinner
75
+ * @param {{ startTime?: number, delayMs?: number }} [opts]
76
+ * @returns {{ bump: () => void, pause: () => void, stop: () => void }}
77
+ */
78
+ export function createIdleSpinner(label, opts = {}) {
79
+ const resolvedLabel = pickSpinnerLabel();
80
+ const startTime = opts.startTime ?? Date.now();
81
+ const delayMs = opts.delayMs ?? IDLE_SPINNER_DELAY_MS;
82
+ let dotIdx = 0;
83
+ let interval = null;
84
+ let timeout = null;
85
+ const renderOpts = { gradientLabel: opts.gradientLabel ?? true };
86
+
87
+ const clearTimeoutIfNeeded = () => {
88
+ if (timeout) {
89
+ clearTimeout(timeout);
90
+ timeout = null;
91
+ }
92
+ };
93
+
94
+ const hide = () => {
95
+ clearTimeoutIfNeeded();
96
+ if (interval) {
97
+ clearInterval(interval);
98
+ interval = null;
99
+ process.stdout.write('\r\x1b[0J');
100
+ }
101
+ };
102
+
103
+ const start = () => {
104
+ clearTimeoutIfNeeded();
105
+ if (interval) return;
106
+ dotIdx = 0;
107
+ process.stdout.write('\n\n');
108
+ renderFrame(resolvedLabel, startTime, dotIdx, renderOpts);
109
+ dotIdx++;
110
+ interval = setInterval(() => {
111
+ renderFrame(resolvedLabel, startTime, dotIdx, renderOpts);
112
+ dotIdx++;
113
+ }, SPINNER_INTERVAL_MS);
114
+ };
115
+
116
+ const bump = () => {
117
+ hide();
118
+ timeout = setTimeout(start, delayMs);
119
+ };
120
+
121
+ return {
122
+ bump,
123
+ pause: hide,
124
+ stop: hide,
125
+ };
126
+ }