snow-ai 0.3.16 → 0.3.18
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 +5 -3
- package/dist/hooks/useAgentPicker.d.ts +10 -0
- package/dist/hooks/useAgentPicker.js +32 -0
- package/dist/hooks/useCommandPanel.js +8 -0
- package/dist/hooks/useFilePicker.d.ts +1 -0
- package/dist/hooks/useFilePicker.js +102 -23
- package/dist/hooks/useKeyboardInput.d.ts +22 -0
- package/dist/hooks/useKeyboardInput.js +109 -1
- package/dist/hooks/useStreamingState.js +16 -10
- package/dist/hooks/useTodoPicker.d.ts +16 -0
- package/dist/hooks/useTodoPicker.js +94 -0
- package/dist/ui/components/AgentPickerPanel.d.ts +8 -0
- package/dist/ui/components/AgentPickerPanel.js +74 -0
- package/dist/ui/components/ChatInput.js +41 -96
- package/dist/ui/components/FileList.d.ts +1 -0
- package/dist/ui/components/FileList.js +181 -32
- package/dist/ui/components/TodoPickerPanel.d.ts +14 -0
- package/dist/ui/components/TodoPickerPanel.js +117 -0
- package/dist/ui/pages/ChatScreen.d.ts +2 -0
- package/dist/ui/pages/ChatScreen.js +2 -0
- package/dist/utils/commandExecutor.d.ts +1 -1
- package/dist/utils/commands/agent.d.ts +2 -0
- package/dist/utils/commands/agent.js +12 -0
- package/dist/utils/commands/todoPicker.d.ts +2 -0
- package/dist/utils/commands/todoPicker.js +12 -0
- package/dist/utils/subAgentExecutor.js +3 -12
- package/dist/utils/todoScanner.d.ts +8 -0
- package/dist/utils/todoScanner.js +148 -0
- package/package.json +1 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React, { memo, useMemo } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { Alert } from '@inkjs/ui';
|
|
4
|
+
import { getSubAgents } from '../../utils/subAgentConfig.js';
|
|
5
|
+
const AgentPickerPanel = memo(({ selectedIndex, visible, maxHeight }) => {
|
|
6
|
+
// Fixed maximum display items to prevent rendering issues
|
|
7
|
+
const MAX_DISPLAY_ITEMS = 5;
|
|
8
|
+
const effectiveMaxItems = maxHeight
|
|
9
|
+
? Math.min(maxHeight, MAX_DISPLAY_ITEMS)
|
|
10
|
+
: MAX_DISPLAY_ITEMS;
|
|
11
|
+
// Load sub-agents
|
|
12
|
+
const agents = useMemo(() => getSubAgents(), []);
|
|
13
|
+
// Limit displayed agents
|
|
14
|
+
const displayedAgents = useMemo(() => {
|
|
15
|
+
if (agents.length <= effectiveMaxItems) {
|
|
16
|
+
return agents;
|
|
17
|
+
}
|
|
18
|
+
// Show agents around the selected index
|
|
19
|
+
const halfWindow = Math.floor(effectiveMaxItems / 2);
|
|
20
|
+
let startIndex = Math.max(0, selectedIndex - halfWindow);
|
|
21
|
+
let endIndex = Math.min(agents.length, startIndex + effectiveMaxItems);
|
|
22
|
+
// Adjust if we're near the end
|
|
23
|
+
if (endIndex - startIndex < effectiveMaxItems) {
|
|
24
|
+
startIndex = Math.max(0, endIndex - effectiveMaxItems);
|
|
25
|
+
}
|
|
26
|
+
return agents.slice(startIndex, endIndex);
|
|
27
|
+
}, [agents, selectedIndex, effectiveMaxItems]);
|
|
28
|
+
// Calculate actual selected index in the displayed subset
|
|
29
|
+
const displayedSelectedIndex = useMemo(() => {
|
|
30
|
+
return displayedAgents.findIndex(agent => {
|
|
31
|
+
const originalIndex = agents.indexOf(agent);
|
|
32
|
+
return originalIndex === selectedIndex;
|
|
33
|
+
});
|
|
34
|
+
}, [displayedAgents, agents, selectedIndex]);
|
|
35
|
+
// Don't show panel if not visible
|
|
36
|
+
if (!visible) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
// Show message if no agents configured
|
|
40
|
+
if (agents.length === 0) {
|
|
41
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
42
|
+
React.createElement(Box, { width: "100%" },
|
|
43
|
+
React.createElement(Box, { flexDirection: "column", width: "100%" },
|
|
44
|
+
React.createElement(Box, null,
|
|
45
|
+
React.createElement(Text, { color: "yellow", bold: true }, "Sub-Agent Selection")),
|
|
46
|
+
React.createElement(Box, { marginTop: 1 },
|
|
47
|
+
React.createElement(Alert, { variant: "warning" }, "No sub-agents configured. Please configure sub-agents first."))))));
|
|
48
|
+
}
|
|
49
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
50
|
+
React.createElement(Box, { width: "100%" },
|
|
51
|
+
React.createElement(Box, { flexDirection: "column", width: "100%" },
|
|
52
|
+
React.createElement(Box, null,
|
|
53
|
+
React.createElement(Text, { color: "yellow", bold: true },
|
|
54
|
+
"Select Sub-Agent",
|
|
55
|
+
' ',
|
|
56
|
+
agents.length > effectiveMaxItems &&
|
|
57
|
+
`(${selectedIndex + 1}/${agents.length})`)),
|
|
58
|
+
displayedAgents.map((agent, index) => (React.createElement(Box, { key: agent.id, flexDirection: "column", width: "100%" },
|
|
59
|
+
React.createElement(Text, { color: index === displayedSelectedIndex ? 'green' : 'gray', bold: true },
|
|
60
|
+
index === displayedSelectedIndex ? '❯ ' : ' ',
|
|
61
|
+
"#",
|
|
62
|
+
agent.name),
|
|
63
|
+
React.createElement(Box, { marginLeft: 3 },
|
|
64
|
+
React.createElement(Text, { color: index === displayedSelectedIndex ? 'green' : 'gray', dimColor: true },
|
|
65
|
+
"\u2514\u2500 ",
|
|
66
|
+
agent.description || 'No description'))))),
|
|
67
|
+
agents.length > effectiveMaxItems && (React.createElement(Box, { marginTop: 1 },
|
|
68
|
+
React.createElement(Text, { color: "gray", dimColor: true },
|
|
69
|
+
"\u2191\u2193 to scroll \u00B7 ",
|
|
70
|
+
agents.length - effectiveMaxItems,
|
|
71
|
+
" more hidden")))))));
|
|
72
|
+
});
|
|
73
|
+
AgentPickerPanel.displayName = 'AgentPickerPanel';
|
|
74
|
+
export default AgentPickerPanel;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef } 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
|
+
import AgentPickerPanel from './AgentPickerPanel.js';
|
|
7
|
+
import TodoPickerPanel from './TodoPickerPanel.js';
|
|
6
8
|
import { useInputBuffer } from '../../hooks/useInputBuffer.js';
|
|
7
9
|
import { useCommandPanel } from '../../hooks/useCommandPanel.js';
|
|
8
10
|
import { useFilePicker } from '../../hooks/useFilePicker.js';
|
|
@@ -11,6 +13,8 @@ import { useClipboard } from '../../hooks/useClipboard.js';
|
|
|
11
13
|
import { useKeyboardInput } from '../../hooks/useKeyboardInput.js';
|
|
12
14
|
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
|
13
15
|
import { useTerminalFocus } from '../../hooks/useTerminalFocus.js';
|
|
16
|
+
import { useAgentPicker } from '../../hooks/useAgentPicker.js';
|
|
17
|
+
import { useTodoPicker } from '../../hooks/useTodoPicker.js';
|
|
14
18
|
/**
|
|
15
19
|
* Calculate context usage percentage
|
|
16
20
|
* This is the same logic used in ChatInput to display usage
|
|
@@ -46,9 +50,13 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
46
50
|
// Use command panel hook
|
|
47
51
|
const { showCommands, setShowCommands, commandSelectedIndex, setCommandSelectedIndex, getFilteredCommands, updateCommandPanelState, isProcessing: commandPanelIsProcessing, } = useCommandPanel(buffer, isProcessing);
|
|
48
52
|
// Use file picker hook
|
|
49
|
-
const { showFilePicker, setShowFilePicker, fileSelectedIndex, setFileSelectedIndex, fileQuery, setFileQuery, atSymbolPosition, setAtSymbolPosition, filteredFileCount, updateFilePickerState, handleFileSelect, handleFilteredCountChange, fileListRef, } = useFilePicker(buffer, triggerUpdate);
|
|
53
|
+
const { showFilePicker, setShowFilePicker, fileSelectedIndex, setFileSelectedIndex, fileQuery, setFileQuery, atSymbolPosition, setAtSymbolPosition, filteredFileCount, searchMode, updateFilePickerState, handleFileSelect, handleFilteredCountChange, fileListRef, } = useFilePicker(buffer, triggerUpdate);
|
|
50
54
|
// Use history navigation hook
|
|
51
55
|
const { showHistoryMenu, setShowHistoryMenu, historySelectedIndex, setHistorySelectedIndex, escapeKeyCount, setEscapeKeyCount, escapeKeyTimer, getUserMessages, handleHistorySelect, currentHistoryIndex, navigateHistoryUp, navigateHistoryDown, resetHistoryNavigation, saveToHistory, } = useHistoryNavigation(buffer, triggerUpdate, chatHistory, onHistorySelect);
|
|
56
|
+
// Use agent picker hook
|
|
57
|
+
const { showAgentPicker, setShowAgentPicker, agentSelectedIndex, setAgentSelectedIndex, agents, handleAgentSelect, } = useAgentPicker(buffer, triggerUpdate);
|
|
58
|
+
// Use todo picker hook
|
|
59
|
+
const { showTodoPicker, setShowTodoPicker, todoSelectedIndex, setTodoSelectedIndex, todos, selectedTodos, toggleTodoSelection, confirmTodoSelection, isLoading: todoIsLoading, searchQuery: todoSearchQuery, setSearchQuery: setTodoSearchQuery, totalTodoCount, } = useTodoPicker(buffer, triggerUpdate, process.cwd());
|
|
52
60
|
// Use clipboard hook
|
|
53
61
|
const { pasteFromClipboard } = useClipboard(buffer, updateCommandPanelState, updateFilePickerState, triggerUpdate);
|
|
54
62
|
// Use keyboard input hook
|
|
@@ -93,6 +101,22 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
93
101
|
pasteFromClipboard,
|
|
94
102
|
onSubmit,
|
|
95
103
|
ensureFocus,
|
|
104
|
+
showAgentPicker,
|
|
105
|
+
setShowAgentPicker,
|
|
106
|
+
agentSelectedIndex,
|
|
107
|
+
setAgentSelectedIndex,
|
|
108
|
+
agents,
|
|
109
|
+
handleAgentSelect,
|
|
110
|
+
showTodoPicker,
|
|
111
|
+
setShowTodoPicker,
|
|
112
|
+
todoSelectedIndex,
|
|
113
|
+
setTodoSelectedIndex,
|
|
114
|
+
todos,
|
|
115
|
+
selectedTodos,
|
|
116
|
+
toggleTodoSelection,
|
|
117
|
+
confirmTodoSelection,
|
|
118
|
+
todoSearchQuery,
|
|
119
|
+
setTodoSearchQuery,
|
|
96
120
|
});
|
|
97
121
|
// Set initial content when provided (e.g., when rolling back to first message)
|
|
98
122
|
useEffect(() => {
|
|
@@ -181,101 +205,18 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
181
205
|
return React.createElement(Text, null, char);
|
|
182
206
|
}
|
|
183
207
|
}, [hasFocus]);
|
|
184
|
-
// Render content with cursor
|
|
208
|
+
// Render content with cursor (treat all text including placeholders as plain text)
|
|
185
209
|
const renderContent = useCallback(() => {
|
|
186
210
|
if (buffer.text.length > 0) {
|
|
187
|
-
// 使用buffer
|
|
211
|
+
// 使用buffer的内部文本,将占位符当作普通文本处理
|
|
188
212
|
const displayText = buffer.text;
|
|
189
213
|
const cursorPos = buffer.getCursorPosition();
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const hasOnlyNewlines = /^[\n\s]*$/.test(displayText.replace(focusTokenPattern, ''));
|
|
197
|
-
const isFocusNoise = cleanedText.length === 0 && !hasOnlyNewlines;
|
|
198
|
-
if (hasPastePlaceholder || hasImagePlaceholder || isFocusNoise) {
|
|
199
|
-
const atCursor = (() => {
|
|
200
|
-
const charInfo = buffer.getCharAtCursor();
|
|
201
|
-
return charInfo.char === '\n' ? ' ' : charInfo.char;
|
|
202
|
-
})();
|
|
203
|
-
// 分割文本并高亮占位符(粘贴和图片)
|
|
204
|
-
const parts = displayText.split(/(\[Paste \d+ characters #\d+\]|\[image #\d+\])/);
|
|
205
|
-
// 先构建带位置信息的数据结构
|
|
206
|
-
const partsWithPosition = [];
|
|
207
|
-
let processedLength = 0;
|
|
208
|
-
for (let i = 0; i < parts.length; i++) {
|
|
209
|
-
const part = parts[i] || '';
|
|
210
|
-
const isPastePlaceholder = !!part.match(/^\[Paste \d+ characters #\d+\]$/);
|
|
211
|
-
const isImagePlaceholder = !!part.match(/^\[image #\d+\]$/);
|
|
212
|
-
const partStart = processedLength;
|
|
213
|
-
const partEnd = processedLength + cpLen(part);
|
|
214
|
-
processedLength = partEnd;
|
|
215
|
-
if (part.length > 0) {
|
|
216
|
-
partsWithPosition.push({
|
|
217
|
-
part,
|
|
218
|
-
partStart,
|
|
219
|
-
partEnd,
|
|
220
|
-
isPastePlaceholder,
|
|
221
|
-
isImagePlaceholder,
|
|
222
|
-
originalIndex: i,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
let cursorRendered = false;
|
|
227
|
-
const elements = [];
|
|
228
|
-
let elementKey = 0;
|
|
229
|
-
for (const item of partsWithPosition) {
|
|
230
|
-
const { part, partStart, partEnd, isPastePlaceholder, isImagePlaceholder, originalIndex } = item;
|
|
231
|
-
const isPlaceholder = isPastePlaceholder || isImagePlaceholder;
|
|
232
|
-
// 检查光标是否在这个部分
|
|
233
|
-
if (cursorPos >= partStart && cursorPos < partEnd) {
|
|
234
|
-
cursorRendered = true;
|
|
235
|
-
const beforeCursorInPart = cpSlice(part, 0, cursorPos - partStart);
|
|
236
|
-
const afterCursorInPart = cpSlice(part, cursorPos - partStart + 1);
|
|
237
|
-
if (isPlaceholder) {
|
|
238
|
-
if (beforeCursorInPart) {
|
|
239
|
-
elements.push(React.createElement(Text, { key: `${originalIndex}-before`, color: isImagePlaceholder ? 'magenta' : 'cyan', dimColor: true }, beforeCursorInPart));
|
|
240
|
-
}
|
|
241
|
-
elements.push(React.createElement(React.Fragment, { key: `cursor-${elementKey++}` }, renderCursor(atCursor)));
|
|
242
|
-
if (afterCursorInPart) {
|
|
243
|
-
elements.push(React.createElement(Text, { key: `${originalIndex}-after`, color: isImagePlaceholder ? 'magenta' : 'cyan', dimColor: true }, afterCursorInPart));
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
else {
|
|
247
|
-
if (beforeCursorInPart) {
|
|
248
|
-
elements.push(React.createElement(Text, { key: `${originalIndex}-before` }, beforeCursorInPart));
|
|
249
|
-
}
|
|
250
|
-
elements.push(React.createElement(React.Fragment, { key: `cursor-${elementKey++}` }, renderCursor(atCursor)));
|
|
251
|
-
if (afterCursorInPart) {
|
|
252
|
-
elements.push(React.createElement(Text, { key: `${originalIndex}-after` }, afterCursorInPart));
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
else {
|
|
257
|
-
if (isPlaceholder) {
|
|
258
|
-
elements.push(React.createElement(Text, { key: originalIndex, color: isImagePlaceholder ? 'magenta' : 'cyan', dimColor: true }, part));
|
|
259
|
-
}
|
|
260
|
-
else {
|
|
261
|
-
elements.push(React.createElement(Text, { key: originalIndex }, part));
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
if (!cursorRendered) {
|
|
266
|
-
elements.push(React.createElement(React.Fragment, { key: `cursor-final` }, renderCursor(' ')));
|
|
267
|
-
}
|
|
268
|
-
return React.createElement(React.Fragment, null, elements);
|
|
269
|
-
}
|
|
270
|
-
else {
|
|
271
|
-
// 普通文本渲染
|
|
272
|
-
const charInfo = buffer.getCharAtCursor();
|
|
273
|
-
const atCursor = charInfo.char === '\n' ? ' ' : charInfo.char;
|
|
274
|
-
return (React.createElement(Text, null,
|
|
275
|
-
cpSlice(displayText, 0, cursorPos),
|
|
276
|
-
renderCursor(atCursor),
|
|
277
|
-
cpSlice(displayText, cursorPos + 1)));
|
|
278
|
-
}
|
|
214
|
+
const charInfo = buffer.getCharAtCursor();
|
|
215
|
+
const atCursor = charInfo.char === '\n' ? ' ' : charInfo.char;
|
|
216
|
+
return (React.createElement(Text, null,
|
|
217
|
+
cpSlice(displayText, 0, cursorPos),
|
|
218
|
+
renderCursor(atCursor),
|
|
219
|
+
cpSlice(displayText, cursorPos + 1)));
|
|
279
220
|
}
|
|
280
221
|
else {
|
|
281
222
|
return (React.createElement(React.Fragment, null,
|
|
@@ -342,7 +283,9 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
342
283
|
React.createElement(Text, { color: "gray" }, '─'.repeat(terminalWidth - 2))),
|
|
343
284
|
React.createElement(CommandPanel, { commands: getFilteredCommands(), selectedIndex: commandSelectedIndex, query: buffer.getFullText().slice(1), visible: showCommands, isProcessing: commandPanelIsProcessing }),
|
|
344
285
|
React.createElement(Box, null,
|
|
345
|
-
React.createElement(FileList, { ref: fileListRef, query: fileQuery, selectedIndex: fileSelectedIndex, visible: showFilePicker, maxItems: 10, rootPath: process.cwd(), onFilteredCountChange: handleFilteredCountChange })),
|
|
286
|
+
React.createElement(FileList, { ref: fileListRef, query: fileQuery, selectedIndex: fileSelectedIndex, visible: showFilePicker, maxItems: 10, rootPath: process.cwd(), onFilteredCountChange: handleFilteredCountChange, searchMode: searchMode })),
|
|
287
|
+
React.createElement(AgentPickerPanel, { selectedIndex: agentSelectedIndex, visible: showAgentPicker, maxHeight: 5 }),
|
|
288
|
+
React.createElement(TodoPickerPanel, { todos: todos, selectedIndex: todoSelectedIndex, selectedTodos: selectedTodos, visible: showTodoPicker, maxHeight: 5, isLoading: todoIsLoading, searchQuery: todoSearchQuery, totalCount: totalTodoCount }),
|
|
346
289
|
yoloMode && (React.createElement(Box, { marginTop: 1 },
|
|
347
290
|
React.createElement(Text, { color: "yellow", dimColor: true }, "\u2741 YOLO MODE ACTIVE - All tools will be auto-approved without confirmation"))),
|
|
348
291
|
contextUsage && (React.createElement(Box, { marginTop: 1 },
|
|
@@ -409,9 +352,11 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
409
352
|
React.createElement(Text, null, showCommands && getFilteredCommands().length > 0
|
|
410
353
|
? 'Type to filter commands'
|
|
411
354
|
: showFilePicker
|
|
412
|
-
?
|
|
355
|
+
? searchMode === 'content'
|
|
356
|
+
? 'Content search • Tab/Enter to select • ESC to cancel'
|
|
357
|
+
: 'Type to filter files • Tab/Enter to select • ESC to cancel'
|
|
413
358
|
: (() => {
|
|
414
359
|
const pasteKey = process.platform === 'darwin' ? 'Ctrl+V' : 'Alt+V';
|
|
415
|
-
return `Ctrl+L: delete to start • Ctrl+R: delete to end • ${pasteKey}: paste images • '@': files • '/': commands`;
|
|
360
|
+
return `Ctrl+L: delete to start • Ctrl+R: delete to end • ${pasteKey}: paste images • '@': files • '@@': search content • '/': commands`;
|
|
416
361
|
})()))))));
|
|
417
362
|
}
|
|
@@ -2,9 +2,12 @@ import React, { useState, useEffect, useMemo, useCallback, forwardRef, useImpera
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
|
|
5
|
+
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
|
6
|
+
const FileList = memo(forwardRef(({ query, selectedIndex, visible, maxItems = 10, rootPath = process.cwd(), onFilteredCountChange, searchMode = 'file', }, ref) => {
|
|
6
7
|
const [files, setFiles] = useState([]);
|
|
7
8
|
const [isLoading, setIsLoading] = useState(false);
|
|
9
|
+
// Get terminal size for dynamic content display
|
|
10
|
+
const { columns: terminalWidth } = useTerminalSize();
|
|
8
11
|
// Fixed maximum display items to prevent rendering issues
|
|
9
12
|
const MAX_DISPLAY_ITEMS = 5;
|
|
10
13
|
const effectiveMaxItems = useMemo(() => {
|
|
@@ -94,35 +97,169 @@ const FileList = memo(forwardRef(({ query, selectedIndex, visible, maxItems = 10
|
|
|
94
97
|
setFiles(fileList);
|
|
95
98
|
setIsLoading(false);
|
|
96
99
|
}, [rootPath]);
|
|
100
|
+
// Search file content for content search mode
|
|
101
|
+
const searchFileContent = useCallback(async (query) => {
|
|
102
|
+
if (!query.trim()) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const results = [];
|
|
106
|
+
const queryLower = query.toLowerCase();
|
|
107
|
+
const maxResults = 100; // Limit results for performance
|
|
108
|
+
// Text file extensions to search
|
|
109
|
+
const textExtensions = new Set([
|
|
110
|
+
'.js',
|
|
111
|
+
'.jsx',
|
|
112
|
+
'.ts',
|
|
113
|
+
'.tsx',
|
|
114
|
+
'.py',
|
|
115
|
+
'.java',
|
|
116
|
+
'.c',
|
|
117
|
+
'.cpp',
|
|
118
|
+
'.h',
|
|
119
|
+
'.hpp',
|
|
120
|
+
'.cs',
|
|
121
|
+
'.go',
|
|
122
|
+
'.rs',
|
|
123
|
+
'.rb',
|
|
124
|
+
'.php',
|
|
125
|
+
'.swift',
|
|
126
|
+
'.kt',
|
|
127
|
+
'.scala',
|
|
128
|
+
'.sh',
|
|
129
|
+
'.bash',
|
|
130
|
+
'.zsh',
|
|
131
|
+
'.fish',
|
|
132
|
+
'.ps1',
|
|
133
|
+
'.html',
|
|
134
|
+
'.css',
|
|
135
|
+
'.scss',
|
|
136
|
+
'.sass',
|
|
137
|
+
'.less',
|
|
138
|
+
'.xml',
|
|
139
|
+
'.json',
|
|
140
|
+
'.yaml',
|
|
141
|
+
'.yml',
|
|
142
|
+
'.toml',
|
|
143
|
+
'.ini',
|
|
144
|
+
'.conf',
|
|
145
|
+
'.config',
|
|
146
|
+
'.txt',
|
|
147
|
+
'.md',
|
|
148
|
+
'.markdown',
|
|
149
|
+
'.rst',
|
|
150
|
+
'.tex',
|
|
151
|
+
'.sql',
|
|
152
|
+
'.graphql',
|
|
153
|
+
'.proto',
|
|
154
|
+
'.vue',
|
|
155
|
+
'.svelte',
|
|
156
|
+
]);
|
|
157
|
+
// Filter to only text files
|
|
158
|
+
const filesToSearch = files.filter(f => {
|
|
159
|
+
if (f.isDirectory)
|
|
160
|
+
return false;
|
|
161
|
+
const ext = path.extname(f.path).toLowerCase();
|
|
162
|
+
return textExtensions.has(ext);
|
|
163
|
+
});
|
|
164
|
+
// Process files in batches to avoid blocking
|
|
165
|
+
const batchSize = 10;
|
|
166
|
+
for (let batchStart = 0; batchStart < filesToSearch.length; batchStart += batchSize) {
|
|
167
|
+
if (results.length >= maxResults) {
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
const batch = filesToSearch.slice(batchStart, batchStart + batchSize);
|
|
171
|
+
// Process batch files concurrently but with limit
|
|
172
|
+
const batchPromises = batch.map(async (file) => {
|
|
173
|
+
const fileResults = [];
|
|
174
|
+
try {
|
|
175
|
+
const fullPath = path.join(rootPath, file.path);
|
|
176
|
+
const content = await fs.promises.readFile(fullPath, 'utf-8');
|
|
177
|
+
const lines = content.split('\n');
|
|
178
|
+
// Search each line for the query
|
|
179
|
+
for (let i = 0; i < lines.length; i++) {
|
|
180
|
+
if (fileResults.length >= 10) {
|
|
181
|
+
// Max 10 results per file
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
const line = lines[i];
|
|
185
|
+
if (line && line.toLowerCase().includes(queryLower)) {
|
|
186
|
+
const maxLineLength = Math.max(40, terminalWidth - 10);
|
|
187
|
+
fileResults.push({
|
|
188
|
+
name: file.name,
|
|
189
|
+
path: file.path,
|
|
190
|
+
isDirectory: false,
|
|
191
|
+
lineNumber: i + 1,
|
|
192
|
+
lineContent: line.trim().slice(0, maxLineLength),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
// Skip files that can't be read (binary or encoding issues)
|
|
199
|
+
}
|
|
200
|
+
return fileResults;
|
|
201
|
+
});
|
|
202
|
+
// Wait for batch to complete
|
|
203
|
+
const batchResults = await Promise.all(batchPromises);
|
|
204
|
+
// Flatten and add to results
|
|
205
|
+
for (const fileResults of batchResults) {
|
|
206
|
+
if (results.length >= maxResults) {
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
results.push(...fileResults.slice(0, maxResults - results.length));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return results;
|
|
213
|
+
}, [files, rootPath, terminalWidth]);
|
|
97
214
|
// Load files on mount - only once when visible
|
|
98
215
|
useEffect(() => {
|
|
99
216
|
if (visible && files.length === 0) {
|
|
100
217
|
loadFiles();
|
|
101
218
|
}
|
|
102
219
|
}, [visible, loadFiles]);
|
|
103
|
-
//
|
|
104
|
-
const allFilteredFiles =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
220
|
+
// State for filtered files (needed for async content search)
|
|
221
|
+
const [allFilteredFiles, setAllFilteredFiles] = useState([]);
|
|
222
|
+
// Filter files based on query and search mode with debounce
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
const performSearch = async () => {
|
|
225
|
+
if (!query.trim()) {
|
|
226
|
+
setAllFilteredFiles(files);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (searchMode === 'content') {
|
|
230
|
+
// Content search mode (@@)
|
|
231
|
+
const results = await searchFileContent(query);
|
|
232
|
+
setAllFilteredFiles(results);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
// File name search mode (@)
|
|
236
|
+
const queryLower = query.toLowerCase();
|
|
237
|
+
const filtered = files.filter(file => {
|
|
238
|
+
const fileName = file.name.toLowerCase();
|
|
239
|
+
const filePath = file.path.toLowerCase();
|
|
240
|
+
return (fileName.includes(queryLower) || filePath.includes(queryLower));
|
|
241
|
+
});
|
|
242
|
+
// Sort by relevance (exact name matches first, then path matches)
|
|
243
|
+
filtered.sort((a, b) => {
|
|
244
|
+
const aNameMatch = a.name.toLowerCase().startsWith(queryLower);
|
|
245
|
+
const bNameMatch = b.name.toLowerCase().startsWith(queryLower);
|
|
246
|
+
if (aNameMatch && !bNameMatch)
|
|
247
|
+
return -1;
|
|
248
|
+
if (!aNameMatch && bNameMatch)
|
|
249
|
+
return 1;
|
|
250
|
+
return a.name.localeCompare(b.name);
|
|
251
|
+
});
|
|
252
|
+
setAllFilteredFiles(filtered);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
// Debounce search to avoid excessive updates during fast typing
|
|
256
|
+
// Use shorter delay for file search (150ms) and longer for content search (500ms)
|
|
257
|
+
const debounceDelay = searchMode === 'content' ? 500 : 150;
|
|
258
|
+
const timer = setTimeout(() => {
|
|
259
|
+
performSearch();
|
|
260
|
+
}, debounceDelay);
|
|
261
|
+
return () => clearTimeout(timer);
|
|
262
|
+
}, [files, query, searchMode, searchFileContent]);
|
|
126
263
|
// Display with scrolling window
|
|
127
264
|
const filteredFiles = useMemo(() => {
|
|
128
265
|
if (allFilteredFiles.length <= effectiveMaxItems) {
|
|
@@ -150,7 +287,12 @@ const FileList = memo(forwardRef(({ query, selectedIndex, visible, maxItems = 10
|
|
|
150
287
|
if (allFilteredFiles.length > 0 &&
|
|
151
288
|
selectedIndex < allFilteredFiles.length &&
|
|
152
289
|
allFilteredFiles[selectedIndex]) {
|
|
153
|
-
|
|
290
|
+
const selectedFile = allFilteredFiles[selectedIndex];
|
|
291
|
+
// For content search mode, include line number
|
|
292
|
+
if (selectedFile.lineNumber !== undefined) {
|
|
293
|
+
return `${selectedFile.path}:${selectedFile.lineNumber}`;
|
|
294
|
+
}
|
|
295
|
+
return selectedFile.path;
|
|
154
296
|
}
|
|
155
297
|
return null;
|
|
156
298
|
},
|
|
@@ -167,26 +309,33 @@ const FileList = memo(forwardRef(({ query, selectedIndex, visible, maxItems = 10
|
|
|
167
309
|
return null;
|
|
168
310
|
}
|
|
169
311
|
if (isLoading) {
|
|
170
|
-
return (React.createElement(Box, {
|
|
171
|
-
React.createElement(Text, { color: "blue" }, "Loading files...")));
|
|
312
|
+
return (React.createElement(Box, { paddingX: 1, marginTop: 1 },
|
|
313
|
+
React.createElement(Text, { color: "blue", dimColor: true }, "Loading files...")));
|
|
172
314
|
}
|
|
173
315
|
if (filteredFiles.length === 0) {
|
|
174
|
-
return (React.createElement(Box, {
|
|
175
|
-
React.createElement(Text, { color: "gray" }, "No files found")));
|
|
316
|
+
return (React.createElement(Box, { paddingX: 1, marginTop: 1 },
|
|
317
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "No files found")));
|
|
176
318
|
}
|
|
177
319
|
return (React.createElement(Box, { paddingX: 1, marginTop: 1, flexDirection: "column" },
|
|
178
320
|
React.createElement(Box, { marginBottom: 1 },
|
|
179
321
|
React.createElement(Text, { color: "blue", bold: true },
|
|
180
|
-
|
|
322
|
+
searchMode === 'content' ? '≡ Content Search' : '≡ Files',
|
|
181
323
|
' ',
|
|
182
324
|
allFilteredFiles.length > effectiveMaxItems &&
|
|
183
325
|
`(${selectedIndex + 1}/${allFilteredFiles.length})`)),
|
|
184
|
-
filteredFiles.map((file, index) => (React.createElement(Box, { key: file.path },
|
|
326
|
+
filteredFiles.map((file, index) => (React.createElement(Box, { key: `${file.path}-${file.lineNumber || 0}`, flexDirection: "column" },
|
|
185
327
|
React.createElement(Text, { backgroundColor: index === displaySelectedIndex ? '#1E3A8A' : undefined, color: index === displaySelectedIndex
|
|
186
328
|
? '#FFFFFF'
|
|
187
329
|
: file.isDirectory
|
|
188
330
|
? 'yellow'
|
|
189
|
-
: 'white' },
|
|
331
|
+
: 'white' }, searchMode === 'content' && file.lineNumber !== undefined
|
|
332
|
+
? `${file.path}:${file.lineNumber}`
|
|
333
|
+
: file.isDirectory
|
|
334
|
+
? '◇ ' + file.path
|
|
335
|
+
: '◆ ' + file.path),
|
|
336
|
+
searchMode === 'content' && file.lineContent && (React.createElement(Text, { backgroundColor: index === displaySelectedIndex ? '#1E3A8A' : undefined, color: index === displaySelectedIndex ? '#D1D5DB' : 'gray', dimColor: true },
|
|
337
|
+
' ',
|
|
338
|
+
file.lineContent))))),
|
|
190
339
|
allFilteredFiles.length > effectiveMaxItems && (React.createElement(Box, { marginTop: 1 },
|
|
191
340
|
React.createElement(Text, { color: "gray", dimColor: true },
|
|
192
341
|
"\u2191\u2193 to scroll \u00B7 ",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { TodoItem } from '../../utils/todoScanner.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
todos: TodoItem[];
|
|
5
|
+
selectedIndex: number;
|
|
6
|
+
selectedTodos: Set<string>;
|
|
7
|
+
visible: boolean;
|
|
8
|
+
maxHeight?: number;
|
|
9
|
+
isLoading?: boolean;
|
|
10
|
+
searchQuery?: string;
|
|
11
|
+
totalCount?: number;
|
|
12
|
+
}
|
|
13
|
+
declare const TodoPickerPanel: React.MemoExoticComponent<({ todos, selectedIndex, selectedTodos, visible, maxHeight, isLoading, searchQuery, totalCount, }: Props) => React.JSX.Element | null>;
|
|
14
|
+
export default TodoPickerPanel;
|