feat(ui): land the centralized shortcuts core from #2532 with review fixes

The schema/config/bindings/registry/dispatcher module, useKeybind hooks,
recording dialog, reworked shortcuts settings page, help dialog, and the
localized action labels — re-based onto current main rather than merged
(the branch predates 440+ commits including the session-tabs shortcuts).

Review fixes applied on top of the original:
- close_session_tab (alt+w) joins the schema with labels in every locale;
  it shipped on main after the PR's base and would otherwise silently die.
- switch_context_surface's special-case in conflict resolution is now a
  declared prefixStyle config property instead of a magic id string.
- Duplicate handler registration warns in dev builds.
- The risky-browser-shortcut warning inspects every chord and covers
  mod+q/d/h/j/o/u plus mod+shift+w/q.
- The dispatcher remembers which target armed a two-chord prefix so the
  window-level completion handler can distinguish a deliberate sequence
  from typing in an editable field (guard lands with the dispatch hook).
- Schema tests: unique normalized default bindings enforced, and the
  flat-file-era override format proven to keep resolving.
This commit is contained in:
Bohdan Triapitsyn
2026-08-26 10:47:44 +03:00
parent 428854fd78
commit f1c3870909
44 changed files with 2716 additions and 1045 deletions
@@ -1,12 +1,7 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
SettingsSection,
SettingsFieldRow,
} from '@/components/sections/shared/SettingsSection';
import { SettingsFieldRow, SettingsSection } from '@/components/sections/shared/SettingsSection';
import { useUIStore } from '@/stores/useUIStore';
import { cn } from '@/lib/utils';
import { updateDesktopSettings } from '@/lib/persistence';
import { isVSCodeRuntime } from '@/lib/desktop';
import {
@@ -14,314 +9,124 @@ import {
getCustomizableShortcutActions,
getEffectiveShortcutCombo,
getEffectiveShortcutPrefix,
isRiskyBrowserShortcut,
keyToShortcutToken,
normalizeCombo,
UNASSIGNED_SHORTCUT,
type ShortcutActionId,
type ShortcutCategory,
type ShortcutCombo,
type CustomizableShortcutAction,
} from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n';
import { ShortcutRecordingDialog } from './ShortcutRecordingDialog';
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
const keyboardEventToCombo = (event: React.KeyboardEvent<HTMLInputElement>): ShortcutCombo | null => {
if (MODIFIER_KEYS.has(event.key.toLowerCase())) {
return null;
}
const parts: string[] = [];
if (event.metaKey || event.ctrlKey) {
parts.push('mod');
}
if (event.shiftKey) {
parts.push('shift');
}
if (event.altKey) {
parts.push('alt');
}
const keyToken = keyToShortcutToken(event.key);
if (!keyToken) {
return null;
}
parts.push(keyToken);
return normalizeCombo(parts.join('+'));
};
// Prefix capture for chord-style shortcuts (e.g. "switch context panel
// surface"): a bare modifier press is accepted so the prefix can be just the
// primary modifier (default) or a modifier + key chord like `mod+p`.
const keyboardEventToPrefixCombo = (event: React.KeyboardEvent<HTMLInputElement>): ShortcutCombo | null => {
const parts: string[] = [];
if (event.metaKey || event.ctrlKey) {
parts.push('mod');
}
if (event.shiftKey) {
parts.push('shift');
}
if (event.altKey) {
parts.push('alt');
}
if (MODIFIER_KEYS.has(event.key.toLowerCase())) {
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
}
const keyToken = keyToShortcutToken(event.key);
if (!keyToken) {
return null;
}
parts.push(keyToken);
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
};
const CATEGORIES: ShortcutCategory[] = ['session', 'models', 'panels', 'navigation', 'application'];
export const KeyboardShortcutsSettings: React.FC = () => {
const { t } = useI18n();
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
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<CustomizableShortcutAction | null>(null);
const actions = React.useMemo(() => {
const all = getCustomizableShortcutActions();
if (!isVSCodeRuntime()) {
return all;
}
return all.filter((action) => action.id !== 'toggle_prompt_navigator');
return isVSCodeRuntime() ? all.filter((action) => action.id !== 'toggle_prompt_navigator') : all;
}, []);
const actionLabel = React.useCallback((id: string, fallbackLabel: string): string => {
const key = `settings.openchamber.keyboardShortcuts.action.${id}.label`;
const translated = tUnsafe(key);
return translated === key ? fallbackLabel : translated;
}, [tUnsafe]);
const [capturingActionId, setCapturingActionId] = React.useState<string | null>(null);
const [draftByAction, setDraftByAction] = React.useState<Record<string, ShortcutCombo>>({});
const [errorText, setErrorText] = React.useState<string>('');
const [warningText, setWarningText] = React.useState<string>('');
const [pendingOverwrite, setPendingOverwrite] = React.useState<{
actionId: string;
combo: ShortcutCombo;
conflictActionId: string;
} | null>(null);
const persistShortcutOverrides = React.useCallback((nextOverrides: Record<string, ShortcutCombo>) => {
const persist = (nextOverrides: Record<string, ShortcutCombo>) => {
void updateDesktopSettings({ shortcutOverrides: nextOverrides });
}, []);
const findConflict = React.useCallback((actionId: string, combo: ShortcutCombo): string | null => {
const normalized = normalizeCombo(combo);
for (const action of actions) {
if (action.id === actionId) {
continue;
}
const existing = getEffectiveShortcutCombo(action.id, shortcutOverrides);
if (normalizeCombo(existing) === normalized) {
return action.id;
}
}
return null;
}, [actions, shortcutOverrides]);
const saveCombo = React.useCallback((actionId: string, combo: ShortcutCombo) => {
const normalized = normalizeCombo(combo);
const conflictActionId = findConflict(actionId, normalized);
if (conflictActionId) {
setPendingOverwrite({ actionId, combo: normalized, conflictActionId });
setErrorText('');
return;
}
const nextOverrides = { ...shortcutOverrides, [actionId]: normalized };
setShortcutOverride(actionId, normalized);
persistShortcutOverrides(nextOverrides);
setPendingOverwrite(null);
setErrorText('');
setWarningText(isRiskyBrowserShortcut(normalized) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : '');
setDraftByAction((current) => {
const rest = { ...current };
delete rest[actionId];
return rest;
});
}, [findConflict, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]);
const confirmOverwrite = React.useCallback(() => {
if (!pendingOverwrite) {
return;
}
const nextOverrides = {
...shortcutOverrides,
[pendingOverwrite.conflictActionId]: UNASSIGNED_SHORTCUT,
[pendingOverwrite.actionId]: pendingOverwrite.combo,
};
setShortcutOverride(pendingOverwrite.conflictActionId, UNASSIGNED_SHORTCUT);
setShortcutOverride(pendingOverwrite.actionId, pendingOverwrite.combo);
persistShortcutOverrides(nextOverrides);
setPendingOverwrite(null);
setErrorText('');
setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : '');
setDraftByAction((current) => {
const rest = { ...current };
delete rest[pendingOverwrite.actionId];
return rest;
});
}, [pendingOverwrite, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]);
const resetOne = React.useCallback((actionId: string) => {
};
const save = (
actionId: ShortcutActionId,
combo: ShortcutCombo,
replaceActionId?: ShortcutActionId,
) => {
const nextOverrides = { ...shortcutOverrides, [actionId]: combo };
if (replaceActionId) nextOverrides[replaceActionId] = UNASSIGNED_SHORTCUT;
setShortcutOverride(actionId, combo);
if (replaceActionId) setShortcutOverride(replaceActionId, UNASSIGNED_SHORTCUT);
persist(nextOverrides);
};
const resetOne = (actionId: ShortcutActionId) => {
const nextOverrides = { ...shortcutOverrides };
delete nextOverrides[actionId];
clearShortcutOverride(actionId);
persistShortcutOverrides(nextOverrides);
setDraftByAction((current) => {
const rest = { ...current };
delete rest[actionId];
return rest;
});
setPendingOverwrite(null);
setErrorText('');
setWarningText('');
}, [clearShortcutOverride, persistShortcutOverrides, shortcutOverrides]);
persist(nextOverrides);
};
const shortcutDisplay = (action: CustomizableShortcutAction): string => {
const isSurfaceSwitch = action.id === 'switch_context_surface';
const combo = isSurfaceSwitch
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
: getEffectiveShortcutCombo(action.id, shortcutOverrides);
const formatted = formatShortcutForDisplay(
combo,
t('settings.openchamber.keyboardShortcuts.unassigned'),
);
return isSurfaceSwitch && combo && combo !== UNASSIGNED_SHORTCUT
? `${formatted}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}`
: formatted;
};
return (
<SettingsSection
settingsItem="shortcuts.keyboard-shortcuts"
title={t('settings.openchamber.keyboardShortcuts.title')}
divider={false}
info={t('settings.openchamber.keyboardShortcuts.tooltip')}
headerAction={(
<Button
type="button"
variant="outline"
size="xs"
className="!font-normal"
onClick={() => {
resetAllShortcutOverrides();
persistShortcutOverrides({});
setDraftByAction({});
setPendingOverwrite(null);
setErrorText('');
setWarningText('');
}}
>
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
</Button>
)}
>
{(errorText || warningText || pendingOverwrite) && (
<div className="mb-2 space-y-2">
{pendingOverwrite && (
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 flex flex-col @xl:flex-row @xl:items-center justify-between gap-3">
<span className="typography-meta text-foreground">
{t('settings.openchamber.keyboardShortcuts.overwritePrompt')}
</span>
<div className="flex gap-2 shrink-0">
<Button type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>{t('settings.openchamber.keyboardShortcuts.actions.overwrite')}</Button>
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>{t('settings.common.actions.cancel')}</Button>
</div>
<>
{CATEGORIES.map((category, categoryIndex) => {
const categoryActions = actions.filter((action) => action.category === category);
if (categoryActions.length === 0) return null;
return (
<SettingsSection
key={category}
settingsItem={categoryIndex === 0 ? 'shortcuts.keyboard-shortcuts' : undefined}
title={t(`settings.openchamber.keyboardShortcuts.category.${category}`)}
divider={categoryIndex !== 0}
info={categoryIndex === 0 ? t('settings.openchamber.keyboardShortcuts.tooltip') : undefined}
headerAction={categoryIndex === 0 ? (
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => {
resetAllShortcutOverrides();
persist({});
}}>
{t('settings.openchamber.keyboardShortcuts.actions.resetAll')}
</Button>
) : undefined}
>
<div className="space-y-2">
{categoryActions.map((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"
>
{shortcutDisplay(action)}
</kbd>
<Button
type="button"
variant="secondary"
size="xs"
className="!font-normal"
onClick={() => setEditingAction(action)}
>
{t('settings.openchamber.keyboardShortcuts.actions.edit')}
</Button>
<Button
type="button"
variant="ghost"
size="xs"
className="!font-normal"
onClick={() => resetOne(action.id)}
>
{t('settings.common.actions.reset')}
</Button>
</SettingsFieldRow>
))}
</div>
)}
{errorText && (
<div className="rounded-lg border border-[var(--status-error-border)] bg-[var(--status-error-background)] p-3 typography-meta text-foreground">
{errorText}
</div>
)}
{warningText && (
<div className="rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3 typography-meta text-foreground">
{warningText}
</div>
)}
</div>
)}
<div>
{actions.map((action, index) => {
const isSurfaceSwitch = action.id === 'switch_context_surface';
const effective = isSurfaceSwitch
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
: getEffectiveShortcutCombo(action.id, shortcutOverrides);
const draft = draftByAction[action.id];
const displayCombo = draft ?? effective;
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
const isUnassignedDisplay = displayCombo === '' || normalizeCombo(displayCombo) === UNASSIGNED_SHORTCUT;
const displayValue = capturingActionId === action.id
? t('settings.openchamber.keyboardShortcuts.field.pressKeys')
: isSurfaceSwitch && !isUnassignedDisplay
? `${formatShortcutForDisplay(displayCombo)}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}`
: formatShortcutForDisplay(displayCombo);
return (
<div key={action.id} className={cn("py-1.5", index > 0 && "border-t border-border/40")}>
<SettingsFieldRow
label={actionLabel(action.id, action.label)}
alignEnd={false}
>
<Input
readOnly
value={displayValue}
onFocus={() => {
setCapturingActionId(action.id);
setErrorText('');
}}
onBlur={() => {
if (capturingActionId === action.id) {
setCapturingActionId(null);
}
}}
onKeyDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (event.key === 'Escape') {
setCapturingActionId(null);
return;
}
const combo = isSurfaceSwitch ? keyboardEventToPrefixCombo(event) : keyboardEventToCombo(event);
if (!combo) {
return;
}
setDraftByAction((current) => ({
...current,
[action.id]: combo,
}));
setCapturingActionId(null);
setPendingOverwrite(null);
setErrorText('');
}}
className="h-7 w-40 min-w-0 typography-ui-label text-center"
/>
<Button
type="button"
variant="secondary"
size="xs"
className="!font-normal"
onClick={() => {
const next = draftByAction[action.id];
if (!next) {
setErrorText(t('settings.openchamber.keyboardShortcuts.error.captureFirst'));
return;
}
saveCombo(action.id, next);
}}
disabled={!hasDraft}
>
{t('settings.common.actions.saveChanges')}
</Button>
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
{t('settings.common.actions.reset')}
</Button>
</SettingsFieldRow>
</div>
);
})}
</div>
</SettingsSection>
</SettingsSection>
);
})}
<ShortcutRecordingDialog
action={editingAction}
overrides={shortcutOverrides}
onSave={save}
onOpenChange={(open) => {
if (!open) setEditingAction(null);
}}
/>
</>
);
};
@@ -0,0 +1,64 @@
import { describe, expect, test } from 'bun:test';
import { settleShortcutRecordingState, updateShortcutRecordingState } from './ShortcutRecordingDialog';
const emptyState = { chords: [], livePreview: null, settled: false };
function keyEvent(key: string, modifiers: Partial<Record<'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey', boolean>> = {}) {
return { key, repeat: false, isComposing: false, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers };
}
describe('ShortcutRecordingDialog recording state', () => {
test('previews modifiers and clears the preview when they are released', () => {
const pressed = updateShortcutRecordingState(emptyState, keyEvent('Control', { ctrlKey: true, shiftKey: true }), 'keydown');
expect(pressed.livePreview).toBe('mod+shift');
expect(updateShortcutRecordingState(pressed, keyEvent('Control'), 'keyup').livePreview).toBeNull();
});
test('waits after the first chord and settles when a second chord is recorded', () => {
const first = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown');
const second = updateShortcutRecordingState(first, keyEvent('p'), 'keydown');
const third = updateShortcutRecordingState(second, keyEvent('x'), 'keydown');
expect(first.chords).toEqual(['mod+s']);
expect(first.settled).toBe(false);
expect(second.chords).toEqual(['mod+s', 'p']);
expect(second.settled).toBe(true);
expect(third.chords).toEqual(['x']);
expect(third.settled).toBe(false);
});
test('settles a single chord for timeout and Confirm validation', () => {
const waiting = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown');
expect(settleShortcutRecordingState(waiting)).toEqual({ chords: ['mod+s'], livePreview: null, settled: true });
});
test('records at most three simultaneous keys', () => {
const previous = { chords: ['mod+k'], livePreview: null, settled: false };
const threeKeys = updateShortcutRecordingState(
previous,
keyEvent('s', { ctrlKey: true, shiftKey: true }),
'keydown',
);
const fourKeys = updateShortcutRecordingState(
previous,
keyEvent('s', { ctrlKey: true, metaKey: true, shiftKey: true }),
'keydown',
);
expect(threeKeys.chords).toEqual(['mod+k', 'mod+shift+s']);
expect(fourKeys.chords).toEqual(['mod+k']);
});
test('ignores repeat and IME events', () => {
expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), repeat: true }, 'keydown')).toEqual(emptyState);
expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), isComposing: true }, 'keydown')).toEqual(emptyState);
});
test('records Enter and Escape while Backspace removes the final chord', () => {
const state = { chords: ['mod+k', 'mod+p'], livePreview: null, settled: true };
expect(updateShortcutRecordingState(emptyState, keyEvent('Enter'), 'keydown').chords).toEqual(['enter']);
expect(updateShortcutRecordingState(emptyState, keyEvent('Escape'), 'keydown').chords).toEqual(['escape']);
expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').chords).toEqual(['mod+k']);
expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').settled).toBe(false);
expect(updateShortcutRecordingState({ chords: ['mod+k'], livePreview: null, settled: false }, keyEvent('Backspace'), 'keydown')).toEqual(emptyState);
});
});
@@ -0,0 +1,311 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
formatShortcutForDisplay,
getShortcutBindingConflicts,
isRiskyBrowserShortcut,
keyToShortcutToken,
normalizeCombo,
type ShortcutActionId,
type ShortcutBindingConflict,
type ShortcutCombo,
type CustomizableShortcutAction,
} from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n';
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
const MAX_SHORTCUT_KEY_COUNT = 3;
const SECOND_CHORD_TIMEOUT_MS = 3000;
interface RecordingKeyboardEvent {
altKey: boolean;
ctrlKey: boolean;
isComposing: boolean;
key: string;
metaKey: boolean;
repeat: boolean;
shiftKey: boolean;
}
interface ShortcutRecordingState {
chords: ShortcutCombo[];
livePreview: ShortcutCombo | null;
settled: boolean;
}
interface ShortcutRecordingDialogProps {
action: CustomizableShortcutAction | null;
overrides: Record<string, string>;
onSave: (
actionId: ShortcutActionId,
combo: ShortcutCombo,
replaceActionId?: ShortcutActionId,
) => void;
onOpenChange: (open: boolean) => void;
}
function getPhysicalKeyCount(
event: Pick<RecordingKeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
includeEventKey = false,
): number {
const keys = new Set<string>();
if (event.altKey) keys.add('alt');
if (event.ctrlKey) keys.add('control');
if (event.metaKey) keys.add('meta');
if (event.shiftKey) keys.add('shift');
if (includeEventKey) keys.add(event.key.toLowerCase());
return keys.size;
}
function isCustomizableConflict(
conflict: ShortcutBindingConflict,
): conflict is ShortcutBindingConflict & { action: CustomizableShortcutAction } {
return conflict.action.customizable;
}
function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null {
if (getPhysicalKeyCount(event) > MAX_SHORTCUT_KEY_COUNT) return null;
const parts: string[] = [];
if (event.metaKey || event.ctrlKey) parts.push('mod');
if (event.shiftKey) parts.push('shift');
if (event.altKey) parts.push('alt');
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
}
function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | null {
if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null;
if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null;
const key = keyToShortcutToken(event.key);
if (!key) return null;
const parts: string[] = [];
if (event.metaKey || event.ctrlKey) parts.push('mod');
if (event.shiftKey) parts.push('shift');
if (event.altKey) parts.push('alt');
parts.push(key);
return normalizeCombo(parts.join('+'));
}
function modifierKeyUpToCombo(event: React.KeyboardEvent<HTMLDivElement>): ShortcutCombo | null {
const key = event.key.toLowerCase();
if (!MODIFIER_KEYS.has(key)) return null;
if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null;
const parts: string[] = [];
if (event.metaKey || event.ctrlKey || key === 'meta' || key === 'control') parts.push('mod');
if (event.shiftKey || key === 'shift') parts.push('shift');
if (event.altKey || key === 'alt') parts.push('alt');
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
}
// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition
export function settleShortcutRecordingState(state: ShortcutRecordingState): ShortcutRecordingState {
return state.chords.length > 0 ? { ...state, livePreview: null, settled: true } : state;
}
// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition
export function updateShortcutRecordingState(
state: ShortcutRecordingState,
event: RecordingKeyboardEvent,
phase: 'keydown' | 'keyup',
): ShortcutRecordingState {
if (event.repeat || event.isComposing) return state;
if (phase === 'keyup') {
return { ...state, livePreview: getModifierPreview(event) };
}
if (event.key === 'Backspace') {
return { chords: state.chords.slice(0, -1), livePreview: null, settled: false };
}
const chord = keyboardEventToCombo(event);
if (chord) {
if (state.settled) {
return { chords: [chord], livePreview: null, settled: false };
}
const chords = state.chords.length < 2 ? [...state.chords, chord] : state.chords;
return {
chords,
livePreview: null,
settled: chords.length === 2,
};
}
return { ...state, livePreview: getModifierPreview(event) };
}
export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = ({
action,
overrides,
onSave,
onOpenChange,
}) => {
const { t } = useI18n();
const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey);
const conflictActionLabel = (conflict: ShortcutBindingConflict) => (
conflict.action.customizable
? actionLabel(conflict.action)
: formatShortcutForDisplay(conflict.action.defaultBinding)
);
const [recording, setRecording] = React.useState<ShortcutRecordingState>({ chords: [], livePreview: null, settled: false });
const recordingRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!action) return;
setRecording({ chords: [], livePreview: null, settled: false });
recordingRef.current?.focus();
}, [action]);
const waitingForSecondChord = recording.chords.length === 1 && !recording.settled;
React.useEffect(() => {
if (!waitingForSecondChord) return;
const timeout = window.setTimeout(
() => setRecording(settleShortcutRecordingState),
SECOND_CHORD_TIMEOUT_MS,
);
return () => window.clearTimeout(timeout);
}, [waitingForSecondChord]);
const combo = normalizeCombo(recording.chords.join(' '));
const conflicts = React.useMemo(
() => action && combo ? getShortcutBindingConflicts(action.id, combo, overrides) : [],
[action, combo, overrides],
);
const protectedConflict = conflicts.find((conflict) => (
!conflict.action.customizable && conflict.kind !== 'contextual-prefix'
));
const customizableConflicts = conflicts.filter(isCustomizableConflict);
const prefixConflict = customizableConflicts.find((conflict) => conflict.kind === 'prefix');
const exactConflict = customizableConflicts.find((conflict) => conflict.kind === 'exact');
const contextualPrefixConflict = conflicts.find((conflict) => conflict.kind === 'contextual-prefix');
const close = () => onOpenChange(false);
const confirm = () => {
if (!recording.settled) setRecording(settleShortcutRecordingState);
if (!action || !combo || protectedConflict || prefixConflict) return;
onSave(action.id, combo, exactConflict?.action.id);
close();
};
const handleRecordingEvent = (event: React.KeyboardEvent<HTMLDivElement>, phase: 'keydown' | 'keyup') => {
event.preventDefault();
event.stopPropagation();
if (phase === 'keyup' && action?.id === 'switch_context_surface' && recording.chords.length === 0) {
const modifierCombo = modifierKeyUpToCombo(event);
if (modifierCombo) {
setRecording({ chords: [modifierCombo], livePreview: null, settled: true });
return;
}
}
const nextRecording = updateShortcutRecordingState(recording, {
altKey: event.altKey,
ctrlKey: event.ctrlKey,
isComposing: event.nativeEvent.isComposing,
key: event.key,
metaKey: event.metaKey,
repeat: event.repeat,
shiftKey: event.shiftKey,
}, phase);
setRecording(action?.id === 'switch_context_surface' && nextRecording.chords.length > 1
? recording
: nextRecording);
};
return (
<Dialog
open={action !== null}
onOpenChange={(open, eventDetails) => {
if (!open) {
eventDetails.cancel();
}
}}
>
<DialogContent className="max-w-md" initialFocus={recordingRef} showCloseButton={false}>
<DialogHeader>
<DialogTitle>
{action ? t('settings.openchamber.keyboardShortcuts.dialog.title', { action: actionLabel(action) }) : ''}
</DialogTitle>
<DialogDescription>{t('settings.openchamber.keyboardShortcuts.dialog.instructions')}</DialogDescription>
</DialogHeader>
<div
className="flex min-h-28 items-center justify-center rounded-lg border border-border bg-[var(--surface-elevated)] px-4 py-5 text-center outline-none focus-visible:ring-2 focus-visible:ring-ring"
tabIndex={0}
ref={recordingRef}
onKeyDown={(event) => handleRecordingEvent(event, 'keydown')}
onKeyUp={(event) => handleRecordingEvent(event, 'keyup')}
onBlur={() => setRecording((current) => ({ ...current, livePreview: null }))}
>
<div className="flex flex-wrap items-center justify-center gap-2">
{recording.chords.map((chord, index) => (
<kbd key={`${chord}-${index}`} className="rounded-md border border-border bg-muted px-3 py-2 typography-ui-label font-mono text-foreground">
{formatShortcutForDisplay(chord)}
</kbd>
))}
{recording.livePreview ? (
<kbd className="rounded-md border border-dashed border-border bg-muted px-3 py-2 typography-ui-label font-mono text-muted-foreground">
{formatShortcutForDisplay(recording.livePreview)}
</kbd>
) : null}
{recording.chords.length === 0 && !recording.livePreview ? (
<span className="typography-ui-label text-muted-foreground">
{t('settings.openchamber.keyboardShortcuts.dialog.recording')}
</span>
) : null}
</div>
</div>
{recording.settled && protectedConflict ? (
<p className="typography-meta text-[var(--status-error)]">
{t('settings.openchamber.keyboardShortcuts.error.internalConflict')}
</p>
) : recording.settled && prefixConflict ? (
<p className="typography-meta text-[var(--status-error)]">
{t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })}
</p>
) : null}
{recording.settled && exactConflict && !protectedConflict && !prefixConflict ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })}
</p>
) : null}
{recording.settled && contextualPrefixConflict && !protectedConflict && !prefixConflict ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.warning.contextualPrefix', {
action: conflictActionLabel(contextualPrefixConflict),
})}
</p>
) : null}
{recording.settled && combo && isRiskyBrowserShortcut(combo) ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="ghost" size="sm" onClick={close}>
{t('settings.common.actions.cancel')}
</Button>
<Button
type="button"
size="sm"
disabled={!combo || (recording.settled && (Boolean(protectedConflict) || Boolean(prefixConflict)))}
onClick={confirm}
>
{t('settings.openchamber.keyboardShortcuts.actions.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+72 -43
View File
@@ -11,17 +11,17 @@ import { useUIStore } from "@/stores/useUIStore";
import {
getEffectiveShortcutCombo,
getShortcutAction,
getModifierLabel,
formatShortcutForDisplay,
type ShortcutActionId,
} from "@/lib/shortcuts";
import { useI18n, type I18nKey } from "@/lib/i18n";
import { isVSCodeRuntime } from "@/lib/desktop";
import type { IconName } from "@/components/icon/icons";
type ShortcutItem = {
id?: string;
id?: ShortcutActionId;
keys: string | string[];
descriptionKey: I18nKey;
descriptionKey?: I18nKey;
icon: IconName | null;
};
@@ -30,9 +30,12 @@ type ShortcutSection = {
items: ShortcutItem[];
};
const renderShortcut = (id: string, fallbackCombo: string, overrides: Record<string, string>) => {
const action = getShortcutAction(id);
return action ? formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides)) : fallbackCombo;
const renderShortcut = (
id: ShortcutActionId,
overrides: Record<string, string>,
unassignedLabel: string,
) => {
return formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides), unassignedLabel);
};
export const HelpDialog: React.FC = () => {
@@ -40,7 +43,6 @@ export const HelpDialog: React.FC = () => {
const isHelpDialogOpen = useUIStore((state) => state.isHelpDialogOpen);
const setHelpDialogOpen = useUIStore((state) => state.setHelpDialogOpen);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const mod = getModifierLabel();
const isVSCode = isVSCodeRuntime();
const shortcuts: ShortcutSection[] = [
@@ -100,7 +102,7 @@ export const HelpDialog: React.FC = () => {
keys: '',
},
{
keys: [`Shift + Alt + ${mod} + N`],
keys: [formatShortcutForDisplay('mod+shift+alt+n')],
descriptionKey: "helpDialog.item.newWindow",
icon: "window",
},
@@ -121,6 +123,21 @@ export const HelpDialog: React.FC = () => {
icon: "git-branch",
keys: '',
},
{
id: 'open_draft_project_picker',
icon: 'folder',
keys: '',
},
{
id: 'open_draft_worktree_picker',
icon: 'git-branch',
keys: '',
},
{
id: 'open_session_list',
icon: 'list-unordered',
keys: '',
},
{ id: 'focus_input', descriptionKey: "helpDialog.item.focusChatInput", icon: "text", keys: '' },
{
id: 'toggle_prompt_navigator',
@@ -176,7 +193,7 @@ export const HelpDialog: React.FC = () => {
keys: '',
},
{
keys: [`${mod} + 1...0`],
keys: [`${formatShortcutForDisplay('mod')} + 1...0`],
descriptionKey: "helpDialog.item.switchContextSurface",
icon: "layout-right",
},
@@ -214,7 +231,7 @@ export const HelpDialog: React.FC = () => {
];
return (
<Dialog open={isHelpDialogOpen} onOpenChange={setHelpDialogOpen}>
<Dialog open={isHelpDialogOpen} onOpenChange={setHelpDialogOpen}>
<DialogContent className="max-w-2xl w-[min(42rem,calc(100vw-1.5rem))] max-h-[calc(100dvh-2rem)] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@@ -237,40 +254,48 @@ export const HelpDialog: React.FC = () => {
{section.items
.filter((shortcut) => !(isVSCode && shortcut.id === 'toggle_prompt_navigator'))
.map((shortcut) => {
const displayKeys = shortcut.id
? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides)
: (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / "));
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,
shortcutOverrides,
t('settings.openchamber.keyboardShortcuts.unassigned'),
)
: (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / "));
return (
<div
key={shortcut.id || shortcut.descriptionKey}
className="flex items-center justify-between py-1 px-2"
>
<div className="flex items-center gap-2">
{shortcut.icon && (
<Icon name={shortcut.icon} className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="typography-meta">
{t(shortcut.descriptionKey)}
</span>
return (
<div
key={shortcut.id || descriptionKey}
className="flex items-center justify-between py-1 px-2"
>
<div className="flex items-center gap-2">
{shortcut.icon && (
<Icon name={shortcut.icon} className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="typography-meta">
{t(descriptionKey)}
</span>
</div>
<div className="flex items-center gap-1">
{(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => (
<React.Fragment key={`${keyCombo}-${i}`}>
{i > 0 && (
<span className="typography-meta text-muted-foreground mx-1">
{t('helpDialog.keyCombiner.or')}
</span>
)}
<kbd className="inline-flex items-center gap-1 px-1.5 py-0.5 typography-meta font-mono bg-muted rounded border border-border/20">
{keyCombo}
</kbd>
</React.Fragment>
))}
</div>
</div>
<div className="flex items-center gap-1">
{(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => (
<React.Fragment key={`${keyCombo}-${i}`}>
{i > 0 && (
<span className="typography-meta text-muted-foreground mx-1">
{t('helpDialog.keyCombiner.or')}
</span>
)}
<kbd className="inline-flex items-center gap-1 px-1.5 py-0.5 typography-meta font-mono bg-muted rounded border border-border/20">
{keyCombo}
</kbd>
</React.Fragment>
))}
</div>
</div>
);
})}
);
})}
</div>
</div>
))}
@@ -284,7 +309,11 @@ export const HelpDialog: React.FC = () => {
<ul className="space-y-0.5 typography-meta">
<li>
{t('helpDialog.proTips.commandPalette', {
shortcut: renderShortcut('open_command_palette', `${mod} P`, shortcutOverrides),
shortcut: renderShortcut(
'open_command_palette',
shortcutOverrides,
t('settings.openchamber.keyboardShortcuts.unassigned'),
),
})}
</li>
<li>
@@ -0,0 +1,45 @@
import type React from 'react';
import { isIMECompositionEvent } from '@/lib/ime';
export function getDropdownNavigationKey(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): 'ArrowDown' | 'ArrowUp' | null {
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
if (event.key.toLowerCase() === 'n') return 'ArrowDown';
if (event.key.toLowerCase() === 'p') return 'ArrowUp';
return null;
}
type DropdownNavigationEvent = Pick<
React.KeyboardEvent<HTMLElement>,
| 'altKey'
| 'ctrlKey'
| 'defaultPrevented'
| 'isPropagationStopped'
| 'key'
| 'metaKey'
| 'preventDefault'
| 'shiftKey'
| 'stopPropagation'
>;
export function handleDropdownNavigationKey(
event: DropdownNavigationEvent,
navigate: (key: 'ArrowDown' | 'ArrowUp') => void,
): boolean {
if (event.defaultPrevented || event.isPropagationStopped()) return false;
const navigationKey = getDropdownNavigationKey(event);
if (!navigationKey) return false;
// Do not add an IME guard: exact Ctrl+N/P remain intentional commands, while
// every other composing key falls through without being handled.
navigate(navigationKey);
event.preventDefault();
event.stopPropagation();
return true;
}
export function shouldDismissDropdown(
event: KeyboardEvent | React.KeyboardEvent,
): boolean {
return event.key === 'Escape' && !isIMECompositionEvent(event);
}