snow-ai 0.3.35 → 0.3.36

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.
@@ -141,46 +141,32 @@ PLACEHOLDER_FOR_WORKFLOW_SECTION
141
141
 
142
142
  ## Available Tools
143
143
 
144
- **Filesystem:**
145
- - \`filesystem-read\` - Read files before editing
146
- - \`filesystem-edit\` - Modify existing files
147
- - \`filesystem-create\` - Create new files
144
+ **Filesystem (SUPPORTS BATCH OPERATIONS):**
145
+ - Read first and then modify to avoid grammatical errors caused by boundary judgment errors**
146
+
147
+ **BATCH EDITING WORKFLOW - HIGH EFFICIENCY:**
148
+ When modifying multiple files (extremely common in real projects):
149
+ 1. Use filesystem-read with array of files to read them ALL at once
150
+ 2. Use filesystem-edit or filesystem-edit_search with array config to modify ALL at once
151
+ 3. This saves multiple round trips and dramatically improves efficiency
152
+
153
+ **BATCH EXAMPLES:**
154
+ - Read multiple: \`filesystem-read(filePath=["a.ts", "b.ts", "c.ts"])\`
155
+ - Edit multiple with same change: \`filesystem-edit_search(filePath=["a.ts", "b.ts"], searchContent="old", replaceContent="new")\`
156
+ - Edit multiple with different changes: \`filesystem-edit_search(filePath=[{path:"a.ts", searchContent:"old1", replaceContent:"new1"}, {path:"b.ts", searchContent:"old2", replaceContent:"new2"}])\`
157
+ - Per-file line ranges: \`filesystem-edit(filePath=[{path:"a.ts", startLine:10, endLine:20, newContent:"..."}, {path:"b.ts", startLine:50, endLine:60, newContent:"..."}])\`
158
+
159
+ **CRITICAL EFFICIENCY RULE:**
160
+ When you need to modify 2+ files, ALWAYS use batch operations instead of calling tools multiple times. This is faster, cleaner, and more reliable.
148
161
 
149
162
  **Code Search:**
150
163
  PLACEHOLDER_FOR_CODE_SEARCH_SECTION
151
164
 
152
165
  **IDE Diagnostics:**
153
- - \`ide-get_diagnostics\` - Get real-time diagnostics (errors, warnings, hints) from connected IDE
154
- - Supports VSCode and JetBrains IDEs
155
- - Returns diagnostic info: severity, line/column, message, source
156
- - Requires IDE plugin installed and running
157
- - Use AFTER code changes to verify quality
166
+ - After completing all tasks, it is recommended that you use this tool to check the error message in the IDE to avoid missing anything
158
167
 
159
168
  **Notebook (Code Memory):**
160
- - \`notebook-add\` - Record fragile code that new features might break during iteration
161
- - Core purpose: Prevent new functionality from breaking old functionality
162
- - Record: Bugs that recurred, fragile dependencies, critical constraints
163
- - Examples: "validateInput() must run first - broke twice", "null return required by X"
164
- - **IMPORTANT**: Use notebook for documentation, NOT separate .md files
165
- - \`notebook-query\` - Manual search (rarely needed, auto-shown when reading files)
166
- - Auto-attached: Last 10 notebooks appear when reading ANY file
167
- - Use before: Adding features that might affect existing behavior
168
- - \`notebook-update\` - Update existing note to fix mistakes or refine information
169
- - Fix errors in previously recorded notes
170
- - Clarify or improve wording after better understanding
171
- - Update note when code changes but constraint still applies
172
- - \`notebook-delete\` - Remove outdated or incorrect notes
173
- - Delete when code is refactored and note is obsolete
174
- - Remove notes recorded by mistake
175
- - Clean up after workarounds are properly fixed
176
- - \`notebook-list\` - View all notes for a specific file
177
- - List all constraints for a file before making changes
178
- - Find note IDs for update/delete operations
179
- - Review all warnings before refactoring
180
-
181
- **Web Search:**
182
- - \`websearch-search\` - Search web for latest docs/solutions
183
- - \`websearch-fetch\` - Read web page content (always provide userQuery)
169
+ - Instead of adding md instructions to your project too often, you should use this NoteBook tool for documentation
184
170
 
185
171
  **Terminal:**
186
172
  - \`terminal-execute\` - You have a comprehensive understanding of terminal pipe mechanisms and can help users
@@ -232,12 +218,7 @@ system administration and data processing challenges.
232
218
 
233
219
  **PRACTICAL EXAMPLES:**
234
220
 
235
- **BAD - Doing everything in main agent:**
236
- - User: "Add user authentication"
237
- - Main: *reads 20 files, analyzes auth patterns, plans implementation, writes code*
238
- - Result: Main context bloated with analysis that won't be reused
239
-
240
- **GOOD - Aggressive delegation:**
221
+ **Best - Aggressive delegation:**
241
222
  - User: "Add user authentication"
242
223
  - Main: Delegate to sub-agent → "Analyze current auth patterns and create implementation plan"
243
224
  - Sub-agent: *analyzes, returns concise plan*
@@ -334,15 +315,23 @@ function getWorkflowSection(hasCodebase) {
334
315
  }
335
316
  else {
336
317
  return `**Your workflow:**
