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,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PromptInput component
|
|
3
|
+
* Text input with @ detection, history, autocomplete, and multi-line support
|
|
4
|
+
* Uses a custom input implementation to avoid re-render issues with ink-text-input
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
|
8
|
+
import { Box, Text, useInput } from 'ink';
|
|
9
|
+
import { FileType } from '../hooks/useFileAttachments.js';
|
|
10
|
+
import { COMMANDS } from '../hooks/useCommands.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Estimate token count from text (rough approximation)
|
|
14
|
+
* ~4 characters per token for English text
|
|
15
|
+
*/
|
|
16
|
+
function estimateTokens(text) {
|
|
17
|
+
if (!text) return 0;
|
|
18
|
+
return Math.ceil(text.length / 4);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Format token count for display
|
|
23
|
+
*/
|
|
24
|
+
function formatTokenEstimate(tokens) {
|
|
25
|
+
if (tokens < 1000) return `~${tokens} tokens`;
|
|
26
|
+
return `~${(tokens / 1000).toFixed(1)}k tokens`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* File chip display for attached files with animation
|
|
31
|
+
*/
|
|
32
|
+
export function FileChip({ file, onRemove, isNew = false }) {
|
|
33
|
+
const [highlight, setHighlight] = useState(isNew);
|
|
34
|
+
const icon = file.type === FileType.FOLDER ? '📁' : '📄';
|
|
35
|
+
const info = file.type === FileType.FOLDER
|
|
36
|
+
? `${file.fileCount} files`
|
|
37
|
+
: `${file.lineCount} lines`;
|
|
38
|
+
|
|
39
|
+
// Flash effect for newly attached files
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!isNew) return;
|
|
42
|
+
const timer = setTimeout(() => setHighlight(false), 500);
|
|
43
|
+
return () => clearTimeout(timer);
|
|
44
|
+
}, [isNew]);
|
|
45
|
+
|
|
46
|
+
const bgColor = highlight ? '#1e3a5f' : undefined;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Box>
|
|
50
|
+
<Text color="#374151">├─ </Text>
|
|
51
|
+
<Text>{icon} </Text>
|
|
52
|
+
<Text color={highlight ? '#60a5fa' : '#06b6d4'} bold={highlight}>
|
|
53
|
+
{file.name || file.path.split('/').pop()}
|
|
54
|
+
</Text>
|
|
55
|
+
<Text dimColor> ({info})</Text>
|
|
56
|
+
{onRemove && (
|
|
57
|
+
<Text color="#6b7280"> ×</Text>
|
|
58
|
+
)}
|
|
59
|
+
</Box>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* File chips container with summary
|
|
65
|
+
*/
|
|
66
|
+
export function FileChips({ files, onRemove }) {
|
|
67
|
+
if (!files || files.length === 0) return null;
|
|
68
|
+
|
|
69
|
+
const totalLines = files.reduce((sum, f) => sum + (f.lineCount || 0), 0);
|
|
70
|
+
const tokenEstimate = files.reduce((sum, f) => {
|
|
71
|
+
return sum + estimateTokens(f.content || '');
|
|
72
|
+
}, 0);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
76
|
+
<Box>
|
|
77
|
+
<Text color="#374151">╭─ </Text>
|
|
78
|
+
<Text color="#06b6d4" bold>📎 Attached Context</Text>
|
|
79
|
+
<Text dimColor> ({files.length} item{files.length !== 1 ? 's' : ''}, {totalLines.toLocaleString()} lines)</Text>
|
|
80
|
+
</Box>
|
|
81
|
+
{files.map((file, index) => (
|
|
82
|
+
<FileChip
|
|
83
|
+
key={file.path || index}
|
|
84
|
+
file={file}
|
|
85
|
+
onRemove={onRemove ? () => onRemove(file.path) : null}
|
|
86
|
+
/>
|
|
87
|
+
))}
|
|
88
|
+
<Box>
|
|
89
|
+
<Text color="#374151">╰─ </Text>
|
|
90
|
+
<Text dimColor>{formatTokenEstimate(tokenEstimate)}</Text>
|
|
91
|
+
</Box>
|
|
92
|
+
</Box>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Command autocomplete suggestions
|
|
98
|
+
* Memoized to prevent unnecessary re-renders
|
|
99
|
+
*/
|
|
100
|
+
const CommandAutocomplete = React.memo(function CommandAutocomplete({ input, onSelect }) {
|
|
101
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
102
|
+
|
|
103
|
+
const suggestions = useMemo(() => {
|
|
104
|
+
if (!input.startsWith('/') || input.length > 15) return [];
|
|
105
|
+
|
|
106
|
+
const query = input.slice(1).toLowerCase();
|
|
107
|
+
|
|
108
|
+
return Object.entries(COMMANDS)
|
|
109
|
+
.filter(([name, cmd]) => {
|
|
110
|
+
return name.startsWith(query) ||
|
|
111
|
+
cmd.aliases.some(a => a.startsWith(query));
|
|
112
|
+
})
|
|
113
|
+
.slice(0, 5)
|
|
114
|
+
.map(([name, cmd]) => ({
|
|
115
|
+
name,
|
|
116
|
+
usage: cmd.usage,
|
|
117
|
+
description: cmd.description,
|
|
118
|
+
}));
|
|
119
|
+
}, [input]);
|
|
120
|
+
|
|
121
|
+
const hasSuggestions = suggestions.length > 0;
|
|
122
|
+
|
|
123
|
+
// Only listen for input when there are suggestions
|
|
124
|
+
useInput((inp, key) => {
|
|
125
|
+
if (key.upArrow) {
|
|
126
|
+
setSelectedIndex(i => Math.max(0, i - 1));
|
|
127
|
+
} else if (key.downArrow) {
|
|
128
|
+
setSelectedIndex(i => Math.min(suggestions.length - 1, i + 1));
|
|
129
|
+
} else if (key.tab) {
|
|
130
|
+
onSelect?.(suggestions[selectedIndex]);
|
|
131
|
+
}
|
|
132
|
+
}, { isActive: hasSuggestions });
|
|
133
|
+
|
|
134
|
+
if (!hasSuggestions) return null;
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Box flexDirection="column" marginLeft={5} marginBottom={1}>
|
|
138
|
+
<Box>
|
|
139
|
+
<Text color="#374151">╭─ </Text>
|
|
140
|
+
<Text dimColor>Commands</Text>
|
|
141
|
+
</Box>
|
|
142
|
+
{suggestions.map((cmd, i) => (
|
|
143
|
+
<Box key={cmd.name}>
|
|
144
|
+
<Text color="#374151">│ </Text>
|
|
145
|
+
<Text color={i === selectedIndex ? '#06b6d4' : '#6b7280'}>
|
|
146
|
+
{i === selectedIndex ? '❯ ' : ' '}
|
|
147
|
+
</Text>
|
|
148
|
+
<Text color={i === selectedIndex ? '#06b6d4' : '#9ca3af'} bold={i === selectedIndex}>
|
|
149
|
+
{cmd.usage}
|
|
150
|
+
</Text>
|
|
151
|
+
<Text dimColor> - {cmd.description}</Text>
|
|
152
|
+
</Box>
|
|
153
|
+
))}
|
|
154
|
+
<Box>
|
|
155
|
+
<Text color="#374151">╰─ </Text>
|
|
156
|
+
<Text dimColor>Tab to complete</Text>
|
|
157
|
+
</Box>
|
|
158
|
+
</Box>
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Input history hook
|
|
164
|
+
*/
|
|
165
|
+
export function useInputHistory(maxSize = 50) {
|
|
166
|
+
const [history, setHistory] = useState([]);
|
|
167
|
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
168
|
+
const [tempInput, setTempInput] = useState('');
|
|
169
|
+
|
|
170
|
+
const addToHistory = useCallback((input) => {
|
|
171
|
+
if (!input.trim()) return;
|
|
172
|
+
|
|
173
|
+
setHistory(prev => {
|
|
174
|
+
// Remove duplicates and add to front
|
|
175
|
+
const filtered = prev.filter(h => h !== input);
|
|
176
|
+
return [input, ...filtered].slice(0, maxSize);
|
|
177
|
+
});
|
|
178
|
+
setHistoryIndex(-1);
|
|
179
|
+
}, [maxSize]);
|
|
180
|
+
|
|
181
|
+
const navigateHistory = useCallback((direction, currentInput) => {
|
|
182
|
+
if (history.length === 0) return currentInput;
|
|
183
|
+
|
|
184
|
+
let newIndex = historyIndex;
|
|
185
|
+
|
|
186
|
+
if (direction === 'up') {
|
|
187
|
+
if (historyIndex === -1) {
|
|
188
|
+
setTempInput(currentInput);
|
|
189
|
+
}
|
|
190
|
+
newIndex = Math.min(historyIndex + 1, history.length - 1);
|
|
191
|
+
} else if (direction === 'down') {
|
|
192
|
+
newIndex = Math.max(historyIndex - 1, -1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
setHistoryIndex(newIndex);
|
|
196
|
+
|
|
197
|
+
if (newIndex === -1) {
|
|
198
|
+
return tempInput;
|
|
199
|
+
}
|
|
200
|
+
return history[newIndex];
|
|
201
|
+
}, [history, historyIndex, tempInput]);
|
|
202
|
+
|
|
203
|
+
const resetNavigation = useCallback(() => {
|
|
204
|
+
setHistoryIndex(-1);
|
|
205
|
+
setTempInput('');
|
|
206
|
+
}, []);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
history,
|
|
210
|
+
historyIndex,
|
|
211
|
+
addToHistory,
|
|
212
|
+
navigateHistory,
|
|
213
|
+
resetNavigation,
|
|
214
|
+
isNavigating: historyIndex !== -1,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Token estimate display
|
|
220
|
+
*/
|
|
221
|
+
export function TokenEstimate({ text, attachedFiles = [] }) {
|
|
222
|
+
const textTokens = estimateTokens(text);
|
|
223
|
+
const fileTokens = attachedFiles.reduce((sum, f) => {
|
|
224
|
+
return sum + estimateTokens(f.content || '');
|
|
225
|
+
}, 0);
|
|
226
|
+
const totalTokens = textTokens + fileTokens;
|
|
227
|
+
|
|
228
|
+
if (totalTokens < 10) return null;
|
|
229
|
+
|
|
230
|
+
// Color based on token count
|
|
231
|
+
let color = '#6b7280';
|
|
232
|
+
if (totalTokens > 4000) color = '#f59e0b';
|
|
233
|
+
if (totalTokens > 8000) color = '#ef4444';
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<Box>
|
|
237
|
+
<Text color={color}>{formatTokenEstimate(totalTokens)}</Text>
|
|
238
|
+
</Box>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Multi-line input indicator
|
|
244
|
+
*/
|
|
245
|
+
export function MultilineIndicator({ lineCount }) {
|
|
246
|
+
if (lineCount <= 1) return null;
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<Box marginLeft={5}>
|
|
250
|
+
<Text dimColor>({lineCount} lines)</Text>
|
|
251
|
+
</Box>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Blinking cursor component
|
|
257
|
+
*/
|
|
258
|
+
function BlinkingCursor() {
|
|
259
|
+
const [visible, setVisible] = useState(true);
|
|
260
|
+
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
const timer = setInterval(() => {
|
|
263
|
+
setVisible(v => !v);
|
|
264
|
+
}, 530); // Standard cursor blink rate
|
|
265
|
+
return () => clearInterval(timer);
|
|
266
|
+
}, []);
|
|
267
|
+
|
|
268
|
+
return <Text color="#a855f7">{visible ? '▋' : ' '}</Text>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Simple display component for the input value (no internal state updates)
|
|
273
|
+
*/
|
|
274
|
+
const InputDisplay = React.memo(function InputDisplay({ value, placeholder, showCursor }) {
|
|
275
|
+
return (
|
|
276
|
+
<Text>
|
|
277
|
+
{value ? (
|
|
278
|
+
<Text>{value}</Text>
|
|
279
|
+
) : (
|
|
280
|
+
<Text dimColor>{placeholder}</Text>
|
|
281
|
+
)}
|
|
282
|
+
{showCursor && <BlinkingCursor />}
|
|
283
|
+
</Text>
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Main prompt input component with history, autocomplete, and token estimation
|
|
289
|
+
* Uses a single useInput hook with batched updates to avoid re-render issues
|
|
290
|
+
*/
|
|
291
|
+
export const PromptInput = React.memo(function PromptInput({
|
|
292
|
+
onSubmit,
|
|
293
|
+
onAtTrigger,
|
|
294
|
+
placeholder = 'Type your message...',
|
|
295
|
+
disabled = false,
|
|
296
|
+
attachedFiles = [],
|
|
297
|
+
onDetachFile,
|
|
298
|
+
showTokenEstimate = true,
|
|
299
|
+
enableHistory = true,
|
|
300
|
+
}) {
|
|
301
|
+
// Use ref for the actual input buffer to avoid re-renders on every keystroke
|
|
302
|
+
const inputBuffer = useRef('');
|
|
303
|
+
// Display value is updated less frequently (batched)
|
|
304
|
+
const [displayValue, setDisplayValue] = useState('');
|
|
305
|
+
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
|
306
|
+
const pendingUpdate = useRef(false);
|
|
307
|
+
|
|
308
|
+
// Input history
|
|
309
|
+
const {
|
|
310
|
+
addToHistory,
|
|
311
|
+
navigateHistory,
|
|
312
|
+
resetNavigation,
|
|
313
|
+
isNavigating,
|
|
314
|
+
} = useInputHistory();
|
|
315
|
+
|
|
316
|
+
// Schedule a batched display update
|
|
317
|
+
const scheduleDisplayUpdate = useCallback(() => {
|
|
318
|
+
if (pendingUpdate.current) return;
|
|
319
|
+
pendingUpdate.current = true;
|
|
320
|
+
|
|
321
|
+
// Batch updates to reduce re-renders
|
|
322
|
+
setTimeout(() => {
|
|
323
|
+
pendingUpdate.current = false;
|
|
324
|
+
const currentValue = inputBuffer.current;
|
|
325
|
+
setDisplayValue(currentValue);
|
|
326
|
+
// Update autocomplete state
|
|
327
|
+
setShowAutocomplete(currentValue.startsWith('/') && currentValue.length > 1);
|
|
328
|
+
}, 50); // ~20fps for display updates
|
|
329
|
+
}, []);
|
|
330
|
+
|
|
331
|
+
// Handle command autocomplete selection
|
|
332
|
+
const handleAutocompleteSelect = useCallback((cmd) => {
|
|
333
|
+
// Complete to just the command name + space if it has arguments
|
|
334
|
+
// This way /model completes to "/model " not "/model [model-name]"
|
|
335
|
+
const hasArgs = cmd.usage.includes('[') || cmd.usage.includes('<');
|
|
336
|
+
const completedValue = hasArgs ? `/${cmd.name} ` : `/${cmd.name}`;
|
|
337
|
+
|
|
338
|
+
inputBuffer.current = completedValue;
|
|
339
|
+
setDisplayValue(completedValue);
|
|
340
|
+
setShowAutocomplete(false);
|
|
341
|
+
}, []);
|
|
342
|
+
|
|
343
|
+
// Single useInput hook for ALL keyboard handling
|
|
344
|
+
useInput((input, key) => {
|
|
345
|
+
if (disabled) return;
|
|
346
|
+
|
|
347
|
+
// Submit on enter
|
|
348
|
+
if (key.return) {
|
|
349
|
+
const value = inputBuffer.current.trim();
|
|
350
|
+
if (!value) return;
|
|
351
|
+
|
|
352
|
+
// Add to history
|
|
353
|
+
if (enableHistory) {
|
|
354
|
+
addToHistory(inputBuffer.current);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
onSubmit?.(inputBuffer.current);
|
|
358
|
+
inputBuffer.current = '';
|
|
359
|
+
setDisplayValue('');
|
|
360
|
+
setShowAutocomplete(false);
|
|
361
|
+
resetNavigation();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// History navigation with up/down arrows (only when autocomplete not showing)
|
|
366
|
+
if (enableHistory && !showAutocomplete && (key.upArrow || key.downArrow)) {
|
|
367
|
+
const direction = key.upArrow ? 'up' : 'down';
|
|
368
|
+
const newValue = navigateHistory(direction, inputBuffer.current);
|
|
369
|
+
inputBuffer.current = newValue;
|
|
370
|
+
setDisplayValue(newValue);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Tab to trigger file picker (if not in autocomplete)
|
|
375
|
+
if (key.tab && !showAutocomplete && onAtTrigger) {
|
|
376
|
+
onAtTrigger();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Escape to clear input
|
|
381
|
+
if (key.escape) {
|
|
382
|
+
inputBuffer.current = '';
|
|
383
|
+
setDisplayValue('');
|
|
384
|
+
setShowAutocomplete(false);
|
|
385
|
+
resetNavigation();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Skip other control keys
|
|
390
|
+
if (key.ctrl || key.meta) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Backspace/delete
|
|
395
|
+
if (key.backspace || key.delete) {
|
|
396
|
+
if (inputBuffer.current.length > 0) {
|
|
397
|
+
inputBuffer.current = inputBuffer.current.slice(0, -1);
|
|
398
|
+
if (isNavigating) resetNavigation();
|
|
399
|
+
scheduleDisplayUpdate();
|
|
400
|
+
}
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Regular character input
|
|
405
|
+
if (input) {
|
|
406
|
+
// Check for @ trigger
|
|
407
|
+
if (input === '@' && onAtTrigger) {
|
|
408
|
+
onAtTrigger();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
inputBuffer.current += input;
|
|
413
|
+
if (isNavigating) resetNavigation();
|
|
414
|
+
scheduleDisplayUpdate();
|
|
415
|
+
}
|
|
416
|
+
}, { isActive: !disabled });
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
<Box flexDirection="column">
|
|
420
|
+
{/* Attached files display - simplified */}
|
|
421
|
+
{attachedFiles.length > 0 && (
|
|
422
|
+
<Box marginBottom={1}>
|
|
423
|
+
<Text dimColor>📎 </Text>
|
|
424
|
+
{attachedFiles.map((f, i) => (
|
|
425
|
+
<Text key={f.path || i}>
|
|
426
|
+
<Text color="#06b6d4">{f.name || f.path.split('/').pop()}</Text>
|
|
427
|
+
{i < attachedFiles.length - 1 && <Text dimColor>, </Text>}
|
|
428
|
+
</Text>
|
|
429
|
+
))}
|
|
430
|
+
</Box>
|
|
431
|
+
)}
|
|
432
|
+
|
|
433
|
+
{/* Command autocomplete */}
|
|
434
|
+
{showAutocomplete && (
|
|
435
|
+
<CommandAutocomplete
|
|
436
|
+
input={displayValue}
|
|
437
|
+
onSelect={handleAutocompleteSelect}
|
|
438
|
+
/>
|
|
439
|
+
)}
|
|
440
|
+
|
|
441
|
+
{/* Simple input line */}
|
|
442
|
+
<Box>
|
|
443
|
+
<Text color="#a855f7" bold>❯ </Text>
|
|
444
|
+
{disabled ? (
|
|
445
|
+
<Box>
|
|
446
|
+
<Text dimColor>{placeholder}</Text>
|
|
447
|
+
{placeholder === 'Generating...' && (
|
|
448
|
+
<Text color="#6b7280"> (Ctrl+C to stop)</Text>
|
|
449
|
+
)}
|
|
450
|
+
</Box>
|
|
451
|
+
) : (
|
|
452
|
+
<InputDisplay
|
|
453
|
+
value={displayValue}
|
|
454
|
+
placeholder={placeholder}
|
|
455
|
+
showCursor={true}
|
|
456
|
+
/>
|
|
457
|
+
)}
|
|
458
|
+
</Box>
|
|
459
|
+
</Box>
|
|
460
|
+
);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Simple prompt with just "You: " prefix
|
|
465
|
+
*/
|
|
466
|
+
export function SimplePrompt({ children }) {
|
|
467
|
+
return (
|
|
468
|
+
<Box>
|
|
469
|
+
<Text color="#a855f7" bold>You: </Text>
|
|
470
|
+
{children}
|
|
471
|
+
</Box>
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Typing indicator (for when AI is processing)
|
|
477
|
+
*/
|
|
478
|
+
export function TypingIndicator({ text = 'AI is thinking' }) {
|
|
479
|
+
const [dots, setDots] = useState(0);
|
|
480
|
+
|
|
481
|
+
useEffect(() => {
|
|
482
|
+
const timer = setInterval(() => {
|
|
483
|
+
setDots(d => (d + 1) % 4);
|
|
484
|
+
}, 300);
|
|
485
|
+
return () => clearInterval(timer);
|
|
486
|
+
}, []);
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<Box>
|
|
490
|
+
<Text dimColor>{text}</Text>
|
|
491
|
+
<Text dimColor>{'.'.repeat(dots)}</Text>
|
|
492
|
+
</Box>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Quick action buttons hint
|
|
498
|
+
*/
|
|
499
|
+
export function QuickActions() {
|
|
500
|
+
return (
|
|
501
|
+
<Box marginTop={1}>
|
|
502
|
+
<Text color="#374151">[</Text>
|
|
503
|
+
<Text color="#06b6d4">Enter</Text>
|
|
504
|
+
<Text color="#374151">] send [</Text>
|
|
505
|
+
<Text color="#06b6d4">@</Text>
|
|
506
|
+
<Text color="#374151">] attach [</Text>
|
|
507
|
+
<Text color="#06b6d4">/help</Text>
|
|
508
|
+
<Text color="#374151">] commands [</Text>
|
|
509
|
+
<Text color="#06b6d4">Ctrl+C</Text>
|
|
510
|
+
<Text color="#374151">] stop</Text>
|
|
511
|
+
</Box>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export default PromptInput;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StreamingResponse component
|
|
3
|
+
* Displays the current streaming response from the assistant
|
|
4
|
+
* This component updates live as new content arrives
|
|
5
|
+
*
|
|
6
|
+
* Responsive: adapts text rendering to terminal width
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useMemo } from 'react';
|
|
10
|
+
import { Box, Text } from 'ink';
|
|
11
|
+
import { ThinkingIndicator, CompletedThinking } from './ThinkingIndicator.jsx';
|
|
12
|
+
import { ToolExecution } from './ToolExecution.jsx';
|
|
13
|
+
import { GenerationState } from '../hooks/useChatState.js';
|
|
14
|
+
import { renderMarkdown } from '../utils/markdown.js';
|
|
15
|
+
import { useTerminal } from '../context/TerminalContext.jsx';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* StreamingText component
|
|
19
|
+
* Displays streaming text content with assistant prefix
|
|
20
|
+
* Renders markdown for consistent formatting with completed messages
|
|
21
|
+
* Responsive: uses terminal width for markdown rendering
|
|
22
|
+
*/
|
|
23
|
+
export function StreamingText({ content, showPrefix = true }) {
|
|
24
|
+
const { textWidth } = useTerminal();
|
|
25
|
+
|
|
26
|
+
if (!content) return null;
|
|
27
|
+
|
|
28
|
+
// Render markdown for consistent display with completed messages
|
|
29
|
+
// Use textWidth from terminal context for responsive rendering
|
|
30
|
+
const rendered = useMemo(() => {
|
|
31
|
+
try {
|
|
32
|
+
return renderMarkdown(content, textWidth);
|
|
33
|
+
} catch {
|
|
34
|
+
return content;
|
|
35
|
+
}
|
|
36
|
+
}, [content, textWidth]);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
40
|
+
{showPrefix && (
|
|
41
|
+
<Box>
|
|
42
|
+
<Text color="green" bold>Otherwise:</Text>
|
|
43
|
+
</Box>
|
|
44
|
+
)}
|
|
45
|
+
<Box marginLeft={0}>
|
|
46
|
+
<Text wrap="wrap">{rendered}</Text>
|
|
47
|
+
</Box>
|
|
48
|
+
</Box>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* StreamingResponse component
|
|
54
|
+
* Orchestrates the display of thinking, tools, and text
|
|
55
|
+
*/
|
|
56
|
+
export function StreamingResponse({
|
|
57
|
+
generationState,
|
|
58
|
+
thinkingContent,
|
|
59
|
+
thinkingStartTime,
|
|
60
|
+
streamingContent,
|
|
61
|
+
tools,
|
|
62
|
+
remoteSuffix = null,
|
|
63
|
+
}) {
|
|
64
|
+
const isThinking = generationState === GenerationState.THINKING;
|
|
65
|
+
const isGenerating = generationState === GenerationState.GENERATING;
|
|
66
|
+
const isToolExecuting = generationState === GenerationState.TOOL_EXECUTING;
|
|
67
|
+
|
|
68
|
+
// Nothing to show if idle
|
|
69
|
+
if (generationState === GenerationState.IDLE) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Calculate thinking stats for completed thinking summary
|
|
74
|
+
const thinkingChars = thinkingContent?.length || 0;
|
|
75
|
+
const thinkingElapsed = thinkingStartTime ? Date.now() - thinkingStartTime : 0;
|
|
76
|
+
|
|
77
|
+
// Determine if we should show completed thinking summary
|
|
78
|
+
// (show when we've moved past thinking to generating/tools)
|
|
79
|
+
const showThinkingSummary = !isThinking && thinkingChars > 0;
|
|
80
|
+
|
|
81
|
+
// Get active tools
|
|
82
|
+
const activeTools = tools ? Object.values(tools) : [];
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Box flexDirection="column">
|
|
86
|
+
{/* Thinking indicator (shown during thinking phase) */}
|
|
87
|
+
{isThinking && (
|
|
88
|
+
<ThinkingIndicator
|
|
89
|
+
charCount={thinkingChars}
|
|
90
|
+
startTime={thinkingStartTime}
|
|
91
|
+
suffix={remoteSuffix}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{/* Completed thinking summary */}
|
|
96
|
+
{showThinkingSummary && (
|
|
97
|
+
<CompletedThinking charCount={thinkingChars} elapsed={thinkingElapsed} />
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{/* Tool executions */}
|
|
101
|
+
{activeTools.length > 0 && (
|
|
102
|
+
<Box flexDirection="column">
|
|
103
|
+
{activeTools.map(tool => (
|
|
104
|
+
<ToolExecution key={tool.id} tool={tool} />
|
|
105
|
+
))}
|
|
106
|
+
</Box>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
{/* Streaming text content */}
|
|
110
|
+
{streamingContent && (
|
|
111
|
+
<StreamingText
|
|
112
|
+
content={streamingContent}
|
|
113
|
+
showPrefix={!isToolExecuting || activeTools.length === 0}
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{/* Error state */}
|
|
118
|
+
{generationState === GenerationState.ERROR && (
|
|
119
|
+
<Box>
|
|
120
|
+
<Text color="red">✗ Generation error</Text>
|
|
121
|
+
</Box>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{/* Stopped state */}
|
|
125
|
+
{generationState === GenerationState.STOPPED && (
|
|
126
|
+
<Box>
|
|
127
|
+
<Text color="yellow">⚠ Generation stopped</Text>
|
|
128
|
+
</Box>
|
|
129
|
+
)}
|
|
130
|
+
</Box>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default StreamingResponse;
|