From 649eeddff34e0fc1c90b0083c4162a94ee6b1779 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 15 Apr 2026 07:02:27 +0200 Subject: [PATCH] feat: add configurable hotkey for Quick Prompt in settings Add a HotkeyRecorder UI component that lets users click to enter recording mode, press a modifier+key combination, and immediately save it as the new Quick Prompt global shortcut. - New HotkeyRecorder component in ui/ with live modifier preview, animated recording indicator, click-outside/Escape to cancel, platform-aware key symbol display, and disabled state support - Integrate into QuickPromptSettingsSection with separate enable toggle and recorder rows - Recorder is disabled when the global hotkey toggle is off - Existing GlobalHotkeyService.update() re-registers automatically Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/components/SettingsPanel.tsx | 41 ++-- src/renderer/components/ui/HotkeyRecorder.tsx | 222 ++++++++++++++++++ src/renderer/components/ui/index.ts | 1 + 3 files changed, 243 insertions(+), 21 deletions(-) create mode 100644 src/renderer/components/ui/HotkeyRecorder.tsx diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 0487c66..6ec3552 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -3,12 +3,11 @@ import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranc import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard'; import { WorkflowEditor } from '@renderer/components/WorkflowEditor'; -import { ToggleSwitch } from '@renderer/components/ui'; +import { HotkeyRecorder, ToggleSwitch } from '@renderer/components/ui'; import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor'; import { McpServerEditor } from '@renderer/components/settings/McpServerEditor'; import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor'; import { getElectronApi } from '@renderer/lib/electronApi'; -import { isMac } from '@renderer/lib/platform'; import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar'; import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling'; import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; @@ -1339,13 +1338,6 @@ function QuickPromptSettingsSection({ const resolvedModel = defaultModel ? availableModels.find((m) => m.id === defaultModel) : undefined; const modelSupportsReasoning = resolvedModel?.supportedReasoningEfforts?.length; - const hotkeyKeys = hotkey - .replace('Super', isMac ? '⌘' : 'Win') - .replace('Alt', isMac ? '⌥' : 'Alt') - .replace('Shift', '⇧') - .split('+') - .map((k) => k.trim()); - // Group models by tier for the dropdown const tierOrder = ['premium', 'standard', 'fast'] as const; const tierLabels: Record = { premium: 'Premium', standard: 'Standard', fast: 'Fast' }; @@ -1372,7 +1364,7 @@ function QuickPromptSettingsSection({

- {/* Enable / Disable + Keyboard Shortcut — compact row */} + {/* Enable / Disable toggle */}
+ {/* Hotkey Recorder */} +
+
+ + Keyboard shortcut + +
+ onUpdate?.({ hotkey: newHotkey })} + /> +
+ {/* Default Model — compact dropdown selector */}

Default Model

