refactor(ui): centralize shortcut schema

This commit is contained in:
ChangeHow
2026-08-06 14:18:15 +08:00
parent 4c5421a7af
commit bb25b68657
21 changed files with 610 additions and 994 deletions
+5 -21
View File
@@ -17,7 +17,7 @@ import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { useConfigStore } from '@/stores/useConfigStore';
import { hasModifier } from '@/lib/utils';
import { useKeybind } from '@/hooks/useKeybind';
import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop';
import {
getInjectedBootOutcome,
@@ -699,26 +699,10 @@ function App({ apis }: AppProps) {
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
React.useEffect(() => {
if (embeddedSessionChat) {
return;
}
const handleKeyDown = (e: KeyboardEvent) => {
const isDebugShortcut = hasModifier(e)
&& e.shiftKey
&& !e.altKey
&& (e.code === 'KeyD' || e.key.toLowerCase() === 'd');
if (isDebugShortcut) {
e.preventDefault();
setShowMemoryDebug(prev => !prev);
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [embeddedSessionChat]);
useKeybind('toggle_memory_debug', () => {
if (embeddedSessionChat) return false;
setShowMemoryDebug((previous) => !previous);
});
React.useEffect(() => {
if (embeddedSessionChat) {
@@ -9,12 +9,11 @@ import {
getCustomizableShortcutActions,
getEffectiveShortcutCombo,
getEffectiveShortcutPrefix,
getShortcutCategory,
UNASSIGNED_SHORTCUT,
type ShortcutAction,
type ShortcutActionId,
type ShortcutCategory,
type ShortcutCombo,
type CustomizableShortcutAction,
} from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n';
import { ShortcutRecordingDialog } from './ShortcutRecordingDialog';
@@ -23,22 +22,16 @@ const CATEGORIES: ShortcutCategory[] = ['session', 'models', 'panels', 'navigati
export const KeyboardShortcutsSettings: React.FC = () => {
const { t } = useI18n();
const tUnsafe = (key: string) => t(key as Parameters<typeof t>[0]);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const setShortcutOverride = useUIStore((state) => state.setShortcutOverride);
const clearShortcutOverride = useUIStore((state) => state.clearShortcutOverride);
const resetAllShortcutOverrides = useUIStore((state) => state.resetAllShortcutOverrides);
const [editingAction, setEditingAction] = React.useState<ShortcutAction | null>(null);
const [editingAction, setEditingAction] = React.useState<CustomizableShortcutAction | null>(null);
const actions = React.useMemo(() => {
const all = getCustomizableShortcutActions();
return isVSCodeRuntime() ? all.filter((action) => action.id !== 'toggle_prompt_navigator') : all;
}, []);
const actionLabel = (action: ShortcutAction): string => {
const key = `settings.openchamber.keyboardShortcuts.action.${action.id}.label`;
const translated = tUnsafe(key);
return translated === key ? action.label : translated;
};
const persist = (nextOverrides: Record<string, ShortcutCombo>) => {
void updateDesktopSettings({ shortcutOverrides: nextOverrides });
};
@@ -59,7 +52,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
clearShortcutOverride(actionId);
persist(nextOverrides);
};
const shortcutDisplay = (action: ShortcutAction): string => {
const shortcutDisplay = (action: CustomizableShortcutAction): string => {
const isSurfaceSwitch = action.id === 'switch_context_surface';
const combo = isSurfaceSwitch
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
@@ -76,7 +69,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
return (
<>
{CATEGORIES.map((category, categoryIndex) => {
const categoryActions = actions.filter((action) => getShortcutCategory(action) === category);
const categoryActions = actions.filter((action) => action.category === category);
if (categoryActions.length === 0) return null;
return (
<SettingsSection
@@ -96,7 +89,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
>
<div className="space-y-2">
{categoryActions.map((action) => (
<SettingsFieldRow key={action.id} label={actionLabel(action)}>
<SettingsFieldRow key={action.id} label={t(action.settingsLabelKey)}>
<kbd
className="min-w-32 rounded-md border border-border bg-muted px-2 py-1 text-center typography-meta font-mono text-foreground"
>
@@ -130,7 +123,6 @@ export const KeyboardShortcutsSettings: React.FC = () => {
action={editingAction}
actions={actions}
overrides={shortcutOverrides}
actionLabel={actionLabel}
onSave={save}
onOpenChange={(open) => {
if (!open) setEditingAction(null);
@@ -16,19 +16,18 @@ import {
isRiskyBrowserShortcut,
keyToShortcutToken,
normalizeCombo,
type ShortcutAction,
type ShortcutActionId,
type ShortcutCombo,
type CustomizableShortcutAction,
} from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n';
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
interface ShortcutRecordingDialogProps {
action: ShortcutAction | null;
actions: ReadonlyArray<ShortcutAction>;
action: CustomizableShortcutAction | null;
actions: ReadonlyArray<CustomizableShortcutAction>;
overrides: Record<string, string>;
actionLabel: (action: ShortcutAction) => string;
onSave: (
actionId: ShortcutActionId,
combo: ShortcutCombo,
@@ -66,11 +65,11 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
action,
actions,
overrides,
actionLabel,
onSave,
onOpenChange,
}) => {
const { t } = useI18n();
const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey);
const [chords, setChords] = React.useState<ShortcutCombo[]>([]);
const recordingRef = React.useRef<HTMLDivElement>(null);
@@ -82,7 +81,7 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
const combo = normalizeCombo(chords.join(' '));
const conflicts = React.useMemo(() => {
if (!action || !combo) return [];
const result: Array<{ action: ShortcutAction; kind: 'exact' | 'prefix' }> = [];
const result: Array<{ action: CustomizableShortcutAction; kind: 'exact' | 'prefix' }> = [];
for (const candidate of actions) {
if (candidate.id === action.id) continue;
const candidateCombo = candidate.id === 'switch_context_surface'
+8 -13
View File
@@ -22,7 +22,7 @@ import type { IconName } from "@/components/icon/icons";
type ShortcutItem = {
id?: ShortcutActionId;
keys: string | string[];
descriptionKey: I18nKey;
descriptionKey?: I18nKey;
icon: IconName | null;
};
@@ -33,12 +33,10 @@ type ShortcutSection = {
const renderShortcut = (
id: ShortcutActionId,
fallbackCombo: string,
overrides: Record<string, string>,
unassignedLabel: string,
) => {
const action = getShortcutAction(id);
return action ? formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides), unassignedLabel) : fallbackCombo;
return formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides), unassignedLabel);
};
export const HelpDialog: React.FC = () => {
@@ -129,13 +127,11 @@ export const HelpDialog: React.FC = () => {
},
{
id: 'open_draft_project_picker',
descriptionKey: 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label',
icon: 'folder',
keys: '',
},
{
id: 'open_draft_worktree_picker',
descriptionKey: 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label',
icon: 'git-branch',
keys: '',
},
@@ -255,13 +251,13 @@ export const HelpDialog: React.FC = () => {
{section.items
.filter((shortcut) => !(isVSCode && shortcut.id === 'toggle_prompt_navigator'))
.map((shortcut) => {
const fallbackKeys = Array.isArray(shortcut.keys)
? shortcut.keys[0]
: shortcut.keys;
const action = shortcut.id ? getShortcutAction(shortcut.id) : undefined;
const descriptionKey = shortcut.descriptionKey
?? (action?.customizable ? action.settingsLabelKey : undefined);
if (!descriptionKey) return null;
const displayKeys = shortcut.id
? renderShortcut(
shortcut.id,
fallbackKeys,
shortcutOverrides,
t('settings.openchamber.keyboardShortcuts.unassigned'),
)
@@ -269,7 +265,7 @@ export const HelpDialog: React.FC = () => {
return (
<div
key={shortcut.id || shortcut.descriptionKey}
key={shortcut.id || descriptionKey}
className="flex items-center justify-between py-1 px-2"
>
<div className="flex items-center gap-2">
@@ -277,7 +273,7 @@ export const HelpDialog: React.FC = () => {
<Icon name={shortcut.icon} className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="typography-meta">
{t(shortcut.descriptionKey)}
{t(descriptionKey)}
</span>
</div>
<div className="flex items-center gap-1">
@@ -312,7 +308,6 @@ export const HelpDialog: React.FC = () => {
{t('helpDialog.proTips.commandPalette', {
shortcut: renderShortcut(
'open_command_palette',
`${mod} P`,
shortcutOverrides,
t('settings.openchamber.keyboardShortcuts.unassigned'),
),
@@ -43,7 +43,7 @@ import {
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel, getRevealLabelKey } from '@/lib/utils';
import { cn, getRevealLabelKey } from '@/lib/utils';
import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers';
import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
@@ -53,6 +53,7 @@ import { getOutsideFileGrant } from '@/lib/outsideFileGrants';
import { DiagramEditor } from '@/components/diagram';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useKeybind, useKeybinds } from '@/hooks/useKeybind';
import { getModifierLabel } from '@/lib/shortcuts';
import { EditorView } from '@codemirror/view';
import type { Extension } from '@codemirror/state';
import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -1,5 +1,6 @@
import React from 'react';
import { cn, getModifierLabel } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { getModifierLabel } from '@/lib/shortcuts';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useAgentsStore } from '@/stores/useAgentsStore';
+1 -2
View File
@@ -1,6 +1,5 @@
import React from 'react';
import { shortcutRegistry, type ShortcutHandler } from '@/lib/shortcutRegistry';
import type { ShortcutActionId } from '@/lib/shortcuts';
import { shortcutRegistry, type ShortcutActionId, type ShortcutHandler } from '@/lib/shortcuts';
export function useKeybind(actionId: ShortcutActionId, handler: ShortcutHandler): void {
const handlerRef = React.useRef(handler);
+24 -19
View File
@@ -17,10 +17,10 @@ import {
getEffectiveShortcutCombo,
getEffectiveShortcutPrefix,
normalizeCombo,
ShortcutDispatcher,
shortcutRegistry,
type ShortcutActionId,
} from '@/lib/shortcuts';
import { ShortcutDispatcher } from '@/lib/shortcutDispatcher';
import { shortcutRegistry } from '@/lib/shortcutRegistry';
import { getVisibleContextRailSurfaces } from '@/lib/surfaces/registry';
import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -279,6 +279,10 @@ export const useKeyboardShortcuts = () => {
}
window.dispatchEvent(new CustomEvent('openchamber:dictation-toggle'));
},
abort_run: () => {
if (sessionPhase === 'idle' || !currentSessionId) return false;
void sessionActions.abortCurrentOperation(currentSessionId);
},
});
function cycleFavoriteModel(delta: number): boolean | void {
@@ -376,9 +380,8 @@ export const useKeyboardShortcuts = () => {
}
const now = Date.now();
if (abortPrimedUntilRef.current && now < abortPrimedUntilRef.current) {
event.preventDefault();
resetAbortPriming();
void sessionActions.abortCurrentOperation(currentSessionId);
if (invokeRegistered('abort_run', event)) event.preventDefault();
return;
}
event.preventDefault();
@@ -413,21 +416,23 @@ export const useKeyboardShortcuts = () => {
&& eventMatchesShortcutPrefix(event, switchSurfacePrefix, heldKeysRef.current)
) {
const state = useUIStore.getState();
if (state.isMobile || !effectiveDirectory) return;
const directory = normalizeContextPanelDirectoryKey(effectiveDirectory);
const panel = state.contextPanelByDirectory[directory];
const visibleSurfaces = getVisibleContextRailSurfaces({
railOrder: state.contextRailOrder,
planModeEnabled: useFeatureFlagsStore.getState().planModeEnabled,
isVSCode: isVSCodeRuntime(),
screenWidth: window.innerWidth,
tabs: panel?.tabs ?? [],
});
const target = visibleSurfaces[switchSurfaceDigit - 1];
if (!target) return;
event.preventDefault();
state.openContextSurface(directory, target.mode);
return;
if (!state.isMobile && effectiveDirectory) {
const directory = normalizeContextPanelDirectoryKey(effectiveDirectory);
const panel = state.contextPanelByDirectory[directory];
const visibleSurfaces = getVisibleContextRailSurfaces({
railOrder: state.contextRailOrder,
planModeEnabled: useFeatureFlagsStore.getState().planModeEnabled,
isVSCode: isVSCodeRuntime(),
screenWidth: window.innerWidth,
tabs: panel?.tabs ?? [],
});
const target = visibleSurfaces[switchSurfaceDigit - 1];
if (target) {
event.preventDefault();
state.openContextSurface(directory, target.mode);
return;
}
}
}
if (dispatcher.dispatch(event)) event.preventDefault();
@@ -1,9 +1,7 @@
import React from 'react';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop';
import { ShortcutDispatcher } from '@/lib/shortcutDispatcher';
import { shortcutRegistry } from '@/lib/shortcutRegistry';
import { getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { ShortcutDispatcher, getEffectiveShortcutCombo, shortcutRegistry } from '@/lib/shortcuts';
import { useConfigStore } from '@/stores/useConfigStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
-866
View File
@@ -1,866 +0,0 @@
import { isMacOS } from '@/lib/utils';
import { isDesktopShell } from '@/lib/desktop';
type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl';
type ShortcutKey = string;
export type ShortcutCombo = string;
export type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application';
export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__';
interface ShortcutActionDefinition {
id: string;
defaultCombo: ShortcutCombo;
label: string;
description?: string;
customizable?: boolean;
/** Metadata for shortcut browsers; omitted actions use the application category. */
category?: ShortcutCategory;
}
interface ParsedShortcutChord {
modifiers: Set<ShortcutModifier>;
key: ShortcutKey;
}
export interface ParsedShortcut {
chords: ReadonlyArray<ParsedShortcutChord>;
}
export type ShortcutConflict = 'exact' | 'prefix';
const DEFAULT_SHORTCUT_CATEGORY: ShortcutCategory = 'application';
const MODIFIER_KEY_MAP: Record<string, ShortcutModifier> = {
'mod': 'mod',
'shift': 'shift',
'alt': 'alt',
'option': 'alt',
'ctrl': 'ctrl',
'meta': 'mod',
'cmd': 'mod',
'command': 'mod',
};
const DISPLAY_LABEL_MAP: Record<ShortcutModifier, string> = {
'mod': isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl',
'shift': '⇧',
'alt': '⌥',
'option': '⌥',
'ctrl': '⌃',
};
// Physical `event.key` values (lowercased) that satisfy each modifier while a
// chord is being held. `mod` maps to the platform primary key; on web macOS it
// accepts either Meta or Ctrl, matching eventMatchesShortcut.
const MODIFIER_KEY_ALIASES: Record<ShortcutModifier, readonly string[]> = {
'mod': isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'],
'shift': ['shift'],
'alt': ['alt'],
'option': ['alt'],
'ctrl': ['control'],
};
const KEY_LABEL_MAP: Record<string, string> = {
'comma': ',',
'period': '.',
'enter': 'Enter',
'escape': 'Esc',
'tab': 'Tab',
'space': 'Space',
'backspace': '⌫',
'delete': '⌦',
'arrowup': '↑',
'arrowdown': '↓',
'arrowleft': '←',
'arrowright': '→',
'home': 'Home',
'end': 'End',
'pageup': 'Page Up',
'pagedown': 'Page Down',
};
const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt'];
const RISKY_BROWSER_SHORTCUT_KEYS = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']);
const SHIFTED_KEY_BASE_MAP: Record<string, string> = {
'{': '[',
'}': ']',
':': ';',
'"': "'",
'<': ',',
'>': '.',
'?': '/',
'|': '\\',
'~': '`',
'!': '1',
'@': '2',
'#': '3',
'$': '4',
'%': '5',
'^': '6',
'&': '7',
'*': '8',
'(': '9',
')': '0',
};
function isUnassignedShortcut(combo: ShortcutCombo): boolean {
return combo.trim().toLowerCase() === UNASSIGNED_SHORTCUT;
}
export function keyToShortcutToken(key: string): string {
const lowered = key.toLowerCase();
if (lowered === ',') return 'comma';
if (lowered === '.') return 'period';
if (lowered === ' ') return 'space';
if (lowered === 'esc') return 'escape';
if (lowered === '+') return 'plus';
if (lowered === '-' || lowered === '_') return 'minus';
if (lowered === 'arrowup') return 'arrowup';
if (lowered === 'arrowdown') return 'arrowdown';
if (lowered === 'arrowleft') return 'arrowleft';
if (lowered === 'arrowright') return 'arrowright';
return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered;
}
const SHORTCUT_ACTIONS = [
{
id: 'save_file',
defaultCombo: 'mod+s',
label: 'Save file',
description: 'Save the active file editor',
},
{
id: 'find_in_file',
defaultCombo: 'mod+f',
label: 'Find in file',
description: 'Search in the active file editor',
},
{
id: 'open_go_to_line',
defaultCombo: 'alt+g',
label: 'Go to line (files editor)',
description: 'Open go to line in the files editor',
customizable: true,
category: 'navigation',
},
{
id: 'open_command_palette',
defaultCombo: 'mod+p',
label: 'Open command palette',
description: 'Open the command palette',
customizable: true,
category: 'application',
},
{
id: 'focus_input',
defaultCombo: 'mod+i',
label: 'Focus input',
description: 'Focus the chat input field',
customizable: true,
category: 'session',
},
{
id: 'open_status',
defaultCombo: 'mod+shift+o',
label: 'Open OpenCode status',
description: 'Open the OpenCode status dialog',
},
{
id: 'open_settings',
defaultCombo: 'mod+comma',
label: 'Open settings',
description: 'Open the settings panel',
customizable: true,
category: 'application',
},
{
id: 'toggle_terminal',
defaultCombo: 'mod+j',
label: 'Toggle terminal dock',
description: 'Toggle the bottom terminal dock',
customizable: true,
category: 'panels',
},
{
id: 'toggle_terminal_expanded',
defaultCombo: 'mod+shift+j',
label: 'Toggle terminal expanded',
description: 'Toggle terminal expanded or collapsed',
customizable: true,
category: 'panels',
},
{
id: 'toggle_files',
defaultCombo: 'mod+shift+f',
label: 'Toggle files',
description: 'Toggle the files panel',
},
{
id: 'add_selection_to_chat',
defaultCombo: 'mod+l',
label: 'Add selection to chat',
description: 'Add the selected text to the chat input',
customizable: true,
category: 'session',
},
{
id: 'toggle_sidebar',
defaultCombo: 'mod+alt+l',
label: 'Toggle sidebar',
description: 'Toggle the session sidebar',
customizable: true,
category: 'panels',
},
{
id: 'open_timeline_dialog',
defaultCombo: 'mod+t',
label: 'Open conversation timeline',
description: 'Search and navigate within current conversation',
customizable: true,
category: 'session',
},
{
id: 'toggle_prompt_navigator',
defaultCombo: 'mod+alt+p',
label: 'Toggle prompt navigator',
description: 'Show or hide the prompt navigator panel in chat',
customizable: true,
category: 'panels',
},
{
id: 'toggle_right_sidebar',
defaultCombo: 'mod+b',
label: 'Toggle right sidebar',
description: 'Toggle the right sidebar',
customizable: true,
category: 'panels',
},
{
id: 'open_right_sidebar_git',
defaultCombo: 'mod+shift+g',
label: 'Open right sidebar Git tab',
description: 'Open right sidebar and select Git',
customizable: true,
category: 'panels',
},
{
id: 'open_right_sidebar_files',
defaultCombo: 'mod+shift+f',
label: 'Open right sidebar Files tab',
description: 'Open right sidebar and select Files',
customizable: true,
category: 'panels',
},
{
id: 'switch_context_surface',
defaultCombo: 'mod',
label: 'Switch context panel surface',
description: 'Hold the modifier and press a number to open or close the matching rail icon',
customizable: true,
category: 'panels',
},
{
id: 'new_chat',
defaultCombo: 'mod+n',
label: 'New session',
description: 'Start a new session',
customizable: true,
category: 'session',
},
{
id: 'open_draft_project_picker',
defaultCombo: 'mod+s p',
label: 'Open draft project picker',
description: 'Choose a project for a new draft',
customizable: true,
category: 'session',
},
{
id: 'open_draft_worktree_picker',
defaultCombo: 'mod+s g',
label: 'Open draft worktree picker',
description: 'Choose a worktree for a new draft',
customizable: true,
category: 'session',
},
{
id: 'new_chat_worktree',
defaultCombo: 'mod+shift+n',
label: 'New worktree draft',
description: 'Create a new worktree and open a draft in it',
customizable: true,
category: 'session',
},
{
id: 'new_mini_chat',
defaultCombo: 'mod+alt+n',
label: 'New Mini Chat window',
description: 'Open a new Mini Chat draft window',
customizable: true,
category: 'session',
},
{
id: 'submit_message',
defaultCombo: 'mod+enter',
label: 'Submit message',
description: 'Submit the current message',
},
{
id: 'clear_input',
defaultCombo: 'escape',
label: 'Clear input',
description: 'Clear the input field',
},
{
id: 'open_help',
defaultCombo: 'mod+.',
label: 'Open keyboard shortcuts',
description: 'Show the keyboard shortcuts help',
customizable: true,
category: 'application',
},
{
id: 'toggle_context_plan',
defaultCombo: 'mod+shift+p',
label: 'Toggle plan context panel',
description: 'Open or close plan in the context panel',
customizable: true,
category: 'panels',
},
{
id: 'toggle_services_menu',
defaultCombo: 'mod+shift+s',
label: 'Toggle services menu',
description: 'Open or close the services menu',
customizable: true,
category: 'panels',
},
{
id: 'cycle_services_tab',
defaultCombo: 'mod+shift+[',
label: 'Cycle services tab',
description: 'Cycle through tabs in the services menu',
customizable: true,
category: 'navigation',
},
{
id: 'cycle_theme',
defaultCombo: 'mod+/',
label: 'Cycle theme',
description: 'Cycle between light, dark, and system theme',
customizable: true,
category: 'application',
},
{
id: 'open_model_selector',
defaultCombo: 'mod+shift+m',
label: 'Open model selector',
description: 'Open model selector while in chat',
customizable: true,
category: 'models',
},
{
id: 'cycle_thinking_variant',
defaultCombo: 'mod+shift+t',
label: 'Cycle thinking variant',
description: 'Cycle thinking variant while in chat',
},
{
id: 'cycle_agent',
defaultCombo: 'tab',
label: 'Cycle agent',
description: 'Cycle agent while the model selector is open',
customizable: true,
category: 'models',
},
{
id: 'cycle_favorite_model_forward',
defaultCombo: 'ctrl+]',
label: 'Cycle favorite model forward',
description: 'Cycle forward through starred models without opening the picker',
customizable: true,
category: 'models',
},
{
id: 'cycle_favorite_model_backward',
defaultCombo: 'ctrl+[',
label: 'Cycle favorite model backward',
description: 'Cycle backward through starred models without opening the picker',
customizable: true,
category: 'models',
},
{
id: 'expand_input',
defaultCombo: 'mod+shift+e',
label: 'Expand input',
description: 'Toggle focus mode for the chat input',
customizable: true,
category: 'session',
},
{
id: 'toggle_dictation',
defaultCombo: 'mod+alt+v',
label: 'Voice input',
description: 'Start dictation; press again to confirm and insert the transcript',
customizable: true,
category: 'session',
},
{
id: 'abort_run',
defaultCombo: 'escape',
label: 'Abort active run',
description: 'Abort the currently running task (double press)',
},
{
id: 'switch_tab_1',
defaultCombo: 'mod+1',
label: 'Switch to tab 1',
description: 'Switch to the first tab or project',
},
{
id: 'switch_tab_2',
defaultCombo: 'mod+2',
label: 'Switch to tab 2',
description: 'Switch to the second tab or project',
},
{
id: 'switch_tab_3',
defaultCombo: 'mod+3',
label: 'Switch to tab 3',
description: 'Switch to the third tab or project',
},
{
id: 'switch_tab_4',
defaultCombo: 'mod+4',
label: 'Switch to tab 4',
description: 'Switch to the fourth tab or project',
},
{
id: 'switch_tab_5',
defaultCombo: 'mod+5',
label: 'Switch to tab 5',
description: 'Switch to the fifth tab or project',
},
{
id: 'switch_tab_6',
defaultCombo: 'mod+6',
label: 'Switch to tab 6',
description: 'Switch to the sixth tab or project',
},
{
id: 'switch_tab_7',
defaultCombo: 'mod+7',
label: 'Switch to tab 7',
description: 'Switch to the seventh tab or project',
},
{
id: 'switch_tab_8',
defaultCombo: 'mod+8',
label: 'Switch to tab 8',
description: 'Switch to the eighth tab or project',
},
{
id: 'switch_tab_9',
defaultCombo: 'mod+9',
label: 'Switch to tab 9',
description: 'Switch to the ninth tab or project',
},
] as const satisfies ReadonlyArray<ShortcutActionDefinition>;
export type ShortcutActionId = (typeof SHORTCUT_ACTIONS)[number]['id'];
export type ShortcutAction = Omit<ShortcutActionDefinition, 'id'> & { id: ShortcutActionId };
export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
if (isUnassignedShortcut(combo)) {
return UNASSIGNED_SHORTCUT;
}
const chords = combo.trim().replace(/\s*\+\s*/g, '+').split(/\s+/).filter(Boolean);
if (chords.length === 0 || chords.length > 2) {
return '';
}
return chords.map(normalizeChord).join(' ');
}
function normalizeChord(combo: ShortcutCombo): ShortcutCombo {
const rawParts = combo
.toLowerCase()
.trim()
.split('+')
.map((part) => part.trim())
.filter(Boolean);
const modifiers = new Set<ShortcutModifier>();
let key = '';
for (const rawPart of rawParts) {
const part = rawPart === ',' ? 'comma' : rawPart === '.' ? 'period' : rawPart;
const modifier = MODIFIER_KEY_MAP[part];
if (modifier) {
modifiers.add(modifier);
continue;
}
key = part;
}
const orderedModifiers = MODIFIER_PRIORITY.filter((modifier) => modifiers.has(modifier));
return [...orderedModifiers, key].filter(Boolean).join('+');
}
function isValidShortcutCombo(combo: ShortcutCombo): boolean {
if (isUnassignedShortcut(combo)) {
return true;
}
const parsed = parseShortcut(combo);
return parsed !== undefined && parsed.chords.every((chord) => chord.key.trim().length > 0);
}
export function parseShortcut(combo: ShortcutCombo): ParsedShortcut | undefined {
if (isUnassignedShortcut(combo)) {
return { chords: [{ modifiers: new Set<ShortcutModifier>(), key: UNASSIGNED_SHORTCUT }] };
}
const normalized = normalizeCombo(combo);
if (!normalized) {
return undefined;
}
const chords = normalized.split(' ').map((chord) => {
const parts = chord.split('+');
const modifiers: Set<ShortcutModifier> = new Set();
let key: ShortcutKey = '';
for (const part of parts) {
const modifier = MODIFIER_KEY_MAP[part];
if (modifier) {
modifiers.add(modifier);
} else {
key = part;
}
}
return { modifiers, key };
});
return { chords };
}
export function formatShortcutForDisplay(combo: ShortcutCombo, unassignedLabel = 'Unassigned'): string {
if (isUnassignedShortcut(combo)) {
return unassignedLabel;
}
const parsed = parseShortcut(combo);
if (!parsed || parsed.chords.some((chord) => !chord.key && chord.modifiers.size === 0)) {
return unassignedLabel;
}
return parsed.chords.map(formatChordForDisplay).join(', ');
}
function formatChordForDisplay(parsed: ParsedShortcutChord): string {
const parts: string[] = [];
for (const modifier of MODIFIER_PRIORITY) {
if (parsed.modifiers.has(modifier)) {
parts.push(DISPLAY_LABEL_MAP[modifier]);
}
}
if (parsed.key) {
const keyLabel = KEY_LABEL_MAP[parsed.key.toLowerCase()] || parsed.key.toUpperCase();
parts.push(keyLabel);
}
return parts.join(' + ');
}
export function getShortcutAction(id: string): ShortcutAction | undefined {
return SHORTCUT_ACTIONS.find((action) => action.id === id);
}
export function getCustomizableShortcutActions(): ReadonlyArray<ShortcutAction> {
return SHORTCUT_ACTIONS.filter((action) => 'customizable' in action && action.customizable === true);
}
export function getShortcutCategory(action: ShortcutAction): ShortcutCategory {
return action.category ?? DEFAULT_SHORTCUT_CATEGORY;
}
export function getShortcutConflict(left: ShortcutCombo, right: ShortcutCombo): ShortcutConflict | undefined {
const normalizedLeft = normalizeCombo(left);
const normalizedRight = normalizeCombo(right);
const hasInvalidBinding = !isValidShortcutCombo(normalizedLeft)
|| !isValidShortcutCombo(normalizedRight);
const hasUnassignedBinding = normalizedLeft === UNASSIGNED_SHORTCUT
|| normalizedRight === UNASSIGNED_SHORTCUT;
if (hasInvalidBinding || hasUnassignedBinding) {
return undefined;
}
if (normalizedLeft === normalizedRight) {
return 'exact';
}
const leftChords = normalizedLeft.split(' ');
const rightChords = normalizedRight.split(' ');
const sharesLeader = leftChords[0] === rightChords[0];
return sharesLeader && leftChords.length !== rightChords.length ? 'prefix' : undefined;
}
export function getEffectiveShortcutCombo(
actionId: string,
overrides?: Record<string, ShortcutCombo>
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) {
return '';
}
const override = overrides?.[actionId];
if (typeof override === 'string') {
const normalized = normalizeCombo(override);
if (normalized === UNASSIGNED_SHORTCUT) {
return '';
}
if (isValidShortcutCombo(normalized)) {
return normalized;
}
}
return action.defaultCombo;
}
export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean {
if (isUnassignedShortcut(combo)) {
return false;
}
const parsed = parseShortcut(combo);
if (!parsed) {
return false;
}
const chord = parsed.chords[0];
if (!chord.modifiers.has('mod')) {
return false;
}
const key = chord.key.toLowerCase();
return RISKY_BROWSER_SHORTCUT_KEYS.has(key)
&& !chord.modifiers.has('shift')
&& !chord.modifiers.has('alt');
}
export function eventMatchesShortcut(
event: KeyboardEvent | React.KeyboardEvent,
shortcut: ShortcutAction | ShortcutCombo
): boolean {
const combo = typeof shortcut === 'string' ? shortcut : shortcut.defaultCombo;
if (isUnassignedShortcut(combo)) {
return false;
}
const parsed = parseShortcut(combo);
if (!parsed || parsed.chords.length !== 1) {
return false;
}
const chord = parsed.chords[0];
const expectedMod = chord.modifiers.has('mod');
const expectedShift = chord.modifiers.has('shift');
const expectedAlt = chord.modifiers.has('alt');
const expectedCtrl = chord.modifiers.has('ctrl');
const isDesktopMac = isMacOS() && isDesktopShell();
const isMac = isMacOS();
const modMatches = isDesktopMac
? event.metaKey
: isMac
? (event.metaKey || event.ctrlKey)
: event.ctrlKey;
if (expectedMod && !modMatches) {
return false;
}
if (!expectedMod && event.metaKey) {
return false;
}
if (expectedShift !== event.shiftKey) {
return false;
}
if (expectedAlt !== event.altKey) {
return false;
}
if (expectedCtrl) {
if (!event.ctrlKey) {
return false;
}
} else {
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
if (event.ctrlKey && !ctrlUsedAsMod) {
return false;
}
}
let eventKeyRaw = event.key;
if (event.altKey) {
if (event.code.startsWith('Key') && event.code.length === 4) {
eventKeyRaw = event.code.slice(3).toLowerCase();
} else if (event.code.startsWith('Digit') && event.code.length === 6) {
eventKeyRaw = event.code.slice(5);
}
}
const eventKey = keyToShortcutToken(eventKeyRaw);
const expectedKey = keyToShortcutToken(chord.key);
return eventKey === expectedKey;
}
export function getModifierLabel(): string {
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
}
/**
* Resolves the configurable prefix for chord-style shortcuts such as
* "switch context panel surface", where a trailing digit key completes the
* combo. Unlike getEffectiveShortcutCombo, modifier-only overrides (e.g. the
* bare `mod` primary key) are honored so the prefix can omit a primary key.
* Returns UNASSIGNED_SHORTCUT when the user explicitly unassigned the prefix.
*/
export function getEffectiveShortcutPrefix(
actionId: string,
overrides?: Record<string, ShortcutCombo>,
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) {
return '';
}
const override = overrides?.[actionId];
if (typeof override === 'string' && override.trim() !== '') {
const normalized = normalizeCombo(override);
if (normalized === UNASSIGNED_SHORTCUT) {
return UNASSIGNED_SHORTCUT;
}
if (normalized) {
const parsed = parseShortcut(normalized);
const chord = parsed?.chords[0];
if (chord && (chord.modifiers.size > 0 || chord.key)) {
return normalized;
}
}
}
return action.defaultCombo;
}
/**
* True when the physical keys required to "arm" a prefix combo are currently
* held. For modifiers with multiple aliases (e.g. `mod` on web macOS), at
* least one alias must be held.
*/
export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet<string>): boolean {
if (isUnassignedShortcut(prefixCombo)) {
return false;
}
const parsed = parseShortcut(prefixCombo);
if (!parsed || parsed.chords.length !== 1) {
return false;
}
const chord = parsed.chords[0];
for (const modifier of chord.modifiers) {
const aliases = MODIFIER_KEY_ALIASES[modifier];
if (!aliases.some((alias) => heldKeys.has(alias))) {
return false;
}
}
if (chord.key && !heldKeys.has(chord.key.toLowerCase())) {
return false;
}
return true;
}
/**
* Matches an activating keydown (the caller checks the event's own key, e.g. a
* digit) against a chord prefix: the event's modifier state must match the
* prefix's modifiers, and when the prefix has a primary key that key must
* currently be held.
*/
export function eventMatchesShortcutPrefix(
event: KeyboardEvent | React.KeyboardEvent,
prefixCombo: ShortcutCombo,
heldKeys?: ReadonlySet<string>,
): boolean {
if (isUnassignedShortcut(prefixCombo)) {
return false;
}
const parsed = parseShortcut(prefixCombo);
if (!parsed || parsed.chords.length !== 1) {
return false;
}
const chord = parsed.chords[0];
const expectedMod = chord.modifiers.has('mod');
const expectedShift = chord.modifiers.has('shift');
const expectedAlt = chord.modifiers.has('alt');
const expectedCtrl = chord.modifiers.has('ctrl');
const isDesktopMac = isMacOS() && isDesktopShell();
const isMac = isMacOS();
const modMatches = isDesktopMac
? event.metaKey
: isMac
? (event.metaKey || event.ctrlKey)
: event.ctrlKey;
if (expectedMod && !modMatches) {
return false;
}
if (!expectedMod && event.metaKey) {
return false;
}
if (expectedShift !== event.shiftKey) {
return false;
}
if (expectedAlt !== event.altKey) {
return false;
}
if (expectedCtrl) {
if (!event.ctrlKey) {
return false;
}
} else {
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
if (event.ctrlKey && !ctrlUsedAsMod) {
return false;
}
}
if (chord.key && (!heldKeys || !heldKeys.has(chord.key.toLowerCase()))) {
return false;
}
return true;
}
+14 -4
View File
@@ -2,13 +2,21 @@
Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both register with the shared `shortcutRegistry`, so components never receive a registry. The first registration for an action ID wins until it unregisters, then the next mounted registration takes over. A component-local interaction, such as editor navigation or an open menu, remains local event handling rather than a registered application command.
Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `shortcuts.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components.
Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `schema.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components.
# Schema contract
`SHORTCUT_SCHEMA` is the single static source of truth for application commands. Every entry declares an ID, default binding, category, and whether users can customize it. Customizable entries also derive their Settings translation key in the schema, so Settings must not maintain an action-ID switch or English fallback labels.
Component interaction keys that are not application commands, such as list navigation or text editing, do not belong in the schema. Contextual application commands do belong there even when they are not customizable; `save_file` and `find_in_file` are examples.
# Module roles
- `shortcuts.ts` owns action IDs, default bindings, categories, normalization, display, and conflict rules.
- `shortcutRegistry.ts` owns the active handler for each action ID.
- `shortcutDispatcher.ts` resolves current bindings and turns keyboard events into registered command calls.
- `index.ts` is the only public import surface, exposed as `@/lib/shortcuts`.
- `schema.ts` owns `SHORTCUT_SCHEMA`, derived action types, customizable metadata, and effective binding resolution.
- `bindings.ts` owns chord parsing, normalization, display, browser-risk checks, and conflict rules.
- `registry.ts` owns the active handler for each action ID.
- `dispatcher.ts` resolves current bindings and turns keyboard events into registered command calls.
- `useKeybind.ts` ties registrations to React component lifetimes while keeping handlers current without re-registering after every render.
- Runtime hooks install one dispatcher listener for their window. The main application and Mini Chat have separate windows but use the same contracts.
@@ -18,6 +26,8 @@ Bindings remain persisted as `Record<string, string>`. Each binding has one chor
Contextual internal commands may deliberately share a sequence leader. The single-chord handler gets the first chance to handle the event; returning `false` lets the dispatcher start the sequence. The active file editor therefore owns `mod+s` for saving, while a mounted but unfocused editor yields `mod+s p` and `mod+s g` to the draft target pickers.
Runtime-specific commands may also share an exact binding when their handlers are mutually exclusive. `open_diff_panel` handles `mod+2` on desktop, while `switch_tab_2` handles it on mobile; each returns `false` outside its runtime so the dispatcher can try the next registered action.
The settings recorder also stops at two chords. It keeps the recording local until the user explicitly saves, allows an exact conflict to replace the previous assignment, and blocks prefix conflicts because they make dispatch ambiguous.
# Dispatching
@@ -4,16 +4,13 @@ import {
eventMatchesShortcutPrefix,
formatShortcutForDisplay,
getEffectiveShortcutPrefix,
getCustomizableShortcutActions,
getShortcutAction,
getShortcutCategory,
getShortcutConflict,
isRiskyBrowserShortcut,
isShortcutPrefixHeld,
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
} from './shortcuts';
} from './index';
describe('getEffectiveShortcutPrefix', () => {
test('falls back to the action default (bare mod) when unset', () => {
@@ -108,11 +105,4 @@ describe('shortcut sequences', () => {
test('warns when a sequence leader conflicts with a browser shortcut', () => {
expect(isRiskyBrowserShortcut('mod+s p')).toBe(true);
});
test('categorizes customizable actions and includes draft picker sequences', () => {
expect(getCustomizableShortcutActions().every((action) => action.category !== undefined)).toBe(true);
expect(getShortcutAction('open_draft_project_picker')?.defaultCombo).toBe('mod+s p');
expect(getShortcutAction('open_draft_worktree_picker')?.defaultCombo).toBe('mod+s g');
expect(getShortcutCategory(getShortcutAction('focus_input')!)).toBe('session');
});
});
+314
View File
@@ -0,0 +1,314 @@
import type React from 'react';
import { isDesktopShell } from '@/lib/desktop';
import { isMacOS } from '@/lib/utils';
type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'ctrl';
type ShortcutKey = string;
export type ShortcutCombo = string;
export type ShortcutConflict = 'exact' | 'prefix';
export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__';
interface ParsedShortcutChord {
modifiers: Set<ShortcutModifier>;
key: ShortcutKey;
}
export interface ParsedShortcut {
chords: ReadonlyArray<ParsedShortcutChord>;
}
const MODIFIER_KEY_MAP: Record<string, ShortcutModifier> = {
mod: 'mod',
shift: 'shift',
alt: 'alt',
option: 'alt',
ctrl: 'ctrl',
meta: 'mod',
cmd: 'mod',
command: 'mod',
};
const DISPLAY_LABEL_MAP: Record<ShortcutModifier, string> = {
mod: isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl',
shift: '⇧',
alt: '⌥',
ctrl: '⌃',
};
const KEY_LABEL_MAP: Record<string, string> = {
comma: ',',
period: '.',
enter: 'Enter',
escape: 'Esc',
tab: 'Tab',
space: 'Space',
backspace: '⌫',
delete: '⌦',
arrowup: '↑',
arrowdown: '↓',
arrowleft: '←',
arrowright: '→',
home: 'Home',
end: 'End',
pageup: 'Page Up',
pagedown: 'Page Down',
};
const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt'];
const RISKY_BROWSER_SHORTCUT_KEYS = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']);
const MODIFIER_KEY_ALIASES: Record<ShortcutModifier, readonly string[]> = {
mod: isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'],
shift: ['shift'],
alt: ['alt'],
ctrl: ['control'],
};
const SHIFTED_KEY_BASE_MAP: Record<string, string> = {
'{': '[',
'}': ']',
':': ';',
'"': "'",
'<': ',',
'>': '.',
'?': '/',
'|': '\\',
'~': '`',
'!': '1',
'@': '2',
'#': '3',
'$': '4',
'%': '5',
'^': '6',
'&': '7',
'*': '8',
'(': '9',
')': '0',
};
function isUnassignedShortcut(combo: ShortcutCombo): boolean {
return combo.trim().toLowerCase() === UNASSIGNED_SHORTCUT;
}
export function keyToShortcutToken(key: string): string {
const lowered = key.toLowerCase();
if (lowered === ',') return 'comma';
if (lowered === '.') return 'period';
if (lowered === ' ') return 'space';
if (lowered === 'esc') return 'escape';
if (lowered === '+') return 'plus';
if (lowered === '-' || lowered === '_') return 'minus';
if (lowered === 'arrowup') return 'arrowup';
if (lowered === 'arrowdown') return 'arrowdown';
if (lowered === 'arrowleft') return 'arrowleft';
if (lowered === 'arrowright') return 'arrowright';
return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered;
}
export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
if (isUnassignedShortcut(combo)) return UNASSIGNED_SHORTCUT;
const chords = combo
.trim()
.replace(/\s*\+\s*/g, '+')
.split(/\s+/)
.filter(Boolean);
if (chords.length === 0 || chords.length > 2) return '';
return chords.map(normalizeChord).join(' ');
}
function normalizeChord(combo: ShortcutCombo): ShortcutCombo {
const rawParts = combo
.toLowerCase()
.trim()
.split('+')
.map((part) => part.trim())
.filter(Boolean);
const modifiers = new Set<ShortcutModifier>();
let key = '';
for (const rawPart of rawParts) {
const part = rawPart === ',' ? 'comma' : rawPart === '.' ? 'period' : rawPart;
const modifier = MODIFIER_KEY_MAP[part];
if (modifier) {
modifiers.add(modifier);
} else {
key = part;
}
}
const orderedModifiers = MODIFIER_PRIORITY.filter((modifier) => modifiers.has(modifier));
return [...orderedModifiers, key].filter(Boolean).join('+');
}
export function isValidShortcutCombo(combo: ShortcutCombo): boolean {
if (isUnassignedShortcut(combo)) return true;
const parsed = parseShortcut(combo);
return parsed !== undefined && parsed.chords.every((chord) => chord.key.trim().length > 0);
}
export function parseShortcut(combo: ShortcutCombo): ParsedShortcut | undefined {
if (isUnassignedShortcut(combo)) {
return { chords: [{ modifiers: new Set<ShortcutModifier>(), key: UNASSIGNED_SHORTCUT }] };
}
const normalized = normalizeCombo(combo);
if (!normalized) return undefined;
return {
chords: normalized.split(' ').map((chord) => {
const modifiers = new Set<ShortcutModifier>();
let key: ShortcutKey = '';
for (const part of chord.split('+')) {
const modifier = MODIFIER_KEY_MAP[part];
if (modifier) {
modifiers.add(modifier);
} else {
key = part;
}
}
return { modifiers, key };
}),
};
}
export function formatShortcutForDisplay(combo: ShortcutCombo, unassignedLabel = 'Unassigned'): string {
if (isUnassignedShortcut(combo)) return unassignedLabel;
const parsed = parseShortcut(combo);
if (!parsed || parsed.chords.some((chord) => !chord.key && chord.modifiers.size === 0)) {
return unassignedLabel;
}
return parsed.chords.map(formatChordForDisplay).join(', ');
}
function formatChordForDisplay(parsed: ParsedShortcutChord): string {
const parts = MODIFIER_PRIORITY
.filter((modifier) => parsed.modifiers.has(modifier))
.map((modifier) => DISPLAY_LABEL_MAP[modifier]);
if (parsed.key) {
parts.push(KEY_LABEL_MAP[parsed.key.toLowerCase()] || parsed.key.toUpperCase());
}
return parts.join(' + ');
}
export function getShortcutConflict(left: ShortcutCombo, right: ShortcutCombo): ShortcutConflict | undefined {
const normalizedLeft = normalizeCombo(left);
const normalizedRight = normalizeCombo(right);
const hasInvalidBinding = !isValidShortcutCombo(normalizedLeft) || !isValidShortcutCombo(normalizedRight);
const hasUnassignedBinding = normalizedLeft === UNASSIGNED_SHORTCUT
|| normalizedRight === UNASSIGNED_SHORTCUT;
if (hasInvalidBinding || hasUnassignedBinding) return undefined;
if (normalizedLeft === normalizedRight) return 'exact';
const leftChords = normalizedLeft.split(' ');
const rightChords = normalizedRight.split(' ');
const sharesLeader = leftChords[0] === rightChords[0];
return sharesLeader && leftChords.length !== rightChords.length ? 'prefix' : undefined;
}
export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean {
if (isUnassignedShortcut(combo)) return false;
const parsed = parseShortcut(combo);
if (!parsed) return false;
const chord = parsed.chords[0];
if (!chord.modifiers.has('mod')) return false;
return RISKY_BROWSER_SHORTCUT_KEYS.has(chord.key.toLowerCase())
&& !chord.modifiers.has('shift')
&& !chord.modifiers.has('alt');
}
export function eventMatchesShortcut(
event: KeyboardEvent | React.KeyboardEvent,
combo: ShortcutCombo,
): boolean {
if (isUnassignedShortcut(combo)) return false;
const parsed = parseShortcut(combo);
if (!parsed || parsed.chords.length !== 1) return false;
const chord = parsed.chords[0];
const expectedMod = chord.modifiers.has('mod');
const expectedShift = chord.modifiers.has('shift');
const expectedAlt = chord.modifiers.has('alt');
const expectedCtrl = chord.modifiers.has('ctrl');
const isDesktopMac = isMacOS() && isDesktopShell();
const isMac = isMacOS();
let modMatches = event.ctrlKey;
if (isDesktopMac) {
modMatches = event.metaKey;
} else if (isMac) {
modMatches = event.metaKey || event.ctrlKey;
}
if (expectedMod && !modMatches) return false;
if (!expectedMod && event.metaKey) return false;
if (expectedShift !== event.shiftKey) return false;
if (expectedAlt !== event.altKey) return false;
if (expectedCtrl) {
if (!event.ctrlKey) return false;
} else {
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
if (event.ctrlKey && !ctrlUsedAsMod) return false;
}
let eventKeyRaw = event.key;
if (event.altKey) {
if (event.code.startsWith('Key') && event.code.length === 4) {
eventKeyRaw = event.code.slice(3).toLowerCase();
} else if (event.code.startsWith('Digit') && event.code.length === 6) {
eventKeyRaw = event.code.slice(5);
}
}
return keyToShortcutToken(eventKeyRaw) === keyToShortcutToken(chord.key);
}
export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet<string>): boolean {
if (isUnassignedShortcut(prefixCombo)) return false;
const parsed = parseShortcut(prefixCombo);
if (!parsed || parsed.chords.length !== 1) return false;
const chord = parsed.chords[0];
for (const modifier of chord.modifiers) {
if (!MODIFIER_KEY_ALIASES[modifier].some((alias) => heldKeys.has(alias))) return false;
}
return !chord.key || heldKeys.has(chord.key.toLowerCase());
}
export function eventMatchesShortcutPrefix(
event: KeyboardEvent | React.KeyboardEvent,
prefixCombo: ShortcutCombo,
heldKeys?: ReadonlySet<string>,
): boolean {
if (isUnassignedShortcut(prefixCombo)) return false;
const parsed = parseShortcut(prefixCombo);
if (!parsed || parsed.chords.length !== 1) return false;
const chord = parsed.chords[0];
const expectedMod = chord.modifiers.has('mod');
const expectedShift = chord.modifiers.has('shift');
const expectedAlt = chord.modifiers.has('alt');
const expectedCtrl = chord.modifiers.has('ctrl');
const isDesktopMac = isMacOS() && isDesktopShell();
const isMac = isMacOS();
const modMatches = isDesktopMac ? event.metaKey : isMac ? event.metaKey || event.ctrlKey : event.ctrlKey;
if (expectedMod && !modMatches) return false;
if (!expectedMod && event.metaKey) return false;
if (expectedShift !== event.shiftKey || expectedAlt !== event.altKey) return false;
if (expectedCtrl) {
if (!event.ctrlKey) return false;
} else {
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
if (event.ctrlKey && !ctrlUsedAsMod) return false;
}
return !chord.key || Boolean(heldKeys?.has(chord.key.toLowerCase()));
}
export function getModifierLabel(): string {
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test';
import { ShortcutDispatcher } from './shortcutDispatcher';
import { ShortcutRegistry } from './shortcutRegistry';
import { ShortcutDispatcher } from './dispatcher';
import { ShortcutRegistry } from './registry';
function key(key: string, options: Partial<KeyboardEvent> = {}): KeyboardEvent {
return {
@@ -3,10 +3,10 @@ import {
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
type ShortcutActionId,
type ShortcutCombo,
} from './shortcuts';
import { type ShortcutHandler, ShortcutRegistry } from './shortcutRegistry';
} from './bindings';
import { type ShortcutHandler, ShortcutRegistry } from './registry';
import type { ShortcutActionId } from './schema';
const SEQUENCE_TIMEOUT_MS = 1500;
const MODIFIER_KEYS = new Set(['alt', 'control', 'meta', 'shift']);
@@ -106,13 +106,15 @@ export class ShortcutDispatcher {
const matches: BindingMatch[] = [];
for (const actionId of this.options.registry.actionIds()) {
const handler = this.options.registry.get(actionId);
if (!handler) continue;
const binding = normalizeCombo(this.options.getBinding(actionId));
const parsed = parseShortcut(binding);
const isDispatchable = parsed
&& parsed.chords.every((chord) => chord.key && chord.key !== UNASSIGNED_SHORTCUT);
if (handler && isDispatchable) {
matches.push({ chords: binding.split(' '), handler });
if (!parsed || parsed.chords.some((chord) => !chord.key || chord.key === UNASSIGNED_SHORTCUT)) {
continue;
}
matches.push({ chords: binding.split(' '), handler });
}
return matches;
}
+29
View File
@@ -0,0 +1,29 @@
export {
eventMatchesShortcut,
eventMatchesShortcutPrefix,
formatShortcutForDisplay,
getModifierLabel,
getShortcutConflict,
isRiskyBrowserShortcut,
isShortcutPrefixHeld,
keyToShortcutToken,
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
} from './bindings';
export type { ShortcutCombo } from './bindings';
export { ShortcutDispatcher } from './dispatcher';
export { shortcutRegistry } from './registry';
export type { ShortcutHandler } from './registry';
export {
getCustomizableShortcutActions,
getEffectiveShortcutCombo,
getEffectiveShortcutPrefix,
getShortcutAction,
SHORTCUT_SCHEMA,
} from './schema';
export type {
CustomizableShortcutAction,
ShortcutActionId,
ShortcutCategory,
} from './schema';
@@ -1,5 +1,5 @@
import { expect, test } from 'bun:test';
import { ShortcutRegistry } from './shortcutRegistry';
import { ShortcutRegistry } from './registry';
test('the first registration wins and a later unregister cannot remove it', () => {
const registry = new ShortcutRegistry();
@@ -1,10 +1,9 @@
import type { ShortcutActionId } from './shortcuts';
import type { ShortcutActionId } from './schema';
export type ShortcutHandler = (event: KeyboardEvent) => boolean | void;
interface RegisteredHandler {
handler: ShortcutHandler;
token: symbol;
}
/** Active application command handlers, keyed by shortcut action ID. */
@@ -12,14 +11,14 @@ export class ShortcutRegistry {
private readonly handlers = new Map<ShortcutActionId, RegisteredHandler[]>();
register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void {
const token = Symbol(actionId);
const registration = { handler };
const registered = this.handlers.get(actionId) ?? [];
registered.push({ handler, token });
registered.push(registration);
this.handlers.set(actionId, registered);
return () => {
const current = this.handlers.get(actionId);
if (!current) return;
const index = current.findIndex((entry) => entry.token === token);
const index = current.indexOf(registration);
if (index === -1) return;
current.splice(index, 1);
if (current.length === 0) {
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'bun:test';
import {
getCustomizableShortcutActions,
getEffectiveShortcutCombo,
getShortcutAction,
parseShortcut,
SHORTCUT_SCHEMA,
} from './index';
describe('shortcut schema', () => {
test('declares unique IDs and valid bindings for every application shortcut', () => {
const ids = SHORTCUT_SCHEMA.map((action) => action.id);
expect(new Set(ids).size).toBe(ids.length);
expect(SHORTCUT_SCHEMA.every((action) => {
const chordCount = parseShortcut(action.defaultBinding)?.chords.length;
return Boolean(action.category) && chordCount !== undefined && chordCount >= 1 && chordCount <= 2;
})).toBe(true);
});
test('derives settings labels for every customizable shortcut', () => {
const customizable = getCustomizableShortcutActions();
expect(customizable.length).toBeGreaterThan(0);
expect(customizable.every((action) => (
action.settingsLabelKey === `settings.openchamber.keyboardShortcuts.action.${action.id}.label`
))).toBe(true);
});
test('includes draft prefix bindings and session metadata', () => {
expect(getShortcutAction('open_draft_project_picker')?.defaultBinding).toBe('mod+s p');
expect(getShortcutAction('open_draft_worktree_picker')?.defaultBinding).toBe('mod+s g');
expect(getShortcutAction('focus_input')?.category).toBe('session');
});
test('preserves valid overrides and falls back from malformed bindings', () => {
expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k' })).toBe('mod+k');
expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k x y' })).toBe('mod+n');
});
});
+145
View File
@@ -0,0 +1,145 @@
import {
isValidShortcutCombo,
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
type ShortcutCombo,
} from './bindings';
export type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application';
interface ShortcutDefinition<Id extends string = string> {
id: Id;
defaultBinding: ShortcutCombo;
category: ShortcutCategory;
}
interface CustomizableShortcutDefinition<Id extends string = string> extends ShortcutDefinition<Id> {
customizable: true;
settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${Id}.label`;
}
interface InternalShortcutDefinition<Id extends string = string> extends ShortcutDefinition<Id> {
customizable: false;
}
function internalShortcut<const Id extends string>(
id: Id,
defaultBinding: ShortcutCombo,
category: ShortcutCategory,
): InternalShortcutDefinition<Id> {
return { id, defaultBinding, category, customizable: false };
}
function customizableShortcut<const Id extends string>(
id: Id,
defaultBinding: ShortcutCombo,
category: ShortcutCategory,
): CustomizableShortcutDefinition<Id> {
return {
id,
defaultBinding,
category,
customizable: true,
settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${id}.label`,
};
}
/** The single static source of truth for every application-level shortcut. */
export const SHORTCUT_SCHEMA = [
internalShortcut('save_file', 'mod+s', 'navigation'),
internalShortcut('find_in_file', 'mod+f', 'navigation'),
customizableShortcut('open_go_to_line', 'alt+g', 'navigation'),
customizableShortcut('open_command_palette', 'mod+p', 'application'),
customizableShortcut('focus_input', 'mod+i', 'session'),
internalShortcut('open_status', 'mod+shift+o', 'application'),
customizableShortcut('open_settings', 'mod+comma', 'application'),
customizableShortcut('toggle_terminal', 'mod+j', 'panels'),
customizableShortcut('toggle_terminal_expanded', 'mod+shift+j', 'panels'),
customizableShortcut('add_selection_to_chat', 'mod+l', 'session'),
customizableShortcut('toggle_sidebar', 'mod+alt+l', 'panels'),
customizableShortcut('open_timeline_dialog', 'mod+t', 'session'),
customizableShortcut('toggle_prompt_navigator', 'mod+alt+p', 'panels'),
customizableShortcut('toggle_right_sidebar', 'mod+b', 'panels'),
customizableShortcut('open_right_sidebar_git', 'mod+shift+g', 'panels'),
customizableShortcut('open_right_sidebar_files', 'mod+shift+f', 'panels'),
customizableShortcut('switch_context_surface', 'mod', 'panels'),
customizableShortcut('new_chat', 'mod+n', 'session'),
customizableShortcut('open_draft_project_picker', 'mod+s p', 'session'),
customizableShortcut('open_draft_worktree_picker', 'mod+s g', 'session'),
customizableShortcut('new_chat_worktree', 'mod+shift+n', 'session'),
customizableShortcut('new_mini_chat', 'mod+alt+n', 'session'),
customizableShortcut('open_help', 'mod+.', 'application'),
customizableShortcut('toggle_context_plan', 'mod+shift+p', 'panels'),
customizableShortcut('toggle_services_menu', 'mod+shift+s', 'panels'),
customizableShortcut('cycle_services_tab', 'mod+shift+[', 'navigation'),
customizableShortcut('cycle_theme', 'mod+/', 'application'),
customizableShortcut('open_model_selector', 'mod+shift+m', 'models'),
internalShortcut('cycle_thinking_variant', 'mod+shift+t', 'models'),
customizableShortcut('cycle_agent', 'tab', 'models'),
customizableShortcut('cycle_favorite_model_forward', 'ctrl+]', 'models'),
customizableShortcut('cycle_favorite_model_backward', 'ctrl+[', 'models'),
customizableShortcut('expand_input', 'mod+shift+e', 'session'),
customizableShortcut('toggle_dictation', 'mod+alt+v', 'session'),
internalShortcut('abort_run', 'escape', 'session'),
internalShortcut('toggle_memory_debug', 'mod+shift+d', 'application'),
internalShortcut('switch_tab_1', 'mod+1', 'navigation'),
// Mobile tab shortcuts share numeric bindings with desktop-only panel commands.
internalShortcut('switch_tab_2', 'mod+2', 'navigation'),
internalShortcut('switch_tab_3', 'mod+3', 'navigation'),
internalShortcut('switch_tab_4', 'mod+4', 'navigation'),
internalShortcut('switch_tab_5', 'mod+5', 'navigation'),
internalShortcut('switch_tab_6', 'mod+6', 'navigation'),
internalShortcut('switch_tab_7', 'mod+7', 'navigation'),
internalShortcut('switch_tab_8', 'mod+8', 'navigation'),
internalShortcut('switch_tab_9', 'mod+9', 'navigation'),
] as const;
export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number];
export type ShortcutActionId = ShortcutAction['id'];
export type CustomizableShortcutAction = Extract<ShortcutAction, { customizable: true }>;
export function getShortcutAction(id: string): ShortcutAction | undefined {
return SHORTCUT_SCHEMA.find((action) => action.id === id);
}
export function getCustomizableShortcutActions(): ReadonlyArray<CustomizableShortcutAction> {
return SHORTCUT_SCHEMA.filter(
(action): action is CustomizableShortcutAction => action.customizable,
);
}
export function getEffectiveShortcutCombo(
actionId: string,
overrides?: Record<string, ShortcutCombo>,
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) return '';
const override = overrides?.[actionId];
if (typeof override === 'string') {
const normalized = normalizeCombo(override);
if (normalized === UNASSIGNED_SHORTCUT) return '';
if (isValidShortcutCombo(normalized)) return normalized;
}
return action.defaultBinding;
}
export function getEffectiveShortcutPrefix(
actionId: string,
overrides?: Record<string, ShortcutCombo>,
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) return '';
const override = overrides?.[actionId];
if (typeof override === 'string' && override.trim() !== '') {
const normalized = normalizeCombo(override);
if (normalized === UNASSIGNED_SHORTCUT) return UNASSIGNED_SHORTCUT;
const chord = parseShortcut(normalized)?.chords[0];
if (chord && (chord.modifiers.size > 0 || chord.key)) return normalized;
}
return action.defaultBinding;
}
-19
View File
@@ -1,6 +1,5 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { isDesktopShell } from "@/lib/desktop";
import { matchesFuzzyQuery } from "@/lib/search/fuzzySearch";
import type { I18nKey } from "@/lib/i18n";
@@ -28,24 +27,6 @@ export const getRevealLabelKey = (): I18nKey => {
return 'common.revealPath.fileManager';
};
/**
* Checks if the platform-appropriate modifier key is pressed.
* On macOS desktop app: Cmd (metaKey), on other platforms or web: Ctrl (ctrlKey).
* Browser intercepts Cmd shortcuts, so we only use Cmd in the desktop app.
*/
export const hasModifier = (e: KeyboardEvent | React.KeyboardEvent): boolean => {
return isMacOS() && isDesktopShell() ? e.metaKey : e.ctrlKey;
};
/**
* Returns the platform-appropriate modifier key label.
* On macOS desktop app: "⌘", on other platforms or web: "Ctrl"
* Browser intercepts Cmd shortcuts, so we only show Cmd in the desktop app.
*/
export const getModifierLabel = (): string => {
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
};
export const truncatePathMiddle = (
value: string,
options?: { maxLength?: number }