diff --git a/packages/ui/src/hooks/useKeybind.ts b/packages/ui/src/hooks/useKeybind.ts new file mode 100644 index 00000000..f97fa57f --- /dev/null +++ b/packages/ui/src/hooks/useKeybind.ts @@ -0,0 +1,27 @@ +import React from 'react'; +import { shortcutRegistry, type ShortcutHandler } from '@/lib/shortcutRegistry'; +import type { ShortcutActionId } from '@/lib/shortcuts'; + +export function useKeybind(actionId: ShortcutActionId, handler: ShortcutHandler): void { + const handlerRef = React.useRef(handler); + handlerRef.current = handler; + + React.useEffect(() => shortcutRegistry.register(actionId, (event) => handlerRef.current(event)), [actionId]); +} + +export function useKeybinds( + bindings: Partial>, +): void { + const handlersRef = React.useRef(bindings); + handlersRef.current = bindings; + const actionIdsKey = Object.keys(bindings).sort().join('\0'); + + React.useEffect(() => { + const actionIds = (actionIdsKey ? actionIdsKey.split('\0') : []) as ShortcutActionId[]; + const unregister = actionIds.map((actionId) => shortcutRegistry.register(actionId, (event) => { + const handler = handlersRef.current[actionId]; + return handler ? handler(event) : false; + })); + return () => unregister.forEach((remove) => remove()); + }, [actionIdsKey]); +} diff --git a/packages/ui/src/lib/shortcutDispatcher.test.ts b/packages/ui/src/lib/shortcutDispatcher.test.ts new file mode 100644 index 00000000..9df23ef3 --- /dev/null +++ b/packages/ui/src/lib/shortcutDispatcher.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from 'bun:test'; +import { ShortcutDispatcher } from './shortcutDispatcher'; +import { ShortcutRegistry } from './shortcutRegistry'; + +function key(key: string, options: Partial = {}): 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']); + }); +}); diff --git a/packages/ui/src/lib/shortcutDispatcher.ts b/packages/ui/src/lib/shortcutDispatcher.ts new file mode 100644 index 00000000..775bc0b9 --- /dev/null +++ b/packages/ui/src/lib/shortcutDispatcher.ts @@ -0,0 +1,119 @@ +import { + eventMatchesShortcut, + normalizeCombo, + parseShortcut, + UNASSIGNED_SHORTCUT, + type ShortcutActionId, + type ShortcutCombo, +} from './shortcuts'; +import { type ShortcutHandler, ShortcutRegistry } from './shortcutRegistry'; + +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); + const binding = normalizeCombo(this.options.getBinding(actionId)); + const parsed = parseShortcut(binding); + const isDispatchable = parsed + && parsed.chords.every((chord) => chord.key && chord.key !== UNASSIGNED_SHORTCUT); + if (handler && isDispatchable) { + matches.push({ chords: binding.split(' '), handler }); + } + } + return matches; + } +} diff --git a/packages/ui/src/lib/shortcutRegistry.test.ts b/packages/ui/src/lib/shortcutRegistry.test.ts new file mode 100644 index 00000000..658b46d2 --- /dev/null +++ b/packages/ui/src/lib/shortcutRegistry.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test'; +import { ShortcutRegistry } from './shortcutRegistry'; + +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); +}); diff --git a/packages/ui/src/lib/shortcutRegistry.ts b/packages/ui/src/lib/shortcutRegistry.ts new file mode 100644 index 00000000..06c05527 --- /dev/null +++ b/packages/ui/src/lib/shortcutRegistry.ts @@ -0,0 +1,41 @@ +import type { ShortcutActionId } from './shortcuts'; + +export type ShortcutHandler = (event: KeyboardEvent) => boolean | void; + +interface RegisteredHandler { + handler: ShortcutHandler; + token: symbol; +} + +/** Active application command handlers, keyed by shortcut action ID. */ +export class ShortcutRegistry { + private readonly handlers = new Map(); + + register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void { + const token = Symbol(actionId); + const registered = this.handlers.get(actionId) ?? []; + registered.push({ handler, token }); + this.handlers.set(actionId, registered); + return () => { + const current = this.handlers.get(actionId); + if (!current) return; + const index = current.findIndex((entry) => entry.token === token); + 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 { + return this.handlers.keys(); + } +} + +/** Shared registry for application commands registered by React surfaces. */ +export const shortcutRegistry = new ShortcutRegistry(); diff --git a/packages/ui/src/lib/shortcuts.test.ts b/packages/ui/src/lib/shortcuts.test.ts index 593ea714..a97abd37 100644 --- a/packages/ui/src/lib/shortcuts.test.ts +++ b/packages/ui/src/lib/shortcuts.test.ts @@ -2,8 +2,16 @@ import { describe, expect, test } from 'bun:test'; import { eventMatchesShortcutPrefix, + formatShortcutForDisplay, getEffectiveShortcutPrefix, + getCustomizableShortcutActions, + getShortcutAction, + getShortcutCategory, + getShortcutConflict, + isRiskyBrowserShortcut, isShortcutPrefixHeld, + normalizeCombo, + parseShortcut, UNASSIGNED_SHORTCUT, } from './shortcuts'; @@ -78,3 +86,33 @@ describe('eventMatchesShortcutPrefix', () => { 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); + }); + + test('categorizes customizable actions and includes draft picker sequences', () => { + expect(getCustomizableShortcutActions().every((action) => action.category !== undefined)).toBe(true); + expect(getShortcutAction('open_draft_project_picker')?.defaultCombo).toBe('mod+s p'); + expect(getShortcutAction('open_draft_worktree_picker')?.defaultCombo).toBe('mod+s g'); + expect(getShortcutCategory(getShortcutAction('focus_input')!)).toBe('session'); + }); +}); diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index 4398aca5..18b1679a 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -4,22 +4,33 @@ import { isDesktopShell } from '@/lib/desktop'; type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl'; type ShortcutKey = string; export type ShortcutCombo = string; +export type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application'; export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__'; -export interface ShortcutAction { +interface ShortcutActionDefinition { id: string; defaultCombo: ShortcutCombo; label: string; description?: string; customizable?: boolean; + /** Metadata for shortcut browsers; omitted actions use the application category. */ + category?: ShortcutCategory; } -interface ParsedShortcut { +interface ParsedShortcutChord { modifiers: Set; key: ShortcutKey; } +export interface ParsedShortcut { + chords: ReadonlyArray; +} + +export type ShortcutConflict = 'exact' | 'prefix'; + +const DEFAULT_SHORTCUT_CATEGORY: ShortcutCategory = 'application'; + const MODIFIER_KEY_MAP: Record = { 'mod': 'mod', 'shift': 'shift', @@ -70,6 +81,7 @@ const KEY_LABEL_MAP: Record = { }; const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt']; +const RISKY_BROWSER_SHORTCUT_KEYS = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']); const SHIFTED_KEY_BASE_MAP: Record = { '{': '[', @@ -114,13 +126,26 @@ export function keyToShortcutToken(key: string): string { return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered; } -const SHORTCUT_ACTIONS: ReadonlyArray = [ +const SHORTCUT_ACTIONS = [ + { + id: 'save_file', + defaultCombo: 'mod+s', + label: 'Save file', + description: 'Save the active file editor', + }, + { + id: 'find_in_file', + defaultCombo: 'mod+f', + label: 'Find in file', + description: 'Search in the active file editor', + }, { id: 'open_go_to_line', defaultCombo: 'alt+g', label: 'Go to line (files editor)', description: 'Open go to line in the files editor', customizable: true, + category: 'navigation', }, { id: 'open_command_palette', @@ -128,6 +153,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open command palette', description: 'Open the command palette', customizable: true, + category: 'application', }, { id: 'focus_input', @@ -135,6 +161,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Focus input', description: 'Focus the chat input field', customizable: true, + category: 'session', }, { id: 'open_status', @@ -148,6 +175,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open settings', description: 'Open the settings panel', customizable: true, + category: 'application', }, { id: 'toggle_terminal', @@ -155,6 +183,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle terminal dock', description: 'Toggle the bottom terminal dock', customizable: true, + category: 'panels', }, { id: 'toggle_terminal_expanded', @@ -162,6 +191,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle terminal expanded', description: 'Toggle terminal expanded or collapsed', customizable: true, + category: 'panels', }, { id: 'toggle_files', @@ -175,6 +205,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Add selection to chat', description: 'Add the selected text to the chat input', customizable: true, + category: 'session', }, { id: 'toggle_sidebar', @@ -182,6 +213,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle sidebar', description: 'Toggle the session sidebar', customizable: true, + category: 'panels', }, { id: 'open_timeline_dialog', @@ -189,6 +221,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open conversation timeline', description: 'Search and navigate within current conversation', customizable: true, + category: 'session', }, { id: 'toggle_prompt_navigator', @@ -196,6 +229,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle prompt navigator', description: 'Show or hide the prompt navigator panel in chat', customizable: true, + category: 'panels', }, { id: 'toggle_right_sidebar', @@ -203,6 +237,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle right sidebar', description: 'Toggle the right sidebar', customizable: true, + category: 'panels', }, { id: 'open_right_sidebar_git', @@ -210,6 +245,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open right sidebar Git tab', description: 'Open right sidebar and select Git', customizable: true, + category: 'panels', }, { id: 'open_right_sidebar_files', @@ -217,6 +253,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open right sidebar Files tab', description: 'Open right sidebar and select Files', customizable: true, + category: 'panels', }, { id: 'switch_context_surface', @@ -224,6 +261,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Switch context panel surface', description: 'Hold the modifier and press a number to open or close the matching rail icon', customizable: true, + category: 'panels', }, { id: 'new_chat', @@ -231,6 +269,23 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'New session', description: 'Start a new session', customizable: true, + category: 'session', + }, + { + id: 'open_draft_project_picker', + defaultCombo: 'mod+s p', + label: 'Open draft project picker', + description: 'Choose a project for a new draft', + customizable: true, + category: 'session', + }, + { + id: 'open_draft_worktree_picker', + defaultCombo: 'mod+s g', + label: 'Open draft worktree picker', + description: 'Choose a worktree for a new draft', + customizable: true, + category: 'session', }, { id: 'new_chat_worktree', @@ -238,6 +293,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'New worktree draft', description: 'Create a new worktree and open a draft in it', customizable: true, + category: 'session', }, { id: 'new_mini_chat', @@ -245,6 +301,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'New Mini Chat window', description: 'Open a new Mini Chat draft window', customizable: true, + category: 'session', }, { id: 'submit_message', @@ -264,6 +321,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open keyboard shortcuts', description: 'Show the keyboard shortcuts help', customizable: true, + category: 'application', }, { id: 'toggle_context_plan', @@ -271,6 +329,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle plan context panel', description: 'Open or close plan in the context panel', customizable: true, + category: 'panels', }, { id: 'toggle_services_menu', @@ -278,6 +337,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle services menu', description: 'Open or close the services menu', customizable: true, + category: 'panels', }, { id: 'cycle_services_tab', @@ -285,6 +345,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Cycle services tab', description: 'Cycle through tabs in the services menu', customizable: true, + category: 'navigation', }, { id: 'cycle_theme', @@ -292,6 +353,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Cycle theme', description: 'Cycle between light, dark, and system theme', customizable: true, + category: 'application', }, { id: 'open_model_selector', @@ -299,6 +361,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open model selector', description: 'Open model selector while in chat', customizable: true, + category: 'models', }, { id: 'cycle_thinking_variant', @@ -312,6 +375,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Cycle agent', description: 'Cycle agent while the model selector is open', customizable: true, + category: 'models', }, { id: 'cycle_favorite_model_forward', @@ -319,6 +383,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Cycle favorite model forward', description: 'Cycle forward through starred models without opening the picker', customizable: true, + category: 'models', }, { id: 'cycle_favorite_model_backward', @@ -326,6 +391,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Cycle favorite model backward', description: 'Cycle backward through starred models without opening the picker', customizable: true, + category: 'models', }, { id: 'expand_input', @@ -333,6 +399,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Expand input', description: 'Toggle focus mode for the chat input', customizable: true, + category: 'session', }, { id: 'toggle_dictation', @@ -340,6 +407,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Voice input', description: 'Start dictation; press again to confirm and insert the transcript', customizable: true, + category: 'session', }, { id: 'abort_run', @@ -347,13 +415,79 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Abort active run', description: 'Abort the currently running task (double press)', }, -] as const; + { + id: 'switch_tab_1', + defaultCombo: 'mod+1', + label: 'Switch to tab 1', + description: 'Switch to the first tab or project', + }, + { + id: 'switch_tab_2', + defaultCombo: 'mod+2', + label: 'Switch to tab 2', + description: 'Switch to the second tab or project', + }, + { + id: 'switch_tab_3', + defaultCombo: 'mod+3', + label: 'Switch to tab 3', + description: 'Switch to the third tab or project', + }, + { + id: 'switch_tab_4', + defaultCombo: 'mod+4', + label: 'Switch to tab 4', + description: 'Switch to the fourth tab or project', + }, + { + id: 'switch_tab_5', + defaultCombo: 'mod+5', + label: 'Switch to tab 5', + description: 'Switch to the fifth tab or project', + }, + { + id: 'switch_tab_6', + defaultCombo: 'mod+6', + label: 'Switch to tab 6', + description: 'Switch to the sixth tab or project', + }, + { + id: 'switch_tab_7', + defaultCombo: 'mod+7', + label: 'Switch to tab 7', + description: 'Switch to the seventh tab or project', + }, + { + id: 'switch_tab_8', + defaultCombo: 'mod+8', + label: 'Switch to tab 8', + description: 'Switch to the eighth tab or project', + }, + { + id: 'switch_tab_9', + defaultCombo: 'mod+9', + label: 'Switch to tab 9', + description: 'Switch to the ninth tab or project', + }, +] as const satisfies ReadonlyArray; + +export type ShortcutActionId = (typeof SHORTCUT_ACTIONS)[number]['id']; +export type ShortcutAction = Omit & { id: ShortcutActionId }; export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo { if (isUnassignedShortcut(combo)) { return UNASSIGNED_SHORTCUT; } + const chords = combo.trim().replace(/\s*\+\s*/g, '+').split(/\s+/).filter(Boolean); + if (chords.length === 0 || chords.length > 2) { + return ''; + } + + return chords.map(normalizeChord).join(' '); +} + +function normalizeChord(combo: ShortcutCombo): ShortcutCombo { const rawParts = combo .toLowerCase() .trim() @@ -384,42 +518,54 @@ function isValidShortcutCombo(combo: ShortcutCombo): boolean { } const parsed = parseShortcut(combo); - return parsed.key.trim().length > 0; + return parsed !== undefined && parsed.chords.every((chord) => chord.key.trim().length > 0); } -function parseShortcut(combo: ShortcutCombo): ParsedShortcut { +export function parseShortcut(combo: ShortcutCombo): ParsedShortcut | undefined { if (isUnassignedShortcut(combo)) { - return { modifiers: new Set(), key: UNASSIGNED_SHORTCUT }; + return { chords: [{ modifiers: new Set(), key: UNASSIGNED_SHORTCUT }] }; } const normalized = normalizeCombo(combo); - const parts = normalized.split('+'); - const modifiers: Set = new Set(); - let key: ShortcutKey = ''; - - for (const part of parts) { - const modifier = MODIFIER_KEY_MAP[part]; - if (modifier) { - modifiers.add(modifier); - } else { - key = part; - } + if (!normalized) { + return undefined; } - return { modifiers, key }; + const chords = normalized.split(' ').map((chord) => { + const parts = chord.split('+'); + const modifiers: Set = new Set(); + let key: ShortcutKey = ''; + + for (const part of parts) { + const modifier = MODIFIER_KEY_MAP[part]; + if (modifier) { + modifiers.add(modifier); + } else { + key = part; + } + } + + return { modifiers, key }; + }); + + return { chords }; } -export function formatShortcutForDisplay(combo: ShortcutCombo): string { +export function formatShortcutForDisplay(combo: ShortcutCombo, unassignedLabel = 'Unassigned'): string { if (isUnassignedShortcut(combo)) { - return 'Unassigned'; + return unassignedLabel; } const parsed = parseShortcut(combo); - if (!parsed.key && parsed.modifiers.size === 0) { - return 'Unassigned'; + if (!parsed || parsed.chords.some((chord) => !chord.key && chord.modifiers.size === 0)) { + return unassignedLabel; } + return parsed.chords.map(formatChordForDisplay).join(', '); +} + +function formatChordForDisplay(parsed: ParsedShortcutChord): string { const parts: string[] = []; for (const modifier of MODIFIER_PRIORITY) { @@ -441,7 +587,32 @@ export function getShortcutAction(id: string): ShortcutAction | undefined { } export function getCustomizableShortcutActions(): ReadonlyArray { - return SHORTCUT_ACTIONS.filter((action) => action.customizable === true); + return SHORTCUT_ACTIONS.filter((action) => 'customizable' in action && action.customizable === true); +} + +export function getShortcutCategory(action: ShortcutAction): ShortcutCategory { + return action.category ?? DEFAULT_SHORTCUT_CATEGORY; +} + +export function getShortcutConflict(left: ShortcutCombo, right: ShortcutCombo): ShortcutConflict | undefined { + const normalizedLeft = normalizeCombo(left); + const normalizedRight = normalizeCombo(right); + const hasInvalidBinding = !isValidShortcutCombo(normalizedLeft) + || !isValidShortcutCombo(normalizedRight); + const hasUnassignedBinding = normalizedLeft === UNASSIGNED_SHORTCUT + || normalizedRight === UNASSIGNED_SHORTCUT; + if (hasInvalidBinding || hasUnassignedBinding) { + return undefined; + } + + if (normalizedLeft === normalizedRight) { + return 'exact'; + } + + const leftChords = normalizedLeft.split(' '); + const rightChords = normalizedRight.split(' '); + const sharesLeader = leftChords[0] === rightChords[0]; + return sharesLeader && leftChords.length !== rightChords.length ? 'prefix' : undefined; } export function getEffectiveShortcutCombo( @@ -455,13 +626,9 @@ export function getEffectiveShortcutCombo( const override = overrides?.[actionId]; if (typeof override === 'string') { - if (override.trim().toLowerCase() === UNASSIGNED_SHORTCUT) { - return ''; - } - const normalized = normalizeCombo(override); if (normalized === UNASSIGNED_SHORTCUT) { - return UNASSIGNED_SHORTCUT; + return ''; } if (isValidShortcutCombo(normalized)) { @@ -478,13 +645,18 @@ export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean { } const parsed = parseShortcut(combo); - if (!parsed.modifiers.has('mod')) { + if (!parsed) { + return false; + } + const chord = parsed.chords[0]; + if (!chord.modifiers.has('mod')) { return false; } - const key = parsed.key.toLowerCase(); - const dangerousPrimary = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']); - return dangerousPrimary.has(key) && !parsed.modifiers.has('shift') && !parsed.modifiers.has('alt'); + const key = chord.key.toLowerCase(); + return RISKY_BROWSER_SHORTCUT_KEYS.has(key) + && !chord.modifiers.has('shift') + && !chord.modifiers.has('alt'); } export function eventMatchesShortcut( @@ -497,11 +669,15 @@ export function eventMatchesShortcut( } const parsed = parseShortcut(combo); + if (!parsed || parsed.chords.length !== 1) { + return false; + } + const chord = parsed.chords[0]; - const expectedMod = parsed.modifiers.has('mod'); - const expectedShift = parsed.modifiers.has('shift'); - const expectedAlt = parsed.modifiers.has('alt'); - const expectedCtrl = parsed.modifiers.has('ctrl'); + 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(); @@ -548,7 +724,7 @@ export function eventMatchesShortcut( } const eventKey = keyToShortcutToken(eventKeyRaw); - const expectedKey = keyToShortcutToken(parsed.key); + const expectedKey = keyToShortcutToken(chord.key); return eventKey === expectedKey; } @@ -581,7 +757,8 @@ export function getEffectiveShortcutPrefix( } if (normalized) { const parsed = parseShortcut(normalized); - if (parsed.modifiers.size > 0 || parsed.key) { + const chord = parsed?.chords[0]; + if (chord && (chord.modifiers.size > 0 || chord.key)) { return normalized; } } @@ -601,15 +778,19 @@ export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: Reado } const parsed = parseShortcut(prefixCombo); + if (!parsed || parsed.chords.length !== 1) { + return false; + } + const chord = parsed.chords[0]; - for (const modifier of parsed.modifiers) { + for (const modifier of chord.modifiers) { const aliases = MODIFIER_KEY_ALIASES[modifier]; if (!aliases.some((alias) => heldKeys.has(alias))) { return false; } } - if (parsed.key && !heldKeys.has(parsed.key.toLowerCase())) { + if (chord.key && !heldKeys.has(chord.key.toLowerCase())) { return false; } @@ -632,11 +813,15 @@ export function eventMatchesShortcutPrefix( } const parsed = parseShortcut(prefixCombo); + if (!parsed || parsed.chords.length !== 1) { + return false; + } + const chord = parsed.chords[0]; - const expectedMod = parsed.modifiers.has('mod'); - const expectedShift = parsed.modifiers.has('shift'); - const expectedAlt = parsed.modifiers.has('alt'); - const expectedCtrl = parsed.modifiers.has('ctrl'); + 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(); @@ -673,7 +858,7 @@ export function eventMatchesShortcutPrefix( } } - if (parsed.key && (!heldKeys || !heldKeys.has(parsed.key.toLowerCase()))) { + if (chord.key && (!heldKeys || !heldKeys.has(chord.key.toLowerCase()))) { return false; } diff --git a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md new file mode 100644 index 00000000..cf4d167d --- /dev/null +++ b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md @@ -0,0 +1,29 @@ +# Registration boundary + +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. + +# 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. +- `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`. Each binding has one chord or at most two space-separated chords, such as `mod+s p`. `normalizeCombo`, `parseShortcut`, `formatShortcutForDisplay`, and `getShortcutConflict` provide the shared parsing and validation behavior. 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` and `mod+s g` to the draft target pickers. + +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 + +`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 1500ms. 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. + +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.