scorpion-cli 0.1.0

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/src/ui/repl.js ADDED
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Interactive REPL - Claude Code / Gemini CLI Style
3
+ * Clean, natural language interface
4
+ */
5
+
6
+ import readline from 'readline';
7
+ import { runAgent } from '../agent.js';
8
+ import { isModelAvailable, DEFAULT_MODEL } from '../ollama.js';
9
+ import { setProgressCallback } from '../tools/deep-research.js';
10
+ import { handleStats, handleListReports, handleDemo, handleExport, handleListModels, handleModelSelect } from '../commands.js';
11
+ import {
12
+ displayBanner,
13
+ showToolExecution,
14
+ showThinking,
15
+ showResponse,
16
+ showError,
17
+ showInfo,
18
+ showSuccess,
19
+ showSimmering,
20
+ showDivider,
21
+ showWelcomeCode,
22
+ getPrompt,
23
+ clearLine,
24
+ colors,
25
+ chalk
26
+ } from './formatter.js';
27
+
28
+ /**
29
+ * Start the interactive REPL
30
+ * @param {Object} options - REPL options
31
+ */
32
+ export async function startREPL(options = {}) {
33
+ const {
34
+ model = DEFAULT_MODEL,
35
+ showThinkingOutput = false, // Hidden by default like Claude Code
36
+ } = options;
37
+
38
+ const settings = { model, showThinkingOutput };
39
+
40
+ // Display welcome banner
41
+ displayBanner();
42
+
43
+ // Check Ollama connection
44
+ try {
45
+ const available = await isModelAvailable(settings.model);
46
+ if (!available) {
47
+ showError(`Model '${settings.model}' not found. Run: ollama pull ${settings.model}`);
48
+ } else {
49
+ showSuccess(`Connected to ${settings.model}`);
50
+ }
51
+ } catch (error) {
52
+ showError('Could not connect to Ollama. Make sure it is running.');
53
+ showInfo('Start with: ollama serve');
54
+ }
55
+
56
+ // Show the welcome code block like Claude Code
57
+ showWelcomeCode();
58
+
59
+ showSimmering();
60
+
61
+ // Create readline interface
62
+ const rl = readline.createInterface({
63
+ input: process.stdin,
64
+ output: process.stdout,
65
+ });
66
+
67
+ // Conversation history
68
+ let history = [];
69
+ let isClosing = false;
70
+
71
+ // Main loop
72
+ const prompt = () => {
73
+ if (isClosing || rl.closed) return;
74
+ rl.question(getPrompt(), async (input) => {
75
+ if (isClosing || rl.closed) return;
76
+ const trimmedInput = input.trim();
77
+
78
+ // Handle empty input
79
+ if (!trimmedInput) {
80
+ prompt();
81
+ return;
82
+ }
83
+
84
+ // Handle exit
85
+ if (trimmedInput.toLowerCase() === 'exit' || trimmedInput.toLowerCase() === 'quit') {
86
+ isClosing = true;
87
+ console.log();
88
+ showInfo('Goodbye! 👋');
89
+ console.log();
90
+ rl.close();
91
+ process.exit(0);
92
+ }
93
+
94
+ // Handle slash commands and retain the older bare command aliases.
95
+ const command = parseCommand(trimmedInput);
96
+ if (command) {
97
+ const shouldContinue = await handleCommand(command, settings, () => {
98
+ history = [];
99
+ }, (question) => new Promise((resolve) => rl.question(question, resolve)));
100
+ if (shouldContinue && !isClosing && !rl.closed) {
101
+ prompt();
102
+ return;
103
+ }
104
+ if (isClosing || rl.closed) return;
105
+ }
106
+
107
+ // Show help
108
+ if (trimmedInput.toLowerCase() === 'help' || trimmedInput === '?') {
109
+ showHelp();
110
+ prompt();
111
+ return;
112
+ }
113
+
114
+ // Handle clear
115
+ if (trimmedInput.toLowerCase() === 'clear') {
116
+ history = [];
117
+ displayBanner();
118
+ showSuccess('Conversation cleared');
119
+ showWelcomeCode();
120
+ prompt();
121
+ return;
122
+ }
123
+
124
+ // Handle stats command
125
+ if (trimmedInput.toLowerCase() === 'stats') {
126
+ await handleStats();
127
+ prompt();
128
+ return;
129
+ }
130
+
131
+ // Handle reports command
132
+ if (trimmedInput.toLowerCase() === 'reports') {
133
+ await handleListReports();
134
+ prompt();
135
+ return;
136
+ }
137
+
138
+ // Handle export command
139
+ if (trimmedInput.toLowerCase().startsWith('export ')) {
140
+ const format = trimmedInput.split(' ')[1] || 'md';
141
+ await handleExport(format);
142
+ prompt();
143
+ return;
144
+ }
145
+
146
+ // Handle demo command
147
+ if (trimmedInput.toLowerCase() === 'demo') {
148
+ await handleDemo();
149
+ prompt();
150
+ return;
151
+ }
152
+
153
+ // Process with agent
154
+ await processInput(trimmedInput, history, settings);
155
+
156
+ prompt();
157
+ });
158
+ };
159
+
160
+ prompt();
161
+ }
162
+
163
+ function parseCommand(input) {
164
+ if (!input.startsWith('/')) return null;
165
+ const [name, ...args] = input.slice(1).trim().split(/\s+/);
166
+ return name ? { name: name.toLowerCase(), args } : { name: 'help', args: [] };
167
+ }
168
+
169
+ async function handleCommand(command, settings, clearHistory, ask) {
170
+ const arg = command.args[0]?.toLowerCase();
171
+
172
+ switch (command.name) {
173
+ case 'help':
174
+ case '?':
175
+ showHelp();
176
+ return true;
177
+ case 'list':
178
+ case 'models':
179
+ await handleListModels(settings.model);
180
+ return true;
181
+ case 'model': {
182
+ const selected = await handleModelSelect(settings.model, ask, command.args.join(' '));
183
+ if (selected) {
184
+ settings.model = selected;
185
+ showSuccess(`Model changed to ${settings.model}`);
186
+ }
187
+ return true;
188
+ }
189
+ case 'settings':
190
+ showSettings(settings);
191
+ return true;
192
+ case 'think':
193
+ if (!['on', 'off', 'toggle'].includes(arg)) {
194
+ showInfo('Usage: /think on | /think off | /think toggle');
195
+ } else {
196
+ settings.showThinkingOutput = arg === 'toggle' ? !settings.showThinkingOutput : arg === 'on';
197
+ showSuccess(`Thinking output ${settings.showThinkingOutput ? 'enabled' : 'disabled'}`);
198
+ }
199
+ return true;
200
+ case 'clear':
201
+ clearHistory();
202
+ displayBanner();
203
+ showSuccess('Conversation cleared');
204
+ showWelcomeCode();
205
+ return true;
206
+ case 'stats':
207
+ await handleStats();
208
+ return true;
209
+ case 'reports':
210
+ await handleListReports();
211
+ return true;
212
+ case 'demo':
213
+ await handleDemo();
214
+ return true;
215
+ case 'export':
216
+ await handleExport(command.args[0] || 'md');
217
+ return true;
218
+ case 'exit':
219
+ case 'quit':
220
+ showInfo('Goodbye! 👋');
221
+ process.exit(0);
222
+ default:
223
+ showError(`Unknown command '/${command.name}'. Type /help for available commands.`);
224
+ return true;
225
+ }
226
+ }
227
+
228
+ function showSettings(settings) {
229
+ console.log();
230
+ console.log(colors.bold.white(' Current Settings'));
231
+ console.log(colors.dim(' ' + '─'.repeat(30)));
232
+ console.log(` Model: ${colors.accent(settings.model)}`);
233
+ console.log(` Thinking output: ${colors.accent(settings.showThinkingOutput ? 'on' : 'off')}`);
234
+ console.log(` Ollama host: ${colors.dim(process.env.OLLAMA_HOST || 'http://localhost:11434')}`);
235
+ console.log();
236
+ }
237
+
238
+ /**
239
+ * Show help for @ triggers
240
+ */
241
+ function showHelp() {
242
+ console.log();
243
+ console.log(colors.bold.white(' 🎯 Available Commands'));
244
+ console.log(colors.dim(' ─'.repeat(50)));
245
+ console.log();
246
+
247
+ console.log(colors.accent(' @ Triggers:'));
248
+ console.log(colors.dim(' @deepresearch, @deep') + colors.white(' <query>'));
249
+ console.log(colors.dim(' • Comprehensive multi-source research'));
250
+ console.log(colors.dim(' • Searches arXiv, Hacker News, Wikipedia'));
251
+ console.log(colors.dim(' • Generates structured report with citations'));
252
+ console.log(colors.dim(' • Shows live progress updates'));
253
+ console.log();
254
+ console.log(colors.dim(' @quick, @fast') + colors.white(' <question>'));
255
+ console.log(colors.dim(' • Fast response without deep research'));
256
+ console.log();
257
+
258
+ console.log(colors.accent(' Utility Commands:'));
259
+ console.log(colors.dim(' /help, /?') + colors.white(' - Show this help message'));
260
+ console.log(colors.dim(' /list ') + colors.white(' - List installed Ollama models'));
261
+ console.log(colors.dim(' /model ') + colors.white(' [name] - Select or switch model'));
262
+ console.log(colors.dim(' /settings') + colors.white(' - Show current settings'));
263
+ console.log(colors.dim(' /think ') + colors.white(' on|off|toggle - Toggle thinking output'));
264
+ console.log(colors.dim(' /demo ') + colors.white(' - See all UI features (tables, charts)'));
265
+ console.log(colors.dim(' /stats ') + colors.white(' - Show session statistics'));
266
+ console.log(colors.dim(' /reports ') + colors.white(' - List saved research reports'));
267
+ console.log(colors.dim(' /export ') + colors.white(' [md|json] - Export last report'));
268
+ console.log(colors.dim(' /clear ') + colors.white(' - Clear conversation history'));
269
+ console.log(colors.dim(' /exit ') + colors.white(' - Quit Scorpion'));
270
+ console.log();
271
+
272
+ console.log(colors.accent(' Auto-Detection:'));
273
+ console.log(colors.dim(' Queries containing "research", "analyze", "explain in detail"'));
274
+ console.log(colors.dim(' automatically trigger deep research mode.'));
275
+ console.log();
276
+
277
+ console.log(colors.accent(' Examples:'));
278
+ console.log(colors.cyan(' @deep transformer architecture in ML'));
279
+ console.log(colors.cyan(' research quantum computing applications'));
280
+ console.log(colors.cyan(' demo'));
281
+ console.log(colors.cyan(' stats'));
282
+ console.log();
283
+ }
284
+
285
+ /**
286
+ * Parse @ triggers and mode from input
287
+ * @param {string} input - Raw user input
288
+ * @returns {Object} - { mode, cleanInput }
289
+ */
290
+ function parseMode(input) {
291
+ const triggerMatch = input.match(/^@(\w+)\s+(.+)$/);
292
+
293
+ if (triggerMatch) {
294
+ const [, trigger, cleanInput] = triggerMatch;
295
+
296
+ if (trigger === 'deepresearch' || trigger === 'deep') {
297
+ return { mode: 'deep_research', cleanInput, explicit: true };
298
+ }
299
+ if (trigger === 'quick' || trigger === 'fast') {
300
+ return { mode: 'quick_response', cleanInput, explicit: true };
301
+ }
302
+ }
303
+
304
+ // Auto-detect deep research intent
305
+ const deepKeywords = ['research', 'analyze', 'explain in detail', 'comprehensive', 'deep dive', 'study'];
306
+ const inputLower = input.toLowerCase();
307
+
308
+ for (const keyword of deepKeywords) {
309
+ if (inputLower.includes(keyword)) {
310
+ return { mode: 'deep_research', cleanInput: input, explicit: false };
311
+ }
312
+ }
313
+
314
+ return { mode: 'normal', cleanInput: input, explicit: false };
315
+ }
316
+
317
+ /**
318
+ * Process user input through the agent
319
+ * @param {string} input - User input
320
+ * @param {Array} history - Conversation history
321
+ * @param {Object} options - Processing options
322
+ */
323
+ async function processInput(input, history, options = {}) {
324
+ const { model, showThinkingOutput = false } = options;
325
+
326
+ // Parse mode
327
+ const { mode, cleanInput, explicit } = parseMode(input);
328
+
329
+ // If explicit @ trigger, show what mode we're in
330
+ if (explicit) {
331
+ console.log();
332
+ if (mode === 'deep_research') {
333
+ console.log(colors.accent(' 🔬 ') + colors.bold.white('Deep Research Mode'));
334
+ } else if (mode === 'quick_response') {
335
+ console.log(colors.accent(' ⚡ ') + colors.bold.white('Quick Response Mode'));
336
+ }
337
+ } else if (mode === 'deep_research') {
338
+ // Auto-detected deep research
339
+ console.log();
340
+ console.log(colors.dim(' 🔍 Auto-detected: ') + colors.accent('Deep Research Mode'));
341
+ }
342
+
343
+ // Start timer
344
+ const startTime = Date.now();
345
+
346
+ // Show initial thinking state
347
+ console.log();
348
+ process.stdout.write(colors.accent(' ✦ ') + colors.dim('Thinking...'));
349
+
350
+ let hasStartedOutput = false;
351
+ let toolCallCount = 0;
352
+ let thinkingShown = false;
353
+ let isDeepResearch = false;
354
+ let contentBuffer = ''; // Buffer for Markdown rendering
355
+
356
+ // Setup progress callback for deep research
357
+ setProgressCallback((event, data) => {
358
+ if (event === 'start') {
359
+ console.log();
360
+ console.log(colors.accent(' 🔬 ') + colors.bold.white('Deep Research In Progress'));
361
+ console.log(colors.dim(' ─'.repeat(30)));
362
+ } else if (event === 'subquery') {
363
+ console.log(colors.dim(' ├─ ') + colors.info(`Exploring [${data.type}]: `) + colors.white(`"${data.query.slice(0, 50)}..."`));
364
+ } else if (event === 'searching') {
365
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
366
+ console.log(colors.dim(' │ ') + colors.cyan(`Searching ${data.source}...`) + colors.dim(` (${elapsed}s)`));
367
+ } else if (event === 'found') {
368
+ if (data.count > 0) {
369
+ console.log(colors.dim(' │ ') + colors.success(`✓ Found ${data.count} results from ${data.source}`));
370
+ } else {
371
+ console.log(colors.dim(' │ ') + colors.muted(`⊗ No results from ${data.source}`));
372
+ }
373
+ } else if (event === 'generating') {
374
+ console.log(colors.dim(' ├─ ') + colors.accent('Generating report...'));
375
+ } else if (event === 'complete') {
376
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
377
+ console.log(colors.dim(' └─ ') + colors.success('Research Complete!'));
378
+ console.log();
379
+ console.log(colors.dim(' 📊 Stats:'));
380
+ console.log(colors.dim(' • ') + `Sources: ${chalk.cyan(data.sources)}`);
381
+ console.log(colors.dim(' • ') + `Confidence: ${chalk.cyan(data.confidence + '%')}`);
382
+ console.log(colors.dim(' • ') + `Time: ${chalk.cyan(elapsed + 's')}`);
383
+ console.log();
384
+ }
385
+ });
386
+
387
+ try {
388
+ const result = await runAgent(cleanInput, {
389
+ history,
390
+ model,
391
+
392
+ onThinking: (text) => {
393
+ if (!hasStartedOutput && !isDeepResearch) {
394
+ clearLine();
395
+ hasStartedOutput = true;
396
+ }
397
+
398
+ if (showThinkingOutput && !thinkingShown) {
399
+ showThinking(text);
400
+ thinkingShown = true;
401
+ }
402
+ },
403
+
404
+ onContent: (text) => {
405
+ if (!hasStartedOutput) {
406
+ clearLine();
407
+ console.log(); // Clear the "Thinking..." line
408
+ hasStartedOutput = true;
409
+ }
410
+
411
+ // Buffer content for Markdown rendering
412
+ contentBuffer += text;
413
+ },
414
+
415
+ onToolCall: ({ name, args }) => {
416
+ toolCallCount++;
417
+
418
+ // Check if this is deep research
419
+ if (name === 'deep_research') {
420
+ isDeepResearch = true;
421
+ clearLine();
422
+ hasStartedOutput = true;
423
+ return; // Progress callback will handle display
424
+ }
425
+
426
+ if (!hasStartedOutput) {
427
+ clearLine();
428
+ hasStartedOutput = true;
429
+ }
430
+
431
+ // Show human-readable action for other tools
432
+ showToolExecution(name, args);
433
+ },
434
+
435
+ onToolResult: ({ name, result }) => {
436
+ // Parse and show brief result if relevant
437
+ try {
438
+ const parsed = JSON.parse(result);
439
+ if (parsed.success === false && parsed.error) {
440
+ console.log(colors.error(` ✗ ${parsed.error}`));
441
+ }
442
+ } catch {
443
+ // Ignore parse errors
444
+ }
445
+ },
446
+ });
447
+
448
+ // Render buffered content with Markdown formatting
449
+ if (contentBuffer) {
450
+ showResponse(contentBuffer);
451
+ }
452
+
453
+ // Show elapsed time in a nice format
454
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
455
+ console.log();
456
+ console.log(colors.dim(' ─'.repeat(30)));
457
+ console.log(colors.accent(' ⏱ ') + colors.white(`${elapsed}s`));
458
+ console.log();
459
+
460
+ // Update history
461
+ history.push({ role: 'user', content: cleanInput });
462
+ if (contentBuffer) {
463
+ history.push({ role: 'assistant', content: contentBuffer });
464
+ }
465
+
466
+ } catch (error) {
467
+ clearLine();
468
+
469
+ if (error.message?.includes('ECONNREFUSED') || error.message?.includes('fetch failed')) {
470
+ showError('Could not connect to Ollama. Make sure it is running.');
471
+ } else {
472
+ showError(error.message);
473
+ }
474
+ }
475
+ }
476
+
477
+ export { processInput, parseCommand };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Spinner Utilities
3
+ * Beautiful loading animations for long operations
4
+ */
5
+
6
+ import ora from 'ora';
7
+ import { colors } from './formatter.js';
8
+
9
+ let currentSpinner = null;
10
+
11
+ /**
12
+ * Start a spinner
13
+ * @param {string} text - Loading text
14
+ * @param {string} spinnerType - Spinner type (dots, line, etc.)
15
+ */
16
+ export function startSpinner(text, spinnerType = 'dots') {
17
+ if (currentSpinner) {
18
+ currentSpinner.stop();
19
+ }
20
+
21
+ currentSpinner = ora({
22
+ text: colors.dim(text),
23
+ spinner: spinnerType,
24
+ color: 'cyan',
25
+ indent: 2
26
+ }).start();
27
+
28
+ return currentSpinner;
29
+ }
30
+
31
+ /**
32
+ * Update spinner text
33
+ * @param {string} text - New text
34
+ */
35
+ export function updateSpinner(text) {
36
+ if (currentSpinner) {
37
+ currentSpinner.text = colors.dim(text);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Stop spinner with success
43
+ * @param {string} text - Success message
44
+ */
45
+ export function succeedSpinner(text) {
46
+ if (currentSpinner) {
47
+ currentSpinner.succeed(colors.success(text));
48
+ currentSpinner = null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Stop spinner with failure
54
+ * @param {string} text - Failure message
55
+ */
56
+ export function failSpinner(text) {
57
+ if (currentSpinner) {
58
+ currentSpinner.fail(colors.error(text));
59
+ currentSpinner = null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Stop spinner with info
65
+ * @param {string} text - Info message
66
+ */
67
+ export function infoSpinner(text) {
68
+ if (currentSpinner) {
69
+ currentSpinner.info(colors.info(text));
70
+ currentSpinner = null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Stop spinner without message
76
+ */
77
+ export function stopSpinner() {
78
+ if (currentSpinner) {
79
+ currentSpinner.stop();
80
+ currentSpinner = null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Get current spinner instance
86
+ */
87
+ export function getCurrentSpinner() {
88
+ return currentSpinner;
89
+ }
90
+
91
+ export default {
92
+ startSpinner,
93
+ updateSpinner,
94
+ succeedSpinner,
95
+ failSpinner,
96
+ infoSpinner,
97
+ stopSpinner,
98
+ getCurrentSpinner
99
+ };