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