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,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HistoryPanel component
|
|
3
|
+
* Displays recent chat history
|
|
4
|
+
*
|
|
5
|
+
* Responsive: adapts to terminal size
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useState, useEffect } from 'react';
|
|
9
|
+
import { Box, Text, useInput } from 'ink';
|
|
10
|
+
import { useTerminal } from '../context/TerminalContext.jsx';
|
|
11
|
+
import { responsiveTruncate } from '../utils/formatters.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Chat item display
|
|
15
|
+
* Responsive: truncates title based on available width
|
|
16
|
+
*/
|
|
17
|
+
function ChatItem({ chat, index, isSelected, onSelect, maxTitleLength = 40 }) {
|
|
18
|
+
const rawTitle = chat.title || `Chat #${chat.id}`;
|
|
19
|
+
const title = responsiveTruncate(rawTitle, maxTitleLength);
|
|
20
|
+
// Handle both snake_case (from API) and camelCase date fields
|
|
21
|
+
const dateStr = chat.updated_at || chat.updatedAt || chat.created_at || chat.createdAt;
|
|
22
|
+
const date = dateStr ? new Date(dateStr).toLocaleDateString() : '';
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Box>
|
|
26
|
+
<Text color={isSelected ? '#06b6d4' : '#6b7280'}>
|
|
27
|
+
{isSelected ? '❯ ' : ' '}
|
|
28
|
+
</Text>
|
|
29
|
+
<Text color={isSelected ? '#06b6d4' : undefined} bold={isSelected} wrap="truncate">
|
|
30
|
+
{index + 1}. {title}
|
|
31
|
+
</Text>
|
|
32
|
+
{date && <Text dimColor> ({date})</Text>}
|
|
33
|
+
</Box>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* HistoryPanel component
|
|
39
|
+
* Responsive: adapts visible items and title length to terminal size
|
|
40
|
+
*/
|
|
41
|
+
export function HistoryPanel({ serverUrl, onSelect, onClose, isVisible = true }) {
|
|
42
|
+
const { rows, columns, isNarrow } = useTerminal();
|
|
43
|
+
const [chats, setChats] = useState([]);
|
|
44
|
+
const [loading, setLoading] = useState(true);
|
|
45
|
+
const [error, setError] = useState(null);
|
|
46
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
47
|
+
|
|
48
|
+
// Responsive: adjust visible items and title length
|
|
49
|
+
const maxVisible = Math.max(5, Math.min(15, rows - 8));
|
|
50
|
+
const maxTitleLength = Math.max(20, columns - 25);
|
|
51
|
+
|
|
52
|
+
// Fetch chat history on mount
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!isVisible) return;
|
|
55
|
+
|
|
56
|
+
const fetchHistory = async () => {
|
|
57
|
+
try {
|
|
58
|
+
setLoading(true);
|
|
59
|
+
const response = await fetch(`${serverUrl}/api/chats`);
|
|
60
|
+
if (response.ok) {
|
|
61
|
+
const data = await response.json();
|
|
62
|
+
// API returns array directly, not wrapped in { chats: [...] }
|
|
63
|
+
const chatList = Array.isArray(data) ? data : (data.chats || []);
|
|
64
|
+
// Sort by most recent first
|
|
65
|
+
chatList.sort((a, b) => {
|
|
66
|
+
const dateA = new Date(a.updated_at || a.created_at || 0);
|
|
67
|
+
const dateB = new Date(b.updated_at || b.created_at || 0);
|
|
68
|
+
return dateB - dateA;
|
|
69
|
+
});
|
|
70
|
+
setChats(chatList);
|
|
71
|
+
} else {
|
|
72
|
+
setError('Failed to load history');
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
setError('Could not connect to server');
|
|
76
|
+
} finally {
|
|
77
|
+
setLoading(false);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
fetchHistory();
|
|
82
|
+
}, [serverUrl, isVisible]);
|
|
83
|
+
|
|
84
|
+
// Handle keyboard input
|
|
85
|
+
useInput((input, key) => {
|
|
86
|
+
if (!isVisible) return;
|
|
87
|
+
|
|
88
|
+
if (key.escape || input === 'q') {
|
|
89
|
+
onClose?.();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (key.upArrow) {
|
|
94
|
+
setSelectedIndex(i => Math.max(0, i - 1));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (key.downArrow) {
|
|
99
|
+
setSelectedIndex(i => Math.min(chats.length - 1, i + 1));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (key.return && chats.length > 0) {
|
|
104
|
+
onSelect?.(chats[selectedIndex]);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}, { isActive: isVisible });
|
|
108
|
+
|
|
109
|
+
if (!isVisible) return null;
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<Box flexDirection="column" padding={1}>
|
|
113
|
+
<Box marginBottom={1}>
|
|
114
|
+
<Text color="cyan" bold>Recent Chats</Text>
|
|
115
|
+
</Box>
|
|
116
|
+
|
|
117
|
+
{loading && (
|
|
118
|
+
<Text dimColor>Loading...</Text>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{error && (
|
|
122
|
+
<Text color="red">{error}</Text>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{!loading && !error && chats.length === 0 && (
|
|
126
|
+
<Text dimColor>No chat history yet</Text>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{!loading && !error && chats.length > 0 && (
|
|
130
|
+
<Box flexDirection="column">
|
|
131
|
+
{chats.slice(0, maxVisible).map((chat, index) => (
|
|
132
|
+
<ChatItem
|
|
133
|
+
key={chat.id}
|
|
134
|
+
chat={chat}
|
|
135
|
+
index={index}
|
|
136
|
+
isSelected={index === selectedIndex}
|
|
137
|
+
onSelect={() => onSelect?.(chat)}
|
|
138
|
+
maxTitleLength={maxTitleLength}
|
|
139
|
+
/>
|
|
140
|
+
))}
|
|
141
|
+
{chats.length > maxVisible && (
|
|
142
|
+
<Text dimColor> ... {chats.length - maxVisible} more</Text>
|
|
143
|
+
)}
|
|
144
|
+
</Box>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
<Box marginTop={1}>
|
|
148
|
+
{isNarrow ? (
|
|
149
|
+
<Text dimColor>↑↓ Enter Esc</Text>
|
|
150
|
+
) : (
|
|
151
|
+
<Text dimColor>↑↓ navigate • Enter select • Esc close</Text>
|
|
152
|
+
)}
|
|
153
|
+
</Box>
|
|
154
|
+
</Box>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default HistoryPanel;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageList component
|
|
3
|
+
* Displays completed chat messages in a scrolling list
|
|
4
|
+
*
|
|
5
|
+
* Responsive: adapts text rendering and dividers to terminal width
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useMemo } from 'react';
|
|
9
|
+
import { Box, Text } from 'ink';
|
|
10
|
+
import { renderMarkdown } from '../utils/markdown.js';
|
|
11
|
+
import { MessageRole, ToolState } from '../hooks/useChatState.js';
|
|
12
|
+
import { getToolIcon, formatToolName, getPrimaryArg } from '../utils/formatters.js';
|
|
13
|
+
import { useTerminal } from '../context/TerminalContext.jsx';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* User message component
|
|
17
|
+
*/
|
|
18
|
+
export function UserMessage({ content, source = null }) {
|
|
19
|
+
// Determine label based on source
|
|
20
|
+
const isFromWeb = source === 'web';
|
|
21
|
+
const label = isFromWeb ? 'You (web)' : 'You';
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
25
|
+
<Box>
|
|
26
|
+
<Text color="#a855f7" bold>{label}</Text>
|
|
27
|
+
<Text color="#a855f7" bold>:</Text>
|
|
28
|
+
</Box>
|
|
29
|
+
<Box marginLeft={0}>
|
|
30
|
+
<Text>{content}</Text>
|
|
31
|
+
</Box>
|
|
32
|
+
</Box>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Inline tool display component - compact representation of tool calls
|
|
38
|
+
*/
|
|
39
|
+
export function InlineTool({ tool }) {
|
|
40
|
+
const icon = getToolIcon(tool.name);
|
|
41
|
+
const displayName = formatToolName(tool.name);
|
|
42
|
+
const primaryArg = getPrimaryArg(tool.name, tool.args);
|
|
43
|
+
|
|
44
|
+
const isComplete = tool.status === ToolState.COMPLETE;
|
|
45
|
+
const hasError = tool.status === ToolState.ERROR;
|
|
46
|
+
|
|
47
|
+
// Determine status indicator
|
|
48
|
+
let statusIcon, statusColor;
|
|
49
|
+
if (hasError) {
|
|
50
|
+
statusIcon = '✗';
|
|
51
|
+
statusColor = '#ef4444';
|
|
52
|
+
} else if (isComplete) {
|
|
53
|
+
statusIcon = '✓';
|
|
54
|
+
statusColor = '#22c55e';
|
|
55
|
+
} else {
|
|
56
|
+
statusIcon = '•';
|
|
57
|
+
statusColor = '#f59e0b';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Box marginLeft={2} marginBottom={1}>
|
|
62
|
+
<Text color={statusColor}>{statusIcon} </Text>
|
|
63
|
+
<Text>{icon} </Text>
|
|
64
|
+
<Text color="#06b6d4">{displayName}</Text>
|
|
65
|
+
{primaryArg && (
|
|
66
|
+
<Text color="#9ca3af"> {primaryArg}</Text>
|
|
67
|
+
)}
|
|
68
|
+
</Box>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Inline tools list
|
|
74
|
+
*/
|
|
75
|
+
export function InlineToolsList({ tools }) {
|
|
76
|
+
if (!tools || tools.length === 0) return null;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
80
|
+
{tools.map((tool, i) => (
|
|
81
|
+
<InlineTool key={tool.id || `tool-${i}`} tool={tool} />
|
|
82
|
+
))}
|
|
83
|
+
</Box>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Assistant message component
|
|
89
|
+
* Layout matches StreamingText for consistent rendering
|
|
90
|
+
* Responsive: uses terminal width for markdown rendering
|
|
91
|
+
*/
|
|
92
|
+
export function AssistantMessage({ content, model = null, tools = null }) {
|
|
93
|
+
const { textWidth } = useTerminal();
|
|
94
|
+
|
|
95
|
+
// Render markdown to terminal format with responsive width
|
|
96
|
+
const rendered = useMemo(() => {
|
|
97
|
+
if (!content) return null;
|
|
98
|
+
try {
|
|
99
|
+
return renderMarkdown(content, textWidth);
|
|
100
|
+
} catch {
|
|
101
|
+
return content;
|
|
102
|
+
}
|
|
103
|
+
}, [content, textWidth]);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
107
|
+
<Box>
|
|
108
|
+
<Text color="green" bold>Otherwise:</Text>
|
|
109
|
+
</Box>
|
|
110
|
+
|
|
111
|
+
{/* Show tools if present */}
|
|
112
|
+
{tools && tools.length > 0 && (
|
|
113
|
+
<InlineToolsList tools={tools} />
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{/* Show content if present */}
|
|
117
|
+
{rendered && (
|
|
118
|
+
<Box marginLeft={0}>
|
|
119
|
+
<Text wrap="wrap">{rendered}</Text>
|
|
120
|
+
</Box>
|
|
121
|
+
)}
|
|
122
|
+
</Box>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* System message component
|
|
128
|
+
*/
|
|
129
|
+
export function SystemMessage({ content }) {
|
|
130
|
+
return (
|
|
131
|
+
<Box marginBottom={1}>
|
|
132
|
+
<Text dimColor italic>System: {content}</Text>
|
|
133
|
+
</Box>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Single message renderer
|
|
139
|
+
*/
|
|
140
|
+
export function Message({ message }) {
|
|
141
|
+
switch (message.role) {
|
|
142
|
+
case MessageRole.USER:
|
|
143
|
+
return <UserMessage content={message.content} source={message.source} />;
|
|
144
|
+
case MessageRole.ASSISTANT:
|
|
145
|
+
return (
|
|
146
|
+
<AssistantMessage
|
|
147
|
+
content={message.content}
|
|
148
|
+
model={message.model}
|
|
149
|
+
tools={message.tools}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
case MessageRole.SYSTEM:
|
|
153
|
+
return <SystemMessage content={message.content} />;
|
|
154
|
+
default:
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* MessageList component
|
|
161
|
+
* Renders all messages in order
|
|
162
|
+
*/
|
|
163
|
+
export function MessageList({ messages }) {
|
|
164
|
+
if (!messages || messages.length === 0) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<Box flexDirection="column">
|
|
170
|
+
{messages.map((message, index) => (
|
|
171
|
+
<Box key={message.id || `msg-${index}`} flexDirection="column">
|
|
172
|
+
<Message message={message} />
|
|
173
|
+
</Box>
|
|
174
|
+
))}
|
|
175
|
+
</Box>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Divider between messages or sections
|
|
181
|
+
* Responsive: uses terminal width for divider
|
|
182
|
+
*/
|
|
183
|
+
export function MessageDivider({ char = '─' }) {
|
|
184
|
+
const { uiWidth } = useTerminal();
|
|
185
|
+
const width = Math.min(uiWidth, 60);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<Box marginY={1}>
|
|
189
|
+
<Text dimColor>{char.repeat(width)}</Text>
|
|
190
|
+
</Box>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Stats display (shown after generation completes)
|
|
196
|
+
* Responsive: adapts line width to terminal size
|
|
197
|
+
*/
|
|
198
|
+
export function GenerationStats({ stats }) {
|
|
199
|
+
const { uiWidth, isNarrow } = useTerminal();
|
|
200
|
+
|
|
201
|
+
if (!stats) return null;
|
|
202
|
+
|
|
203
|
+
const parts = [];
|
|
204
|
+
|
|
205
|
+
if (stats.numTokens) {
|
|
206
|
+
parts.push(`${stats.numTokens} tok`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (stats.tps) {
|
|
210
|
+
parts.push(`${Math.round(stats.tps)} tok/s`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Only show model on wider terminals
|
|
214
|
+
if (stats.model && !isNarrow) {
|
|
215
|
+
// Import would be circular, just use the model ID
|
|
216
|
+
parts.push(stats.model.split('-').slice(0, 2).join(' '));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (stats.finishReason && stats.finishReason !== 'STOP' && stats.finishReason !== 'stop') {
|
|
220
|
+
parts.push(stats.finishReason.toLowerCase());
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const content = parts.join(' · ');
|
|
224
|
+
const targetWidth = Math.min(uiWidth, 60);
|
|
225
|
+
const padding = Math.max(0, Math.floor((targetWidth - content.length - 2) / 2));
|
|
226
|
+
const line = '─'.repeat(padding);
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<Box justifyContent="center" marginY={1}>
|
|
230
|
+
<Text dimColor>{line} {content} {line}</Text>
|
|
231
|
+
</Box>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export default MessageList;
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelSelector component
|
|
3
|
+
* Interactive model selector using Ink
|
|
4
|
+
*
|
|
5
|
+
* Responsive: adapts to terminal height
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
9
|
+
import { Box, Text, useInput, useFocus } from 'ink';
|
|
10
|
+
import { getFriendlyModelName } from '../utils/formatters.js';
|
|
11
|
+
import { config } from '../../config.js';
|
|
12
|
+
import { MODEL_DATA } from '../../models.js';
|
|
13
|
+
import { useTerminal } from '../context/TerminalContext.jsx';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Provider icons
|
|
17
|
+
*/
|
|
18
|
+
const PROVIDER_ICONS = {
|
|
19
|
+
anthropic: '🟣',
|
|
20
|
+
openai: '🟢',
|
|
21
|
+
google: '🔵',
|
|
22
|
+
xai: '⚫',
|
|
23
|
+
openrouter: '🔀',
|
|
24
|
+
ollama: '🦙',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Model item display
|
|
29
|
+
*/
|
|
30
|
+
function ModelItem({ model, isSelected, isCurrent }) {
|
|
31
|
+
const prefix = isSelected ? '❯ ' : ' ';
|
|
32
|
+
|
|
33
|
+
let nameDisplay;
|
|
34
|
+
if (isSelected) {
|
|
35
|
+
nameDisplay = <Text color="cyan" bold>{model.name}</Text>;
|
|
36
|
+
} else if (isCurrent) {
|
|
37
|
+
nameDisplay = <Text color="green">{model.name}</Text>;
|
|
38
|
+
} else {
|
|
39
|
+
nameDisplay = <Text>{model.name}</Text>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Feature indicators
|
|
43
|
+
const features = [];
|
|
44
|
+
if (model.type?.includes('reasoning')) features.push('⚡');
|
|
45
|
+
if (model.type?.includes('web-search')) features.push('🔍');
|
|
46
|
+
if (model.type?.includes('image-generation')) features.push('🎨');
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Box>
|
|
50
|
+
<Text color={isSelected ? 'cyan' : undefined}>{prefix}</Text>
|
|
51
|
+
{nameDisplay}
|
|
52
|
+
{isCurrent && <Text color="green"> ✓</Text>}
|
|
53
|
+
{features.length > 0 && <Text> {features.join('')}</Text>}
|
|
54
|
+
{model.size != null && model.size > 0 && <Text dimColor> {(model.size / 1e9).toFixed(1)}GB</Text>}
|
|
55
|
+
</Box>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Provider header
|
|
61
|
+
*/
|
|
62
|
+
function ProviderHeader({ name, icon, hasKey }) {
|
|
63
|
+
return (
|
|
64
|
+
<Box marginTop={1}>
|
|
65
|
+
<Text>{icon} </Text>
|
|
66
|
+
<Text bold>{name}</Text>
|
|
67
|
+
{!hasKey && <Text color="red"> (no key)</Text>}
|
|
68
|
+
</Box>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* ModelSelector component
|
|
74
|
+
* Responsive: adapts visible items to terminal height
|
|
75
|
+
*/
|
|
76
|
+
export function ModelSelector({
|
|
77
|
+
currentModel,
|
|
78
|
+
onSelect,
|
|
79
|
+
onCancel,
|
|
80
|
+
isVisible = true,
|
|
81
|
+
}) {
|
|
82
|
+
const { rows, isNarrow } = useTerminal();
|
|
83
|
+
const [models, setModels] = useState([]);
|
|
84
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
85
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
86
|
+
const [ollamaModels, setOllamaModels] = useState([]);
|
|
87
|
+
const [ollamaAvailable, setOllamaAvailable] = useState(false);
|
|
88
|
+
|
|
89
|
+
// Responsive: adjust visible items based on terminal height
|
|
90
|
+
const maxVisible = Math.max(5, Math.min(15, rows - 8));
|
|
91
|
+
const { isFocused } = useFocus({ autoFocus: true, isActive: isVisible });
|
|
92
|
+
|
|
93
|
+
// Load available models
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!isVisible) return;
|
|
96
|
+
|
|
97
|
+
const loadModels = async () => {
|
|
98
|
+
const apiKeys = config.get('apiKeys') || {};
|
|
99
|
+
const loadedModels = [];
|
|
100
|
+
|
|
101
|
+
// Provider configurations (static models)
|
|
102
|
+
const providers = [
|
|
103
|
+
{ key: 'anthropic', name: 'Anthropic', models: MODEL_DATA.apiModels.anthropic },
|
|
104
|
+
{ key: 'openai', name: 'OpenAI', models: MODEL_DATA.apiModels.openai },
|
|
105
|
+
{ key: 'google', name: 'Google', models: MODEL_DATA.apiModels.google },
|
|
106
|
+
{ key: 'xai', name: 'xAI', models: MODEL_DATA.apiModels.xai },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
for (const provider of providers) {
|
|
110
|
+
if (apiKeys[provider.key]) {
|
|
111
|
+
for (const model of provider.models) {
|
|
112
|
+
loadedModels.push({
|
|
113
|
+
id: model.id,
|
|
114
|
+
name: model.name,
|
|
115
|
+
provider: provider.name,
|
|
116
|
+
providerKey: provider.key,
|
|
117
|
+
type: model.type,
|
|
118
|
+
maxTokens: model.maxTokens,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Try to load OpenRouter models (dynamic)
|
|
125
|
+
if (apiKeys.openrouter) {
|
|
126
|
+
try {
|
|
127
|
+
const { fetchOpenRouterModels } = await import('../../inference/openrouter.js');
|
|
128
|
+
const orModels = await fetchOpenRouterModels(apiKeys.openrouter);
|
|
129
|
+
for (const model of orModels) {
|
|
130
|
+
loadedModels.push({
|
|
131
|
+
id: model.id,
|
|
132
|
+
name: model.name,
|
|
133
|
+
provider: 'OpenRouter',
|
|
134
|
+
providerKey: 'openrouter',
|
|
135
|
+
type: model.type,
|
|
136
|
+
maxTokens: model.maxTokens,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// OpenRouter fetch failed, continue without
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Try to load Ollama models
|
|
145
|
+
try {
|
|
146
|
+
const ollamaUrl = config.get('ollamaUrl') || 'http://localhost:11434';
|
|
147
|
+
const response = await fetch(`${ollamaUrl}/api/tags`, {
|
|
148
|
+
signal: AbortSignal.timeout(2000)
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (response.ok) {
|
|
152
|
+
const data = await response.json();
|
|
153
|
+
const ollama = (data.models || []).map(m => ({
|
|
154
|
+
id: `ollama:${m.name}`,
|
|
155
|
+
name: m.name,
|
|
156
|
+
provider: 'Ollama',
|
|
157
|
+
providerKey: 'ollama',
|
|
158
|
+
size: m.size,
|
|
159
|
+
}));
|
|
160
|
+
setOllamaModels(ollama);
|
|
161
|
+
setOllamaAvailable(true);
|
|
162
|
+
loadedModels.push(...ollama);
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
setOllamaAvailable(false);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setModels(loadedModels);
|
|
169
|
+
|
|
170
|
+
// Find current model index
|
|
171
|
+
const currentIndex = loadedModels.findIndex(m => m.id === currentModel);
|
|
172
|
+
if (currentIndex >= 0) {
|
|
173
|
+
setSelectedIndex(currentIndex);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
loadModels();
|
|
178
|
+
}, [isVisible, currentModel]);
|
|
179
|
+
|
|
180
|
+
// Handle keyboard input
|
|
181
|
+
useInput((input, key) => {
|
|
182
|
+
if (!isVisible || !isFocused) return;
|
|
183
|
+
|
|
184
|
+
// Navigation
|
|
185
|
+
if (key.upArrow || input === 'k') {
|
|
186
|
+
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (key.downArrow || input === 'j') {
|
|
191
|
+
setSelectedIndex(prev => Math.min(models.length - 1, prev + 1));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Page up/down
|
|
196
|
+
if (key.pageUp) {
|
|
197
|
+
setSelectedIndex(prev => Math.max(0, prev - maxVisible));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (key.pageDown) {
|
|
202
|
+
setSelectedIndex(prev => Math.min(models.length - 1, prev + maxVisible));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Select
|
|
207
|
+
if (key.return || input === ' ') {
|
|
208
|
+
const selected = models[selectedIndex];
|
|
209
|
+
if (selected) {
|
|
210
|
+
onSelect?.(selected);
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Cancel
|
|
216
|
+
if (key.escape || input === 'q') {
|
|
217
|
+
onCancel?.();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}, { isActive: isVisible && isFocused });
|
|
221
|
+
|
|
222
|
+
// Adjust scroll offset
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (selectedIndex < scrollOffset) {
|
|
225
|
+
setScrollOffset(selectedIndex);
|
|
226
|
+
} else if (selectedIndex >= scrollOffset + maxVisible) {
|
|
227
|
+
setScrollOffset(selectedIndex - maxVisible + 1);
|
|
228
|
+
}
|
|
229
|
+
}, [selectedIndex, scrollOffset]);
|
|
230
|
+
|
|
231
|
+
if (!isVisible) return null;
|
|
232
|
+
|
|
233
|
+
// Group models by provider for display
|
|
234
|
+
const visibleModels = models.slice(scrollOffset, scrollOffset + maxVisible);
|
|
235
|
+
|
|
236
|
+
// Track current provider for headers
|
|
237
|
+
let currentProvider = null;
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" padding={1}>
|
|
241
|
+
{/* Header - responsive hints */}
|
|
242
|
+
<Box marginBottom={1}>
|
|
243
|
+
<Text color="cyan" bold>Select Model</Text>
|
|
244
|
+
{isNarrow ? (
|
|
245
|
+
<Text dimColor> (↑↓ Enter Esc)</Text>
|
|
246
|
+
) : (
|
|
247
|
+
<Text dimColor> (↑/↓ navigate, Enter select, Esc cancel)</Text>
|
|
248
|
+
)}
|
|
249
|
+
</Box>
|
|
250
|
+
|
|
251
|
+
{/* Scroll up indicator */}
|
|
252
|
+
{scrollOffset > 0 && (
|
|
253
|
+
<Box>
|
|
254
|
+
<Text dimColor> ↑ more models above...</Text>
|
|
255
|
+
</Box>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{/* Model list */}
|
|
259
|
+
{models.length === 0 ? (
|
|
260
|
+
<Box>
|
|
261
|
+
<Text color="yellow">No models available.</Text>
|
|
262
|
+
<Text dimColor> Configure API keys or start Ollama.</Text>
|
|
263
|
+
</Box>
|
|
264
|
+
) : (
|
|
265
|
+
visibleModels.map((model, i) => {
|
|
266
|
+
const actualIndex = scrollOffset + i;
|
|
267
|
+
const showProvider = model.provider !== currentProvider;
|
|
268
|
+
currentProvider = model.provider;
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<Box key={model.id} flexDirection="column">
|
|
272
|
+
{showProvider && (
|
|
273
|
+
<ProviderHeader
|
|
274
|
+
name={model.provider}
|
|
275
|
+
icon={PROVIDER_ICONS[model.providerKey] || '●'}
|
|
276
|
+
hasKey={true}
|
|
277
|
+
/>
|
|
278
|
+
)}
|
|
279
|
+
<ModelItem
|
|
280
|
+
model={model}
|
|
281
|
+
isSelected={selectedIndex === actualIndex}
|
|
282
|
+
isCurrent={model.id === currentModel}
|
|
283
|
+
/>
|
|
284
|
+
</Box>
|
|
285
|
+
);
|
|
286
|
+
})
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
{/* Scroll down indicator */}
|
|
290
|
+
{scrollOffset + maxVisible < models.length && (
|
|
291
|
+
<Box>
|
|
292
|
+
<Text dimColor> ↓ more models below...</Text>
|
|
293
|
+
</Box>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
{/* Footer */}
|
|
297
|
+
<Box marginTop={1}>
|
|
298
|
+
<Text dimColor>{models.length} models available</Text>
|
|
299
|
+
</Box>
|
|
300
|
+
</Box>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export default ModelSelector;
|