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