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,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commands hook for Ink UI
|
|
3
|
+
* Handles slash command parsing and execution
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useCallback } from 'react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Available slash commands
|
|
10
|
+
*/
|
|
11
|
+
export const COMMANDS = {
|
|
12
|
+
help: {
|
|
13
|
+
aliases: ['h', '?'],
|
|
14
|
+
description: 'Show available commands',
|
|
15
|
+
usage: '/help',
|
|
16
|
+
},
|
|
17
|
+
new: {
|
|
18
|
+
aliases: ['n'],
|
|
19
|
+
description: 'Start a new chat',
|
|
20
|
+
usage: '/new',
|
|
21
|
+
},
|
|
22
|
+
history: {
|
|
23
|
+
aliases: ['list', 'ls'],
|
|
24
|
+
description: 'Show recent chats',
|
|
25
|
+
usage: '/history',
|
|
26
|
+
},
|
|
27
|
+
continue: {
|
|
28
|
+
aliases: ['c', 'cont', 'resume'],
|
|
29
|
+
description: 'Continue a previous chat',
|
|
30
|
+
usage: '/continue <number|id>',
|
|
31
|
+
},
|
|
32
|
+
model: {
|
|
33
|
+
aliases: ['m', 'models'],
|
|
34
|
+
description: 'Switch AI model',
|
|
35
|
+
usage: '/model [model-name]',
|
|
36
|
+
},
|
|
37
|
+
clear: {
|
|
38
|
+
aliases: ['cls'],
|
|
39
|
+
description: 'Clear the screen',
|
|
40
|
+
usage: '/clear',
|
|
41
|
+
},
|
|
42
|
+
exit: {
|
|
43
|
+
aliases: ['quit', 'q'],
|
|
44
|
+
description: 'Exit chat mode',
|
|
45
|
+
usage: '/exit',
|
|
46
|
+
},
|
|
47
|
+
status: {
|
|
48
|
+
aliases: ['s', 'info'],
|
|
49
|
+
description: 'Show current chat status',
|
|
50
|
+
usage: '/status',
|
|
51
|
+
},
|
|
52
|
+
delete: {
|
|
53
|
+
aliases: ['del', 'rm'],
|
|
54
|
+
description: 'Delete a chat',
|
|
55
|
+
usage: '/delete <number|id>',
|
|
56
|
+
},
|
|
57
|
+
config: {
|
|
58
|
+
aliases: ['settings'],
|
|
59
|
+
description: 'Show current configuration',
|
|
60
|
+
usage: '/config',
|
|
61
|
+
},
|
|
62
|
+
open: {
|
|
63
|
+
aliases: ['browser', 'web', 'ui'],
|
|
64
|
+
description: 'Open the web interface in browser',
|
|
65
|
+
usage: '/open',
|
|
66
|
+
},
|
|
67
|
+
attach: {
|
|
68
|
+
aliases: ['at', 'file', 'add'],
|
|
69
|
+
description: 'Open file picker to attach files/folders',
|
|
70
|
+
usage: '/attach',
|
|
71
|
+
},
|
|
72
|
+
clearfiles: {
|
|
73
|
+
aliases: ['cf', 'detach'],
|
|
74
|
+
description: 'Clear all attached files',
|
|
75
|
+
usage: '/clearfiles',
|
|
76
|
+
},
|
|
77
|
+
setbrowser: {
|
|
78
|
+
aliases: ['browser-select', 'automation-browser'],
|
|
79
|
+
description: 'Select browser for automation (tools & web search)',
|
|
80
|
+
usage: '/setbrowser',
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Command result actions
|
|
86
|
+
*/
|
|
87
|
+
export const CommandAction = {
|
|
88
|
+
CONTINUE: 'continue',
|
|
89
|
+
EXIT: 'exit',
|
|
90
|
+
NEW_CHAT: 'new_chat',
|
|
91
|
+
CONTINUE_CHAT: 'continue_chat',
|
|
92
|
+
SET_MODEL: 'set_model',
|
|
93
|
+
DELETE_CHAT: 'delete_chat',
|
|
94
|
+
OPEN_FILE_PICKER: 'open_file_picker',
|
|
95
|
+
OPEN_MODEL_SELECTOR: 'open_model_selector',
|
|
96
|
+
CLEAR_FILES: 'clear_files',
|
|
97
|
+
SHOW_HELP: 'show_help',
|
|
98
|
+
SHOW_HISTORY: 'show_history',
|
|
99
|
+
SHOW_STATUS: 'show_status',
|
|
100
|
+
SHOW_CONFIG: 'show_config',
|
|
101
|
+
OPEN_BROWSER: 'open_browser',
|
|
102
|
+
OPEN_BROWSER_SELECT: 'open_browser_select',
|
|
103
|
+
CLEAR_SCREEN: 'clear_screen',
|
|
104
|
+
UNKNOWN: 'unknown',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Parse a command from input
|
|
109
|
+
* @param {string} input - User input
|
|
110
|
+
* @returns {object} - Parsed command
|
|
111
|
+
*/
|
|
112
|
+
export function parseCommand(input) {
|
|
113
|
+
const trimmed = input.trim();
|
|
114
|
+
|
|
115
|
+
if (!trimmed.startsWith('/')) {
|
|
116
|
+
return { command: null, args: [], raw: trimmed };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const parts = trimmed.slice(1).split(/\s+/);
|
|
120
|
+
const commandName = parts[0].toLowerCase();
|
|
121
|
+
const args = parts.slice(1);
|
|
122
|
+
|
|
123
|
+
// Find command by name or alias
|
|
124
|
+
for (const [name, cmd] of Object.entries(COMMANDS)) {
|
|
125
|
+
if (name === commandName || cmd.aliases.includes(commandName)) {
|
|
126
|
+
return { command: name, args, raw: trimmed };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { command: 'unknown', args: [commandName, ...args], raw: trimmed };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Custom hook for command handling
|
|
135
|
+
* @returns {object} - Command methods
|
|
136
|
+
*/
|
|
137
|
+
export function useCommands() {
|
|
138
|
+
/**
|
|
139
|
+
* Execute a parsed command
|
|
140
|
+
* @param {string} command - Command name
|
|
141
|
+
* @param {string[]} args - Command arguments
|
|
142
|
+
* @returns {object} - Execution result with action and data
|
|
143
|
+
*/
|
|
144
|
+
const execute = useCallback((command, args) => {
|
|
145
|
+
switch (command) {
|
|
146
|
+
case 'help':
|
|
147
|
+
return { action: CommandAction.SHOW_HELP };
|
|
148
|
+
|
|
149
|
+
case 'new':
|
|
150
|
+
return { action: CommandAction.NEW_CHAT };
|
|
151
|
+
|
|
152
|
+
case 'history':
|
|
153
|
+
return { action: CommandAction.SHOW_HISTORY };
|
|
154
|
+
|
|
155
|
+
case 'continue': {
|
|
156
|
+
if (args.length === 0) {
|
|
157
|
+
return {
|
|
158
|
+
action: CommandAction.CONTINUE,
|
|
159
|
+
error: 'Usage: /continue <number|id>'
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const identifier = args[0];
|
|
164
|
+
const num = parseInt(identifier, 10);
|
|
165
|
+
|
|
166
|
+
if (isNaN(num)) {
|
|
167
|
+
return {
|
|
168
|
+
action: CommandAction.CONTINUE,
|
|
169
|
+
error: 'Invalid chat identifier'
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
action: CommandAction.CONTINUE_CHAT,
|
|
175
|
+
data: { identifier: num, isIndex: num <= 20 }
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
case 'model':
|
|
180
|
+
if (args.length === 0) {
|
|
181
|
+
return { action: CommandAction.OPEN_MODEL_SELECTOR };
|
|
182
|
+
}
|
|
183
|
+
if (args[0] === '--list' || args[0] === '-l') {
|
|
184
|
+
return { action: CommandAction.OPEN_MODEL_SELECTOR, data: { listOnly: true } };
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
action: CommandAction.SET_MODEL,
|
|
188
|
+
data: { modelName: args[0].toLowerCase() }
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
case 'clear':
|
|
192
|
+
return { action: CommandAction.CLEAR_SCREEN };
|
|
193
|
+
|
|
194
|
+
case 'exit':
|
|
195
|
+
return { action: CommandAction.EXIT };
|
|
196
|
+
|
|
197
|
+
case 'status':
|
|
198
|
+
return { action: CommandAction.SHOW_STATUS };
|
|
199
|
+
|
|
200
|
+
case 'delete': {
|
|
201
|
+
if (args.length === 0) {
|
|
202
|
+
return {
|
|
203
|
+
action: CommandAction.CONTINUE,
|
|
204
|
+
error: 'Usage: /delete <number|id>'
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const identifier = args[0];
|
|
209
|
+
const num = parseInt(identifier, 10);
|
|
210
|
+
|
|
211
|
+
if (isNaN(num)) {
|
|
212
|
+
return {
|
|
213
|
+
action: CommandAction.CONTINUE,
|
|
214
|
+
error: 'Invalid chat identifier'
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
action: CommandAction.DELETE_CHAT,
|
|
220
|
+
data: { identifier: num, isIndex: num <= 20 }
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case 'config':
|
|
225
|
+
return { action: CommandAction.SHOW_CONFIG };
|
|
226
|
+
|
|
227
|
+
case 'open':
|
|
228
|
+
return { action: CommandAction.OPEN_BROWSER };
|
|
229
|
+
|
|
230
|
+
case 'setbrowser':
|
|
231
|
+
return { action: CommandAction.OPEN_BROWSER_SELECT };
|
|
232
|
+
|
|
233
|
+
case 'attach':
|
|
234
|
+
return { action: CommandAction.OPEN_FILE_PICKER };
|
|
235
|
+
|
|
236
|
+
case 'clearfiles':
|
|
237
|
+
return { action: CommandAction.CLEAR_FILES };
|
|
238
|
+
|
|
239
|
+
case 'unknown':
|
|
240
|
+
return {
|
|
241
|
+
action: CommandAction.UNKNOWN,
|
|
242
|
+
error: `Unknown command: /${args[0]}`
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
default:
|
|
246
|
+
return { action: CommandAction.CONTINUE };
|
|
247
|
+
}
|
|
248
|
+
}, []);
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get all commands for help display
|
|
252
|
+
* @returns {Array} - Array of command objects
|
|
253
|
+
*/
|
|
254
|
+
const getCommandList = useCallback(() => {
|
|
255
|
+
return Object.entries(COMMANDS).map(([name, cmd]) => ({
|
|
256
|
+
name,
|
|
257
|
+
...cmd,
|
|
258
|
+
}));
|
|
259
|
+
}, []);
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Check if input is a command
|
|
263
|
+
* @param {string} input - User input
|
|
264
|
+
* @returns {boolean} - True if command
|
|
265
|
+
*/
|
|
266
|
+
const isCommand = useCallback((input) => {
|
|
267
|
+
return input.trim().startsWith('/');
|
|
268
|
+
}, []);
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
parse: parseCommand,
|
|
272
|
+
execute,
|
|
273
|
+
getCommandList,
|
|
274
|
+
isCommand,
|
|
275
|
+
COMMANDS,
|
|
276
|
+
CommandAction,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export default useCommands;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File attachments hook for Ink UI
|
|
3
|
+
* Manages file/folder attachments for chat context
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useCallback } from 'react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* File types
|
|
10
|
+
*/
|
|
11
|
+
export const FileType = {
|
|
12
|
+
FILE: 'file',
|
|
13
|
+
FOLDER: 'folder',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Custom hook for file attachment management
|
|
18
|
+
* @param {string} serverUrl - Server URL for file API
|
|
19
|
+
* @returns {object} - File state and methods
|
|
20
|
+
*/
|
|
21
|
+
export function useFileAttachments(serverUrl) {
|
|
22
|
+
const [files, setFiles] = useState([]);
|
|
23
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
24
|
+
const [error, setError] = useState(null);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fetch file content from server
|
|
28
|
+
* @param {string} filePath - Path to file
|
|
29
|
+
* @returns {Promise<object|null>} - File data or null
|
|
30
|
+
*/
|
|
31
|
+
const fetchFileContent = useCallback(async (filePath) => {
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(
|
|
34
|
+
`${serverUrl}/api/files/read?path=${encodeURIComponent(filePath)}`
|
|
35
|
+
);
|
|
36
|
+
const result = await response.json();
|
|
37
|
+
|
|
38
|
+
if (result.success) {
|
|
39
|
+
return {
|
|
40
|
+
name: result.name,
|
|
41
|
+
path: result.path,
|
|
42
|
+
content: result.content,
|
|
43
|
+
lineCount: result.lineCount,
|
|
44
|
+
size: result.size,
|
|
45
|
+
type: FileType.FILE,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
setError(err.message);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}, [serverUrl]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fetch folder tree from server
|
|
57
|
+
* @param {string} folderPath - Path to folder
|
|
58
|
+
* @returns {Promise<object|null>} - Folder data or null
|
|
59
|
+
*/
|
|
60
|
+
const fetchFolderTree = useCallback(async (folderPath) => {
|
|
61
|
+
try {
|
|
62
|
+
const cleanPath = folderPath.endsWith('/')
|
|
63
|
+
? folderPath.slice(0, -1)
|
|
64
|
+
: folderPath;
|
|
65
|
+
|
|
66
|
+
const response = await fetch(
|
|
67
|
+
`${serverUrl}/api/files/tree?path=${encodeURIComponent(cleanPath)}&maxDepth=5&maxFiles=100&includeContent=false`
|
|
68
|
+
);
|
|
69
|
+
const result = await response.json();
|
|
70
|
+
|
|
71
|
+
if (result.success) {
|
|
72
|
+
return {
|
|
73
|
+
name: result.folderName,
|
|
74
|
+
path: result.folderPath,
|
|
75
|
+
content: result.treeDisplay || result.files.map(f => ` ${f.path}`).join('\n'),
|
|
76
|
+
lineCount: result.totalLines,
|
|
77
|
+
size: result.totalSize,
|
|
78
|
+
fileCount: result.fileCount,
|
|
79
|
+
directoryCount: result.directoryCount || 0,
|
|
80
|
+
type: FileType.FOLDER,
|
|
81
|
+
truncated: result.truncated,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
setError(err.message);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}, [serverUrl]);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Attach a file
|
|
93
|
+
* @param {string} path - File path
|
|
94
|
+
* @returns {Promise<object|null>} - Attached file data or null
|
|
95
|
+
*/
|
|
96
|
+
const attachFile = useCallback(async (path) => {
|
|
97
|
+
setIsLoading(true);
|
|
98
|
+
setError(null);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const fileData = await fetchFileContent(path);
|
|
102
|
+
|
|
103
|
+
if (fileData) {
|
|
104
|
+
// Check if already attached
|
|
105
|
+
const alreadyAttached = files.some(f => f.path === fileData.path);
|
|
106
|
+
if (!alreadyAttached) {
|
|
107
|
+
setFiles(prev => [...prev, fileData]);
|
|
108
|
+
}
|
|
109
|
+
return fileData;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
} finally {
|
|
113
|
+
setIsLoading(false);
|
|
114
|
+
}
|
|
115
|
+
}, [files, fetchFileContent]);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Attach a folder
|
|
119
|
+
* @param {string} path - Folder path
|
|
120
|
+
* @returns {Promise<object|null>} - Attached folder data or null
|
|
121
|
+
*/
|
|
122
|
+
const attachFolder = useCallback(async (path) => {
|
|
123
|
+
setIsLoading(true);
|
|
124
|
+
setError(null);
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const folderData = await fetchFolderTree(path);
|
|
128
|
+
|
|
129
|
+
if (folderData) {
|
|
130
|
+
// Check if already attached
|
|
131
|
+
const alreadyAttached = files.some(f => f.path === folderData.path);
|
|
132
|
+
if (!alreadyAttached) {
|
|
133
|
+
setFiles(prev => [...prev, folderData]);
|
|
134
|
+
}
|
|
135
|
+
return folderData;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
} finally {
|
|
139
|
+
setIsLoading(false);
|
|
140
|
+
}
|
|
141
|
+
}, [files, fetchFolderTree]);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Detach a file by path
|
|
145
|
+
* @param {string} path - File path to detach
|
|
146
|
+
*/
|
|
147
|
+
const detach = useCallback((path) => {
|
|
148
|
+
setFiles(prev => prev.filter(f => f.path !== path));
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Clear all attachments
|
|
153
|
+
*/
|
|
154
|
+
const clear = useCallback(() => {
|
|
155
|
+
setFiles([]);
|
|
156
|
+
setError(null);
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build file context string for message
|
|
161
|
+
* @returns {string} - Context string
|
|
162
|
+
*/
|
|
163
|
+
const buildContext = useCallback(() => {
|
|
164
|
+
if (files.length === 0) return '';
|
|
165
|
+
|
|
166
|
+
const contextParts = files.map(fileData => {
|
|
167
|
+
if (fileData.type === FileType.FOLDER) {
|
|
168
|
+
return `<folder_tree path="${fileData.path}" files="${fileData.fileCount}" total_lines="${fileData.lineCount}">
|
|
169
|
+
The following files are available in this folder. Use read_file to access their contents if needed:
|
|
170
|
+
|
|
171
|
+
${fileData.content}
|
|
172
|
+
</folder_tree>`;
|
|
173
|
+
} else {
|
|
174
|
+
return `<file_context path="${fileData.path}" lines="${fileData.lineCount}">
|
|
175
|
+
${fileData.content}
|
|
176
|
+
</file_context>`;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return contextParts.join('\n\n');
|
|
181
|
+
}, [files]);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Get total stats
|
|
185
|
+
* @returns {object} - Stats object
|
|
186
|
+
*/
|
|
187
|
+
const getStats = useCallback(() => {
|
|
188
|
+
const totalFiles = files.filter(f => f.type === FileType.FILE).length;
|
|
189
|
+
const totalFolders = files.filter(f => f.type === FileType.FOLDER).length;
|
|
190
|
+
const totalLines = files.reduce((sum, f) => sum + (f.lineCount || 0), 0);
|
|
191
|
+
const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
count: files.length,
|
|
195
|
+
totalFiles,
|
|
196
|
+
totalFolders,
|
|
197
|
+
totalLines,
|
|
198
|
+
totalSize,
|
|
199
|
+
};
|
|
200
|
+
}, [files]);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
files,
|
|
204
|
+
isLoading,
|
|
205
|
+
error,
|
|
206
|
+
hasFiles: files.length > 0,
|
|
207
|
+
attachFile,
|
|
208
|
+
attachFolder,
|
|
209
|
+
detach,
|
|
210
|
+
clear,
|
|
211
|
+
buildContext,
|
|
212
|
+
getStats,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export default useFileAttachments;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard shortcuts hook for Ink UI
|
|
3
|
+
* Provides a centralized way to handle keyboard shortcuts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
7
|
+
import { useInput } from 'ink';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default keyboard shortcuts configuration
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_SHORTCUTS = {
|
|
13
|
+
// Global shortcuts
|
|
14
|
+
'ctrl+c': { action: 'stop', description: 'Stop generation / Exit' },
|
|
15
|
+
'ctrl+d': { action: 'exit', description: 'Exit application' },
|
|
16
|
+
'ctrl+l': { action: 'clear', description: 'Clear screen' },
|
|
17
|
+
'ctrl+n': { action: 'newChat', description: 'New chat' },
|
|
18
|
+
'ctrl+o': { action: 'openBrowser', description: 'Open in browser' },
|
|
19
|
+
|
|
20
|
+
// Navigation
|
|
21
|
+
'ctrl+k': { action: 'clearInput', description: 'Clear input' },
|
|
22
|
+
'ctrl+u': { action: 'clearInput', description: 'Clear input' },
|
|
23
|
+
|
|
24
|
+
// File picker
|
|
25
|
+
'tab': { action: 'openFilePicker', description: 'Open file picker' },
|
|
26
|
+
|
|
27
|
+
// Model selector
|
|
28
|
+
'ctrl+m': { action: 'openModelSelector', description: 'Select model' },
|
|
29
|
+
|
|
30
|
+
// Help
|
|
31
|
+
'?': { action: 'showHelp', description: 'Show help', requiresNoInput: true },
|
|
32
|
+
'ctrl+/': { action: 'showHelp', description: 'Show help' },
|
|
33
|
+
|
|
34
|
+
// History
|
|
35
|
+
'up': { action: 'historyUp', description: 'Previous in history' },
|
|
36
|
+
'down': { action: 'historyDown', description: 'Next in history' },
|
|
37
|
+
|
|
38
|
+
// Escape
|
|
39
|
+
'escape': { action: 'cancel', description: 'Cancel / Close overlay' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse a key combination into parts
|
|
44
|
+
* @param {string} combo - Key combination (e.g., 'ctrl+c')
|
|
45
|
+
* @returns {object} - Parsed key parts
|
|
46
|
+
*/
|
|
47
|
+
function parseKeyCombo(combo) {
|
|
48
|
+
const parts = combo.toLowerCase().split('+');
|
|
49
|
+
return {
|
|
50
|
+
ctrl: parts.includes('ctrl'),
|
|
51
|
+
alt: parts.includes('alt'),
|
|
52
|
+
shift: parts.includes('shift'),
|
|
53
|
+
meta: parts.includes('meta') || parts.includes('cmd'),
|
|
54
|
+
key: parts[parts.length - 1],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if a key event matches a key combo
|
|
60
|
+
* @param {object} key - Key event from useInput
|
|
61
|
+
* @param {string} input - Input character
|
|
62
|
+
* @param {object} combo - Parsed key combo
|
|
63
|
+
* @returns {boolean} - Whether it matches
|
|
64
|
+
*/
|
|
65
|
+
function matchesCombo(key, input, combo) {
|
|
66
|
+
// Check modifiers
|
|
67
|
+
if (combo.ctrl && !key.ctrl) return false;
|
|
68
|
+
if (combo.alt && !key.alt) return false;
|
|
69
|
+
if (combo.shift && !key.shift) return false;
|
|
70
|
+
if (combo.meta && !key.meta) return false;
|
|
71
|
+
|
|
72
|
+
// Check key
|
|
73
|
+
switch (combo.key) {
|
|
74
|
+
case 'up': return key.upArrow;
|
|
75
|
+
case 'down': return key.downArrow;
|
|
76
|
+
case 'left': return key.leftArrow;
|
|
77
|
+
case 'right': return key.rightArrow;
|
|
78
|
+
case 'enter': return key.return;
|
|
79
|
+
case 'escape': return key.escape;
|
|
80
|
+
case 'tab': return key.tab;
|
|
81
|
+
case 'backspace': return key.backspace;
|
|
82
|
+
case 'delete': return key.delete;
|
|
83
|
+
default:
|
|
84
|
+
// Match character
|
|
85
|
+
return input.toLowerCase() === combo.key;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Custom hook for keyboard shortcut handling
|
|
91
|
+
* @param {object} handlers - Map of action names to handler functions
|
|
92
|
+
* @param {object} options - Configuration options
|
|
93
|
+
* @returns {object} - Shortcut utilities
|
|
94
|
+
*/
|
|
95
|
+
export function useKeyboardShortcuts(handlers = {}, options = {}) {
|
|
96
|
+
const {
|
|
97
|
+
shortcuts = DEFAULT_SHORTCUTS,
|
|
98
|
+
enabled = true,
|
|
99
|
+
currentInput = '',
|
|
100
|
+
} = options;
|
|
101
|
+
|
|
102
|
+
// Parse all shortcuts
|
|
103
|
+
const parsedShortcuts = useRef({});
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
parsedShortcuts.current = {};
|
|
107
|
+
for (const [combo, config] of Object.entries(shortcuts)) {
|
|
108
|
+
parsedShortcuts.current[combo] = {
|
|
109
|
+
...config,
|
|
110
|
+
parsed: parseKeyCombo(combo),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}, [shortcuts]);
|
|
114
|
+
|
|
115
|
+
// Handle input
|
|
116
|
+
useInput((input, key) => {
|
|
117
|
+
if (!enabled) return;
|
|
118
|
+
|
|
119
|
+
for (const [combo, config] of Object.entries(parsedShortcuts.current)) {
|
|
120
|
+
// Check if shortcut requires no current input
|
|
121
|
+
if (config.requiresNoInput && currentInput) continue;
|
|
122
|
+
|
|
123
|
+
if (matchesCombo(key, input, config.parsed)) {
|
|
124
|
+
const handler = handlers[config.action];
|
|
125
|
+
if (handler) {
|
|
126
|
+
handler();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}, { isActive: enabled });
|
|
132
|
+
|
|
133
|
+
// Get all shortcuts for help display
|
|
134
|
+
const getShortcutsList = useCallback(() => {
|
|
135
|
+
return Object.entries(shortcuts).map(([combo, config]) => ({
|
|
136
|
+
combo,
|
|
137
|
+
...config,
|
|
138
|
+
}));
|
|
139
|
+
}, [shortcuts]);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
shortcuts,
|
|
143
|
+
getShortcutsList,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Format a key combo for display
|
|
149
|
+
* @param {string} combo - Key combination
|
|
150
|
+
* @returns {string} - Formatted string
|
|
151
|
+
*/
|
|
152
|
+
export function formatKeyCombo(combo) {
|
|
153
|
+
const isMac = process.platform === 'darwin';
|
|
154
|
+
|
|
155
|
+
return combo
|
|
156
|
+
.replace('ctrl', isMac ? '⌃' : 'Ctrl')
|
|
157
|
+
.replace('alt', isMac ? '⌥' : 'Alt')
|
|
158
|
+
.replace('shift', isMac ? '⇧' : 'Shift')
|
|
159
|
+
.replace('meta', isMac ? '⌘' : 'Win')
|
|
160
|
+
.replace('cmd', '⌘')
|
|
161
|
+
.replace('+', isMac ? '' : '+')
|
|
162
|
+
.replace('up', '↑')
|
|
163
|
+
.replace('down', '↓')
|
|
164
|
+
.replace('left', '←')
|
|
165
|
+
.replace('right', '→')
|
|
166
|
+
.replace('enter', '↵')
|
|
167
|
+
.replace('escape', 'Esc')
|
|
168
|
+
.replace('tab', 'Tab')
|
|
169
|
+
.replace('backspace', '⌫')
|
|
170
|
+
.toUpperCase();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export default useKeyboardShortcuts;
|