fix(ui): enforce shortcut conflict rules

This commit is contained in:
ChangeHow
2026-08-06 14:18:15 +08:00
parent 669f1603d4
commit 8d968f3d71
20 changed files with 184 additions and 102 deletions
@@ -29,9 +29,9 @@ 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`, `mod+s g`, and `mod+s l` to the draft target pickers and session list.
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 internal `switch_tab_*` bindings remain available to mobile handlers. Desktop numeric context-surface switching is resolved by the configurable `switch_context_surface` prefix before normal dispatcher matching and falls through on mobile.
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.
The settings recorder also stops at two chords and checks the complete schema, not only customizable actions. It keeps the recording local until the user clicks Confirm, allows an exact customizable conflict to replace the previous assignment, and blocks prefix conflicts because they make dispatch ambiguous. Internal bindings are authoritative: persisted overrides cannot change or unassign them, and recorder conflicts with them cannot be replaced.
# Dispatching
@@ -43,7 +43,7 @@ Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGloba
Terminal capture, Escape abort priming, and the shifted reverse-agent chord are input-boundary exceptions. They preserve their target-specific semantics and invoke the registered application handler rather than duplicating command behavior.
Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands.
Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording.
# Adding shortcuts
+2
View File
@@ -17,6 +17,7 @@ export { shortcutRegistry } from './registry';
export type { ShortcutHandler } from './registry';
export {
getCustomizableShortcutActions,
getShortcutBindingConflicts,
getEffectiveShortcutCombo,
getEffectiveShortcutPrefix,
getShortcutAction,
@@ -24,6 +25,7 @@ export {
} from './schema';
export type {
CustomizableShortcutAction,
ShortcutBindingConflict,
ShortcutActionId,
ShortcutCategory,
} from './schema';
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import {
getCustomizableShortcutActions,
getEffectiveShortcutCombo,
getShortcutBindingConflicts,
getShortcutAction,
parseShortcut,
SHORTCUT_SCHEMA,
@@ -59,4 +60,25 @@ describe('shortcut schema', () => {
expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k' })).toBe('mod+k');
expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k x y' })).toBe('mod+n');
});
test('keeps internal bindings authoritative over persisted overrides', () => {
expect(getEffectiveShortcutCombo('save_file', { save_file: 'mod+k' })).toBe('mod+s');
expect(getEffectiveShortcutCombo('save_file', { save_file: '__unassigned__' })).toBe('mod+s');
});
test('detects conflicts against customizable and internal bindings', () => {
const customizableConflict = getShortcutBindingConflicts('new_chat', 'mod+p')
.find((conflict) => conflict.action.id === 'open_command_palette');
const internalConflict = getShortcutBindingConflicts('new_chat', 'mod+f')
.find((conflict) => conflict.action.id === 'find_in_file');
const internalPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+s x')
.find((conflict) => conflict.action.id === 'save_file');
expect(customizableConflict?.kind).toBe('exact');
expect(customizableConflict?.action.customizable).toBe(true);
expect(internalConflict?.kind).toBe('exact');
expect(internalConflict?.action.customizable).toBe(false);
expect(internalPrefixConflict?.kind).toBe('prefix');
expect(internalPrefixConflict?.action.customizable).toBe(false);
});
});
+25
View File
@@ -1,9 +1,11 @@
import {
getShortcutConflict,
isValidShortcutCombo,
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
type ShortcutCombo,
type ShortcutConflict,
} from './bindings';
import { SHORTCUT_SCHEMA } from './config';
@@ -13,6 +15,10 @@ export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number];
export type ShortcutActionId = ShortcutAction['id'];
export type ShortcutCategory = ShortcutAction['category'];
export type CustomizableShortcutAction = Extract<ShortcutAction, { customizable: true }>;
export type ShortcutBindingConflict = {
action: ShortcutAction;
kind: ShortcutConflict;
};
export function getShortcutAction(id: string): ShortcutAction | undefined {
return SHORTCUT_SCHEMA.find((action) => action.id === id);
@@ -30,6 +36,7 @@ export function getEffectiveShortcutCombo(
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) return '';
if (!action.customizable) return action.defaultBinding;
const override = overrides?.[actionId];
if (typeof override === 'string') {
@@ -47,6 +54,7 @@ export function getEffectiveShortcutPrefix(
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) return '';
if (!action.customizable) return action.defaultBinding;
const override = overrides?.[actionId];
if (typeof override === 'string' && override.trim() !== '') {
@@ -58,3 +66,20 @@ export function getEffectiveShortcutPrefix(
return action.defaultBinding;
}
export function getShortcutBindingConflicts(
actionId: ShortcutActionId,
combo: ShortcutCombo,
overrides?: Record<string, ShortcutCombo>,
): ShortcutBindingConflict[] {
const conflicts: ShortcutBindingConflict[] = [];
for (const candidate of SHORTCUT_SCHEMA) {
if (candidate.id === actionId) continue;
const candidateCombo = candidate.id === 'switch_context_surface'
? getEffectiveShortcutPrefix(candidate.id, overrides)
: getEffectiveShortcutCombo(candidate.id, overrides);
const kind = getShortcutConflict(combo, candidateCombo);
if (kind) conflicts.push({ action: candidate, kind });
}
return conflicts;
}