337
- 1. Read the primary file(s) mentioned
318
+ 1. Read the primary file(s) mentioned - USE BATCH READ if multiple files
338
319
  2. Use \\\`ace-search-symbols\\\`, \\\`ace-find-definition\\\`, or \\\`ace-find-references\\\` to find related code
339
320
  3. Check dependencies/imports that directly impact the change
340
321
  4. Read related files ONLY if they're critical to understanding the task
341
- 5. Write/modify code with proper context
322
+ 5. Write/modify code with proper context - USE BATCH EDIT if modifying 2+ files
342
323
  6. Verify with build
343
324
  7. NO excessive exploration beyond what's needed
344
325
  8. NO reading entire modules "for reference"
345
- 9. NO over-planning multi-step workflows for simple tasks`;
326
+ 9. NO over-planning multi-step workflows for simple tasks
327
+
328
+ **Golden Rule: Read what you need to write correct code, nothing more.**
329
+
330
+ **BATCH OPERATIONS RULE:**
331
+ When dealing with 2+ files, ALWAYS prefer batch operations:
332
+ - Multiple reads? Use \\\`filesystem-read(filePath=["a.ts", "b.ts"])\\\` in ONE call
333
+ - Multiple edits? Use \\\`filesystem-edit_search(filePath=[{...}, {...}])\\\` in ONE call
334
+ - This is NOT optional for efficiency - batch operations are the EXPECTED workflow`;
346
335
  }
347
336
  }
