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