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,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop notifications hook for Ink UI
|
|
3
|
+
* Provides notifications for long-running tasks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useCallback, useRef, useEffect } from 'react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if desktop notifications are available
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
function areNotificationsAvailable() {
|
|
13
|
+
// Check for node-notifier or native notifications
|
|
14
|
+
try {
|
|
15
|
+
// In Node.js environment, we'll use terminal bell as fallback
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Send a terminal bell (audible notification)
|
|
24
|
+
*/
|
|
25
|
+
function terminalBell() {
|
|
26
|
+
process.stdout.write('\x07');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Send a native notification (if available)
|
|
31
|
+
* @param {object} options - Notification options
|
|
32
|
+
*/
|
|
33
|
+
async function sendNativeNotification({ title, message, sound = true }) {
|
|
34
|
+
try {
|
|
35
|
+
// Try to use node-notifier if available
|
|
36
|
+
const notifier = await import('node-notifier').catch(() => null);
|
|
37
|
+
|
|
38
|
+
if (notifier?.default?.notify) {
|
|
39
|
+
notifier.default.notify({
|
|
40
|
+
title: title || 'Otherwise',
|
|
41
|
+
message: message,
|
|
42
|
+
sound: sound,
|
|
43
|
+
wait: false,
|
|
44
|
+
});
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fallback to terminal bell
|
|
49
|
+
if (sound) {
|
|
50
|
+
terminalBell();
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
} catch {
|
|
54
|
+
// Fallback to terminal bell
|
|
55
|
+
if (sound) {
|
|
56
|
+
terminalBell();
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Custom hook for notifications
|
|
64
|
+
* @param {object} options - Configuration options
|
|
65
|
+
* @returns {object} - Notification utilities
|
|
66
|
+
*/
|
|
67
|
+
export function useNotifications(options = {}) {
|
|
68
|
+
const {
|
|
69
|
+
enabled = true,
|
|
70
|
+
soundEnabled = true,
|
|
71
|
+
minDuration = 30000, // Only notify for tasks > 30 seconds
|
|
72
|
+
notifyOnComplete = true,
|
|
73
|
+
notifyOnError = true,
|
|
74
|
+
} = options;
|
|
75
|
+
|
|
76
|
+
const taskStartTimes = useRef({});
|
|
77
|
+
const isAvailable = areNotificationsAvailable();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Start tracking a task for potential notification
|
|
81
|
+
* @param {string} taskId - Unique task identifier
|
|
82
|
+
*/
|
|
83
|
+
const startTask = useCallback((taskId) => {
|
|
84
|
+
if (!enabled) return;
|
|
85
|
+
taskStartTimes.current[taskId] = Date.now();
|
|
86
|
+
}, [enabled]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Complete a task and potentially send notification
|
|
90
|
+
* @param {string} taskId - Task identifier
|
|
91
|
+
* @param {object} options - Completion options
|
|
92
|
+
*/
|
|
93
|
+
const completeTask = useCallback(async (taskId, {
|
|
94
|
+
success = true,
|
|
95
|
+
message = '',
|
|
96
|
+
forceNotify = false,
|
|
97
|
+
} = {}) => {
|
|
98
|
+
if (!enabled) return;
|
|
99
|
+
|
|
100
|
+
const startTime = taskStartTimes.current[taskId];
|
|
101
|
+
if (!startTime) return;
|
|
102
|
+
|
|
103
|
+
const duration = Date.now() - startTime;
|
|
104
|
+
delete taskStartTimes.current[taskId];
|
|
105
|
+
|
|
106
|
+
// Check if we should notify
|
|
107
|
+
const shouldNotify = forceNotify || duration >= minDuration;
|
|
108
|
+
if (!shouldNotify) return;
|
|
109
|
+
|
|
110
|
+
if (!success && notifyOnError) {
|
|
111
|
+
await sendNativeNotification({
|
|
112
|
+
title: 'Otherwise - Task Failed',
|
|
113
|
+
message: message || 'A task failed to complete',
|
|
114
|
+
sound: soundEnabled,
|
|
115
|
+
});
|
|
116
|
+
} else if (success && notifyOnComplete) {
|
|
117
|
+
await sendNativeNotification({
|
|
118
|
+
title: 'Otherwise - Task Complete',
|
|
119
|
+
message: message || 'Your task has completed',
|
|
120
|
+
sound: soundEnabled,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}, [enabled, minDuration, notifyOnComplete, notifyOnError, soundEnabled]);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Send an immediate notification
|
|
127
|
+
* @param {string} title - Notification title
|
|
128
|
+
* @param {string} message - Notification message
|
|
129
|
+
* @param {object} options - Additional options
|
|
130
|
+
*/
|
|
131
|
+
const notify = useCallback(async (title, message, { sound = true } = {}) => {
|
|
132
|
+
if (!enabled) return false;
|
|
133
|
+
|
|
134
|
+
return sendNativeNotification({
|
|
135
|
+
title,
|
|
136
|
+
message,
|
|
137
|
+
sound: sound && soundEnabled,
|
|
138
|
+
});
|
|
139
|
+
}, [enabled, soundEnabled]);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Play just the bell sound
|
|
143
|
+
*/
|
|
144
|
+
const bell = useCallback(() => {
|
|
145
|
+
if (soundEnabled) {
|
|
146
|
+
terminalBell();
|
|
147
|
+
}
|
|
148
|
+
}, [soundEnabled]);
|
|
149
|
+
|
|
150
|
+
// Cleanup on unmount
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
return () => {
|
|
153
|
+
taskStartTimes.current = {};
|
|
154
|
+
};
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
isAvailable,
|
|
159
|
+
startTask,
|
|
160
|
+
completeTask,
|
|
161
|
+
notify,
|
|
162
|
+
bell,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Generation completion notification helper
|
|
168
|
+
* @param {object} stats - Generation stats
|
|
169
|
+
* @param {number} duration - Duration in ms
|
|
170
|
+
* @param {function} notify - Notify function
|
|
171
|
+
*/
|
|
172
|
+
export async function notifyGenerationComplete(stats, duration, notify) {
|
|
173
|
+
if (duration < 30000) return; // Don't notify for quick generations
|
|
174
|
+
|
|
175
|
+
const tokenStr = stats?.numTokens ? `${stats.numTokens} tokens` : '';
|
|
176
|
+
const tpsStr = stats?.tps ? `${Math.round(stats.tps)} tok/s` : '';
|
|
177
|
+
const parts = [tokenStr, tpsStr].filter(Boolean).join(' • ');
|
|
178
|
+
|
|
179
|
+
await notify(
|
|
180
|
+
'Generation Complete',
|
|
181
|
+
parts || 'AI has finished responding',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export default useNotifications;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTerminalSize hook
|
|
3
|
+
* Provides reactive terminal dimensions that update on resize
|
|
4
|
+
* Essential for responsive CLI rendering with Ink
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default terminal dimensions fallback
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_COLUMNS = 80;
|
|
13
|
+
const DEFAULT_ROWS = 24;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Breakpoint thresholds for responsive layouts
|
|
17
|
+
*/
|
|
18
|
+
const BREAKPOINTS = {
|
|
19
|
+
narrow: 60,
|
|
20
|
+
medium: 100,
|
|
21
|
+
wide: 140,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get current terminal dimensions
|
|
26
|
+
* @returns {{columns: number, rows: number}}
|
|
27
|
+
*/
|
|
28
|
+
function getTerminalSize() {
|
|
29
|
+
return {
|
|
30
|
+
columns: process.stdout.columns || DEFAULT_COLUMNS,
|
|
31
|
+
rows: process.stdout.rows || DEFAULT_ROWS,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Hook to track terminal size with automatic updates on resize
|
|
37
|
+
* @param {object} options - Configuration options
|
|
38
|
+
* @param {number} options.padding - Horizontal padding to subtract from content width (default: 4)
|
|
39
|
+
* @param {number} options.maxContentWidth - Maximum content width for readability (default: 120)
|
|
40
|
+
* @param {number} options.minContentWidth - Minimum content width (default: 40)
|
|
41
|
+
* @returns {object} Terminal size information and responsive utilities
|
|
42
|
+
*/
|
|
43
|
+
export function useTerminalSize(options = {}) {
|
|
44
|
+
const {
|
|
45
|
+
padding = 4,
|
|
46
|
+
maxContentWidth = 120,
|
|
47
|
+
minContentWidth = 40,
|
|
48
|
+
} = options;
|
|
49
|
+
|
|
50
|
+
const [size, setSize] = useState(getTerminalSize);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const handleResize = () => {
|
|
54
|
+
const newSize = getTerminalSize();
|
|
55
|
+
setSize(prev => {
|
|
56
|
+
// Only update if dimensions actually changed
|
|
57
|
+
if (prev.columns !== newSize.columns || prev.rows !== newSize.rows) {
|
|
58
|
+
return newSize;
|
|
59
|
+
}
|
|
60
|
+
return prev;
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Listen for resize events
|
|
65
|
+
process.stdout.on('resize', handleResize);
|
|
66
|
+
|
|
67
|
+
// Also check periodically in case resize event is missed
|
|
68
|
+
// (some terminals don't emit resize properly)
|
|
69
|
+
const interval = setInterval(handleResize, 1000);
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
process.stdout.off('resize', handleResize);
|
|
73
|
+
clearInterval(interval);
|
|
74
|
+
};
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// Compute responsive values
|
|
78
|
+
const responsive = useMemo(() => {
|
|
79
|
+
const { columns, rows } = size;
|
|
80
|
+
|
|
81
|
+
// Responsive breakpoints
|
|
82
|
+
const isNarrow = columns < BREAKPOINTS.narrow;
|
|
83
|
+
const isMedium = columns >= BREAKPOINTS.narrow && columns < BREAKPOINTS.medium;
|
|
84
|
+
const isWide = columns >= BREAKPOINTS.medium;
|
|
85
|
+
const isExtraWide = columns >= BREAKPOINTS.wide;
|
|
86
|
+
|
|
87
|
+
// Content width calculations
|
|
88
|
+
const rawContentWidth = columns - padding;
|
|
89
|
+
const contentWidth = Math.max(minContentWidth, Math.min(rawContentWidth, maxContentWidth));
|
|
90
|
+
|
|
91
|
+
// For text that should use more width on wide terminals
|
|
92
|
+
const textWidth = Math.max(minContentWidth, Math.min(rawContentWidth, 100));
|
|
93
|
+
|
|
94
|
+
// For UI elements like borders and dividers
|
|
95
|
+
const uiWidth = Math.max(30, Math.min(rawContentWidth, 60));
|
|
96
|
+
|
|
97
|
+
// Compact mode for very narrow terminals
|
|
98
|
+
const useCompactMode = columns < 50;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
columns,
|
|
102
|
+
rows,
|
|
103
|
+
isNarrow,
|
|
104
|
+
isMedium,
|
|
105
|
+
isWide,
|
|
106
|
+
isExtraWide,
|
|
107
|
+
contentWidth,
|
|
108
|
+
textWidth,
|
|
109
|
+
uiWidth,
|
|
110
|
+
useCompactMode,
|
|
111
|
+
// Raw values for custom calculations
|
|
112
|
+
rawWidth: columns,
|
|
113
|
+
rawHeight: rows,
|
|
114
|
+
};
|
|
115
|
+
}, [size, padding, maxContentWidth, minContentWidth]);
|
|
116
|
+
|
|
117
|
+
return responsive;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get terminal width synchronously (for non-React contexts)
|
|
122
|
+
* @returns {number} Current terminal width
|
|
123
|
+
*/
|
|
124
|
+
export function getTerminalWidth() {
|
|
125
|
+
return process.stdout.columns || DEFAULT_COLUMNS;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get terminal height synchronously (for non-React contexts)
|
|
130
|
+
* @returns {number} Current terminal height
|
|
131
|
+
*/
|
|
132
|
+
export function getTerminalHeight() {
|
|
133
|
+
return process.stdout.rows || DEFAULT_ROWS;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Calculate responsive width based on terminal size
|
|
138
|
+
* @param {number} baseWidth - Base width at 100 columns
|
|
139
|
+
* @param {object} options - Options
|
|
140
|
+
* @param {number} options.min - Minimum width
|
|
141
|
+
* @param {number} options.max - Maximum width
|
|
142
|
+
* @returns {number} Scaled width
|
|
143
|
+
*/
|
|
144
|
+
export function calculateResponsiveWidth(baseWidth, options = {}) {
|
|
145
|
+
const { min = 20, max = baseWidth * 1.5 } = options;
|
|
146
|
+
const terminalWidth = getTerminalWidth();
|
|
147
|
+
const scale = terminalWidth / 100;
|
|
148
|
+
return Math.max(min, Math.min(Math.floor(baseWidth * scale), max));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default useTerminalSize;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket connection hook for Ink UI
|
|
3
|
+
* Manages real-time communication with the Otherwise server
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* WebSocket connection states
|
|
11
|
+
*/
|
|
12
|
+
export const ConnectionState = {
|
|
13
|
+
CONNECTING: 'connecting',
|
|
14
|
+
CONNECTED: 'connected',
|
|
15
|
+
DISCONNECTED: 'disconnected',
|
|
16
|
+
RECONNECTING: 'reconnecting',
|
|
17
|
+
ERROR: 'error',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Custom hook for WebSocket connection management
|
|
22
|
+
* @param {string} serverUrl - Base server URL (http://localhost:3000)
|
|
23
|
+
* @param {object} options - Configuration options
|
|
24
|
+
* @returns {object} - WebSocket state and methods
|
|
25
|
+
*/
|
|
26
|
+
export function useWebSocket(serverUrl, options = {}) {
|
|
27
|
+
const {
|
|
28
|
+
autoConnect = true,
|
|
29
|
+
autoReconnect = true,
|
|
30
|
+
reconnectDelay = 2000,
|
|
31
|
+
maxReconnectAttempts = 10,
|
|
32
|
+
onMessage,
|
|
33
|
+
onConnect,
|
|
34
|
+
onDisconnect,
|
|
35
|
+
onError,
|
|
36
|
+
} = options;
|
|
37
|
+
|
|
38
|
+
const wsUrl = serverUrl.replace('http', 'ws') + '/ws';
|
|
39
|
+
|
|
40
|
+
const [connectionState, setConnectionState] = useState(ConnectionState.DISCONNECTED);
|
|
41
|
+
const [error, setError] = useState(null);
|
|
42
|
+
|
|
43
|
+
const wsRef = useRef(null);
|
|
44
|
+
const reconnectAttemptsRef = useRef(0);
|
|
45
|
+
const reconnectTimeoutRef = useRef(null);
|
|
46
|
+
const mountedRef = useRef(true);
|
|
47
|
+
const hasConnectedRef = useRef(false); // Track if we've ever connected
|
|
48
|
+
const connectionStateRef = useRef(connectionState); // Track current state in ref
|
|
49
|
+
|
|
50
|
+
// Keep ref in sync with state
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
connectionStateRef.current = connectionState;
|
|
53
|
+
}, [connectionState]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Schedule a reconnection attempt
|
|
57
|
+
*/
|
|
58
|
+
const scheduleReconnect = useCallback(() => {
|
|
59
|
+
if (!autoReconnect) return;
|
|
60
|
+
if (reconnectAttemptsRef.current >= maxReconnectAttempts) return;
|
|
61
|
+
if (!mountedRef.current) return;
|
|
62
|
+
|
|
63
|
+
reconnectAttemptsRef.current++;
|
|
64
|
+
setConnectionState(ConnectionState.RECONNECTING);
|
|
65
|
+
|
|
66
|
+
reconnectTimeoutRef.current = setTimeout(() => {
|
|
67
|
+
if (mountedRef.current) {
|
|
68
|
+
if (wsRef.current) {
|
|
69
|
+
wsRef.current.removeAllListeners();
|
|
70
|
+
wsRef.current = null;
|
|
71
|
+
}
|
|
72
|
+
setConnectionState(ConnectionState.CONNECTING);
|
|
73
|
+
createConnection();
|
|
74
|
+
}
|
|
75
|
+
}, reconnectDelay);
|
|
76
|
+
}, [autoReconnect, maxReconnectAttempts, reconnectDelay]);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create WebSocket connection
|
|
80
|
+
*/
|
|
81
|
+
const createConnection = useCallback(() => {
|
|
82
|
+
try {
|
|
83
|
+
const ws = new WebSocket(wsUrl);
|
|
84
|
+
wsRef.current = ws;
|
|
85
|
+
|
|
86
|
+
ws.on('open', () => {
|
|
87
|
+
if (!mountedRef.current) return;
|
|
88
|
+
|
|
89
|
+
hasConnectedRef.current = true;
|
|
90
|
+
reconnectAttemptsRef.current = 0;
|
|
91
|
+
setConnectionState(ConnectionState.CONNECTED);
|
|
92
|
+
onConnect?.();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
ws.on('message', (data) => {
|
|
96
|
+
if (!mountedRef.current) return;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const message = JSON.parse(data.toString());
|
|
100
|
+
onMessage?.(message);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// Ignore parse errors
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
ws.on('close', () => {
|
|
107
|
+
if (!mountedRef.current) return;
|
|
108
|
+
|
|
109
|
+
const wasConnected = connectionStateRef.current === ConnectionState.CONNECTED;
|
|
110
|
+
setConnectionState(ConnectionState.DISCONNECTED);
|
|
111
|
+
|
|
112
|
+
if (wasConnected) {
|
|
113
|
+
onDisconnect?.();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Always try to reconnect (whether initial connect failed or we disconnected)
|
|
117
|
+
scheduleReconnect();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
ws.on('error', (err) => {
|
|
121
|
+
if (!mountedRef.current) return;
|
|
122
|
+
|
|
123
|
+
// Only report errors if we were connected
|
|
124
|
+
if (hasConnectedRef.current) {
|
|
125
|
+
setError(err.message);
|
|
126
|
+
onError?.(err);
|
|
127
|
+
}
|
|
128
|
+
// The 'close' event will fire after error, which will trigger reconnect
|
|
129
|
+
});
|
|
130
|
+
} catch (err) {
|
|
131
|
+
// Connection creation failed - schedule retry
|
|
132
|
+
scheduleReconnect();
|
|
133
|
+
}
|
|
134
|
+
}, [wsUrl, onMessage, onConnect, onDisconnect, onError, scheduleReconnect]);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Connect to WebSocket server
|
|
138
|
+
*/
|
|
139
|
+
const connect = useCallback(() => {
|
|
140
|
+
// Don't connect if already connected or connecting
|
|
141
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) return;
|
|
142
|
+
if (wsRef.current?.readyState === WebSocket.CONNECTING) return;
|
|
143
|
+
|
|
144
|
+
// Clean up existing socket
|
|
145
|
+
if (wsRef.current) {
|
|
146
|
+
wsRef.current.removeAllListeners();
|
|
147
|
+
wsRef.current = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
setConnectionState(ConnectionState.CONNECTING);
|
|
151
|
+
setError(null);
|
|
152
|
+
reconnectAttemptsRef.current = 0;
|
|
153
|
+
|
|
154
|
+
createConnection();
|
|
155
|
+
}, [createConnection]);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Disconnect from WebSocket server
|
|
159
|
+
*/
|
|
160
|
+
const disconnect = useCallback(() => {
|
|
161
|
+
// Cancel any pending reconnect
|
|
162
|
+
if (reconnectTimeoutRef.current) {
|
|
163
|
+
clearTimeout(reconnectTimeoutRef.current);
|
|
164
|
+
reconnectTimeoutRef.current = null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Close and clean up socket
|
|
168
|
+
if (wsRef.current) {
|
|
169
|
+
wsRef.current.removeAllListeners();
|
|
170
|
+
try {
|
|
171
|
+
wsRef.current.close();
|
|
172
|
+
} catch (e) {
|
|
173
|
+
// Ignore close errors
|
|
174
|
+
}
|
|
175
|
+
wsRef.current = null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
setConnectionState(ConnectionState.DISCONNECTED);
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Send a message through WebSocket
|
|
183
|
+
* @param {object} message - Message to send
|
|
184
|
+
* @returns {boolean} - Success status
|
|
185
|
+
*/
|
|
186
|
+
const send = useCallback((message) => {
|
|
187
|
+
if (wsRef.current?.readyState !== WebSocket.OPEN) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
wsRef.current.send(JSON.stringify(message));
|
|
193
|
+
return true;
|
|
194
|
+
} catch (err) {
|
|
195
|
+
setError(err.message);
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}, []);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Send a chat message
|
|
202
|
+
* @param {object} payload - Chat payload
|
|
203
|
+
*/
|
|
204
|
+
const sendChatMessage = useCallback((payload) => {
|
|
205
|
+
return send({
|
|
206
|
+
type: 'chat',
|
|
207
|
+
payload,
|
|
208
|
+
});
|
|
209
|
+
}, [send]);
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Send a stop generation request
|
|
213
|
+
* @param {number} chatId - Chat ID to stop
|
|
214
|
+
*/
|
|
215
|
+
const sendStop = useCallback((chatId) => {
|
|
216
|
+
return send({
|
|
217
|
+
type: 'stop',
|
|
218
|
+
chatId,
|
|
219
|
+
});
|
|
220
|
+
}, [send]);
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Broadcast chat selection to other clients
|
|
224
|
+
* @param {number|null} chatId - Selected chat ID
|
|
225
|
+
*/
|
|
226
|
+
const selectChat = useCallback((chatId) => {
|
|
227
|
+
return send({
|
|
228
|
+
type: 'select_chat',
|
|
229
|
+
chatId,
|
|
230
|
+
});
|
|
231
|
+
}, [send]);
|
|
232
|
+
|
|
233
|
+
// Auto-connect on mount
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
mountedRef.current = true;
|
|
236
|
+
|
|
237
|
+
if (autoConnect) {
|
|
238
|
+
// Delay to ensure server is ready (server starts concurrently with Ink UI)
|
|
239
|
+
const timer = setTimeout(() => {
|
|
240
|
+
if (mountedRef.current) {
|
|
241
|
+
connect();
|
|
242
|
+
}
|
|
243
|
+
}, 500);
|
|
244
|
+
|
|
245
|
+
return () => {
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
mountedRef.current = false;
|
|
248
|
+
disconnect();
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return () => {
|
|
253
|
+
mountedRef.current = false;
|
|
254
|
+
disconnect();
|
|
255
|
+
};
|
|
256
|
+
}, []); // Only run on mount/unmount - don't depend on connect/disconnect
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
connectionState,
|
|
260
|
+
isConnected: connectionState === ConnectionState.CONNECTED,
|
|
261
|
+
isConnecting: connectionState === ConnectionState.CONNECTING,
|
|
262
|
+
isReconnecting: connectionState === ConnectionState.RECONNECTING,
|
|
263
|
+
error,
|
|
264
|
+
connect,
|
|
265
|
+
disconnect,
|
|
266
|
+
send,
|
|
267
|
+
sendChatMessage,
|
|
268
|
+
sendStop,
|
|
269
|
+
selectChat,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export default useWebSocket;
|
package/src/ui/index.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ink UI entry point
|
|
3
|
+
* Provides a clean interface to the Ink-based React UI
|
|
4
|
+
*
|
|
5
|
+
* Note: This file uses dynamic import with tsx loader for JSX transpilation.
|
|
6
|
+
* The UI components use JSX syntax and are loaded at runtime.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { render } from 'ink';
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
|
|
13
|
+
// Re-export hooks and utilities (these don't use JSX)
|
|
14
|
+
export * from './hooks/index.js';
|
|
15
|
+
export * from './utils/index.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Dynamically load JSX components using tsx
|
|
19
|
+
* Falls back to a simple UI if tsx loader isn't available
|
|
20
|
+
*/
|
|
21
|
+
async function loadComponents() {
|
|
22
|
+
try {
|
|
23
|
+
// Try to import JSX component
|
|
24
|
+
// This will work if tsx loader is registered or if running under tsx
|
|
25
|
+
const { App } = await import('./components/App.jsx');
|
|
26
|
+
return { App };
|
|
27
|
+
} catch (err) {
|
|
28
|
+
// Fallback: return a simple placeholder component
|
|
29
|
+
console.error('Failed to load Ink components:', err.message);
|
|
30
|
+
|
|
31
|
+
// Return a simple fallback component
|
|
32
|
+
const FallbackApp = () => {
|
|
33
|
+
return React.createElement('text', { color: 'red' },
|
|
34
|
+
'Error: JSX components could not be loaded.'
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return { App: FallbackApp };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Start the Ink-based CLI
|
|
44
|
+
* @param {object} options - Configuration options
|
|
45
|
+
* @param {string} options.serverUrl - Server URL to connect to
|
|
46
|
+
* @param {boolean} options.showBanner - Whether to show the welcome banner
|
|
47
|
+
* @returns {Promise<object>} - Ink instance with rerender, unmount, waitUntilExit, clear methods
|
|
48
|
+
*/
|
|
49
|
+
export async function startInkCli(options = {}) {
|
|
50
|
+
const {
|
|
51
|
+
serverUrl = 'http://localhost:3000',
|
|
52
|
+
showBanner = true,
|
|
53
|
+
isRemoteMode = false,
|
|
54
|
+
} = options;
|
|
55
|
+
|
|
56
|
+
// Clear terminal and set title before rendering
|
|
57
|
+
// This ensures a clean slate for Ink to manage
|
|
58
|
+
process.stdout.write('\x1b[2J\x1b[H'); // Clear screen and move cursor to top-left
|
|
59
|
+
process.stdout.write('\x1b]0;Otherwise\x07\x1b]2;Otherwise\x07'); // Set title
|
|
60
|
+
|
|
61
|
+
// Load the App component
|
|
62
|
+
const { App } = await loadComponents();
|
|
63
|
+
|
|
64
|
+
// Create React element
|
|
65
|
+
const element = React.createElement(App, { serverUrl, showBanner, isRemoteMode });
|
|
66
|
+
|
|
67
|
+
// Render the app with optimized settings
|
|
68
|
+
const instance = render(element, {
|
|
69
|
+
exitOnCtrlC: false, // We handle Ctrl+C ourselves
|
|
70
|
+
patchConsole: true, // Intercept console.log to not interfere with UI
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return instance;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Main entry point when run directly
|
|
78
|
+
*/
|
|
79
|
+
export async function main() {
|
|
80
|
+
const instance = await startInkCli({
|
|
81
|
+
serverUrl: process.env.SERVER_URL || 'http://localhost:3000',
|
|
82
|
+
showBanner: true,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Wait for exit
|
|
86
|
+
await instance.waitUntilExit();
|
|
87
|
+
|
|
88
|
+
console.log(chalk.dim.italic('\nGoodbye'));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export default {
|
|
92
|
+
startInkCli,
|
|
93
|
+
main,
|
|
94
|
+
};
|