From 37bd642a4837587dbbe96773917afcf222080b5b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 17:27:05 -0700 Subject: [PATCH] [eric] voice: rebindable dictation shortcut (Settings recorder chip, live rebind across all 3 hotkey tiers; F5 default dropped since macOS routes it to Siri; voice_hold_to_talk now actually persists) --- backend/apps/settings/models.py | 3 + electron/preload.js | 1 + electron/voiceHotkey.js | 130 +++++++++++++----- .../sections/general/GeneralInterface.tsx | 73 +++------- .../sections/general/ShortcutRecorderChip.tsx | 78 +++++++++++ frontend/src/shared/state/settingsSlice.ts | 2 + .../shared/voice/VoiceDictationContext.tsx | 7 + 7 files changed, 203 insertions(+), 91 deletions(-) create mode 100644 frontend/src/app/pages/Settings/sections/general/ShortcutRecorderChip.tsx diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 29170683..384677c5 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -49,6 +49,9 @@ class AppSettings(BaseModel): # Shared across App Builder workspaces (each runs its own vite port / localStorage origin); null = follow system. app_template_theme_override: Optional[Literal["light", "dark"]] = None new_agent_shortcut: str = "Meta+l" + # None = platform default (Cmd/Ctrl+Shift+D); parts format matches new_agent_shortcut. + dictation_shortcut: Optional[str] = None + voice_hold_to_talk: bool = True anthropic_api_key: Optional[str] = None browser_homepage: str = "https://www.google.com" openai_api_key: Optional[str] = None diff --git a/electron/preload.js b/electron/preload.js index cedb54d6..a508f3ab 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -75,6 +75,7 @@ contextBridge.exposeInMainWorld('openswarm', { revealBundle: (folderPath) => ipcRenderer.invoke('help:reveal-bundle', folderPath), // True keyboard hold-to-talk needs the native key tap; renderers ask so Settings copy stays honest, // and request triggers the macOS Accessibility prompt when the tap is blocked on permission. + setVoiceHotkey: (combo) => ipcRenderer.send('voice:set-hotkey', combo), voiceHoldCapable: () => ipcRenderer.invoke('voice:hold-capable'), voiceRequestHoldPermission: () => ipcRenderer.invoke('voice:request-hold-permission'), haptic: (pattern) => ipcRenderer.invoke('haptic:perform', pattern), diff --git a/electron/voiceHotkey.js b/electron/voiceHotkey.js index 5b9d77a2..f913da3b 100644 --- a/electron/voiceHotkey.js +++ b/electron/voiceHotkey.js @@ -1,6 +1,6 @@ const { app, globalShortcut, ipcMain, systemPreferences } = require('electron'); -// Voice dictation hotkey (F5 primary, Cmd/Ctrl+Shift+D backup), two tiers: +// Voice dictation hotkey, user-rebindable (Settings > Interface > Dictation shortcut), two tiers: // // NATIVE (uiohook-napi event tap): sees real key-down AND key-up globally, in or out of focus, // immune to macOS's letter-keyup-under-Cmd suppression, so the keyboard gets TRUE hold-to-talk @@ -15,22 +15,57 @@ const { app, globalShortcut, ipcMain, systemPreferences } = require('electron'); // fallback stays armed until the tap delivers its first real key event. To keep the two paths from // double-firing on one press, fallback sends are deferred 90ms and skipped when the tap just // handled a key; a deaf tap never updates that timestamp, so the fallback always fires. +// +// F5 is deliberately NOT a default: macOS's media-key layer routes it to Siri before any app sees +// it. It stays bindable for users who have remapped that key at the OS level. -const VOICE_COMBOS = ['F5', 'CommandOrControl+Shift+D']; +const DEFAULT_COMBO = process.platform === 'darwin' ? 'Meta+Shift+d' : 'Ctrl+Shift+d'; const TAP_FRESH_MS = 200; const FALLBACK_DEFER_MS = 90; +// "Meta+Shift+d" (renderer parts format, same as new_agent_shortcut) -> matcher pieces. +function parseCombo(str) { + const parts = String(str || DEFAULT_COMBO).split('+').filter(Boolean); + const key = parts[parts.length - 1] || 'd'; + const mods = { + meta: parts.includes('Meta'), + ctrl: parts.includes('Ctrl') || parts.includes('Control'), + alt: parts.includes('Alt'), + shift: parts.includes('Shift'), + }; + const accel = [ + mods.meta ? 'Meta' : null, + mods.ctrl ? 'Control' : null, + mods.alt ? 'Alt' : null, + mods.shift ? 'Shift' : null, + key.length === 1 ? key.toUpperCase() : key, + ].filter(Boolean).join('+'); + return { key, mods, accel }; +} + +function uiohookKeycodeFor(key, UiohookKey) { + if (key.length === 1 && /[a-z]/i.test(key)) return UiohookKey[key.toUpperCase()]; + if (key.length === 1 && /[0-9]/.test(key)) return UiohookKey[key]; + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(key)) return UiohookKey[key]; + if (key === ' ' || key === 'Space') return UiohookKey.Space; + return undefined; // unmappable for the tap; the fallback tiers still cover it +} + function installVoiceHotkey(getMainWindow) { const send = (channel) => { const win = getMainWindow(); if (win && !win.isDestroyed()) win.webContents.send(channel); }; + let combo = parseCombo(DEFAULT_COMBO); let tapProven = false; let lastTapKeyMs = 0; + let registeredAccel = null; - const unregisterFallbackShortcuts = () => { - for (const combo of VOICE_COMBOS) { try { globalShortcut.unregister(combo); } catch (_) {} } + const unregisterFallbackShortcut = () => { + if (!registeredAccel) return; + try { globalShortcut.unregister(registeredAccel); } catch (_) {} + registeredAccel = null; }; // Fallback toggle, deferred so a live tap's hold-down wins the same press. @@ -41,6 +76,19 @@ function installVoiceHotkey(getMainWindow) { }, FALLBACK_DEFER_MS); }; + // Fallback shortcut stays registered while unfocused until the tap proves alive. + const registerVoiceShortcut = () => { + if (tapProven) return; + if (registeredAccel === combo.accel) return; + unregisterFallbackShortcut(); + try { + if (globalShortcut.register(combo.accel, sendFallbackToggle)) registeredAccel = combo.accel; + } catch (_) { /* a taken shortcut just means no global hotkey; the pill still works */ } + }; + + let tapKeycode; + let UiohookKeyRef = null; + const tryStartNativeTap = () => { try { if (process.platform === 'darwin' && !systemPreferences.isTrustedAccessibilityClient(false)) { @@ -48,41 +96,42 @@ function installVoiceHotkey(getMainWindow) { return false; } const { uIOhook, UiohookKey } = require('uiohook-napi'); + UiohookKeyRef = UiohookKey; + tapKeycode = uiohookKeycodeFor(combo.key, UiohookKey); const MOD_KEYS = new Set([ UiohookKey.Ctrl, UiohookKey.CtrlRight, UiohookKey.Shift, UiohookKey.ShiftRight, - UiohookKey.Meta, UiohookKey.MetaRight, + UiohookKey.Meta, UiohookKey.MetaRight, UiohookKey.Alt, UiohookKey.AltRight, ]); - // 'f5' | 'combo' | null; one hold at a time, repeats and the other combo ignored while held. - let heldBy = null; + let held = false; const markAlive = () => { lastTapKeyMs = Date.now(); if (!tapProven) { tapProven = true; - unregisterFallbackShortcuts(); + unregisterFallbackShortcut(); console.log('[voice] native key tap PROVEN (events flowing), hold-to-talk enabled'); } }; + const modsMatch = (e) => + (!combo.mods.meta || e.metaKey) && + (!combo.mods.ctrl || e.ctrlKey) && + (!combo.mods.alt || e.altKey) && + (!combo.mods.shift || e.shiftKey); + uIOhook.on('keydown', (e) => { markAlive(); - if (heldBy) return; - if (e.keycode === UiohookKey.F5) { - heldBy = 'f5'; - send('voice:hold-down'); - } else if (e.keycode === UiohookKey.D && e.shiftKey && (e.metaKey || e.ctrlKey)) { - heldBy = 'combo'; + if (held || tapKeycode === undefined) return; + if (e.keycode === tapKeycode && modsMatch(e)) { + held = true; send('voice:hold-down'); } }); uIOhook.on('keyup', (e) => { markAlive(); - if (!heldBy) return; - const releases = - (heldBy === 'f5' && e.keycode === UiohookKey.F5) || - (heldBy === 'combo' && (e.keycode === UiohookKey.D || MOD_KEYS.has(e.keycode))); - if (releases) { - heldBy = null; + if (!held) return; + if (e.keycode === tapKeycode || MOD_KEYS.has(e.keycode)) { + held = false; send('voice:hold-up'); } }); @@ -97,28 +146,26 @@ function installVoiceHotkey(getMainWindow) { } }; tryStartNativeTap(); - - // Fallback shortcuts stay registered while unfocused until the tap proves alive. - const registerVoiceShortcut = () => { - if (tapProven) return; - for (const combo of VOICE_COMBOS) { - try { - if (!globalShortcut.isRegistered(combo)) { - globalShortcut.register(combo, sendFallbackToggle); - } - } catch (_) { /* a taken shortcut just means no global hotkey; the pill still works */ } - } - }; registerVoiceShortcut(); - app.on('browser-window-focus', unregisterFallbackShortcuts); + app.on('browser-window-focus', unregisterFallbackShortcut); app.on('browser-window-blur', registerVoiceShortcut); + const inputMatchesCombo = (input) => { + const k = combo.key; + const keyHit = k.length === 1 + ? (input.code === `Key${k.toUpperCase()}` || (input.key || '').toLowerCase() === k.toLowerCase()) + : (input.code === k || input.key === k); + return keyHit && + (!combo.mods.meta || input.meta) && + (!combo.mods.ctrl || input.control) && + (!combo.mods.alt || input.alt) && + (!combo.mods.shift || input.shift); + }; + const installVoiceHoldRelay = (contents) => { contents.on('before-input-event', (event, input) => { if (input.type !== 'keyDown' || input.isAutoRepeat) return; - const isD = (input.code === 'KeyD' || (input.key || '').toLowerCase() === 'd'); - const combo = (isD && (input.meta || input.control) && input.shift) || input.code === 'F5'; - if (combo) { + if (inputMatchesCombo(input)) { if (!tapProven) sendFallbackToggle(); event.preventDefault(); } @@ -131,6 +178,17 @@ function installVoiceHotkey(getMainWindow) { if (t === 'window' || t === 'webview') installVoiceHoldRelay(contents); }); + // Renderer pushes the user's saved combo on boot and whenever Settings changes it. + ipcMain.on('voice:set-hotkey', (_e, comboStr) => { + const next = parseCombo(comboStr); + if (next.accel === combo.accel) return; + combo = next; + if (UiohookKeyRef) tapKeycode = uiohookKeycodeFor(combo.key, UiohookKeyRef); + unregisterFallbackShortcut(); + registerVoiceShortcut(); + console.log('[voice] hotkey set to', combo.accel); + }); + ipcMain.handle('voice:hold-capable', () => tapProven); // Settings' "Hold to talk" fires the Accessibility prompt; Input Monitoring has no Electron API, // but a running tap makes macOS list the app in that pane for the user to flip. diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx index b0d9cb22..81ea84eb 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import TextField from '@mui/material/TextField'; @@ -8,13 +8,13 @@ import Slider from '@mui/material/Slider'; import Switch from '@mui/material/Switch'; import LightModeIcon from '@mui/icons-material/LightMode'; import DarkModeIcon from '@mui/icons-material/DarkMode'; -import KeyboardIcon from '@mui/icons-material/Keyboard'; import LanguageIcon from '@mui/icons-material/Language'; import { AppSettings } from '@/shared/state/settingsSlice'; import { useClaudeTokens, useThemeWash } from '@/shared/styles/ThemeContext'; import AccentColorPad from '@/app/components/theme/AccentColorPad'; import type { SettingsStyles } from '../settingsStyles'; import { settingSelectAttrs } from '../settingSelect'; +import ShortcutRecorderChip, { dictationDefaultCombo, comboDisplay } from './ShortcutRecorderChip'; const GeneralInterface: React.FC<{ form: AppSettings; @@ -23,7 +23,6 @@ const GeneralInterface: React.FC<{ }> = ({ form, setForm, styles }) => { const c = useClaudeTokens(); const { washOpacity, grain, setWashOpacity, setGrain } = useThemeWash(); - const [recordingShortcut, setRecordingShortcut] = useState(false); const { fieldSx, sectionSx, rowSx, rowLastSx, inlineRowSx, inlineRowLastSx, labelSx, descSx, toggleGroupSx, switchSx } = styles; return ( @@ -72,7 +71,7 @@ const GeneralInterface: React.FC<{ Dictation - {`How the mic button and the dictation shortcut (${/Mac/.test(navigator.platform) ? '\u2318\u21e7D' : 'Ctrl+Shift+D'}) work.`} + {`How the mic button and the dictation shortcut (${comboDisplay(form.dictation_shortcut || dictationDefaultCombo())}) work.`} + + + Dictation shortcut + Works anywhere, even with the app in the background. + + setForm({ ...form, dictation_shortcut: combo })} + /> + + Accent color @@ -138,57 +148,10 @@ const GeneralInterface: React.FC<{ New agent shortcut Keyboard shortcut to create an agent. - { - if (!recordingShortcut) return; - if (['Meta', 'Control', 'Shift', 'Alt'].includes(e.key)) return; - e.preventDefault(); - const parts: string[] = []; - if (e.metaKey) parts.push('Meta'); - if (e.ctrlKey) parts.push('Ctrl'); - if (e.altKey) parts.push('Alt'); - if (e.shiftKey) parts.push('Shift'); - parts.push(e.key.length === 1 ? e.key.toLowerCase() : e.key); - setForm({ ...form, new_agent_shortcut: parts.join('+') }); - setRecordingShortcut(false); - }} - onBlur={() => setRecordingShortcut(false)} - onClick={() => setRecordingShortcut(true)} - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 0.75, - px: 1.5, - py: 0.75, - borderRadius: `${c.radius.sm}px`, - border: `1px solid ${recordingShortcut ? c.accent.primary : c.border.medium}`, - cursor: 'pointer', - outline: 'none', - transition: 'border-color 0.15s', - '&:hover': { borderColor: c.accent.primary }, - }} - > - - {recordingShortcut ? ( - - Press shortcut… - - ) : ( - - {form.new_agent_shortcut - .split('+') - .map((p) => { - if (p === 'Meta') return '⌘'; - if (p === 'Ctrl') return 'Ctrl'; - if (p === 'Alt') return '⌥'; - if (p === 'Shift') return '⇧'; - return p.toUpperCase(); - }) - .join(' + ')} - - )} - + setForm({ ...form, new_agent_shortcut: combo })} + /> diff --git a/frontend/src/app/pages/Settings/sections/general/ShortcutRecorderChip.tsx b/frontend/src/app/pages/Settings/sections/general/ShortcutRecorderChip.tsx new file mode 100644 index 00000000..1ad332d5 --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/general/ShortcutRecorderChip.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import KeyboardIcon from '@mui/icons-material/Keyboard'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +export const IS_MAC = /Mac/.test(navigator.platform); + +/** Platform default for the dictation hotkey; F5 is deliberately absent (macOS routes it to Siri before apps ever see it). */ +export function dictationDefaultCombo(): string { + return IS_MAC ? 'Meta+Shift+d' : 'Ctrl+Shift+d'; +} + +export function comboDisplay(combo: string): string { + return combo + .split('+') + .map((p) => { + if (p === 'Meta') return IS_MAC ? '⌘' : 'Win'; + if (p === 'Ctrl') return IS_MAC ? '⌃' : 'Ctrl'; + if (p === 'Alt') return IS_MAC ? '⌥' : 'Alt'; + if (p === 'Shift') return IS_MAC ? '⇧' : 'Shift'; + return p.length === 1 ? p.toUpperCase() : p; + }) + .join(IS_MAC ? '' : '+'); +} + +/** Click-to-record shortcut chip: click arms it, the next non-modifier keydown becomes the combo ("Meta+Shift+d" parts format, same as new_agent_shortcut). */ +const ShortcutRecorderChip: React.FC<{ value: string; onChange: (combo: string) => void }> = ({ value, onChange }) => { + const c = useClaudeTokens(); + const [recording, setRecording] = useState(false); + return ( + { + if (!recording) return; + if (['Meta', 'Control', 'Shift', 'Alt'].includes(e.key)) return; + e.preventDefault(); + if (e.key === 'Escape') { setRecording(false); return; } + const parts: string[] = []; + if (e.metaKey) parts.push('Meta'); + if (e.ctrlKey) parts.push('Ctrl'); + if (e.altKey) parts.push('Alt'); + if (e.shiftKey) parts.push('Shift'); + parts.push(e.key.length === 1 ? e.key.toLowerCase() : e.key); + onChange(parts.join('+')); + setRecording(false); + }} + onBlur={() => setRecording(false)} + onClick={() => setRecording(true)} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.75, + px: 1.5, + py: 0.75, + borderRadius: `${c.radius.sm}px`, + border: `1px solid ${recording ? c.accent.primary : c.border.medium}`, + cursor: 'pointer', + outline: 'none', + transition: 'border-color 0.15s', + '&:hover': { borderColor: c.accent.primary }, + }} + > + + {recording ? ( + + Press shortcut… + + ) : ( + + {comboDisplay(value)} + + )} + + ); +}; + +export default ShortcutRecorderChip; diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 40d5dae0..0b86d57e 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -45,6 +45,7 @@ export interface AppSettings { zoom_sensitivity: number; theme: 'light' | 'dark'; new_agent_shortcut: string; + dictation_shortcut?: string | null; anthropic_api_key: string | null; openai_api_key?: string | null; google_api_key?: string | null; @@ -165,6 +166,7 @@ export const DEFAULT_SETTINGS: AppSettings = { voice_hold_to_talk: true, theme: 'light', new_agent_shortcut: 'Meta+l', + dictation_shortcut: null, anthropic_api_key: null, browser_homepage: 'https://duckduckgo.com', auto_select_mode_on_new_agent: false, diff --git a/frontend/src/shared/voice/VoiceDictationContext.tsx b/frontend/src/shared/voice/VoiceDictationContext.tsx index 95acdc84..28da3f15 100644 --- a/frontend/src/shared/voice/VoiceDictationContext.tsx +++ b/frontend/src/shared/voice/VoiceDictationContext.tsx @@ -11,6 +11,13 @@ import VoiceOverlay from './VoiceOverlay'; export function VoiceDictationProvider({ children }: { children: React.ReactNode }): React.ReactElement { const { state, lastText, error, pct, feedback, toggle, start, stop, volumeRef } = useVoiceDictation(); const holdMode = useAppSelector((s) => s.settings.data.voice_hold_to_talk ?? true); + const dictationShortcut = useAppSelector((s) => s.settings.data.dictation_shortcut ?? null); + + // Push the user's combo to main on boot and on change so every hotkey tier rebinds live. + useEffect(() => { + const bridge = window as unknown as { openswarm?: { setVoiceHotkey?: (combo: string | null) => void } }; + bridge.openswarm?.setVoiceHotkey?.(dictationShortcut); + }, [dictationShortcut]); const stateRef = useRef(state); stateRef.current = state; const heldRef = useRef(false);