fix(keybindings): migrate to namespaced ids closes #2391

This commit is contained in:
Mario Zechner
2026-03-20 01:50:47 +01:00
parent 74a46fc7ea
commit e3fee7a511
43 changed files with 1077 additions and 883 deletions

View File

@@ -1,4 +1,4 @@
import { getEditorKeybindings } from "../keybindings.js";
import { getKeybindings } from "../keybindings.js";
import { Loader } from "./loader.js";
/**
@@ -27,8 +27,8 @@ export class CancellableLoader extends Loader {
}
handleInput(data: string): void {
const kb = getEditorKeybindings();
if (kb.matches(data, "selectCancel")) {
const kb = getKeybindings();
if (kb.matches(data, "tui.select.cancel")) {
this.abortController.abort();
this.onAbort?.();
}

View File

@@ -1,5 +1,5 @@
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
import { getEditorKeybindings } from "../keybindings.js";
import { getKeybindings } from "../keybindings.js";
import { decodeKittyPrintable, matchesKey } from "../keys.js";
import { KillRing } from "../kill-ring.js";
import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.js";
@@ -517,12 +517,12 @@ export class Editor implements Component, Focusable {
}
handleInput(data: string): void {
const kb = getEditorKeybindings();
const kb = getKeybindings();
// Handle character jump mode (awaiting next character to jump to)
if (this.jumpMode !== null) {
// Cancel if the hotkey is pressed again
if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) {
if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
this.jumpMode = null;
return;
}
@@ -566,29 +566,29 @@ export class Editor implements Component, Focusable {
}
// Ctrl+C - let parent handle (exit/clear)
if (kb.matches(data, "copy")) {
if (kb.matches(data, "tui.input.copy")) {
return;
}
// Undo
if (kb.matches(data, "undo")) {
if (kb.matches(data, "tui.editor.undo")) {
this.undo();
return;
}
// Handle autocomplete mode
if (this.autocompleteState && this.autocompleteList) {
if (kb.matches(data, "selectCancel")) {
if (kb.matches(data, "tui.select.cancel")) {
this.cancelAutocomplete();
return;
}
if (kb.matches(data, "selectUp") || kb.matches(data, "selectDown")) {
if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
this.autocompleteList.handleInput(data);
return;
}
if (kb.matches(data, "tab")) {
if (kb.matches(data, "tui.input.tab")) {
const selected = this.autocompleteList.getSelectedItem();
if (selected && this.autocompleteProvider) {
const shouldChainSlashArgumentAutocomplete = this.shouldChainSlashArgumentAutocompleteOnTabSelection();
@@ -615,7 +615,7 @@ export class Editor implements Component, Focusable {
return;
}
if (kb.matches(data, "selectConfirm")) {
if (kb.matches(data, "tui.select.confirm")) {
const selected = this.autocompleteList.getSelectedItem();
if (selected && this.autocompleteProvider) {
this.pushUndoSnapshot();
@@ -644,68 +644,68 @@ export class Editor implements Component, Focusable {
}
// Tab - trigger completion
if (kb.matches(data, "tab") && !this.autocompleteState) {
if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) {
this.handleTabCompletion();
return;
}
// Deletion actions
if (kb.matches(data, "deleteToLineEnd")) {
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
this.deleteToEndOfLine();
return;
}
if (kb.matches(data, "deleteToLineStart")) {
if (kb.matches(data, "tui.editor.deleteToLineStart")) {
this.deleteToStartOfLine();
return;
}
if (kb.matches(data, "deleteWordBackward")) {
if (kb.matches(data, "tui.editor.deleteWordBackward")) {
this.deleteWordBackwards();
return;
}
if (kb.matches(data, "deleteWordForward")) {
if (kb.matches(data, "tui.editor.deleteWordForward")) {
this.deleteWordForward();
return;
}
if (kb.matches(data, "deleteCharBackward") || matchesKey(data, "shift+backspace")) {
if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
this.handleBackspace();
return;
}
if (kb.matches(data, "deleteCharForward") || matchesKey(data, "shift+delete")) {
if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
this.handleForwardDelete();
return;
}
// Kill ring actions
if (kb.matches(data, "yank")) {
if (kb.matches(data, "tui.editor.yank")) {
this.yank();
return;
}
if (kb.matches(data, "yankPop")) {
if (kb.matches(data, "tui.editor.yankPop")) {
this.yankPop();
return;
}
// Cursor movement actions
if (kb.matches(data, "cursorLineStart")) {
if (kb.matches(data, "tui.editor.cursorLineStart")) {
this.moveToLineStart();
return;
}
if (kb.matches(data, "cursorLineEnd")) {
if (kb.matches(data, "tui.editor.cursorLineEnd")) {
this.moveToLineEnd();
return;
}
if (kb.matches(data, "cursorWordLeft")) {
if (kb.matches(data, "tui.editor.cursorWordLeft")) {
this.moveWordBackwards();
return;
}
if (kb.matches(data, "cursorWordRight")) {
if (kb.matches(data, "tui.editor.cursorWordRight")) {
this.moveWordForwards();
return;
}
// New line
if (
kb.matches(data, "newLine") ||
kb.matches(data, "tui.input.newLine") ||
(data.charCodeAt(0) === 10 && data.length > 1) ||
data === "\x1b\r" ||
data === "\x1b[13;2~" ||
@@ -722,7 +722,7 @@ export class Editor implements Component, Focusable {
}
// Submit (Enter)
if (kb.matches(data, "submit")) {
if (kb.matches(data, "tui.input.submit")) {
if (this.disableSubmit) return;
// Workaround for terminals without Shift+Enter support:
@@ -739,7 +739,7 @@ export class Editor implements Component, Focusable {
}
// Arrow key navigation (with history support)
if (kb.matches(data, "cursorUp")) {
if (kb.matches(data, "tui.editor.cursorUp")) {
if (this.isEditorEmpty()) {
this.navigateHistory(-1);
} else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
@@ -752,7 +752,7 @@ export class Editor implements Component, Focusable {
}
return;
}
if (kb.matches(data, "cursorDown")) {
if (kb.matches(data, "tui.editor.cursorDown")) {
if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
this.navigateHistory(1);
} else if (this.isOnLastVisualLine()) {
@@ -763,31 +763,31 @@ export class Editor implements Component, Focusable {
}
return;
}
if (kb.matches(data, "cursorRight")) {
if (kb.matches(data, "tui.editor.cursorRight")) {
this.moveCursor(0, 1);
return;
}
if (kb.matches(data, "cursorLeft")) {
if (kb.matches(data, "tui.editor.cursorLeft")) {
this.moveCursor(0, -1);
return;
}
// Page up/down - scroll by page and move cursor
if (kb.matches(data, "pageUp")) {
if (kb.matches(data, "tui.editor.pageUp")) {
this.pageScroll(-1);
return;
}
if (kb.matches(data, "pageDown")) {
if (kb.matches(data, "tui.editor.pageDown")) {
this.pageScroll(1);
return;
}
// Character jump mode triggers
if (kb.matches(data, "jumpForward")) {
if (kb.matches(data, "tui.editor.jumpForward")) {
this.jumpMode = "forward";
return;
}
if (kb.matches(data, "jumpBackward")) {
if (kb.matches(data, "tui.editor.jumpBackward")) {
this.jumpMode = "backward";
return;
}
@@ -1149,10 +1149,10 @@ export class Editor implements Component, Focusable {
}
}
private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType<typeof getEditorKeybindings>): boolean {
private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType<typeof getKeybindings>): boolean {
if (this.disableSubmit) return false;
if (!matchesKey(data, "enter")) return false;
const submitKeys = kb.getKeys("submit");
const submitKeys = kb.getKeys("tui.input.submit");
const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
if (!hasShiftEnter) return false;

View File

@@ -1,4 +1,4 @@
import { getEditorKeybindings } from "../keybindings.js";
import { getKeybindings } from "../keybindings.js";
import { decodeKittyPrintable } from "../keys.js";
import { KillRing } from "../kill-ring.js";
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js";
@@ -82,69 +82,69 @@ export class Input implements Component, Focusable {
return;
}
const kb = getEditorKeybindings();
const kb = getKeybindings();
// Escape/Cancel
if (kb.matches(data, "selectCancel")) {
if (kb.matches(data, "tui.select.cancel")) {
if (this.onEscape) this.onEscape();
return;
}
// Undo
if (kb.matches(data, "undo")) {
if (kb.matches(data, "tui.editor.undo")) {
this.undo();
return;
}
// Submit
if (kb.matches(data, "submit") || data === "\n") {
if (kb.matches(data, "tui.input.submit") || data === "\n") {
if (this.onSubmit) this.onSubmit(this.value);
return;
}
// Deletion
if (kb.matches(data, "deleteCharBackward")) {
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
this.handleBackspace();
return;
}
if (kb.matches(data, "deleteCharForward")) {
if (kb.matches(data, "tui.editor.deleteCharForward")) {
this.handleForwardDelete();
return;
}
if (kb.matches(data, "deleteWordBackward")) {
if (kb.matches(data, "tui.editor.deleteWordBackward")) {
this.deleteWordBackwards();
return;
}
if (kb.matches(data, "deleteWordForward")) {
if (kb.matches(data, "tui.editor.deleteWordForward")) {
this.deleteWordForward();
return;
}
if (kb.matches(data, "deleteToLineStart")) {
if (kb.matches(data, "tui.editor.deleteToLineStart")) {
this.deleteToLineStart();
return;
}
if (kb.matches(data, "deleteToLineEnd")) {
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
this.deleteToLineEnd();
return;
}
// Kill ring actions
if (kb.matches(data, "yank")) {
if (kb.matches(data, "tui.editor.yank")) {
this.yank();
return;
}
if (kb.matches(data, "yankPop")) {
if (kb.matches(data, "tui.editor.yankPop")) {
this.yankPop();
return;
}
// Cursor movement
if (kb.matches(data, "cursorLeft")) {
if (kb.matches(data, "tui.editor.cursorLeft")) {
this.lastAction = null;
if (this.cursor > 0) {
const beforeCursor = this.value.slice(0, this.cursor);
@@ -155,7 +155,7 @@ export class Input implements Component, Focusable {
return;
}
if (kb.matches(data, "cursorRight")) {
if (kb.matches(data, "tui.editor.cursorRight")) {
this.lastAction = null;
if (this.cursor < this.value.length) {
const afterCursor = this.value.slice(this.cursor);
@@ -166,24 +166,24 @@ export class Input implements Component, Focusable {
return;
}
if (kb.matches(data, "cursorLineStart")) {
if (kb.matches(data, "tui.editor.cursorLineStart")) {
this.lastAction = null;
this.cursor = 0;
return;
}
if (kb.matches(data, "cursorLineEnd")) {
if (kb.matches(data, "tui.editor.cursorLineEnd")) {
this.lastAction = null;
this.cursor = this.value.length;
return;
}
if (kb.matches(data, "cursorWordLeft")) {
if (kb.matches(data, "tui.editor.cursorWordLeft")) {
this.moveWordBackwards();
return;
}
if (kb.matches(data, "cursorWordRight")) {
if (kb.matches(data, "tui.editor.cursorWordRight")) {
this.moveWordForwards();
return;
}

View File

@@ -1,4 +1,4 @@
import { getEditorKeybindings } from "../keybindings.js";
import { getKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth } from "../utils.js";
@@ -110,26 +110,26 @@ export class SelectList implements Component {
}
handleInput(keyData: string): void {
const kb = getEditorKeybindings();
const kb = getKeybindings();
// Up arrow - wrap to bottom when at top
if (kb.matches(keyData, "selectUp")) {
if (kb.matches(keyData, "tui.select.up")) {
this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
this.notifySelectionChange();
}
// Down arrow - wrap to top when at bottom
else if (kb.matches(keyData, "selectDown")) {
else if (kb.matches(keyData, "tui.select.down")) {
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
this.notifySelectionChange();
}
// Enter
else if (kb.matches(keyData, "selectConfirm")) {
else if (kb.matches(keyData, "tui.select.confirm")) {
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem && this.onSelect) {
this.onSelect(selectedItem);
}
}
// Escape or Ctrl+C
else if (kb.matches(keyData, "selectCancel")) {
else if (kb.matches(keyData, "tui.select.cancel")) {
if (this.onCancel) {
this.onCancel();
}

View File

@@ -1,5 +1,5 @@
import { fuzzyFilter } from "../fuzzy.js";
import { getEditorKeybindings } from "../keybindings.js";
import { getKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js";
import { Input } from "./input.js";
@@ -174,17 +174,17 @@ export class SettingsList implements Component {
}
// Main list input handling
const kb = getEditorKeybindings();
const kb = getKeybindings();
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (kb.matches(data, "selectUp")) {
if (kb.matches(data, "tui.select.up")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
} else if (kb.matches(data, "selectDown")) {
} else if (kb.matches(data, "tui.select.down")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
} else if (kb.matches(data, "selectConfirm") || data === " ") {
} else if (kb.matches(data, "tui.select.confirm") || data === " ") {
this.activateItem();
} else if (kb.matches(data, "selectCancel")) {
} else if (kb.matches(data, "tui.select.cancel")) {
this.onCancel();
} else if (this.searchEnabled && this.searchInput) {
const sanitized = data.replace(/ /g, "");

View File

@@ -32,12 +32,16 @@ export type { EditorComponent } from "./editor-component.js";
export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js";
// Keybindings
export {
DEFAULT_EDITOR_KEYBINDINGS,
type EditorAction,
type EditorKeybindingsConfig,
EditorKeybindingsManager,
getEditorKeybindings,
setEditorKeybindings,
getKeybindings,
type Keybinding,
type KeybindingConflict,
type KeybindingDefinition,
type KeybindingDefinitions,
type Keybindings,
type KeybindingsConfig,
KeybindingsManager,
setKeybindings,
TUI_KEYBINDINGS,
} from "./keybindings.js";
// Keyboard input handling
export {

View File

@@ -1,189 +1,253 @@
import { type KeyId, matchesKey } from "./keys.js";
/**
* Editor actions that can be bound to keys.
* Global keybinding registry.
* Downstream packages can add keybindings via declaration merging.
*/
export type EditorAction =
// Cursor movement
| "cursorUp"
| "cursorDown"
| "cursorLeft"
| "cursorRight"
| "cursorWordLeft"
| "cursorWordRight"
| "cursorLineStart"
| "cursorLineEnd"
| "jumpForward"
| "jumpBackward"
| "pageUp"
| "pageDown"
// Deletion
| "deleteCharBackward"
| "deleteCharForward"
| "deleteWordBackward"
| "deleteWordForward"
| "deleteToLineStart"
| "deleteToLineEnd"
// Text input
| "newLine"
| "submit"
| "tab"
// Selection/autocomplete
| "selectUp"
| "selectDown"
| "selectPageUp"
| "selectPageDown"
| "selectConfirm"
| "selectCancel"
// Clipboard
| "copy"
// Kill ring
| "yank"
| "yankPop"
// Undo
| "undo"
// Tool output
| "expandTools"
// Tree navigation
| "treeFoldOrUp"
| "treeUnfoldOrDown"
// Session
| "toggleSessionPath"
| "toggleSessionSort"
| "renameSession"
| "deleteSession"
| "deleteSessionNoninvasive";
export interface Keybindings {
// Editor navigation and editing
"tui.editor.cursorUp": true;
"tui.editor.cursorDown": true;
"tui.editor.cursorLeft": true;
"tui.editor.cursorRight": true;
"tui.editor.cursorWordLeft": true;
"tui.editor.cursorWordRight": true;
"tui.editor.cursorLineStart": true;
"tui.editor.cursorLineEnd": true;
"tui.editor.jumpForward": true;
"tui.editor.jumpBackward": true;
"tui.editor.pageUp": true;
"tui.editor.pageDown": true;
"tui.editor.deleteCharBackward": true;
"tui.editor.deleteCharForward": true;
"tui.editor.deleteWordBackward": true;
"tui.editor.deleteWordForward": true;
"tui.editor.deleteToLineStart": true;
"tui.editor.deleteToLineEnd": true;
"tui.editor.yank": true;
"tui.editor.yankPop": true;
"tui.editor.undo": true;
// Generic input actions
"tui.input.newLine": true;
"tui.input.submit": true;
"tui.input.tab": true;
"tui.input.copy": true;
// Generic selection actions
"tui.select.up": true;
"tui.select.down": true;
"tui.select.pageUp": true;
"tui.select.pageDown": true;
"tui.select.confirm": true;
"tui.select.cancel": true;
}
// Re-export KeyId from keys.ts
export type { KeyId };
export type Keybinding = keyof Keybindings;
/**
* Editor keybindings configuration.
*/
export type EditorKeybindingsConfig = {
[K in EditorAction]?: KeyId | KeyId[];
};
export interface KeybindingDefinition {
defaultKeys: KeyId | KeyId[];
description?: string;
}
/**
* Default editor keybindings.
*/
export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
// Cursor movement
cursorUp: "up",
cursorDown: "down",
cursorLeft: ["left", "ctrl+b"],
cursorRight: ["right", "ctrl+f"],
cursorWordLeft: ["alt+left", "ctrl+left", "alt+b"],
cursorWordRight: ["alt+right", "ctrl+right", "alt+f"],
cursorLineStart: ["home", "ctrl+a"],
cursorLineEnd: ["end", "ctrl+e"],
jumpForward: "ctrl+]",
jumpBackward: "ctrl+alt+]",
pageUp: "pageUp",
pageDown: "pageDown",
// Deletion
deleteCharBackward: "backspace",
deleteCharForward: ["delete", "ctrl+d"],
deleteWordBackward: ["ctrl+w", "alt+backspace"],
deleteWordForward: ["alt+d", "alt+delete"],
deleteToLineStart: "ctrl+u",
deleteToLineEnd: "ctrl+k",
// Text input
newLine: "shift+enter",
submit: "enter",
tab: "tab",
// Selection/autocomplete
selectUp: "up",
selectDown: "down",
selectPageUp: "pageUp",
selectPageDown: "pageDown",
selectConfirm: "enter",
selectCancel: ["escape", "ctrl+c"],
// Clipboard
copy: "ctrl+c",
// Kill ring
yank: "ctrl+y",
yankPop: "alt+y",
// Undo
undo: "ctrl+-",
// Tool output
expandTools: "ctrl+o",
// Tree navigation
treeFoldOrUp: ["ctrl+left", "alt+left"],
treeUnfoldOrDown: ["ctrl+right", "alt+right"],
// Session
toggleSessionPath: "ctrl+p",
toggleSessionSort: "ctrl+s",
renameSession: "ctrl+r",
deleteSession: "ctrl+d",
deleteSessionNoninvasive: "ctrl+backspace",
};
export type KeybindingDefinitions = Record<string, KeybindingDefinition>;
export type KeybindingsConfig = Record<string, KeyId | KeyId[] | undefined>;
/**
* Manages keybindings for the editor.
*/
export class EditorKeybindingsManager {
private actionToKeys: Map<EditorAction, KeyId[]>;
export const TUI_KEYBINDINGS = {
"tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" },
"tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" },
"tui.editor.cursorLeft": {
defaultKeys: ["left", "ctrl+b"],
description: "Move cursor left",
},
"tui.editor.cursorRight": {
defaultKeys: ["right", "ctrl+f"],
description: "Move cursor right",
},
"tui.editor.cursorWordLeft": {
defaultKeys: ["alt+left", "ctrl+left", "alt+b"],
description: "Move cursor word left",
},
"tui.editor.cursorWordRight": {
defaultKeys: ["alt+right", "ctrl+right", "alt+f"],
description: "Move cursor word right",
},
"tui.editor.cursorLineStart": {
defaultKeys: ["home", "ctrl+a"],
description: "Move to line start",
},
"tui.editor.cursorLineEnd": {
defaultKeys: ["end", "ctrl+e"],
description: "Move to line end",
},
"tui.editor.jumpForward": {
defaultKeys: "ctrl+]",
description: "Jump forward to character",
},
"tui.editor.jumpBackward": {
defaultKeys: "ctrl+alt+]",
description: "Jump backward to character",
},
"tui.editor.pageUp": { defaultKeys: "pageUp", description: "Page up" },
"tui.editor.pageDown": { defaultKeys: "pageDown", description: "Page down" },
"tui.editor.deleteCharBackward": {
defaultKeys: "backspace",
description: "Delete character backward",
},
"tui.editor.deleteCharForward": {
defaultKeys: ["delete", "ctrl+d"],
description: "Delete character forward",
},
"tui.editor.deleteWordBackward": {
defaultKeys: ["ctrl+w", "alt+backspace"],
description: "Delete word backward",
},
"tui.editor.deleteWordForward": {
defaultKeys: ["alt+d", "alt+delete"],
description: "Delete word forward",
},
"tui.editor.deleteToLineStart": {
defaultKeys: "ctrl+u",
description: "Delete to line start",
},
"tui.editor.deleteToLineEnd": {
defaultKeys: "ctrl+k",
description: "Delete to line end",
},
"tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
"tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
"tui.editor.undo": { defaultKeys: "ctrl+-", description: "Undo" },
"tui.input.newLine": { defaultKeys: "shift+enter", description: "Insert newline" },
"tui.input.submit": { defaultKeys: "enter", description: "Submit input" },
"tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" },
"tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" },
"tui.select.up": { defaultKeys: "up", description: "Move selection up" },
"tui.select.down": { defaultKeys: "down", description: "Move selection down" },
"tui.select.pageUp": { defaultKeys: "pageUp", description: "Selection page up" },
"tui.select.pageDown": {
defaultKeys: "pageDown",
description: "Selection page down",
},
"tui.select.confirm": { defaultKeys: "enter", description: "Confirm selection" },
"tui.select.cancel": {
defaultKeys: ["escape", "ctrl+c"],
description: "Cancel selection",
},
} as const satisfies KeybindingDefinitions;
constructor(config: EditorKeybindingsConfig = {}) {
this.actionToKeys = new Map();
this.buildMaps(config);
export interface KeybindingConflict {
key: KeyId;
keybindings: string[];
}
function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
if (keys === undefined) return [];
const keyList = Array.isArray(keys) ? keys : [keys];
const seen = new Set<KeyId>();
const result: KeyId[] = [];
for (const key of keyList) {
if (!seen.has(key)) {
seen.add(key);
result.push(key);
}
}
return result;
}
export class KeybindingsManager {
private definitions: KeybindingDefinitions;
private userBindings: KeybindingsConfig;
private keysById = new Map<Keybinding, KeyId[]>();
private conflicts: KeybindingConflict[] = [];
constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
this.definitions = definitions;
this.userBindings = userBindings;
this.rebuild();
}
private buildMaps(config: EditorKeybindingsConfig): void {
this.actionToKeys.clear();
private rebuild(): void {
this.keysById.clear();
this.conflicts = [];
// Start with defaults
for (const [action, keys] of Object.entries(DEFAULT_EDITOR_KEYBINDINGS)) {
const keyArray = Array.isArray(keys) ? keys : [keys];
this.actionToKeys.set(action as EditorAction, [...keyArray]);
const userClaims = new Map<KeyId, Set<Keybinding>>();
for (const [keybinding, keys] of Object.entries(this.userBindings)) {
if (!(keybinding in this.definitions)) continue;
for (const key of normalizeKeys(keys)) {
const claimants = userClaims.get(key) ?? new Set<Keybinding>();
claimants.add(keybinding as Keybinding);
userClaims.set(key, claimants);
}
}
// Override with user config
for (const [action, keys] of Object.entries(config)) {
if (keys === undefined) continue;
const keyArray = Array.isArray(keys) ? keys : [keys];
this.actionToKeys.set(action as EditorAction, keyArray);
for (const [key, keybindings] of userClaims) {
if (keybindings.size > 1) {
this.conflicts.push({ key, keybindings: [...keybindings] });
}
}
for (const [id, definition] of Object.entries(this.definitions)) {
const defaults = normalizeKeys(definition.defaultKeys);
const keys = defaults.filter((key) => {
const claimants = userClaims.get(key);
if (!claimants) return true;
return claimants.size === 1 && claimants.has(id as Keybinding);
});
this.keysById.set(id as Keybinding, keys);
}
for (const [keybinding, keys] of Object.entries(this.userBindings)) {
if (!(keybinding in this.definitions)) continue;
this.keysById.set(keybinding as Keybinding, normalizeKeys(keys));
}
}
/**
* Check if input matches a specific action.
*/
matches(data: string, action: EditorAction): boolean {
const keys = this.actionToKeys.get(action);
if (!keys) return false;
matches(data: string, keybinding: Keybinding): boolean {
const keys = this.keysById.get(keybinding) ?? [];
for (const key of keys) {
if (matchesKey(data, key)) return true;
}
return false;
}
/**
* Get keys bound to an action.
*/
getKeys(action: EditorAction): KeyId[] {
return this.actionToKeys.get(action) ?? [];
getKeys(keybinding: Keybinding): KeyId[] {
return [...(this.keysById.get(keybinding) ?? [])];
}
/**
* Update configuration.
*/
setConfig(config: EditorKeybindingsConfig): void {
this.buildMaps(config);
getDefinition(keybinding: Keybinding): KeybindingDefinition {
return this.definitions[keybinding];
}
getConflicts(): KeybindingConflict[] {
return this.conflicts.map((conflict) => ({ ...conflict, keybindings: [...conflict.keybindings] }));
}
setUserBindings(userBindings: KeybindingsConfig): void {
this.userBindings = userBindings;
this.rebuild();
}
getUserBindings(): KeybindingsConfig {
return { ...this.userBindings };
}
getResolvedBindings(): KeybindingsConfig {
const resolved: KeybindingsConfig = {};
for (const id of Object.keys(this.definitions)) {
const keys = this.keysById.get(id as Keybinding) ?? [];
resolved[id] = keys.length === 1 ? keys[0]! : [...keys];
}
return resolved;
}
}
// Global instance
let globalEditorKeybindings: EditorKeybindingsManager | null = null;
let globalKeybindings: KeybindingsManager | null = null;
export function getEditorKeybindings(): EditorKeybindingsManager {
if (!globalEditorKeybindings) {
globalEditorKeybindings = new EditorKeybindingsManager();
export function setKeybindings(keybindings: KeybindingsManager): void {
globalKeybindings = keybindings;
}
export function getKeybindings(): KeybindingsManager {
if (!globalKeybindings) {
globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS);
}
return globalEditorKeybindings;
}
export function setEditorKeybindings(manager: EditorKeybindingsManager): void {
globalEditorKeybindings = manager;
return globalKeybindings;
}