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,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header component
|
|
3
|
+
* Displays the app banner, connection status, and current model
|
|
4
|
+
* Features typewriter animation and gradient effects
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
8
|
+
import { Box, Text } from 'ink';
|
|
9
|
+
import { getFriendlyModelName } from '../utils/formatters.js';
|
|
10
|
+
import { ConnectionState } from '../hooks/useWebSocket.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ASCII art banner for Otherwise (as array of lines for animation)
|
|
14
|
+
*/
|
|
15
|
+
const BANNER_LINES = [
|
|
16
|
+
' ██████╗ ████████╗██╗ ██╗███████╗██████╗ ██╗ ██╗██╗███████╗███████╗',
|
|
17
|
+
'██╔═══██╗╚══██╔══╝██║ ██║██╔════╝██╔══██╗██║ ██║██║██╔════╝██╔════╝',
|
|
18
|
+
'██║ ██║ ██║ ███████║█████╗ ██████╔╝██║ █╗ ██║██║███████╗█████╗ ',
|
|
19
|
+
'██║ ██║ ██║ ██╔══██║██╔══╝ ██╔══██╗██║███╗██║██║╚════██║██╔══╝ ',
|
|
20
|
+
'╚██████╔╝ ██║ ██║ ██║███████╗██║ ██║╚███╔███╔╝██║███████║███████╗',
|
|
21
|
+
' ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ╚═╝╚══════╝╚══════╝',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const TAGLINE = ' Your AI that lives on your computer';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Gradient colors for banner
|
|
28
|
+
*/
|
|
29
|
+
const BANNER_GRADIENT = [
|
|
30
|
+
'#22d3ee', // Cyan 400
|
|
31
|
+
'#06b6d4', // Cyan 500
|
|
32
|
+
'#0891b2', // Cyan 600
|
|
33
|
+
'#0e7490', // Cyan 700
|
|
34
|
+
'#155e75', // Cyan 800
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Animated banner with typewriter effect
|
|
39
|
+
*/
|
|
40
|
+
export function AnimatedBanner({ animate = true, speed = 15 }) {
|
|
41
|
+
const [visibleLines, setVisibleLines] = useState(animate ? 0 : BANNER_LINES.length);
|
|
42
|
+
const [visibleChars, setVisibleChars] = useState(animate ? 0 : 1000);
|
|
43
|
+
const [taglineVisible, setTaglineVisible] = useState(!animate);
|
|
44
|
+
const [colorOffset, setColorOffset] = useState(0);
|
|
45
|
+
|
|
46
|
+
// Typewriter effect for banner lines
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!animate) return;
|
|
49
|
+
|
|
50
|
+
// Reveal lines one by one
|
|
51
|
+
if (visibleLines < BANNER_LINES.length) {
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
setVisibleLines(v => v + 1);
|
|
54
|
+
}, speed * 5);
|
|
55
|
+
return () => clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Then reveal characters within each line
|
|
59
|
+
const totalChars = BANNER_LINES.join('').length;
|
|
60
|
+
if (visibleChars < totalChars) {
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
setVisibleChars(v => Math.min(v + 5, totalChars));
|
|
63
|
+
}, speed);
|
|
64
|
+
return () => clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Finally show tagline
|
|
68
|
+
if (!taglineVisible) {
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
setTaglineVisible(true);
|
|
71
|
+
}, 200);
|
|
72
|
+
return () => clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
}, [animate, visibleLines, visibleChars, taglineVisible, speed]);
|
|
75
|
+
|
|
76
|
+
// Color cycling for shimmer effect
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const timer = setInterval(() => {
|
|
79
|
+
setColorOffset(o => (o + 1) % BANNER_GRADIENT.length);
|
|
80
|
+
}, 200);
|
|
81
|
+
return () => clearInterval(timer);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
// Calculate which characters are visible
|
|
85
|
+
const getLineContent = (lineIndex) => {
|
|
86
|
+
if (lineIndex >= visibleLines) return '';
|
|
87
|
+
|
|
88
|
+
const line = BANNER_LINES[lineIndex];
|
|
89
|
+
let charsBefore = 0;
|
|
90
|
+
for (let i = 0; i < lineIndex; i++) {
|
|
91
|
+
charsBefore += BANNER_LINES[i].length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const charsToShow = Math.max(0, visibleChars - charsBefore);
|
|
95
|
+
return line.substring(0, Math.min(charsToShow, line.length));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<Box flexDirection="column">
|
|
100
|
+
{BANNER_LINES.map((line, i) => {
|
|
101
|
+
const content = getLineContent(i);
|
|
102
|
+
const color = BANNER_GRADIENT[(i + colorOffset) % BANNER_GRADIENT.length];
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Text key={i} color={color}>
|
|
106
|
+
{content}
|
|
107
|
+
{content.length < line.length && content.length > 0 && (
|
|
108
|
+
<Text color="#ffffff">▌</Text>
|
|
109
|
+
)}
|
|
110
|
+
</Text>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
{taglineVisible && (
|
|
114
|
+
<TaglineText text={TAGLINE} animate={animate} />
|
|
115
|
+
)}
|
|
116
|
+
</Box>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Tagline with fade-in effect
|
|
122
|
+
*/
|
|
123
|
+
function TaglineText({ text, animate }) {
|
|
124
|
+
const [opacity, setOpacity] = useState(animate ? 0 : 1);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!animate) return;
|
|
128
|
+
|
|
129
|
+
const steps = [0.3, 0.6, 0.8, 1];
|
|
130
|
+
let step = 0;
|
|
131
|
+
|
|
132
|
+
const timer = setInterval(() => {
|
|
133
|
+
if (step < steps.length) {
|
|
134
|
+
setOpacity(steps[step]);
|
|
135
|
+
step++;
|
|
136
|
+
} else {
|
|
137
|
+
clearInterval(timer);
|
|
138
|
+
}
|
|
139
|
+
}, 80);
|
|
140
|
+
|
|
141
|
+
return () => clearInterval(timer);
|
|
142
|
+
}, [animate]);
|
|
143
|
+
|
|
144
|
+
const color = opacity < 0.5 ? '#4b5563' : opacity < 1 ? '#6b7280' : '#9ca3af';
|
|
145
|
+
|
|
146
|
+
return <Text color={color}>{text}</Text>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Simple banner (no animation)
|
|
151
|
+
*/
|
|
152
|
+
export function Banner() {
|
|
153
|
+
return (
|
|
154
|
+
<Box flexDirection="column">
|
|
155
|
+
{BANNER_LINES.map((line, i) => (
|
|
156
|
+
<Text key={i} color="cyan">{line}</Text>
|
|
157
|
+
))}
|
|
158
|
+
<Text dimColor>{TAGLINE}</Text>
|
|
159
|
+
</Box>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Animated connection dot
|
|
165
|
+
*/
|
|
166
|
+
function ConnectionDot({ state }) {
|
|
167
|
+
const [frame, setFrame] = useState(0);
|
|
168
|
+
const isAnimating = state === ConnectionState.CONNECTING || state === ConnectionState.RECONNECTING;
|
|
169
|
+
|
|
170
|
+
// Pulsing animation for connecting states
|
|
171
|
+
const pulseFrames = ['○', '◔', '◑', '◕', '●', '◕', '◑', '◔'];
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
if (!isAnimating) return;
|
|
175
|
+
|
|
176
|
+
const timer = setInterval(() => {
|
|
177
|
+
setFrame(f => (f + 1) % pulseFrames.length);
|
|
178
|
+
}, 150);
|
|
179
|
+
|
|
180
|
+
return () => clearInterval(timer);
|
|
181
|
+
}, [isAnimating]);
|
|
182
|
+
|
|
183
|
+
let icon, color;
|
|
184
|
+
|
|
185
|
+
switch (state) {
|
|
186
|
+
case ConnectionState.CONNECTED:
|
|
187
|
+
icon = '●';
|
|
188
|
+
color = '#22c55e';
|
|
189
|
+
break;
|
|
190
|
+
case ConnectionState.CONNECTING:
|
|
191
|
+
case ConnectionState.RECONNECTING:
|
|
192
|
+
icon = pulseFrames[frame];
|
|
193
|
+
color = '#f59e0b';
|
|
194
|
+
break;
|
|
195
|
+
case ConnectionState.ERROR:
|
|
196
|
+
icon = '✗';
|
|
197
|
+
color = '#ef4444';
|
|
198
|
+
break;
|
|
199
|
+
default:
|
|
200
|
+
icon = '○';
|
|
201
|
+
color = '#ef4444';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return <Text color={color}>{icon}</Text>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Connection status indicator with animation
|
|
209
|
+
*/
|
|
210
|
+
export function ConnectionStatus({ connectionState, serverUrl, compact = false }) {
|
|
211
|
+
const [showSuccess, setShowSuccess] = useState(false);
|
|
212
|
+
|
|
213
|
+
// Flash success animation when connected
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (connectionState === ConnectionState.CONNECTED) {
|
|
216
|
+
setShowSuccess(true);
|
|
217
|
+
const timer = setTimeout(() => setShowSuccess(false), 2000);
|
|
218
|
+
return () => clearTimeout(timer);
|
|
219
|
+
}
|
|
220
|
+
}, [connectionState]);
|
|
221
|
+
|
|
222
|
+
let statusText;
|
|
223
|
+
let statusColor;
|
|
224
|
+
|
|
225
|
+
switch (connectionState) {
|
|
226
|
+
case ConnectionState.CONNECTED:
|
|
227
|
+
statusText = showSuccess ? 'Connected!' : 'Connected';
|
|
228
|
+
statusColor = '#22c55e';
|
|
229
|
+
break;
|
|
230
|
+
case ConnectionState.CONNECTING:
|
|
231
|
+
statusText = 'Connecting';
|
|
232
|
+
statusColor = '#f59e0b';
|
|
233
|
+
break;
|
|
234
|
+
case ConnectionState.RECONNECTING:
|
|
235
|
+
statusText = 'Reconnecting';
|
|
236
|
+
statusColor = '#f59e0b';
|
|
237
|
+
break;
|
|
238
|
+
case ConnectionState.ERROR:
|
|
239
|
+
statusText = 'Error';
|
|
240
|
+
statusColor = '#ef4444';
|
|
241
|
+
break;
|
|
242
|
+
default:
|
|
243
|
+
statusText = 'Disconnected';
|
|
244
|
+
statusColor = '#ef4444';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (compact) {
|
|
248
|
+
return <ConnectionDot state={connectionState} />;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<Box>
|
|
253
|
+
<ConnectionDot state={connectionState} />
|
|
254
|
+
<Text color={statusColor}> {statusText}</Text>
|
|
255
|
+
</Box>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Model indicator with icon
|
|
261
|
+
*/
|
|
262
|
+
export function ModelIndicator({ model, showLabel = true }) {
|
|
263
|
+
const displayName = getFriendlyModelName(model);
|
|
264
|
+
|
|
265
|
+
// Determine provider icon
|
|
266
|
+
let icon = '🤖';
|
|
267
|
+
if (model?.includes('claude') || model?.includes('anthropic')) {
|
|
268
|
+
icon = '🟣';
|
|
269
|
+
} else if (model?.includes('gpt') || model?.includes('openai') || model?.includes('o1') || model?.includes('o3')) {
|
|
270
|
+
icon = '🟢';
|
|
271
|
+
} else if (model?.includes('gemini') || model?.includes('google')) {
|
|
272
|
+
icon = '🔵';
|
|
273
|
+
} else if (model?.includes('grok') || model?.includes('xai')) {
|
|
274
|
+
icon = '⚫';
|
|
275
|
+
} else if (model?.includes('ollama') || model?.includes('llama') || model?.includes('mistral')) {
|
|
276
|
+
icon = '🦙';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<Box>
|
|
281
|
+
<Text>{icon} </Text>
|
|
282
|
+
{showLabel && <Text dimColor>Model: </Text>}
|
|
283
|
+
<Text color="#a855f7">{displayName}</Text>
|
|
284
|
+
</Box>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Chat title indicator with animation
|
|
290
|
+
*/
|
|
291
|
+
export function ChatTitle({ title, chatId, isNew = false }) {
|
|
292
|
+
const [blink, setBlink] = useState(true);
|
|
293
|
+
|
|
294
|
+
// Blinking cursor for new chat
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
if (!isNew && chatId) return;
|
|
297
|
+
|
|
298
|
+
const timer = setInterval(() => {
|
|
299
|
+
setBlink(b => !b);
|
|
300
|
+
}, 500);
|
|
301
|
+
|
|
302
|
+
return () => clearInterval(timer);
|
|
303
|
+
}, [isNew, chatId]);
|
|
304
|
+
|
|
305
|
+
if (!chatId) {
|
|
306
|
+
return (
|
|
307
|
+
<Box>
|
|
308
|
+
<Text color="#06b6d4">✨ New chat</Text>
|
|
309
|
+
{blink && <Text color="#06b6d4">▌</Text>}
|
|
310
|
+
</Box>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<Box>
|
|
316
|
+
<Text color="#06b6d4">💬 </Text>
|
|
317
|
+
<Text color="cyan" bold>{title || `Chat #${chatId}`}</Text>
|
|
318
|
+
</Box>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Divider line with optional label
|
|
324
|
+
*/
|
|
325
|
+
export function Divider({ label = '', char = '─', width = 50, color = '#4b5563' }) {
|
|
326
|
+
if (!label) {
|
|
327
|
+
return <Text color={color}>{char.repeat(width)}</Text>;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const labelWidth = label.length + 2;
|
|
331
|
+
const sideWidth = Math.floor((width - labelWidth) / 2);
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<Box>
|
|
335
|
+
<Text color={color}>{char.repeat(sideWidth)} </Text>
|
|
336
|
+
<Text dimColor>{label}</Text>
|
|
337
|
+
<Text color={color}> {char.repeat(sideWidth)}</Text>
|
|
338
|
+
</Box>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Main Header component
|
|
344
|
+
*/
|
|
345
|
+
export function Header({
|
|
346
|
+
connectionState,
|
|
347
|
+
serverUrl,
|
|
348
|
+
model,
|
|
349
|
+
chatTitle,
|
|
350
|
+
chatId,
|
|
351
|
+
showBanner = false,
|
|
352
|
+
animateBanner = true,
|
|
353
|
+
}) {
|
|
354
|
+
return (
|
|
355
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
356
|
+
{showBanner && (
|
|
357
|
+
animateBanner
|
|
358
|
+
? <AnimatedBanner animate={true} />
|
|
359
|
+
: <Banner />
|
|
360
|
+
)}
|
|
361
|
+
|
|
362
|
+
<Box marginTop={showBanner ? 1 : 0}>
|
|
363
|
+
<Text color="#374151">╭</Text>
|
|
364
|
+
<Text color="#374151">{'─'.repeat(70)}</Text>
|
|
365
|
+
<Text color="#374151">╮</Text>
|
|
366
|
+
</Box>
|
|
367
|
+
|
|
368
|
+
<Box justifyContent="space-between" paddingX={1}>
|
|
369
|
+
<ChatTitle title={chatTitle} chatId={chatId} />
|
|
370
|
+
<Box>
|
|
371
|
+
<ModelIndicator model={model} showLabel={false} />
|
|
372
|
+
<Text dimColor> │ </Text>
|
|
373
|
+
<ConnectionStatus connectionState={connectionState} compact={false} />
|
|
374
|
+
</Box>
|
|
375
|
+
</Box>
|
|
376
|
+
|
|
377
|
+
<Box>
|
|
378
|
+
<Text color="#374151">╰</Text>
|
|
379
|
+
<Text color="#374151">{'─'.repeat(70)}</Text>
|
|
380
|
+
<Text color="#374151">╯</Text>
|
|
381
|
+
</Box>
|
|
382
|
+
</Box>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Compact status bar (alternative to full header)
|
|
388
|
+
*/
|
|
389
|
+
export function StatusBar({
|
|
390
|
+
chatTitle,
|
|
391
|
+
chatId,
|
|
392
|
+
model,
|
|
393
|
+
connectionState,
|
|
394
|
+
showBorder = false,
|
|
395
|
+
}) {
|
|
396
|
+
const modelName = getFriendlyModelName(model);
|
|
397
|
+
|
|
398
|
+
const content = (
|
|
399
|
+
<Box paddingX={showBorder ? 1 : 0}>
|
|
400
|
+
<Text color="cyan">{chatTitle || (chatId ? `Chat #${chatId}` : '✨ New')}</Text>
|
|
401
|
+
<Text dimColor> · </Text>
|
|
402
|
+
<Text color="#a855f7">{modelName}</Text>
|
|
403
|
+
<Text dimColor> · </Text>
|
|
404
|
+
<ConnectionStatus connectionState={connectionState} compact={true} />
|
|
405
|
+
</Box>
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
if (!showBorder) return content;
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<Box flexDirection="column">
|
|
412
|
+
<Box>
|
|
413
|
+
<Text color="#374151">╭{'─'.repeat(60)}╮</Text>
|
|
414
|
+
</Box>
|
|
415
|
+
{content}
|
|
416
|
+
<Box>
|
|
417
|
+
<Text color="#374151">╰{'─'.repeat(60)}╯</Text>
|
|
418
|
+
</Box>
|
|
419
|
+
</Box>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Welcome message for new users
|
|
425
|
+
*/
|
|
426
|
+
export function WelcomeMessage() {
|
|
427
|
+
return (
|
|
428
|
+
<Box flexDirection="column" marginY={1} paddingX={2}>
|
|
429
|
+
<Box borderStyle="round" borderColor="#374151" paddingX={2} paddingY={1}>
|
|
430
|
+
<Box flexDirection="column">
|
|
431
|
+
<Text color="#06b6d4" bold>Welcome to Otherwise! 👋</Text>
|
|
432
|
+
<Text> </Text>
|
|
433
|
+
<Text>Quick tips:</Text>
|
|
434
|
+
<Text dimColor> • Type your message and press Enter to send</Text>
|
|
435
|
+
<Text dimColor> • Use @ to attach files for context</Text>
|
|
436
|
+
<Text dimColor> • Type /help for all commands</Text>
|
|
437
|
+
<Text dimColor> • Press Ctrl+C to stop generation</Text>
|
|
438
|
+
</Box>
|
|
439
|
+
</Box>
|
|
440
|
+
</Box>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export default Header;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HelpPanel component
|
|
3
|
+
* Displays available commands and keyboard shortcuts
|
|
4
|
+
*
|
|
5
|
+
* Responsive: adapts to terminal width for optimal display
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import { Box, Text, useInput } from 'ink';
|
|
10
|
+
import { COMMANDS } from '../hooks/useCommands.js';
|
|
11
|
+
import { useTerminal } from '../context/TerminalContext.jsx';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Command item display
|
|
15
|
+
*/
|
|
16
|
+
function CommandItem({ name, usage, description, aliases }) {
|
|
17
|
+
return (
|
|
18
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
19
|
+
<Box>
|
|
20
|
+
<Text color="yellow">{usage}</Text>
|
|
21
|
+
{aliases.length > 0 && (
|
|
22
|
+
<Text dimColor> ({aliases.join(', ')})</Text>
|
|
23
|
+
)}
|
|
24
|
+
</Box>
|
|
25
|
+
<Box marginLeft={2}>
|
|
26
|
+
<Text dimColor>{description}</Text>
|
|
27
|
+
</Box>
|
|
28
|
+
</Box>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Section header
|
|
34
|
+
*/
|
|
35
|
+
function SectionHeader({ title }) {
|
|
36
|
+
return (
|
|
37
|
+
<Box marginTop={1} marginBottom={1}>
|
|
38
|
+
<Text color="cyan" bold>{title}</Text>
|
|
39
|
+
</Box>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Keyboard shortcut item
|
|
45
|
+
* Responsive: adjusts key column width based on terminal size
|
|
46
|
+
*/
|
|
47
|
+
function ShortcutItem({ keys, description, keyWidth = 20 }) {
|
|
48
|
+
return (
|
|
49
|
+
<Box>
|
|
50
|
+
<Box width={keyWidth}>
|
|
51
|
+
<Text color="yellow">{keys}</Text>
|
|
52
|
+
</Box>
|
|
53
|
+
<Text dimColor wrap="truncate">{description}</Text>
|
|
54
|
+
</Box>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* HelpPanel component
|
|
60
|
+
* Responsive: adapts panel width and content layout to terminal size
|
|
61
|
+
*/
|
|
62
|
+
export function HelpPanel({ onClose, isVisible = true }) {
|
|
63
|
+
const { columns, isNarrow, useCompactMode } = useTerminal();
|
|
64
|
+
|
|
65
|
+
// Handle keyboard input
|
|
66
|
+
useInput((input, key) => {
|
|
67
|
+
if (!isVisible) return;
|
|
68
|
+
|
|
69
|
+
// Any key closes help
|
|
70
|
+
if (key.escape || key.return || input === 'q' || input === '?') {
|
|
71
|
+
onClose?.();
|
|
72
|
+
}
|
|
73
|
+
}, { isActive: isVisible });
|
|
74
|
+
|
|
75
|
+
if (!isVisible) return null;
|
|
76
|
+
|
|
77
|
+
// Calculate responsive panel width
|
|
78
|
+
// Leave some margin on sides, cap at 70 for readability
|
|
79
|
+
const panelWidth = Math.min(70, Math.max(40, columns - 4));
|
|
80
|
+
const keyWidth = isNarrow ? 12 : 20;
|
|
81
|
+
|
|
82
|
+
// Compact mode: show minimal help
|
|
83
|
+
if (useCompactMode) {
|
|
84
|
+
return (
|
|
85
|
+
<Box
|
|
86
|
+
flexDirection="column"
|
|
87
|
+
borderStyle="single"
|
|
88
|
+
borderColor="cyan"
|
|
89
|
+
padding={1}
|
|
90
|
+
width={panelWidth}
|
|
91
|
+
>
|
|
92
|
+
<Box justifyContent="center" marginBottom={1}>
|
|
93
|
+
<Text color="cyan" bold>Help</Text>
|
|
94
|
+
</Box>
|
|
95
|
+
|
|
96
|
+
<Text dimColor>/help - commands</Text>
|
|
97
|
+
<Text dimColor>/new - new chat</Text>
|
|
98
|
+
<Text dimColor>/model - change model</Text>
|
|
99
|
+
<Text dimColor>@ - attach file</Text>
|
|
100
|
+
<Text dimColor>Ctrl+C - stop/exit</Text>
|
|
101
|
+
|
|
102
|
+
<Box marginTop={1} justifyContent="center">
|
|
103
|
+
<Text dimColor>Any key to close</Text>
|
|
104
|
+
</Box>
|
|
105
|
+
</Box>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<Box
|
|
111
|
+
flexDirection="column"
|
|
112
|
+
borderStyle="round"
|
|
113
|
+
borderColor="cyan"
|
|
114
|
+
padding={1}
|
|
115
|
+
width={panelWidth}
|
|
116
|
+
>
|
|
117
|
+
<Box justifyContent="center" marginBottom={1}>
|
|
118
|
+
<Text color="cyan" bold>Otherwise CLI Help</Text>
|
|
119
|
+
</Box>
|
|
120
|
+
|
|
121
|
+
<SectionHeader title="Commands" />
|
|
122
|
+
|
|
123
|
+
{Object.entries(COMMANDS).map(([name, cmd]) => (
|
|
124
|
+
<CommandItem
|
|
125
|
+
key={name}
|
|
126
|
+
name={name}
|
|
127
|
+
usage={cmd.usage}
|
|
128
|
+
description={isNarrow ? cmd.description.slice(0, 30) + (cmd.description.length > 30 ? '…' : '') : cmd.description}
|
|
129
|
+
aliases={isNarrow ? [] : cmd.aliases}
|
|
130
|
+
/>
|
|
131
|
+
))}
|
|
132
|
+
|
|
133
|
+
<SectionHeader title="File Attachments" />
|
|
134
|
+
|
|
135
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
136
|
+
<Box>
|
|
137
|
+
<Text color="yellow">@</Text>
|
|
138
|
+
<Text dimColor> or </Text>
|
|
139
|
+
<Text color="yellow">/attach</Text>
|
|
140
|
+
<Text dimColor> Open file picker</Text>
|
|
141
|
+
</Box>
|
|
142
|
+
{!isNarrow && (
|
|
143
|
+
<>
|
|
144
|
+
<Box marginLeft={2}>
|
|
145
|
+
<Text dimColor>↑↓ navigate • Enter select/enter folder</Text>
|
|
146
|
+
</Box>
|
|
147
|
+
<Box marginLeft={2}>
|
|
148
|
+
<Text dimColor>Tab attach folder contents • Backspace go up</Text>
|
|
149
|
+
</Box>
|
|
150
|
+
<Box marginLeft={2}>
|
|
151
|
+
<Text dimColor>Type to filter • Esc cancel</Text>
|
|
152
|
+
</Box>
|
|
153
|
+
</>
|
|
154
|
+
)}
|
|
155
|
+
</Box>
|
|
156
|
+
|
|
157
|
+
<SectionHeader title="Keyboard Shortcuts" />
|
|
158
|
+
|
|
159
|
+
<ShortcutItem keys="Ctrl+C" description="Stop generation or exit" keyWidth={keyWidth} />
|
|
160
|
+
<ShortcutItem keys="Ctrl+D" description="Exit" keyWidth={keyWidth} />
|
|
161
|
+
<ShortcutItem keys="Tab" description="Open file picker" keyWidth={keyWidth} />
|
|
162
|
+
<ShortcutItem keys="↑ / ↓" description="Navigate lists" keyWidth={keyWidth} />
|
|
163
|
+
<ShortcutItem keys="Enter" description="Submit / Select" keyWidth={keyWidth} />
|
|
164
|
+
<ShortcutItem keys="Escape" description="Cancel / Close" keyWidth={keyWidth} />
|
|
165
|
+
|
|
166
|
+
<Box marginTop={2} justifyContent="center">
|
|
167
|
+
<Text dimColor>Press any key to close</Text>
|
|
168
|
+
</Box>
|
|
169
|
+
</Box>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export default HelpPanel;
|