[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)

This commit is contained in:
ciregenz
2026-07-28 17:27:05 -07:00
parent c8de032306
commit 37bd642a48
7 changed files with 203 additions and 91 deletions
+3
View File
@@ -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
+1
View File
@@ -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),
+94 -36
View File
@@ -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.
@@ -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<{
<Box sx={inlineRowSx} {...settingSelectAttrs('voice_hold_to_talk', 'Dictation', 'Interface', 'Hold to talk, or tap to start and stop.')}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Dictation</Typography>
<Typography sx={descSx}>{`How the mic button and the dictation shortcut (${/Mac/.test(navigator.platform) ? '\u2318\u21e7D' : 'Ctrl+Shift+D'}) work.`}</Typography>
<Typography sx={descSx}>{`How the mic button and the dictation shortcut (${comboDisplay(form.dictation_shortcut || dictationDefaultCombo())}) work.`}</Typography>
</Box>
<ToggleButtonGroup
value={form.voice_hold_to_talk ?? true}
@@ -92,6 +91,17 @@ const GeneralInterface: React.FC<{
</ToggleButtonGroup>
</Box>
<Box sx={inlineRowSx} {...settingSelectAttrs('dictation_shortcut', 'Dictation shortcut', 'Interface', 'Global hotkey that starts dictation.')}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Dictation shortcut</Typography>
<Typography sx={descSx}>Works anywhere, even with the app in the background.</Typography>
</Box>
<ShortcutRecorderChip
value={form.dictation_shortcut || dictationDefaultCombo()}
onChange={(combo) => setForm({ ...form, dictation_shortcut: combo })}
/>
</Box>
<Box sx={rowSx} {...settingSelectAttrs('accent_color', 'Accent color', 'Interface', 'The accent color used across the app.')}>
<Typography sx={labelSx}>Accent color</Typography>
<Typography sx={{ ...descSx, mb: 1.5 }}>
@@ -138,57 +148,10 @@ const GeneralInterface: React.FC<{
<Typography sx={labelSx}>New agent shortcut</Typography>
<Typography sx={descSx}>Keyboard shortcut to create an agent.</Typography>
</Box>
<Box
tabIndex={0}
onKeyDown={(e) => {
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 },
}}
>
<KeyboardIcon sx={{ fontSize: 16, color: recordingShortcut ? c.accent.primary : c.text.tertiary }} />
{recordingShortcut ? (
<Typography sx={{ fontSize: '0.8125rem', color: c.accent.primary, fontWeight: 500 }}>
Press shortcut…
</Typography>
) : (
<Typography sx={{ fontSize: '0.8125rem', color: c.text.primary, fontFamily: c.font.mono, fontWeight: 500 }}>
{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(' + ')}
</Typography>
)}
</Box>
<ShortcutRecorderChip
value={form.new_agent_shortcut}
onChange={(combo) => setForm({ ...form, new_agent_shortcut: combo })}
/>
</Box>
<Box sx={inlineRowSx} {...settingSelectAttrs('auto_select_mode_on_new_agent', 'Auto-enable element selection', 'Interface', 'Enter element selection mode when creating a new agent.')}>
@@ -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 (
<Box
tabIndex={0}
onKeyDown={(e) => {
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 },
}}
>
<KeyboardIcon sx={{ fontSize: 16, color: recording ? c.accent.primary : c.text.tertiary }} />
{recording ? (
<Typography sx={{ fontSize: '0.8125rem', color: c.accent.primary, fontWeight: 500 }}>
Press shortcut…
</Typography>
) : (
<Typography sx={{ fontSize: '0.8125rem', color: c.text.primary, fontFamily: c.font.mono, fontWeight: 500 }}>
{comboDisplay(value)}
</Typography>
)}
</Box>
);
};
export default ShortcutRecorderChip;
@@ -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,
@@ -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);