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.
@@ -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, 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
+ 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 and paste placeholders
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的内部文本而不是getFullText(),这样可以显示占位符
211
+ // 使用buffer的内部文本,将占位符当作普通文本处理
188
212
  const displayText = buffer.text;
189
213
  const cursorPos = buffer.getCursorPosition();
190
- const hasPastePlaceholder = displayText.includes('[Paste ') &&
191
- displayText.includes(' characters #');
192
- const hasImagePlaceholder = displayText.includes('[image #');
193
- const focusTokenPattern = /(\x1b)?\[[IO]/g;
194
- const cleanedText = displayText.replace(focusTokenPattern, '').trim();
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
- ? 'Type to filter files • Tab/Enter to select • ESC to cancel'
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
  }
@@ -6,6 +6,7 @@ type Props = {
6
6
  maxItems?: number;
7
7
  rootPath?: string;
8
8
  onFilteredCountChange?: (count: number) => void;
9
+ searchMode?: 'file' | 'content';
9
10
  };
10
11
  export type FileListRef = {
11
12
  getSelectedFile: () => string | null;
@@ -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
- const FileList = memo(forwardRef(({ query, selectedIndex, visible, maxItems = 10, rootPath = process.cwd(), onFilteredCountChange, }, ref) => {
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
- // Filter files based on query (no limit here, we'll slice for display)
104
- const allFilteredFiles = useMemo(() => {
105
- if (!query.trim()) {
106
- return files;
107
- }
108
- const queryLower = query.toLowerCase();
109
- const filtered = files.filter(file => {
110
- const fileName = file.name.toLowerCase();
111
- const filePath = file.path.toLowerCase();
112
- return fileName.includes(queryLower) || filePath.includes(queryLower);
113
- });
114
- // Sort by relevance (exact name matches first, then path matches)
115
- filtered.sort((a, b) => {
116
- const aNameMatch = a.name.toLowerCase().startsWith(queryLower);
117
- const bNameMatch = b.name.toLowerCase().startsWith(queryLower);
118
- if (aNameMatch && !bNameMatch)
119
- return -1;
120
- if (!aNameMatch && bNameMatch)
121
- return 1;
122
- return a.name.localeCompare(b.name);
123
- });
124
- return filtered;
125
- }, [files, query]);
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
- return allFilteredFiles[selectedIndex].path;
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, { borderStyle: "round", borderColor: "blue", paddingX: 1, marginTop: 1 },
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, { borderStyle: "round", borderColor: "gray", paddingX: 1, marginTop: 1 },
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
- "\u2261 Files",
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' }, file.isDirectory ? '' + file.path : '◆ ' + file.path)))),
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;