diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 848ade8c..ea5484e0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Configurable keybindings via `~/.pi/agent/keybindings.json`. All keyboard shortcuts (editor navigation, deletion, app actions like model cycling, etc.) can now be customized. Supports multiple bindings per action. ([#405](https://github.com/badlogic/pi-mono/pull/405) by [@hjanuschka](https://github.com/hjanuschka)) + ## [0.32.3] - 2026-01-03 ### Fixed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 68e8179b..2d2d9d56 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -24,6 +24,7 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows- - [Slash Commands](#slash-commands) - [Editor Features](#editor-features) - [Keyboard Shortcuts](#keyboard-shortcuts) + - [Custom Keybindings](#custom-keybindings) - [Bash Mode](#bash-mode) - [Image Support](#image-support) - [Sessions](#sessions) @@ -266,6 +267,61 @@ Both modes are configurable via `/settings`: "one-at-a-time" delivers messages o | Ctrl+T | Toggle thinking block visibility | | Ctrl+G | Edit message in external editor (`$VISUAL` or `$EDITOR`) | +### Custom Keybindings + +All keyboard shortcuts can be customized via `~/.pi/agent/keybindings.json`. Each action can be bound to one or more keys. + +**Key format:** `modifier+key` where modifiers are `ctrl`, `shift`, `alt` and keys are `a-z`, `0-9`, `escape`, `tab`, `enter`, `space`, `backspace`, `delete`, `home`, `end`, `up`, `down`, `left`, `right`. + +**Configurable actions:** + +| Action | Default | Description | +|--------|---------|-------------| +| `cursorUp` | `up` | Move cursor up | +| `cursorDown` | `down` | Move cursor down | +| `cursorLeft` | `left` | Move cursor left | +| `cursorRight` | `right` | Move cursor right | +| `cursorWordLeft` | `alt+left`, `ctrl+left` | Move cursor word left | +| `cursorWordRight` | `alt+right`, `ctrl+right` | Move cursor word right | +| `cursorLineStart` | `home`, `ctrl+a` | Move to line start | +| `cursorLineEnd` | `end`, `ctrl+e` | Move to line end | +| `deleteCharBackward` | `backspace` | Delete char backward | +| `deleteCharForward` | `delete` | Delete char forward | +| `deleteWordBackward` | `ctrl+w`, `alt+backspace` | Delete word backward | +| `deleteToLineStart` | `ctrl+u` | Delete to line start | +| `deleteToLineEnd` | `ctrl+k` | Delete to line end | +| `newLine` | `shift+enter`, `alt+enter` | Insert new line | +| `submit` | `enter` | Submit input | +| `tab` | `tab` | Tab/autocomplete | +| `interrupt` | `escape` | Interrupt operation | +| `clear` | `ctrl+c` | Clear editor | +| `exit` | `ctrl+d` | Exit (when empty) | +| `suspend` | `ctrl+z` | Suspend process | +| `cycleThinkingLevel` | `shift+tab` | Cycle thinking level | +| `cycleModelForward` | `ctrl+p` | Next model | +| `cycleModelBackward` | `shift+ctrl+p` | Previous model | +| `selectModel` | `ctrl+l` | Open model selector | +| `expandTools` | `ctrl+o` | Expand tool output | +| `toggleThinking` | `ctrl+t` | Toggle thinking | +| `externalEditor` | `ctrl+g` | Open external editor | +| `followUp` | `alt+enter` | Queue follow-up message | + +**Example (Emacs-style):** + +```json +{ + "cursorUp": ["up", "ctrl+p"], + "cursorDown": ["down", "ctrl+n"], + "cursorLeft": ["left", "ctrl+b"], + "cursorRight": ["right", "ctrl+f"], + "cursorWordLeft": ["alt+left", "alt+b"], + "cursorWordRight": ["alt+right", "alt+f"], + "deleteCharForward": ["delete", "ctrl+d"], + "deleteCharBackward": ["backspace", "ctrl+h"], + "newLine": ["shift+enter", "ctrl+j"] +} +``` + ### Bash Mode Prefix commands with `!` to execute them and add output to context: diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index c90cb151..a186f1b7 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -3,7 +3,7 @@ */ import type { HookAPI } from "@mariozechner/pi-coding-agent"; -import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape, visibleWidth } from "@mariozechner/pi-tui"; +import { matchesKey, visibleWidth } from "@mariozechner/pi-tui"; const GAME_WIDTH = 40; const GAME_HEIGHT = 15; @@ -150,7 +150,7 @@ class SnakeComponent { handleInput(data: string): void { // If paused (resuming), wait for any key if (this.paused) { - if (isEscape(data) || data === "q" || data === "Q") { + if (matchesKey(data, "escape") || data === "q" || data === "Q") { // Quit without clearing save this.dispose(); this.onClose(); @@ -163,7 +163,7 @@ class SnakeComponent { } // ESC to pause and save - if (isEscape(data)) { + if (matchesKey(data, "escape")) { this.dispose(); this.onSave(this.state); this.onClose(); @@ -179,13 +179,13 @@ class SnakeComponent { } // Arrow keys or WASD - if (isArrowUp(data) || data === "w" || data === "W") { + if (matchesKey(data, "up") || data === "w" || data === "W") { if (this.state.direction !== "down") this.state.nextDirection = "up"; - } else if (isArrowDown(data) || data === "s" || data === "S") { + } else if (matchesKey(data, "down") || data === "s" || data === "S") { if (this.state.direction !== "up") this.state.nextDirection = "down"; - } else if (isArrowRight(data) || data === "d" || data === "D") { + } else if (matchesKey(data, "right") || data === "d" || data === "D") { if (this.state.direction !== "left") this.state.nextDirection = "right"; - } else if (isArrowLeft(data) || data === "a" || data === "A") { + } else if (matchesKey(data, "left") || data === "a" || data === "A") { if (this.state.direction !== "right") this.state.nextDirection = "left"; } diff --git a/packages/coding-agent/examples/hooks/todo/index.ts b/packages/coding-agent/examples/hooks/todo/index.ts index 607eb6d0..4f4a7df1 100644 --- a/packages/coding-agent/examples/hooks/todo/index.ts +++ b/packages/coding-agent/examples/hooks/todo/index.ts @@ -6,7 +6,7 @@ */ import type { HookAPI, Theme } from "@mariozechner/pi-coding-agent"; -import { isCtrlC, isEscape, truncateToWidth } from "@mariozechner/pi-tui"; +import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui"; interface Todo { id: number; @@ -35,7 +35,7 @@ class TodoListComponent { } handleInput(data: string): void { - if (isEscape(data) || isCtrlC(data)) { + if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.onClose(); } } diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts new file mode 100644 index 00000000..1d00e7ff --- /dev/null +++ b/packages/coding-agent/src/core/keybindings.ts @@ -0,0 +1,199 @@ +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 = { + 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 = { + ...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; + + 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 { + 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 }; diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index c0b951bd..f9e3fa4e 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,113 +1,65 @@ -import { - Editor, - isAltEnter, - isCtrlC, - isCtrlD, - isCtrlG, - isCtrlL, - isCtrlO, - isCtrlP, - isCtrlT, - isCtrlZ, - isEscape, - isShiftCtrlP, - isShiftTab, -} from "@mariozechner/pi-tui"; +import { Editor, type EditorTheme } from "@mariozechner/pi-tui"; +import type { AppAction, KeybindingsManager } from "../../../core/keybindings.js"; /** - * Custom editor that handles Escape and Ctrl+C keys for coding-agent + * Custom editor that handles app-level keybindings for coding-agent. */ export class CustomEditor extends Editor { + private keybindings: KeybindingsManager; + private actionHandlers: Map void> = new Map(); + + // Special handlers that can be dynamically replaced public onEscape?: () => void; - public onCtrlC?: () => void; public onCtrlD?: () => void; - public onShiftTab?: () => void; - public onCtrlP?: () => void; - public onShiftCtrlP?: () => void; - public onCtrlL?: () => void; - public onCtrlO?: () => void; - public onCtrlT?: () => void; - public onCtrlG?: () => void; - public onCtrlZ?: () => void; - public onAltEnter?: () => void; + + constructor(theme: EditorTheme, keybindings: KeybindingsManager) { + super(theme); + this.keybindings = keybindings; + } + + /** + * Register a handler for an app action. + */ + onAction(action: AppAction, handler: () => void): void { + this.actionHandlers.set(action, handler); + } handleInput(data: string): void { - // Intercept Alt+Enter for follow-up messages - if (isAltEnter(data) && this.onAltEnter) { - this.onAltEnter(); - return; - } - // Intercept Ctrl+G for external editor - if (isCtrlG(data) && this.onCtrlG) { - this.onCtrlG(); - return; - } + // Check app keybindings first - // Intercept Ctrl+Z for suspend - if (isCtrlZ(data) && this.onCtrlZ) { - this.onCtrlZ(); - return; - } - - // Intercept Ctrl+T for thinking block visibility toggle - if (isCtrlT(data) && this.onCtrlT) { - this.onCtrlT(); - return; - } - - // Intercept Ctrl+L for model selector - if (isCtrlL(data) && this.onCtrlL) { - this.onCtrlL(); - return; - } - - // Intercept Ctrl+O for tool output expansion - if (isCtrlO(data) && this.onCtrlO) { - this.onCtrlO(); - return; - } - - // Intercept Shift+Ctrl+P for backward model cycling (check before Ctrl+P) - if (isShiftCtrlP(data) && this.onShiftCtrlP) { - this.onShiftCtrlP(); - return; - } - - // Intercept Ctrl+P for model cycling - if (isCtrlP(data) && this.onCtrlP) { - this.onCtrlP(); - return; - } - - // Intercept Shift+Tab for thinking level cycling - if (isShiftTab(data) && this.onShiftTab) { - this.onShiftTab(); - return; - } - - // Intercept Escape key - but only if autocomplete is NOT active - // (let parent handle escape for autocomplete cancellation) - if (isEscape(data) && this.onEscape && !this.isShowingAutocomplete()) { - this.onEscape(); - return; - } - - // Intercept Ctrl+C - if (isCtrlC(data) && this.onCtrlC) { - this.onCtrlC(); - return; - } - - // Intercept Ctrl+D (only when editor is empty) - if (isCtrlD(data)) { - if (this.getText().length === 0 && this.onCtrlD) { - this.onCtrlD(); + // Escape/interrupt - only if autocomplete is NOT active + if (this.keybindings.matches(data, "interrupt")) { + if (!this.isShowingAutocomplete()) { + // Use dynamic onEscape if set, otherwise registered handler + const handler = this.onEscape ?? this.actionHandlers.get("interrupt"); + if (handler) { + handler(); + return; + } } - // Always consume Ctrl+D (don't pass to parent) + // Let parent handle escape for autocomplete cancellation + super.handleInput(data); return; } - // Pass to parent for normal handling + // Exit (Ctrl+D) - only when editor is empty + if (this.keybindings.matches(data, "exit")) { + if (this.getText().length === 0) { + const handler = this.onCtrlD ?? this.actionHandlers.get("exit"); + if (handler) handler(); + } + return; // Always consume + } + + // Check all other app actions + for (const [action, handler] of this.actionHandlers) { + if (action !== "interrupt" && action !== "exit" && this.keybindings.matches(data, action)) { + handler(); + return; + } + } + + // Pass to parent for editor handling super.handleInput(data); } } diff --git a/packages/coding-agent/src/modes/interactive/components/hook-editor.ts b/packages/coding-agent/src/modes/interactive/components/hook-editor.ts index 3b1282b4..c07a634c 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-editor.ts @@ -7,7 +7,7 @@ import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { Container, Editor, isCtrlC, isCtrlG, isEscape, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import { Container, Editor, getEditorKeybindings, matchesKey, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; import { getEditorTheme, theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -67,14 +67,15 @@ export class HookEditorComponent extends Container { return; } + const kb = getEditorKeybindings(); // Escape or Ctrl+C to cancel - if (isEscape(keyData) || isCtrlC(keyData)) { + if (kb.matches(keyData, "selectCancel")) { this.onCancelCallback(); return; } - // Ctrl+G for external editor - if (isCtrlG(keyData)) { + // Ctrl+G for external editor (keep matchesKey for this app-specific action) + if (matchesKey(keyData, "ctrl+g")) { this.openExternalEditor(); return; } diff --git a/packages/coding-agent/src/modes/interactive/components/hook-input.ts b/packages/coding-agent/src/modes/interactive/components/hook-input.ts index e76c41e5..0f56fc49 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-input.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-input.ts @@ -2,7 +2,7 @@ * Simple text input component for hooks. */ -import { Container, Input, isCtrlC, isEnter, isEscape, Spacer, Text } from "@mariozechner/pi-tui"; +import { Container, getEditorKeybindings, Input, Spacer, Text } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -46,14 +46,15 @@ export class HookInputComponent extends Container { } handleInput(keyData: string): void { + const kb = getEditorKeybindings(); // Enter - if (isEnter(keyData) || keyData === "\n") { + if (kb.matches(keyData, "selectConfirm") || keyData === "\n") { this.onSubmitCallback(this.input.getValue()); return; } // Escape or Ctrl+C to cancel - if (isEscape(keyData) || isCtrlC(keyData)) { + if (kb.matches(keyData, "selectCancel")) { this.onCancelCallback(); return; } diff --git a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts b/packages/coding-agent/src/modes/interactive/components/hook-selector.ts index 2238c79f..756d463a 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-selector.ts @@ -3,7 +3,7 @@ * Displays a list of string options with keyboard navigation. */ -import { Container, isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape, Spacer, Text } from "@mariozechner/pi-tui"; +import { Container, getEditorKeybindings, Spacer, Text } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -66,25 +66,26 @@ export class HookSelectorComponent extends Container { } handleInput(keyData: string): void { + const kb = getEditorKeybindings(); // Up arrow or k - if (isArrowUp(keyData) || keyData === "k") { + if (kb.matches(keyData, "selectUp") || keyData === "k") { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.updateList(); } // Down arrow or j - else if (isArrowDown(keyData) || keyData === "j") { + else if (kb.matches(keyData, "selectDown") || keyData === "j") { this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1); this.updateList(); } // Enter - else if (isEnter(keyData) || keyData === "\n") { + else if (kb.matches(keyData, "selectConfirm") || keyData === "\n") { const selected = this.options[this.selectedIndex]; if (selected) { this.onSelectCallback(selected); } } // Escape or Ctrl+C - else if (isEscape(keyData) || isCtrlC(keyData)) { + else if (kb.matches(keyData, "selectCancel")) { this.onCancelCallback(); } } diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 99429220..4b1e5778 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -1,16 +1,5 @@ import { type Model, modelsAreEqual } from "@mariozechner/pi-ai"; -import { - Container, - Input, - isArrowDown, - isArrowUp, - isCtrlC, - isEnter, - isEscape, - Spacer, - Text, - type TUI, -} from "@mariozechner/pi-tui"; +import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; import type { ModelRegistry } from "../../../core/model-registry.js"; import type { SettingsManager } from "../../../core/settings-manager.js"; import { fuzzyFilter } from "../../../utils/fuzzy.js"; @@ -216,27 +205,28 @@ export class ModelSelectorComponent extends Container { } handleInput(keyData: string): void { + const kb = getEditorKeybindings(); // Up arrow - wrap to bottom when at top - if (isArrowUp(keyData)) { + if (kb.matches(keyData, "selectUp")) { if (this.filteredModels.length === 0) return; this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1; this.updateList(); } // Down arrow - wrap to top when at bottom - else if (isArrowDown(keyData)) { + else if (kb.matches(keyData, "selectDown")) { if (this.filteredModels.length === 0) return; this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1; this.updateList(); } // Enter - else if (isEnter(keyData)) { + else if (kb.matches(keyData, "selectConfirm")) { const selectedModel = this.filteredModels[this.selectedIndex]; if (selectedModel) { this.handleSelect(selectedModel.model); } } // Escape or Ctrl+C - else if (isEscape(keyData) || isCtrlC(keyData)) { + else if (kb.matches(keyData, "selectCancel")) { this.onCancelCallback(); } // Pass everything else to search input diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts index ac97c211..5b29281d 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -1,14 +1,5 @@ import { getOAuthProviders, type OAuthProviderInfo } from "@mariozechner/pi-ai"; -import { - Container, - isArrowDown, - isArrowUp, - isCtrlC, - isEnter, - isEscape, - Spacer, - TruncatedText, -} from "@mariozechner/pi-tui"; +import { Container, getEditorKeybindings, Spacer, TruncatedText } from "@mariozechner/pi-tui"; import type { AuthStorage } from "../../../core/auth-storage.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -104,25 +95,26 @@ export class OAuthSelectorComponent extends Container { } handleInput(keyData: string): void { + const kb = getEditorKeybindings(); // Up arrow - if (isArrowUp(keyData)) { + if (kb.matches(keyData, "selectUp")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.updateList(); } // Down arrow - else if (isArrowDown(keyData)) { + else if (kb.matches(keyData, "selectDown")) { this.selectedIndex = Math.min(this.allProviders.length - 1, this.selectedIndex + 1); this.updateList(); } // Enter - else if (isEnter(keyData)) { + else if (kb.matches(keyData, "selectConfirm")) { const selectedProvider = this.allProviders[this.selectedIndex]; if (selectedProvider?.available) { this.onSelectCallback(selectedProvider.id); } } // Escape or Ctrl+C - else if (isEscape(keyData) || isCtrlC(keyData)) { + else if (kb.matches(keyData, "selectCancel")) { this.onCancelCallback(); } } diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 8a4939be..317c72a5 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -1,12 +1,8 @@ import { type Component, Container, + getEditorKeybindings, Input, - isArrowDown, - isArrowUp, - isCtrlC, - isEnter, - isEscape, Spacer, Text, truncateToWidth, @@ -126,31 +122,28 @@ class SessionList implements Component { } handleInput(keyData: string): void { + const kb = getEditorKeybindings(); // Up arrow - if (isArrowUp(keyData)) { + if (kb.matches(keyData, "selectUp")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); } // Down arrow - else if (isArrowDown(keyData)) { + else if (kb.matches(keyData, "selectDown")) { this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1); } // Enter - else if (isEnter(keyData)) { + else if (kb.matches(keyData, "selectConfirm")) { const selected = this.filteredSessions[this.selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.path); } } // Escape - cancel - else if (isEscape(keyData)) { + else if (kb.matches(keyData, "selectCancel")) { if (this.onCancel) { this.onCancel(); } } - // Ctrl+C - exit - else if (isCtrlC(keyData)) { - this.onExit(); - } // Pass everything else to search input else { this.searchInput.handleInput(keyData); diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index bdb61e42..6ad99c1a 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -1,17 +1,9 @@ import { type Component, Container, + getEditorKeybindings, Input, - isArrowDown, - isArrowLeft, - isArrowRight, - isArrowUp, - isBackspace, - isCtrlC, - isCtrlO, - isEnter, - isEscape, - isShiftCtrlO, + matchesKey, Spacer, Text, TruncatedText, @@ -664,43 +656,42 @@ class TreeList implements Component { } handleInput(keyData: string): void { - if (isArrowUp(keyData)) { + const kb = getEditorKeybindings(); + if (kb.matches(keyData, "selectUp")) { this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; - } else if (isArrowDown(keyData)) { + } else if (kb.matches(keyData, "selectDown")) { this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1; - } else if (isArrowLeft(keyData)) { + } else if (kb.matches(keyData, "cursorLeft")) { // Page up this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines); - } else if (isArrowRight(keyData)) { + } else if (kb.matches(keyData, "cursorRight")) { // Page down this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines); - } else if (isEnter(keyData)) { + } else if (kb.matches(keyData, "selectConfirm")) { const selected = this.filteredNodes[this.selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.node.entry.id); } - } else if (isEscape(keyData)) { + } else if (kb.matches(keyData, "selectCancel")) { if (this.searchQuery) { this.searchQuery = ""; this.applyFilter(); } else { this.onCancel?.(); } - } else if (isCtrlC(keyData)) { - this.onCancel?.(); - } else if (isShiftCtrlO(keyData)) { + } else if (matchesKey(keyData, "shift+ctrl+o")) { // Cycle filter backwards const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"]; const currentIndex = modes.indexOf(this.filterMode); this.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length]; this.applyFilter(); - } else if (isCtrlO(keyData)) { + } else if (matchesKey(keyData, "ctrl+o")) { // Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"]; const currentIndex = modes.indexOf(this.filterMode); this.filterMode = modes[(currentIndex + 1) % modes.length]; this.applyFilter(); - } else if (isBackspace(keyData)) { + } else if (kb.matches(keyData, "deleteCharBackward")) { if (this.searchQuery.length > 0) { this.searchQuery = this.searchQuery.slice(0, -1); this.applyFilter(); @@ -768,10 +759,11 @@ class LabelInput implements Component { } handleInput(keyData: string): void { - if (isEnter(keyData)) { + const kb = getEditorKeybindings(); + if (kb.matches(keyData, "selectConfirm")) { const value = this.input.getValue().trim(); this.onSubmit?.(this.entryId, value || undefined); - } else if (isEscape(keyData)) { + } else if (kb.matches(keyData, "selectCancel")) { this.onCancel?.(); } else { this.input.handleInput(keyData); diff --git a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts index 8a8f2152..c38de14a 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts @@ -1,15 +1,4 @@ -import { - type Component, - Container, - isArrowDown, - isArrowUp, - isCtrlC, - isEnter, - isEscape, - Spacer, - Text, - truncateToWidth, -} from "@mariozechner/pi-tui"; +import { type Component, Container, getEditorKeybindings, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -89,29 +78,24 @@ class UserMessageList implements Component { } handleInput(keyData: string): void { + const kb = getEditorKeybindings(); // Up arrow - go to previous (older) message, wrap to bottom when at top - if (isArrowUp(keyData)) { + if (kb.matches(keyData, "selectUp")) { this.selectedIndex = this.selectedIndex === 0 ? this.messages.length - 1 : this.selectedIndex - 1; } // Down arrow - go to next (newer) message, wrap to top when at bottom - else if (isArrowDown(keyData)) { + else if (kb.matches(keyData, "selectDown")) { this.selectedIndex = this.selectedIndex === this.messages.length - 1 ? 0 : this.selectedIndex + 1; } // Enter - select message and branch - else if (isEnter(keyData)) { + else if (kb.matches(keyData, "selectConfirm")) { const selected = this.messages[this.selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.id); } } // Escape - cancel - else if (isEscape(keyData)) { - if (this.onCancel) { - this.onCancel(); - } - } - // Ctrl+C - cancel - else if (isCtrlC(keyData)) { + else if (kb.matches(keyData, "selectCancel")) { if (this.onCancel) { this.onCancel(); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 39f9882c..0f0b862b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -13,6 +13,7 @@ import { CombinedAutocompleteProvider, type Component, Container, + getEditorKeybindings, Input, Loader, Markdown, @@ -28,6 +29,7 @@ import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index.js"; import type { HookUIContext } from "../../core/hooks/index.js"; +import { KeybindingsManager } from "../../core/keybindings.js"; import { createCompactionSummaryMessage } from "../../core/messages.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js"; import { loadSkills } from "../../core/skills.js"; @@ -84,6 +86,7 @@ export class InteractiveMode { private editor: CustomEditor; private editorContainer: Container; private footer: FooterComponent; + private keybindings: KeybindingsManager; private version: string; private isInitialized = false; private onInputCallback?: (text: string) => void; @@ -165,7 +168,8 @@ export class InteractiveMode { this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); this.statusContainer = new Container(); - this.editor = new CustomEditor(getEditorTheme()); + this.keybindings = KeybindingsManager.create(); + this.editor = new CustomEditor(getEditorTheme(), this.keybindings); this.editorContainer = new Container(); this.editorContainer.addChild(this.editor); this.footer = new FooterComponent(session); @@ -217,43 +221,65 @@ export class InteractiveMode { async init(): Promise { if (this.isInitialized) return; - // Add header + // Add header with keybindings from config const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`); + + // Format keybinding for startup display (lowercase, compact) + const formatStartupKey = (keys: string | string[]): string => { + const keyArray = Array.isArray(keys) ? keys : [keys]; + return keyArray.join("/"); + }; + + const kb = this.keybindings; + const interrupt = formatStartupKey(kb.getKeys("interrupt")); + const clear = formatStartupKey(kb.getKeys("clear")); + const exit = formatStartupKey(kb.getKeys("exit")); + const suspend = formatStartupKey(kb.getKeys("suspend")); + const deleteToLineEnd = formatStartupKey(getEditorKeybindings().getKeys("deleteToLineEnd")); + const cycleThinkingLevel = formatStartupKey(kb.getKeys("cycleThinkingLevel")); + const cycleModelForward = formatStartupKey(kb.getKeys("cycleModelForward")); + const cycleModelBackward = formatStartupKey(kb.getKeys("cycleModelBackward")); + const selectModel = formatStartupKey(kb.getKeys("selectModel")); + const expandTools = formatStartupKey(kb.getKeys("expandTools")); + const toggleThinking = formatStartupKey(kb.getKeys("toggleThinking")); + const externalEditor = formatStartupKey(kb.getKeys("externalEditor")); + const followUp = formatStartupKey(kb.getKeys("followUp")); + const instructions = - theme.fg("dim", "esc") + + theme.fg("dim", interrupt) + theme.fg("muted", " to interrupt") + "\n" + - theme.fg("dim", "ctrl+c") + + theme.fg("dim", clear) + theme.fg("muted", " to clear") + "\n" + - theme.fg("dim", "ctrl+c twice") + + theme.fg("dim", `${clear} twice`) + theme.fg("muted", " to exit") + "\n" + - theme.fg("dim", "ctrl+d") + + theme.fg("dim", exit) + theme.fg("muted", " to exit (empty)") + "\n" + - theme.fg("dim", "ctrl+z") + + theme.fg("dim", suspend) + theme.fg("muted", " to suspend") + "\n" + - theme.fg("dim", "ctrl+k") + + theme.fg("dim", deleteToLineEnd) + theme.fg("muted", " to delete line") + "\n" + - theme.fg("dim", "shift+tab") + + theme.fg("dim", cycleThinkingLevel) + theme.fg("muted", " to cycle thinking") + "\n" + - theme.fg("dim", "ctrl+p/shift+ctrl+p") + + theme.fg("dim", `${cycleModelForward}/${cycleModelBackward}`) + theme.fg("muted", " to cycle models") + "\n" + - theme.fg("dim", "ctrl+l") + + theme.fg("dim", selectModel) + theme.fg("muted", " to select model") + "\n" + - theme.fg("dim", "ctrl+o") + + theme.fg("dim", expandTools) + theme.fg("muted", " to expand tools") + "\n" + - theme.fg("dim", "ctrl+t") + + theme.fg("dim", toggleThinking) + theme.fg("muted", " to toggle thinking") + "\n" + - theme.fg("dim", "ctrl+g") + + theme.fg("dim", externalEditor) + theme.fg("muted", " for external editor") + "\n" + theme.fg("dim", "/") + @@ -262,7 +288,7 @@ export class InteractiveMode { theme.fg("dim", "!") + theme.fg("muted", " to run bash") + "\n" + - theme.fg("dim", "alt+enter") + + theme.fg("dim", followUp) + theme.fg("muted", " to queue follow-up") + "\n" + theme.fg("dim", "drop files") + @@ -770,20 +796,21 @@ export class InteractiveMode { } }; - this.editor.onCtrlC = () => this.handleCtrlC(); + // Register app action handlers + this.editor.onAction("clear", () => this.handleCtrlC()); this.editor.onCtrlD = () => this.handleCtrlD(); - this.editor.onCtrlZ = () => this.handleCtrlZ(); - this.editor.onShiftTab = () => this.cycleThinkingLevel(); - this.editor.onCtrlP = () => this.cycleModel("forward"); - this.editor.onShiftCtrlP = () => this.cycleModel("backward"); + this.editor.onAction("suspend", () => this.handleCtrlZ()); + this.editor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel()); + this.editor.onAction("cycleModelForward", () => this.cycleModel("forward")); + this.editor.onAction("cycleModelBackward", () => this.cycleModel("backward")); // Global debug handler on TUI (works regardless of focus) this.ui.onDebug = () => this.handleDebugCommand(); - this.editor.onCtrlL = () => this.showModelSelector(); - this.editor.onCtrlO = () => this.toggleToolOutputExpansion(); - this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility(); - this.editor.onCtrlG = () => this.openExternalEditor(); - this.editor.onAltEnter = () => this.handleAltEnter(); + this.editor.onAction("selectModel", () => this.showModelSelector()); + this.editor.onAction("expandTools", () => this.toggleToolOutputExpansion()); + this.editor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility()); + this.editor.onAction("externalEditor", () => this.openExternalEditor()); + this.editor.onAction("followUp", () => this.handleFollowUp()); this.editor.onChange = (text: string) => { const wasBashMode = this.isBashMode; @@ -1448,7 +1475,7 @@ export class InteractiveMode { process.kill(0, "SIGTSTP"); } - private async handleAltEnter(): Promise { + private async handleFollowUp(): Promise { const text = this.editor.getText().trim(); if (!text) return; @@ -2262,38 +2289,96 @@ export class InteractiveMode { this.ui.requestRender(); } + /** + * Format keybindings for display (e.g., "ctrl+c" -> "Ctrl+C"). + */ + private formatKeyDisplay(keys: string | string[]): string { + const keyArray = Array.isArray(keys) ? keys : [keys]; + return keyArray + .map((key) => + key + .split("+") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join("+"), + ) + .join("/"); + } + + /** + * Get display string for an app keybinding action. + */ + private getAppKeyDisplay(action: Parameters[0]): string { + const display = this.keybindings.getDisplayString(action); + return this.formatKeyDisplay(display); + } + + /** + * Get display string for an editor keybinding action. + */ + private getEditorKeyDisplay(action: Parameters["getKeys"]>[0]): string { + const keys = getEditorKeybindings().getKeys(action); + return this.formatKeyDisplay(keys); + } + private handleHotkeysCommand(): void { + // Navigation keybindings + const cursorWordLeft = this.getEditorKeyDisplay("cursorWordLeft"); + const cursorWordRight = this.getEditorKeyDisplay("cursorWordRight"); + const cursorLineStart = this.getEditorKeyDisplay("cursorLineStart"); + const cursorLineEnd = this.getEditorKeyDisplay("cursorLineEnd"); + + // Editing keybindings + const submit = this.getEditorKeyDisplay("submit"); + const newLine = this.getEditorKeyDisplay("newLine"); + const deleteWordBackward = this.getEditorKeyDisplay("deleteWordBackward"); + const deleteToLineStart = this.getEditorKeyDisplay("deleteToLineStart"); + const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd"); + const tab = this.getEditorKeyDisplay("tab"); + + // App keybindings + const interrupt = this.getAppKeyDisplay("interrupt"); + const clear = this.getAppKeyDisplay("clear"); + const exit = this.getAppKeyDisplay("exit"); + const suspend = this.getAppKeyDisplay("suspend"); + const cycleThinkingLevel = this.getAppKeyDisplay("cycleThinkingLevel"); + const cycleModelForward = this.getAppKeyDisplay("cycleModelForward"); + const expandTools = this.getAppKeyDisplay("expandTools"); + const toggleThinking = this.getAppKeyDisplay("toggleThinking"); + const externalEditor = this.getAppKeyDisplay("externalEditor"); + const followUp = this.getAppKeyDisplay("followUp"); + const hotkeys = ` **Navigation** | Key | Action | |-----|--------| | \`Arrow keys\` | Move cursor / browse history (Up when empty) | -| \`Option+Left/Right\` | Move by word | -| \`Ctrl+A\` / \`Home\` / \`Cmd+Left\` | Start of line | -| \`Ctrl+E\` / \`End\` / \`Cmd+Right\` | End of line | +| \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word | +| \`${cursorLineStart}\` | Start of line | +| \`${cursorLineEnd}\` | End of line | **Editing** | Key | Action | |-----|--------| -| \`Enter\` | Send message | -| \`Shift+Enter\` / \`Alt+Enter\` | New line | -| \`Ctrl+W\` / \`Option+Backspace\` | Delete word backwards | -| \`Ctrl+U\` | Delete to start of line | -| \`Ctrl+K\` | Delete to end of line | +| \`${submit}\` | Send message | +| \`${newLine}\` | New line | +| \`${deleteWordBackward}\` | Delete word backwards | +| \`${deleteToLineStart}\` | Delete to start of line | +| \`${deleteToLineEnd}\` | Delete to end of line | **Other** | Key | Action | |-----|--------| -| \`Tab\` | Path completion / accept autocomplete | -| \`Escape\` | Cancel autocomplete / abort streaming | -| \`Ctrl+C\` | Clear editor (first) / exit (second) | -| \`Ctrl+D\` | Exit (when editor is empty) | -| \`Ctrl+Z\` | Suspend to background | -| \`Shift+Tab\` | Cycle thinking level | -| \`Ctrl+P\` | Cycle models | -| \`Ctrl+O\` | Toggle tool output expansion | -| \`Ctrl+T\` | Toggle thinking block visibility | -| \`Ctrl+G\` | Edit message in external editor | +| \`${tab}\` | Path completion / accept autocomplete | +| \`${interrupt}\` | Cancel autocomplete / abort streaming | +| \`${clear}\` | Clear editor (first) / exit (second) | +| \`${exit}\` | Exit (when editor is empty) | +| \`${suspend}\` | Suspend to background | +| \`${cycleThinkingLevel}\` | Cycle thinking level | +| \`${cycleModelForward}\` | Cycle models | +| \`${expandTools}\` | Toggle tool output expansion | +| \`${toggleThinking}\` | Toggle thinking block visibility | +| \`${externalEditor}\` | Edit message in external editor | +| \`${followUp}\` | Queue follow-up message | | \`/\` | Slash commands | | \`!\` | Run bash command | `; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b7ff897f..b218c85b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +### Breaking Changes + +- **Key detection functions removed**: All `isXxx()` key detection functions (`isEnter()`, `isEscape()`, `isCtrlC()`, etc.) have been removed. Use `matchesKey(data, keyId)` instead (e.g., `matchesKey(data, "enter")`, `matchesKey(data, "ctrl+c")`). This affects hooks and custom tools that use `ctx.ui.custom()` with keyboard input handling. ([#405](https://github.com/badlogic/pi-mono/pull/405)) + +### Added + +- `EditorKeybindingsManager` for configurable editor keybindings. Components now use `matchesKey()` and keybindings manager instead of individual `isXxx()` functions. ([#405](https://github.com/badlogic/pi-mono/pull/405) by [@hjanuschka](https://github.com/hjanuschka)) + +### Changed + +- Key detection refactored: consolidated `is*()` functions into generic `matchesKey(data, keyId)` function that accepts key identifiers like `"ctrl+c"`, `"shift+enter"`, `"alt+left"`, etc. + ## [0.32.3] - 2026-01-03 ## [0.32.2] - 2026-01-03 diff --git a/packages/tui/src/components/cancellable-loader.ts b/packages/tui/src/components/cancellable-loader.ts index 8e2621da..506b763d 100644 --- a/packages/tui/src/components/cancellable-loader.ts +++ b/packages/tui/src/components/cancellable-loader.ts @@ -1,4 +1,4 @@ -import { isEscape } from "../keys.js"; +import { getEditorKeybindings } from "../keybindings.js"; import { Loader } from "./loader.js"; /** @@ -27,7 +27,8 @@ export class CancellableLoader extends Loader { } handleInput(data: string): void { - if (isEscape(data)) { + const kb = getEditorKeybindings(); + if (kb.matches(data, "selectCancel")) { this.abortController.abort(); this.onAbort?.(); } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 82e849a3..43622010 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,33 +1,6 @@ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; -import { - isAltBackspace, - isAltEnter, - isAltLeft, - isAltRight, - isArrowDown, - isArrowLeft, - isArrowRight, - isArrowUp, - isBackspace, - isCtrlA, - isCtrlC, - isCtrlE, - isCtrlK, - isCtrlLeft, - isCtrlRight, - isCtrlU, - isCtrlW, - isDelete, - isEnd, - isEnter, - isEscape, - isHome, - isShiftBackspace, - isShiftDelete, - isShiftEnter, - isShiftSpace, - isTab, -} from "../keys.js"; +import { getEditorKeybindings } from "../keybindings.js"; +import { matchesKey } from "../keys.js"; import type { Component } from "../tui.js"; import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js"; import { SelectList, type SelectListTheme } from "./select-list.js"; @@ -421,277 +394,210 @@ export class Editor implements Component { } handleInput(data: string): void { - // Handle bracketed paste mode - // Start of paste: \x1b[200~ - // End of paste: \x1b[201~ + const kb = getEditorKeybindings(); - // Check if we're starting a bracketed paste + // Handle bracketed paste mode if (data.includes("\x1b[200~")) { this.isInPaste = true; this.pasteBuffer = ""; - // Remove the start marker and keep the rest data = data.replace("\x1b[200~", ""); } - // If we're in a paste, buffer the data if (this.isInPaste) { - // Append data to buffer first (end marker could be split across chunks) this.pasteBuffer += data; - - // Check if the accumulated buffer contains the end marker const endIndex = this.pasteBuffer.indexOf("\x1b[201~"); if (endIndex !== -1) { - // Extract content before the end marker const pasteContent = this.pasteBuffer.substring(0, endIndex); - - // Process the complete paste this.handlePaste(pasteContent); - - // Reset paste state this.isInPaste = false; - - // Process any remaining data after the end marker - const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~ + const remaining = this.pasteBuffer.substring(endIndex + 6); this.pasteBuffer = ""; - if (remaining.length > 0) { this.handleInput(remaining); } return; - } else { - // Still accumulating, wait for more data - return; } - } - - // Handle special key combinations first - - // Ctrl+C - Exit (let parent handle this) - if (isCtrlC(data)) { return; } - // Handle autocomplete special keys first (but don't block other input) + // Ctrl+C - let parent handle (exit/clear) + if (kb.matches(data, "copy")) { + return; + } + + // Handle autocomplete mode if (this.isAutocompleting && this.autocompleteList) { - // Escape - cancel autocomplete - if (isEscape(data)) { + if (kb.matches(data, "selectCancel")) { this.cancelAutocomplete(); return; } - // Let the autocomplete list handle navigation and selection - else if (isArrowUp(data) || isArrowDown(data) || isEnter(data) || isTab(data)) { - // Only pass arrow keys to the list, not Enter/Tab (we handle those directly) - if (isArrowUp(data) || isArrowDown(data)) { - this.autocompleteList.handleInput(data); - return; - } - // If Tab was pressed, always apply the selection - if (isTab(data)) { - const selected = this.autocompleteList.getSelectedItem(); - if (selected && this.autocompleteProvider) { - const result = this.autocompleteProvider.applyCompletion( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, - selected, - this.autocompletePrefix, - ); + if (kb.matches(data, "selectUp") || kb.matches(data, "selectDown")) { + this.autocompleteList.handleInput(data); + return; + } - this.state.lines = result.lines; - this.state.cursorLine = result.cursorLine; - this.state.cursorCol = result.cursorCol; - - this.cancelAutocomplete(); - - if (this.onChange) { - this.onChange(this.getText()); - } - } - return; - } - - // If Enter was pressed on a slash command, apply completion and submit - if (isEnter(data) && this.autocompletePrefix.startsWith("/")) { - const selected = this.autocompleteList.getSelectedItem(); - if (selected && this.autocompleteProvider) { - const result = this.autocompleteProvider.applyCompletion( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, - selected, - this.autocompletePrefix, - ); - - this.state.lines = result.lines; - this.state.cursorLine = result.cursorLine; - this.state.cursorCol = result.cursorCol; - } + if (kb.matches(data, "tab")) { + const selected = this.autocompleteList.getSelectedItem(); + if (selected && this.autocompleteProvider) { + const result = this.autocompleteProvider.applyCompletion( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + selected, + this.autocompletePrefix, + ); + this.state.lines = result.lines; + this.state.cursorLine = result.cursorLine; + this.state.cursorCol = result.cursorCol; this.cancelAutocomplete(); - // Don't return - fall through to submission logic + if (this.onChange) this.onChange(this.getText()); } - // If Enter was pressed on a file path, apply completion - else if (isEnter(data)) { - const selected = this.autocompleteList.getSelectedItem(); - if (selected && this.autocompleteProvider) { - const result = this.autocompleteProvider.applyCompletion( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, - selected, - this.autocompletePrefix, - ); + return; + } - this.state.lines = result.lines; - this.state.cursorLine = result.cursorLine; - this.state.cursorCol = result.cursorCol; + if (kb.matches(data, "selectConfirm")) { + const selected = this.autocompleteList.getSelectedItem(); + if (selected && this.autocompleteProvider) { + const result = this.autocompleteProvider.applyCompletion( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + selected, + this.autocompletePrefix, + ); + this.state.lines = result.lines; + this.state.cursorLine = result.cursorLine; + this.state.cursorCol = result.cursorCol; + if (this.autocompletePrefix.startsWith("/")) { this.cancelAutocomplete(); - - if (this.onChange) { - this.onChange(this.getText()); - } + // Fall through to submit + } else { + this.cancelAutocomplete(); + if (this.onChange) this.onChange(this.getText()); + return; } - return; } } - // For other keys (like regular typing), DON'T return here - // Let them fall through to normal character handling } - // Tab key - context-aware completion (but not when already autocompleting) - if (isTab(data) && !this.isAutocompleting) { + // Tab - trigger completion + if (kb.matches(data, "tab") && !this.isAutocompleting) { this.handleTabCompletion(); return; } - // Continue with rest of input handling - // Ctrl+K - Delete to end of line - if (isCtrlK(data)) { + // Deletion actions + if (kb.matches(data, "deleteToLineEnd")) { this.deleteToEndOfLine(); + return; } - // Ctrl+U - Delete to start of line - else if (isCtrlU(data)) { + if (kb.matches(data, "deleteToLineStart")) { this.deleteToStartOfLine(); + return; } - // Ctrl+W - Delete word backwards - else if (isCtrlW(data)) { + if (kb.matches(data, "deleteWordBackward")) { this.deleteWordBackwards(); + return; } - // Option/Alt+Backspace - Delete word backwards - else if (isAltBackspace(data)) { - this.deleteWordBackwards(); + if (kb.matches(data, "deleteCharBackward") || matchesKey(data, "shift+backspace")) { + this.handleBackspace(); + return; } - // Ctrl+A - Move to start of line - else if (isCtrlA(data)) { + if (kb.matches(data, "deleteCharForward") || matchesKey(data, "shift+delete")) { + this.handleForwardDelete(); + return; + } + + // Cursor movement actions + if (kb.matches(data, "cursorLineStart")) { this.moveToLineStart(); + return; } - // Ctrl+E - Move to end of line - else if (isCtrlE(data)) { + if (kb.matches(data, "cursorLineEnd")) { this.moveToLineEnd(); + return; } - // New line shortcuts (but not plain LF/CR which should be submit) - else if ( - (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers - data === "\x1b\r" || // Option+Enter in some terminals (legacy) - data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format) - isShiftEnter(data) || // Shift+Enter (Kitty protocol, handles lock bits) - isAltEnter(data) || // Alt+Enter (Kitty protocol, handles lock bits) + if (kb.matches(data, "cursorWordLeft")) { + this.moveWordBackwards(); + return; + } + if (kb.matches(data, "cursorWordRight")) { + this.moveWordForwards(); + return; + } + + // New line (Shift+Enter, Alt+Enter, etc.) + if ( + kb.matches(data, "newLine") || + (data.charCodeAt(0) === 10 && data.length > 1) || + data === "\x1b\r" || + data === "\x1b[13;2~" || (data.length > 1 && data.includes("\x1b") && data.includes("\r")) || - (data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping - data === "\\\r" // Shift+Enter in VS Code terminal + (data === "\n" && data.length === 1) || + data === "\\\r" ) { - // Modifier + Enter = new line this.addNewLine(); + return; } - // Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits) - else if (isEnter(data)) { - // If submit is disabled, do nothing - if (this.disableSubmit) { - return; - } - // Get text and substitute paste markers with actual content + // Submit (Enter) + if (kb.matches(data, "submit")) { + if (this.disableSubmit) return; + let result = this.state.lines.join("\n").trim(); - - // Replace all [paste #N +xxx lines] or [paste #N xxx chars] markers with actual paste content for (const [pasteId, pasteContent] of this.pastes) { - // Match formats: [paste #N], [paste #N +xxx lines], or [paste #N xxx chars] const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g"); result = result.replace(markerRegex, pasteContent); } - // Reset editor and clear pastes - this.state = { - lines: [""], - cursorLine: 0, - cursorCol: 0, - }; + this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; this.pastes.clear(); this.pasteCounter = 0; - this.historyIndex = -1; // Exit history browsing mode + this.historyIndex = -1; - // Notify that editor is now empty - if (this.onChange) { - this.onChange(""); - } + if (this.onChange) this.onChange(""); + if (this.onSubmit) this.onSubmit(result); + return; + } - if (this.onSubmit) { - this.onSubmit(result); - } - } - // Backspace (including Shift+Backspace) - else if (isBackspace(data) || isShiftBackspace(data)) { - this.handleBackspace(); - } - // Line navigation shortcuts (Home/End keys) - else if (isHome(data)) { - this.moveToLineStart(); - } else if (isEnd(data)) { - this.moveToLineEnd(); - } - // Forward delete (Fn+Backspace or Delete key, including Shift+Delete) - else if (isDelete(data) || isShiftDelete(data)) { - this.handleForwardDelete(); - } - // Word navigation (Option/Alt + Arrow or Ctrl + Arrow) - else if (isAltLeft(data) || isCtrlLeft(data)) { - // Word left - this.moveWordBackwards(); - } else if (isAltRight(data) || isCtrlRight(data)) { - // Word right - this.moveWordForwards(); - } - // Arrow keys - else if (isArrowUp(data)) { - // Up - history navigation or cursor movement + // Arrow key navigation (with history support) + if (kb.matches(data, "cursorUp")) { if (this.isEditorEmpty()) { - this.navigateHistory(-1); // Start browsing history + this.navigateHistory(-1); } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) { - this.navigateHistory(-1); // Navigate to older history entry + this.navigateHistory(-1); } else { - this.moveCursor(-1, 0); // Cursor movement (within text or history entry) + this.moveCursor(-1, 0); } - } else if (isArrowDown(data)) { - // Down - history navigation or cursor movement + return; + } + if (kb.matches(data, "cursorDown")) { if (this.historyIndex > -1 && this.isOnLastVisualLine()) { - this.navigateHistory(1); // Navigate to newer history entry or clear + this.navigateHistory(1); } else { - this.moveCursor(1, 0); // Cursor movement (within text or history entry) + this.moveCursor(1, 0); } - } else if (isArrowRight(data)) { - // Right + return; + } + if (kb.matches(data, "cursorRight")) { this.moveCursor(0, 1); - } else if (isArrowLeft(data)) { - // Left + return; + } + if (kb.matches(data, "cursorLeft")) { this.moveCursor(0, -1); + return; } - // Shift+Space - insert regular space (Kitty protocol sends escape sequence) - else if (isShiftSpace(data)) { + + // Shift+Space - insert regular space + if (matchesKey(data, "shift+space")) { this.insertCharacter(" "); + return; } - // Regular characters (printable characters and unicode, but not control characters) - else if (data.charCodeAt(0) >= 32) { + + // Regular characters + if (data.charCodeAt(0) >= 32) { this.insertCharacter(data); } } diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 37b663a3..ba4032a3 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,20 +1,4 @@ -import { - isAltBackspace, - isAltLeft, - isAltRight, - isArrowLeft, - isArrowRight, - isBackspace, - isCtrlA, - isCtrlE, - isCtrlK, - isCtrlLeft, - isCtrlRight, - isCtrlU, - isCtrlW, - isDelete, - isEnter, -} from "../keys.js"; +import { getEditorKeybindings } from "../keybindings.js"; import type { Component } from "../tui.js"; import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js"; @@ -78,17 +62,16 @@ export class Input implements Component { } return; } - // Handle special keys - if (isEnter(data) || data === "\n") { - // Enter - submit - if (this.onSubmit) { - this.onSubmit(this.value); - } + const kb = getEditorKeybindings(); + + // Submit + if (kb.matches(data, "submit") || data === "\n") { + if (this.onSubmit) this.onSubmit(this.value); return; } - if (isBackspace(data)) { - // Backspace - delete grapheme before cursor (handles emojis, etc.) + // Deletion + if (kb.matches(data, "deleteCharBackward")) { if (this.cursor > 0) { const beforeCursor = this.value.slice(0, this.cursor); const graphemes = [...segmenter.segment(beforeCursor)]; @@ -100,30 +83,7 @@ export class Input implements Component { return; } - if (isArrowLeft(data)) { - // Left arrow - move by one grapheme (handles emojis, etc.) - if (this.cursor > 0) { - const beforeCursor = this.value.slice(0, this.cursor); - const graphemes = [...segmenter.segment(beforeCursor)]; - const lastGrapheme = graphemes[graphemes.length - 1]; - this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1; - } - return; - } - - if (isArrowRight(data)) { - // Right arrow - move by one grapheme (handles emojis, etc.) - if (this.cursor < this.value.length) { - const afterCursor = this.value.slice(this.cursor); - const graphemes = [...segmenter.segment(afterCursor)]; - const firstGrapheme = graphemes[0]; - this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1; - } - return; - } - - if (isDelete(data)) { - // Delete - delete grapheme at cursor (handles emojis, etc.) + if (kb.matches(data, "deleteCharForward")) { if (this.cursor < this.value.length) { const afterCursor = this.value.slice(this.cursor); const graphemes = [...segmenter.segment(afterCursor)]; @@ -134,49 +94,59 @@ export class Input implements Component { return; } - if (isCtrlA(data)) { - // Ctrl+A - beginning of line - this.cursor = 0; - return; - } - - if (isCtrlE(data)) { - // Ctrl+E - end of line - this.cursor = this.value.length; - return; - } - - if (isCtrlW(data)) { - // Ctrl+W - delete word backwards + if (kb.matches(data, "deleteWordBackward")) { this.deleteWordBackwards(); return; } - if (isAltBackspace(data)) { - // Option/Alt+Backspace - delete word backwards - this.deleteWordBackwards(); - return; - } - - if (isCtrlU(data)) { - // Ctrl+U - delete from cursor to start of line + if (kb.matches(data, "deleteToLineStart")) { this.value = this.value.slice(this.cursor); this.cursor = 0; return; } - if (isCtrlK(data)) { - // Ctrl+K - delete from cursor to end of line + if (kb.matches(data, "deleteToLineEnd")) { this.value = this.value.slice(0, this.cursor); return; } - if (isCtrlLeft(data) || isAltLeft(data)) { + // Cursor movement + if (kb.matches(data, "cursorLeft")) { + if (this.cursor > 0) { + const beforeCursor = this.value.slice(0, this.cursor); + const graphemes = [...segmenter.segment(beforeCursor)]; + const lastGrapheme = graphemes[graphemes.length - 1]; + this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1; + } + return; + } + + if (kb.matches(data, "cursorRight")) { + if (this.cursor < this.value.length) { + const afterCursor = this.value.slice(this.cursor); + const graphemes = [...segmenter.segment(afterCursor)]; + const firstGrapheme = graphemes[0]; + this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1; + } + return; + } + + if (kb.matches(data, "cursorLineStart")) { + this.cursor = 0; + return; + } + + if (kb.matches(data, "cursorLineEnd")) { + this.cursor = this.value.length; + return; + } + + if (kb.matches(data, "cursorWordLeft")) { this.moveWordBackwards(); return; } - if (isCtrlRight(data) || isAltRight(data)) { + if (kb.matches(data, "cursorWordRight")) { this.moveWordForwards(); return; } diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 2342c7c4..06352ac4 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,4 +1,4 @@ -import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys.js"; +import { getEditorKeybindings } from "../keybindings.js"; import type { Component } from "../tui.js"; import { truncateToWidth } from "../utils.js"; @@ -145,25 +145,26 @@ export class SelectList implements Component { } handleInput(keyData: string): void { + const kb = getEditorKeybindings(); // Up arrow - wrap to bottom when at top - if (isArrowUp(keyData)) { + if (kb.matches(keyData, "selectUp")) { this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1; this.notifySelectionChange(); } // Down arrow - wrap to top when at bottom - else if (isArrowDown(keyData)) { + else if (kb.matches(keyData, "selectDown")) { this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1; this.notifySelectionChange(); } // Enter - else if (isEnter(keyData)) { + else if (kb.matches(keyData, "selectConfirm")) { const selectedItem = this.filteredItems[this.selectedIndex]; if (selectedItem && this.onSelect) { this.onSelect(selectedItem); } } // Escape or Ctrl+C - else if (isEscape(keyData) || isCtrlC(keyData)) { + else if (kb.matches(keyData, "selectCancel")) { if (this.onCancel) { this.onCancel(); } diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 5a273824..051b6502 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -1,4 +1,4 @@ -import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys.js"; +import { getEditorKeybindings } from "../keybindings.js"; import type { Component } from "../tui.js"; import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js"; @@ -145,13 +145,14 @@ export class SettingsList implements Component { } // Main list input handling - if (isArrowUp(data)) { + const kb = getEditorKeybindings(); + if (kb.matches(data, "selectUp")) { this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1; - } else if (isArrowDown(data)) { + } else if (kb.matches(data, "selectDown")) { this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1; - } else if (isEnter(data) || data === " ") { + } else if (kb.matches(data, "selectConfirm") || data === " ") { this.activateItem(); - } else if (isEscape(data) || isCtrlC(data)) { + } else if (kb.matches(data, "selectCancel")) { this.onCancel(); } } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index d5a16207..23f9262e 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -20,45 +20,17 @@ export { type SettingItem, SettingsList, type SettingsListTheme } from "./compon export { Spacer } from "./components/spacer.js"; export { Text } from "./components/text.js"; export { TruncatedText } from "./components/truncated-text.js"; -// Kitty keyboard protocol helpers +// Keybindings export { - isAltBackspace, - isAltEnter, - isAltLeft, - isAltRight, - isArrowDown, - isArrowLeft, - isArrowRight, - isArrowUp, - isBackspace, - isCtrlA, - isCtrlC, - isCtrlD, - isCtrlE, - isCtrlG, - isCtrlK, - isCtrlL, - isCtrlLeft, - isCtrlO, - isCtrlP, - isCtrlRight, - isCtrlT, - isCtrlU, - isCtrlW, - isCtrlZ, - isDelete, - isEnd, - isEnter, - isEscape, - isHome, - isShiftCtrlD, - isShiftCtrlO, - isShiftCtrlP, - isShiftEnter, - isShiftTab, - isTab, - Keys, -} from "./keys.js"; + DEFAULT_EDITOR_KEYBINDINGS, + type EditorAction, + type EditorKeybindingsConfig, + EditorKeybindingsManager, + getEditorKeybindings, + setEditorKeybindings, +} from "./keybindings.js"; +// Keyboard input handling +export { Key, type KeyId, matchesKey, parseKey } from "./keys.js"; // Terminal interface and implementations export { ProcessTerminal, type Terminal } from "./terminal.js"; // Terminal image support diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts new file mode 100644 index 00000000..0e3ad244 --- /dev/null +++ b/packages/tui/src/keybindings.ts @@ -0,0 +1,143 @@ +import { type KeyId, 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"; + +// Re-export KeyId from keys.ts +export type { KeyId }; + +/** + * Editor keybindings configuration. + */ +export type EditorKeybindingsConfig = { + [K in EditorAction]?: KeyId | KeyId[]; +}; + +/** + * Default editor keybindings. + */ +export const DEFAULT_EDITOR_KEYBINDINGS: Required = { + // 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", "ctrl+c"], + // Clipboard + copy: "ctrl+c", +}; + +/** + * Manages keybindings for the editor. + */ +export class EditorKeybindingsManager { + private actionToKeys: Map; + + 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; +} diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 58d57c4a..4ec1503f 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -1,87 +1,157 @@ /** - * Kitty keyboard protocol key sequence helpers. - * - * The Kitty keyboard protocol sends enhanced escape sequences in the format: - * \x1b[;u - * - * Modifier bits (before adding 1 for transmission): - * - Shift: 1 (value 2) - * - Alt: 2 (value 3) - * - Ctrl: 4 (value 5) - * - Super: 8 (value 9) - * - Hyper: 16 - * - Meta: 32 - * - Caps_Lock: 64 - * - Num_Lock: 128 + * Keyboard input handling for terminal applications. * + * Supports both legacy terminal sequences and Kitty keyboard protocol. * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ * - * NOTE: Some terminals (e.g., Ghostty on Linux) include lock key states - * (Caps Lock, Num Lock) in the modifier field. We mask these out when - * checking for key combinations since they shouldn't affect behavior. + * API: + * - matchesKey(data, keyId) - Check if input matches a key identifier + * - parseKey(data) - Parse input and return the key identifier + * - Key - Helper object for creating typed key identifiers */ -// Common codepoints -const CODEPOINTS = { - // Letters (lowercase ASCII) - a: 97, - c: 99, - d: 100, - e: 101, - g: 103, - k: 107, - l: 108, - o: 111, - p: 112, - t: 116, - u: 117, - w: 119, - z: 122, +// ============================================================================= +// Type-Safe Key Identifiers +// ============================================================================= +type Letter = + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z"; + +type SpecialKey = + | "escape" + | "esc" + | "enter" + | "return" + | "tab" + | "space" + | "backspace" + | "delete" + | "home" + | "end" + | "up" + | "down" + | "left" + | "right"; + +type BaseKey = Letter | SpecialKey; + +/** + * Union type of all valid key identifiers. + * Provides autocomplete and catches typos at compile time. + */ +export type KeyId = + | BaseKey + | `ctrl+${BaseKey}` + | `shift+${BaseKey}` + | `alt+${BaseKey}` + | `ctrl+shift+${BaseKey}` + | `shift+ctrl+${BaseKey}` + | `ctrl+alt+${BaseKey}` + | `alt+ctrl+${BaseKey}` + | `shift+alt+${BaseKey}` + | `alt+shift+${BaseKey}` + | `ctrl+shift+alt+${BaseKey}` + | `ctrl+alt+shift+${BaseKey}` + | `shift+ctrl+alt+${BaseKey}` + | `shift+alt+ctrl+${BaseKey}` + | `alt+ctrl+shift+${BaseKey}` + | `alt+shift+ctrl+${BaseKey}`; + +/** + * Helper object for creating typed key identifiers with autocomplete. + * + * Usage: + * - Key.escape, Key.enter, Key.tab, etc. for special keys + * - Key.ctrl("c"), Key.alt("x") for single modifier + * - Key.ctrlShift("p"), Key.ctrlAlt("x") for combined modifiers + */ +export const Key = { // Special keys + escape: "escape" as const, + esc: "esc" as const, + enter: "enter" as const, + return: "return" as const, + tab: "tab" as const, + space: "space" as const, + backspace: "backspace" as const, + delete: "delete" as const, + home: "home" as const, + end: "end" as const, + up: "up" as const, + down: "down" as const, + left: "left" as const, + right: "right" as const, + + // Single modifiers + ctrl: (key: K): `ctrl+${K}` => `ctrl+${key}`, + shift: (key: K): `shift+${K}` => `shift+${key}`, + alt: (key: K): `alt+${K}` => `alt+${key}`, + + // Combined modifiers + ctrlShift: (key: K): `ctrl+shift+${K}` => `ctrl+shift+${key}`, + shiftCtrl: (key: K): `shift+ctrl+${K}` => `shift+ctrl+${key}`, + ctrlAlt: (key: K): `ctrl+alt+${K}` => `ctrl+alt+${key}`, + altCtrl: (key: K): `alt+ctrl+${K}` => `alt+ctrl+${key}`, + shiftAlt: (key: K): `shift+alt+${K}` => `shift+alt+${key}`, + altShift: (key: K): `alt+shift+${K}` => `alt+shift+${key}`, + + // Triple modifiers + ctrlShiftAlt: (key: K): `ctrl+shift+alt+${K}` => `ctrl+shift+alt+${key}`, +} as const; + +// ============================================================================= +// Constants +// ============================================================================= + +const MODIFIERS = { + shift: 1, + alt: 2, + ctrl: 4, +} as const; + +const LOCK_MASK = 64 + 128; // Caps Lock + Num Lock + +const CODEPOINTS = { escape: 27, tab: 9, enter: 13, space: 32, backspace: 127, + kpEnter: 57414, // Numpad Enter (Kitty protocol) } as const; -// Lock key bits to ignore when matching (Caps Lock + Num Lock) -const LOCK_MASK = 64 + 128; // 192 - -// Modifier bits (before adding 1) -const MODIFIERS = { - shift: 1, - alt: 2, - ctrl: 4, - super: 8, +const ARROW_CODEPOINTS = { + up: -1, + down: -2, + right: -3, + left: -4, } as const; -/** - * Build a Kitty keyboard protocol sequence for a key with modifier. - */ -function kittySequence(codepoint: number, modifier: number): string { - return `\x1b[${codepoint};${modifier + 1}u`; -} - -/** - * Parsed Kitty keyboard protocol sequence. - */ -interface ParsedKittySequence { - codepoint: number; - modifier: number; // Actual modifier bits (after subtracting 1) -} - -/** - * Parse a Kitty keyboard protocol sequence. - * Handles formats: - * - \x1b[u (no modifier) - * - \x1b[;u (with modifier) - * - \x1b[1;A/B/C/D (arrow keys with modifier) - * - * Returns null if not a valid Kitty sequence. - */ -// Virtual codepoints for functional keys (negative to avoid conflicts) const FUNCTIONAL_CODEPOINTS = { delete: -10, insert: -11, @@ -91,8 +161,17 @@ const FUNCTIONAL_CODEPOINTS = { end: -15, } as const; +// ============================================================================= +// Kitty Protocol Parsing +// ============================================================================= + +interface ParsedKittySequence { + codepoint: number; + modifier: number; +} + function parseKittySequence(data: string): ParsedKittySequence | null { - // Match CSI u format: \x1b[u or \x1b[;u + // CSI u format: \x1b[u or \x1b[;u const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?u$/); if (csiUMatch) { const codepoint = parseInt(csiUMatch[1]!, 10); @@ -100,30 +179,26 @@ function parseKittySequence(data: string): ParsedKittySequence | null { return { codepoint, modifier: modValue - 1 }; } - // Match arrow keys with modifier: \x1b[1;A/B/C/D + // Arrow keys with modifier: \x1b[1;A/B/C/D const arrowMatch = data.match(/^\x1b\[1;(\d+)([ABCD])$/); if (arrowMatch) { const modValue = parseInt(arrowMatch[1]!, 10); - // Map arrow letters to virtual codepoints for easier matching const arrowCodes: Record = { A: -1, B: -2, C: -3, D: -4 }; - const codepoint = arrowCodes[arrowMatch[2]!]!; - return { codepoint, modifier: modValue - 1 }; + return { codepoint: arrowCodes[arrowMatch[2]!]!, modifier: modValue - 1 }; } - // Match functional keys with ~ terminator: \x1b[~ or \x1b[;~ - // DELETE=3, INSERT=2, PAGEUP=5, PAGEDOWN=6, etc. + // Functional keys: \x1b[~ or \x1b[;~ const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?~$/); if (funcMatch) { const keyNum = parseInt(funcMatch[1]!, 10); const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1; - // Map functional key numbers to virtual codepoints const funcCodes: Record = { 2: FUNCTIONAL_CODEPOINTS.insert, 3: FUNCTIONAL_CODEPOINTS.delete, 5: FUNCTIONAL_CODEPOINTS.pageUp, 6: FUNCTIONAL_CODEPOINTS.pageDown, - 7: FUNCTIONAL_CODEPOINTS.home, // Alternative home - 8: FUNCTIONAL_CODEPOINTS.end, // Alternative end + 7: FUNCTIONAL_CODEPOINTS.home, + 8: FUNCTIONAL_CODEPOINTS.end, }; const codepoint = funcCodes[keyNum]; if (codepoint !== undefined) { @@ -131,7 +206,7 @@ function parseKittySequence(data: string): ParsedKittySequence | null { } } - // Match Home/End with modifier: \x1b[1;H/F + // Home/End with modifier: \x1b[1;H/F const homeEndMatch = data.match(/^\x1b\[1;(\d+)([HF])$/); if (homeEndMatch) { const modValue = parseInt(homeEndMatch[1]!, 10); @@ -142,434 +217,280 @@ function parseKittySequence(data: string): ParsedKittySequence | null { return null; } -/** - * Check if a Kitty sequence matches the expected codepoint and modifier, - * ignoring lock key bits (Caps Lock, Num Lock). - */ function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean { const parsed = parseKittySequence(data); if (!parsed) return false; - - // Mask out lock bits from both sides for comparison const actualMod = parsed.modifier & ~LOCK_MASK; const expectedMod = expectedModifier & ~LOCK_MASK; - return parsed.codepoint === expectedCodepoint && actualMod === expectedMod; } -// Pre-built sequences for common key combinations -export const Keys = { - // Ctrl+ combinations - CTRL_A: kittySequence(CODEPOINTS.a, MODIFIERS.ctrl), - CTRL_C: kittySequence(CODEPOINTS.c, MODIFIERS.ctrl), - CTRL_D: kittySequence(CODEPOINTS.d, MODIFIERS.ctrl), - CTRL_E: kittySequence(CODEPOINTS.e, MODIFIERS.ctrl), - CTRL_G: kittySequence(CODEPOINTS.g, MODIFIERS.ctrl), - CTRL_K: kittySequence(CODEPOINTS.k, MODIFIERS.ctrl), - CTRL_L: kittySequence(CODEPOINTS.l, MODIFIERS.ctrl), - CTRL_O: kittySequence(CODEPOINTS.o, MODIFIERS.ctrl), - CTRL_P: kittySequence(CODEPOINTS.p, MODIFIERS.ctrl), - CTRL_T: kittySequence(CODEPOINTS.t, MODIFIERS.ctrl), - CTRL_U: kittySequence(CODEPOINTS.u, MODIFIERS.ctrl), - CTRL_W: kittySequence(CODEPOINTS.w, MODIFIERS.ctrl), - CTRL_Z: kittySequence(CODEPOINTS.z, MODIFIERS.ctrl), +// ============================================================================= +// Generic Key Matching +// ============================================================================= - // Enter combinations - SHIFT_ENTER: kittySequence(CODEPOINTS.enter, MODIFIERS.shift), - ALT_ENTER: kittySequence(CODEPOINTS.enter, MODIFIERS.alt), - CTRL_ENTER: kittySequence(CODEPOINTS.enter, MODIFIERS.ctrl), +function rawCtrlChar(letter: string): string { + const code = letter.toLowerCase().charCodeAt(0) - 96; + return String.fromCharCode(code); +} - // Tab combinations - SHIFT_TAB: kittySequence(CODEPOINTS.tab, MODIFIERS.shift), - - // Backspace combinations - ALT_BACKSPACE: kittySequence(CODEPOINTS.backspace, MODIFIERS.alt), -} as const; - -/** - * Check if input matches a Kitty protocol Ctrl+ sequence. - * Ignores lock key bits (Caps Lock, Num Lock). - * @param data - The input data to check - * @param key - Single lowercase letter (e.g., 'c' for Ctrl+C) - */ -export function isKittyCtrl(data: string, key: string): boolean { - if (key.length !== 1) return false; - const codepoint = key.charCodeAt(0); - // Check exact match first (fast path) - if (data === kittySequence(codepoint, MODIFIERS.ctrl)) return true; - // Check with lock bits masked out - return matchesKittySequence(data, codepoint, MODIFIERS.ctrl); +function parseKeyId(keyId: string): { key: string; ctrl: boolean; shift: boolean; alt: boolean } | null { + const parts = keyId.toLowerCase().split("+"); + const key = parts[parts.length - 1]; + if (!key) return null; + return { + key, + ctrl: parts.includes("ctrl"), + shift: parts.includes("shift"), + alt: parts.includes("alt"), + }; } /** - * Check if input matches a Kitty protocol key sequence with specific modifier. - * Ignores lock key bits (Caps Lock, Num Lock). - * @param data - The input data to check - * @param codepoint - ASCII codepoint of the key - * @param modifier - Modifier value (use MODIFIERS constants) + * Match input data against a key identifier string. + * + * Supported key identifiers: + * - Single keys: "escape", "tab", "enter", "backspace", "delete", "home", "end", "space" + * - Arrow keys: "up", "down", "left", "right" + * - Ctrl combinations: "ctrl+c", "ctrl+z", etc. + * - Shift combinations: "shift+tab", "shift+enter" + * - Alt combinations: "alt+enter", "alt+backspace" + * - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x" + * + * Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p") + * + * @param data - Raw input data from terminal + * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c")) */ -export function isKittyKey(data: string, codepoint: number, modifier: number): boolean { - // Check exact match first (fast path) - if (data === kittySequence(codepoint, modifier)) return true; - // Check with lock bits masked out - return matchesKittySequence(data, codepoint, modifier); -} +export function matchesKey(data: string, keyId: KeyId): boolean { + const parsed = parseKeyId(keyId); + if (!parsed) return false; -// Raw control character codes -const RAW = { - CTRL_A: "\x01", - CTRL_C: "\x03", - CTRL_D: "\x04", - CTRL_E: "\x05", - CTRL_G: "\x07", - CTRL_K: "\x0b", - CTRL_L: "\x0c", - CTRL_O: "\x0f", - CTRL_P: "\x10", - CTRL_T: "\x14", - CTRL_U: "\x15", - CTRL_W: "\x17", - CTRL_Z: "\x1a", - ALT_BACKSPACE: "\x1b\x7f", - SHIFT_TAB: "\x1b[Z", -} as const; + const { key, ctrl, shift, alt } = parsed; + let modifier = 0; + if (shift) modifier |= MODIFIERS.shift; + if (alt) modifier |= MODIFIERS.alt; + if (ctrl) modifier |= MODIFIERS.ctrl; -/** - * Check if input matches Ctrl+A (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlA(data: string): boolean { - return data === RAW.CTRL_A || data === Keys.CTRL_A || matchesKittySequence(data, CODEPOINTS.a, MODIFIERS.ctrl); + switch (key) { + case "escape": + case "esc": + if (modifier !== 0) return false; + return data === "\x1b" || matchesKittySequence(data, CODEPOINTS.escape, 0); + + case "space": + if (modifier === 0) { + return data === " " || matchesKittySequence(data, CODEPOINTS.space, 0); + } + return matchesKittySequence(data, CODEPOINTS.space, modifier); + + case "tab": + if (shift && !ctrl && !alt) { + return data === "\x1b[Z" || matchesKittySequence(data, CODEPOINTS.tab, MODIFIERS.shift); + } + if (modifier === 0) { + return data === "\t" || matchesKittySequence(data, CODEPOINTS.tab, 0); + } + return matchesKittySequence(data, CODEPOINTS.tab, modifier); + + case "enter": + case "return": + if (shift && !ctrl && !alt) { + return ( + matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) || + matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift) + ); + } + if (alt && !ctrl && !shift) { + return ( + data === "\x1b\r" || + matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) || + matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt) + ); + } + if (modifier === 0) { + return ( + data === "\r" || + data === "\x1bOM" || // SS3 M (numpad enter in some terminals) + matchesKittySequence(data, CODEPOINTS.enter, 0) || + matchesKittySequence(data, CODEPOINTS.kpEnter, 0) + ); + } + return ( + matchesKittySequence(data, CODEPOINTS.enter, modifier) || + matchesKittySequence(data, CODEPOINTS.kpEnter, modifier) + ); + + case "backspace": + if (alt && !ctrl && !shift) { + return data === "\x1b\x7f" || matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt); + } + if (modifier === 0) { + return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0); + } + return matchesKittySequence(data, CODEPOINTS.backspace, modifier); + + case "delete": + if (modifier === 0) { + return data === "\x1b[3~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0); + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, modifier); + + case "home": + if (modifier === 0) { + return ( + data === "\x1b[H" || + data === "\x1b[1~" || + data === "\x1b[7~" || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0) + ); + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, modifier); + + case "end": + if (modifier === 0) { + return ( + data === "\x1b[F" || + data === "\x1b[4~" || + data === "\x1b[8~" || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0) + ); + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, modifier); + + case "up": + if (modifier === 0) { + return data === "\x1b[A" || matchesKittySequence(data, ARROW_CODEPOINTS.up, 0); + } + return matchesKittySequence(data, ARROW_CODEPOINTS.up, modifier); + + case "down": + if (modifier === 0) { + return data === "\x1b[B" || matchesKittySequence(data, ARROW_CODEPOINTS.down, 0); + } + return matchesKittySequence(data, ARROW_CODEPOINTS.down, modifier); + + case "left": + if (alt && !ctrl && !shift) { + return ( + data === "\x1b[1;3D" || + data === "\x1bb" || + matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt) + ); + } + if (ctrl && !alt && !shift) { + return data === "\x1b[1;5D" || matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.ctrl); + } + if (modifier === 0) { + return data === "\x1b[D" || matchesKittySequence(data, ARROW_CODEPOINTS.left, 0); + } + return matchesKittySequence(data, ARROW_CODEPOINTS.left, modifier); + + case "right": + if (alt && !ctrl && !shift) { + return ( + data === "\x1b[1;3C" || + data === "\x1bf" || + matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt) + ); + } + if (ctrl && !alt && !shift) { + return data === "\x1b[1;5C" || matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl); + } + if (modifier === 0) { + return data === "\x1b[C" || matchesKittySequence(data, ARROW_CODEPOINTS.right, 0); + } + return matchesKittySequence(data, ARROW_CODEPOINTS.right, modifier); + } + + // Handle single letter keys (a-z) + if (key.length === 1 && key >= "a" && key <= "z") { + const codepoint = key.charCodeAt(0); + + if (ctrl && !shift && !alt) { + const raw = rawCtrlChar(key); + if (data === raw) return true; + if (data.length > 0 && data.charCodeAt(0) === raw.charCodeAt(0)) return true; + return matchesKittySequence(data, codepoint, MODIFIERS.ctrl); + } + + if (ctrl && shift && !alt) { + return matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl); + } + + if (modifier !== 0) { + return matchesKittySequence(data, codepoint, modifier); + } + + return data === key; + } + + return false; } /** - * Check if input matches Ctrl+C (raw byte or Kitty protocol). - * Ignores lock key bits. + * Parse input data and return the key identifier if recognized. + * + * @param data - Raw input data from terminal + * @returns Key identifier string (e.g., "ctrl+c") or undefined */ -export function isCtrlC(data: string): boolean { - return data === RAW.CTRL_C || data === Keys.CTRL_C || matchesKittySequence(data, CODEPOINTS.c, MODIFIERS.ctrl); -} +export function parseKey(data: string): string | undefined { + const kitty = parseKittySequence(data); + if (kitty) { + const { codepoint, modifier } = kitty; + const mods: string[] = []; + const effectiveMod = modifier & ~LOCK_MASK; + if (effectiveMod & MODIFIERS.shift) mods.push("shift"); + if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl"); + if (effectiveMod & MODIFIERS.alt) mods.push("alt"); -/** - * Check if input matches Ctrl+D (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlD(data: string): boolean { - return data === RAW.CTRL_D || data === Keys.CTRL_D || matchesKittySequence(data, CODEPOINTS.d, MODIFIERS.ctrl); -} + let keyName: string | undefined; + if (codepoint === CODEPOINTS.escape) keyName = "escape"; + else if (codepoint === CODEPOINTS.tab) keyName = "tab"; + else if (codepoint === CODEPOINTS.enter || codepoint === CODEPOINTS.kpEnter) keyName = "enter"; + else if (codepoint === CODEPOINTS.space) keyName = "space"; + else if (codepoint === CODEPOINTS.backspace) keyName = "backspace"; + else if (codepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete"; + else if (codepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home"; + else if (codepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end"; + else if (codepoint === ARROW_CODEPOINTS.up) keyName = "up"; + else if (codepoint === ARROW_CODEPOINTS.down) keyName = "down"; + else if (codepoint === ARROW_CODEPOINTS.left) keyName = "left"; + else if (codepoint === ARROW_CODEPOINTS.right) keyName = "right"; + else if (codepoint >= 97 && codepoint <= 122) keyName = String.fromCharCode(codepoint); -/** - * Check if input matches Ctrl+E (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlE(data: string): boolean { - return data === RAW.CTRL_E || data === Keys.CTRL_E || matchesKittySequence(data, CODEPOINTS.e, MODIFIERS.ctrl); -} + if (keyName) { + return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName; + } + } -/** - * Check if input matches Ctrl+G (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlG(data: string): boolean { - return data === RAW.CTRL_G || data === Keys.CTRL_G || matchesKittySequence(data, CODEPOINTS.g, MODIFIERS.ctrl); -} + // Legacy sequences + if (data === "\x1b") return "escape"; + if (data === "\t") return "tab"; + if (data === "\r" || data === "\x1bOM") return "enter"; + if (data === " ") return "space"; + if (data === "\x7f" || data === "\x08") return "backspace"; + if (data === "\x1b[Z") return "shift+tab"; + if (data === "\x1b\r") return "alt+enter"; + if (data === "\x1b\x7f") return "alt+backspace"; + if (data === "\x1b[A") return "up"; + if (data === "\x1b[B") return "down"; + if (data === "\x1b[C") return "right"; + if (data === "\x1b[D") return "left"; + if (data === "\x1b[H") return "home"; + if (data === "\x1b[F") return "end"; + if (data === "\x1b[3~") return "delete"; -/** - * Check if input matches Ctrl+K (raw byte or Kitty protocol). - * Ignores lock key bits. - * Also checks if first byte is 0x0b for compatibility with terminals - * that may send trailing bytes. - */ -export function isCtrlK(data: string): boolean { - return ( - data === RAW.CTRL_K || - (data.length > 0 && data.charCodeAt(0) === 0x0b) || - data === Keys.CTRL_K || - matchesKittySequence(data, CODEPOINTS.k, MODIFIERS.ctrl) - ); -} + // Raw Ctrl+letter + if (data.length === 1) { + const code = data.charCodeAt(0); + if (code >= 1 && code <= 26) { + return `ctrl+${String.fromCharCode(code + 96)}`; + } + if (code >= 32 && code <= 126) { + return data; + } + } -/** - * Check if input matches Ctrl+L (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlL(data: string): boolean { - return data === RAW.CTRL_L || data === Keys.CTRL_L || matchesKittySequence(data, CODEPOINTS.l, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+O (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlO(data: string): boolean { - return data === RAW.CTRL_O || data === Keys.CTRL_O || matchesKittySequence(data, CODEPOINTS.o, MODIFIERS.ctrl); -} - -/** - * Check if input matches Shift+Ctrl+O (Kitty protocol only). - * Ignores lock key bits. - */ -export function isShiftCtrlO(data: string): boolean { - return matchesKittySequence(data, CODEPOINTS.o, MODIFIERS.shift + MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+P (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlP(data: string): boolean { - return data === RAW.CTRL_P || data === Keys.CTRL_P || matchesKittySequence(data, CODEPOINTS.p, MODIFIERS.ctrl); -} - -/** - * Check if input matches Shift+Ctrl+P (Kitty protocol only). - * Ignores lock key bits. - */ -export function isShiftCtrlP(data: string): boolean { - return matchesKittySequence(data, CODEPOINTS.p, MODIFIERS.shift + MODIFIERS.ctrl); -} - -/** - * Check if input matches Shift+Ctrl+D (Kitty protocol only, for debug). - * Ignores lock key bits. - */ -export function isShiftCtrlD(data: string): boolean { - return matchesKittySequence(data, CODEPOINTS.d, MODIFIERS.shift + MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+T (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlT(data: string): boolean { - return data === RAW.CTRL_T || data === Keys.CTRL_T || matchesKittySequence(data, CODEPOINTS.t, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+U (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlU(data: string): boolean { - return data === RAW.CTRL_U || data === Keys.CTRL_U || matchesKittySequence(data, CODEPOINTS.u, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+W (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlW(data: string): boolean { - return data === RAW.CTRL_W || data === Keys.CTRL_W || matchesKittySequence(data, CODEPOINTS.w, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+Z (raw byte or Kitty protocol). - * Ignores lock key bits. - */ -export function isCtrlZ(data: string): boolean { - return data === RAW.CTRL_Z || data === Keys.CTRL_Z || matchesKittySequence(data, CODEPOINTS.z, MODIFIERS.ctrl); -} - -/** - * Check if input matches Alt+Backspace (legacy or Kitty protocol). - * Ignores lock key bits. - */ -export function isAltBackspace(data: string): boolean { - return ( - data === RAW.ALT_BACKSPACE || - data === Keys.ALT_BACKSPACE || - matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt) - ); -} - -/** - * Check if input matches Shift+Tab (legacy or Kitty protocol). - * Ignores lock key bits. - */ -export function isShiftTab(data: string): boolean { - return ( - data === RAW.SHIFT_TAB || data === Keys.SHIFT_TAB || matchesKittySequence(data, CODEPOINTS.tab, MODIFIERS.shift) - ); -} - -/** - * Check if input matches the Escape key (raw byte or Kitty protocol). - * Raw: \x1b (single byte) - * Kitty: \x1b[27u (codepoint 27 = escape) - * Ignores lock key bits. - */ -export function isEscape(data: string): boolean { - return data === "\x1b" || data === `\x1b[${CODEPOINTS.escape}u` || matchesKittySequence(data, CODEPOINTS.escape, 0); -} - -// Arrow key virtual codepoints (negative to avoid conflicts with real codepoints) -const ARROW_CODEPOINTS = { - up: -1, - down: -2, - right: -3, - left: -4, -} as const; - -/** - * Check if input matches Arrow Up key. - * Handles both legacy (\x1b[A) and Kitty protocol with modifiers. - */ -export function isArrowUp(data: string): boolean { - return data === "\x1b[A" || matchesKittySequence(data, ARROW_CODEPOINTS.up, 0); -} - -/** - * Check if input matches Arrow Down key. - * Handles both legacy (\x1b[B) and Kitty protocol with modifiers. - */ -export function isArrowDown(data: string): boolean { - return data === "\x1b[B" || matchesKittySequence(data, ARROW_CODEPOINTS.down, 0); -} - -/** - * Check if input matches Arrow Right key. - * Handles both legacy (\x1b[C) and Kitty protocol with modifiers. - */ -export function isArrowRight(data: string): boolean { - return data === "\x1b[C" || matchesKittySequence(data, ARROW_CODEPOINTS.right, 0); -} - -/** - * Check if input matches Arrow Left key. - * Handles both legacy (\x1b[D) and Kitty protocol with modifiers. - */ -export function isArrowLeft(data: string): boolean { - return data === "\x1b[D" || matchesKittySequence(data, ARROW_CODEPOINTS.left, 0); -} - -/** - * Check if input matches plain Tab key (no modifiers). - * Handles both legacy (\t) and Kitty protocol. - */ -export function isTab(data: string): boolean { - return data === "\t" || matchesKittySequence(data, CODEPOINTS.tab, 0); -} - -/** - * Check if input matches plain Enter/Return key (no modifiers). - * Handles both legacy (\r) and Kitty protocol. - */ -export function isEnter(data: string): boolean { - return data === "\r" || matchesKittySequence(data, CODEPOINTS.enter, 0); -} - -/** - * Check if input matches plain Backspace key (no modifiers). - * Handles both legacy (\x7f, \x08) and Kitty protocol. - */ -export function isBackspace(data: string): boolean { - return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0); -} - -/** - * Check if input matches Shift+Backspace (Kitty protocol). - * Returns true so caller can treat it as regular backspace. - * Ignores lock key bits. - */ -export function isShiftBackspace(data: string): boolean { - return matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.shift); -} - -/** - * Check if input matches Shift+Enter. - * Ignores lock key bits. - */ -export function isShiftEnter(data: string): boolean { - return data === Keys.SHIFT_ENTER || matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift); -} - -/** - * Check if input matches Alt+Enter. - * Ignores lock key bits. - */ -export function isAltEnter(data: string): boolean { - return data === Keys.ALT_ENTER || data === "\x1b\r" || matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt); -} - -/** - * Check if input matches Shift+Space (Kitty protocol). - * Returns true so caller can insert a regular space. - * Ignores lock key bits. - */ -export function isShiftSpace(data: string): boolean { - return matchesKittySequence(data, CODEPOINTS.space, MODIFIERS.shift); -} - -/** - * Check if input matches Option/Alt+Left (word navigation). - * Handles multiple formats including Kitty protocol. - */ -export function isAltLeft(data: string): boolean { - return data === "\x1b[1;3D" || data === "\x1bb" || matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt); -} - -/** - * Check if input matches Option/Alt+Right (word navigation). - * Handles multiple formats including Kitty protocol. - */ -export function isAltRight(data: string): boolean { - return data === "\x1b[1;3C" || data === "\x1bf" || matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt); -} - -/** - * Check if input matches Ctrl+Left (word navigation). - * Handles multiple formats including Kitty protocol. - */ -export function isCtrlLeft(data: string): boolean { - return data === "\x1b[1;5D" || matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.ctrl); -} - -/** - * Check if input matches Ctrl+Right (word navigation). - * Handles multiple formats including Kitty protocol. - */ -export function isCtrlRight(data: string): boolean { - return data === "\x1b[1;5C" || matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl); -} - -/** - * Check if input matches Home key. - * Handles legacy formats and Kitty protocol with lock key modifiers. - */ -export function isHome(data: string): boolean { - return ( - data === "\x1b[H" || - data === "\x1b[1~" || - data === "\x1b[7~" || - matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0) - ); -} - -/** - * Check if input matches End key. - * Handles legacy formats and Kitty protocol with lock key modifiers. - */ -export function isEnd(data: string): boolean { - return ( - data === "\x1b[F" || - data === "\x1b[4~" || - data === "\x1b[8~" || - matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0) - ); -} - -/** - * Check if input matches Delete key (forward delete). - * Handles legacy format and Kitty protocol with lock key modifiers. - */ -export function isDelete(data: string): boolean { - return data === "\x1b[3~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0); -} - -/** - * Check if input matches Shift+Delete (Kitty protocol). - * Returns true so caller can treat it as regular delete. - * Ignores lock key bits. - */ -export function isShiftDelete(data: string): boolean { - return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, MODIFIERS.shift); + return undefined; } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 583f099e..d5ec9a61 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -5,7 +5,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { isShiftCtrlD } from "./keys.js"; +import { matchesKey } from "./keys.js"; import type { Terminal } from "./terminal.js"; import { getCapabilities, setCellDimensions } from "./terminal-image.js"; import { visibleWidth } from "./utils.js"; @@ -146,7 +146,7 @@ export class TUI extends Container { } // Global debug key handler (Shift+Ctrl+D) - if (isShiftCtrlD(data) && this.onDebug) { + if (matchesKey(data, "shift+ctrl+d") && this.onDebug) { this.onDebug(); return; } diff --git a/packages/tui/test/key-tester.ts b/packages/tui/test/key-tester.ts index 279926df..443ab3ac 100755 --- a/packages/tui/test/key-tester.ts +++ b/packages/tui/test/key-tester.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { isCtrlC } from "../src/keys.js"; +import { matchesKey } from "../src/keys.js"; import { ProcessTerminal } from "../src/terminal.js"; import { type Component, TUI } from "../src/tui.js"; @@ -17,7 +17,7 @@ class KeyLogger implements Component { handleInput(data: string): void { // Handle Ctrl+C (raw or Kitty protocol) for exit - if (isCtrlC(data)) { + if (matchesKey(data, "ctrl+c")) { this.tui.stop(); console.log("\nExiting..."); process.exit(0);