co-mono/packages/coding-agent/src/core/keybindings.ts
Helmut Januschka 8f2682578b 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"
}
2026-01-03 08:23:56 +01:00

199 lines
4.6 KiB
TypeScript

import {
DEFAULT_EDITOR_KEYBINDINGS,
type EditorAction,
type EditorKeybindingsConfig,
EditorKeybindingsManager,
type KeyId,
matchesKey,
setEditorKeybindings,
} from "@mariozechner/pi-tui";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { getAgentDir } from "../config.js";
/**
* Application-level actions (coding agent specific).
*/
export type AppAction =
| "interrupt"
| "clear"
| "exit"
| "suspend"
| "cycleThinkingLevel"
| "cycleModelForward"
| "cycleModelBackward"
| "selectModel"
| "expandTools"
| "toggleThinking"
| "externalEditor"
| "followUp";
/**
* All configurable actions.
*/
export type KeyAction = AppAction | EditorAction;
/**
* Full keybindings configuration (app + editor actions).
*/
export type KeybindingsConfig = {
[K in KeyAction]?: KeyId | KeyId[];
};
/**
* Default application keybindings.
*/
export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
interrupt: "escape",
clear: "ctrl+c",
exit: "ctrl+d",
suspend: "ctrl+z",
cycleThinkingLevel: "shift+tab",
cycleModelForward: "ctrl+p",
cycleModelBackward: "shift+ctrl+p",
selectModel: "ctrl+l",
expandTools: "ctrl+o",
toggleThinking: "ctrl+t",
externalEditor: "ctrl+g",
followUp: "alt+enter",
};
/**
* All default keybindings (app + editor).
*/
export const DEFAULT_KEYBINDINGS: Required<KeybindingsConfig> = {
...DEFAULT_EDITOR_KEYBINDINGS,
...DEFAULT_APP_KEYBINDINGS,
};
// App actions list for type checking
const APP_ACTIONS: AppAction[] = [
"interrupt",
"clear",
"exit",
"suspend",
"cycleThinkingLevel",
"cycleModelForward",
"cycleModelBackward",
"selectModel",
"expandTools",
"toggleThinking",
"externalEditor",
"followUp",
];
function isAppAction(action: string): action is AppAction {
return APP_ACTIONS.includes(action as AppAction);
}
/**
* Manages all keybindings (app + editor).
*/
export class KeybindingsManager {
private config: KeybindingsConfig;
private appActionToKeys: Map<AppAction, KeyId[]>;
private constructor(config: KeybindingsConfig) {
this.config = config;
this.appActionToKeys = new Map();
this.buildMaps();
}
/**
* Create from config file and set up editor keybindings.
*/
static create(agentDir: string = getAgentDir()): KeybindingsManager {
const configPath = join(agentDir, "keybindings.json");
const config = KeybindingsManager.loadFromFile(configPath);
const manager = new KeybindingsManager(config);
// Set up editor keybindings globally
const editorConfig: EditorKeybindingsConfig = {};
for (const [action, keys] of Object.entries(config)) {
if (!isAppAction(action)) {
editorConfig[action as EditorAction] = keys;
}
}
setEditorKeybindings(new EditorKeybindingsManager(editorConfig));
return manager;
}
/**
* Create in-memory.
*/
static inMemory(config: KeybindingsConfig = {}): KeybindingsManager {
return new KeybindingsManager(config);
}
private static loadFromFile(path: string): KeybindingsConfig {
if (!existsSync(path)) return {};
try {
return JSON.parse(readFileSync(path, "utf-8"));
} catch {
return {};
}
}
private buildMaps(): void {
this.appActionToKeys.clear();
// Set defaults for app actions
for (const [action, keys] of Object.entries(DEFAULT_APP_KEYBINDINGS)) {
const keyArray = Array.isArray(keys) ? keys : [keys];
this.appActionToKeys.set(action as AppAction, [...keyArray]);
}
// Override with user config (app actions only)
for (const [action, keys] of Object.entries(this.config)) {
if (keys === undefined || !isAppAction(action)) continue;
const keyArray = Array.isArray(keys) ? keys : [keys];
this.appActionToKeys.set(action, keyArray);
}
}
/**
* Check if input matches an app action.
*/
matches(data: string, action: AppAction): boolean {
const keys = this.appActionToKeys.get(action);
if (!keys) return false;
for (const key of keys) {
if (matchesKey(data, key)) return true;
}
return false;
}
/**
* Get keys bound to an app action.
*/
getKeys(action: AppAction): KeyId[] {
return this.appActionToKeys.get(action) ?? [];
}
/**
* Get display string for an action.
*/
getDisplayString(action: AppAction): string {
const keys = this.getKeys(action);
if (keys.length === 0) return "";
if (keys.length === 1) return keys[0]!;
return keys.join("/");
}
/**
* Get the full effective config.
*/
getEffectiveConfig(): Required<KeybindingsConfig> {
const result = { ...DEFAULT_KEYBINDINGS };
for (const [action, keys] of Object.entries(this.config)) {
if (keys !== undefined) {
(result as KeybindingsConfig)[action as KeyAction] = keys;
}
}
return result;
}
}
// Re-export for convenience
export type { EditorAction, KeyId };