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..6486f140 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, matchesKey, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; import { getEditorTheme, theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -68,13 +68,13 @@ export class HookEditorComponent extends Container { } // Escape or Ctrl+C to cancel - if (isEscape(keyData) || isCtrlC(keyData)) { + if (matchesKey(keyData, "escape") || matchesKey(keyData, "ctrl+c")) { this.onCancelCallback(); return; } // Ctrl+G for external editor - if (isCtrlG(keyData)) { + 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..e4705c00 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, Input, matchesKey, Spacer, Text } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -47,13 +47,13 @@ export class HookInputComponent extends Container { handleInput(keyData: string): void { // Enter - if (isEnter(keyData) || keyData === "\n") { + if (matchesKey(keyData, "enter") || keyData === "\n") { this.onSubmitCallback(this.input.getValue()); return; } // Escape or Ctrl+C to cancel - if (isEscape(keyData) || isCtrlC(keyData)) { + if (matchesKey(keyData, "escape") || matchesKey(keyData, "ctrl+c")) { 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..9a35cd14 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, matchesKey, Spacer, Text } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -67,24 +67,24 @@ export class HookSelectorComponent extends Container { handleInput(keyData: string): void { // Up arrow or k - if (isArrowUp(keyData) || keyData === "k") { + if (matchesKey(keyData, "up") || keyData === "k") { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.updateList(); } // Down arrow or j - else if (isArrowDown(keyData) || keyData === "j") { + else if (matchesKey(keyData, "down") || keyData === "j") { this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1); this.updateList(); } // Enter - else if (isEnter(keyData) || keyData === "\n") { + else if (matchesKey(keyData, "enter") || 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 (matchesKey(keyData, "escape") || matchesKey(keyData, "ctrl+c")) { 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..553fa285 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, Input, matchesKey, 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"; @@ -217,26 +206,26 @@ export class ModelSelectorComponent extends Container { handleInput(keyData: string): void { // Up arrow - wrap to bottom when at top - if (isArrowUp(keyData)) { + if (matchesKey(keyData, "up")) { 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 (matchesKey(keyData, "down")) { 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 (matchesKey(keyData, "enter")) { const selectedModel = this.filteredModels[this.selectedIndex]; if (selectedModel) { this.handleSelect(selectedModel.model); } } // Escape or Ctrl+C - else if (isEscape(keyData) || isCtrlC(keyData)) { + else if (matchesKey(keyData, "escape") || matchesKey(keyData, "ctrl+c")) { 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..fcee684c 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, matchesKey, 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"; @@ -105,24 +96,24 @@ export class OAuthSelectorComponent extends Container { handleInput(keyData: string): void { // Up arrow - if (isArrowUp(keyData)) { + if (matchesKey(keyData, "up")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.updateList(); } // Down arrow - else if (isArrowDown(keyData)) { + else if (matchesKey(keyData, "down")) { this.selectedIndex = Math.min(this.allProviders.length - 1, this.selectedIndex + 1); this.updateList(); } // Enter - else if (isEnter(keyData)) { + else if (matchesKey(keyData, "enter")) { 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 (matchesKey(keyData, "escape") || matchesKey(keyData, "ctrl+c")) { 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..4913fa51 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -1,16 +1,4 @@ -import { - type Component, - Container, - Input, - isArrowDown, - isArrowUp, - isCtrlC, - isEnter, - isEscape, - Spacer, - Text, - truncateToWidth, -} from "@mariozechner/pi-tui"; +import { type Component, Container, Input, matchesKey, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; import type { SessionInfo } from "../../../core/session-manager.js"; import { fuzzyFilter } from "../../../utils/fuzzy.js"; import { theme } from "../theme/theme.js"; @@ -127,28 +115,28 @@ class SessionList implements Component { handleInput(keyData: string): void { // Up arrow - if (isArrowUp(keyData)) { + if (matchesKey(keyData, "up")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); } // Down arrow - else if (isArrowDown(keyData)) { + else if (matchesKey(keyData, "down")) { this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1); } // Enter - else if (isEnter(keyData)) { + else if (matchesKey(keyData, "enter")) { const selected = this.filteredSessions[this.selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.path); } } // Escape - cancel - else if (isEscape(keyData)) { + else if (matchesKey(keyData, "escape")) { if (this.onCancel) { this.onCancel(); } } // Ctrl+C - exit - else if (isCtrlC(keyData)) { + else if (matchesKey(keyData, "ctrl+c")) { this.onExit(); } // Pass everything else to search input 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..402cd3e3 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -2,16 +2,7 @@ import { type Component, Container, Input, - isArrowDown, - isArrowLeft, - isArrowRight, - isArrowUp, - isBackspace, - isCtrlC, - isCtrlO, - isEnter, - isEscape, - isShiftCtrlO, + matchesKey, Spacer, Text, TruncatedText, @@ -664,43 +655,43 @@ class TreeList implements Component { } handleInput(keyData: string): void { - if (isArrowUp(keyData)) { + if (matchesKey(keyData, "up")) { this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; - } else if (isArrowDown(keyData)) { + } else if (matchesKey(keyData, "down")) { this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1; - } else if (isArrowLeft(keyData)) { + } else if (matchesKey(keyData, "left")) { // Page up this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines); - } else if (isArrowRight(keyData)) { + } else if (matchesKey(keyData, "right")) { // Page down this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines); - } else if (isEnter(keyData)) { + } else if (matchesKey(keyData, "enter")) { const selected = this.filteredNodes[this.selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.node.entry.id); } - } else if (isEscape(keyData)) { + } else if (matchesKey(keyData, "escape")) { if (this.searchQuery) { this.searchQuery = ""; this.applyFilter(); } else { this.onCancel?.(); } - } else if (isCtrlC(keyData)) { + } else if (matchesKey(keyData, "ctrl+c")) { 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 (matchesKey(keyData, "backspace")) { if (this.searchQuery.length > 0) { this.searchQuery = this.searchQuery.slice(0, -1); this.applyFilter(); @@ -768,10 +759,10 @@ class LabelInput implements Component { } handleInput(keyData: string): void { - if (isEnter(keyData)) { + if (matchesKey(keyData, "enter")) { const value = this.input.getValue().trim(); this.onSubmit?.(this.entryId, value || undefined); - } else if (isEscape(keyData)) { + } else if (matchesKey(keyData, "escape")) { 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..6b97c6e9 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, matchesKey, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -90,28 +79,28 @@ class UserMessageList implements Component { handleInput(keyData: string): void { // Up arrow - go to previous (older) message, wrap to bottom when at top - if (isArrowUp(keyData)) { + if (matchesKey(keyData, "up")) { 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 (matchesKey(keyData, "down")) { this.selectedIndex = this.selectedIndex === this.messages.length - 1 ? 0 : this.selectedIndex + 1; } // Enter - select message and branch - else if (isEnter(keyData)) { + else if (matchesKey(keyData, "enter")) { const selected = this.messages[this.selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.id); } } // Escape - cancel - else if (isEscape(keyData)) { + else if (matchesKey(keyData, "escape")) { if (this.onCancel) { this.onCancel(); } } // Ctrl+C - cancel - else if (isCtrlC(keyData)) { + else if (matchesKey(keyData, "ctrl+c")) { 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 238aaa5c..b051477d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -28,6 +28,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 +85,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 +167,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); @@ -770,20 +773,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; @@ -1456,7 +1460,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; diff --git a/packages/tui/src/components/cancellable-loader.ts b/packages/tui/src/components/cancellable-loader.ts index 8e2621da..f8c76193 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 { matchesKey } from "../keys.js"; import { Loader } from "./loader.js"; /** @@ -27,7 +27,7 @@ export class CancellableLoader extends Loader { } handleInput(data: string): void { - if (isEscape(data)) { + if (matchesKey(data, "escape")) { this.abortController.abort(); this.onAbort?.(); } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index c5765162..b62edeaf 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..31489187 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 { matchesKey } from "../keys.js"; import type { Component } from "../tui.js"; import { truncateToWidth } from "../utils.js"; @@ -146,24 +146,24 @@ export class SelectList implements Component { handleInput(keyData: string): void { // Up arrow - wrap to bottom when at top - if (isArrowUp(keyData)) { + if (matchesKey(keyData, "up")) { this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1; this.notifySelectionChange(); } // Down arrow - wrap to top when at bottom - else if (isArrowDown(keyData)) { + else if (matchesKey(keyData, "down")) { this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1; this.notifySelectionChange(); } // Enter - else if (isEnter(keyData)) { + else if (matchesKey(keyData, "enter")) { 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 (matchesKey(keyData, "escape") || matchesKey(keyData, "ctrl+c")) { 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..2452a3ea 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 { matchesKey } from "../keys.js"; import type { Component } from "../tui.js"; import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js"; @@ -145,13 +145,13 @@ export class SettingsList implements Component { } // Main list input handling - if (isArrowUp(data)) { + if (matchesKey(data, "up")) { this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1; - } else if (isArrowDown(data)) { + } else if (matchesKey(data, "down")) { this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1; - } else if (isEnter(data) || data === " ") { + } else if (matchesKey(data, "enter") || data === " ") { this.activateItem(); - } else if (isEscape(data) || isCtrlC(data)) { + } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.onCancel(); } } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index d5a16207..32fb2356 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -20,45 +20,18 @@ 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, + type KeyId, + setEditorKeybindings, +} from "./keybindings.js"; +// Keyboard input handling +export { 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..f426b394 --- /dev/null +++ b/packages/tui/src/keybindings.ts @@ -0,0 +1,145 @@ +import { matchesKey } from "./keys.js"; + +/** + * Editor actions that can be bound to keys. + */ +export type EditorAction = + // Cursor movement + | "cursorUp" + | "cursorDown" + | "cursorLeft" + | "cursorRight" + | "cursorWordLeft" + | "cursorWordRight" + | "cursorLineStart" + | "cursorLineEnd" + // Deletion + | "deleteCharBackward" + | "deleteCharForward" + | "deleteWordBackward" + | "deleteToLineStart" + | "deleteToLineEnd" + // Text input + | "newLine" + | "submit" + | "tab" + // Selection/autocomplete + | "selectUp" + | "selectDown" + | "selectConfirm" + | "selectCancel" + // Clipboard + | "copy"; + +/** + * Key identifier string (e.g., "ctrl+c", "shift+ctrl+p", "escape"). + */ +export type KeyId = string; + +/** + * Editor keybindings configuration. + */ +export type EditorKeybindingsConfig = { + [K in EditorAction]?: KeyId | KeyId[]; +}; + +/** + * Default editor keybindings. + */ +export const DEFAULT_EDITOR_KEYBINDINGS: Required = { + // Cursor movement + cursorUp: "up", + cursorDown: "down", + cursorLeft: "left", + cursorRight: "right", + cursorWordLeft: ["alt+left", "ctrl+left"], + cursorWordRight: ["alt+right", "ctrl+right"], + cursorLineStart: ["home", "ctrl+a"], + cursorLineEnd: ["end", "ctrl+e"], + // Deletion + deleteCharBackward: "backspace", + deleteCharForward: "delete", + deleteWordBackward: ["ctrl+w", "alt+backspace"], + deleteToLineStart: "ctrl+u", + deleteToLineEnd: "ctrl+k", + // Text input + newLine: ["shift+enter", "alt+enter"], + submit: "enter", + tab: "tab", + // Selection/autocomplete + selectUp: "up", + selectDown: "down", + selectConfirm: "enter", + selectCancel: "escape", + // Clipboard + copy: "ctrl+c", +}; + +/** + * Manages keybindings for the editor. + */ +export class EditorKeybindingsManager { + private actionToKeys: Map; + + 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..8d1b583d 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -1,87 +1,42 @@ /** - * 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 */ -// 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, +// ============================================================================= +// Constants +// ============================================================================= - // Special keys +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 +46,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 +64,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 +91,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 +102,278 @@ 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" + * + * @param data - Raw input data from terminal + * @param keyId - Key identifier string (e.g., "ctrl+c", "escape") */ -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: string): 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);