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