Skip to content

Commit 26387f7

Browse files
authored
Add global keyboard shortcut preference (#371)
* feat: add global keyboard shortcut preference * fix: clarify shortcut preference behavior
1 parent 6aa2af1 commit 26387f7

25 files changed

Lines changed: 410 additions & 31 deletions

frontend/src/App.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ function App() {
108108
const [resumableSessions, setResumableSessions] = useState<ResumableSession[]>([]);
109109
const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false);
110110
const [isHelpOpen, setIsHelpOpen] = useState(false);
111+
const [isKeyboardShortcutsOpen, setIsKeyboardShortcutsOpen] = useState(false);
111112
const [isDocsOpen, setIsDocsOpen] = useState(false);
112113
const [showCreateSessionDialog, setShowCreateSessionDialog] = useState(false);
113114
const [showAddProjectDialog, setShowAddProjectDialog] = useState(false);
@@ -825,6 +826,10 @@ function App() {
825826
onCategoryChange={setSettingsCategory}
826827
openRequest={settingsOpenRequest}
827828
onOpenRequestHandled={() => setSettingsOpenRequest(undefined)}
829+
onShowKeyboardShortcuts={() => {
830+
closeSettings();
831+
setIsKeyboardShortcutsOpen(true);
832+
}}
828833
/>
829834
<AnalyticsConsentDialog
830835
isOpen={isAnalyticsConsentOpen}
@@ -891,6 +896,11 @@ function App() {
891896
onClose={() => setShowAddProjectDialog(false)}
892897
/>
893898
<Help isOpen={isHelpOpen} onClose={() => setIsHelpOpen(false)} />
899+
<Help
900+
isOpen={isKeyboardShortcutsOpen}
901+
onClose={() => setIsKeyboardShortcutsOpen(false)}
902+
shortcutsOnly
903+
/>
894904
<DocsDialog isOpen={isDocsOpen} onClose={() => setIsDocsOpen(false)} />
895905
<ShortcutHintsOverlay isVisible={shortcutHintsVisible} shortcuts={terminalShortcuts} />
896906
</div>

frontend/src/components/CommitDialog.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { formatKeyDisplay } from '../utils/hotkeyUtils';
44
import { Modal, ModalHeader, ModalBody, ModalFooter } from './ui/Modal';
55
import { Button } from './ui/Button';
66
import { Textarea } from './ui/Textarea';
7+
import { areKeyboardShortcutsEnabled, useConfigStore } from '../stores/configStore';
78

89
interface CommitDialogProps {
910
isOpen: boolean;
@@ -22,6 +23,7 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
2223
const [isCommitting, setIsCommitting] = useState(false);
2324
const [error, setError] = useState<string | null>(null);
2425
const textareaRef = useRef<HTMLTextAreaElement>(null);
26+
const keyboardShortcutsEnabled = useConfigStore((state) => areKeyboardShortcutsEnabled(state.config));
2527

2628
// Set default message
2729
useEffect(() => {
@@ -59,13 +61,13 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
5961
}, [commitMessage, onCommit, onClose]);
6062

6163
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
62-
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
64+
if (keyboardShortcutsEnabled && e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
6365
e.preventDefault();
6466
handleCommit();
6567
} else if (e.key === 'Escape') {
6668
onClose();
6769
}
68-
}, [handleCommit, onClose]);
70+
}, [handleCommit, keyboardShortcutsEnabled, onClose]);
6971

7072
return (
7173
<Modal isOpen={isOpen} onClose={onClose} size="md">
@@ -116,4 +118,4 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
116118
</ModalFooter>
117119
</Modal>
118120
);
119-
};
121+
};

frontend/src/components/CreateSessionDialog.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Button } from './ui/Button';
1010
import { Input } from './ui/Input';
1111
import { useSessionPreferencesStore, type SessionCreationPreferences } from '../stores/sessionPreferencesStore';
1212
import { useSessionStore } from '../stores/sessionStore';
13+
import { areKeyboardShortcutsEnabled, useConfigStore } from '../stores/configStore';
1314
import { generatePaneName, sanitizePaneName } from '../utils/paneName';
1415

