diff --git a/src/renderer/components/Settings/tabs/ShortcutsTab.tsx b/src/renderer/components/Settings/tabs/ShortcutsTab.tsx index 4f670ce651..a3c7eac0f6 100644 --- a/src/renderer/components/Settings/tabs/ShortcutsTab.tsx +++ b/src/renderer/components/Settings/tabs/ShortcutsTab.tsx @@ -7,8 +7,10 @@ */ import React, { useState, useRef, useEffect } from 'react'; +import { Search } from 'lucide-react'; import { useSettings } from '../../../hooks'; import { formatShortcutKeys } from '../../../utils/shortcutFormatter'; +import { buildKeysFromEvent } from '../../../utils/shortcutRecorder'; import type { Theme, Shortcut } from '../../../types'; export interface ShortcutsTabProps { @@ -22,12 +24,15 @@ export function ShortcutsTab({ theme, hasNoAgents, onRecordingChange }: Shortcut const [recordingId, setRecordingId] = useState(null); const [shortcutsFilter, setShortcutsFilter] = useState(''); + const [recordingFilterShortcut, setRecordingFilterShortcut] = useState(false); + const [filterShortcutKeys, setFilterShortcutKeys] = useState([]); const shortcutsFilterRef = useRef(null); + const filterShortcutButtonRef = useRef(null); // Notify parent of recording state changes (for escape handler coordination) useEffect(() => { - onRecordingChange?.(!!recordingId); - }, [recordingId, onRecordingChange]); + onRecordingChange?.(!!recordingId || recordingFilterShortcut); + }, [recordingId, recordingFilterShortcut, onRecordingChange]); // Auto-focus filter input on mount useEffect(() => { @@ -49,28 +54,8 @@ export function ShortcutsTab({ theme, hasNoAgents, onRecordingChange }: Shortcut return; } - const keys = []; - if (e.metaKey) keys.push('Meta'); - if (e.ctrlKey) keys.push('Ctrl'); - if (e.altKey) keys.push('Alt'); - if (e.shiftKey) keys.push('Shift'); - if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return; - - // On macOS, Alt+letter produces special characters (e.g., Alt+L = ¬, Alt+P = π) - // Use e.code to get the physical key name when Alt is pressed - let mainKey = e.key; - if (e.altKey && e.code) { - // e.code is like 'KeyL', 'KeyP', 'Digit1', etc. - if (e.code.startsWith('Key')) { - mainKey = e.code.replace('Key', '').toLowerCase(); - } else if (e.code.startsWith('Digit')) { - mainKey = e.code.replace('Digit', ''); - } else { - // For other keys like Arrow keys, use as-is - mainKey = e.key; - } - } - keys.push(mainKey); + const keys = buildKeysFromEvent(e); + if (!keys) return; if (isTabShortcut) { setTabShortcuts({ @@ -86,14 +71,36 @@ export function ShortcutsTab({ theme, hasNoAgents, onRecordingChange }: Shortcut setRecordingId(null); }; + const handleFilterRecord = (e: React.KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === 'Escape') { + setRecordingFilterShortcut(false); + setFilterShortcutKeys([]); + return; + } + + const keys = buildKeysFromEvent(e); + if (!keys) return; + + setFilterShortcutKeys(keys); + setRecordingFilterShortcut(false); + }; + const allShortcuts = [ ...Object.values(shortcuts).map((sc) => ({ ...sc, isTabShortcut: false })), ...Object.values(tabShortcuts).map((sc) => ({ ...sc, isTabShortcut: true })), ]; const totalShortcuts = allShortcuts.length; - const filteredShortcuts = allShortcuts.filter((sc) => - sc.label.toLowerCase().includes(shortcutsFilter.toLowerCase()) - ); + const filteredShortcuts = allShortcuts.filter((sc) => { + if (filterShortcutKeys.length > 0) { + const sortedFilter = [...filterShortcutKeys].sort().join('+'); + const sortedKeys = [...sc.keys].sort().join('+'); + return sortedKeys === sortedFilter; + } + return sc.label.toLowerCase().includes(shortcutsFilter.toLowerCase()); + }); const filteredCount = filteredShortcuts.length; // Group shortcuts by category @@ -150,24 +157,76 @@ export function ShortcutsTab({ theme, hasNoAgents, onRecordingChange }: Shortcut Note: Most functionality is unavailable until you've created your first agent.

)} -
+
setShortcutsFilter(e.target.value)} + onChange={(e) => { + setShortcutsFilter(e.target.value); + setFilterShortcutKeys([]); + }} placeholder="Filter shortcuts..." className="flex-1 px-3 py-2 rounded border bg-transparent outline-none text-sm" style={{ borderColor: theme.colors.border, color: theme.colors.textMain }} /> + - {shortcutsFilter ? `${filteredCount} / ${totalShortcuts}` : totalShortcuts} + {shortcutsFilter || filterShortcutKeys.length > 0 + ? `${filteredCount} / ${totalShortcuts}` + : totalShortcuts}