348
337
  /**
@@ -10,6 +10,7 @@ export function useKeyboardInput(options) {
10
10
  const inputBuffer = useRef('');
11
11
  const inputTimer = useRef(null);
12
12
  const isPasting = useRef(false); // Track if we're in pasting mode
13
+ const inputStartCursorPos = useRef(0); // Track cursor position when input starts accumulating
13
14
  // Cleanup timer on unmount
14
15
  useEffect(() => {
15
16
  return () => {
@@ -374,6 +375,22 @@ export function useKeyboardInput(options) {
374
375
  }
375
376
  // Arrow keys for cursor movement
376
377
  if (key.leftArrow) {
378
+ // If there's accumulated input, process it immediately before moving cursor
379
+ if (inputBuffer.current) {
380
+ if (inputTimer.current) {
381
+ clearTimeout(inputTimer.current);
382
+ inputTimer.current = null;
383
+ }
384
+ const accumulated = inputBuffer.current;
385
+ const savedCursorPosition = inputStartCursorPos.current;
386
+ inputBuffer.current = '';
387
+ isPasting.current = false;
388
+ // Insert at saved position
389
+ buffer.setCursorPosition(savedCursorPosition);
390
+ buffer.insert(accumulated);
391
+ // Reset inputStartCursorPos after processing
392
+ inputStartCursorPos.current = buffer.getCursorPosition();
393
+ }
377
394
  buffer.moveLeft();
378
395
  const text = buffer.getFullText();
379
396
  const cursorPos = buffer.getCursorPosition();
@@ -382,6 +399,22 @@ export function useKeyboardInput(options) {
382
399
  return;
383
400
  }
384
401
  if (key.rightArrow) {
402
+ // If there's accumulated input, process it immediately before moving cursor
403
+ if (inputBuffer.current) {
404
+ if (inputTimer.current) {
405
+ clearTimeout(inputTimer.current);
406
+ inputTimer.current = null;
407
+ }
408
+ const accumulated = inputBuffer.current;
409
+ const savedCursorPosition = inputStartCursorPos.current;
410
+ inputBuffer.current = '';
411
+ isPasting.current = false;
412
+ // Insert at saved position
413
+ buffer.setCursorPosition(savedCursorPosition);
414
+ buffer.insert(accumulated);
415
+ // Reset inputStartCursorPos after processing
416
+ inputStartCursorPos.current = buffer.getCursorPosition();
417
+ }
385
418
  buffer.moveRight();
386
419
  const text = buffer.getFullText();
387
420
  const cursorPos = buffer.getCursorPosition();
@@ -390,6 +423,22 @@ export function useKeyboardInput(options) {
390
423
  return;
391
424
  }
392
425
  if (key.upArrow && !showCommands && !showFilePicker) {
426
+ // If there's accumulated input, process it immediately before moving cursor
427
+ if (inputBuffer.current) {
428
+ if (inputTimer.current) {
429
+ clearTimeout(inputTimer.current);
430
+ inputTimer.current = null;
431
+ }
432
+ const accumulated = inputBuffer.current;
433
+ const savedCursorPosition = inputStartCursorPos.current;
434
+ inputBuffer.current = '';
435
+ isPasting.current = false;
436
+ // Insert at saved position
437
+ buffer.setCursorPosition(savedCursorPosition);
438
+ buffer.insert(accumulated);
439
+ // Reset inputStartCursorPos after processing
440
+ inputStartCursorPos.current = buffer.getCursorPosition();
441
+ }
393
442
  const text = buffer.getFullText();
394
443
  const cursorPos = buffer.getCursorPosition();
395
444
  const isEmpty = text.trim() === '';
@@ -414,6 +463,22 @@ export function useKeyboardInput(options) {
414
463
  return;
415
464
  }
416
465
  if (key.downArrow && !showCommands && !showFilePicker) {
466
+ // If there's accumulated input, process it immediately before moving cursor
467
+ if (inputBuffer.current) {
468
+ if (inputTimer.current) {
469
+ clearTimeout(inputTimer.current);
470
+ inputTimer.current = null;
471
+ }
472
+ const accumulated = inputBuffer.current;
473
+ const savedCursorPosition = inputStartCursorPos.current;
474
+ inputBuffer.current = '';
475
+ isPasting.current = false;
476
+ // Insert at saved position
477
+ buffer.setCursorPosition(savedCursorPosition);
478
+ buffer.insert(accumulated);
479
+ // Reset inputStartCursorPos after processing
480
+ inputStartCursorPos.current = buffer.getCursorPosition();
481
+ }
417
482
  const text = buffer.getFullText();
418
483
  const cursorPos = buffer.getCursorPosition();
419
484
  const isEmpty = text.trim() === '';
@@ -447,44 +512,85 @@ export function useKeyboardInput(options) {
447
512
  // This is especially important for drag-and-drop operations where focus
448
513
  // events may arrive out of order or be filtered by sanitizeInput
449
514
  ensureFocus();
450
- // Accumulate input for paste detection
451
- inputBuffer.current += input;
452
- // Clear existing timer
453
- if (inputTimer.current) {
454
- clearTimeout(inputTimer.current);
455
- }
456
- // Detect large paste: if accumulated buffer is getting large, extend timeout
457
- // This prevents splitting large pastes into multiple insert() calls
458
- const currentLength = inputBuffer.current.length;
459
- const timeoutDelay = currentLength > 200 ? 150 : 10;
460
- // Show pasting indicator for large text (>300 chars)
461
- // Simple static message - no progress animation
462
- if (currentLength > 300 && !isPasting.current) {
463
- isPasting.current = true;
464
- buffer.insertPastingIndicator();
465
- // Trigger UI update to show the indicator
515
+ // Detect if this is a single character input (normal typing) or multi-character (paste)
516
+ const isSingleCharInput = input.length === 1;
517
+ if (isSingleCharInput) {
518
+ // For single character input (normal typing), insert immediately
519
+ // This prevents the "disappearing text" issue at line start
520
+ buffer.insert(input);
466
521
  const text = buffer.getFullText();
467
522
  const cursorPos = buffer.getCursorPosition();
468
523
  updateCommandPanelState(text);
469
524
  updateFilePickerState(text, cursorPos);
470
525
  triggerUpdate();
471
526
  }
472
- // Set timer to process accumulated input
473
- inputTimer.current = setTimeout(() => {
474
- const accumulated = inputBuffer.current;
475
- inputBuffer.current = '';
476
- isPasting.current = false; // Reset pasting state
477
- // If we accumulated input, insert it as a single operation
478
- // The insert() method will automatically remove the pasting indicator
479
- if (accumulated) {
480
- buffer.insert(accumulated);
527
+ else {
528
+ // For multi-character input (paste), use the buffering mechanism
529
+ // Save cursor position when starting new input accumulation
530
+ const isStartingNewInput = inputBuffer.current === '';
531
+ if (isStartingNewInput) {
532
+ inputStartCursorPos.current = buffer.getCursorPosition();
533
+ }
534
+ // Accumulate input for paste detection
535
+ inputBuffer.current += input;
536
+ // Clear existing timer
537
+ if (inputTimer.current) {
538
+ clearTimeout(inputTimer.current);
539
+ }
540
+ // Detect large paste: if accumulated buffer is getting large, extend timeout
541
+ // This prevents splitting large pastes into multiple insert() calls
542
+ const currentLength = inputBuffer.current.length;
543
+ const timeoutDelay = currentLength > 200 ? 150 : 10;
544
+ // Show pasting indicator for large text (>300 chars)
545
+ // Simple static message - no progress animation
546
+ if (currentLength > 300 && !isPasting.current) {
547
+ isPasting.current = true;
548
+ buffer.insertPastingIndicator();
549
+ // Trigger UI update to show the indicator
481
550
  const text = buffer.getFullText();
482
551
  const cursorPos = buffer.getCursorPosition();
483
552
  updateCommandPanelState(text);
484
553
  updateFilePickerState(text, cursorPos);
485
554
  triggerUpdate();
486
555
  }
487
- }, timeoutDelay); // Extended delay for large pastes to ensure complete accumulation
556
+ // Set timer to process accumulated input
557
+ inputTimer.current = setTimeout(() => {
558
+ const accumulated = inputBuffer.current;
559
+ const savedCursorPosition = inputStartCursorPos.current;
560
+ const wasPasting = isPasting.current; // Save pasting state before clearing
561
+ inputBuffer.current = '';
562
+ isPasting.current = false; // Reset pasting state
563
+ // If we accumulated input, insert it at the saved cursor position
564
+ // The insert() method will automatically remove the pasting indicator
565
+ if (accumulated) {
566
+ // Get current cursor position to calculate if user moved cursor during input
567
+ const currentCursor = buffer.getCursorPosition();
568
+ // If cursor hasn't moved from where we started (or only moved due to pasting indicator),
569
+ // insert at the saved position
570
+ // Otherwise, insert at current position (user deliberately moved cursor)
571
+ // Note: wasPasting check uses saved state, not current isPasting.current
572
+ if (currentCursor === savedCursorPosition ||
573
+ (wasPasting && currentCursor > savedCursorPosition)) {
574
+ // Temporarily set cursor to saved position for insertion
575
+ // This is safe because we're in a timeout, not during active cursor movement
576
+ buffer.setCursorPosition(savedCursorPosition);
577
+ buffer.insert(accumulated);
578
+ // No need to restore cursor - insert() moves it naturally
579
+ }
580
+ else {
581
+ // User moved cursor during input, insert at current position
582
+ buffer.insert(accumulated);
583
+ }
584
+ // Reset inputStartCursorPos after processing to prevent stale position
585
+ inputStartCursorPos.current = buffer.getCursorPosition();
586
+ const text = buffer.getFullText();
587
+ const cursorPos = buffer.getCursorPosition();
588
+ updateCommandPanelState(text);
589
+ updateFilePickerState(text, cursorPos);
590
+ triggerUpdate();
591
+ }
592
+ }, timeoutDelay); // Extended delay for large pastes to ensure complete accumulation
593
+ }
488
594
  }
489
595
  });
490
596
  }
@@ -56,6 +56,11 @@ export declare class ACECodeSearchService {
56
56
  * Strategy 2: Use system grep (or ripgrep if available) for fast searching
57
57
  */
