fix(keybindings): migrate to namespaced ids closes #2391
This commit is contained in:
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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, "");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user