otherwise-cli 0.1.0

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.
Files changed (81) hide show
  1. package/README.md +193 -0
  2. package/bin/otherwise.js +5 -0
  3. package/frontend/404.html +84 -0
  4. package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
  5. package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
  6. package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  7. package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
  8. package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
  9. package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
  10. package/frontend/assets/index-BLux5ps4.js +21 -0
  11. package/frontend/assets/index-Blh8_TEM.js +5272 -0
  12. package/frontend/assets/index-BpQ1PuKu.js +18 -0
  13. package/frontend/assets/index-Df737c8w.css +1 -0
  14. package/frontend/assets/index-xaYHL6wb.js +113 -0
  15. package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
  16. package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
  17. package/frontend/assets/transformers-tULNc5V3.js +31 -0
  18. package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
  19. package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
  20. package/frontend/assets/worker-2d5ABSLU.js +31 -0
  21. package/frontend/banner.png +0 -0
  22. package/frontend/favicon.svg +3 -0
  23. package/frontend/google55e5ec47ee14a5f8.html +1 -0
  24. package/frontend/index.html +234 -0
  25. package/frontend/manifest.json +17 -0
  26. package/frontend/pdf.worker.min.mjs +21 -0
  27. package/frontend/robots.txt +5 -0
  28. package/frontend/sitemap.xml +27 -0
  29. package/package.json +81 -0
  30. package/src/agent/index.js +1066 -0
  31. package/src/agent/location.js +51 -0
  32. package/src/agent/prompt.js +548 -0
  33. package/src/agent/tools.js +4372 -0
  34. package/src/browser/detect.js +68 -0
  35. package/src/browser/session.js +1109 -0
  36. package/src/config.js +137 -0
  37. package/src/email/client.js +503 -0
  38. package/src/index.js +557 -0
  39. package/src/inference/anthropic.js +113 -0
  40. package/src/inference/google.js +373 -0
  41. package/src/inference/index.js +81 -0
  42. package/src/inference/ollama.js +383 -0
  43. package/src/inference/openai.js +140 -0
  44. package/src/inference/openrouter.js +378 -0
  45. package/src/inference/xai.js +200 -0
  46. package/src/logBridge.js +9 -0
  47. package/src/models.js +146 -0
  48. package/src/remote/client.js +225 -0
  49. package/src/scheduler/cron.js +243 -0
  50. package/src/server.js +3876 -0
  51. package/src/storage/db.js +1135 -0
  52. package/src/storage/supabase.js +364 -0
  53. package/src/tunnel/cloudflare.js +241 -0
  54. package/src/ui/components/App.jsx +687 -0
  55. package/src/ui/components/BrowserSelect.jsx +111 -0
  56. package/src/ui/components/FilePicker.jsx +472 -0
  57. package/src/ui/components/Header.jsx +444 -0
  58. package/src/ui/components/HelpPanel.jsx +173 -0
  59. package/src/ui/components/HistoryPanel.jsx +158 -0
  60. package/src/ui/components/MessageList.jsx +235 -0
  61. package/src/ui/components/ModelSelector.jsx +304 -0
  62. package/src/ui/components/PromptInput.jsx +515 -0
  63. package/src/ui/components/StreamingResponse.jsx +134 -0
  64. package/src/ui/components/ThinkingIndicator.jsx +365 -0
  65. package/src/ui/components/ToolExecution.jsx +714 -0
  66. package/src/ui/components/index.js +82 -0
  67. package/src/ui/context/TerminalContext.jsx +150 -0
  68. package/src/ui/context/index.js +13 -0
  69. package/src/ui/hooks/index.js +16 -0
  70. package/src/ui/hooks/useChatState.js +675 -0
  71. package/src/ui/hooks/useCommands.js +280 -0
  72. package/src/ui/hooks/useFileAttachments.js +216 -0
  73. package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
  74. package/src/ui/hooks/useNotifications.js +185 -0
  75. package/src/ui/hooks/useTerminalSize.js +151 -0
  76. package/src/ui/hooks/useWebSocket.js +273 -0
  77. package/src/ui/index.js +94 -0
  78. package/src/ui/ink-runner.js +22 -0
  79. package/src/ui/utils/formatters.js +424 -0
  80. package/src/ui/utils/index.js +6 -0
  81. package/src/ui/utils/markdown.js +166 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Terminal browser selector for CLI automation.
