refactor(ui): separate shortcut configuration

This commit is contained in:
ChangeHow
2026-08-06 14:18:15 +08:00
parent bb25b68657
commit 51fd947de8
4 changed files with 318 additions and 95 deletions
+23 -3
View File
@@ -2,18 +2,21 @@
Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both register with the shared `shortcutRegistry`, so components never receive a registry. The first registration for an action ID wins until it unregisters, then the next mounted registration takes over. A component-local interaction, such as editor navigation or an open menu, remains local event handling rather than a registered application command.
Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `schema.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components.
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
`SHORTCUT_SCHEMA` is the single static source of truth for application commands. Every entry declares an ID, default binding, category, and whether users can customize it. Customizable entries also derive their Settings translation key in the schema, so Settings must not maintain an action-ID switch or English fallback labels.
`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`.
- `schema.ts` owns `SHORTCUT_SCHEMA`, derived action types, customizable metadata, and effective binding resolution.
- `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.
- `dispatcher.ts` resolves current bindings and turns keyboard events into registered command calls.
@@ -37,3 +40,20 @@ The settings recorder also stops at two chords. It keeps the recording local unt
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.
# 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.
+265
View File
@@ -0,0 +1,265 @@
import type { ShortcutCombo } from './bindings';
type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application';
type ShortcutConfig = {
id: string;
defaultBinding: ShortcutCombo;
} & (
| { customizable: false }
| {
customizable: true;
settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${string}.label`;
}
);
const SHORTCUT_GROUPS = {
session: [
{
id: 'add_selection_to_chat',
defaultBinding: 'mod+l',
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: '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: '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',
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 },
{ 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;
+27 -4
View File
@@ -5,16 +5,39 @@ import {
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);
expect(new Set(ids).size).toBe(ids.length);
expect(SHORTCUT_SCHEMA.every((action) => {
const hasValidMetadata = SHORTCUT_SCHEMA.every((action) => {
const chordCount = parseShortcut(action.defaultBinding)?.chords.length;
return Boolean(action.category) && chordCount !== undefined && chordCount >= 1 && chordCount <= 2;
})).toBe(true);
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', () => {
+3 -88
View File
@@ -5,98 +5,13 @@ import {
UNASSIGNED_SHORTCUT,
type ShortcutCombo,
} from './bindings';
import { SHORTCUT_SCHEMA } from './config';
export type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application';
interface ShortcutDefinition<Id extends string = string> {
id: Id;
defaultBinding: ShortcutCombo;
category: ShortcutCategory;
}
interface CustomizableShortcutDefinition<Id extends string = string> extends ShortcutDefinition<Id> {
customizable: true;
settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${Id}.label`;
}
interface InternalShortcutDefinition<Id extends string = string> extends ShortcutDefinition<Id> {
customizable: false;
}
function internalShortcut<const Id extends string>(
id: Id,
defaultBinding: ShortcutCombo,
category: ShortcutCategory,
): InternalShortcutDefinition<Id> {
return { id, defaultBinding, category, customizable: false };
}
function customizableShortcut<const Id extends string>(
id: Id,
defaultBinding: ShortcutCombo,
category: ShortcutCategory,
): CustomizableShortcutDefinition<Id> {
return {
id,
defaultBinding,
category,
customizable: true,
settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${id}.label`,
};
}
/** The single static source of truth for every application-level shortcut. */
export const SHORTCUT_SCHEMA = [
internalShortcut('save_file', 'mod+s', 'navigation'),
internalShortcut('find_in_file', 'mod+f', 'navigation'),
customizableShortcut('open_go_to_line', 'alt+g', 'navigation'),
customizableShortcut('open_command_palette', 'mod+p', 'application'),
customizableShortcut('focus_input', 'mod+i', 'session'),
internalShortcut('open_status', 'mod+shift+o', 'application'),
customizableShortcut('open_settings', 'mod+comma', 'application'),
customizableShortcut('toggle_terminal', 'mod+j', 'panels'),
customizableShortcut('toggle_terminal_expanded', 'mod+shift+j', 'panels'),
customizableShortcut('add_selection_to_chat', 'mod+l', 'session'),
customizableShortcut('toggle_sidebar', 'mod+alt+l', 'panels'),
customizableShortcut('open_timeline_dialog', 'mod+t', 'session'),
customizableShortcut('toggle_prompt_navigator', 'mod+alt+p', 'panels'),
customizableShortcut('toggle_right_sidebar', 'mod+b', 'panels'),
customizableShortcut('open_right_sidebar_git', 'mod+shift+g', 'panels'),
customizableShortcut('open_right_sidebar_files', 'mod+shift+f', 'panels'),
customizableShortcut('switch_context_surface', 'mod', 'panels'),
customizableShortcut('new_chat', 'mod+n', 'session'),
customizableShortcut('open_draft_project_picker', 'mod+s p', 'session'),
customizableShortcut('open_draft_worktree_picker', 'mod+s g', 'session'),
customizableShortcut('new_chat_worktree', 'mod+shift+n', 'session'),
customizableShortcut('new_mini_chat', 'mod+alt+n', 'session'),
customizableShortcut('open_help', 'mod+.', 'application'),
customizableShortcut('toggle_context_plan', 'mod+shift+p', 'panels'),
customizableShortcut('toggle_services_menu', 'mod+shift+s', 'panels'),
customizableShortcut('cycle_services_tab', 'mod+shift+[', 'navigation'),
customizableShortcut('cycle_theme', 'mod+/', 'application'),
customizableShortcut('open_model_selector', 'mod+shift+m', 'models'),
internalShortcut('cycle_thinking_variant', 'mod+shift+t', 'models'),
customizableShortcut('cycle_agent', 'tab', 'models'),
customizableShortcut('cycle_favorite_model_forward', 'ctrl+]', 'models'),
customizableShortcut('cycle_favorite_model_backward', 'ctrl+[', 'models'),
customizableShortcut('expand_input', 'mod+shift+e', 'session'),
customizableShortcut('toggle_dictation', 'mod+alt+v', 'session'),
internalShortcut('abort_run', 'escape', 'session'),
internalShortcut('toggle_memory_debug', 'mod+shift+d', 'application'),
internalShortcut('switch_tab_1', 'mod+1', 'navigation'),
// Mobile tab shortcuts share numeric bindings with desktop-only panel commands.
internalShortcut('switch_tab_2', 'mod+2', 'navigation'),
internalShortcut('switch_tab_3', 'mod+3', 'navigation'),
internalShortcut('switch_tab_4', 'mod+4', 'navigation'),
internalShortcut('switch_tab_5', 'mod+5', 'navigation'),
internalShortcut('switch_tab_6', 'mod+6', 'navigation'),
internalShortcut('switch_tab_7', 'mod+7', 'navigation'),
internalShortcut('switch_tab_8', 'mod+8', 'navigation'),
internalShortcut('switch_tab_9', 'mod+9', 'navigation'),
] as const;
export { 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 function getShortcutAction(id: string): ShortcutAction | undefined {