diff --git a/src/renderer/components/ui/HotkeyRecorder.tsx b/src/renderer/components/ui/HotkeyRecorder.tsx new file mode 100644 index 0000000..d64ffbb --- /dev/null +++ b/src/renderer/components/ui/HotkeyRecorder.tsx @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { isMac } from '@renderer/lib/platform'; + +/** Modifier keys we recognise, in canonical display order. */ +const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta']); + +/** Keys that should never be accepted as the primary (non-modifier) key. */ +const IGNORED_KEYS = new Set([ + 'Dead', 'Unidentified', 'Process', 'CapsLock', 'NumLock', 'ScrollLock', + 'Fn', 'FnLock', 'Hyper', 'Super', 'OS', +]); + +/** + * Convert a portable hotkey string (e.g. `"Alt+Shift+C"`) into + * platform-aware display tokens. + */ +export function hotkeyToDisplayTokens(hotkey: string): string[] { + return hotkey + .replace(/\bSuper\b/g, isMac ? '⌘' : 'Win') + .replace(/\bMeta\b/g, isMac ? '⌘' : 'Win') + .replace(/\bCtrl\b/g, isMac ? '⌃' : 'Ctrl') + .replace(/\bControl\b/g, isMac ? '⌃' : 'Ctrl') + .replace(/\bAlt\b/g, isMac ? '⌥' : 'Alt') + .replace(/\bShift\b/g, isMac ? '⇧' : 'Shift') + .split('+') + .map((k) => k.trim()); +} + +/** + * Normalise a `KeyboardEvent` key name into the portable format used in + * settings (Electron accelerator-compatible). + */ +function normaliseKeyName(key: string): string | undefined { + if (MODIFIER_KEYS.has(key) || IGNORED_KEYS.has(key)) return undefined; + + // Letters → uppercase + if (/^[a-zA-Z]$/.test(key)) return key.toUpperCase(); + + // Digits + if (/^[0-9]$/.test(key)) return key; + + // F-keys + if (/^F\d{1,2}$/.test(key)) return key; + + // Named keys + const named: Record = { + ' ': 'Space', Enter: 'Enter', Tab: 'Tab', Escape: 'Escape', + Backspace: 'Backspace', Delete: 'Delete', Insert: 'Insert', + Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown', + ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', + '+': 'Plus', '-': 'Minus', '=': 'Equal', + '[': 'BracketLeft', ']': 'BracketRight', + '\\': 'Backslash', '/': 'Slash', + ';': 'Semicolon', "'": 'Quote', ',': 'Comma', '.': 'Period', + '`': 'Backquote', + }; + return named[key] ?? key; +} + +/** Build the portable hotkey string from modifier flags and a key name. */ +function buildHotkeyString(mods: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }, key: string): string { + const parts: string[] = []; + if (mods.ctrl) parts.push('Ctrl'); + if (mods.alt) parts.push('Alt'); + if (mods.shift) parts.push('Shift'); + if (mods.meta) parts.push('Super'); + parts.push(key); + return parts.join('+'); +} + +interface HotkeyRecorderProps { + value: string; + onChange: (hotkey: string) => void; + disabled?: boolean; +} + +export function HotkeyRecorder({ value, onChange, disabled }: HotkeyRecorderProps) { + const [recording, setRecording] = useState(false); + const [liveKeys, setLiveKeys] = useState(null); + const recorderRef = useRef(null); + + const displayTokens = hotkeyToDisplayTokens(liveKeys ?? value); + + const stopRecording = useCallback(() => { + setRecording(false); + setLiveKeys(null); + }, []); + + // Escape cancels recording + // Valid combo (modifier + key) commits immediately + useEffect(() => { + if (!recording) return; + + function handleKeyDown(e: KeyboardEvent) { + e.preventDefault(); + e.stopPropagation(); + + // Escape cancels + if (e.key === 'Escape') { + stopRecording(); + return; + } + + const mods = { ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey }; + const hasModifier = mods.ctrl || mods.alt || mods.shift || mods.meta; + const key = normaliseKeyName(e.key); + + if (key && hasModifier) { + const hotkey = buildHotkeyString(mods, key); + onChange(hotkey); + setRecording(false); + setLiveKeys(null); + } else if (!key) { + // Only modifiers held — show live preview + const preview = buildModifierPreview(mods); + if (preview) setLiveKeys(preview); + } + } + + function handleKeyUp(e: KeyboardEvent) { + e.preventDefault(); + e.stopPropagation(); + } + + window.addEventListener('keydown', handleKeyDown, true); + window.addEventListener('keyup', handleKeyUp, true); + return () => { + window.removeEventListener('keydown', handleKeyDown, true); + window.removeEventListener('keyup', handleKeyUp, true); + }; + }, [recording, onChange, stopRecording]); + + // Click outside cancels recording + useEffect(() => { + if (!recording) return; + function handleClick(e: MouseEvent) { + if (recorderRef.current && !recorderRef.current.contains(e.target as Node)) { + stopRecording(); + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [recording, stopRecording]); + + return ( + + ); +} + +function buildModifierPreview(mods: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }): string | null { + const parts: string[] = []; + if (mods.ctrl) parts.push('Ctrl'); + if (mods.alt) parts.push('Alt'); + if (mods.shift) parts.push('Shift'); + if (mods.meta) parts.push('Super'); + return parts.length > 0 ? parts.join('+') + '+…' : null; +} diff --git a/src/renderer/components/ui/index.ts b/src/renderer/components/ui/index.ts index 986155c..f5bab12 100644 --- a/src/renderer/components/ui/index.ts +++ b/src/renderer/components/ui/index.ts @@ -7,5 +7,6 @@ export { TextInput } from './TextInput'; export { TextareaInput } from './TextareaInput'; export { SelectInput } from './SelectInput'; export { InfoCallout } from './InfoCallout'; +export { HotkeyRecorder, hotkeyToDisplayTokens } from './HotkeyRecorder'; export type { UpdateBannerProps } from './UpdateBanner'; export { UpdateBanner } from './UpdateBanner';