fix: route all app-level shortcuts through KeybindingsManager

fixes #468
This commit is contained in:
can1357
2026-03-18 22:29:38 +01:00
parent 66c80fd613
commit fb76484aa4
9 changed files with 229 additions and 162 deletions
@@ -1,34 +1,89 @@
import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
import type { AppAction } from "../../config/keybindings";
type ConfigurableEditorAction = Extract<
AppAction,
| "interrupt"
| "clear"
| "exit"
| "suspend"
| "cycleThinkingLevel"
| "cycleModelForward"
| "cycleModelBackward"
| "selectModel"
| "expandTools"
| "toggleThinking"
| "externalEditor"
| "historySearch"
| "dequeue"
| "pasteImage"
| "copyPrompt"
>;
const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, 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"],
historySearch: ["ctrl+r"],
dequeue: ["alt+up"],
pasteImage: ["ctrl+v"],
copyPrompt: ["alt+shift+c"],
};
/**
* Custom editor that handles Escape and Ctrl+C keys for coding-agent
* Custom editor that handles configurable app-level shortcuts for coding-agent.
*/
export class CustomEditor extends Editor {
onEscape?: () => void;
shouldBypassAutocompleteOnEscape?: () => boolean;
onCtrlC?: () => void;
onCtrlD?: () => void;
onShiftTab?: () => void;
onCtrlP?: () => void;
onShiftCtrlP?: () => void;
onCtrlL?: () => void;
onCtrlR?: () => void;
onCtrlO?: () => void;
onCtrlT?: () => void;
onCtrlG?: () => void;
onCtrlZ?: () => void;
onQuestionMark?: () => void;
onCapsLock?: () => void;
onAltP?: () => void;
/** Called when Alt+Shift+C is pressed to copy prompt to clipboard. */
onClear?: () => void;
onExit?: () => void;
onCycleThinkingLevel?: () => void;
onCycleModelForward?: () => void;
onCycleModelBackward?: () => void;
onSelectModel?: () => void;
onExpandTools?: () => void;
onToggleThinking?: () => void;
onExternalEditor?: () => void;
onHistorySearch?: () => void;
onSuspend?: () => void;
onShowHotkeys?: () => void;
onQuickSelectModel?: () => void;
/** Called when the configured copy-prompt shortcut is pressed. */
onCopyPrompt?: () => void;
/** Called when Ctrl+V is pressed. Returns true if handled (image found), false to fall through to text paste. */
onCtrlV?: () => Promise<boolean>;
/** Called when Alt+Up is pressed (dequeue keybinding). */
onAltUp?: () => void;
/** Called when the configured image-paste shortcut is pressed. */
onPasteImage?: () => Promise<boolean>;
/** Called when the configured dequeue shortcut is pressed. */
onDequeue?: () => void;
/** Called when Caps Lock is pressed. */
onCapsLock?: () => void;
/** Custom key handlers from extensions */
/** Custom key handlers from extensions and non-built-in app actions. */
#customKeyHandlers = new Map<KeyId, () => void>();
#actionKeys = new Map<ConfigurableEditorAction, KeyId[]>(
Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]),
);
setActionKeys(action: ConfigurableEditorAction, keys: KeyId[]): void {
this.#actionKeys.set(action, [...keys]);
}
#matchesAction(data: string, action: ConfigurableEditorAction): boolean {
const keys = this.#actionKeys.get(action);
if (!keys) return false;
for (const key of keys) {
if (matchesKey(data, key)) return true;
}
return false;
}
/**
* Register a custom key handler. Extensions use this for shortcuts.
@@ -59,111 +114,111 @@ export class CustomEditor extends Editor {
return;
}
// Intercept Ctrl+V for image paste (async - fires and handles result)
if (matchesKey(data, "ctrl+v") && this.onCtrlV) {
void this.onCtrlV();
// Intercept configured image paste (async - fires and handles result)
if (this.#matchesAction(data, "pasteImage") && this.onPasteImage) {
void this.onPasteImage();
return;
}
// Intercept Ctrl+G for external editor
if (matchesKey(data, "ctrl+g") && this.onCtrlG) {
this.onCtrlG();
// Intercept configured external editor shortcut
if (this.#matchesAction(data, "externalEditor") && this.onExternalEditor) {
this.onExternalEditor();
return;
}
// Intercept Alt+P for quick model switching
if (matchesKey(data, "alt+p") && this.onAltP) {
this.onAltP();
if (matchesKey(data, "alt+p") && this.onQuickSelectModel) {
this.onQuickSelectModel();
return;
}
// Intercept Ctrl+Z for suspend
if (matchesKey(data, "ctrl+z") && this.onCtrlZ) {
this.onCtrlZ();
// Intercept configured suspend shortcut
if (this.#matchesAction(data, "suspend") && this.onSuspend) {
this.onSuspend();
return;
}
// Intercept Ctrl+T for thinking block visibility toggle
if (matchesKey(data, "ctrl+t") && this.onCtrlT) {
this.onCtrlT();
// Intercept configured thinking block visibility toggle
if (this.#matchesAction(data, "toggleThinking") && this.onToggleThinking) {
this.onToggleThinking();
return;
}
// Intercept Ctrl+L for model selector
if (matchesKey(data, "ctrl+l") && this.onCtrlL) {
this.onCtrlL();
// Intercept configured model selector shortcut
if (this.#matchesAction(data, "selectModel") && this.onSelectModel) {
this.onSelectModel();
return;
}
// Intercept Ctrl+R for history search
if (matchesKey(data, "ctrl+r") && this.onCtrlR) {
this.onCtrlR();
// Intercept configured history search shortcut
if (this.#matchesAction(data, "historySearch") && this.onHistorySearch) {
this.onHistorySearch();
return;
}
// Intercept Ctrl+O for tool output expansion
if (matchesKey(data, "ctrl+o") && this.onCtrlO) {
this.onCtrlO();
// Intercept configured tool output expansion shortcut
if (this.#matchesAction(data, "expandTools") && this.onExpandTools) {
this.onExpandTools();
return;
}
// Intercept Shift+Ctrl+P for backward model cycling (check before Ctrl+P)
if ((matchesKey(data, "shift+ctrl+p") || matchesKey(data, "ctrl+shift+p")) && this.onShiftCtrlP) {
this.onShiftCtrlP();
// Intercept configured backward model cycling (check before forward cycling)
if (this.#matchesAction(data, "cycleModelBackward") && this.onCycleModelBackward) {
this.onCycleModelBackward();
return;
}
// Intercept Ctrl+P for model cycling
if (matchesKey(data, "ctrl+p") && this.onCtrlP) {
this.onCtrlP();
// Intercept configured forward model cycling
if (this.#matchesAction(data, "cycleModelForward") && this.onCycleModelForward) {
this.onCycleModelForward();
return;
}
// Intercept Shift+Tab for thinking level cycling
if (matchesKey(data, "shift+tab") && this.onShiftTab) {
this.onShiftTab();
// Intercept configured thinking level cycling
if (this.#matchesAction(data, "cycleThinkingLevel") && this.onCycleThinkingLevel) {
this.onCycleThinkingLevel();
return;
}
// Intercept Escape key.
// Default behavior keeps autocomplete dismissal, but parent can prioritize global escape handling.
if ((matchesKey(data, "escape") || matchesKey(data, "esc")) && this.onEscape) {
// Intercept configured interrupt shortcut.
// Default behavior keeps autocomplete dismissal, but parent can prioritize global interrupt handling.
if (this.#matchesAction(data, "interrupt") && this.onEscape) {
if (!this.isShowingAutocomplete() || this.shouldBypassAutocompleteOnEscape?.()) {
this.onEscape();
return;
}
}
// Intercept Ctrl+C
if (matchesKey(data, "ctrl+c") && this.onCtrlC) {
this.onCtrlC();
// Intercept configured clear shortcut
if (this.#matchesAction(data, "clear") && this.onClear) {
this.onClear();
return;
}
// Intercept Ctrl+D (only when editor is empty)
if (matchesKey(data, "ctrl+d")) {
if (this.getText().length === 0 && this.onCtrlD) {
this.onCtrlD();
// Intercept configured exit shortcut (only when editor is empty)
if (this.#matchesAction(data, "exit")) {
if (this.getText().length === 0 && this.onExit) {
this.onExit();
}
// Always consume Ctrl+D (don't pass to parent)
// Always consume exit shortcut (don't pass to parent)
return;
}
// Intercept Alt+Up for dequeue (restore queued message to editor)
if (matchesKey(data, "alt+up") && this.onAltUp) {
this.onAltUp();
// Intercept configured dequeue shortcut (restore queued message to editor)
if (this.#matchesAction(data, "dequeue") && this.onDequeue) {
this.onDequeue();
return;
}
// Intercept Alt+Shift+C to copy prompt to clipboard
if (matchesKey(data, "alt+shift+c") && this.onCopyPrompt) {
// Intercept configured copy-prompt shortcut
if (this.#matchesAction(data, "copyPrompt") && this.onCopyPrompt) {
this.onCopyPrompt();
return;
}
// Intercept ? when editor is empty to show hotkeys
if (data === "?" && this.getText().length === 0 && this.onQuestionMark) {
this.onQuestionMark();
if (data === "?" && this.getText().length === 0 && this.onShowHotkeys) {
this.onShowHotkeys();
return;
}