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
@@ -0,0 +1,65 @@
# Registration boundary
Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both accept only action IDs derived from `SHORTCUT_SCHEMA`. Batch registration also rejects undeclared keys in prebuilt objects, including objects that mix valid and misspelled IDs. Both hooks use 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 `config.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
`config.ts` is the declaration-only source for application commands. It organizes entries into `session`, `models`, `panels`, `navigation`, and `application` groups, then explicitly concatenates them into `SHORTCUT_SCHEMA`. Every entry declares an ID, default binding, and whether users can customize it. Customizable entries also declare their Settings translation key, so Settings must not maintain an action-ID switch or English fallback labels.
Configuration must not contain lookup functions, override resolution, event matching, registry state, or runtime handlers. Those concerns belong to the owning modules below. Keeping configuration declarative makes the complete shortcut inventory reviewable without reading execution code.
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
- `index.ts` is the only public import surface, exposed as `@/lib/shortcuts`.
- `config.ts` owns grouped declarations and the final `SHORTCUT_SCHEMA`.
- `schema.ts` derives action and category types and provides schema lookup 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 and stack-safe temporary suspension of all application handlers.
- `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.
# Binding rules
Bindings remain persisted as `Record<string, string>`. Each binding has one chord or at most two space-separated chords, such as `mod+s p`. `mod` is the platform-neutral primary modifier (Command on macOS, Control elsewhere), while `alt` is the platform-neutral alternate modifier (Option on macOS, Alt elsewhere); `command`, `cmd`, `meta`, and `option` are accepted input aliases but normalize to those canonical tokens. `normalizeCombo`, `parseShortcut`, `formatShortcutForDisplay`, and `getShortcutConflict` provide the shared parsing and validation behavior. Display formatting uses macOS keyboard symbols (`⌘`, `⌥`, `⌃`, `⇧`) on macOS and named modifiers (`Ctrl`, `Alt`, `Shift`) elsewhere, including tooltip and accessible text consumers. A single chord conflicts with a sequence sharing its first chord; sibling sequences are valid.
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.
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 captures up to two chords with at most three simultaneous physical keys per chord and checks the complete schema, not only customizable actions. After the first chord it waits up to 3000ms for a second; conflict and browser-risk feedback appears only when the second chord, timeout, or Confirm settles the recording. It keeps the recording local until the user clicks Confirm, allows an exact customizable conflict to replace the previous assignment, and blocks prefix conflicts unless the single-chord action explicitly allows sequence fallback. Those contextual prefixes remain saveable with a warning because their handler yields outside its owning context. Internal bindings are authoritative: persisted overrides cannot change or unassign them, and recorder conflicts with them cannot be replaced.
`add_selection_to_chat` is contextual. A visible text-selection toolbar publishes its Add to chat and dismiss actions, suspends the shared application registry, and clears both synchronously when hidden or unmounted. The main application route also gates directly on active toolbar ownership before global dispatch, so unrelated shortcuts cannot escape the scoped interaction even if runtime bundling isolates registry state. The newest visible toolbar owns a dedicated scoped dispatcher; it ignores IME composition, stops IME Escape before the global Escape route without preventing its native default, handles non-IME Escape and the configured Add to chat binding (including a two-chord binding), and lets native input continue for unrelated keys. The application handler returns `false` when no toolbar action is active, so an unselected or stale DOM range can instead become a sequence leader. Opening, closing, or replacing a toolbar invalidates any pending scoped or global prefix.
# Dispatching
`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 3000ms. The application keydown route clears that prefix on window blur and consumes Escape only when it cancels a prefix. A handler returns `false` to leave the completed binding unconsumed. When a sequence prefix is active, only its second key is dispatched during window capture so local input handlers cannot block it; an exact second key remains eligible during IME composition and is prevented when handled, while an IME mismatch clears the prefix and retains normal composition input. Normal application shortcuts remain window-bubble listeners.
`shortcutRegistry.suspend()` disables all application handlers and returns an idempotent cleanup. Suspensions nest; handlers resume only after the final cleanup. Starting or ending a suspension invalidates every pending global dispatcher prefix, so stale second keys and Escape cannot consume it. Interaction surfaces that need shortcuts while suspended must own a dedicated scoped dispatcher and process it before the global route.
Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGlobalShortcuts`; they suspend while open for both controlled and uncontrolled popups and resume on close or unmount. Exact `Ctrl+N` and `Ctrl+P` chords are translated to menu navigation even when the native event reports IME composition; no other composing key is intercepted. Window capture stops an IME Escape before Base UI's document-level dismiss listener without preventing the native IME action. Controlled draft project and worktree pickers close on non-IME Escape from either the trigger or portaled popup.
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. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording.
# Adding shortcuts
1. Add the command to the matching group in `config.ts`. Use a stable action ID and a normalized default binding. Keep sequences to at most two chords.
2. Mark the command `customizable: true` only when it should appear in Settings. Add its `settingsLabelKey` and provide that key in every locale in the same change.
3. Register the handler with `useKeybind` or `useKeybinds` near the state or UI that owns the behavior. Do not pass shortcut callbacks through unrelated components or move local UI state into a global store.
4. Return `false` when the mounted handler is not applicable in the current runtime or focus context. This lets another command sharing the binding or prefix continue dispatching.
5. Add or update schema, binding, registry, or dispatcher tests for the changed contract. Update Help Dialog metadata when the command should be discoverable there.
# Best practices
- Import production APIs only from `@/lib/shortcuts`; deep imports are reserved for files and tests inside this module.
- Keep `config.ts` declarative and grouped. Do not add helpers there for querying state or executing behavior.
- Every application command must appear exactly once in `SHORTCUT_SCHEMA`, including internal and debug commands. Component-only editing and navigation keys stay local and out of the schema.
- Avoid exact default-binding conflicts. When runtime-exclusive commands intentionally share one, document the reason beside both declarations and make each handler return `false` outside its runtime.
- Persist bindings as normalized strings. Never change the `Record<string, string>` override contract without an explicit migration and compatibility tests.
- Preserve the two-chord maximum in configuration, recording UI, parsing, conflict detection, display, and tests.
@@ -0,0 +1,128 @@
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);
});
});
describe('platform shortcut labels', () => {
test('normalizes Command and Option to platform-neutral modifiers', () => {
expect(normalizeCombo('command+option+n')).toBe('mod+alt+n');
});
test('uses macOS modifier symbols', () => {
expect(formatShortcutForDisplay('mod+ctrl+shift+alt+n', 'Unassigned', 'macos')).toBe(
'⌘ + ⌃ + ⇧ + ⌥ + N',
);
expect(formatShortcutForDisplay('alt', 'Unassigned', 'macos')).toBe('⌥');
});
test('uses named modifiers on other platforms', () => {
expect(formatShortcutForDisplay('mod+shift+alt+n', 'Unassigned', 'other')).toBe(
'Ctrl + Shift + Alt + N',
);
expect(formatShortcutForDisplay('alt', 'Unassigned', 'other')).toBe('Alt');
});
});
+335
View File
@@ -0,0 +1,335 @@
import type React from 'react';
import { isDesktopShell } from '@/lib/desktop';
import { isMacOS } from '@/lib/utils';
type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'ctrl';
type ShortcutDisplayPlatform = 'macos' | 'other';
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 MODIFIER_LABELS: Record<ShortcutDisplayPlatform, Record<ShortcutModifier, string>> = {
macos: {
mod: '⌘',
shift: '⇧',
alt: '⌥',
ctrl: '⌃',
},
other: {
mod: 'Ctrl',
shift: 'Shift',
alt: 'Alt',
ctrl: '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', 'q', 'd', 'h', 'j', 'o', 'u']);
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 };
}),
};
}
function getShortcutDisplayPlatform(): ShortcutDisplayPlatform {
return isMacOS() ? 'macos' : 'other';
}
export function formatShortcutForDisplay(
combo: ShortcutCombo,
unassignedLabel = 'Unassigned',
platform = getShortcutDisplayPlatform(),
): 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((chord) => formatChordForDisplay(chord, platform)).join(', ');
}
function formatChordForDisplay(
parsed: ParsedShortcutChord,
platform: ShortcutDisplayPlatform,
): string {
const modifierLabels = MODIFIER_LABELS[platform];
const parts = MODIFIER_PRIORITY
.filter((modifier) => parsed.modifiers.has(modifier))
.map((modifier) => modifierLabels[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;
// Every chord counts: a second chord like "mod+w" is just as capable of
// closing the tab as a first one, and mod+shift+w closes a window.
return parsed.chords.some((chord) => {
if (!chord.modifiers.has('mod')) return false;
if (chord.modifiers.has('alt')) return false;
if (chord.modifiers.has('shift')) {
return chord.key.toLowerCase() === 'w' || chord.key.toLowerCase() === 'q';
}
return RISKY_BROWSER_SHORTCUT_KEYS.has(chord.key.toLowerCase());
});
}
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()));
}
+286
View File
@@ -0,0 +1,286 @@
import type { ShortcutCombo } from './bindings';
type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application';
type ShortcutConfig = {
id: string;
defaultBinding: ShortcutCombo;
allowsSequenceFallback?: true;
/** The binding is a bare-modifier chord prefix (completed by another key);
conflict resolution compares its prefix rather than a full combo. */
prefixStyle?: true;
} & (
| { customizable: false }
| {
customizable: true;
settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${string}.label`;
}
);
const SHORTCUT_GROUPS = {
session: [
{
id: 'add_selection_to_chat',
defaultBinding: 'mod+l',
allowsSequenceFallback: true,
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label',
},
{
id: 'focus_input',
defaultBinding: 'mod+i',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.focus_input.label',
},
{
id: 'open_timeline_dialog',
defaultBinding: 'mod+t',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label',
},
{
id: 'new_chat',
defaultBinding: 'mod+n',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.new_chat.label',
},
{
id: 'close_session_tab',
defaultBinding: 'alt+w',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label',
},
{
id: 'open_draft_project_picker',
defaultBinding: 'mod+s p',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label',
},
{
id: 'open_draft_worktree_picker',
defaultBinding: 'mod+s g',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label',
},
{
id: 'open_session_list',
defaultBinding: 'mod+s l',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_session_list.label',
},
{
id: 'new_chat_worktree',
defaultBinding: 'mod+shift+n',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label',
},
{
id: 'new_mini_chat',
defaultBinding: 'mod+alt+n',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label',
},
{
id: 'expand_input',
defaultBinding: 'mod+shift+e',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.expand_input.label',
},
{
id: 'toggle_dictation',
defaultBinding: 'mod+alt+v',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label',
},
{ id: 'abort_run', defaultBinding: 'escape', customizable: false },
],
models: [
{
id: 'open_model_selector',
defaultBinding: 'mod+shift+m',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_model_selector.label',
},
{ id: 'cycle_thinking_variant', defaultBinding: 'mod+shift+t', customizable: false },
{
id: 'cycle_agent',
defaultBinding: 'tab',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label',
},
{
id: 'cycle_favorite_model_forward',
defaultBinding: 'ctrl+]',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label',
},
{
id: 'cycle_favorite_model_backward',
defaultBinding: 'ctrl+[',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label',
},
],
panels: [
{
id: 'toggle_terminal',
defaultBinding: 'mod+j',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label',
},
{
id: 'toggle_terminal_expanded',
defaultBinding: 'mod+shift+j',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label',
},
{
id: 'toggle_sidebar',
defaultBinding: 'mod+alt+l',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label',
},
{
id: 'toggle_prompt_navigator',
defaultBinding: 'mod+alt+p',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label',
},
{
id: 'toggle_right_sidebar',
defaultBinding: 'mod+b',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label',
},
{
id: 'open_right_sidebar_git',
defaultBinding: 'mod+shift+g',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label',
},
{
id: 'open_right_sidebar_files',
defaultBinding: 'mod+shift+f',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label',
},
{
id: 'switch_context_surface',
defaultBinding: 'mod',
// The binding is a bare modifier acting as a chord prefix (completed by
// a digit); conflict resolution must compare its PREFIX, not a combo.
prefixStyle: true,
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label',
},
{
id: 'toggle_context_plan',
defaultBinding: 'mod+shift+p',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label',
},
{
id: 'toggle_services_menu',
defaultBinding: 'mod+shift+s',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label',
},
],
navigation: [
{ id: 'save_file', defaultBinding: 'mod+s', customizable: false, allowsSequenceFallback: true },
{ id: 'find_in_file', defaultBinding: 'mod+f', customizable: false },
{
id: 'open_go_to_line',
defaultBinding: 'alt+g',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label',
},
{
id: 'cycle_services_tab',
defaultBinding: 'mod+shift+[',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label',
},
{ id: 'switch_tab_1', defaultBinding: 'mod+1', customizable: false },
// Mobile tab shortcuts may share numeric bindings with desktop-only panel commands.
{ id: 'switch_tab_2', defaultBinding: 'mod+2', customizable: false },
{ id: 'switch_tab_3', defaultBinding: 'mod+3', customizable: false },
{ id: 'switch_tab_4', defaultBinding: 'mod+4', customizable: false },
{ id: 'switch_tab_5', defaultBinding: 'mod+5', customizable: false },
{ id: 'switch_tab_6', defaultBinding: 'mod+6', customizable: false },
{ id: 'switch_tab_7', defaultBinding: 'mod+7', customizable: false },
{ id: 'switch_tab_8', defaultBinding: 'mod+8', customizable: false },
{ id: 'switch_tab_9', defaultBinding: 'mod+9', customizable: false },
],
application: [
{
id: 'open_command_palette',
defaultBinding: 'mod+p',
customizable: true,
settingsLabelKey:
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label',
},
{ id: 'open_status', defaultBinding: 'mod+shift+o', customizable: false },
{
id: 'open_settings',
defaultBinding: 'mod+comma',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_settings.label',
},
{
id: 'open_help',
defaultBinding: 'mod+.',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_help.label',
},
{
id: 'cycle_theme',
defaultBinding: 'mod+/',
customizable: true,
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label',
},
{ id: 'toggle_memory_debug', defaultBinding: 'mod+shift+d', customizable: false },
],
} as const satisfies Record<ShortcutCategory, readonly ShortcutConfig[]>;
/** All application shortcuts, flattened in the same order used by Settings. */
export const SHORTCUT_SCHEMA = [
...SHORTCUT_GROUPS.session.map((shortcut) => ({
...shortcut,
category: 'session' as const,
})),
...SHORTCUT_GROUPS.models.map((shortcut) => ({
...shortcut,
category: 'models' as const,
})),
...SHORTCUT_GROUPS.panels.map((shortcut) => ({
...shortcut,
category: 'panels' as const,
})),
...SHORTCUT_GROUPS.navigation.map((shortcut) => ({
...shortcut,
category: 'navigation' as const,
})),
...SHORTCUT_GROUPS.application.map((shortcut) => ({
...shortcut,
category: 'application' as const,
})),
] as const;
@@ -0,0 +1,207 @@
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 = 2999;
expect(dispatcher.hasActivePrefix()).toBe(true);
now = 3000;
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('invalidates a prefix when shortcut suspension changes', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_command_palette', () => { calls.push('sequence'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
expect(dispatcher.dispatch(key('g'))).toBe(true);
const resume = registry.suspend();
expect(dispatcher.hasActivePrefix()).toBe(false);
expect(dispatcher.handleEscape()).toBe(false);
resume();
expect(dispatcher.dispatch(key('h'))).toBe(false);
expect(calls).toEqual([]);
});
test('marks a second key dispatched from capture so bubble does not dispatch it again', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_command_palette', () => { calls.push('sequence'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
const secondKey = key('h');
dispatcher.dispatch(key('g'));
expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true);
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true);
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(false);
expect(calls).toEqual(['sequence']);
});
test('consumes a matching captured prefix key during IME composition', () => {
for (const compositionState of [{ isComposing: true }, { keyCode: 229 }]) {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_session_list', () => { calls.push('sequence'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'mod+s l' });
const secondKey = key('l', compositionState);
expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true);
expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true);
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true);
expect(calls).toEqual(['sequence']);
}
});
test('clears an active prefix but preserves an unmatched IME key', () => {
const registry = new ShortcutRegistry();
const calls: string[] = [];
registry.register('open_session_list', () => { calls.push('sequence'); });
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'mod+s l' });
const secondKey = key('x', { isComposing: true });
dispatcher.dispatch(key('s', { ctrlKey: true }));
expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(false);
expect(dispatcher.hasActivePrefix()).toBe(false);
expect(calls).toEqual([]);
});
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']);
});
});
+170
View File
@@ -0,0 +1,170 @@
import {
eventMatchesShortcut,
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
type ShortcutCombo,
} from './bindings';
import { type ShortcutHandler, ShortcutRegistry } from './registry';
import type { ShortcutActionId } from './schema';
import { isIMECompositionEvent } from '../ime';
const SEQUENCE_TIMEOUT_MS = 3000;
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;
// The target the leader chord was pressed on. DOM-agnostic (opaque
// EventTarget): callers use it to decide whether an unmodified completion
// key arriving from an EDITABLE target is a deliberate sequence (same
// target as the arming press) or typing that must not be swallowed.
private prefixTarget: EventTarget | null = null;
private expiresAt = 0;
private prefixSuspensionVersion = 0;
private readonly capturedPrefixEvents = new WeakSet<KeyboardEvent>();
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 || isIMECompositionEvent(event) || MODIFIER_KEYS.has(event.key.toLowerCase())) {
return false;
}
if (event.key === 'Escape' && this.hasActivePrefix()) {
return this.handleEscape();
}
this.hasActivePrefix();
const matches = this.getMatches();
if (this.prefix) {
const pending = this.getPrefixMatches(matches, event);
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.prefixTarget = event.target;
this.expiresAt = this.now() + this.timeoutMs;
this.prefixSuspensionVersion = this.options.registry.getSuspensionVersion();
return true;
}
return false;
}
clear(): void {
this.prefix = undefined;
this.prefixTarget = null;
this.expiresAt = 0;
this.prefixSuspensionVersion = 0;
}
getActivePrefixTarget(): EventTarget | null {
return this.hasActivePrefix() ? this.prefixTarget : null;
}
handleBlur(): void {
this.clear();
}
handleEscape(): boolean {
const hadPrefix = this.hasActivePrefix();
this.clear();
return hadPrefix;
}
hasActivePrefix(): boolean {
if (!this.prefix) return false;
if (
this.now() >= this.expiresAt
|| this.prefixSuspensionVersion !== this.options.registry.getSuspensionVersion()
) {
this.clear();
return false;
}
return true;
}
dispatchActivePrefix(event: KeyboardEvent): boolean {
this.capturedPrefixEvents.add(event);
if (isIMECompositionEvent(event)) {
if (event.repeat || MODIFIER_KEYS.has(event.key.toLowerCase()) || !this.hasActivePrefix()) {
return false;
}
const pending = this.getPrefixMatches(this.getMatches(), event);
this.clear();
return pending.length > 0 ? this.invoke(pending, event) : false;
}
return this.dispatch(event);
}
consumeCapturedPrefixEvent(event: KeyboardEvent): boolean {
if (!this.capturedPrefixEvents.has(event)) return false;
this.capturedPrefixEvents.delete(event);
return true;
}
private invoke(matches: BindingMatch[], event: KeyboardEvent): boolean {
for (const match of matches) {
if (match.handler(event) !== false) {
return true;
}
}
return false;
}
private getPrefixMatches(matches: BindingMatch[], event: KeyboardEvent): BindingMatch[] {
return matches.filter((match) => (
match.chords.length === 2
&& match.chords[0] === this.prefix
&& eventMatchesShortcut(event, match.chords[1])
));
}
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;
}
}
+30
View File
@@ -0,0 +1,30 @@
export {
eventMatchesShortcut,
eventMatchesShortcutPrefix,
formatShortcutForDisplay,
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,
getShortcutBindingConflicts,
getEffectiveShortcutCombo,
getEffectiveShortcutPrefix,
getShortcutAction,
SHORTCUT_SCHEMA,
} from './schema';
export type {
CustomizableShortcutAction,
ShortcutBindingConflict,
ShortcutActionId,
ShortcutCategory,
} from './schema';
@@ -0,0 +1,46 @@
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);
});
test('suspends all handlers until every idempotent cleanup completes', () => {
const registry = new ShortcutRegistry();
const handler = () => undefined;
registry.register('open_settings', handler);
const resumeFirst = registry.suspend();
const resumeSecond = registry.suspend();
expect(registry.get('open_settings')).toBe(undefined);
expect(registry.isSuspended()).toBe(true);
resumeFirst();
resumeFirst();
expect(registry.get('open_settings')).toBe(undefined);
resumeSecond();
resumeSecond();
expect(registry.get('open_settings')).toBe(handler);
expect(registry.isSuspended()).toBe(false);
});
+71
View File
@@ -0,0 +1,71 @@
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[]>();
private suspensionCount = 0;
private suspensionVersion = 0;
register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void {
const registration = { handler };
const registered = this.handlers.get(actionId) ?? [];
if (registered.length > 0 && typeof console !== 'undefined' && import.meta.env?.DEV) {
// First registration wins at dispatch; a silent second registration is
// almost always two components fighting over one action.
console.warn(`[shortcuts] duplicate handler registration for "${actionId}" — only the first will dispatch`);
}
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 {
if (this.suspensionCount > 0) return undefined;
return this.handlers.get(actionId)?.[0]?.handler;
}
/** Temporarily disables every registered application shortcut. */
suspend(): () => void {
this.suspensionCount += 1;
this.suspensionVersion += 1;
let active = true;
return () => {
if (!active) return;
active = false;
this.suspensionCount -= 1;
if (this.suspensionCount === 0) {
this.suspensionVersion += 1;
}
};
}
getSuspensionVersion(): number {
return this.suspensionVersion;
}
isSuspended(): boolean {
return this.suspensionCount > 0;
}
actionIds(): IterableIterator<ShortcutActionId> {
return this.handlers.keys();
}
}
/** Shared registry for application commands registered by React surfaces. */
export const shortcutRegistry = new ShortcutRegistry();
@@ -0,0 +1,129 @@
import { describe, expect, test } from 'bun:test';
import {
getCustomizableShortcutActions,
getEffectiveShortcutCombo,
getShortcutBindingConflicts,
getShortcutAction,
parseShortcut,
SHORTCUT_SCHEMA,
type ShortcutCategory,
} from './index';
describe('shortcut schema', () => {
test('declares unique IDs and valid bindings for every application shortcut', () => {
const ids = SHORTCUT_SCHEMA.map((action) => action.id);
const hasValidMetadata = SHORTCUT_SCHEMA.every((action) => {
const chordCount = parseShortcut(action.defaultBinding)?.chords.length;
return Boolean(action.category)
&& chordCount !== undefined
&& chordCount >= 1
&& chordCount <= 2;
});
expect(new Set(ids).size).toBe(ids.length);
expect(hasValidMetadata).toBe(true);
});
test('keeps the flattened schema grouped in Settings order', () => {
const groupOrder: ShortcutCategory[] = [];
for (const action of SHORTCUT_SCHEMA) {
if (groupOrder.at(-1) !== action.category) {
groupOrder.push(action.category);
}
}
expect(groupOrder).toEqual([
'session',
'models',
'panels',
'navigation',
'application',
]);
});
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 session prefix bindings and 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('open_session_list')?.defaultBinding).toBe('mod+s l');
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');
});
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');
const contextualPrefixConflict = getShortcutBindingConflicts('focus_input', 'mod+l l')
.find((conflict) => conflict.action.id === 'add_selection_to_chat');
const contextualLeaderConflict = getShortcutBindingConflicts('add_selection_to_chat', 'mod+s')
.find((conflict) => conflict.action.id === 'open_draft_project_picker');
const blockingPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+p x')
.find((conflict) => conflict.action.id === 'open_command_palette');
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('contextual-prefix');
expect(internalPrefixConflict?.action.customizable).toBe(false);
expect(contextualPrefixConflict?.kind).toBe('contextual-prefix');
expect(contextualLeaderConflict?.kind).toBe('contextual-prefix');
expect(blockingPrefixConflict?.kind).toBe('prefix');
});
});
describe('shortcut defaults', () => {
// Two actions silently sharing a default binding would race at dispatch
// (registry insertion order decides). Pairs that intentionally share a
// combo because they can never be active in the same runtime must be
// whitelisted here explicitly.
const RUNTIME_EXCLUSIVE_BINDING_PAIRS: ReadonlyArray<ReadonlySet<string>> = [];
test('no two actions share a normalized default binding', () => {
const byBinding = new Map<string, string[]>();
for (const action of SHORTCUT_SCHEMA) {
const combo = getEffectiveShortcutCombo(action.id);
if (!combo) continue;
const list = byBinding.get(combo) ?? [];
list.push(action.id);
byBinding.set(combo, list);
}
for (const [combo, ids] of byBinding) {
if (ids.length <= 1) continue;
const whitelisted = RUNTIME_EXCLUSIVE_BINDING_PAIRS.some(
(pair) => ids.every((id) => pair.has(id)),
);
expect(whitelisted, `default binding "${combo}" shared by ${ids.join(', ')}`).toBe(true);
}
});
test('overrides recorded under the flat-file era still resolve', () => {
// The persisted override format is a flat Record<string, string> and
// must keep resolving through the schema after the module split.
const overrides = { close_session_tab: 'alt+q', open_command_palette: 'mod+shift+k' };
expect(getEffectiveShortcutCombo('close_session_tab', overrides)).toBe('alt+q');
expect(getEffectiveShortcutCombo('open_command_palette', overrides)).toBe('mod+shift+k');
// Unknown ids stay inert rather than throwing.
expect(getEffectiveShortcutCombo('close_session_tab', { ghost_action: 'mod+z', close_session_tab: 'alt+q' } as Record<string, string>)).toBe('alt+q');
});
});
+111
View File
@@ -0,0 +1,111 @@
import {
getShortcutConflict,
isValidShortcutCombo,
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
type ShortcutCombo,
type ShortcutConflict,
} from './bindings';
import { SHORTCUT_SCHEMA } from './config';
export { SHORTCUT_SCHEMA } from './config';
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 ShortcutBindingConflictKind = ShortcutConflict | 'contextual-prefix';
export type ShortcutBindingConflict = {
action: ShortcutAction;
kind: ShortcutBindingConflictKind;
};
function allowsContextualPrefix(
action: ShortcutAction,
combo: ShortcutCombo,
candidate: ShortcutAction,
candidateCombo: ShortcutCombo,
): boolean {
const chordCount = parseShortcut(combo)?.chords.length;
const candidateChordCount = parseShortcut(candidateCombo)?.chords.length;
if (chordCount === 1 && candidateChordCount === 2) {
return 'allowsSequenceFallback' in action && action.allowsSequenceFallback;
}
if (chordCount === 2 && candidateChordCount === 1) {
return 'allowsSequenceFallback' in candidate && candidate.allowsSequenceFallback;
}
return false;
}
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 '';
if (!action.customizable) return action.defaultBinding;
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 '';
if (!action.customizable) return action.defaultBinding;
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;
}
export function getShortcutBindingConflicts(
actionId: ShortcutActionId,
combo: ShortcutCombo,
overrides?: Record<string, ShortcutCombo>,
): ShortcutBindingConflict[] {
const conflicts: ShortcutBindingConflict[] = [];
const action = getShortcutAction(actionId);
if (!action) return conflicts;
for (const candidate of SHORTCUT_SCHEMA) {
if (candidate.id === actionId) continue;
const candidateCombo = ('prefixStyle' in candidate && candidate.prefixStyle)
? getEffectiveShortcutPrefix(candidate.id, overrides)
: getEffectiveShortcutCombo(candidate.id, overrides);
const kind = getShortcutConflict(combo, candidateCombo);
if (!kind) continue;
conflicts.push({
action: candidate,
kind: kind === 'prefix' && allowsContextualPrefix(action, combo, candidate, candidateCombo)
? 'contextual-prefix'
: kind,
});
}
return conflicts;
}