1516
// Interface for branch information
@@ -72,6 +73,7 @@ export function CreateSessionDialog({
7273
const { showError } = useErrorStore();
7374
const { preferences, loadPreferences, updatePreferences } = useSessionPreferencesStore();
7475
const existingSessions = useSessionStore(state => state.sessions);
76+
const keyboardShortcutsEnabled = useConfigStore((state) => areKeyboardShortcutsEnabled(state.config));
7577

7678
// Load session creation preferences when dialog opens
7779
useEffect(() => {
@@ -267,7 +269,7 @@ export function CreateSessionDialog({
267269
if (!isOpen) return;
268270

269271
// Cmd/Ctrl + Enter to submit
270-
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
272+
if (keyboardShortcutsEnabled && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
271273
e.preventDefault();
272274
const form = document.getElementById('create-session-form') as HTMLFormElement;
273275
if (form) {
@@ -279,7 +281,7 @@ export function CreateSessionDialog({
279281

280282
window.addEventListener('keydown', handleKeyDown);
281283
return () => window.removeEventListener('keydown', handleKeyDown);
282-
}, [isOpen]);
284+
}, [isOpen, keyboardShortcutsEnabled]);
283285

284286
// Auto-focus name input on dialog open (always available immediately)
285287
useEffect(() => {

frontend/src/components/Help.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,17 @@ function KeyboardShortcutsSection() {
6565
interface HelpProps {
6666
isOpen: boolean;
6767
onClose: () => void;
68+
shortcutsOnly?: boolean;
6869
}
6970

70-
export default function Help({ isOpen, onClose }: HelpProps) {
71+
export default function Help({ isOpen, onClose, shortcutsOnly = false }: HelpProps) {
7172
return (
7273
<Modal isOpen={isOpen} onClose={onClose} size="xl" showCloseButton={false}>
73-
<ModalHeader title="Pane Help" />
74+
<ModalHeader title={shortcutsOnly ? 'Keyboard Shortcuts' : 'Pane Help'} />
7475
<ModalBody>
76+
{shortcutsOnly ? (
77+
<KeyboardShortcutsSection />
78+
) : (
7579
<div className="space-y-8">
7680
{/* Quick Start */}
7781
<section>
@@ -286,7 +290,8 @@ export default function Help({ isOpen, onClose }: HelpProps) {
286290
<li>Enable notifications to know when Claude needs your input</li>
287291
</ul>
288292
</section>
289-
</div>
293+
</div>
294+
)}
290295
</ModalBody>
291296

292297
<div className="p-4 border-t border-border-primary text-center text-sm text-text-muted">

frontend/src/components/Settings.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ interface SettingsProps {
4242
onCategoryChange: (category: SettingsCategoryId) => void;
4343
openRequest?: SettingsOpenRequest;
4444
onOpenRequestHandled: () => void;
45+
onShowKeyboardShortcuts: () => void;
4546
}
4647

47-
export function Settings({ isOpen, onClose, category, onCategoryChange, openRequest, onOpenRequestHandled }: SettingsProps) {
48+
export function Settings({ isOpen, onClose, category, onCategoryChange, openRequest, onOpenRequestHandled, onShowKeyboardShortcuts }: SettingsProps) {
4849
const persistence = useSettingsPersistence(isOpen);
4950
const dirtyForms = useDirtySettingsForms();
5051
const {
@@ -114,6 +115,10 @@ export function Settings({ isOpen, onClose, category, onCategoryChange, openRequ
114115
});
115116
}, [onClose, requestTransition]);
116117

118+
const showKeyboardShortcuts = useCallback(() => {
119+
requestTransition(onShowKeyboardShortcuts);
120+
}, [onShowKeyboardShortcuts, requestTransition]);
121+
117122
const openRemoteSubview = useCallback((subview: RemoteAccessSubviewId) => {
118123
requestTransition(() => setRemoteSubview(subview));
119124
}, [requestTransition]);
@@ -141,7 +146,7 @@ export function Settings({ isOpen, onClose, category, onCategoryChange, openRequ
141146
case 'integrations':
142147
return <IntegrationsSettings persistence={persistence} {...sharedDirtyProps} />;
143148
case 'shortcuts':
144-
return <ShortcutsSettings persistence={persistence} {...sharedDirtyProps} />;
149+
return <ShortcutsSettings persistence={persistence} onShowKeyboardShortcuts={showKeyboardShortcuts} {...sharedDirtyProps} />;
145150
case 'privacy':
146151
return <PrivacySettings persistence={persistence} />;
147152
case 'advanced':

frontend/src/components/panels/TerminalPanel.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { TerminalPanelProps } from '../../types/panelComponents';
1111
import { isHotkeyEnabledForEvent, useHotkeyStore } from '../../stores/hotkeyStore';
1212
import { renderLog, devLog } from '../../utils/console';
1313
import { getTerminalTheme } from '../../utils/terminalTheme';
14-
import { resolveTerminalKeyHandling } from '../../utils/terminalKeyHandling';
14+
import { resolveTerminalKeyHandling, shouldOpenTerminalSearch } from '../../utils/terminalKeyHandling';
1515
import { isMac } from '../../utils/platformUtils';
1616
import { FileEdit, FolderOpen } from 'lucide-react';
1717
import { useTerminalLinks } from '../terminal/hooks/useTerminalLinks';
@@ -27,7 +27,7 @@ import { createAtTerminalHandler } from '../../services/terminalInterceptor/hand
2727
import { InterceptorDropdown } from '../terminal/InterceptorDropdown';
2828
import { InterceptorToast } from '../terminal/InterceptorToast';
2929
import { usePanelStore } from '../../stores/panelStore';
30-
import { useConfigStore } from '../../stores/configStore';
30+
import { areKeyboardShortcutsEnabled, useConfigStore } from '../../stores/configStore';
3131
import type { InterceptorState, AtTerminalHandlerState, TerminalSuggestion } from '../../services/terminalInterceptor/types';
3232
import '@xterm/xterm/css/xterm.css';
3333

@@ -41,7 +41,7 @@ const SKELETON_TRANSCRIPT_WIDTHS = ['w-2/3', 'w-1/2', 'w-5/6', 'w-1/3', 'w-3/4',
4141
// lines, and a prompt box, swept by a single shimmer so it reads as one
4242
// cohesive loading surface. Shown while initializing, refreshing, and CLI startup.
4343
const TerminalLoadingSkeleton: React.FC = () => (
44-
<div className="relative w-full h-full overflow-hidden px-4 py-4 font-mono select-none" aria-label="Loading terminal">
44+
<div className="relative w-full h-full overflow-hidden px-4 py-4 font-mono select-none" role="status" aria-label="Loading terminal">
4545
<div className="flex h-full flex-col gap-4">
4646
<div className="rounded-md border border-border-primary p-3 space-y-2 max-w-md">
4747
<div className="h-3.5 w-40 rounded bg-surface-tertiary" />
@@ -233,6 +233,8 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = React.memo(({ panel,
233233
const interceptorRef = useRef<TerminalInterceptor | null>(null);
234234
const skipNextInterceptRef = useRef(false); // set by AltGr @ detection
235235
const terminalPowerMode = useConfigStore((state) => state.config?.terminalPowerMode ?? 'performance');
236+
const keyboardShortcutsEnabled = useConfigStore((state) => areKeyboardShortcutsEnabled(state.config));
237+
const keyboardShortcutsEnabledRef = useRef(keyboardShortcutsEnabled);
236238
const useBatterySaverTerminalVisibility = terminalPowerMode === 'batterySaver';
237239
const panelVisible = isActive;
238240
const effectiveVisible = useBatterySaverTerminalVisibility ? panelVisible && windowFocused : true;
@@ -295,6 +297,10 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = React.memo(({ panel,
295297
isCliPanelRef.current = isCliPanel;
296298
}, [isCliPanel]);
297299

300+
useEffect(() => {
301+
keyboardShortcutsEnabledRef.current = keyboardShortcutsEnabled;
302+
}, [keyboardShortcutsEnabled]);
303+
298304
useEffect(() => {
299305
if (terminalState?.isCliReady && !isCliReady) {
300306
setIsCliReady(true);
@@ -767,12 +773,11 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = React.memo(({ panel,
767773

768774
// Open search on Ctrl/Cmd+F from the container div
769775
const handleTerminalKeyDown = useCallback((e: React.KeyboardEvent) => {
770-
const ctrlOrMeta = e.ctrlKey || e.metaKey;
771-
if (ctrlOrMeta && e.key.toLowerCase() === 'f') {
776+
if (shouldOpenTerminalSearch(e, keyboardShortcutsEnabled)) {
772777
e.preventDefault();
773778
openSearch();
774779
}
775-
}, [openSearch]);
780+
}, [keyboardShortcutsEnabled, openSearch]);
776781

777782
const getDropdownPosition = useCallback((): { x: number; y: number } => {
778783
const container = terminalRef.current;
@@ -906,6 +911,8 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = React.memo(({ panel,
906911

907912
// Intercept app-level shortcuts before xterm consumes them
908913
terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
914+
if (!keyboardShortcutsEnabledRef.current) return !isHotkeyEnabledForEvent(e);
915+
909916
const ctrlOrMeta = e.ctrlKey || e.metaKey;
910917

911918
// Ctrl/Cmd+K: clear xterm scrollback without writing ^K to the PTY.
@@ -925,6 +932,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = React.memo(({ panel,
925932
isTuiActive: tuiActiveRef.current,
926933
isCliPanel: isCliPanelRef.current,
927934
isMac: isMac(),
935+
keyboardShortcutsEnabled: keyboardShortcutsEnabledRef.current,
928936
});
929937

930938
// Shift+Enter sends the same ESC+CR sequence as Alt+Enter for CLI

frontend/src/components/panels/editor/FileEditor.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { ExplorerPanelState } from '../../../../../shared/types/panels';
1515
import { isMac, isWindows } from '../../../utils/platformUtils';
1616
import { formatKeyDisplay } from '../../../utils/hotkeyUtils';
1717
import { TerminalPopover, PopoverButton } from '../../terminal/TerminalPopover';
18-
import { useConfigStore } from '../../../stores/configStore';
18+
import { areKeyboardShortcutsEnabled, useConfigStore } from '../../../stores/configStore';
1919
import { LiveRegion } from '../../ui/LiveRegion';
2020

2121
interface FileItem {
@@ -89,6 +89,7 @@ function HeadlessFileTree({
8989
// Platform-adaptive label
9090
const revealLabel = isMac() ? 'Reveal in Finder' : isWindows() ? 'Show in Explorer' : 'Show in File Manager';
9191
const isRemoteMode = useConfigStore((state) => state.config?.remoteDaemon?.client.mode === 'remote');
92+
const keyboardShortcutsEnabled = useConfigStore((state) => areKeyboardShortcutsEnabled(state.config));
9293

9394
// Initialize expanded state from persisted state or default to root expanded.
9495
// Normalize legacy '' root to ROOT_ID so saved state from the old FileTree still works.
@@ -703,7 +704,7 @@ function HeadlessFileTree({
703704
const isEditingText = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || !!target?.isContentEditable;
704705
if (isEditingText && e.key !== 'Escape') return;
705706

706-
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
707+
if (keyboardShortcutsEnabled && (e.metaKey || e.ctrlKey) && e.key === 'f') {
707708
e.preventDefault();
708709
setShowSearch(prev => !prev);
709710
}
@@ -722,6 +723,7 @@ function HeadlessFileTree({
722723
searchInputRef.current?.focus();
723724
}
724725
}
726+
if (!keyboardShortcutsEnabled) return;
725727
if (e.key === 'F2' && selectedItems.length === 1) {
726728
const item = tree.getItemInstance(selectedItems[0])?.getItemData();
727729
if (item) {
@@ -752,7 +754,7 @@ function HeadlessFileTree({
752754

753755
window.addEventListener('keydown', handleKeyDown);
754756
return () => window.removeEventListener('keydown', handleKeyDown);
755-
}, [searchQuery, showNewItemDialog, contextMenu, selectedItems, tree, startRename, handleDelete, clipboard, handlePaste]);
757+
}, [searchQuery, showNewItemDialog, contextMenu, keyboardShortcutsEnabled, selectedItems, tree, startRename, handleDelete, clipboard, handlePaste]);
756758

757759
return (
758760
<div

frontend/src/components/panels/logPanel/LogsView.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { cn } from '../../../utils/cn';
44
import AnsiToHtml from 'ansi-to-html';
55
import { useTheme } from '../../../contexts/ThemeContext';
66
import { LiveRegion } from '../../ui/LiveRegion';
7+
import { areKeyboardShortcutsEnabled, useConfigStore } from '../../../stores/configStore';
78

89
interface LogEntry {
910
timestamp: string;
@@ -30,6 +31,7 @@ export const LogsView: React.FC<LogsViewProps> = ({ sessionId, isVisible }) => {
3031
const searchInputRef = useRef<HTMLInputElement>(null);
3132
const lastLogCount = useRef(0);
3233
const { theme } = useTheme();
34+
const keyboardShortcutsEnabled = useConfigStore((state) => areKeyboardShortcutsEnabled(state.config));
3335

3436
// Create ANSI to HTML converter with theme-aware colors
3537
const ansiConverter = useMemo(() => {
@@ -174,7 +176,7 @@ export const LogsView: React.FC<LogsViewProps> = ({ sessionId, isVisible }) => {
174176

175177
const handleKeyDown = (e: KeyboardEvent) => {
176178
// Cmd+F or Ctrl+F for search
177-
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
179+
if (keyboardShortcutsEnabled && (e.metaKey || e.ctrlKey) && e.key === 'f') {
178180
e.preventDefault();
179181
setSearchVisible(true);
180182
setTimeout(() => searchInputRef.current?.focus(), 100);
@@ -197,7 +199,7 @@ export const LogsView: React.FC<LogsViewProps> = ({ sessionId, isVisible }) => {
197199

198200
window.addEventListener('keydown', handleKeyDown);
199201
return () => window.removeEventListener('keydown', handleKeyDown);
200-
}, [isVisible, searchVisible, searchMatches, goToNextMatch, goToPreviousMatch]);
202+
}, [isVisible, keyboardShortcutsEnabled, searchVisible, searchMatches, goToNextMatch, goToPreviousMatch]);
201203

202204
const handleClearLogs = async () => {
203205
try {

frontend/src/components/settings/catalog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,9 @@ export const SETTINGS_CATEGORIES: readonly SettingsCategoryDefinition[] = [
9292
{
9393
id: 'shortcuts',
9494
label: 'Shortcuts',
95-
description: 'Terminal snippet hotkeys.',
95+
description: 'Application and terminal snippet hotkeys.',
9696
icon: Keyboard,
97-
settingIds: ['terminal-shortcuts'],
97+
settingIds: ['keyboard-shortcuts', 'command-palette-shortcut', 'terminal-shortcuts'],
9898
aliases: ['hotkeys', 'keyboard', 'snippets'],
9999
},
100100
{

frontend/src/components/settings/categories/ShortcutsSettings.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@ import { Button, IconButton } from '../../ui/Button';
44
import { Input, Textarea } from '../../ui/Input';
55
import { SettingsSection } from '../../ui/SettingsSection';
66
import { SettingRow, SettingsPage } from '../SettingRow';
7+
import { ImmediateToggle } from '../SettingsControls';
78
import type { SettingsPersistence } from '../useSettingsPersistence';
89
import type { TerminalShortcut } from '../../../types/config';
910
import { formatKeyDisplay } from '../../../utils/hotkeyUtils';
1011

1112
interface ShortcutsSettingsProps {
1213
persistence: SettingsPersistence;
1314
onDirtyChange: (dirty: boolean) => void;
15+
onShowKeyboardShortcuts: () => void;
1416
}
1517

16-
export function ShortcutsSettings({ persistence, onDirtyChange }: ShortcutsSettingsProps) {
18+
export function ShortcutsSettings({ persistence, onDirtyChange, onShowKeyboardShortcuts }: ShortcutsSettingsProps) {
1719
const config = persistence.config!;
1820
const persistedShortcuts = config.terminalShortcuts ?? [];
1921
const persistedKey = JSON.stringify(persistedShortcuts);
@@ -46,7 +48,40 @@ export function ShortcutsSettings({ persistence, onDirtyChange }: ShortcutsSetti
4648
};
4749

4850
return (
49-
<SettingsPage title="Shortcuts" description="Bind application-wide Ctrl/Cmd+Alt+letter shortcuts to terminal snippets.">
51+
<SettingsPage title="Shortcuts" description="Control Pane keyboard shortcuts and terminal snippet hotkeys.">
52+
<SettingsSection title="Application shortcuts">
53+
<SettingRow
54+
settingId="keyboard-shortcuts"
55+
label="Enable Pane keyboard shortcuts"
56+
description="When disabled, Pane shortcuts won't work, but native terminal and embedded app shortcuts will. The Command Palette shortcut can remain enabled below."
57+
saveState={persistence.saveStates['keyboard-shortcuts']}
58+
>
59+
<ImmediateToggle
60+
label="Enable Pane keyboard shortcuts"
61+
value={config.keyboardShortcutsEnabled !== false}
62+
onSave={(value) => persistence.saveConfig('keyboard-shortcuts', { keyboardShortcutsEnabled: value })}
63+
/>
64+
</SettingRow>
65+
<SettingRow
66+
settingId="command-palette-shortcut"
67+
label="Keep Command Palette shortcut enabled"
68+
description="Keep Ctrl/Cmd+Shift+P available when other Pane keyboard shortcuts are disabled."
69+
saveState={persistence.saveStates['command-palette-shortcut']}
70+
>
71+
<ImmediateToggle
72+
label="Keep Command Palette shortcut enabled"
73+
value={config.commandPaletteShortcutEnabled !== false}
74+
onSave={(value) => persistence.saveConfig('command-palette-shortcut', { commandPaletteShortcutEnabled: value })}
75+
/>
76+
</SettingRow>
77+
<button
78+
type="button"
79+
className="text-sm font-medium text-interactive underline underline-offset-4 hover:text-interactive-hover"
80+
onClick={onShowKeyboardShortcuts}
81+
>
82+
View all Pane keyboard shortcuts
83+
</button>
84+
</SettingsSection>
5085
<SettingsSection title="Terminal snippets">
5186
<SettingRow
5287
settingId="terminal-shortcuts"

0 commit comments

Comments
 (0)