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.
- package/README.md +193 -0
- package/bin/otherwise.js +5 -0
- package/frontend/404.html +84 -0
- package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
- package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
- package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
- package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
- package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
- package/frontend/assets/index-BLux5ps4.js +21 -0
- package/frontend/assets/index-Blh8_TEM.js +5272 -0
- package/frontend/assets/index-BpQ1PuKu.js +18 -0
- package/frontend/assets/index-Df737c8w.css +1 -0
- package/frontend/assets/index-xaYHL6wb.js +113 -0
- package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
- package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
- package/frontend/assets/transformers-tULNc5V3.js +31 -0
- package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
- package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
- package/frontend/assets/worker-2d5ABSLU.js +31 -0
- package/frontend/banner.png +0 -0
- package/frontend/favicon.svg +3 -0
- package/frontend/google55e5ec47ee14a5f8.html +1 -0
- package/frontend/index.html +234 -0
- package/frontend/manifest.json +17 -0
- package/frontend/pdf.worker.min.mjs +21 -0
- package/frontend/robots.txt +5 -0
- package/frontend/sitemap.xml +27 -0
- package/package.json +81 -0
- package/src/agent/index.js +1066 -0
- package/src/agent/location.js +51 -0
- package/src/agent/prompt.js +548 -0
- package/src/agent/tools.js +4372 -0
- package/src/browser/detect.js +68 -0
- package/src/browser/session.js +1109 -0
- package/src/config.js +137 -0
- package/src/email/client.js +503 -0
- package/src/index.js +557 -0
- package/src/inference/anthropic.js +113 -0
- package/src/inference/google.js +373 -0
- package/src/inference/index.js +81 -0
- package/src/inference/ollama.js +383 -0
- package/src/inference/openai.js +140 -0
- package/src/inference/openrouter.js +378 -0
- package/src/inference/xai.js +200 -0
- package/src/logBridge.js +9 -0
- package/src/models.js +146 -0
- package/src/remote/client.js +225 -0
- package/src/scheduler/cron.js +243 -0
- package/src/server.js +3876 -0
- package/src/storage/db.js +1135 -0
- package/src/storage/supabase.js +364 -0
- package/src/tunnel/cloudflare.js +241 -0
- package/src/ui/components/App.jsx +687 -0
- package/src/ui/components/BrowserSelect.jsx +111 -0
- package/src/ui/components/FilePicker.jsx +472 -0
- package/src/ui/components/Header.jsx +444 -0
- package/src/ui/components/HelpPanel.jsx +173 -0
- package/src/ui/components/HistoryPanel.jsx +158 -0
- package/src/ui/components/MessageList.jsx +235 -0
- package/src/ui/components/ModelSelector.jsx +304 -0
- package/src/ui/components/PromptInput.jsx +515 -0
- package/src/ui/components/StreamingResponse.jsx +134 -0
- package/src/ui/components/ThinkingIndicator.jsx +365 -0
- package/src/ui/components/ToolExecution.jsx +714 -0
- package/src/ui/components/index.js +82 -0
- package/src/ui/context/TerminalContext.jsx +150 -0
- package/src/ui/context/index.js +13 -0
- package/src/ui/hooks/index.js +16 -0
- package/src/ui/hooks/useChatState.js +675 -0
- package/src/ui/hooks/useCommands.js +280 -0
- package/src/ui/hooks/useFileAttachments.js +216 -0
- package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
- package/src/ui/hooks/useNotifications.js +185 -0
- package/src/ui/hooks/useTerminalSize.js +151 -0
- package/src/ui/hooks/useWebSocket.js +273 -0
- package/src/ui/index.js +94 -0
- package/src/ui/ink-runner.js +22 -0
- package/src/ui/utils/formatters.js +424 -0
- package/src/ui/utils/index.js +6 -0
- 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;
|