diff --git a/.gitignore b/.gitignore index 53782b9..df2c861 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ first-run.flag todo.md .env + +# VSCode +.vscode/* \ No newline at end of file diff --git a/README.md b/README.md index 41e58d2..ceca256 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 👼 smol menubar -This is a smol menubar app that helps you quickly access **the full webapps** of ChatGPT (defaults to "[GPT4.5](https://www.latent.space/p/code-interpreter#details)"!!), Bing and Anthropic Claude 2 with a single keyboard shortcut (Cmd+Shift+G). +This is a smol menubar app that helps you quickly access **the full webapps** of ChatGPT (defaults to "[GPT4.5](https://www.latent.space/p/code-interpreter#details)"!!), Bing, Claude 2, and more (see below) with a single keyboard shortcut (Cmd+Shift+G). > we also support Bard, Claude 1, and local models like LLaMA and Vicuna (via [OobaBooga](https://github.com/oobabooga/text-generation-webui)) but hide by default bc they aren't as good! @@ -41,16 +41,21 @@ Yes and no: - **Keyboard Shortcuts**: - Use `Cmd+Shift+G` for quick open and `Cmd+Enter` to submit. - - Customize these shortcuts by building from source. + - Customize these shortcuts (thanks [@davej](https://github.com/smol-ai/menubar/pull/85)!): + - Quick Open + - ![image](https://github.com/davej/smol-ai-menubar/assets/6764957/3a6d0a16-7f54-43e5-9060-ec7b2486d32d) + - Submit can be toggled to use `Enter` (faster for quick chat replies) vs `Cmd+Enter` (easier to enter multiline prompts) + - Remember you can customize further by building from source! - **Window Resizing**: - Resize the window by clicking and dragging. - - Use `Cmd+Shift+F` to set the width to 100% of your screen. - Use `Cmd+1/2/3/A/+/-` or drag to resize the internal webviews. + - `Cmd +` and `Cmd -` are especially useful if you have a lot of chats enabled! + - Use `Cmd+Shift+F` to set the width to 100% of your screen. - **Model Toggle**: - Enable/disable providers by accessing the context menu from the menubar icon (right-click and choose from the list). The choice is saved for future sessions. - - Supported models: ChatGPT, Bing, Bard, Claude 1/2. + - Supported models: ChatGPT, Bing, Bard, Claude 1/2, and more (see Supported LLM Providers above) - **Support for oobabooga/text-generation-webui**: - Initial support for [oobabooga/text-generation-webui](https://github.com/oobabooga/text-generation-webui) has been added. diff --git a/index.js b/index.js index 9a0895e..12da91f 100644 --- a/index.js +++ b/index.js @@ -19,6 +19,7 @@ const { globalShortcut, shell, screen, + BrowserWindow, ipcMain, } = require('electron'); @@ -64,6 +65,10 @@ const image = nativeImage.createFromPath( path.join(__dirname, `images/iconTemplate.png`), ); +// Default quick open shortcut +const quickOpenDefaultShortcut = 'CommandOrControl+Shift+G'; +let settingsWindow = null; + // Once the app is ready, the following code will execute app.on('ready', () => { const tray = new Tray(image); @@ -122,11 +127,53 @@ app.on('ready', () => { }, { label: 'Quick Open (use this!)', - accelerator: 'CommandorControl+Shift+G', + accelerator: store.get('quickOpenShortcut', quickOpenDefaultShortcut), click: () => { window.reload(); }, }, + { + label: 'Change Quick Open Shortcut', + click: () => { + if (settingsWindow && !settingsWindow.isDestroyed()) { + // If the settings window is already open, just focus it + settingsWindow.show(); + settingsWindow.focus(); + return; + } + settingsWindow = new BrowserWindow({ + show: true, + width: 380, + height: 200, + titleBarStyle: 'hidden', + minimizable: false, + fullscreenable: false, + maximizable: false, + webPreferences: { + preload: path.join( + __dirname, + 'settings', + 'settings-preload.js', + ), + contextIsolation: true, + }, + }); + + settingsWindow.loadFile( + path.join(__dirname, 'settings', 'settings.html'), + ); + if (process.env.NODE_ENV === 'development') { + // open devtools if in dev mode + settingsWindow.openDevTools({ + mode: 'detach', + }); + } + + settingsWindow.once('ready-to-show', () => { + mb.hideWindow(); + }); + }, + }, { label: 'Toggle Fullscreen', accelerator: 'CommandorControl+Shift+F', @@ -157,10 +204,11 @@ app.on('ready', () => { }; }); + const superPromptChecked = store.get('SuperPromptEnterKey', false) const superPromptEnterKey = { - label: 'Super Prompt "Enter" Key', + label: superPromptChecked ? 'Toggle "Enter" Submit (faster, but harder to multiline)' : 'Toggle "Cmd+Enter" Submit (takes extra key, but easier to multiline)', type: 'checkbox', - checked: store.get('SuperPromptEnterKey', false), + checked: superPromptChecked, click: () => { store.set( 'SuperPromptEnterKey', @@ -180,37 +228,6 @@ app.on('ready', () => { }); const menuFooter = [ - // Removing the preferences window for now because all settings are now - // in the menubar context menu dropdown. (Seemed like a better UX) - // { - // label: 'Preferences', - // click: () => { - // const preferencesWindow = new BrowserWindow({ - // parent: null, - // modal: false, - // alwaysOnTop: true, - // show: false, - // autoHideMenuBar: true, - // width: 500, - // height: 300, - // webPreferences: { - // nodeIntegration: true, - // contextIsolation: false, - // }, - // }); - // preferencesWindow.loadFile('preferences.html'); - // preferencesWindow.once('ready-to-show', () => { - // mb.hideWindow(); - // preferencesWindow.show(); - // }); - - // // When the preferences window is closed, show the main window again - // preferencesWindow.on('close', () => { - // mb.showWindow(); - // mb.window.reload(); // reload the main window to apply the new settings - // }); - // }, - // }, { label: 'View on GitHub', click: () => { @@ -228,11 +245,10 @@ app.on('ready', () => { // Return the complete context menu template return [ ...menuHeader, + superPromptEnterKey, // TODO: move into the customize keyboard shortcut window separator, ...providersToggles, separator, - superPromptEnterKey, - separator, ...providerLinks, separator, ...menuFooter, @@ -255,7 +271,7 @@ app.on('ready', () => { }); const menu = new Menu(); - globalShortcut.register('CommandOrControl+Shift+g', () => { + function quickOpen() { if (window.isVisible()) { mb.hideWindow(); } else { @@ -265,6 +281,23 @@ app.on('ready', () => { } mb.app.focus(); } + } + + globalShortcut.register( + store.get('quickOpenShortcut', quickOpenDefaultShortcut), + quickOpen, + ); + + store.onDidChange('quickOpenShortcut', (newValue, oldValue) => { + if (newValue === oldValue) return; + if (oldValue) { + globalShortcut.unregister(oldValue); + } else if (quickOpenDefaultShortcut) { + globalShortcut.unregister(quickOpenDefaultShortcut); + } + if (newValue) { + globalShortcut.register(newValue, quickOpen); + } }); store.onDidChange('isFullscreen', (isFullscreen) => { @@ -376,6 +409,18 @@ app.on('window-all-closed', () => { } }); +ipcMain.handle('getQuickOpenShortcut', () => { + return store.get('quickOpenShortcut', quickOpenDefaultShortcut); +}); + +ipcMain.handle('setQuickOpenShortcut', (event, value) => { + store.set('quickOpenShortcut', value); +}); + +ipcMain.handle('getPlatform', () => { + return process.platform; +}); + ipcMain.handle('getStoreValue', (event, key) => { return store.get(key); }); diff --git a/package-lock.json b/package-lock.json index 0193036..83d0a04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -992,9 +992,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.8.tgz", - "integrity": "sha512-0LNz4EY8B/8xXY86wMrQ4tz6zEHZv9ehFMJPm8u2gq5lQ71cfRKdaKyxfJAx5aUoyzx0qzgURblTisPGgz3d+Q==", + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", "dev": true, "engines": { "node": ">=10.0.0" @@ -1927,9 +1927,9 @@ "dev": true }, "node_modules/detect-libc": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", - "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", "dev": true, "engines": { "node": ">=8" @@ -1996,9 +1996,9 @@ "dev": true }, "node_modules/electron": { - "version": "22.3.16", - "resolved": "https://registry.npmjs.org/electron/-/electron-22.3.16.tgz", - "integrity": "sha512-8dHTKetzhfF6Ez7ggLznNtZ8gUPuBUiLVlhd0iGxASn483m2GcOGLB5HmRVEcw/FSNd2nLfWdsbzA6HlMf/UbA==", + "version": "22.3.18", + "resolved": "https://registry.npmjs.org/electron/-/electron-22.3.18.tgz", + "integrity": "sha512-JgjB966ghTBszAX/GgVgDY/2CktWCjTZWGJI0WISRHDudBZ8/WPkI/hIjsMiLQLe0wSTk6S+WHOYbIqyw0I/sg==", "hasInstallScript": true, "dependencies": { "@electron/get": "^2.0.0", @@ -3907,9 +3907,9 @@ } }, "node_modules/keyv": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.2.tgz", - "integrity": "sha512-5MHbFaKn8cNSmVW7BYnijeAVlE4cYA/SVkifVgrh7yotnfhKmjuXpDKjrABLnT0SfHWV21P8ow07OGfRrNDg8g==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", + "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", "dependencies": { "json-buffer": "3.0.1" } @@ -6086,9 +6086,9 @@ } }, "node_modules/tslib": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz", - "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", + "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==", "dev": true }, "node_modules/type-fest": { @@ -6282,9 +6282,9 @@ "dev": true }, "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "optional": true, "engines": { diff --git a/settings/settings-preload.js b/settings/settings-preload.js new file mode 100644 index 0000000..fbd6414 --- /dev/null +++ b/settings/settings-preload.js @@ -0,0 +1,16 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('settings', { + getQuickOpenShortcut: () => { + return ipcRenderer.invoke('getQuickOpenShortcut'); + }, + /** + * @param {string} shortcut + */ + setQuickOpenShortcut: (shortcut) => { + return ipcRenderer.invoke('setQuickOpenShortcut', shortcut); + }, + getPlatform: () => { + return ipcRenderer.invoke('getPlatform'); + }, +}); diff --git a/settings/settings.css b/settings/settings.css new file mode 100644 index 0000000..b38ef89 --- /dev/null +++ b/settings/settings.css @@ -0,0 +1,80 @@ +html { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + margin: 0; + height: 100%; + width: 100%; +} + +body { + margin: 0; +} + +.titlebar { + height: 28px; + -webkit-app-region: drag; + color: #666; + font-size: 12px; + font-weight: bold; + text-align: center; + line-height: 28px; + margin-bottom: 20px; + border-bottom: 1px #eee solid; + cursor: default; + user-select: none; +} + +.container { + padding: 20px; +} + +.accelerator-wrapper { + display: flex; + flex-direction: column; + gap: 8px; +} + +.accelerator { + color: rgba(0, 0, 0, 0.8) !important; + background: transparent; + border-radius: 4px; + padding: 0px 8px; + transition: var(--theme-transitions-primary); + border: 1px solid; + border-color: rgba(0, 0, 0, 0.1); + display: flex; + flex-wrap: wrap; + align-items: center; + padding: 8px; + gap: 4px; + font-size: 12px; +} + +.accelerator:hover { + border-color: rgba(0, 0, 0, 0.5); +} + +.accelerator-token { + display: flex; + align-items: center; +} + +.tag { + padding: 2px 4px; + border-radius: 2px; + width: fit-content; + font-size: 12px; + background-color: #eee; + color: #222; +} + +.tag.in-progress { + background-color: #e0e7ff; + color: #3730a3; +} + +button { + padding: 8px; + border-radius: 5px; + border: 1px solid rgba(0, 0, 0, 0.2); +} diff --git a/settings/settings.html b/settings/settings.html new file mode 100644 index 0000000..14c94bb --- /dev/null +++ b/settings/settings.html @@ -0,0 +1,12 @@ + + + + + + +
Change Quick Open Shortcut
+
+ + + + diff --git a/settings/settings.js b/settings/settings.js new file mode 100644 index 0000000..ac555f6 --- /dev/null +++ b/settings/settings.js @@ -0,0 +1,181 @@ +/** + * @type {"darwin" | "win32" | "linux"} + */ +let currentPlatform = 'darwin'; +/** + * @type {"Command" | "Super"} + */ +let metaKey = 'Command'; +window.settings?.getPlatform?.().then((platform) => { + // Get the platform from the main process + currentPlatform = platform; + metaKey = currentPlatform === 'darwin' ? 'Command' : 'Super'; +}); + +/** + * @type {string[]} + */ +let shortcut = []; +window.settings?.getQuickOpenShortcut?.().then((accelerator) => { + // Get the accelerator from the store in the main process + shortcut = convertAcceleratorToTokens(accelerator); + updateDOM(); +}); + +/** + * @type {Set} + */ +let modifierKeySet = new Set(); +let interimShift = false; +let isRecording = false; +const modifierKeys = new Set(['Control', 'Shift', 'Alt', 'Meta']); + +function convertAcceleratorToTokens(accelerator) { + return accelerator.split('+').filter(Boolean); +} + +function convertTokensToAccelerator(tokens) { + return tokens.join('+'); +} + +/** + * Convert a key code from a Web keyboard event to an Electron key code + * @param {string} code + * @returns {string} + */ +function mapWebKeyCodeToElectronKeyCode(code) { + return code + .toUpperCase() + .replace('KEY', '') + .replace('DIGIT', '') + .replace('NUMPAD', 'NUM') + .replace('COMMA', ','); +} + +function onlyPressedModifierKeyIsShift() { + return ( + modifierKeySet.size === 1 && modifierKeySet.has('Shift') && interimShift + ); +} + +/** + * Handle the recording of a keyboard shortcut + * @param {KeyboardEvent} event + */ +function recordShortcut(event) { + event.preventDefault(); + if (!isRecording) { + return; + } + const { key } = event; + if (key === 'Shift') { + interimShift = true; + } + if (interimShift && modifierKeys.has(key)) { + modifierKeySet.add('Shift'); + if (key === 'Meta') { + modifierKeySet.add(metaKey); + } else { + modifierKeySet.add(key); + } + } else if (modifierKeys.has(key)) { + if (key === 'Meta') { + modifierKeySet.add(metaKey); + } else { + modifierKeySet.add(key); + } + } else if ( + key.length === 1 && + modifierKeySet.size > 0 && + !onlyPressedModifierKeyIsShift() + ) { + const nonModifiedKey = mapWebKeyCodeToElectronKeyCode(event.code); + if (interimShift === false) { + modifierKeySet.delete('Shift'); + } + const finalShortcut = Array.from(modifierKeySet).concat(nonModifiedKey); + shortcut = finalShortcut; + modifierKeySet = new Set(); + interimShift = false; + isRecording = false; + window.settings?.setQuickOpenShortcut?.( + convertTokensToAccelerator(shortcut), + ); + } + + updateDOM(); +} + +/** + * Handle the release of a key + * @param {KeyboardEvent} event + */ +function keyUp(event) { + if (!isRecording) return; + if (event.key === 'Escape') { + isRecording = false; + } + const { key } = event; + if (event.key === 'Shift') { + interimShift = false; + } else if (modifierKeys.has(key)) { + modifierKeySet.delete(key); + } +} + +function toggleRecording() { + isRecording = !isRecording; + updateDOM(); +} + +function turnRecordingOff() { + isRecording = false; + updateDOM(); +} + +window.addEventListener('keydown', recordShortcut); +window.addEventListener('keyup', keyUp); + +// Manually manage the DOM based on state +function updateDOM() { + const container = document.getElementById('accelerator-container'); + container.innerHTML = ''; + + if (!shortcut || shortcut.length === 0) { + const btn = document.createElement('button'); + btn.textContent = isRecording + ? 'Recording shortcut...' + : 'Click to record shortcut'; + btn.addEventListener('click', toggleRecording); + container.appendChild(btn); + } else { + const wrapper = document.createElement('div'); + wrapper.classList.add('accelerator-wrapper'); + const shortcutDiv = document.createElement('div'); + shortcutDiv.classList.add('accelerator'); + if (isRecording) { + const tagDiv = document.createElement('div'); + tagDiv.classList.add('tag', 'in-progress'); + tagDiv.textContent = 'Type shortcut...'; + shortcutDiv.appendChild(tagDiv); + } else { + shortcut.forEach((token) => { + const tagDiv = document.createElement('div'); + tagDiv.classList.add('tag'); + tagDiv.textContent = token; + shortcutDiv.appendChild(tagDiv); + }); + } + const btn = document.createElement('button'); + btn.textContent = isRecording + ? 'Cancel recording' + : 'Click to record new shortcut'; + btn.addEventListener('click', toggleRecording); + wrapper.appendChild(shortcutDiv); + wrapper.appendChild(btn); + container.appendChild(wrapper); + } +} + +// Initial render +updateDOM();