diff --git a/src/renderer/components/ShortcutsHelpModal.tsx b/src/renderer/components/ShortcutsHelpModal.tsx index e7b60a3cc4..dc23e59c70 100644 --- a/src/renderer/components/ShortcutsHelpModal.tsx +++ b/src/renderer/components/ShortcutsHelpModal.tsx @@ -1,11 +1,12 @@ -import { useState, useRef, useMemo } from 'react'; -import { X, Award, CheckCircle, Trophy, ExternalLink } from 'lucide-react'; +import React, { useState, useRef, useMemo, useCallback } from 'react'; +import { X, Award, CheckCircle, Trophy, ExternalLink, Search } from 'lucide-react'; import { GhostIconButton } from './ui/GhostIconButton'; import type { Theme, Shortcut, KeyboardMasteryStats } from '../types'; import { fuzzyMatch } from '../utils/search'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import { FIXED_SHORTCUTS } from '../constants/shortcuts'; import { formatShortcutKeys } from '../utils/shortcutFormatter'; +import { buildKeysFromEvent } from '../utils/shortcutRecorder'; import { Modal } from './ui/Modal'; import { KEYBOARD_MASTERY_LEVELS, getLevelForPercentage } from '../constants/keyboardMastery'; import { openUrl } from '../utils/openUrl'; @@ -29,7 +30,23 @@ export function ShortcutsHelpModal({ keyboardMasteryStats, }: ShortcutsHelpModalProps) { const [searchQuery, setSearchQuery] = useState(''); + const [recordingFilterShortcut, setRecordingFilterShortcut] = useState(false); + const [filterShortcutKeys, setFilterShortcutKeys] = useState([]); const searchInputRef = useRef(null); + const filterShortcutButtonRef = useRef(null); + // Ref mirrors recording state so onBeforeClose stays stable for layer registration. + const recordingRef = useRef(recordingFilterShortcut); + recordingRef.current = recordingFilterShortcut; + + // Block modal close on Escape while recording — instead, cancel the recording. + const handleBeforeClose = useCallback(() => { + if (recordingRef.current) { + setRecordingFilterShortcut(false); + setFilterShortcutKeys([]); + return false; + } + return true; + }, []); // Combine all shortcuts for display and mastery tracking const allShortcuts = useMemo( @@ -54,8 +71,32 @@ export function ShortcutsHelpModal({ return KEYBOARD_MASTERY_LEVELS.find((l) => l.threshold > masteryPercentage); }, [masteryPercentage]); const usedShortcutIds = new Set(keyboardMasteryStats?.usedShortcuts ?? []); + const handleFilterRecord = (e: React.KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === 'Escape') { + setRecordingFilterShortcut(false); + setFilterShortcutKeys([]); + return; + } + + const keys = buildKeysFromEvent(e); + if (!keys) return; + + setFilterShortcutKeys(keys); + setRecordingFilterShortcut(false); + }; + const filteredShortcuts = Object.values(allShortcuts) - .filter((sc) => fuzzyMatch(sc.label, searchQuery) || fuzzyMatch(sc.keys.join(' '), searchQuery)) + .filter((sc) => { + if (filterShortcutKeys.length > 0) { + const sortedFilter = [...filterShortcutKeys].sort().join('+'); + const sortedKeys = [...sc.keys].sort().join('+'); + return sortedKeys === sortedFilter; + } + return fuzzyMatch(sc.label, searchQuery) || fuzzyMatch(sc.keys.join(' '), searchQuery); + }) .sort((a, b) => a.label.localeCompare(b.label)); const filteredCount = filteredShortcuts.length; @@ -68,10 +109,12 @@ export function ShortcutsHelpModal({ Keyboard Shortcuts - {searchQuery ? `${filteredCount} / ${totalShortcuts}` : totalShortcuts} + {searchQuery || filterShortcutKeys.length > 0 + ? `${filteredCount} / ${totalShortcuts}` + : totalShortcuts}

@@ -87,15 +130,67 @@ export function ShortcutsHelpModal({ Note: Most functionality is unavailable until you've created your first agent.

)} - setSearchQuery(e.target.value)} - placeholder="Search shortcuts..." - className="w-full px-3 py-2 rounded border bg-transparent outline-none text-sm" - style={{ borderColor: theme.colors.border, color: theme.colors.textMain }} - /> +
+ { + setSearchQuery(e.target.value); + setFilterShortcutKeys([]); + }} + placeholder="Search shortcuts..." + className="flex-1 px-3 py-2 rounded border bg-transparent outline-none text-sm" + style={{ borderColor: theme.colors.border, color: theme.colors.textMain }} + /> + +

Many shortcuts can be customized from Settings → Shortcuts.