58
58
  private systemGrepSearch;
59
+ /**
60
+ * Convert a glob pattern to a RegExp that matches full paths
61
+ * Supports: *, **, ?, {a,b}, [abc]
62
+ */
63
+ private globPatternToRegex;
59
64
  /**
60
65
  * Strategy 3: Pure JavaScript fallback search
61
66
  */
@@ -76,6 +81,7 @@ export declare class ACECodeSearchService {
76
81
  /**
77
82
  * Sort search results by file modification time (recent files first)
78
83
  * Files modified within last 24 hours are prioritized
84
+ * Uses parallel stat calls for better performance
79
85
  */
80
86
  private sortResultsByRecency;
81
87
  /**
@@ -7,7 +7,7 @@ import { processManager } from '../utils/processManager.js';
7
7
  import { detectLanguage } from './utils/aceCodeSearch/language.utils.js';
8
8
  import { loadExclusionPatterns, shouldExcludeDirectory, readFileWithCache, } from './utils/aceCodeSearch/filesystem.utils.js';
9
9
  import { parseFileSymbols, getContext, } from './utils/aceCodeSearch/symbol.utils.js';
10
- import { isCommandAvailable, parseGrepOutput, globToRegex, } from './utils/aceCodeSearch/search.utils.js';
10
+ import { isCommandAvailable, parseGrepOutput, } from './utils/aceCodeSearch/search.utils.js';
11
11
  export class ACECodeSearchService {
12
12
  constructor(basePath = process.cwd()) {
13
13
  Object.defineProperty(this, "basePath", {
@@ -196,10 +196,11 @@ export class ACECodeSearchService {
196
196
  await fs.access(cachedPath);
197
197
  }
198
198
  catch {
199
- // File no longer exists, remove from cache
199
+ // File no longer exists, remove from all caches
200
200
  this.indexCache.delete(cachedPath);
201
201
  this.fileModTimes.delete(cachedPath);
202
202
  this.allIndexedFiles.delete(cachedPath);
203
+ this.fileContentCache.delete(cachedPath);
203
204
  }
204
205
  }
205
206
  this.lastIndexTime = now;
@@ -372,6 +373,10 @@ export class ACECodeSearchService {
372
373
  */
373
374
  async findReferences(symbolName, maxResults = 100) {
374
375
  const references = [];
376
+ // Load exclusion patterns
377
+ await this.loadExclusionPatterns();
378
+ // Escape special regex characters to prevent ReDoS
379
+ const escapedSymbol = symbolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
375
380
  const searchInDirectory = async (dirPath) => {
376
381
  try {
377
382
  const entries = await fs.readdir(dirPath, { withFileTypes: true });
@@ -380,11 +385,8 @@ export class ACECodeSearchService {
380
385
  break;
381
386
  const fullPath = path.join(dirPath, entry.name);
382
387
  if (entry.isDirectory()) {
383
- if (entry.name === 'node_modules' ||
384
- entry.name === '.git' ||
385
- entry.name === 'dist' ||
386
- entry.name === 'build' ||
387
- entry.name.startsWith('.')) {
388
+ // Use configurable exclusion check
389
+ if (shouldExcludeDirectory(entry.name, fullPath, this.basePath, this.customExcludes, this.regexCache)) {
388
390
  continue;
389
391
  }
390
392
  await searchInDirectory(fullPath);
@@ -395,12 +397,14 @@ export class ACECodeSearchService {
395
397
  try {
396
398
  const content = await fs.readFile(fullPath, 'utf-8');
397
399
  const lines = content.split('\n');
398
- // Search for symbol usage
400
+ // Search for symbol usage with escaped symbol name
401
+ const regex = new RegExp(`\\b${escapedSymbol}\\b`, 'g');
399
402
  for (let i = 0; i < lines.length; i++) {
400
403
  const line = lines[i];
401
404
  if (!line)
402
405
  continue;
403
- const regex = new RegExp(`\\b${symbolName}\\b`, 'g');
406
+ // Reset regex for each line
407
+ regex.lastIndex = 0;
404
408
  let match;
405
409
  while ((match = regex.exec(line)) !== null) {
406
410
  if (references.length >= maxResults)
@@ -410,7 +414,7 @@ export class ACECodeSearchService {
410
414
  if (line.includes('import') && line.includes(symbolName)) {
411
415
  referenceType = 'import';
412
416
  }
413
- else if (line.match(new RegExp(`(?:function|class|const|let|var)\\s+${symbolName}`))) {
417
+ else if (new RegExp(`(?:function|class|const|let|var)\\s+${escapedSymbol}`).test(line)) {
414
418
  referenceType = 'definition';
415
419
  }
416
420
  else if (line.includes(':') &&
@@ -491,19 +495,24 @@ export class ACECodeSearchService {
491
495
  /**
492
496
  * Strategy 1: Use git grep for fast searching in Git repositories
493
497
  */
494
- async gitGrepSearch(pattern, fileGlob, maxResults = 100) {
498
+ async gitGrepSearch(pattern, fileGlob, maxResults = 100, isRegex = false) {
495
499
  return new Promise((resolve, reject) => {
496
- const args = [
497
- 'grep',
498
- '--untracked',
499
- '-n',
500
- '-E',
501
- '--ignore-case',
502
- pattern,
503
- ];
500
+ const args = ['grep', '--untracked', '-n', '--ignore-case'];
501
+ // Use fixed-strings for literal search, extended regex for pattern search
502
+ if (isRegex) {
503
+ args.push('-E'); // Extended regex
504
+ }
505
+ else {
506
+ args.push('--fixed-strings'); // Literal string matching
507
+ }
508
+ args.push(pattern);
504
509
  if (fileGlob) {
505
- // Expand glob patterns with braces (e.g., "source/**/*.{ts,tsx}" -> ["source/**/*.ts", "source/**/*.tsx"])
506
- const expandedGlobs = this.expandGlobBraces(fileGlob);
510
+ // Normalize path separators for Windows compatibility
511
+ let gitGlob = fileGlob.replace(/\\/g, '/');
512
+ // Convert ** to * as git grep has limited ** support
513
+ gitGlob = gitGlob.replace(/\*\*/g, '*');
514
+ // Expand glob patterns with braces (e.g., "source/*.{ts,tsx}" -> ["source/*.ts", "source/*.tsx"])
515
+ const expandedGlobs = this.expandGlobBraces(gitGlob);
507
516
  args.push('--', ...expandedGlobs);
508
517
  }
509
518
  const child = spawn('git', args, {
@@ -563,14 +572,22 @@ export class ACECodeSearchService {
563
572
  // Ripgrep uses --glob for filtering
564
573
  excludeDirs.forEach(dir => args.push('--glob', `!${dir}/`));
565
574
  if (fileGlob) {
566
- args.push('--glob', fileGlob);
575
+ // Normalize path separators for Windows compatibility
576
+ const normalizedGlob = fileGlob.replace(/\\/g, '/');
577
+ // Expand glob patterns with braces
578
+ const expandedGlobs = this.expandGlobBraces(normalizedGlob);
579
+ expandedGlobs.forEach(glob => args.push('--glob', glob));
567
580
  }
568
581
  }
569
582
  else {
570
583
  // System grep uses --exclude-dir
571
584
  excludeDirs.forEach(dir => args.push(`--exclude-dir=${dir}`));
572
585
  if (fileGlob) {
573
- args.push(`--include=${fileGlob}`);
586
+ // Normalize path separators for Windows compatibility
587
+ const normalizedGlob = fileGlob.replace(/\\/g, '/');
588
+ // Expand glob patterns with braces
589
+ const expandedGlobs = this.expandGlobBraces(normalizedGlob);
590
+ expandedGlobs.forEach(glob => args.push(`--include=${glob}`));
574
591
  }
575
592
  args.push(pattern, '.');
576
593
  }
@@ -615,11 +632,35 @@ export class ACECodeSearchService {
615
632
  });
616
633
  });
617
634
  }
635
+ /**
636
+ * Convert a glob pattern to a RegExp that matches full paths
637
+ * Supports: *, **, ?, {a,b}, [abc]
638
+ */
639
+ globPatternToRegex(globPattern) {
640
+ // Normalize path separators
641
+ const normalizedGlob = globPattern.replace(/\\/g, '/');
642
+ // First, temporarily replace glob special patterns with placeholders
643
+ // to prevent them from being escaped
644
+ let regexStr = normalizedGlob
645
+ .replace(/\*\*/g, '\x00DOUBLESTAR\x00') // ** -> placeholder
646
+ .replace(/\*/g, '\x00STAR\x00') // * -> placeholder
647
+ .replace(/\?/g, '\x00QUESTION\x00'); // ? -> placeholder
648
+ // Now escape all special regex characters
649
+ regexStr = regexStr.replace(/[.+^${}()|[\]\\]/g, '\\$&');
650
+ // Replace placeholders with actual regex patterns
651
+ regexStr = regexStr
652
+ .replace(/\x00DOUBLESTAR\x00/g, '.*') // ** -> .* (match any path segments)
653
+ .replace(/\x00STAR\x00/g, '[^/]*') // * -> [^/]* (match within single segment)
654
+ .replace(/\x00QUESTION\x00/g, '.'); // ? -> . (match single character)
655
+ return new RegExp(regexStr, 'i');
656
+ }
618
657
  /**
619
658
  * Strategy 3: Pure JavaScript fallback search
620
659
  */
621
660
  async jsTextSearch(pattern, fileGlob, isRegex = false, maxResults = 100) {
622
661
  const results = [];
662
+ // Load exclusion patterns
663
+ await this.loadExclusionPatterns();
623
664
  // Compile search pattern
624
665
  let searchRegex;
625
666
  try {
@@ -635,8 +676,42 @@ export class ACECodeSearchService {
635
676
  catch (error) {
636
677
  throw new Error(`Invalid regex pattern: ${pattern}`);
637
678
  }
638
- // Parse glob pattern if provided
639
- const globRegex = fileGlob ? globToRegex(fileGlob) : null;
679
+ // Parse glob pattern if provided using improved glob parser
680
+ const globRegex = fileGlob ? this.globPatternToRegex(fileGlob) : null;
681
+ // Binary file extensions (using Set for O(1) lookup)
682
+ const binaryExts = new Set([
683
+ '.jpg',
684
+ '.jpeg',
685
+ '.png',
686
+ '.gif',
687
+ '.bmp',
688
+ '.ico',
689
+ '.svg',
690
+ '.pdf',
691
+ '.zip',
692
+ '.tar',
693
+ '.gz',
694
+ '.rar',
695
+ '.7z',
696
+ '.exe',
697
+ '.dll',
698
+ '.so',
699
+ '.dylib',
700
+ '.mp3',
701
+ '.mp4',
702
+ '.avi',
703
+ '.mov',
704
+ '.woff',
705
+ '.woff2',
706
+ '.ttf',
707
+ '.eot',
708
+ '.class',
709
+ '.jar',
710
+ '.war',
711
+ '.o',
712
+ '.a',
713
+ '.lib',
714
+ ]);
640
715
  // Search recursively
641
716
  const searchInDirectory = async (dirPath) => {
642
717
  if (results.length >= maxResults)
@@ -648,62 +723,26 @@ export class ACECodeSearchService {
648
723
  break;
649
724
  const fullPath = path.join(dirPath, entry.name);
650
725
  if (entry.isDirectory()) {
651
- // Skip ignored directories
652
- if (entry.name === 'node_modules' ||
653
- entry.name === '.git' ||
654
- entry.name === 'dist' ||
655
- entry.name === 'build' ||
656
- entry.name === '__pycache__' ||
657
- entry.name === 'target' ||
658
- entry.name === '.next' ||
659
- entry.name === '.nuxt' ||
660
- entry.name === 'coverage' ||
661
- entry.name.startsWith('.')) {
726
+ // Use configurable exclusion check
727
+ if (shouldExcludeDirectory(entry.name, fullPath, this.basePath, this.customExcludes, this.regexCache)) {
662
728
  continue;
663
729
  }
664
730
  await searchInDirectory(fullPath);
665
731
  }
666
732
  else if (entry.isFile()) {
667
733
  // Filter by glob if specified
668
- if (globRegex && !globRegex.test(fullPath)) {
669
- continue;
734
+ if (globRegex) {
735
+ // Use relative path from basePath for glob matching
736
+ const relativePath = path
737
+ .relative(this.basePath, fullPath)
738
+ .replace(/\\/g, '/');
739
+ if (!globRegex.test(relativePath)) {
740
+ continue;
741
+ }
670
742
  }
671
- // Skip binary files
743
+ // Skip binary files (using Set for fast lookup)
672
744
  const ext = path.extname(entry.name).toLowerCase();
673
- const binaryExts = [
674
- '.jpg',
675
- '.jpeg',
676
- '.png',
677
- '.gif',
678
- '.bmp',
679
- '.ico',
680
- '.svg',
681
- '.pdf',
682
- '.zip',
683
- '.tar',
684
- '.gz',
685
- '.rar',
686
- '.7z',
687
- '.exe',
688
- '.dll',
689
- '.so',
690
- '.dylib',
691
- '.mp3',
692
- '.mp4',
693
- '.avi',
694
- '.mov',
695
- '.woff',
696
- '.woff2',
697
- '.ttf',
698
- '.eot',
699
- '.class',
700
- '.jar',
701
- '.war',
702
- '.o',
703
- '.a',
704
- '.lib',
705
- ];
706
- if (binaryExts.includes(ext)) {
745
+ if (binaryExts.has(ext)) {
707
746
  continue;
708
747
  }
709
748
  try {
@@ -754,10 +793,8 @@ export class ACECodeSearchService {
754
793
  try {
755
794
  const gitAvailable = await isCommandAvailable('git');
756
795
  if (gitAvailable) {
757
- const results = await this.gitGrepSearch(pattern, fileGlob, maxResults);
758
- if (results.length > 0 || !isRegex) {
759
- // git grep doesn't support all regex features,
760
- // fall back if pattern is complex regex and no results
796
+ const results = await this.gitGrepSearch(pattern, fileGlob, maxResults, isRegex);
797
+ if (results.length > 0) {
761
798
  return await this.sortResultsByRecency(results);
762
799
  }
763
800
  }
@@ -786,27 +823,32 @@ export class ACECodeSearchService {
786
823
  /**
787
824
  * Sort search results by file modification time (recent files first)
788
825
  * Files modified within last 24 hours are prioritized
826
+ * Uses parallel stat calls for better performance
789
827
  */
790
828
  async sortResultsByRecency(results) {
791
829
  if (results.length === 0)
792
830
  return results;
793
831
  const now = Date.now();
794
832
  const recentThreshold = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
795
- // Get file modification times
833
+ // Get unique file paths
834
+ const uniqueFiles = Array.from(new Set(results.map(r => r.filePath)));
835
+ // Fetch file modification times in parallel using Promise.allSettled
836
+ const statResults = await Promise.allSettled(uniqueFiles.map(async (filePath) => {
837
+ const fullPath = path.resolve(this.basePath, filePath);
838
+ const stats = await fs.stat(fullPath);
839
+ return { filePath, mtimeMs: stats.mtimeMs };
840
+ }));
841
+ // Build map of file modification times
796
842
  const fileModTimes = new Map();
797
- for (const result of results) {
798
- if (fileModTimes.has(result.filePath))
799
- continue;
800
- try {
801
- const fullPath = path.resolve(this.basePath, result.filePath);
802
- const stats = await fs.stat(fullPath);
803
- fileModTimes.set(result.filePath, stats.mtimeMs);
843
+ statResults.forEach((result, index) => {
844
+ if (result.status === 'fulfilled') {
845
+ fileModTimes.set(result.value.filePath, result.value.mtimeMs);
804
846
  }
805
- catch {
847
+ else {
806
848
  // If we can't get stats, treat as old file
807
- fileModTimes.set(result.filePath, 0);
849
+ fileModTimes.set(uniqueFiles[index], 0);
808
850
  }
809
- }
851
+ });
810
852
  // Sort results: recent files first, then by original order
811
853
  return results.sort((a, b) => {
812
854
  const aMtime = fileModTimes.get(a.filePath) || 0;
@@ -1,6 +1,6 @@
1
1
  import React, { useCallback, useEffect, useRef, useMemo } from 'react';
2
2
  import { Box, Text } from 'ink';
3
- import { cpSlice, cpLen } from '../../utils/textUtils.js';
3
+ import { cpSlice } from '../../utils/textUtils.js';
4
4
  import CommandPanel from './CommandPanel.js';
5
5
  import FileList from './FileList.js';
6
6
  import AgentPickerPanel from './AgentPickerPanel.js';
@@ -211,36 +211,28 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
211
211
  }
212
212
  }, [hasFocus]);
213
213
  // Render content with cursor (treat all text including placeholders as plain text)
214
- const renderContent = useCallback(() => {
214
+ const renderContent = () => {
215
215
  if (buffer.text.length > 0) {
216
- // 使用buffer的内部文本,将占位符当作普通文本处理
217
- const displayText = buffer.text;
218
- const cursorPos = buffer.getCursorPosition();
219
- const charInfo = buffer.getCharAtCursor();
220
- const atCursor = charInfo.char === '\n' ? ' ' : charInfo.char;
221
- // Split text into lines for proper multi-line rendering
222
- const lines = displayText.split('\n');
216
+ // Use visual lines for proper wrapping and multi-line support
217
+ const visualLines = buffer.viewportVisualLines;
218
+ const [cursorRow, cursorCol] = buffer.visualCursor;
223
219
  const renderedLines = [];
224
- let currentPos = 0;
225
- for (let i = 0; i < lines.length; i++) {
226
- const line = lines[i] || '';
227
- const lineStart = currentPos;
228
- const lineEnd = lineStart + cpLen(line);
229
- // Check if cursor is in this line
230
- if (cursorPos >= lineStart && cursorPos <= lineEnd) {
231
- const beforeCursor = cpSlice(line, 0, cursorPos - lineStart);
232
- const afterCursor = cpSlice(line, cursorPos - lineStart + 1);
233
- renderedLines.push(React.createElement(Text, { key: i },
234
- beforeCursor,
220
+ for (let i = 0; i < visualLines.length; i++) {
221
+ const line = visualLines[i] || '';
222
+ if (i === cursorRow) {
223
+ // This line contains the cursor
224
+ const beforeCursor = cpSlice(line, 0, cursorCol);
225
+ const atCursor = cpSlice(line, cursorCol, cursorCol + 1) || ' ';
226
+ const afterCursor = cpSlice(line, cursorCol + 1);
227
+ renderedLines.push(React.createElement(Box, { key: i, flexDirection: "row" },
228
+ React.createElement(Text, null, beforeCursor),
235
229
  renderCursor(atCursor),
236
- afterCursor));
230
+ React.createElement(Text, null, afterCursor)));
237
231
  }
238
232
  else {
239
233
  // No cursor in this line
240
234
  renderedLines.push(React.createElement(Text, { key: i }, line || ' '));
241
235
  }
242
- // Account for newline character
243
- currentPos = lineEnd + 1;
244
236
  }
245
237
  return React.createElement(Box, { flexDirection: "column" }, renderedLines);
246
238
  }
@@ -249,7 +241,7 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
249
241
  renderCursor(' '),
250
242
  React.createElement(Text, { color: disabled ? 'darkGray' : 'gray', dimColor: true }, disabled ? 'Waiting for response...' : placeholder)));
251
243
  }
252
- }, [buffer, disabled, placeholder, renderCursor]); // 移除 buffer.text 避免循环依赖,buffer 变化时会自然触发重渲染
244
+ };
253
245
  return (React.createElement(Box, { flexDirection: "column", paddingX: 1, width: terminalWidth },
254
246
  showHistoryMenu && (React.createElement(Box, { flexDirection: "column", marginBottom: 1, width: terminalWidth - 2 },
255
247
  React.createElement(Box, { flexDirection: "column" }, (() => {
@@ -50,6 +50,7 @@ export declare class TextBuffer {
50
50
  getFullText(): string;
51
51
  get visualCursor(): [number, number];
52
52
  getCursorPosition(): number;
53
+ setCursorPosition(position: number): void;
53
54
  get viewportVisualLines(): string[];
54
55
  get maxWidth(): number;
55
56
  private scheduleUpdate;
@@ -131,6 +131,11 @@ export class TextBuffer {
131
131
  getCursorPosition() {
132
132
  return this.cursorIndex;
133
133
  }
134
+ setCursorPosition(position) {
135
+ this.cursorIndex = position;
136
+ this.clampCursorIndex();
137
+ this.recomputeVisualCursorOnly();
138
+ }
134
139
  get viewportVisualLines() {
135
140
  return this.visualLines;
136
141
  }
@@ -161,9 +166,21 @@ export class TextBuffer {
161
166
  return;
162
167
  }
163
168
  const charCount = sanitized.length;
164
- // 如果存在临时"粘贴中"占位符,先移除它
169
+ // 如果存在临时"粘贴中"占位符,先移除它,并调整光标位置
165
170
  if (this.tempPastingPlaceholder) {
166
- this.content = this.content.replace(this.tempPastingPlaceholder, '');
171
+ const placeholderIndex = this.content.indexOf(this.tempPastingPlaceholder);
172
+ if (placeholderIndex !== -1) {
173
+ // 找到占位符的位置
174
+ const placeholderLength = cpLen(this.tempPastingPlaceholder);
175
+ // 移除占位符
176
+ this.content =
177
+ this.content.slice(0, placeholderIndex) +
178
+ this.content.slice(placeholderIndex + this.tempPastingPlaceholder.length);
179
+ // 调整光标位置:如果光标在占位符之后,需要向前移动
180
+ if (this.cursorIndex > placeholderIndex) {
181
+ this.cursorIndex = Math.max(placeholderIndex, this.cursorIndex - placeholderLength);
182
+ }
183
+ }
167
184
  this.tempPastingPlaceholder = null;
168
185
  }
169
186
  // 如果是大文本(>300字符),直接创建占位符
@@ -364,11 +381,59 @@ export class TextBuffer {
364
381
  const codePoints = toCodePoints(line);
365
382
  const segments = [];
366
383
  let start = 0;
384
+ // Helper function to find placeholder at given position
385
+ const findPlaceholderAt = (pos) => {
386
+ // Look backwards to find the opening bracket
387
+ let openPos = pos;
388
+ while (openPos >= 0 && codePoints[openPos] !== '[') {
389
+ openPos--;
390
+ }
391
+ if (openPos >= 0 && codePoints[openPos] === '[') {
392
+ // Look forward to find the closing bracket
393
+ let closePos = openPos + 1;
394
+ while (closePos < codePoints.length && codePoints[closePos] !== ']') {
395
+ closePos++;
396
+ }
397
+ if (closePos < codePoints.length && codePoints[closePos] === ']') {
398
+ const placeholderText = codePoints.slice(openPos, closePos + 1).join('');
399
+ // Check if it's a valid placeholder
400
+ if (placeholderText.match(/^\[Paste \d+ lines #\d+\]$/) ||
401
+ placeholderText.match(/^\[image #\d+\]$/) ||
402
+ placeholderText === '[Pasting...]') {
403
+ return { start: openPos, end: closePos + 1 };
404
+ }
405
+ }
406
+ }
407
+ return null;
408
+ };
367
409
  while (start < codePoints.length) {
368
410
  let currentWidth = 0;
369
411
  let end = start;
370
412
  let lastBreak = -1;
371
413
  while (end < codePoints.length) {
414
+ // Check if current position is start of a placeholder
415
+ if (codePoints[end] === '[') {
416
+ const placeholder = findPlaceholderAt(end);
417
+ if (placeholder && placeholder.start === end) {
418
+ const placeholderText = codePoints.slice(placeholder.start, placeholder.end).join('');
419
+ const placeholderWidth = Array.from(placeholderText).reduce((sum, c) => sum + visualWidth(c), 0);
420
+ // If placeholder fits on current line, include it
421
+ if (currentWidth + placeholderWidth <= width) {
422
+ currentWidth += placeholderWidth;
423
+ end = placeholder.end;
424
+ continue;
425
+ }
426
+ else if (currentWidth === 0) {
427
+ // Placeholder doesn't fit but we're at line start, force it on this line
428
+ end = placeholder.end;
429
+ break;
430
+ }
431
+ else {
432
+ // Placeholder doesn't fit, break before it
433
+ break;
434
+ }
435
+ }
436
+ }
372
437
  const char = codePoints[end] || '';
373
438
  const charWidth = visualWidth(char);
374
439
  if (char === ' ') {
@@ -452,8 +517,8 @@ export class TextBuffer {
452
517
  */
453
518
  getImages() {
454
519
  return Array.from(this.placeholderStorage.values())
455
- .filter((p) => p.type === 'image')
456
- .map((p) => {
520
+ .filter(p => p.type === 'image')
521
+ .map(p => {
457
522
  const mimeType = p.mimeType || 'image/png';
458
523
  // 还原为 data URL 格式
459
524
  const dataUrl = `data:${mimeType};base64,${p.content}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snow-ai",
3
- "version": "0.3.35",
3
+ "version": "0.3.36",
4
4
  "description": "Intelligent Command Line Assistant powered by AI",
5
5
  "license": "MIT",
6
6
  "bin": {