Add customizable keyboard shortcuts and new panel/service bindings (#457)
* feat(shortcuts): centralize shortcut registry and matching * feat(ui-store): persist customizable shortcut overrides * feat(shortcuts): wire effective shortcuts into global handlers * feat(help): render shortcut hints from effective mappings * feat(command-palette): show and trigger panel shortcut actions * feat(settings): add keyboard shortcuts customization section * feat(settings): add shortcuts section to OpenChamber sidebar * feat(settings): render keyboard shortcuts section content * feat(header): add shortcut-driven services and plan controls * fix(shortcuts): support unassigned overrides and bracket key matching * fix(settings): clear overwritten shortcut conflicts safely * fix: shortcut conflicts, focus retention, and theme-safe warning text --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
47cecfc356
commit
d9370f3af5
@@ -3,11 +3,11 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||
import { hasModifier } from '@/lib/utils';
|
||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
||||
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
|
||||
export const useKeyboardShortcuts = () => {
|
||||
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
|
||||
@@ -15,15 +15,26 @@ export const useKeyboardShortcuts = () => {
|
||||
toggleCommandPalette,
|
||||
toggleHelpDialog,
|
||||
toggleSidebar,
|
||||
toggleRightSidebar,
|
||||
setRightSidebarOpen,
|
||||
setRightSidebarTab,
|
||||
toggleBottomTerminal,
|
||||
setBottomTerminalExpanded,
|
||||
setSessionSwitcherOpen,
|
||||
setActiveMainTab,
|
||||
setSettingsDialogOpen,
|
||||
setModelSelectorOpen,
|
||||
shortcutOverrides,
|
||||
} = useUIStore();
|
||||
const { themeMode, setThemeMode } = useThemeSystem();
|
||||
const { working } = useAssistantStatus();
|
||||
const abortPrimedUntilRef = React.useRef<number | null>(null);
|
||||
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const themeModeRef = React.useRef(themeMode);
|
||||
|
||||
React.useEffect(() => {
|
||||
themeModeRef.current = themeMode;
|
||||
}, [themeMode]);
|
||||
|
||||
const resetAbortPriming = React.useCallback(() => {
|
||||
if (abortPrimedTimeoutRef.current) {
|
||||
@@ -35,70 +46,86 @@ export const useKeyboardShortcuts = () => {
|
||||
}, [clearAbortPrompt]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides);
|
||||
|
||||
if (hasModifier(e) && e.key === 'k') {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (eventMatchesShortcut(e, combo('open_command_palette'))) {
|
||||
e.preventDefault();
|
||||
toggleCommandPalette();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'l') {
|
||||
if (eventMatchesShortcut(e, combo('open_status'))) {
|
||||
e.preventDefault();
|
||||
void showOpenCodeStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasModifier(e) && e.key === '.') {
|
||||
if (eventMatchesShortcut(e, combo('open_help'))) {
|
||||
e.preventDefault();
|
||||
toggleHelpDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasModifier(e) && e.key.toLowerCase() === 'n') {
|
||||
if (eventMatchesShortcut(e, combo('new_chat')) || eventMatchesShortcut(e, combo('new_chat_worktree'))) {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const autoWorktree = useConfigStore.getState().settingsAutoCreateWorktree;
|
||||
// If autoWorktree is true: Cmd+N -> Worktree, Cmd+Shift+N -> Standard
|
||||
// If autoWorktree is false: Cmd+N -> Standard, Cmd+Shift+N -> Worktree
|
||||
// VS Code: always open standard session (no worktree support)
|
||||
const shouldCreateWorktree = isVSCode ? false : (autoWorktree ? !e.shiftKey : e.shiftKey);
|
||||
const matchedPrimaryShortcut = eventMatchesShortcut(e, combo('new_chat'));
|
||||
const shouldCreateWorktree = isVSCode
|
||||
? false
|
||||
: (matchedPrimaryShortcut ? autoWorktree : !autoWorktree);
|
||||
|
||||
if (shouldCreateWorktree) {
|
||||
// Create new session with auto-generated worktree
|
||||
setActiveMainTab('chat');
|
||||
setSessionSwitcherOpen(false);
|
||||
createWorktreeSession();
|
||||
return;
|
||||
}
|
||||
// Open a new session without worktree
|
||||
|
||||
setActiveMainTab('chat');
|
||||
setSessionSwitcherOpen(false);
|
||||
openNewSessionDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasModifier(e) && e.key === '/') {
|
||||
if (eventMatchesShortcut(e, combo('cycle_theme'))) {
|
||||
e.preventDefault();
|
||||
const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
|
||||
const currentIndex = modes.indexOf(themeMode);
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
const currentIndex = modes.indexOf(themeModeRef.current);
|
||||
const nextIndex = (currentIndex + 1) % modes.length;
|
||||
setThemeMode(modes[nextIndex]);
|
||||
requestAnimationFrame(() => {
|
||||
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!document.hasFocus()) {
|
||||
window.focus();
|
||||
}
|
||||
if (activeElement && document.contains(activeElement)) {
|
||||
activeElement.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 't') {
|
||||
if (eventMatchesShortcut(e, combo('open_timeline'))) {
|
||||
e.preventDefault();
|
||||
const { isTimelineDialogOpen, setTimelineDialogOpen } = useUIStore.getState();
|
||||
setTimelineDialogOpen(!isTimelineDialogOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasModifier(e) && !e.shiftKey && e.key === ',') {
|
||||
if (eventMatchesShortcut(e, combo('open_settings'))) {
|
||||
e.preventDefault();
|
||||
const { isSettingsDialogOpen } = useUIStore.getState();
|
||||
setSettingsDialogOpen(!isSettingsDialogOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'l') {
|
||||
if (eventMatchesShortcut(e, combo('toggle_sidebar'))) {
|
||||
e.preventDefault();
|
||||
const { isMobile, isSessionSwitcherOpen } = useUIStore.getState();
|
||||
if (isMobile) {
|
||||
@@ -109,15 +136,83 @@ export const useKeyboardShortcuts = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasModifier(e) && !e.shiftKey && e.key.toLowerCase() === 'i') {
|
||||
if (eventMatchesShortcut(e, combo('focus_input'))) {
|
||||
e.preventDefault();
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
|
||||
textarea?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(e, combo('toggle_right_sidebar'))) {
|
||||
const { isMobile } = useUIStore.getState();
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
toggleRightSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(e, combo('open_right_sidebar_git'))) {
|
||||
const { isMobile } = useUIStore.getState();
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setRightSidebarOpen(true);
|
||||
setRightSidebarTab('git');
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(e, combo('open_right_sidebar_files'))) {
|
||||
const { isMobile } = useUIStore.getState();
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setRightSidebarOpen(true);
|
||||
setRightSidebarTab('files');
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(e, combo('cycle_right_sidebar_tab'))) {
|
||||
const { isMobile, rightSidebarTab } = useUIStore.getState();
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabs = ['git', 'files'] as const;
|
||||
const currentIndex = tabs.indexOf(rightSidebarTab);
|
||||
const nextTab = tabs[(currentIndex + 1) % tabs.length];
|
||||
|
||||
e.preventDefault();
|
||||
setRightSidebarOpen(true);
|
||||
setRightSidebarTab(nextTab);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(e, combo('toggle_terminal'))) {
|
||||
const { isMobile } = useUIStore.getState();
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
toggleBottomTerminal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventMatchesShortcut(e, combo('toggle_terminal_expanded'))) {
|
||||
const { isMobile, isBottomTerminalExpanded } = useUIStore.getState();
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setBottomTerminalExpanded(!isBottomTerminalExpanded);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+M: Open model selector (same conditions as double-ESC: chat tab, no overlays)
|
||||
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'm') {
|
||||
if (eventMatchesShortcut(e, combo('open_model_selector'))) {
|
||||
const {
|
||||
isSettingsDialogOpen,
|
||||
isCommandPaletteOpen,
|
||||
@@ -147,7 +242,7 @@ export const useKeyboardShortcuts = () => {
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+T: Cycle thinking variant (same gating as Shift+M)
|
||||
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 't') {
|
||||
if (eventMatchesShortcut(e, combo('cycle_thinking_variant'))) {
|
||||
const {
|
||||
isSettingsDialogOpen,
|
||||
isCommandPaletteOpen,
|
||||
@@ -276,16 +371,21 @@ export const useKeyboardShortcuts = () => {
|
||||
toggleCommandPalette,
|
||||
toggleHelpDialog,
|
||||
toggleSidebar,
|
||||
toggleRightSidebar,
|
||||
setRightSidebarOpen,
|
||||
setRightSidebarTab,
|
||||
toggleBottomTerminal,
|
||||
setBottomTerminalExpanded,
|
||||
setSessionSwitcherOpen,
|
||||
setActiveMainTab,
|
||||
setSettingsDialogOpen,
|
||||
setModelSelectorOpen,
|
||||
setThemeMode,
|
||||
themeMode,
|
||||
working,
|
||||
armAbortPrompt,
|
||||
resetAbortPriming,
|
||||
currentSessionId,
|
||||
shortcutOverrides,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user