@@ -165,12 +260,13 @@ export function ShortcutsHelpModal({ customHeader={customHeader} footer={footer} initialFocusRef={searchInputRef} + layerOptions={{ onBeforeClose: handleBeforeClose }} > -
+
{filteredShortcuts.map((sc, i) => { const isUsed = usedShortcutIds.has(sc.id); return ( -
+
{keyboardMasteryStats && ( diff --git a/src/renderer/constants/shortcuts.ts b/src/renderer/constants/shortcuts.ts index 3f7e9e3eae..4a46634a52 100644 --- a/src/renderer/constants/shortcuts.ts +++ b/src/renderer/constants/shortcuts.ts @@ -162,15 +162,6 @@ export const FIXED_SHORTCUTS: Record = { }, }; -// Terminal tab shortcuts -export const TERMINAL_SHORTCUTS: Record = { - newTerminalTab: { - id: 'newTerminalTab', - label: 'New Terminal Tab', - keys: ['Control', 'Shift', '`'], - }, -}; - // Tab navigation shortcuts (AI mode only) export const TAB_SHORTCUTS: Record = { tabSwitcher: { id: 'tabSwitcher', label: 'Tab Switcher', keys: ['Alt', 'Meta', 't'] }, diff --git a/src/renderer/hooks/keyboard/useKeyboardShortcutHelpers.ts b/src/renderer/hooks/keyboard/useKeyboardShortcutHelpers.ts index 17f96ea8cd..fe2bbf97f5 100644 --- a/src/renderer/hooks/keyboard/useKeyboardShortcutHelpers.ts +++ b/src/renderer/hooks/keyboard/useKeyboardShortcutHelpers.ts @@ -44,7 +44,7 @@ export function useKeyboardShortcutHelpers( * - Arrow keys, Backspace, special characters * - Shift+bracket producing { and } characters * - Shift+number producing symbol characters (US layout) - * - macOS Alt key producing special characters (uses e.code fallback) + * - Alt-rewritten characters on macOS/AltGr layouts (uses e.code fallback) */ const isShortcut = useCallback( (e: KeyboardEvent, actionId: string): boolean => { @@ -93,8 +93,9 @@ export function useKeyboardShortcutHelpers( }; if (shiftNumberMap[key] === mainKey) return true; - // For Alt+Meta shortcuts on macOS, e.key produces special characters (e.g., Alt+p = π, Alt+l = ¬) - // Use e.code to get the physical key pressed instead + // When Alt is held, e.key may be rewritten by the layout (macOS Alt+p = π, + // Alt+l = ¬; Windows/Linux AltGr variants). Fall back to e.code for the + // physical key. Must stay symmetric with buildKeysFromEvent in shortcutRecorder.ts. if (altPressed && e.code) { const codeKey = e.code.replace('Key', '').toLowerCase(); // Map e.code values to key characters for punctuation keys @@ -152,8 +153,8 @@ export function useKeyboardShortcutHelpers( if (mainKey === ',' && (key === ',' || key === '<')) return true; if (mainKey === '.' && (key === '.' || key === '>')) return true; - // For Alt+Meta shortcuts on macOS, e.key produces special characters (e.g., Alt+t = †) - // Use e.code to get the physical key pressed instead + // When Alt is held, e.key may be rewritten by the layout (macOS Alt+t = †; + // Windows/Linux AltGr variants). Fall back to e.code for the physical key. if (altPressed && e.code) { const codeKey = e.code.replace('Key', '').toLowerCase(); // Map e.code values to key characters for punctuation keys diff --git a/src/renderer/utils/shortcutRecorder.ts b/src/renderer/utils/shortcutRecorder.ts new file mode 100644 index 0000000000..92a49edb73 --- /dev/null +++ b/src/renderer/utils/shortcutRecorder.ts @@ -0,0 +1,32 @@ +import type React from 'react'; + +/** + * Build a shortcut key array from a keyboard event. + * Returns null if only modifier keys are pressed (caller should keep recording). + * + * When Alt is held, the main key is derived from e.code rather than e.key. + * This recovers the physical key name across layouts where Alt rewrites the + * character — most notably macOS (Alt+L = ¬, Alt+P = π) but also AltGr-based + * layouts on Windows/Linux. Applied unconditionally so recording stays + * symmetric with isShortcut's matching path in useKeyboardShortcutHelpers.ts. + */ +export function buildKeysFromEvent(e: React.KeyboardEvent): string[] | null { + if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return null; + + const keys: string[] = []; + if (e.metaKey) keys.push('Meta'); + if (e.ctrlKey) keys.push('Ctrl'); + if (e.altKey) keys.push('Alt'); + if (e.shiftKey) keys.push('Shift'); + + let mainKey = e.key; + if (e.altKey && e.code) { + if (e.code.startsWith('Key')) { + mainKey = e.code.replace('Key', '').toLowerCase(); + } else if (e.code.startsWith('Digit')) { + mainKey = e.code.replace('Digit', ''); + } + } + keys.push(mainKey); + return keys; +}