mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 12:34:50 +02:00
[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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user