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
+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
@@ -0,0 +1,108 @@
import { describe, expect, test } from 'bun:test';
import {
eventMatchesShortcutPrefix,
formatShortcutForDisplay,
getEffectiveShortcutPrefix,
getShortcutConflict,
isRiskyBrowserShortcut,
isShortcutPrefixHeld,
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
} from './index';
describe('getEffectiveShortcutPrefix', () => {
test('falls back to the action default (bare mod) when unset', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod');
});
test('honors modifier + key overrides', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'mod+p' })).toBe('mod+p');
});
test('honors modifier-only overrides', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'shift' })).toBe('shift');
});
test('returns UNASSIGNED for an explicit unassignment', () => {
expect(
getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: UNASSIGNED_SHORTCUT }),
).toBe(UNASSIGNED_SHORTCUT);
});
test('returns empty string for an unknown action', () => {
expect(getEffectiveShortcutPrefix('does_not_exist', {})).toBe('');
});
});
describe('isShortcutPrefixHeld', () => {
test('false for an unassigned prefix', () => {
expect(isShortcutPrefixHeld(UNASSIGNED_SHORTCUT, new Set(['control']))).toBe(false);
});
test('requires the prefix primary key to be held', () => {
expect(isShortcutPrefixHeld('mod+p', new Set(['control']))).toBe(false);
expect(isShortcutPrefixHeld('mod+p', new Set(['control', 'p']))).toBe(true);
});
test('requires every prefix modifier to be held', () => {
expect(isShortcutPrefixHeld('mod+shift', new Set(['control']))).toBe(false);
expect(isShortcutPrefixHeld('mod+shift', new Set(['control', 'shift']))).toBe(true);
});
});
const keydown = (key: string, mods: { meta?: boolean; ctrl?: boolean; shift?: boolean; alt?: boolean }): KeyboardEvent =>
({
key,
metaKey: mods.meta ?? false,
ctrlKey: mods.ctrl ?? false,
shiftKey: mods.shift ?? false,
altKey: mods.alt ?? false,
}) as KeyboardEvent;
describe('eventMatchesShortcutPrefix', () => {
test('matches a bare mod prefix when the primary modifier is held', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod')).toBe(true);
});
test('rejects a bare mod prefix without the primary modifier', () => {
expect(eventMatchesShortcutPrefix(keydown('1', {}), 'mod')).toBe(false);
});
test('rejects when the event carries modifiers the prefix does not expect', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true, shift: true }), 'mod')).toBe(false);
});
test('requires the prefix primary key to be held at match time', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control']))).toBe(false);
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control', 'p']))).toBe(true);
});
test('false for an unassigned prefix', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), UNASSIGNED_SHORTCUT)).toBe(false);
});
});
describe('shortcut sequences', () => {
test('normalizes, parses, and formats up to two chords', () => {
expect(normalizeCombo(' command + S P ')).toBe('mod+s p');
expect(parseShortcut('mod+s p')?.chords).toHaveLength(2);
expect(formatShortcutForDisplay('mod+s p')).toBe('Ctrl + S, P');
});
test('rejects bindings with more than two chords', () => {
expect(normalizeCombo('mod+s p q')).toBe('');
expect(parseShortcut('mod+s p q')).toBe(undefined);
});
test('reports exact and prefix conflicts but allows sibling sequences', () => {
expect(getShortcutConflict('mod+s', 'mod+s')).toBe('exact');
expect(getShortcutConflict('mod+s', 'mod+s p')).toBe('prefix');
expect(getShortcutConflict('mod+s p', 'mod+s q')).toBe(undefined);
});
test('warns when a sequence leader conflicts with a browser shortcut', () => {
expect(isRiskyBrowserShortcut('mod+s p')).toBe(true);
});
});
+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';
}
@@ -0,0 +1,148 @@
import { describe, expect, test } from 'bun:test';
import { ShortcutDispatcher } from './dispatcher';
import { ShortcutRegistry } from './registry';
function key(key: string, options: Partial<KeyboardEvent> = {}): KeyboardEvent {
return {
key,
code: `Key${key.toUpperCase()}`,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
repeat: false,
isComposing: false,
...options,
} as KeyboardEvent;
}
describe('ShortcutDispatcher', () => {
test('dispatches a sequence and consumes only leaders with active handlers', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
const unregister = registry.register('open_command_palette', (event) => {
calls.push(event.key);
});
const dispatcher = new ShortcutDispatcher({
registry,
getBinding: (id) => id === 'open_command_palette' ? 'g h' : '',
});
expect(dispatcher.dispatch(key('g'))).toBe(true);
expect(dispatcher.dispatch(key('h'))).toBe(true);
expect(calls).toEqual(['h']);
unregister();
expect(dispatcher.dispatch(key('g'))).toBe(false);
});
test('re-matches a prefix mismatch and clears on escape or blur', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_command_palette', () => { calls.push('sequence'); });
registry.register('open_help', () => { calls.push('single'); });
const dispatcher = new ShortcutDispatcher({
registry,
getBinding: (id) => id === 'open_command_palette' ? 'g h' : 'x',
});
dispatcher.dispatch(key('g'));
expect(dispatcher.dispatch(key('x'))).toBe(true);
expect(calls).toEqual(['single']);
dispatcher.dispatch(key('g'));
expect(dispatcher.dispatch(key('Escape'))).toBe(true);
expect(dispatcher.handleEscape()).toBe(false);
dispatcher.dispatch(key('g'));
dispatcher.handleBlur();
expect(dispatcher.dispatch(key('h'))).toBe(false);
});
test('expires prefixes and ignores repeats, composition, and modifier keys', () => {
let now = 0;
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_command_palette', () => { calls.push('sequence'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h', now: () => now });
expect(dispatcher.dispatch(key('g'))).toBe(true);
now = 1500;
expect(dispatcher.dispatch(key('h'))).toBe(false);
expect(dispatcher.dispatch(key('g', { repeat: true }))).toBe(false);
expect(dispatcher.dispatch(key('g', { isComposing: true }))).toBe(false);
expect(dispatcher.dispatch(key('Shift'))).toBe(false);
expect(calls).toEqual([]);
});
test('does not consume a completed binding when every handler declines it', () => {
const registry = new ShortcutRegistry();
registry.register('open_command_palette', () => false);
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
expect(dispatcher.dispatch(key('g'))).toBe(true);
expect(dispatcher.dispatch(key('h'))).toBe(false);
});
test('does not consume a single chord when its handler declines it', () => {
const registry = new ShortcutRegistry();
registry.register('open_command_palette', () => false);
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'x' });
expect(dispatcher.dispatch(key('x'))).toBe(false);
});
test('starts a sequence when a single-chord handler with the same leader declines', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('save_file', () => false);
registry.register('open_draft_project_picker', () => { calls.push('project'); });
const dispatcher = new ShortcutDispatcher({
registry,
getBinding: (id) => id === 'save_file' ? 'mod+s' : 'mod+s p',
});
expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true);
expect(dispatcher.dispatch(key('p'))).toBe(true);
expect(calls).toEqual(['project']);
});
test('does not start a sequence when a single-chord handler accepts the leader', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('save_file', () => { calls.push('save'); });
registry.register('open_draft_project_picker', () => { calls.push('project'); });
const dispatcher = new ShortcutDispatcher({
registry,
getBinding: (id) => id === 'save_file' ? 'mod+s' : 'mod+s p',
});
expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true);
expect(dispatcher.dispatch(key('p'))).toBe(false);
expect(calls).toEqual(['save']);
});
test('resolves bindings at dispatch time', () => {
const registry = new ShortcutRegistry();
let binding = 'x';
const calls: string[] = [];
registry.register('open_command_palette', (event) => { calls.push(event.key); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => binding });
expect(dispatcher.dispatch(key('x'))).toBe(true);
binding = 'y';
expect(dispatcher.dispatch(key('x'))).toBe(false);
expect(dispatcher.dispatch(key('y'))).toBe(true);
expect(calls).toEqual(['x', 'y']);
});
test('stops after the first handler that accepts a conflicting binding', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_command_palette', () => { calls.push('declined'); return false; });
registry.register('open_help', () => { calls.push('first'); });
registry.register('open_settings', () => { calls.push('second'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'x' });
expect(dispatcher.dispatch(key('x'))).toBe(true);
expect(calls).toEqual(['declined', 'first']);
});
});
+121
View File
@@ -0,0 +1,121 @@
import {
eventMatchesShortcut,
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
type ShortcutCombo,
} 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']);
export interface ShortcutDispatcherOptions {
registry: ShortcutRegistry;
getBinding: (actionId: ShortcutActionId) => ShortcutCombo;
now?: () => number;
timeoutMs?: number;
}
interface BindingMatch {
chords: string[];
handler: ShortcutHandler;
}
/** Stateless with respect to the DOM; callers decide whether a consumed event is prevented. */
export class ShortcutDispatcher {
private readonly now: () => number;
private readonly timeoutMs: number;
private prefix: string | undefined;
private expiresAt = 0;
constructor(private readonly options: ShortcutDispatcherOptions) {
this.now = options.now ?? Date.now;
this.timeoutMs = options.timeoutMs ?? SEQUENCE_TIMEOUT_MS;
}
dispatch(event: KeyboardEvent): boolean {
if (event.repeat || event.isComposing || MODIFIER_KEYS.has(event.key.toLowerCase())) {
return false;
}
if (event.key === 'Escape' && this.prefix) {
return this.handleEscape();
}
if (this.prefix && this.now() >= this.expiresAt) {
this.clear();
}
const matches = this.getMatches();
if (this.prefix) {
const pending = matches.filter((match) => (
match.chords.length === 2
&& match.chords[0] === this.prefix
&& eventMatchesShortcut(event, match.chords[1])
));
if (pending.length > 0) {
this.clear();
return this.invoke(pending, event);
}
this.clear();
}
const singles = matches.filter((match) => (
match.chords.length === 1 && eventMatchesShortcut(event, match.chords[0])
));
if (singles.length > 0 && this.invoke(singles, event)) {
return true;
}
const leader = matches.find((match) => (
match.chords.length === 2 && eventMatchesShortcut(event, match.chords[0])
));
if (leader) {
this.prefix = leader.chords[0];
this.expiresAt = this.now() + this.timeoutMs;
return true;
}
return false;
}
clear(): void {
this.prefix = undefined;
this.expiresAt = 0;
}
handleBlur(): void {
this.clear();
}
handleEscape(): boolean {
const hadPrefix = Boolean(this.prefix);
this.clear();
return hadPrefix;
}
private invoke(matches: BindingMatch[], event: KeyboardEvent): boolean {
for (const match of matches) {
if (match.handler(event) !== false) {
return true;
}
}
return false;
}
private getMatches(): BindingMatch[] {
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);
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';
@@ -0,0 +1,27 @@
import { expect, test } from 'bun:test';
import { ShortcutRegistry } from './registry';
test('the first registration wins and a later unregister cannot remove it', () => {
const registry = new ShortcutRegistry();
const firstHandler = () => undefined;
const first = registry.register('open_settings', firstHandler);
const replacement = registry.register('open_settings', () => false);
replacement();
expect(registry.get('open_settings')).toBe(firstHandler);
first();
expect(registry.get('open_settings')).toBe(undefined);
});
test('a later registration takes over after the first unregisters', () => {
const registry = new ShortcutRegistry();
const firstHandler = () => undefined;
const secondHandler = () => false;
const first = registry.register('open_settings', firstHandler);
registry.register('open_settings', secondHandler);
expect(registry.get('open_settings')).toBe(firstHandler);
first();
expect(registry.get('open_settings')).toBe(secondHandler);
});
+40
View File
@@ -0,0 +1,40 @@
import type { ShortcutActionId } from './schema';
export type ShortcutHandler = (event: KeyboardEvent) => boolean | void;
interface RegisteredHandler {
handler: ShortcutHandler;
}
/** Active application command handlers, keyed by shortcut action ID. */
export class ShortcutRegistry {
private readonly handlers = new Map<ShortcutActionId, RegisteredHandler[]>();
register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void {
const registration = { handler };
const registered = this.handlers.get(actionId) ?? [];
registered.push(registration);
this.handlers.set(actionId, registered);
return () => {
const current = this.handlers.get(actionId);
if (!current) return;
const index = current.indexOf(registration);
if (index === -1) return;
current.splice(index, 1);
if (current.length === 0) {
this.handlers.delete(actionId);
}
};
}
get(actionId: ShortcutActionId): ShortcutHandler | undefined {
return this.handlers.get(actionId)?.[0]?.handler;
}
actionIds(): IterableIterator<ShortcutActionId> {
return this.handlers.keys();
}
}
/** Shared registry for application commands registered by React surfaces. */
export const shortcutRegistry = new ShortcutRegistry();
@@ -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;
}