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;
+}