feat: configurable keybindings for all editor and app actions

All keybindings configurable via ~/.pi/agent/keybindings.json

Editor actions:
- cursorUp, cursorDown, cursorLeft, cursorRight
- cursorWordLeft, cursorWordRight, cursorLineStart, cursorLineEnd
- deleteCharBackward, deleteCharForward, deleteWordBackward
- deleteToLineStart, deleteToLineEnd
- newLine, submit, tab
- selectUp, selectDown, selectConfirm, selectCancel

App actions:
- interrupt, clear, exit, suspend
- cycleThinkingLevel, cycleModelForward, cycleModelBackward
- selectModel, expandTools, toggleThinking, externalEditor

Also adds support for numpad Enter key (Kitty protocol codepoint 57414
and SS3 M sequence)

Example emacs-style keybindings.json:
{
  "cursorUp": ["up", "ctrl+p"],
  "cursorDown": ["down", "ctrl+n"],
  "cursorLeft": ["left", "ctrl+b"],
  "cursorRight": ["right", "ctrl+f"],
  "deleteCharForward": ["delete", "ctrl+d"],
  "cycleModelForward": "ctrl+o"
}
This commit is contained in:
Helmut Januschka 2026-01-02 17:31:54 +01:00
parent 5f91baa29e
commit 8f2682578b
23 changed files with 949 additions and 1048 deletions

View file

@ -0,0 +1,145 @@
import { matchesKey } from "./keys.js";
/**
* Editor actions that can be bound to keys.
*/
export type EditorAction =
// Cursor movement
| "cursorUp"
| "cursorDown"
| "cursorLeft"
| "cursorRight"
| "cursorWordLeft"
| "cursorWordRight"
| "cursorLineStart"
| "cursorLineEnd"
// Deletion
| "deleteCharBackward"
| "deleteCharForward"
| "deleteWordBackward"
| "deleteToLineStart"
| "deleteToLineEnd"
// Text input
| "newLine"
| "submit"
| "tab"
// Selection/autocomplete
| "selectUp"
| "selectDown"
| "selectConfirm"
| "selectCancel"
// Clipboard
| "copy";
/**
* Key identifier string (e.g., "ctrl+c", "shift+ctrl+p", "escape").
*/
export type KeyId = string;
/**
* Editor keybindings configuration.
*/
export type EditorKeybindingsConfig = {
[K in EditorAction]?: KeyId | KeyId[];
};
/**
* Default editor keybindings.
*/
export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
// Cursor movement
cursorUp: "up",
cursorDown: "down",
cursorLeft: "left",
cursorRight: "right",
cursorWordLeft: ["alt+left", "ctrl+left"],
cursorWordRight: ["alt+right", "ctrl+right"],
cursorLineStart: ["home", "ctrl+a"],
cursorLineEnd: ["end", "ctrl+e"],
// Deletion
deleteCharBackward: "backspace",
deleteCharForward: "delete",
deleteWordBackward: ["ctrl+w", "alt+backspace"],
deleteToLineStart: "ctrl+u",
deleteToLineEnd: "ctrl+k",
// Text input
newLine: ["shift+enter", "alt+enter"],
submit: "enter",
tab: "tab",
// Selection/autocomplete
selectUp: "up",
selectDown: "down",
selectConfirm: "enter",
selectCancel: "escape",
// Clipboard
copy: "ctrl+c",
};
/**
* Manages keybindings for the editor.
*/
export class EditorKeybindingsManager {
private actionToKeys: Map<EditorAction, KeyId[]>;
constructor(config: EditorKeybindingsConfig = {}) {
this.actionToKeys = new Map();
this.buildMaps(config);
}
private buildMaps(config: EditorKeybindingsConfig): void {
this.actionToKeys.clear();
// 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]);
}
// 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);
}
}
/**
* Check if input matches a specific action.
*/
matches(data: string, action: EditorAction): boolean {
const keys = this.actionToKeys.get(action);
if (!keys) return false;
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) ?? [];
}
/**
* Update configuration.
*/
setConfig(config: EditorKeybindingsConfig): void {
this.buildMaps(config);
}
}
// Global instance
let globalEditorKeybindings: EditorKeybindingsManager | null = null;
export function getEditorKeybindings(): EditorKeybindingsManager {
if (!globalEditorKeybindings) {
globalEditorKeybindings = new EditorKeybindingsManager();
}
return globalEditorKeybindings;
}
export function setEditorKeybindings(manager: EditorKeybindingsManager): void {
globalEditorKeybindings = manager;
}