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 +2 -0
- package/package.json +1 -1
- package/src/editor/codeBlockEdits.js +1 -1
- package/src/input.js +39 -13
- package/src/interactive.js +229 -172
- package/src/tools.js +30 -1
- package/src/ui/logo.js +9 -17
- package/src/ui/prompts.js +57 -9
- package/src/ui/spinner.js +91 -5
package/.env.example
CHANGED
package/package.json
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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
|
|
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 →
|
|
140
|
-
if (key === '\x03') {
|
|
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') {
|
|
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
|
}
|
package/src/interactive.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
121
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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) =>
|
|
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
|
|
381
|
-
|
|
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: () => {
|
|
404
|
-
onBeforeToolRun: () => {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
627
|
+
return (await askConfirm(chalk.bold('Apply this change? [y/N] '))) === true;
|
|
558
628
|
};
|
|
559
629
|
const startTime = Date.now();
|
|
560
|
-
const
|
|
561
|
-
|
|
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: () => {
|
|
584
|
-
onBeforeToolRun: () => {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
683
|
-
const
|
|
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
|
|
731
|
-
|
|
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: () => {
|
|
754
|
-
onBeforeToolRun: () => {
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
879
|
+
return (await askConfirm(chalk.bold('Apply this change? [y/N] '))) === true;
|
|
809
880
|
};
|
|
810
881
|
const startTime = Date.now();
|
|
811
|
-
const
|
|
812
|
-
|
|
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
|
-
|
|
892
|
+
idleSpinner.bump();
|
|
838
893
|
},
|
|
839
894
|
onBeforeToolRun: () => {
|
|
840
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(['#
|
|
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(
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
49
|
+
process.stdout.write('\n\n');
|
|
50
|
+
renderFrame(resolvedLabel, startTime, dotIdx, renderOpts);
|
|
51
|
+
dotIdx++;
|
|
22
52
|
interval = setInterval(() => {
|
|
23
|
-
|
|
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
|
-
},
|
|
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
|
+
}
|