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