sapper-iq 1.1.28 → 1.1.30

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/sapper.mjs CHANGED
@@ -316,6 +316,160 @@ function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
316
316
  return { files, totalSize };
317
317
  }
318
318
 
319
+ // Scan directory for files (for @ file picker)
320
+ function getFilesForPicker(dir = '.', prefix = '', maxFiles = 50) {
321
+ let files = [];
322
+ try {
323
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
324
+ for (const entry of entries) {
325
+ if (files.length >= maxFiles) break;
326
+ if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
327
+
328
+ const fullPath = prefix ? `${prefix}/${entry.name}` : entry.name;
329
+
330
+ if (entry.isDirectory()) {
331
+ files.push({ path: fullPath + '/', isDir: true });
332
+ // Recurse one level for common structures
333
+ const subFiles = getFilesForPicker(`${dir}/${entry.name}`, fullPath, 20);
334
+ files = files.concat(subFiles.slice(0, 15)); // Limit subdirectory files
335
+ } else {
336
+ const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
337
+ if (CODE_EXTENSIONS.has(ext.toLowerCase()) || CODE_EXTENSIONS.has(entry.name)) {
338
+ try {
339
+ const stats = fs.statSync(`${dir}/${entry.name}`);
340
+ files.push({ path: fullPath, isDir: false, size: stats.size });
341
+ } catch (e) {
342
+ files.push({ path: fullPath, isDir: false, size: 0 });
343
+ }
344
+ }
345
+ }
346
+ }
347
+ } catch (e) {}
348
+ return files.slice(0, maxFiles);
349
+ }
350
+
351
+ // Interactive file picker with arrow keys
352
+ async function pickFiles() {
353
+ const files = getFilesForPicker('.', '', 50).filter(f => !f.isDir);
354
+
355
+ if (files.length === 0) {
356
+ console.log(chalk.yellow('No code files found in current directory.'));
357
+ return [];
358
+ }
359
+
360
+ const selected = new Set();
361
+ let cursor = 0;
362
+ const pageSize = Math.min(15, process.stdout.rows - 10 || 15);
363
+
364
+ // Enable raw mode for key capture
365
+ if (process.stdin.isTTY) {
366
+ process.stdin.setRawMode(true);
367
+ }
368
+ process.stdin.resume();
369
+
370
+ const renderList = () => {
371
+ // Clear screen and move cursor to top
372
+ console.clear();
373
+ console.log(box(
374
+ `${chalk.cyan('↑↓')} Navigate ${chalk.cyan('Space')} Toggle ${chalk.cyan('a')} All ${chalk.cyan('Enter')} Confirm ${chalk.cyan('q/Esc')} Cancel`,
375
+ '📎 Select Files', 'cyan'
376
+ ));
377
+ console.log();
378
+
379
+ // Calculate visible range (pagination)
380
+ const startIdx = Math.max(0, Math.min(cursor - Math.floor(pageSize / 2), files.length - pageSize));
381
+ const endIdx = Math.min(startIdx + pageSize, files.length);
382
+
383
+ // Show scroll indicator if needed
384
+ if (startIdx > 0) {
385
+ console.log(chalk.gray(' ↑ more files above...'));
386
+ }
387
+
388
+ for (let i = startIdx; i < endIdx; i++) {
389
+ const file = files[i];
390
+ const isSelected = selected.has(i);
391
+ const isCursor = i === cursor;
392
+
393
+ const checkbox = isSelected ? chalk.green('◉') : chalk.gray('○');
394
+ const prefix = isCursor ? chalk.cyan('▸ ') : ' ';
395
+ const name = isCursor ? chalk.cyan.bold(file.path) : chalk.white(file.path);
396
+ const size = file.size ? chalk.gray(` (${Math.round(file.size/1024)}KB)`) : '';
397
+
398
+ console.log(`${prefix}${checkbox} ${name}${size}`);
399
+ }
400
+
401
+ if (endIdx < files.length) {
402
+ console.log(chalk.gray(' ↓ more files below...'));
403
+ }
404
+
405
+ console.log();
406
+ console.log(chalk.gray(` Selected: ${selected.size} file${selected.size !== 1 ? 's' : ''}`));
407
+ };
408
+
409
+ return new Promise((resolve) => {
410
+ renderList();
411
+
412
+ const onKeypress = (chunk, key) => {
413
+ if (!key) {
414
+ // Handle raw chunk for arrow keys
415
+ const str = chunk.toString();
416
+ if (str === '\x1b[A') key = { name: 'up' };
417
+ else if (str === '\x1b[B') key = { name: 'down' };
418
+ else if (str === '\x1b[C') key = { name: 'right' };
419
+ else if (str === '\x1b[D') key = { name: 'left' };
420
+ else if (str === ' ') key = { name: 'space' };
421
+ else if (str === '\r' || str === '\n') key = { name: 'return' };
422
+ else if (str === '\x1b' || str === 'q') key = { name: 'escape' };
423
+ else if (str === 'a' || str === 'A') key = { name: 'a' };
424
+ else if (str === '\x03') key = { name: 'c', ctrl: true }; // Ctrl+C
425
+ }
426
+
427
+ if (!key) return;
428
+
429
+ if (key.name === 'up' || key.name === 'k') {
430
+ cursor = cursor > 0 ? cursor - 1 : files.length - 1;
431
+ renderList();
432
+ } else if (key.name === 'down' || key.name === 'j') {
433
+ cursor = cursor < files.length - 1 ? cursor + 1 : 0;
434
+ renderList();
435
+ } else if (key.name === 'space' || key.name === 'right') {
436
+ if (selected.has(cursor)) {
437
+ selected.delete(cursor);
438
+ } else {
439
+ selected.add(cursor);
440
+ }
441
+ renderList();
442
+ } else if (key.name === 'a') {
443
+ // Toggle all
444
+ if (selected.size === files.length) {
445
+ selected.clear();
446
+ } else {
447
+ for (let i = 0; i < files.length; i++) selected.add(i);
448
+ }
449
+ renderList();
450
+ } else if (key.name === 'return') {
451
+ cleanup();
452
+ const selectedFiles = Array.from(selected).map(i => files[i].path);
453
+ console.log(chalk.green(`\n✓ Selected ${selectedFiles.length} file${selectedFiles.length !== 1 ? 's' : ''}`));
454
+ resolve(selectedFiles);
455
+ } else if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
456
+ cleanup();
457
+ console.log(chalk.gray('\nCancelled.'));
458
+ resolve([]);
459
+ }
460
+ };
461
+
462
+ const cleanup = () => {
463
+ process.stdin.removeListener('data', onKeypress);
464
+ if (process.stdin.isTTY) {
465
+ process.stdin.setRawMode(false);
466
+ }
467
+ };
468
+
469
+ process.stdin.on('data', onKeypress);
470
+ });
471
+ }
472
+
319
473
  // Format scan results for AI context
