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,687 @@
1
+ /**
2
+ * App component - Root component for the Ink-based CLI
3
+ * Clean, minimal design with fixed header
4
+ *
5
+ * Features responsive rendering that adapts to terminal window size changes
6
+ */
7
+
8
+ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
9
+ import { Box, Text, useApp, useInput } from 'ink';
10
+ import open from 'open';
11
+
12
+ // Components
13
+ import { MessageList, GenerationStats } from './MessageList.jsx';
14
+ import { StreamingResponse } from './StreamingResponse.jsx';
15
+ import { PromptInput } from './PromptInput.jsx';
16
+ import { FilePicker } from './FilePicker.jsx';
17
+ import { ModelSelector } from './ModelSelector.jsx';
18
+ import { HelpPanel } from './HelpPanel.jsx';
19
+ import { HistoryPanel } from './HistoryPanel.jsx';
20
+ import { BrowserSelect } from './BrowserSelect.jsx';
21
+ // ThinkingIndicator components used in StreamingResponse
22
+
23
+ // Hooks
24
+ import { useWebSocket, ConnectionState } from '../hooks/useWebSocket.js';
25
+ import { useChatState, GenerationState } from '../hooks/useChatState.js';
26
+ import { useFileAttachments } from '../hooks/useFileAttachments.js';
27
+ import { useCommands, CommandAction } from '../hooks/useCommands.js';
28
+ import { useNotifications, notifyGenerationComplete } from '../hooks/useNotifications.js';
29
+
30
+ // Context for responsive terminal rendering
31
+ import { TerminalProvider, useTerminal } from '../context/TerminalContext.jsx';
32
+
33
+ // Utils
34
+ import { config } from '../../config.js';
35
+ import { getFriendlyModelName } from '../utils/formatters.js';
36
+
37
+ /**
38
+ * View states for the app
39
+ */
40
+ const View = {
41
+ CHAT: 'chat',
42
+ FILE_PICKER: 'file_picker',
43
+ MODEL_SELECTOR: 'model_selector',
44
+ HELP: 'help',
45
+ HISTORY: 'history',
46
+ BROWSER_SELECT: 'browser_select',
47
+ };
48
+
49
+ /**
50
+ * Fixed header component - always visible at top
51
+ * Uses simple text instead of Box borders to avoid Ink re-render bugs
52
+ * Responsive: adapts to narrow terminals by hiding less important info
53
+ */
54
+ const FixedHeader = React.memo(function FixedHeader({ chatTitle, chatId, model, connectionState, serverUrl, isRemoteMode }) {
55
+ const { isNarrow, useCompactMode } = useTerminal();
56
+ const modelName = getFriendlyModelName(model);
57
+ const isConnected = connectionState === ConnectionState.CONNECTED;
58
+ const isConnecting = connectionState === ConnectionState.CONNECTING;
59
+
60
+ // Determine model icon
61
+ let modelIcon = '🤖';
62
+ if (model?.includes('claude')) modelIcon = '🟣';
63
+ else if (model?.includes('gpt') || model?.includes('o1') || model?.includes('o3')) modelIcon = '🟢';
64
+ else if (model?.includes('gemini')) modelIcon = '🔵';
65
+ else if (model?.includes('grok')) modelIcon = '⚫';
66
+ else if (model?.includes('llama') || model?.includes('ollama')) modelIcon = '🦙';
67
+
68
+ const title = chatTitle || (chatId ? `Chat #${chatId}` : '✨ New Chat');
69
+ const statusColor = isConnected ? '#22c55e' : (isConnecting ? '#f59e0b' : '#ef4444');
70
+
71
+ // Compact status for narrow terminals
72
+ const statusText = useCompactMode
73
+ ? (isConnected ? '●' : '○')
74
+ : (isConnecting ? '◐ Connecting' : (isConnected ? '● Connected' : '○ Disconnected'));
75
+
76
+ // Very compact mode: just title and status dot
77
+ if (useCompactMode) {
78
+ return (
79
+ <Box marginBottom={1}>
80
+ <Text color="#06b6d4" bold wrap="truncate">{title}</Text>
81
+ {isRemoteMode && <Text dimColor> </Text>}
82
+ {isRemoteMode && <Text color="#f59e0b">Remote</Text>}
83
+ <Text dimColor> </Text>
84
+ <Text color={statusColor}>{statusText}</Text>
85
+ </Box>
86
+ );
87
+ }
88
+
89
+ // Narrow mode: hide model name, keep icon
90
+ if (isNarrow) {
91
+ return (
92
+ <Box marginBottom={1}>
93
+ <Text color="#06b6d4" bold wrap="truncate">{title}</Text>
94
+ {isRemoteMode && <Text dimColor> </Text>}
95
+ {isRemoteMode && <Text color="#f59e0b">Remote</Text>}
96
+ <Text dimColor> </Text>
97
+ <Text>{modelIcon}</Text>
98
+ <Text dimColor> </Text>
99
+ <Text color={statusColor}>{statusText}</Text>
100
+ </Box>
101
+ );
102
+ }
103
+
104
+ // Full header for normal/wide terminals
105
+ return (
106
+ <Box marginBottom={1}>
107
+ <Text color="#06b6d4" bold>{title}</Text>
108
+ {isRemoteMode && <Text dimColor> · </Text>}
109
+ {isRemoteMode && <Text color="#f59e0b">Remote</Text>}
110
+ <Text dimColor> · </Text>
111
+ <Text>{modelIcon} </Text>
112
+ <Text color="#a855f7">{modelName}</Text>
113
+ <Text dimColor> · </Text>
114
+ <Text color={statusColor}>{statusText}</Text>
115
+ </Box>
116
+ );
117
+ });
118
+
119
+ /**
120
+ * Simple welcome/tips shown on empty chat
121
+ * Memoized to prevent re-renders
122
+ */
123
+ const QuickTips = React.memo(function QuickTips() {
124
+ return (
125
+ <Box flexDirection="column" paddingY={1}>
126
+ <Text dimColor>
127
+ Type a message to start • <Text color="#06b6d4">@</Text> attach files • <Text color="#06b6d4">/help</Text> commands
128
+ </Text>
129
+ </Box>
130
+ );
131
+ });
132
+
133
+ /**
134
+ * Status toast component
135
+ * Memoized to prevent re-renders
136
+ */
137
+ const StatusToast = React.memo(function StatusToast({ message, type }) {
138
+ if (!message) return null;
139
+
140
+ const colors = {
141
+ success: '#22c55e',
142
+ error: '#ef4444',
143
+ info: '#3b82f6',
144
+ };
145
+ const icons = {
146
+ success: '✓',
147
+ error: '✗',
148
+ info: 'ℹ',
149
+ };
150
+
151
+ return (
152
+ <Box marginY={1}>
153
+ <Text color={colors[type] || colors.info}>
154
+ {icons[type] || icons.info} {message}
155
+ </Text>
156
+ </Box>
157
+ );
158
+ });
159
+
160
+ /**
161
+ * Inner App component - contains all app logic
162
+ * Wrapped by AppWithProvider to ensure TerminalProvider context is available
163
+ */
164
+ function AppInner({ serverUrl = 'http://localhost:3000', showBanner = true, isRemoteMode = false }) {
165
+ const { exit } = useApp();
166
+ const { contentWidth, useCompactMode } = useTerminal();
167
+
168
+ // View state
169
+ const [currentView, setCurrentView] = useState(View.CHAT);
170
+ const [statusMessage, setStatusMessage] = useState(null);
171
+ const [statusType, setStatusType] = useState('info');
172
+
173
+ // Show browser selection on first load when not configured (CLI TUI only)
174
+ const hasCheckedBrowserRef = React.useRef(false);
175
+ useEffect(() => {
176
+ if (hasCheckedBrowserRef.current) return;
177
+ hasCheckedBrowserRef.current = true;
178
+ const channel = config.get('browserChannel');
179
+ if (channel == null || channel === '') {
180
+ setCurrentView(View.BROWSER_SELECT);
181
+ }
182
+ }, []);
183
+
184
+ // Generation tracking for notifications
185
+ const generationStartTime = useRef(null);
186
+
187
+ // Initialize hooks
188
+ const chatState = useChatState({
189
+ initialModel: config.get('model') || 'claude-sonnet-4-20250514',
190
+ });
191
+
192
+ const fileAttachments = useFileAttachments(serverUrl);
193
+ const commands = useCommands();
194
+ const notifications = useNotifications({
195
+ enabled: true,
196
+ minDuration: 30000,
197
+ });
198
+
199
+ // When remote frontend selects a chat, try to load it from local server (if it exists)
200
+ const loadChatByIdIfExists = useCallback((chatId) => {
201
+ if (!chatId) return;
202
+ fetch(`${serverUrl}/api/chats/${chatId}`)
203
+ .then((r) => (r.ok ? r.json() : null))
204
+ .then((chat) => {
205
+ if (chat && !chat.error && chatState.loadChat) {
206
+ chatState.loadChat({
207
+ id: chat.id,
208
+ title: chat.title,
209
+ messages: (chat.messages || []).map((m) => ({
210
+ id: m.id,
211
+ role: m.role,
212
+ content: m.content,
213
+ timestamp: m.created_at || m.timestamp,
214
+ })),
215
+ });
216
+ }
217
+ })
218
+ .catch(() => {});
219
+ }, [serverUrl, chatState.loadChat]);
220
+
221
+ // WebSocket connection with message handling
222
+ const ws = useWebSocket(serverUrl, {
223
+ autoConnect: true,
224
+ autoReconnect: true,
225
+ onMessage: useCallback((message) => {
226
+ chatState.processMessage(message);
227
+ if (message.type === 'chat_selected' && message.chatId != null) {
228
+ loadChatByIdIfExists(message.chatId);
229
+ }
230
+ // Track generation completion for notifications
231
+ if (message.type === 'done' && generationStartTime.current) {
232
+ const duration = Date.now() - generationStartTime.current;
233
+ notifyGenerationComplete(message, duration, notifications.notify);
234
+ generationStartTime.current = null;
235
+ }
236
+ }, [chatState.processMessage, loadChatByIdIfExists, notifications.notify]),
237
+ onConnect: useCallback(() => {
238
+ // Silent - header shows connection status
239
+ }, []),
240
+ onDisconnect: useCallback(() => {
241
+ // Silent - header shows connection status
242
+ // Only show error if we were actively generating
243
+ }, []),
244
+ onError: useCallback(() => {
245
+ // Silent - connection issues handled by auto-reconnect
246
+ }, []),
247
+ });
248
+
249
+ // Helper to show status messages
250
+ const showStatus = useCallback((message, type = 'info') => {
251
+ setStatusMessage(message);
252
+ setStatusType(type);
253
+ }, []);
254
+
255
+ // Clear status message after delay
256
+ useEffect(() => {
257
+ if (statusMessage) {
258
+ const timer = setTimeout(() => setStatusMessage(null), 2500);
259
+ return () => clearTimeout(timer);
260
+ }
261
+ }, [statusMessage]);
262
+
263
+ // Handle sending a message
264
+ const handleSendMessage = useCallback(async (content) => {
265
+ if (!ws.isConnected) {
266
+ showStatus('Not connected to server', 'error');
267
+ return;
268
+ }
269
+
270
+ if (chatState.isGenerating) {
271
+ showStatus('Already generating. Press Ctrl+C to stop.', 'info');
272
+ return;
273
+ }
274
+
275
+ // Check for commands
276
+ if (commands.isCommand(content)) {
277
+ const { command, args } = commands.parse(content);
278
+ const result = commands.execute(command, args);
279
+
280
+ await handleCommandResult(result);
281
+ return;
282
+ }
283
+
284
+ // Prepend pending RAG mention if there is one
285
+ let messageContent = content;
286
+ if (pendingRagMention) {
287
+ // Only prepend if not already mentioned in the content
288
+ if (!content.toLowerCase().includes(`@${pendingRagMention.toLowerCase()}`)) {
289
+ messageContent = `@${pendingRagMention} ${content}`;
290
+ }
291
+ setPendingRagMention(null); // Clear the pending mention
292
+ }
293
+
294
+ // Build message with file context
295
+ const fileContext = fileAttachments.buildContext();
296
+ const fullContent = fileContext
297
+ ? `${messageContent}\n\n--- Attached Files/Folders ---\n${fileContext}`
298
+ : messageContent;
299
+
300
+ // Add user message to display (with RAG mention if applicable)
301
+ chatState.addUserMessage(messageContent);
302
+
303
+ // Track generation start time for notifications
304
+ generationStartTime.current = Date.now();
305
+ notifications.startTask('generation');
306
+
307
+ // Start generation
308
+ chatState.startGeneration();
309
+
310
+ // Send via WebSocket
311
+ const success = ws.sendChatMessage({
312
+ chatId: chatState.currentChatId,
313
+ content: fullContent,
314
+ model: chatState.currentModel,
315
+ systemMessage: config.get('systemMessage') || '',
316
+ maxTokens: config.get('maxTokens') || 8192,
317
+ });
318
+
319
+ if (!success) {
320
+ showStatus('Failed to send message', 'error');
321
+ chatState.handleError('Failed to send message');
322
+ generationStartTime.current = null;
323
+ }
324
+
325
+ // Clear file attachments after sending
326
+ fileAttachments.clear();
327
+ }, [ws, chatState, fileAttachments, commands, notifications, showStatus]);
328
+
329
+ // Handle command results
330
+ const handleCommandResult = useCallback(async (result) => {
331
+ switch (result.action) {
332
+ case CommandAction.EXIT:
333
+ exit();
334
+ break;
335
+
336
+ case CommandAction.NEW_CHAT:
337
+ // Clear the terminal screen
338
+ process.stdout.write('\x1b[2J\x1b[H');
339
+ chatState.newChat();
340
+ ws.selectChat(null);
341
+ break;
342
+
343
+ case CommandAction.SHOW_HELP:
344
+ setCurrentView(View.HELP);
345
+ break;
346
+
347
+ case CommandAction.SHOW_HISTORY:
348
+ setCurrentView(View.HISTORY);
349
+ break;
350
+
351
+ case CommandAction.OPEN_FILE_PICKER:
352
+ setCurrentView(View.FILE_PICKER);
353
+ break;
354
+
355
+ case CommandAction.OPEN_MODEL_SELECTOR:
356
+ setCurrentView(View.MODEL_SELECTOR);
357
+ break;
358
+
359
+ case CommandAction.OPEN_BROWSER_SELECT:
360
+ setCurrentView(View.BROWSER_SELECT);
361
+ break;
362
+
363
+ case CommandAction.CLEAR_FILES:
364
+ fileAttachments.clear();
365
+ showStatus('Files cleared', 'success');
366
+ break;
367
+
368
+ case CommandAction.SET_MODEL:
369
+ if (result.data?.modelName) {
370
+ chatState.setCurrentModel(result.data.modelName);
371
+ config.set('model', result.data.modelName);
372
+ // Broadcast model change to other clients
373
+ ws.send({ type: 'select_model', model: result.data.modelName });
374
+ showStatus(`Model: ${result.data.modelName}`, 'success');
375
+ }
376
+ break;
377
+
378
+ case CommandAction.OPEN_BROWSER:
379
+ try {
380
+ const chatId = chatState.currentChatId;
381
+ const url = chatId
382
+ ? `${serverUrl}?chat=${encodeURIComponent(String(chatId))}`
383
+ : serverUrl;
384
+ await open(url);
385
+ showStatus(chatId ? 'Opened browser to current chat' : 'Opened browser', 'success');
386
+ } catch (err) {
387
+ showStatus('Failed to open browser', 'error');
388
+ }
389
+ break;
390
+
391
+ case CommandAction.CLEAR_SCREEN:
392
+ setStatusMessage(null);
393
+ break;
394
+
395
+ case CommandAction.SHOW_STATUS:
396
+ showStatus(`${serverUrl}`, 'info');
397
+ break;
398
+
399
+ case CommandAction.UNKNOWN:
400
+ if (result.error) {
401
+ showStatus(result.error, 'error');
402
+ }
403
+ break;
404
+
405
+ default:
406
+ if (result.error) {
407
+ showStatus(result.error, 'error');
408
+ }
409
+ }
410
+ }, [chatState, fileAttachments, ws, serverUrl, exit, showStatus]);
411
+
412
+ // Store pending RAG mention to add to next message
413
+ const [pendingRagMention, setPendingRagMention] = useState(null);
414
+
415
+ // Handle file selection from picker
416
+ const handleFileSelect = useCallback(async (selection) => {
417
+ setCurrentView(View.CHAT);
418
+
419
+ // Clear the terminal screen for a fresh start
420
+ process.stdout.write('\x1b[2J\x1b[H');
421
+
422
+ // Handle RAG document selection
423
+ if (selection.isRagDocument) {
424
+ setPendingRagMention(selection.name);
425
+ showStatus(`📚 @${selection.name} ready - type your question`, 'success');
426
+ return;
427
+ }
428
+
429
+ if (selection.isFolder) {
430
+ // Remove trailing slash if present for consistent path handling
431
+ const folderPath = selection.path.replace(/\/$/, '');
432
+ const result = await fileAttachments.attachFolder(folderPath);
433
+ if (result) {
434
+ const stats = `${result.fileCount} files, ${result.lineCount?.toLocaleString() || 0} lines`;
435
+ showStatus(`📁 Attached: ${result.name}/ (${stats})`, 'success');
436
+ } else {
437
+ showStatus(`Failed to attach folder: ${selection.path}`, 'error');
438
+ }
439
+ } else {
440
+ const result = await fileAttachments.attachFile(selection.path);
441
+ if (result) {
442
+ showStatus(`📄 Attached: ${result.name} (${result.lineCount?.toLocaleString() || 0} lines)`, 'success');
443
+ } else {
444
+ showStatus(`Failed to attach file: ${selection.path}`, 'error');
445
+ }
446
+ }
447
+ }, [fileAttachments, showStatus]);
448
+
449
+ // Handle model selection
450
+ const handleModelSelect = useCallback((model) => {
451
+ setCurrentView(View.CHAT);
452
+ chatState.setCurrentModel(model.id);
453
+ config.set('model', model.id);
454
+ // Broadcast model change to other clients
455
+ ws.send({ type: 'select_model', model: model.id });
456
+ setStatusMessage(`Switched to ${model.name}`);
457
+ }, [chatState, ws]);
458
+
459
+ // Handle @ trigger for file picker
460
+ const handleAtTrigger = useCallback(() => {
461
+ setCurrentView(View.FILE_PICKER);
462
+ }, []);
463
+
464
+ // Global keyboard shortcuts
465
+ useInput((input, key) => {
466
+ // Ctrl+C to stop generation or exit
467
+ if (key.ctrl && input === 'c') {
468
+ if (chatState.isGenerating) {
469
+ // Send stop to server (with or without chatId)
470
+ const success = ws.sendStop(chatState.currentChatId);
471
+ if (success) {
472
+ showStatus('Stopping...', 'info');
473
+ } else {
474
+ // If WebSocket send failed, manually reset state
475
+ showStatus('Stopped', 'info');
476
+ }
477
+ } else {
478
+ // Not generating - exit the app
479
+ exit();
480
+ }
481
+ return;
482
+ }
483
+
484
+ // Ctrl+D to always exit
485
+ if (key.ctrl && input === 'd') {
486
+ exit();
487
+ return;
488
+ }
489
+
490
+ // Escape to close overlays or stop generation
491
+ if (key.escape) {
492
+ if (chatState.isGenerating) {
493
+ // Escape also stops generation
494
+ ws.sendStop(chatState.currentChatId);
495
+ showStatus('Stopping...', 'info');
496
+ } else if (currentView !== View.CHAT) {
497
+ setCurrentView(View.CHAT);
498
+ }
499
+ return;
500
+ }
501
+ });
502
+
503
+ // Determine if input should be disabled
504
+ const inputDisabled = chatState.isGenerating || currentView !== View.CHAT;
505
+
506
+ // Memoize message list to prevent re-renders during typing
507
+ const messageListMemo = useMemo(() => (
508
+ <MessageList messages={chatState.messages} />
509
+ ), [chatState.messages]);
510
+
511
+ // Memoize streaming response
512
+ const streamingResponseMemo = useMemo(() => (
513
+ <StreamingResponse
514
+ generationState={chatState.generationState}
515
+ thinkingContent={chatState.thinkingContent}
516
+ thinkingStartTime={chatState.thinkingStartTime}
517
+ streamingContent={chatState.streamingContent}
518
+ tools={chatState.tools}
519
+ />
520
+ ), [chatState.generationState, chatState.thinkingContent, chatState.thinkingStartTime, chatState.streamingContent, chatState.tools]);
521
+
522
+ // Memoize stats display
523
+ const statsDisplay = useMemo(() => {
524
+ if (chatState.lastStats && chatState.generationState === GenerationState.IDLE) {
525
+ return <GenerationStats stats={chatState.lastStats} />;
526
+ }
527
+ return null;
528
+ }, [chatState.lastStats, chatState.generationState]);
529
+
530
+ return (
531
+ <Box flexDirection="column" paddingX={1}>
532
+ {/* Fixed header - always visible */}
533
+ <FixedHeader
534
+ chatTitle={chatState.currentChatTitle}
535
+ chatId={chatState.currentChatId}
536
+ model={chatState.currentModel}
537
+ connectionState={ws.connectionState}
538
+ serverUrl={serverUrl}
539
+ isRemoteMode={isRemoteMode}
540
+ />
541
+
542
+ {/* Status toast */}
543
+ <StatusToast message={statusMessage} type={statusType} />
544
+
545
+ {/* Main content area */}
546
+ {currentView === View.CHAT && (
547
+ <Box flexDirection="column" flexGrow={1}>
548
+ {/* Quick tips for empty chat */}
549
+ {chatState.messages.length === 0 && !chatState.isGenerating && (
550
+ <QuickTips />
551
+ )}
552
+
553
+ {/* Message history */}
554
+ {messageListMemo}
555
+
556
+ {/* Streaming response */}
557
+ {streamingResponseMemo}
558
+
559
+ {/* Stats after generation */}
560
+ {statsDisplay}
561
+
562
+ {/* Pending RAG mention indicator */}
563
+ {pendingRagMention && (
564
+ <Box marginTop={1}>
565
+ <Text color="#10b981">📚 @{pendingRagMention}</Text>
566
+ <Text dimColor> will be added to your message</Text>
567
+ </Box>
568
+ )}
569
+
570
+ {/* Input area */}
571
+ <Box marginTop={1}>
572
+ <PromptInput
573
+ onSubmit={handleSendMessage}
574
+ onAtTrigger={handleAtTrigger}
575
+ disabled={inputDisabled}
576
+ attachedFiles={fileAttachments.files}
577
+ onDetachFile={fileAttachments.detach}
578
+ placeholder={chatState.isGenerating ? 'Generating...' : (pendingRagMention ? `Ask about @${pendingRagMention}...` : '')}
579
+ showTokenEstimate={false}
580
+ enableHistory={true}
581
+ />
582
+ </Box>
583
+ </Box>
584
+ )}
585
+
586
+ {/* File picker overlay */}
587
+ {currentView === View.FILE_PICKER && (
588
+ <FilePicker
589
+ serverUrl={serverUrl}
590
+ onSelect={handleFileSelect}
591
+ onCancel={() => setCurrentView(View.CHAT)}
592
+ isVisible={true}
593
+ />
594
+ )}
595
+
596
+ {/* Model selector overlay */}
597
+ {currentView === View.MODEL_SELECTOR && (
598
+ <ModelSelector
599
+ currentModel={chatState.currentModel}
600
+ onSelect={handleModelSelect}
601
+ onCancel={() => setCurrentView(View.CHAT)}
602
+ isVisible={true}
603
+ />
604
+ )}
605
+
606
+ {/* Help overlay */}
607
+ {currentView === View.HELP && (
608
+ <HelpPanel
609
+ onClose={() => setCurrentView(View.CHAT)}
610
+ isVisible={true}
611
+ />
612
+ )}
613
+
614
+ {/* Browser select overlay (when not configured) */}
615
+ {currentView === View.BROWSER_SELECT && (
616
+ <BrowserSelect
617
+ serverUrl={serverUrl}
618
+ onSelect={() => {
619
+ setCurrentView(View.CHAT);
620
+ showStatus('Browser saved', 'success');
621
+ }}
622
+ onCancel={() => setCurrentView(View.CHAT)}
623
+ isVisible={true}
624
+ />
625
+ )}
626
+
627
+ {/* History overlay */}
628
+ {currentView === View.HISTORY && (
629
+ <HistoryPanel
630
+ serverUrl={serverUrl}
631
+ onSelect={async (chat) => {
632
+ setCurrentView(View.CHAT);
633
+ showStatus('Loading chat...', 'info');
634
+
635
+ try {
636
+ // Fetch messages for this chat
637
+ const response = await fetch(`${serverUrl}/api/chats/${chat.id}/messages`);
638
+ if (response.ok) {
639
+ const messages = await response.json();
640
+ // Get last 2 messages to show context
641
+ const lastMessages = messages.slice(-2).map(msg => ({
642
+ id: msg.id || `msg-${Date.now()}-${Math.random()}`,
643
+ role: msg.role,
644
+ content: msg.content,
645
+ timestamp: msg.created_at || msg.timestamp,
646
+ }));
647
+
648
+ chatState.loadChat({
649
+ ...chat,
650
+ messages: lastMessages,
651
+ });
652
+ ws.selectChat(chat.id);
653
+ showStatus(`Loaded: ${chat.title || `Chat #${chat.id}`}`, 'success');
654
+ } else {
655
+ // Load without messages if fetch fails
656
+ chatState.loadChat(chat);
657
+ ws.selectChat(chat.id);
658
+ showStatus(`Loaded: ${chat.title || `Chat #${chat.id}`}`, 'success');
659
+ }
660
+ } catch (err) {
661
+ // Load without messages if fetch fails
662
+ chatState.loadChat(chat);
663
+ ws.selectChat(chat.id);
664
+ showStatus(`Loaded: ${chat.title || `Chat #${chat.id}`}`, 'success');
665
+ }
666
+ }}
667
+ onClose={() => setCurrentView(View.CHAT)}
668
+ isVisible={true}
669
+ />
670
+ )}
671
+ </Box>
672
+ );
673
+ }
674
+
675
+ /**
676
+ * Main App component wrapped with TerminalProvider
677
+ * This ensures all child components have access to responsive terminal dimensions
678
+ */
679
+ export function App(props) {
680
+ return (
681
+ <TerminalProvider>
682
+ <AppInner {...props} />
683
+ </TerminalProvider>
684
+ );
685
+ }
686
+
687
+ export default App;