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,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;