320
474
  function formatScanResults(scanResult) {
321
475
  let output = `\n══════════════════════════════════════\n`;
@@ -681,7 +835,8 @@ TOOL SYNTAX:
681
835
  if (input.toLowerCase() === '/help') {
682
836
  console.log();
683
837
  const helpContent =
684
- `${chalk.cyan('@file')} ${chalk.gray('│')} Attach file to prompt (e.g., @src/app.js)\n` +
838
+ `${chalk.cyan('@')} or ${chalk.cyan('/attach')} ${chalk.gray('│')} Pick files to attach (interactive)\n` +
839
+ `${chalk.cyan('@file')} ${chalk.gray('│')} Attach file inline (e.g., @src/app.js)\n` +
685
840
  `${chalk.cyan('/scan')} ${chalk.gray('│')} Scan codebase into context\n` +
686
841
  `${chalk.cyan('/recall')} ${chalk.gray('│')} Search memory for relevant context\n` +
687
842
  `${chalk.cyan('/reset /clear')} ${chalk.gray('│')} Clear all context\n` +
@@ -790,7 +945,56 @@ TOOL SYNTAX:
790
945
  continue;
791
946
  }
792
947
 
793
- // Process @file attachments in prompt (e.g., "analyze @package.json" or "fix @src/index.js")
948
+ // Handle @ alone or /attach command - interactive file picker
949
+ if (input.trim() === '@' || input.toLowerCase() === '/attach') {
950
+ const selectedFiles = await pickFiles();
951
+
952
+ if (selectedFiles.length === 0) continue;
953
+
954
+ // Read and attach selected files
955
+ const fileAttachments = [];
956
+ for (const filePath of selectedFiles) {
957
+ try {
958
+ const stats = fs.statSync(filePath);
959
+ if (stats.size > MAX_FILE_SIZE) {
960
+ console.log(chalk.yellow(`⚠️ ${filePath} is too large, skipping`));
961
+ continue;
962
+ }
963
+ const content = fs.readFileSync(filePath, 'utf8');
964
+ fileAttachments.push({ path: filePath, content, size: stats.size });
965
+ console.log(chalk.green(`📎 Attached: ${filePath}`));
966
+ } catch (e) {
967
+ console.log(chalk.yellow(`⚠️ Could not read ${filePath}`));
968
+ }
969
+ }
970
+
971
+ if (fileAttachments.length === 0) continue;
972
+
973
+ // Ask for the prompt to go with these files
974
+ console.log();
975
+ const prompt = await safeQuestion(chalk.cyan('Your prompt for these files: '));
976
+
977
+ if (!prompt.trim()) {
978
+ console.log(chalk.gray('Cancelled.'));
979
+ continue;
980
+ }
981
+
982
+ // Build message with attachments
983
+ let attachedContent = '\n\n══════════════════════════════════════\n';
984
+ attachedContent += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
985
+ attachedContent += '══════════════════════════════════════\n\n';
986
+
987
+ for (const file of fileAttachments) {
988
+ attachedContent += `┌─── ${file.path} ───\n`;
989
+ attachedContent += file.content;
990
+ if (!file.content.endsWith('\n')) attachedContent += '\n';
991
+ attachedContent += `└─── END ${file.path} ───\n\n`;
992
+ }
993
+
994
+ messages.push({ role: 'user', content: prompt + attachedContent });
995
+ // Continue to AI response (don't use 'continue' here)
996
+ } else {
997
+ // Process @file attachments in prompt (e.g., "analyze @package.json" or "fix @src/index.js")
794
998
  let processedInput = input;
795
999
  const fileAttachments = [];
796
1000
  const attachRegex = /@([\w.\/\-_]+)/g;
@@ -835,6 +1039,7 @@ TOOL SYNTAX:
835
1039
  }
836
1040
 
837
1041
  messages.push({ role: 'user', content: processedInput });
1042
+ } // End of else block for non-@ input
838
1043
 
839
1044
  let toolRounds = 0; // Prevent infinite loops
840
1045
  const MAX_TOOL_ROUNDS = 20;