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