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.
- package/dist/api/systemPrompt.js +31 -42
- package/dist/hooks/useKeyboardInput.js +132 -26
- package/dist/mcp/aceCodeSearch.d.ts +6 -0
- package/dist/mcp/aceCodeSearch.js +130 -88
- package/dist/ui/components/ChatInput.js +16 -24
- package/dist/utils/textBuffer.d.ts +1 -0
- package/dist/utils/textBuffer.js +69 -4
- package/package.json +1 -1
package/dist/api/systemPrompt.js
CHANGED
|
@@ -141,46 +141,32 @@ PLACEHOLDER_FOR_WORKFLOW_SECTION
|
|
|
141
141
|
|
|
142
142
|
## Available Tools
|
|
143
143
|
|
|
144
|
-
**Filesystem:**
|
|
145
|
-
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
**
|
|
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
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
inputBuffer.current
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
'-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
//
|
|
506
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
//
|
|
652
|
-
if (entry.name
|
|
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
|
|
669
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
798
|
-
if (
|
|
799
|
-
|
|
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
|
-
|
|
847
|
+
else {
|
|
806
848
|
// If we can't get stats, treat as old file
|
|
807
|
-
fileModTimes.set(
|
|
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
|
|
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 =
|
|
214
|
+
const renderContent = () => {
|
|
215
215
|
if (buffer.text.length > 0) {
|
|
216
|
-
//
|
|
217
|
-
const
|
|
218
|
-
const
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
}
|
|
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;
|
package/dist/utils/textBuffer.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
456
|
-
.map(
|
|
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}`;
|