3
+ * Fetches /api/browsers/detect, lets user pick one, saves via PUT /api/config.
4
+ */
5
+
6
+ import React, { useState, useEffect, useCallback } from 'react';
7
+ import { Box, Text, useInput } from 'ink';
8
+ import { config } from '../../config.js';
9
+
10
+ export function BrowserSelect({ serverUrl, onSelect, onCancel, isVisible }) {
11
+ const [browsers, setBrowsers] = useState([]);
12
+ const [loading, setLoading] = useState(true);
13
+ const [error, setError] = useState(null);
14
+ const [selectedIndex, setSelectedIndex] = useState(0);
15
+
16
+ useEffect(() => {
17
+ if (!isVisible || !serverUrl) return;
18
+ setLoading(true);
19
+ setError(null);
20
+ fetch(`${serverUrl}/api/browsers/detect`)
21
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error('Failed to detect'))))
22
+ .then((data) => {
23
+ const list = Array.isArray(data?.browsers) ? data.browsers : [];
24
+ setBrowsers(list);
25
+ setSelectedIndex(0);
26
+ })
27
+ .catch((e) => setError(e?.message || 'Could not load browsers'))
28
+ .finally(() => setLoading(false));
29
+ }, [isVisible, serverUrl]);
30
+
31
+ const handleSelect = useCallback(() => {
32
+ const b = browsers[selectedIndex];
33
+ if (!b) return;
34
+ fetch(`${serverUrl}/api/config`, {
35
+ method: 'PUT',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ browserChannel: b.id }),
38
+ })
39
+ .then((r) => {
40
+ if (!r.ok) throw new Error('Failed to save');
41
+ config.set('browserChannel', b.id);
42
+ onSelect?.(b.id);
43
+ })
44
+ .catch((e) => setError(e?.message || 'Could not save'));
45
+ }, [serverUrl, browsers, selectedIndex, onSelect]);
46
+
47
+ useInput(
48
+ (input, key) => {
49
+ if (!isVisible) return;
50
+ if (key.upArrow) {
51
+ setSelectedIndex((i) => (i <= 0 ? browsers.length - 1 : i - 1));
52
+ return;
53
+ }
54
+ if (key.downArrow) {
55
+ setSelectedIndex((i) => (i >= browsers.length - 1 ? 0 : i + 1));
56
+ return;
57
+ }
58
+ if (key.return) {
59
+ if (browsers.length > 0) handleSelect();
60
+ return;
61
+ }
62
+ if (key.escape) {
63
+ onCancel?.();
64
+ }
65
+ },
66
+ { isActive: isVisible }
67
+ );
68
+
69
+ if (!isVisible) return null;
70
+
71
+ return (
72
+ <Box flexDirection="column" paddingY={1}>
73
+ <Text color="cyan" bold>
74
+ Select browser for automation
75
+ </Text>
76
+ <Text dimColor>
77
+ Used for browser tools and web search. Arrow keys to move, Enter to select, Esc to cancel.
78
+ </Text>
79
+ {loading && (
80
+ <Box marginTop={1}>
81
+ <Text dimColor>Detecting browsers…</Text>
82
+ </Box>
83
+ )}
84
+ {error && (
85
+ <Box marginTop={1}>
86
+ <Text color="red">{error}</Text>
87
+ </Box>
88
+ )}
89
+ {!loading && browsers.length > 0 && (
90
+ <Box flexDirection="column" marginTop={1}>
91
+ {browsers.map((b, i) => (
92
+ <Box key={b.id}>
93
+ <Text color={i === selectedIndex ? 'cyan' : undefined}>
94
+ {i === selectedIndex ? '❯ ' : ' '}
95
+ {b.name}
96
+ {b.available === false ? ' (may not be installed)' : ''}
97
+ </Text>
98
+ </Box>
99
+ ))}
100
+ </Box>
101
+ )}
102
+ {!loading && browsers.length === 0 && !error && (
103
+ <Text dimColor marginTop={1}>
104
+ No browsers detected.
105
+ </Text>
106
+ )}
107
+ </Box>
108
+ );
109
+ }
110
+
111
+ export default BrowserSelect;
@@ -0,0 +1,472 @@
1
+ /**
2
+ * FilePicker component
3
+ * Interactive file browser using Ink
4
+ * Now also shows RAG documents for @ mentions
5
+ *
6
+ * Responsive: adapts to terminal width
7
+ */
8
+
9
+ import React, { useState, useEffect, useCallback } from 'react';
10
+ import { Box, Text, useInput, useFocus } from 'ink';
11
+ import { useTerminal } from '../context/TerminalContext.jsx';
12
+ import { truncatePath } from '../utils/formatters.js';
13
+
14
+ /**
15
+ * RAG Document item
16
+ */
17
+ function RagDocumentItem({ doc, isSelected }) {
18
+ const prefix = isSelected ? '❯ ' : ' ';
19
+
20
+ return (
21
+ <Box>
22
+ <Text color={isSelected ? 'cyan' : undefined}>{prefix}</Text>
23
+ <Text>📚 </Text>
24
+ {isSelected ? (
25
+ <Text color="cyan" bold>@{doc.name}</Text>
26
+ ) : (
27
+ <Text color="#10b981">@{doc.name}</Text>
28
+ )}
29
+ <Text dimColor> ({doc.chunkCount || doc.chunk_count} chunks)</Text>
30
+ </Box>
31
+ );
32
+ }
33
+
34
+ /**
35
+ * File type icon
36
+ */
37
+ function FileIcon({ file }) {
38
+ if (file.isDirectory) {
39
+ return <Text color="yellow">📁</Text>;
40
+ }
41
+
42
+ const typeIcons = {
43
+ javascript: <Text color="yellow"></Text>,
44
+ typescript: <Text color="blue"></Text>,
45
+ python: <Text color="green">🐍</Text>,
46
+ json: <Text color="#f59e0b">{'{}'}</Text>,
47
+ markdown: <Text dimColor>📝</Text>,
48
+ html: <Text color="red">🌐</Text>,
49
+ css: <Text color="blue">🎨</Text>,
50
+ };
51
+
52
+ return typeIcons[file.type] || <Text dimColor>📄</Text>;
53
+ }
54
+
55
+ /**
56
+ * File list item
57
+ */
58
+ function FileItem({ file, isSelected, showPath = false }) {
59
+ const prefix = isSelected ? '❯ ' : ' ';
60
+
61
+ let nameDisplay;
62
+ if (file.isDirectory) {
63
+ nameDisplay = isSelected
64
+ ? <Text color="cyan" bold>{file.name}/</Text>
65
+ : <Text color="yellow">{file.name}/</Text>;
66
+ } else {
67
+ nameDisplay = isSelected
68
+ ? <Text color="cyan" bold>{file.name}</Text>
69
+ : <Text>{file.name}</Text>;
70
+ }
71
+
72
+ return (
73
+ <Box>
74
+ <Text color={isSelected ? 'cyan' : undefined}>{prefix}</Text>
75
+ <FileIcon file={file} />
76
+ <Text> </Text>
77
+ {nameDisplay}
78
+ {showPath && file.path !== file.name && (
79
+ <Text dimColor> {file.path}</Text>
80
+ )}
81
+ </Box>
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Go up directory item
87
+ */
88
+ function GoUpItem({ isSelected }) {
89
+ const prefix = isSelected ? '❯ ' : ' ';
90
+
91
+ return (
92
+ <Box>
93
+ <Text color={isSelected ? 'cyan' : 'gray'}>{prefix}</Text>
94
+ <Text color={isSelected ? 'cyan' : 'gray'} bold>..</Text>
95
+ <Text dimColor> (Go up)</Text>
96
+ </Box>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * FilePicker component
102
+ * Responsive: adapts layout and visible items to terminal size
103
+ */
104
+ export function FilePicker({
105
+ serverUrl,
106
+ onSelect,
107
+ onCancel,
108
+ isVisible = true,
109
+ }) {
110
+ const { rows, columns, isNarrow, useCompactMode } = useTerminal();
111
+ const [files, setFiles] = useState([]);
112
+ const [ragDocuments, setRagDocuments] = useState([]);
113
+ const [currentPath, setCurrentPath] = useState('');
114
+ const [workspaceRoot, setWorkspaceRoot] = useState('');
115
+ const [selectedIndex, setSelectedIndex] = useState(0);
116
+ const [filter, setFilter] = useState('');
117
+ const [isLoading, setIsLoading] = useState(false);
118
+ const [error, setError] = useState(null);
119
+ const [scrollOffset, setScrollOffset] = useState(0);
120
+
121
+ // Responsive: adjust visible items based on terminal height
122
+ const maxVisible = Math.max(5, Math.min(15, rows - 10));
123
+ const maxPathLength = Math.max(20, columns - 20);
124
+ const { isFocused } = useFocus({ autoFocus: true, isActive: isVisible });
125
+
126
+ // Fetch RAG documents from server
127
+ const fetchRagDocuments = useCallback(async () => {
128
+ try {
129
+ const response = await fetch(`${serverUrl}/api/rag/documents`);
130
+ if (response.ok) {
131
+ const result = await response.json();
132
+ if (result.success && result.documents) {
133
+ setRagDocuments(result.documents);
134
+ }
135
+ }
136
+ } catch (err) {
137
+ // Silently fail - RAG documents are optional
138
+ console.log('[FilePicker] Could not fetch RAG documents:', err.message);
139
+ }
140
+ }, [serverUrl]);
141
+
142
+ // Fetch files from server
143
+ const fetchFiles = useCallback(async (path = '', query = '') => {
144
+ setIsLoading(true);
145
+ setError(null);
146
+
147
+ try {
148
+ let url;
149
+ if (query && query.length > 0) {
150
+ url = `${serverUrl}/api/files/search?query=${encodeURIComponent(query)}&maxResults=30`;
151
+ } else {
152
+ const params = new URLSearchParams();
153
+ if (path) params.set('path', path);
154
+ url = `${serverUrl}/api/files?${params.toString()}`;
155
+ }
156
+
157
+ const response = await fetch(url);
158
+
159
+ if (!response.ok) {
160
+ setError(`Server error: ${response.status}`);
161
+ setFiles([]);
162
+ return;
163
+ }
164
+
165
+ const result = await response.json();
166
+
167
+ if (result.success === false) {
168
+ setError(result.error || 'Unknown error');
169
+ setFiles([]);
170
+ return;
171
+ }
172
+
173
+ if (query) {
174
+ setFiles(result.results || []);
175
+ setWorkspaceRoot(result.workspaceRoot || '');
176
+ } else {
177
+ setFiles(result.items || []);
178
+ setCurrentPath(result.path || '');
179
+ setWorkspaceRoot(result.workspaceRoot || '');
180
+ }
181
+
182
+ // Set initial selection to first RAG doc if available, otherwise first file
183
+ const ragDocsCount = ragDocuments.length;
184
+ const hasGoUp = currentPath && currentPath !== '.';
185
+ setSelectedIndex(ragDocsCount > 0 ? 0 : (hasGoUp ? 1 : 0));
186
+ setScrollOffset(0);
187
+ } catch (err) {
188
+ setError(`Connection error: ${err.message}`);
189
+ setFiles([]);
190
+ } finally {
191
+ setIsLoading(false);
192
+ }
193
+ }, [serverUrl, currentPath, ragDocuments.length]);
194
+
195
+ // Initial fetch - both RAG documents and files
196
+ useEffect(() => {
197
+ if (isVisible) {
198
+ fetchRagDocuments();
199
+ fetchFiles();
200
+ }
201
+ }, [isVisible, fetchRagDocuments, fetchFiles]);
202
+
203
+ // Filter files
204
+ const filteredFiles = filter
205
+ ? files.filter(f =>
206
+ f.name.toLowerCase().includes(filter.toLowerCase()) ||
207
+ f.path.toLowerCase().includes(filter.toLowerCase())
208
+ )
209
+ : files;
210
+
211
+ // Filter RAG documents
212
+ const filteredRagDocs = filter
213
+ ? ragDocuments.filter(d =>
214
+ d.name.toLowerCase().includes(filter.toLowerCase())
215
+ )
216
+ : ragDocuments;
217
+
218
+ // Has go up option
219
+ const hasGoUp = currentPath && currentPath !== '.';
220
+ // Total items: RAG docs + go up option + files
221
+ const totalItems = filteredRagDocs.length + (hasGoUp ? 1 : 0) + filteredFiles.length;
222
+
223
+ // Handle keyboard input
224
+ useInput((input, key) => {
225
+ if (!isVisible || !isFocused) return;
226
+
227
+ // Navigation
228
+ if (key.upArrow) {
229
+ setSelectedIndex(prev => Math.max(0, prev - 1));
230
+ return;
231
+ }
232
+
233
+ if (key.downArrow) {
234
+ setSelectedIndex(prev => Math.min(totalItems - 1, prev + 1));
235
+ return;
236
+ }
237
+
238
+ // Escape to cancel
239
+ if (key.escape) {
240
+ onCancel?.();
241
+ return;
242
+ }
243
+
244
+ // Enter to select/navigate
245
+ if (key.return) {
246
+ // Check if selecting a RAG document
247
+ if (selectedIndex < filteredRagDocs.length) {
248
+ const selectedDoc = filteredRagDocs[selectedIndex];
249
+ onSelect?.({
250
+ path: `@${selectedDoc.name}`,
251
+ name: selectedDoc.name,
252
+ isRagDocument: true,
253
+ ragDocId: selectedDoc.id,
254
+ });
255
+ return;
256
+ }
257
+
258
+ // Adjust index for files (after RAG docs)
259
+ const adjustedIndex = selectedIndex - filteredRagDocs.length;
260
+
261
+ // Go up
262
+ if (hasGoUp && adjustedIndex === 0) {
263
+ const parentPath = currentPath.split('/').slice(0, -1).join('/') || '';
264
+ setCurrentPath(parentPath);
265
+ setFilter('');
266
+ fetchFiles(parentPath);
267
+ return;
268
+ }
269
+
270
+ const itemIndex = hasGoUp ? adjustedIndex - 1 : adjustedIndex;
271
+ const selectedFile = filteredFiles[itemIndex];
272
+
273
+ if (!selectedFile) return;
274
+
275
+ if (selectedFile.isDirectory) {
276
+ // Navigate into folder
277
+ setCurrentPath(selectedFile.path);
278
+ setFilter('');
279
+ fetchFiles(selectedFile.path);
280
+ } else {
281
+ // Select file
282
+ onSelect?.({
283
+ path: selectedFile.path,
284
+ name: selectedFile.name,
285
+ isFolder: false,
286
+ });
287
+ }
288
+ return;
289
+ }
290
+
291
+ // Tab to select folder with contents (or RAG document)
292
+ if (key.tab) {
293
+ // If on RAG document, same as Enter
294
+ if (selectedIndex < filteredRagDocs.length) {
295
+ const selectedDoc = filteredRagDocs[selectedIndex];
296
+ onSelect?.({
297
+ path: `@${selectedDoc.name}`,
298
+ name: selectedDoc.name,
299
+ isRagDocument: true,
300
+ ragDocId: selectedDoc.id,
301
+ });
302
+ return;
303
+ }
304
+
305
+ const adjustedIndex = selectedIndex - filteredRagDocs.length;
306
+ if (hasGoUp && adjustedIndex === 0) return;
307
+
308
+ const itemIndex = hasGoUp ? adjustedIndex - 1 : adjustedIndex;
309
+ const selectedFile = filteredFiles[itemIndex];
310
+
311
+ if (selectedFile?.isDirectory) {
312
+ onSelect?.({
313
+ path: selectedFile.path + '/',
314
+ name: selectedFile.name,
315
+ isFolder: true,
316
+ });
317
+ }
318
+ return;
319
+ }
320
+
321
+ // Backspace to go up or clear filter
322
+ if (key.backspace || key.delete) {
323
+ if (filter.length > 0) {
324
+ setFilter(prev => prev.slice(0, -1));
325
+ setSelectedIndex(hasGoUp ? 1 : 0);
326
+ } else if (hasGoUp) {
327
+ const parentPath = currentPath.split('/').slice(0, -1).join('/') || '';
328
+ setCurrentPath(parentPath);
329
+ fetchFiles(parentPath);
330
+ } else {
331
+ onCancel?.();
332
+ }
333
+ return;
334
+ }
335
+
336
+ // Type to filter
337
+ if (input && input.length === 1 && input >= ' ' && input <= '~') {
338
+ const newFilter = filter + input;
339
+ setFilter(newFilter);
340
+ setSelectedIndex(hasGoUp ? 1 : 0);
341
+
342
+ // Server-side search for longer filters
343
+ if (newFilter.length >= 2) {
344
+ fetchFiles('', newFilter);
345
+ }
346
+ }
347
+ }, { isActive: isVisible && isFocused });
348
+
349
+ // Adjust scroll offset
350
+ useEffect(() => {
351
+ if (selectedIndex < scrollOffset) {
352
+ setScrollOffset(selectedIndex);
353
+ } else if (selectedIndex >= scrollOffset + maxVisible) {
354
+ setScrollOffset(selectedIndex - maxVisible + 1);
355
+ }
356
+ }, [selectedIndex, scrollOffset]);
357
+
358
+ if (!isVisible) return null;
359
+
360
+ // Calculate visible items
361
+ const visibleFiles = filteredFiles.slice(scrollOffset, scrollOffset + maxVisible);
362
+
363
+ // Calculate responsive path display
364
+ const displayPath = currentPath
365
+ ? truncatePath(`/${currentPath}`, maxPathLength)
366
+ : workspaceRoot
367
+ ? `Workspace: ${workspaceRoot.split('/').pop()}`
368
+ : 'Select file or folder';
369
+
370
+ return (
371
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" padding={1}>
372
+ {/* Header */}
373
+ <Box marginBottom={1}>
374
+ <Text color="cyan">📂 </Text>
375
+ <Text dimColor wrap="truncate">{displayPath}</Text>
376
+ {filter && (
377
+ <Text color="yellow"> (filter: "{filter}")</Text>
378
+ )}
379
+ {isLoading && (
380
+ <Text dimColor> Loading...</Text>
381
+ )}
382
+ </Box>
383
+
384
+ {/* Navigation hints - shorter on narrow terminals */}
385
+ <Box marginBottom={1}>
386
+ {useCompactMode ? (
387
+ <Text dimColor>↑↓ Enter Tab Esc</Text>
388
+ ) : isNarrow ? (
389
+ <Text dimColor>↑↓ navigate • Enter • Tab folder • Esc</Text>
390
+ ) : (
391
+ <Text dimColor>↑↓ navigate • Enter select • Tab folder contents • Esc cancel</Text>
392
+ )}
393
+ </Box>
394
+
395
+ {/* Error display */}
396
+ {error && (
397
+ <Box>
398
+ <Text color="red">Error: {error}</Text>
399
+ </Box>
400
+ )}
401
+
402
+ {/* RAG Documents section */}
403
+ {filteredRagDocs.length > 0 && (
404
+ <Box flexDirection="column" marginBottom={1}>
405
+ <Box>
406
+ <Text color="#10b981" bold>📚 Documents (type @name to mention)</Text>
407
+ </Box>
408
+ {filteredRagDocs.map((doc, i) => (
409
+ <RagDocumentItem
410
+ key={doc.id}
411
+ doc={doc}
412
+ isSelected={selectedIndex === i}
413
+ />
414
+ ))}
415
+ </Box>
416
+ )}
417
+
418
+ {/* Separator if we have both RAG docs and files */}
419
+ {filteredRagDocs.length > 0 && filteredFiles.length > 0 && (
420
+ <Box marginBottom={1}>
421
+ <Text dimColor>─── Files ───</Text>
422
+ </Box>
423
+ )}
424
+
425
+ {/* Scroll up indicator */}
426
+ {scrollOffset > 0 && (
427
+ <Box>
428
+ <Text dimColor> ↑ more files above...</Text>
429
+ </Box>
430
+ )}
431
+
432
+ {/* Go up option */}
433
+ {hasGoUp && scrollOffset === 0 && (
434
+ <GoUpItem isSelected={selectedIndex === filteredRagDocs.length} />
435
+ )}
436
+
437
+ {/* File list */}
438
+ {filteredFiles.length === 0 && filteredRagDocs.length === 0 && !isLoading && (
439
+ <Box>
440
+ <Text dimColor> No files or documents found</Text>
441
+ </Box>
442
+ )}
443
+
444
+ {visibleFiles.map((file, i) => {
445
+ // Adjust index to account for RAG docs
446
+ const actualIndex = filteredRagDocs.length + (hasGoUp ? 1 : 0) + scrollOffset + i;
447
+ return (
448
+ <FileItem
449
+ key={file.path}
450
+ file={file}
451
+ isSelected={selectedIndex === actualIndex}
452
+ showPath={filter.length > 0}
453
+ />
454
+ );
455
+ })}
456
+
457
+ {/* Scroll down indicator */}
458
+ {scrollOffset + maxVisible < filteredFiles.length && (
459
+ <Box>
460
+ <Text dimColor> ↓ more files below...</Text>
461
+ </Box>
462
+ )}
463
+
464
+ {/* Footer */}
465
+ <Box marginTop={1}>
466
+ <Text dimColor>{filteredFiles.length} items</Text>
467
+ </Box>
468
+ </Box>
469
+ );
470
+ }
471
+
472
+ export default FilePicker;