diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 839c15bb..d3066bd1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,9 +2,15 @@ ## [Unreleased] +### Breaking Changes + +- Interactive keybinding ids are now namespaced, and `keybindings.json` now uses those same canonical namespaced ids. Older config files are migrated automatically on startup. Custom editors and extension UI components still receive an injected `keybindings: KeybindingsManager`. They do not call `getKeybindings()` or `setKeybindings()` themselves. Declaration merging applies to that injected type ([#2391](https://github.com/badlogic/pi-mono/issues/2391)) +- Extension author migration: update `keyHint()`, `keyText()`, and injected `keybindings.matches(...)` calls from old built-in names like `"expandTools"`, `"selectConfirm"`, and `"interrupt"` to namespaced ids like `"app.tools.expand"`, `"tui.select.confirm"`, and `"app.interrupt"`. See [docs/keybindings.md](docs/keybindings.md) for the full list. `pi.registerShortcut("ctrl+shift+p", ...)` is unchanged because extension shortcuts still use raw key combos, not keybinding ids. + ### Fixed - Tests for session-selector-rename and tree-selector are now keybinding-agnostic, resetting editor keybindings to defaults before each test so user `keybindings.json` cannot cause failures ([#2360](https://github.com/badlogic/pi-mono/issues/2360)) +- Fixed custom `keybindings.json` overrides to shadow conflicting default shortcuts globally, so bindings such as `cursorUp: ["up", "ctrl+p"]` no longer leave default actions like model cycling active ([#2391](https://github.com/badlogic/pi-mono/issues/2391)) - Fixed Windows bash execution hanging for commands that spawn detached descendants inheriting stdout/stderr handles, which caused `agent-browser` and similar commands to spin forever. ## [0.60.0] - 2026-03-18 diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 68c9e770..178a7c7a 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -1670,6 +1670,8 @@ Use namespaced keybinding ids: - Coding-agent ids use the `app.*` namespace, for example `app.tools.expand`, `app.editor.external`, `app.session.rename` - Shared TUI ids use the `tui.*` namespace, for example `tui.select.confirm`, `tui.select.cancel`, `tui.input.tab` +For the exhaustive list of keybinding ids and defaults, see [keybindings.md](keybindings.md). `keybindings.json` uses those same namespaced ids. + Custom editors and `ctx.ui.custom()` components receive `keybindings: KeybindingsManager` as an injected argument. They should use that injected manager directly instead of calling `getKeybindings()` or `setKeybindings()`. #### Best Practices diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index b09e62b5..bbf01bd8 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -2,6 +2,10 @@ All keyboard shortcuts can be customized via `~/.pi/agent/keybindings.json`. Each action can be bound to one or more keys. +The config file uses the same namespaced keybinding ids that pi uses internally and that extension authors use in `keyHint()` and injected `keybindings` managers. + +Older configs using pre-namespaced ids such as `cursorUp` or `expandTools` are migrated automatically to the namespaced ids on startup. + After editing `keybindings.json`, run `/reload` in pi to apply the changes without restarting the session. ## Key Format @@ -18,127 +22,112 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1 ## All Actions -### Cursor Movement +### TUI Editor Cursor Movement -| Action | Default | Description | +| Keybinding id | Default | Description | |--------|---------|-------------| -| `cursorUp` | `up` | Move cursor up | -| `cursorDown` | `down` | Move cursor down | -| `cursorLeft` | `left`, `ctrl+b` | Move cursor left | -| `cursorRight` | `right`, `ctrl+f` | Move cursor right | -| `cursorWordLeft` | `alt+left`, `ctrl+left`, `alt+b` | Move cursor word left | -| `cursorWordRight` | `alt+right`, `ctrl+right`, `alt+f` | Move cursor word right | -| `cursorLineStart` | `home`, `ctrl+a` | Move to line start | -| `cursorLineEnd` | `end`, `ctrl+e` | Move to line end | -| `jumpForward` | `ctrl+]` | Jump forward to character | -| `jumpBackward` | `ctrl+alt+]` | Jump backward to character | -| `pageUp` | `pageUp` | Scroll up by page | -| `pageDown` | `pageDown` | Scroll down by page | +| `tui.editor.cursorUp` | `up` | Move cursor up | +| `tui.editor.cursorDown` | `down` | Move cursor down | +| `tui.editor.cursorLeft` | `left`, `ctrl+b` | Move cursor left | +| `tui.editor.cursorRight` | `right`, `ctrl+f` | Move cursor right | +| `tui.editor.cursorWordLeft` | `alt+left`, `ctrl+left`, `alt+b` | Move cursor word left | +| `tui.editor.cursorWordRight` | `alt+right`, `ctrl+right`, `alt+f` | Move cursor word right | +| `tui.editor.cursorLineStart` | `home`, `ctrl+a` | Move to line start | +| `tui.editor.cursorLineEnd` | `end`, `ctrl+e` | Move to line end | +| `tui.editor.jumpForward` | `ctrl+]` | Jump forward to character | +| `tui.editor.jumpBackward` | `ctrl+alt+]` | Jump backward to character | +| `tui.editor.pageUp` | `pageUp` | Scroll up by page | +| `tui.editor.pageDown` | `pageDown` | Scroll down by page | -### Deletion +### TUI Editor Deletion -| Action | Default | Description | +| Keybinding id | Default | Description | |--------|---------|-------------| -| `deleteCharBackward` | `backspace` | Delete character backward | -| `deleteCharForward` | `delete`, `ctrl+d` | Delete character forward | -| `deleteWordBackward` | `ctrl+w`, `alt+backspace` | Delete word backward | -| `deleteWordForward` | `alt+d`, `alt+delete` | Delete word forward | -| `deleteToLineStart` | `ctrl+u` | Delete to line start | -| `deleteToLineEnd` | `ctrl+k` | Delete to line end | +| `tui.editor.deleteCharBackward` | `backspace` | Delete character backward | +| `tui.editor.deleteCharForward` | `delete`, `ctrl+d` | Delete character forward | +| `tui.editor.deleteWordBackward` | `ctrl+w`, `alt+backspace` | Delete word backward | +| `tui.editor.deleteWordForward` | `alt+d`, `alt+delete` | Delete word forward | +| `tui.editor.deleteToLineStart` | `ctrl+u` | Delete to line start | +| `tui.editor.deleteToLineEnd` | `ctrl+k` | Delete to line end | -### Text Input +### TUI Input -| Action | Default | Description | +| Keybinding id | Default | Description | |--------|---------|-------------| -| `newLine` | `shift+enter` | Insert new line | -| `submit` | `enter` | Submit input | -| `tab` | `tab` | Tab / autocomplete | +| `tui.input.newLine` | `shift+enter` | Insert new line | +| `tui.input.submit` | `enter` | Submit input | +| `tui.input.tab` | `tab` | Tab / autocomplete | -### Kill Ring +### TUI Kill Ring -| Action | Default | Description | +| Keybinding id | Default | Description | |--------|---------|-------------| -| `yank` | `ctrl+y` | Paste most recently deleted text | -| `yankPop` | `alt+y` | Cycle through deleted text after yank | -| `undo` | `ctrl+-` | Undo last edit | +| `tui.editor.yank` | `ctrl+y` | Paste most recently deleted text | +| `tui.editor.yankPop` | `alt+y` | Cycle through deleted text after yank | +| `tui.editor.undo` | `ctrl+-` | Undo last edit | -### Clipboard +### TUI Clipboard and Selection -| Action | Default | Description | +| Keybinding id | Default | Description | |--------|---------|-------------| -| `copy` | `ctrl+c` | Copy selection | -| `pasteImage` | `ctrl+v` | Paste image from clipboard | +| `tui.input.copy` | `ctrl+c` | Copy selection | +| `tui.select.up` | `up` | Move selection up | +| `tui.select.down` | `down` | Move selection down | +| `tui.select.pageUp` | `pageUp` | Page up in list | +| `tui.select.pageDown` | `pageDown` | Page down in list | +| `tui.select.confirm` | `enter` | Confirm selection | +| `tui.select.cancel` | `escape`, `ctrl+c` | Cancel selection | ### Application -| Action | Default | Description | +| Keybinding id | Default | Description | |--------|---------|-------------| -| `interrupt` | `escape` | Cancel / abort | -| `clear` | `ctrl+c` | Clear editor | -| `exit` | `ctrl+d` | Exit (when editor empty) | -| `suspend` | `ctrl+z` | Suspend to background | -| `externalEditor` | `ctrl+g` | Open in external editor (`$VISUAL` or `$EDITOR`) | +| `app.interrupt` | `escape` | Cancel / abort | +| `app.clear` | `ctrl+c` | Clear editor | +| `app.exit` | `ctrl+d` | Exit (when editor empty) | +| `app.suspend` | `ctrl+z` | Suspend to background | +| `app.editor.external` | `ctrl+g` | Open in external editor (`$VISUAL` or `$EDITOR`) | +| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows) | Paste image from clipboard | -### Session +### Sessions -| Action | Default | Description | +| Keybinding id | Default | Description | |--------|---------|-------------| -| `newSession` | *(none)* | Start a new session (`/new`) | -| `tree` | *(none)* | Open session tree navigator (`/tree`) | -| `fork` | *(none)* | Fork current session (`/fork`) | -| `resume` | *(none)* | Open session resume picker (`/resume`) | +| `app.session.new` | *(none)* | Start a new session (`/new`) | +| `app.session.tree` | *(none)* | Open session tree navigator (`/tree`) | +| `app.session.fork` | *(none)* | Fork current session (`/fork`) | +| `app.session.resume` | *(none)* | Open session resume picker (`/resume`) | +| `app.session.togglePath` | `ctrl+p` | Toggle path display | +| `app.session.toggleSort` | `ctrl+s` | Toggle sort mode | +| `app.session.toggleNamedFilter` | `ctrl+n` | Toggle named-only filter | +| `app.session.rename` | `ctrl+r` | Rename session | +| `app.session.delete` | `ctrl+d` | Delete session | +| `app.session.deleteNoninvasive` | `ctrl+backspace` | Delete session when query is empty | -### Models & Thinking +### Models and Thinking -| Action | Default | Description | +| Keybinding id | Default | Description | |--------|---------|-------------| -| `selectModel` | `ctrl+l` | Open model selector | -| `cycleModelForward` | `ctrl+p` | Cycle to next model | -| `cycleModelBackward` | `shift+ctrl+p` | Cycle to previous model | -| `cycleThinkingLevel` | `shift+tab` | Cycle thinking level | +| `app.model.select` | `ctrl+l` | Open model selector | +| `app.model.cycleForward` | `ctrl+p` | Cycle to next model | +| `app.model.cycleBackward` | `shift+ctrl+p` | Cycle to previous model | +| `app.thinking.cycle` | `shift+tab` | Cycle thinking level | +| `app.thinking.toggle` | `ctrl+t` | Collapse or expand thinking blocks | -### Display +### Display and Message Queue -| Action | Default | Description | +| Keybinding id | Default | Description | |--------|---------|-------------| -| `expandTools` | `ctrl+o` | Collapse/expand tool output | -| `toggleThinking` | `ctrl+t` | Collapse/expand thinking blocks | - -### Message Queue - -| Action | Default | Description | -|--------|---------|-------------| -| `followUp` | `alt+enter` | Queue follow-up message | -| `dequeue` | `alt+up` | Restore queued messages to editor | - -### Selection (Lists, Pickers) - -| Action | Default | Description | -|--------|---------|-------------| -| `selectUp` | `up` | Move selection up | -| `selectDown` | `down` | Move selection down | -| `selectPageUp` | `pageUp` | Page up in list | -| `selectPageDown` | `pageDown` | Page down in list | -| `selectConfirm` | `enter` | Confirm selection | -| `selectCancel` | `escape`, `ctrl+c` | Cancel selection | +| `app.tools.expand` | `ctrl+o` | Collapse or expand tool output | +| `app.message.followUp` | `alt+enter` | Queue follow-up message | +| `app.message.dequeue` | `alt+up` | Restore queued messages to editor | ### Tree Navigation -| Action | Default | Description | +| Keybinding id | Default | Description | |--------|---------|-------------| -| `treeFoldOrUp` | `ctrl+left`, `alt+left` | Fold current branch segment, or jump to the previous segment start | -| `treeUnfoldOrDown` | `ctrl+right`, `alt+right` | Unfold current branch segment, or jump to the next segment start or branch end | - -### Session Picker - -| Action | Default | Description | -|--------|---------|-------------| -| `toggleSessionPath` | `ctrl+p` | Toggle path display | -| `toggleSessionSort` | `ctrl+s` | Toggle sort mode | -| `toggleSessionNamedFilter` | `ctrl+n` | Toggle named-only filter | -| `renameSession` | `ctrl+r` | Rename session | -| `deleteSession` | `ctrl+d` | Delete session | -| `deleteSessionNoninvasive` | `ctrl+backspace` | Delete session (when query empty) | +| `app.tree.foldOrUp` | `ctrl+left`, `alt+left` | Fold current branch segment, or jump to the previous segment start | +| `app.tree.unfoldOrDown` | `ctrl+right`, `alt+right` | Unfold current branch segment, or jump to the next segment start or branch end | ## Custom Configuration @@ -146,9 +135,9 @@ Create `~/.pi/agent/keybindings.json`: ```json { - "cursorUp": ["up", "ctrl+p"], - "cursorDown": ["down", "ctrl+n"], - "deleteWordBackward": ["ctrl+w", "alt+backspace"] + "tui.editor.cursorUp": ["up", "ctrl+p"], + "tui.editor.cursorDown": ["down", "ctrl+n"], + "tui.editor.deleteWordBackward": ["ctrl+w", "alt+backspace"] } ``` @@ -158,15 +147,15 @@ Each action can have a single key or an array of keys. User config overrides def ```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"] + "tui.editor.cursorUp": ["up", "ctrl+p"], + "tui.editor.cursorDown": ["down", "ctrl+n"], + "tui.editor.cursorLeft": ["left", "ctrl+b"], + "tui.editor.cursorRight": ["right", "ctrl+f"], + "tui.editor.cursorWordLeft": ["alt+left", "alt+b"], + "tui.editor.cursorWordRight": ["alt+right", "alt+f"], + "tui.editor.deleteCharForward": ["delete", "ctrl+d"], + "tui.editor.deleteCharBackward": ["backspace", "ctrl+h"], + "tui.input.newLine": ["shift+enter", "ctrl+j"] } ``` @@ -174,11 +163,11 @@ Each action can have a single key or an array of keys. User config overrides def ```json { - "cursorUp": ["up", "alt+k"], - "cursorDown": ["down", "alt+j"], - "cursorLeft": ["left", "alt+h"], - "cursorRight": ["right", "alt+l"], - "cursorWordLeft": ["alt+left", "alt+b"], - "cursorWordRight": ["alt+right", "alt+w"] + "tui.editor.cursorUp": ["up", "alt+k"], + "tui.editor.cursorDown": ["down", "alt+j"], + "tui.editor.cursorLeft": ["left", "alt+h"], + "tui.editor.cursorRight": ["right", "alt+l"], + "tui.editor.cursorWordLeft": ["alt+left", "alt+b"], + "tui.editor.cursorWordRight": ["alt+right", "alt+w"] } ``` diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index 80c19f98..8fa820c3 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -2,7 +2,7 @@ * TUI session selector for --resume flag */ -import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; +import { ProcessTerminal, setKeybindings, TUI } from "@mariozechner/pi-tui"; import { KeybindingsManager } from "../core/keybindings.js"; import type { SessionInfo, SessionListProgress } from "../core/session-manager.js"; import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js"; @@ -17,6 +17,7 @@ export async function selectSession( return new Promise((resolve) => { const ui = new TUI(new ProcessTerminal()); const keybindings = KeybindingsManager.create(); + setKeybindings(keybindings); let resolved = false; const selector = new SessionSelectorComponent( diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 8ae8e04e..a473d139 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -24,9 +24,9 @@ export type { // Re-exports AgentToolResult, AgentToolUpdateCallback, - // App keybindings (for custom editors) - AppAction, AppendEntryHandler, + // App keybindings (for custom editors) + AppKeybinding, // Events - Tool (ToolCallEvent types) BashToolCallEvent, BashToolResultEvent, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 695e1cf3..dc557d4e 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -7,7 +7,7 @@ import type { ImageContent, Model } from "@mariozechner/pi-ai"; import type { KeyId } from "@mariozechner/pi-tui"; import { type Theme, theme } from "../../modes/interactive/theme/theme.js"; import type { ResourceDiagnostic } from "../diagnostics.js"; -import type { KeyAction, KeybindingsConfig } from "../keybindings.js"; +import type { KeybindingsConfig } from "../keybindings.js"; import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; import type { @@ -51,40 +51,41 @@ import type { UserBashEventResult, } from "./types.js"; -// Keybindings for these actions cannot be overridden by extensions -const RESERVED_ACTIONS_FOR_EXTENSION_CONFLICTS: ReadonlyArray = [ - "interrupt", - "clear", - "exit", - "suspend", - "cycleThinkingLevel", - "cycleModelForward", - "cycleModelBackward", - "selectModel", - "expandTools", - "toggleThinking", - "externalEditor", - "followUp", - "submit", - "selectConfirm", - "selectCancel", - "copy", - "deleteToLineEnd", -]; +// Extension shortcuts compete with canonical keybinding ids from keybindings.json. +// Only editor-global shortcuts are reserved here. Picker-specific bindings are not. +const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [ + "app.interrupt", + "app.clear", + "app.exit", + "app.suspend", + "app.thinking.cycle", + "app.model.cycleForward", + "app.model.cycleBackward", + "app.model.select", + "app.tools.expand", + "app.thinking.toggle", + "app.editor.external", + "app.message.followUp", + "tui.input.submit", + "tui.select.confirm", + "tui.select.cancel", + "tui.input.copy", + "tui.editor.deleteToLineEnd", +] as const; -type BuiltInKeyBindings = Partial>; +type BuiltInKeyBindings = Partial>; -const buildBuiltinKeybindings = (effectiveKeybindings: Required): BuiltInKeyBindings => { +const buildBuiltinKeybindings = (resolvedKeybindings: KeybindingsConfig): BuiltInKeyBindings => { const builtinKeybindings = {} as BuiltInKeyBindings; - for (const [action, keys] of Object.entries(effectiveKeybindings)) { - const keyAction = action as KeyAction; + for (const [keybinding, keys] of Object.entries(resolvedKeybindings)) { + if (keys === undefined) continue; const keyList = Array.isArray(keys) ? keys : [keys]; - const restrictOverride = RESERVED_ACTIONS_FOR_EXTENSION_CONFLICTS.includes(keyAction); + const restrictOverride = (RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS as readonly string[]).includes(keybinding); for (const key of keyList) { const normalizedKey = key.toLowerCase() as KeyId; builtinKeybindings[normalizedKey] = { - action: keyAction, - restrictOverride: restrictOverride, + keybinding, + restrictOverride, }; } } @@ -386,9 +387,9 @@ export class ExtensionRunner { return new Map(this.runtime.flagValues); } - getShortcuts(effectiveKeybindings: Required): Map { + getShortcuts(resolvedKeybindings: KeybindingsConfig): Map { this.shortcutDiagnostics = []; - const builtinKeybindings = buildBuiltinKeybindings(effectiveKeybindings); + const builtinKeybindings = buildBuiltinKeybindings(resolvedKeybindings); const extensionShortcuts = new Map(); const addDiagnostic = (message: string, extensionPath: string) => { @@ -413,7 +414,7 @@ export class ExtensionRunner { if (builtInKeybinding?.restrictOverride === false) { addDiagnostic( - `Extension shortcut conflict: '${key}' is built-in shortcut for ${builtInKeybinding.action} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, + `Extension shortcut conflict: '${key}' is built-in shortcut for ${builtInKeybinding.keybinding} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, shortcut.extensionPath, ); } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index aac7c684..b8b1a8b2 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -74,7 +74,7 @@ import type { export type { ExecOptions, ExecResult } from "../exec.js"; export type { AgentToolResult, AgentToolUpdateCallback }; -export type { AppAction, KeybindingsManager } from "../keybindings.js"; +export type { AppKeybinding, KeybindingsManager } from "../keybindings.js"; // ============================================================================ // UI Context diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 091519b6..32343d7d 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -1,220 +1,302 @@ import { - DEFAULT_EDITOR_KEYBINDINGS, - type EditorAction, - type EditorKeybindingsConfig, - EditorKeybindingsManager, + type Keybinding, + type KeybindingDefinitions, + type KeybindingsConfig, type KeyId, - matchesKey, - setEditorKeybindings, + TUI_KEYBINDINGS, + KeybindingsManager as TuiKeybindingsManager, } from "@mariozechner/pi-tui"; -import { existsSync, readFileSync } from "fs"; +import { existsSync, readFileSync, writeFileSync } 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" - | "toggleSessionNamedFilter" - | "externalEditor" - | "followUp" - | "dequeue" - | "pasteImage" - | "newSession" - | "tree" - | "fork" - | "resume"; - -/** - * 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", - toggleSessionNamedFilter: "ctrl+n", - externalEditor: "ctrl+g", - followUp: "alt+enter", - dequeue: "alt+up", - pasteImage: process.platform === "win32" ? "alt+v" : "ctrl+v", - newSession: [], - tree: [], - fork: [], - resume: [], -}; - -/** - * 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", - "toggleSessionNamedFilter", - "externalEditor", - "followUp", - "dequeue", - "pasteImage", - "newSession", - "tree", - "fork", - "resume", -]; - -function isAppAction(action: string): action is AppAction { - return APP_ACTIONS.includes(action as AppAction); +export interface AppKeybindings { + "app.interrupt": true; + "app.clear": true; + "app.exit": true; + "app.suspend": true; + "app.thinking.cycle": true; + "app.model.cycleForward": true; + "app.model.cycleBackward": true; + "app.model.select": true; + "app.tools.expand": true; + "app.thinking.toggle": true; + "app.session.toggleNamedFilter": true; + "app.editor.external": true; + "app.message.followUp": true; + "app.message.dequeue": true; + "app.clipboard.pasteImage": true; + "app.session.new": true; + "app.session.tree": true; + "app.session.fork": true; + "app.session.resume": true; + "app.tree.foldOrUp": true; + "app.tree.unfoldOrDown": true; + "app.session.togglePath": true; + "app.session.toggleSort": true; + "app.session.rename": true; + "app.session.delete": true; + "app.session.deleteNoninvasive": true; } -/** - * Manages all keybindings (app + editor). - */ -export class KeybindingsManager { - private config: KeybindingsConfig; - private configPath: string | undefined; - private appActionToKeys: Map; +export type AppKeybinding = keyof AppKeybindings; - private constructor(config: KeybindingsConfig, configPath?: string) { - this.config = config; - this.configPath = configPath; - this.appActionToKeys = new Map(); - this.buildMaps(); +declare module "@mariozechner/pi-tui" { + interface Keybindings extends AppKeybindings {} +} + +export const KEYBINDINGS = { + ...TUI_KEYBINDINGS, + "app.interrupt": { defaultKeys: "escape", description: "Cancel or abort" }, + "app.clear": { defaultKeys: "ctrl+c", description: "Clear editor" }, + "app.exit": { defaultKeys: "ctrl+d", description: "Exit when editor is empty" }, + "app.suspend": { defaultKeys: "ctrl+z", description: "Suspend to background" }, + "app.thinking.cycle": { + defaultKeys: "shift+tab", + description: "Cycle thinking level", + }, + "app.model.cycleForward": { + defaultKeys: "ctrl+p", + description: "Cycle to next model", + }, + "app.model.cycleBackward": { + defaultKeys: "shift+ctrl+p", + description: "Cycle to previous model", + }, + "app.model.select": { defaultKeys: "ctrl+l", description: "Open model selector" }, + "app.tools.expand": { defaultKeys: "ctrl+o", description: "Toggle tool output" }, + "app.thinking.toggle": { + defaultKeys: "ctrl+t", + description: "Toggle thinking blocks", + }, + "app.session.toggleNamedFilter": { + defaultKeys: "ctrl+n", + description: "Toggle named session filter", + }, + "app.editor.external": { + defaultKeys: "ctrl+g", + description: "Open external editor", + }, + "app.message.followUp": { + defaultKeys: "alt+enter", + description: "Queue follow-up message", + }, + "app.message.dequeue": { + defaultKeys: "alt+up", + description: "Restore queued messages", + }, + "app.clipboard.pasteImage": { + defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v", + description: "Paste image from clipboard", + }, + "app.session.new": { defaultKeys: [], description: "Start a new session" }, + "app.session.tree": { defaultKeys: [], description: "Open session tree" }, + "app.session.fork": { defaultKeys: [], description: "Fork current session" }, + "app.session.resume": { defaultKeys: [], description: "Resume a session" }, + "app.tree.foldOrUp": { + defaultKeys: ["ctrl+left", "alt+left"], + description: "Fold tree branch or move up", + }, + "app.tree.unfoldOrDown": { + defaultKeys: ["ctrl+right", "alt+right"], + description: "Unfold tree branch or move down", + }, + "app.session.togglePath": { + defaultKeys: "ctrl+p", + description: "Toggle session path display", + }, + "app.session.toggleSort": { + defaultKeys: "ctrl+s", + description: "Toggle session sort mode", + }, + "app.session.rename": { + defaultKeys: "ctrl+r", + description: "Rename session", + }, + "app.session.delete": { + defaultKeys: "ctrl+d", + description: "Delete session", + }, + "app.session.deleteNoninvasive": { + defaultKeys: "ctrl+backspace", + description: "Delete session when query is empty", + }, +} as const satisfies KeybindingDefinitions; + +const KEYBINDING_NAME_MIGRATIONS = { + cursorUp: "tui.editor.cursorUp", + cursorDown: "tui.editor.cursorDown", + cursorLeft: "tui.editor.cursorLeft", + cursorRight: "tui.editor.cursorRight", + cursorWordLeft: "tui.editor.cursorWordLeft", + cursorWordRight: "tui.editor.cursorWordRight", + cursorLineStart: "tui.editor.cursorLineStart", + cursorLineEnd: "tui.editor.cursorLineEnd", + jumpForward: "tui.editor.jumpForward", + jumpBackward: "tui.editor.jumpBackward", + pageUp: "tui.editor.pageUp", + pageDown: "tui.editor.pageDown", + deleteCharBackward: "tui.editor.deleteCharBackward", + deleteCharForward: "tui.editor.deleteCharForward", + deleteWordBackward: "tui.editor.deleteWordBackward", + deleteWordForward: "tui.editor.deleteWordForward", + deleteToLineStart: "tui.editor.deleteToLineStart", + deleteToLineEnd: "tui.editor.deleteToLineEnd", + yank: "tui.editor.yank", + yankPop: "tui.editor.yankPop", + undo: "tui.editor.undo", + newLine: "tui.input.newLine", + submit: "tui.input.submit", + tab: "tui.input.tab", + copy: "tui.input.copy", + selectUp: "tui.select.up", + selectDown: "tui.select.down", + selectPageUp: "tui.select.pageUp", + selectPageDown: "tui.select.pageDown", + selectConfirm: "tui.select.confirm", + selectCancel: "tui.select.cancel", + interrupt: "app.interrupt", + clear: "app.clear", + exit: "app.exit", + suspend: "app.suspend", + cycleThinkingLevel: "app.thinking.cycle", + cycleModelForward: "app.model.cycleForward", + cycleModelBackward: "app.model.cycleBackward", + selectModel: "app.model.select", + expandTools: "app.tools.expand", + toggleThinking: "app.thinking.toggle", + toggleSessionNamedFilter: "app.session.toggleNamedFilter", + externalEditor: "app.editor.external", + followUp: "app.message.followUp", + dequeue: "app.message.dequeue", + pasteImage: "app.clipboard.pasteImage", + newSession: "app.session.new", + tree: "app.session.tree", + fork: "app.session.fork", + resume: "app.session.resume", + treeFoldOrUp: "app.tree.foldOrUp", + treeUnfoldOrDown: "app.tree.unfoldOrDown", + toggleSessionPath: "app.session.togglePath", + toggleSessionSort: "app.session.toggleSort", + renameSession: "app.session.rename", + deleteSession: "app.session.delete", + deleteSessionNoninvasive: "app.session.deleteNoninvasive", +} as const satisfies Record; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAME_MIGRATIONS { + return key in KEYBINDING_NAME_MIGRATIONS; +} + +function toKeybindingsConfig(value: unknown): KeybindingsConfig { + if (!isRecord(value)) return {}; + + const config: KeybindingsConfig = {}; + for (const [key, binding] of Object.entries(value)) { + if (typeof binding === "string") { + config[key] = binding as KeyId; + continue; + } + if (Array.isArray(binding) && binding.every((entry) => typeof entry === "string")) { + config[key] = binding as KeyId[]; + } + } + return config; +} + +function migrateKeybindingNames(rawConfig: Record): { + config: Record; + migrated: boolean; +} { + const config: Record = {}; + let migrated = false; + + for (const [key, value] of Object.entries(rawConfig)) { + const nextKey = isLegacyKeybindingName(key) ? KEYBINDING_NAME_MIGRATIONS[key] : key; + if (nextKey !== key) { + migrated = true; + } + if (key !== nextKey && Object.hasOwn(rawConfig, nextKey)) { + migrated = true; + continue; + } + config[nextKey] = value; + } + + return { config: orderKeybindingsConfig(config), migrated }; +} + +function orderKeybindingsConfig(config: Record): Record { + const ordered: Record = {}; + for (const keybinding of Object.keys(KEYBINDINGS)) { + if (Object.hasOwn(config, keybinding)) { + ordered[keybinding] = config[keybinding]; + } + } + + const extras = Object.keys(config) + .filter((key) => !Object.hasOwn(ordered, key)) + .sort(); + for (const key of extras) { + ordered[key] = config[key]; + } + + return ordered; +} + +function loadRawConfig(path: string): Record | undefined { + if (!existsSync(path)) return undefined; + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +export function migrateKeybindingsConfigFile(agentDir: string = getAgentDir()): boolean { + const configPath = join(agentDir, "keybindings.json"); + const rawConfig = loadRawConfig(configPath); + if (!rawConfig) return false; + + const { config, migrated } = migrateKeybindingNames(rawConfig); + if (!migrated) return false; + + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); + return true; +} + +export class KeybindingsManager extends TuiKeybindingsManager { + private configPath: string | undefined; + + constructor(userBindings: KeybindingsConfig = {}, configPath?: string) { + super(KEYBINDINGS, userBindings); + this.configPath = configPath; } - /** - * 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, configPath); - manager.applyEditorKeybindings(); - return manager; - } - - /** - * Create in-memory. - */ - static inMemory(config: KeybindingsConfig = {}): KeybindingsManager { - return new KeybindingsManager(config); + const userBindings = KeybindingsManager.loadFromFile(configPath); + return new KeybindingsManager(userBindings, configPath); } reload(): void { if (!this.configPath) return; - this.config = KeybindingsManager.loadFromFile(this.configPath); - this.buildMaps(); - this.applyEditorKeybindings(); + this.setUserBindings(KeybindingsManager.loadFromFile(this.configPath)); + } + + getEffectiveConfig(): KeybindingsConfig { + return this.getResolvedBindings(); } 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); - } - } - - private applyEditorKeybindings(): void { - const editorConfig: EditorKeybindingsConfig = {}; - for (const [action, keys] of Object.entries(this.config)) { - if (!isAppAction(action) || action === "expandTools") { - editorConfig[action as EditorAction] = keys; - } - } - setEditorKeybindings(new EditorKeybindingsManager(editorConfig)); - } - - /** - * 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 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; + const rawConfig = loadRawConfig(path); + if (!rawConfig) return {}; + return toKeybindingsConfig(migrateKeybindingNames(rawConfig).config); } } -// Re-export for convenience -export type { EditorAction, KeyId }; +export type { Keybinding, KeyId, KeybindingsConfig }; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index c2f2ab5b..7384799c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -18,7 +18,7 @@ import { APP_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js"; import { AuthStorage } from "./core/auth-storage.js"; import { exportFromFile } from "./core/export-html/index.js"; import type { LoadExtensionsResult } from "./core/extensions/index.js"; -import { KeybindingsManager } from "./core/keybindings.js"; +import { migrateKeybindingsConfigFile } from "./core/keybindings.js"; import { ModelRegistry } from "./core/model-registry.js"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; import { DefaultPackageManager } from "./core/package-manager.js"; @@ -739,6 +739,8 @@ export async function main(args: string[]) { process.exit(0); } + migrateKeybindingsConfigFile(agentDir); + if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); process.exit(1); @@ -771,9 +773,6 @@ export async function main(args: string[]) { // Handle --resume: show session picker if (parsed.resume) { - // Initialize keybindings so session picker respects user config - KeybindingsManager.create(); - // Compute effective session dir for resume (same logic as createSessionManager) const effectiveSessionDir = parsed.sessionDir || (await callSessionDirectoryHook(extensionsResult, cwd)); diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index fa78e030..be6cf8f1 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -12,7 +12,7 @@ import { } from "../../../core/tools/truncate.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; -import { editorKey, keyHint } from "./keybinding-hints.js"; +import { keyHint, keyText } from "./keybinding-hints.js"; import { truncateToVisualLines } from "./visual-truncate.js"; // Preview line limit when not expanded (matches tool execution behavior) @@ -58,7 +58,7 @@ export class BashExecutionComponent extends Container { ui, (spinner) => theme.fg(colorKey, spinner), (text) => theme.fg("muted", text), - `Running... (${editorKey("selectCancel")} to cancel)`, // Plain text for loader + `Running... (${keyText("tui.select.cancel")} to cancel)`, // Plain text for loader ); this.contentContainer.addChild(this.loader); @@ -168,10 +168,10 @@ export class BashExecutionComponent extends Container { // Show how many lines are hidden (collapsed preview) if (hiddenLineCount > 0) { if (this.expanded) { - statusParts.push(`(${keyHint("expandTools", "to collapse")})`); + statusParts.push(`(${keyHint("app.tools.expand", "to collapse")})`); } else { statusParts.push( - `${theme.fg("muted", `... ${hiddenLineCount} more lines`)} (${keyHint("expandTools", "to expand")})`, + `${theme.fg("muted", `... ${hiddenLineCount} more lines`)} (${keyHint("app.tools.expand", "to expand")})`, ); } } diff --git a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts index 4286f024..db6f3145 100644 --- a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts +++ b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts @@ -33,7 +33,7 @@ export class BorderedLoader extends Container { this.addChild(this.loader); if (this.cancellable) { this.addChild(new Spacer(1)); - this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0)); + this.addChild(new Text(keyHint("tui.select.cancel", "cancel"), 1, 0)); } this.addChild(new Spacer(1)); this.addChild(new DynamicBorder(borderColor)); diff --git a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts index bef32d27..798bb0a4 100644 --- a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts @@ -1,7 +1,7 @@ import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui"; import type { BranchSummaryMessage } from "../../../core/messages.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; -import { editorKey } from "./keybinding-hints.js"; +import { keyText } from "./keybinding-hints.js"; /** * Component that renders a branch summary message with collapsed/expanded state. @@ -47,7 +47,7 @@ export class BranchSummaryMessageComponent extends Box { this.addChild( new Text( theme.fg("customMessageText", "Branch summary (") + - theme.fg("dim", editorKey("expandTools")) + + theme.fg("dim", keyText("app.tools.expand")) + theme.fg("customMessageText", " to expand)"), 0, 0, diff --git a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index 5e349bc7..7752093a 100644 --- a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -1,7 +1,7 @@ import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui"; import type { CompactionSummaryMessage } from "../../../core/messages.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; -import { editorKey } from "./keybinding-hints.js"; +import { keyText } from "./keybinding-hints.js"; /** * Component that renders a compaction message with collapsed/expanded state. @@ -48,7 +48,7 @@ export class CompactionSummaryMessageComponent extends Box { this.addChild( new Text( theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (`) + - theme.fg("dim", editorKey("expandTools")) + + theme.fg("dim", keyText("app.tools.expand")) + theme.fg("customMessageText", " to expand)"), 0, 0, diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index 7e93cb51..c96823fe 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -7,7 +7,7 @@ import { type Component, Container, type Focusable, - getEditorKeybindings, + getKeybindings, Input, matchesKey, Spacer, @@ -355,17 +355,17 @@ class ResourceList implements Component, Focusable { } handleInput(data: string): void { - const kb = getEditorKeybindings(); + const kb = getKeybindings(); - if (kb.matches(data, "selectUp")) { + if (kb.matches(data, "tui.select.up")) { this.selectedIndex = this.findNextItem(this.selectedIndex, -1); return; } - if (kb.matches(data, "selectDown")) { + if (kb.matches(data, "tui.select.down")) { this.selectedIndex = this.findNextItem(this.selectedIndex, 1); return; } - if (kb.matches(data, "selectPageUp")) { + if (kb.matches(data, "tui.select.pageUp")) { // Jump up by maxVisible, then find nearest item let target = Math.max(0, this.selectedIndex - this.maxVisible); while (target < this.filteredItems.length && this.filteredItems[target].type !== "item") { @@ -376,7 +376,7 @@ class ResourceList implements Component, Focusable { } return; } - if (kb.matches(data, "selectPageDown")) { + if (kb.matches(data, "tui.select.pageDown")) { // Jump down by maxVisible, then find nearest item let target = Math.min(this.filteredItems.length - 1, this.selectedIndex + this.maxVisible); while (target >= 0 && this.filteredItems[target].type !== "item") { @@ -387,7 +387,7 @@ class ResourceList implements Component, Focusable { } return; } - if (kb.matches(data, "selectCancel")) { + if (kb.matches(data, "tui.select.cancel")) { this.onCancel?.(); return; } @@ -395,7 +395,7 @@ class ResourceList implements Component, Focusable { this.onExit?.(); return; } - if (data === " " || kb.matches(data, "selectConfirm")) { + if (data === " " || kb.matches(data, "tui.select.confirm")) { const entry = this.filteredItems[this.selectedIndex]; if (entry?.type === "item") { const newEnabled = !entry.item.enabled; 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 bb1b29fe..14513b9a 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,12 +1,12 @@ import { Editor, type EditorOptions, type EditorTheme, type TUI } from "@mariozechner/pi-tui"; -import type { AppAction, KeybindingsManager } from "../../../core/keybindings.js"; +import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.js"; /** * Custom editor that handles app-level keybindings for coding-agent. */ export class CustomEditor extends Editor { private keybindings: KeybindingsManager; - public actionHandlers: Map void> = new Map(); + public actionHandlers: Map void> = new Map(); // Special handlers that can be dynamically replaced public onEscape?: () => void; @@ -23,7 +23,7 @@ export class CustomEditor extends Editor { /** * Register a handler for an app action. */ - onAction(action: AppAction, handler: () => void): void { + onAction(action: AppKeybinding, handler: () => void): void { this.actionHandlers.set(action, handler); } @@ -34,7 +34,7 @@ export class CustomEditor extends Editor { } // Check for paste image keybinding - if (this.keybindings.matches(data, "pasteImage")) { + if (this.keybindings.matches(data, "app.clipboard.pasteImage")) { this.onPasteImage?.(); return; } @@ -42,10 +42,10 @@ export class CustomEditor extends Editor { // Check app keybindings first // Escape/interrupt - only if autocomplete is NOT active - if (this.keybindings.matches(data, "interrupt")) { + if (this.keybindings.matches(data, "app.interrupt")) { if (!this.isShowingAutocomplete()) { // Use dynamic onEscape if set, otherwise registered handler - const handler = this.onEscape ?? this.actionHandlers.get("interrupt"); + const handler = this.onEscape ?? this.actionHandlers.get("app.interrupt"); if (handler) { handler(); return; @@ -57,9 +57,9 @@ export class CustomEditor extends Editor { } // Exit (Ctrl+D) - only when editor is empty - if (this.keybindings.matches(data, "exit")) { + if (this.keybindings.matches(data, "app.exit")) { if (this.getText().length === 0) { - const handler = this.onCtrlD ?? this.actionHandlers.get("exit"); + const handler = this.onCtrlD ?? this.actionHandlers.get("app.exit"); if (handler) handler(); return; } @@ -68,7 +68,7 @@ export class CustomEditor extends Editor { // Check all other app actions for (const [action, handler] of this.actionHandlers) { - if (action !== "interrupt" && action !== "exit" && this.keybindings.matches(data, action)) { + if (action !== "app.interrupt" && action !== "app.exit" && this.keybindings.matches(data, action)) { handler(); return; } diff --git a/packages/coding-agent/src/modes/interactive/components/extension-editor.ts b/packages/coding-agent/src/modes/interactive/components/extension-editor.ts index 94c9341c..be6a7257 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-editor.ts @@ -12,7 +12,7 @@ import { Editor, type EditorOptions, type Focusable, - getEditorKeybindings, + getKeybindings, Spacer, Text, type TUI, @@ -20,7 +20,7 @@ import { import type { KeybindingsManager } from "../../../core/keybindings.js"; import { getEditorTheme, theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; -import { appKeyHint, keyHint } from "./keybinding-hints.js"; +import { keyHint } from "./keybinding-hints.js"; export class ExtensionEditorComponent extends Container implements Focusable { private editor: Editor; @@ -78,12 +78,12 @@ export class ExtensionEditorComponent extends Container implements Focusable { // Add hint const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR); const hint = - keyHint("selectConfirm", "submit") + + keyHint("tui.select.confirm", "submit") + " " + - keyHint("newLine", "newline") + + keyHint("tui.input.newLine", "newline") + " " + - keyHint("selectCancel", "cancel") + - (hasExternalEditor ? ` ${appKeyHint(this.keybindings, "externalEditor", "external editor")}` : ""); + keyHint("tui.select.cancel", "cancel") + + (hasExternalEditor ? ` ${keyHint("app.editor.external", "external editor")}` : ""); this.addChild(new Text(hint, 1, 0)); this.addChild(new Spacer(1)); @@ -93,15 +93,15 @@ export class ExtensionEditorComponent extends Container implements Focusable { } handleInput(keyData: string): void { - const kb = getEditorKeybindings(); + const kb = getKeybindings(); // Escape or Ctrl+C to cancel - if (kb.matches(keyData, "selectCancel")) { + if (kb.matches(keyData, "tui.select.cancel")) { this.onCancelCallback(); return; } // External editor (app keybinding) - if (this.keybindings.matches(keyData, "externalEditor")) { + if (this.keybindings.matches(keyData, "app.editor.external")) { this.openExternalEditor(); return; } diff --git a/packages/coding-agent/src/modes/interactive/components/extension-input.ts b/packages/coding-agent/src/modes/interactive/components/extension-input.ts index 16434633..733a533b 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-input.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-input.ts @@ -2,7 +2,7 @@ * Simple text input component for extensions. */ -import { Container, type Focusable, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { CountdownTimer } from "./countdown-timer.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -63,16 +63,18 @@ export class ExtensionInputComponent extends Container implements Focusable { this.input = new Input(); this.addChild(this.input); this.addChild(new Spacer(1)); - this.addChild(new Text(`${keyHint("selectConfirm", "submit")} ${keyHint("selectCancel", "cancel")}`, 1, 0)); + this.addChild( + new Text(`${keyHint("tui.select.confirm", "submit")} ${keyHint("tui.select.cancel", "cancel")}`, 1, 0), + ); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); } handleInput(keyData: string): void { - const kb = getEditorKeybindings(); - if (kb.matches(keyData, "selectConfirm") || keyData === "\n") { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { this.onSubmitCallback(this.input.getValue()); - } else if (kb.matches(keyData, "selectCancel")) { + } else if (kb.matches(keyData, "tui.select.cancel")) { this.onCancelCallback(); } else { this.input.handleInput(keyData); diff --git a/packages/coding-agent/src/modes/interactive/components/extension-selector.ts b/packages/coding-agent/src/modes/interactive/components/extension-selector.ts index f4e7643d..9805ad61 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-selector.ts @@ -3,7 +3,7 @@ * Displays a list of string options with keyboard navigation. */ -import { Container, getEditorKeybindings, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import { Container, getKeybindings, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { CountdownTimer } from "./countdown-timer.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -61,9 +61,9 @@ export class ExtensionSelectorComponent extends Container { new Text( rawKeyHint("↑↓", "navigate") + " " + - keyHint("selectConfirm", "select") + + keyHint("tui.select.confirm", "select") + " " + - keyHint("selectCancel", "cancel"), + keyHint("tui.select.cancel", "cancel"), 1, 0, ), @@ -86,17 +86,17 @@ export class ExtensionSelectorComponent extends Container { } handleInput(keyData: string): void { - const kb = getEditorKeybindings(); - if (kb.matches(keyData, "selectUp") || keyData === "k") { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.up") || keyData === "k") { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.updateList(); - } else if (kb.matches(keyData, "selectDown") || keyData === "j") { + } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1); this.updateList(); - } else if (kb.matches(keyData, "selectConfirm") || keyData === "\n") { + } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { const selected = this.options[this.selectedIndex]; if (selected) this.onSelectCallback(selected); - } else if (kb.matches(keyData, "selectCancel")) { + } else if (kb.matches(keyData, "tui.select.cancel")) { this.onCancelCallback(); } } diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index 78200f36..242d9667 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -14,7 +14,7 @@ export { ExtensionEditorComponent } from "./extension-editor.js"; export { ExtensionInputComponent } from "./extension-input.js"; export { ExtensionSelectorComponent } from "./extension-selector.js"; export { FooterComponent } from "./footer.js"; -export { appKey, appKeyHint, editorKey, keyHint, rawKeyHint } from "./keybinding-hints.js"; +export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.js"; export { LoginDialogComponent } from "./login-dialog.js"; export { ModelSelectorComponent } from "./model-selector.js"; export { OAuthSelectorComponent } from "./oauth-selector.js"; diff --git a/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts b/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts index 18d5fbd4..580375e4 100644 --- a/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts +++ b/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts @@ -2,65 +2,23 @@ * Utilities for formatting keybinding hints in the UI. */ -import { type EditorAction, getEditorKeybindings, type KeyId } from "@mariozechner/pi-tui"; -import type { AppAction, KeybindingsManager } from "../../../core/keybindings.js"; +import { getKeybindings, type Keybinding, type KeyId } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; -/** - * Format keys array as display string (e.g., ["ctrl+c", "escape"] -> "ctrl+c/escape"). - */ function formatKeys(keys: KeyId[]): string { if (keys.length === 0) return ""; if (keys.length === 1) return keys[0]!; return keys.join("/"); } -/** - * Get display string for an editor action. - */ -export function editorKey(action: EditorAction): string { - return formatKeys(getEditorKeybindings().getKeys(action)); +export function keyText(keybinding: Keybinding): string { + return formatKeys(getKeybindings().getKeys(keybinding)); } -/** - * Get display string for an app action. - */ -export function appKey(keybindings: KeybindingsManager, action: AppAction): string { - return formatKeys(keybindings.getKeys(action)); +export function keyHint(keybinding: Keybinding, description: string): string { + return theme.fg("dim", keyText(keybinding)) + theme.fg("muted", ` ${description}`); } -/** - * Format a keybinding hint with consistent styling: dim key, muted description. - * Looks up the key from editor keybindings automatically. - * - * @param action - Editor action name (e.g., "selectConfirm", "expandTools") - * @param description - Description text (e.g., "to expand", "cancel") - * @returns Formatted string with dim key and muted description - */ -export function keyHint(action: EditorAction, description: string): string { - return theme.fg("dim", editorKey(action)) + theme.fg("muted", ` ${description}`); -} - -/** - * Format a keybinding hint for app-level actions. - * Requires the KeybindingsManager instance. - * - * @param keybindings - KeybindingsManager instance - * @param action - App action name (e.g., "interrupt", "externalEditor") - * @param description - Description text - * @returns Formatted string with dim key and muted description - */ -export function appKeyHint(keybindings: KeybindingsManager, action: AppAction, description: string): string { - return theme.fg("dim", appKey(keybindings, action)) + theme.fg("muted", ` ${description}`); -} - -/** - * Format a raw key string with description (for non-configurable keys like ↑↓). - * - * @param key - Raw key string - * @param description - Description text - * @returns Formatted string with dim key and muted description - */ export function rawKeyHint(key: string, description: string): string { return theme.fg("dim", key) + theme.fg("muted", ` ${description}`); } diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index dceb4a5b..28f1f07c 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -1,5 +1,5 @@ import { getOAuthProviders } from "@mariozechner/pi-ai/oauth"; -import { Container, type Focusable, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; import { exec } from "child_process"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -109,7 +109,7 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); this.contentContainer.addChild(this.input); - this.contentContainer.addChild(new Text(`(${keyHint("selectCancel", "to cancel")})`, 1, 0)); + this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to cancel")})`, 1, 0)); this.tui.requestRender(); return new Promise((resolve, reject) => { @@ -130,7 +130,11 @@ export class LoginDialogComponent extends Container implements Focusable { } this.contentContainer.addChild(this.input); this.contentContainer.addChild( - new Text(`(${keyHint("selectCancel", "to cancel,")} ${keyHint("selectConfirm", "to submit")})`, 1, 0), + new Text( + `(${keyHint("tui.select.cancel", "to cancel,")} ${keyHint("tui.select.confirm", "to submit")})`, + 1, + 0, + ), ); this.input.setValue(""); @@ -148,7 +152,7 @@ export class LoginDialogComponent extends Container implements Focusable { showWaiting(message: string): void { this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); - this.contentContainer.addChild(new Text(`(${keyHint("selectCancel", "to cancel")})`, 1, 0)); + this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to cancel")})`, 1, 0)); this.tui.requestRender(); } @@ -161,9 +165,9 @@ export class LoginDialogComponent extends Container implements Focusable { } handleInput(data: string): void { - const kb = getEditorKeybindings(); + const kb = getKeybindings(); - if (kb.matches(data, "selectCancel")) { + if (kb.matches(data, "tui.select.cancel")) { this.cancel(); return; } 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 d14002a2..9234a3d2 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -3,7 +3,7 @@ import { Container, type Focusable, fuzzyFilter, - getEditorKeybindings, + getKeybindings, Input, Spacer, Text, @@ -200,7 +200,7 @@ export class ModelSelectorComponent extends Container implements Focusable { } private getScopeHintText(): string { - return keyHint("tab", "scope") + theme.fg("muted", " (all/scoped)"); + return keyHint("tui.input.tab", "scope") + theme.fg("muted", " (all/scoped)"); } private setScope(scope: ModelScope): void { @@ -284,8 +284,8 @@ export class ModelSelectorComponent extends Container implements Focusable { } handleInput(keyData: string): void { - const kb = getEditorKeybindings(); - if (kb.matches(keyData, "tab")) { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.input.tab")) { if (this.scopedModelItems.length > 0) { const nextScope: ModelScope = this.scope === "all" ? "scoped" : "all"; this.setScope(nextScope); @@ -296,26 +296,26 @@ export class ModelSelectorComponent extends Container implements Focusable { return; } // Up arrow - wrap to bottom when at top - if (kb.matches(keyData, "selectUp")) { + if (kb.matches(keyData, "tui.select.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 (kb.matches(keyData, "selectDown")) { + else if (kb.matches(keyData, "tui.select.down")) { if (this.filteredModels.length === 0) return; this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1; this.updateList(); } // Enter - else if (kb.matches(keyData, "selectConfirm")) { + else if (kb.matches(keyData, "tui.select.confirm")) { const selectedModel = this.filteredModels[this.selectedIndex]; if (selectedModel) { this.handleSelect(selectedModel.model); } } // Escape or Ctrl+C - else if (kb.matches(keyData, "selectCancel")) { + else if (kb.matches(keyData, "tui.select.cancel")) { 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 64cd12d2..767e16c0 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -1,6 +1,6 @@ import type { OAuthProviderInterface } from "@mariozechner/pi-ai"; import { getOAuthProviders } from "@mariozechner/pi-ai/oauth"; -import { Container, getEditorKeybindings, Spacer, TruncatedText } from "@mariozechner/pi-tui"; +import { Container, getKeybindings, 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"; @@ -95,26 +95,26 @@ export class OAuthSelectorComponent extends Container { } handleInput(keyData: string): void { - const kb = getEditorKeybindings(); + const kb = getKeybindings(); // Up arrow - if (kb.matches(keyData, "selectUp")) { + if (kb.matches(keyData, "tui.select.up")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.updateList(); } // Down arrow - else if (kb.matches(keyData, "selectDown")) { + else if (kb.matches(keyData, "tui.select.down")) { this.selectedIndex = Math.min(this.allProviders.length - 1, this.selectedIndex + 1); this.updateList(); } // Enter - else if (kb.matches(keyData, "selectConfirm")) { + else if (kb.matches(keyData, "tui.select.confirm")) { const selectedProvider = this.allProviders[this.selectedIndex]; if (selectedProvider) { this.onSelectCallback(selectedProvider.id); } } // Escape or Ctrl+C - else if (kb.matches(keyData, "selectCancel")) { + else if (kb.matches(keyData, "tui.select.cancel")) { this.onCancelCallback(); } } diff --git a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts index f81b1c7e..3f255e6e 100644 --- a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -3,7 +3,7 @@ import { Container, type Focusable, fuzzyFilter, - getEditorKeybindings, + getKeybindings, Input, Key, matchesKey, @@ -224,16 +224,16 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl } handleInput(data: string): void { - const kb = getEditorKeybindings(); + const kb = getKeybindings(); // Navigation - if (kb.matches(data, "selectUp")) { + if (kb.matches(data, "tui.select.up")) { if (this.filteredItems.length === 0) return; this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1; this.updateList(); return; } - if (kb.matches(data, "selectDown")) { + if (kb.matches(data, "tui.select.down")) { if (this.filteredItems.length === 0) return; this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1; this.updateList(); 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 b62f0520..6ffeabbd 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -6,9 +6,8 @@ import { type Component, Container, type Focusable, - getEditorKeybindings, + getKeybindings, Input, - matchesKey, Spacer, Text, truncateToWidth, @@ -18,7 +17,7 @@ import { KeybindingsManager } from "../../../core/keybindings.js"; import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; -import { appKey, appKeyHint, keyHint } from "./keybinding-hints.js"; +import { keyHint, keyText } from "./keybinding-hints.js"; import { filterAndSortSessions, hasSessionName, type NameFilter, type SortMode } from "./session-selector-search.js"; type SessionScope = "current" | "all"; @@ -52,7 +51,6 @@ class SessionSelectorHeader implements Component { private scope: SessionScope; private sortMode: SortMode; private nameFilter: NameFilter; - private keybindings: KeybindingsManager; private requestRender: () => void; private loading = false; private loadProgress: { loaded: number; total: number } | null = null; @@ -62,17 +60,10 @@ class SessionSelectorHeader implements Component { private statusTimeout: ReturnType | null = null; private showRenameHint = false; - constructor( - scope: SessionScope, - sortMode: SortMode, - nameFilter: NameFilter, - keybindings: KeybindingsManager, - requestRender: () => void, - ) { + constructor(scope: SessionScope, sortMode: SortMode, nameFilter: NameFilter, requestRender: () => void) { this.scope = scope; this.sortMode = sortMode; this.nameFilter = nameFilter; - this.keybindings = keybindings; this.requestRender = requestRender; } @@ -159,7 +150,7 @@ class SessionSelectorHeader implements Component { let hintLine1: string; let hintLine2: string; if (this.confirmingDeletePath !== null) { - const confirmHint = "Delete session? [Enter] confirm · [Esc/Ctrl+C] cancel"; + const confirmHint = `Delete session? ${keyHint("tui.select.confirm", "confirm")} · ${keyHint("tui.select.cancel", "cancel")}`; hintLine1 = theme.fg("error", truncateToWidth(confirmHint, width, "…")); hintLine2 = ""; } else if (this.statusMessage) { @@ -169,15 +160,16 @@ class SessionSelectorHeader implements Component { } else { const pathState = this.showPath ? "(on)" : "(off)"; const sep = theme.fg("muted", " · "); - const hint1 = keyHint("tab", "scope") + sep + theme.fg("muted", 're: regex · "phrase" exact'); + const hint1 = + keyHint("tui.input.tab", "scope") + sep + theme.fg("muted", 're: regex · "phrase" exact'); const hint2Parts = [ - keyHint("toggleSessionSort", "sort"), - appKeyHint(this.keybindings, "toggleSessionNamedFilter", "named"), - keyHint("deleteSession", "delete"), - keyHint("toggleSessionPath", `path ${pathState}`), + keyHint("app.session.toggleSort", "sort"), + keyHint("app.session.toggleNamedFilter", "named"), + keyHint("app.session.delete", "delete"), + keyHint("app.session.togglePath", `path ${pathState}`), ]; if (this.showRenameHint) { - hint2Parts.push(keyHint("renameSession", "rename")); + hint2Parts.push(keyHint("app.session.rename", "rename")); } const hint2 = hint2Parts.join(sep); hintLine1 = truncateToWidth(hint1, width, "…"); @@ -402,7 +394,7 @@ class SessionList implements Component, Focusable { if (this.filteredSessions.length === 0) { let emptyMessage: string; if (this.nameFilter === "named") { - const toggleKey = appKey(this.keybindings, "toggleSessionNamedFilter"); + const toggleKey = keyText("app.session.toggleNamedFilter"); if (this.showCwd) { emptyMessage = ` No named sessions found. Press ${toggleKey} to show all.`; } else { @@ -511,18 +503,17 @@ class SessionList implements Component, Focusable { } handleInput(keyData: string): void { - const kb = getEditorKeybindings(); + const kb = getKeybindings(); // Handle delete confirmation state first - intercept all keys if (this.confirmingDeletePath !== null) { - if (kb.matches(keyData, "selectConfirm")) { + if (kb.matches(keyData, "tui.select.confirm")) { const pathToDelete = this.confirmingDeletePath; this.setConfirmingDeletePath(null); void this.onDeleteSession?.(pathToDelete); return; } - // Allow both Escape and Ctrl+C to cancel (consistent with pi UX) - if (kb.matches(keyData, "selectCancel") || matchesKey(keyData, "ctrl+c")) { + if (kb.matches(keyData, "tui.select.cancel")) { this.setConfirmingDeletePath(null); return; } @@ -530,38 +521,38 @@ class SessionList implements Component, Focusable { return; } - if (kb.matches(keyData, "tab")) { + if (kb.matches(keyData, "tui.input.tab")) { if (this.onToggleScope) { this.onToggleScope(); } return; } - if (kb.matches(keyData, "toggleSessionSort")) { + if (kb.matches(keyData, "app.session.toggleSort")) { this.onToggleSort?.(); return; } - if (this.keybindings.matches(keyData, "toggleSessionNamedFilter")) { + if (this.keybindings.matches(keyData, "app.session.toggleNamedFilter")) { this.onToggleNameFilter?.(); return; } // Ctrl+P: toggle path display - if (kb.matches(keyData, "toggleSessionPath")) { + if (kb.matches(keyData, "app.session.togglePath")) { this.showPath = !this.showPath; this.onTogglePath?.(this.showPath); return; } // Ctrl+D: initiate delete confirmation (useful on terminals that don't distinguish Ctrl+Backspace from Backspace) - if (kb.matches(keyData, "deleteSession")) { + if (kb.matches(keyData, "app.session.delete")) { this.startDeleteConfirmationForSelectedSession(); return; } - // Ctrl+R: rename selected session - if (matchesKey(keyData, "ctrl+r")) { + // Rename selected session + if (kb.matches(keyData, "app.session.rename")) { const selected = this.filteredSessions[this.selectedIndex]; if (selected) { this.onRenameSession?.(selected.session.path); @@ -571,7 +562,7 @@ class SessionList implements Component, Focusable { // Ctrl+Backspace: non-invasive convenience alias for delete // Only triggers deletion when the query is empty; otherwise it is forwarded to the input - if (kb.matches(keyData, "deleteSessionNoninvasive")) { + if (kb.matches(keyData, "app.session.deleteNoninvasive")) { if (this.searchInput.getValue().length > 0) { this.searchInput.handleInput(keyData); this.filterSessions(this.searchInput.getValue()); @@ -583,30 +574,30 @@ class SessionList implements Component, Focusable { } // Up arrow - if (kb.matches(keyData, "selectUp")) { + if (kb.matches(keyData, "tui.select.up")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); } // Down arrow - else if (kb.matches(keyData, "selectDown")) { + else if (kb.matches(keyData, "tui.select.down")) { this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1); } // Page up - jump up by maxVisible items - else if (kb.matches(keyData, "selectPageUp")) { + else if (kb.matches(keyData, "tui.select.pageUp")) { this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible); } // Page down - jump down by maxVisible items - else if (kb.matches(keyData, "selectPageDown")) { + else if (kb.matches(keyData, "tui.select.pageDown")) { this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + this.maxVisible); } // Enter - else if (kb.matches(keyData, "selectConfirm")) { + else if (kb.matches(keyData, "tui.select.confirm")) { const selected = this.filteredSessions[this.selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.session.path); } } // Escape - cancel - else if (kb.matches(keyData, "selectCancel")) { + else if (kb.matches(keyData, "tui.select.cancel")) { if (this.onCancel) { this.onCancel(); } @@ -667,8 +658,8 @@ async function deleteSessionFile( export class SessionSelectorComponent extends Container implements Focusable { handleInput(data: string): void { if (this.mode === "rename") { - const kb = getEditorKeybindings(); - if (kb.matches(data, "selectCancel") || matchesKey(data, "ctrl+c")) { + const kb = getKeybindings(); + if (kb.matches(data, "tui.select.cancel")) { this.exitRenameMode(); return; } @@ -749,13 +740,7 @@ export class SessionSelectorComponent extends Container implements Focusable { this.allSessionsLoader = allSessionsLoader; this.onCancel = onCancel; this.requestRender = requestRender; - this.header = new SessionSelectorHeader( - this.scope, - this.sortMode, - this.nameFilter, - this.keybindings, - this.requestRender, - ); + this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.nameFilter, this.requestRender); const renameSession = options?.renameSession; this.renameSession = renameSession; this.canRename = !!renameSession; @@ -864,7 +849,13 @@ export class SessionSelectorComponent extends Container implements Focusable { panel.addChild(new Spacer(1)); panel.addChild(this.renameInput); panel.addChild(new Spacer(1)); - panel.addChild(new Text(theme.fg("muted", "Enter to save · Esc/Ctrl+C to cancel"), 1, 0)); + panel.addChild( + new Text( + theme.fg("muted", `${keyText("tui.select.confirm")} to save · ${keyText("tui.select.cancel")} to cancel`), + 1, + 0, + ), + ); this.buildBaseLayout(panel, { showHeader: false }); this.requestRender(); diff --git a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts index 47732e67..ff6eda2a 100644 --- a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts @@ -1,7 +1,7 @@ import { Box, Markdown, type MarkdownTheme, Text } from "@mariozechner/pi-tui"; import type { ParsedSkillBlock } from "../../../core/agent-session.js"; import { getMarkdownTheme, theme } from "../theme/theme.js"; -import { editorKey } from "./keybinding-hints.js"; +import { keyText } from "./keybinding-hints.js"; /** * Component that renders a skill invocation message with collapsed/expanded state. @@ -48,7 +48,7 @@ export class SkillInvocationMessageComponent extends Box { const line = theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + theme.fg("customMessageText", this.skillBlock.name) + - theme.fg("dim", ` (${editorKey("expandTools")} to expand)`); + theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`); this.addChild(new Text(line, 0, 0)); } } diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 1d249aff..28b15479 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -588,7 +588,7 @@ export class ToolExecutionComponent extends Container { if (cachedSkipped && cachedSkipped > 0) { const hint = theme.fg("muted", `... (${cachedSkipped} earlier lines,`) + - ` ${keyHint("expandTools", "to expand")})`; + ` ${keyHint("app.tools.expand", "to expand")})`; return ["", truncateToWidth(hint, width, "..."), ...cachedLines]; } // Add blank line for spacing (matches expanded case) @@ -695,7 +695,7 @@ export class ToolExecutionComponent extends Container { .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))) .join("\n"); if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; } const truncation = this.result.details?.truncation; @@ -772,7 +772,7 @@ export class ToolExecutionComponent extends Container { if (remaining > 0) { text += theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`) + - ` ${keyHint("expandTools", "to expand")})`; + ` ${keyHint("app.tools.expand", "to expand")})`; } } @@ -839,7 +839,7 @@ export class ToolExecutionComponent extends Container { text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; } } @@ -881,7 +881,7 @@ export class ToolExecutionComponent extends Container { text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; } } @@ -927,7 +927,7 @@ export class ToolExecutionComponent extends Container { text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; } } 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 58eb7afc..c21c97fc 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -2,7 +2,7 @@ import { type Component, Container, type Focusable, - getEditorKeybindings, + getKeybindings, Input, matchesKey, Spacer, @@ -860,12 +860,12 @@ class TreeList implements Component { } handleInput(keyData: string): void { - const kb = getEditorKeybindings(); - if (kb.matches(keyData, "selectUp")) { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.up")) { this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; - } else if (kb.matches(keyData, "selectDown")) { + } else if (kb.matches(keyData, "tui.select.down")) { this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1; - } else if (kb.matches(keyData, "treeFoldOrUp")) { + } else if (kb.matches(keyData, "app.tree.foldOrUp")) { const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id; if (currentId && this.isFoldable(currentId) && !this.foldedNodes.has(currentId)) { this.foldedNodes.add(currentId); @@ -873,7 +873,7 @@ class TreeList implements Component { } else { this.selectedIndex = this.findBranchSegmentStart("up"); } - } else if (kb.matches(keyData, "treeUnfoldOrDown")) { + } else if (kb.matches(keyData, "app.tree.unfoldOrDown")) { const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id; if (currentId && this.foldedNodes.has(currentId)) { this.foldedNodes.delete(currentId); @@ -881,18 +881,18 @@ class TreeList implements Component { } else { this.selectedIndex = this.findBranchSegmentStart("down"); } - } else if (kb.matches(keyData, "cursorLeft") || kb.matches(keyData, "selectPageUp")) { + } else if (kb.matches(keyData, "tui.editor.cursorLeft") || kb.matches(keyData, "tui.select.pageUp")) { // Page up this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines); - } else if (kb.matches(keyData, "cursorRight") || kb.matches(keyData, "selectPageDown")) { + } else if (kb.matches(keyData, "tui.editor.cursorRight") || kb.matches(keyData, "tui.select.pageDown")) { // Page down this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines); - } else if (kb.matches(keyData, "selectConfirm")) { + } else if (kb.matches(keyData, "tui.select.confirm")) { const selected = this.filteredNodes[this.selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.node.entry.id); } - } else if (kb.matches(keyData, "selectCancel")) { + } else if (kb.matches(keyData, "tui.select.cancel")) { if (this.searchQuery) { this.searchQuery = ""; this.foldedNodes.clear(); @@ -939,7 +939,7 @@ class TreeList implements Component { this.filterMode = modes[(currentIndex + 1) % modes.length]; this.foldedNodes.clear(); this.applyFilter(); - } else if (kb.matches(keyData, "deleteCharBackward")) { + } else if (kb.matches(keyData, "tui.editor.deleteCharBackward")) { if (this.searchQuery.length > 0) { this.searchQuery = this.searchQuery.slice(0, -1); this.foldedNodes.clear(); @@ -1066,17 +1066,20 @@ class LabelInput implements Component, Focusable { lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width)); lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width))); lines.push( - truncateToWidth(`${indent}${keyHint("selectConfirm", "save")} ${keyHint("selectCancel", "cancel")}`, width), + truncateToWidth( + `${indent}${keyHint("tui.select.confirm", "save")} ${keyHint("tui.select.cancel", "cancel")}`, + width, + ), ); return lines; } handleInput(keyData: string): void { - const kb = getEditorKeybindings(); - if (kb.matches(keyData, "selectConfirm")) { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.confirm")) { const value = this.input.getValue().trim(); this.onSubmit?.(this.entryId, value || undefined); - } else if (kb.matches(keyData, "selectCancel")) { + } else if (kb.matches(keyData, "tui.select.cancel")) { 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 c38de14a..c9f28f74 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,4 +1,4 @@ -import { type Component, Container, getEditorKeybindings, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { type Component, Container, getKeybindings, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -78,24 +78,24 @@ class UserMessageList implements Component { } handleInput(keyData: string): void { - const kb = getEditorKeybindings(); + const kb = getKeybindings(); // Up arrow - go to previous (older) message, wrap to bottom when at top - if (kb.matches(keyData, "selectUp")) { + if (kb.matches(keyData, "tui.select.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 (kb.matches(keyData, "selectDown")) { + else if (kb.matches(keyData, "tui.select.down")) { this.selectedIndex = this.selectedIndex === this.messages.length - 1 ? 0 : this.selectedIndex + 1; } // Enter - select message and branch - else if (kb.matches(keyData, "selectConfirm")) { + else if (kb.matches(keyData, "tui.select.confirm")) { const selected = this.messages[this.selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.id); } } // Escape - cancel - else if (kb.matches(keyData, "selectCancel")) { + else if (kb.matches(keyData, "tui.select.cancel")) { 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 f5b52316..3989da32 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -11,9 +11,9 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ImageContent, Message, Model, OAuthProviderId } from "@mariozechner/pi-ai"; import type { AutocompleteItem, - EditorAction, EditorComponent, EditorTheme, + Keybinding, KeyId, MarkdownTheme, OverlayHandle, @@ -30,6 +30,7 @@ import { matchesKey, ProcessTerminal, Spacer, + setKeybindings, Text, TruncatedText, TUI, @@ -55,7 +56,7 @@ import type { ExtensionWidgetOptions, } from "../../core/extensions/index.js"; import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js"; -import { type AppAction, KeybindingsManager } from "../../core/keybindings.js"; +import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.js"; import { createCompactionSummaryMessage } from "../../core/messages.js"; import { findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js"; import { DefaultPackageManager } from "../../core/package-manager.js"; @@ -81,7 +82,7 @@ import { ExtensionEditorComponent } from "./components/extension-editor.js"; import { ExtensionInputComponent } from "./components/extension-input.js"; import { ExtensionSelectorComponent } from "./components/extension-selector.js"; import { FooterComponent } from "./components/footer.js"; -import { appKey, appKeyHint, editorKey, keyHint, rawKeyHint } from "./components/keybinding-hints.js"; +import { keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.js"; import { LoginDialogComponent } from "./components/login-dialog.js"; import { ModelSelectorComponent } from "./components/model-selector.js"; import { OAuthSelectorComponent } from "./components/oauth-selector.js"; @@ -154,6 +155,7 @@ export class InteractiveMode { private editorContainer: Container; private footer: FooterComponent; private footerDataProvider: FooterDataProvider; + // Stored so the same manager can be injected into custom editors, selectors, and extension UI. private keybindings: KeybindingsManager; private version: string; private isInitialized = false; @@ -262,6 +264,7 @@ export class InteractiveMode { this.widgetContainerAbove = new Container(); this.widgetContainerBelow = new Container(); this.keybindings = KeybindingsManager.create(); + setKeybindings(this.keybindings); const editorPaddingX = this.settingsManager.getEditorPaddingX(); const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, { @@ -379,28 +382,27 @@ export class InteractiveMode { const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`); // Build startup instructions using keybinding hint helpers - const kb = this.keybindings; - const hint = (action: AppAction, desc: string) => appKeyHint(kb, action, desc); + const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description); const instructions = [ - hint("interrupt", "to interrupt"), - hint("clear", "to clear"), - rawKeyHint(`${appKey(kb, "clear")} twice`, "to exit"), - hint("exit", "to exit (empty)"), - hint("suspend", "to suspend"), - keyHint("deleteToLineEnd", "to delete to end"), - hint("cycleThinkingLevel", "to cycle thinking level"), - rawKeyHint(`${appKey(kb, "cycleModelForward")}/${appKey(kb, "cycleModelBackward")}`, "to cycle models"), - hint("selectModel", "to select model"), - hint("expandTools", "to expand tools"), - hint("toggleThinking", "to expand thinking"), - hint("externalEditor", "for external editor"), + hint("app.interrupt", "to interrupt"), + hint("app.clear", "to clear"), + rawKeyHint(`${keyText("app.clear")} twice`, "to exit"), + hint("app.exit", "to exit (empty)"), + hint("app.suspend", "to suspend"), + keyHint("tui.editor.deleteToLineEnd", "to delete to end"), + hint("app.thinking.cycle", "to cycle thinking level"), + rawKeyHint(`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`, "to cycle models"), + hint("app.model.select", "to select model"), + hint("app.tools.expand", "to expand tools"), + hint("app.thinking.toggle", "to expand thinking"), + hint("app.editor.external", "for external editor"), rawKeyHint("/", "for commands"), rawKeyHint("!", "to run bash"), rawKeyHint("!!", "to run bash (no context)"), - hint("followUp", "to queue follow-up"), - hint("dequeue", "to edit all queued messages"), - hint("pasteImage", "to paste image"), + hint("app.message.followUp", "to queue follow-up"), + hint("app.message.dequeue", "to edit all queued messages"), + hint("app.clipboard.pasteImage", "to paste image"), rawKeyHint("drop files", "to attach"), ].join("\n"); this.builtInHeader = new Text(`${logo}\n${instructions}`, 1, 0); @@ -1324,9 +1326,7 @@ export class InteractiveMode { this.defaultEditor.onExtensionShortcut = undefined; this.updateTerminalTitle(); if (this.loadingAnimation) { - this.loadingAnimation.setMessage( - `${this.defaultWorkingMessage} (${appKey(this.keybindings, "interrupt")} to interrupt)`, - ); + this.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`); } } @@ -1472,7 +1472,7 @@ export class InteractiveMode { this.loadingAnimation.setMessage(message); } else { this.loadingAnimation.setMessage( - `${this.defaultWorkingMessage} (${appKey(this.keybindings, "interrupt")} to interrupt)`, + `${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`, ); } } else { @@ -1894,25 +1894,25 @@ export class InteractiveMode { }; // Register app action handlers - this.defaultEditor.onAction("clear", () => this.handleCtrlC()); + this.defaultEditor.onAction("app.clear", () => this.handleCtrlC()); this.defaultEditor.onCtrlD = () => this.handleCtrlD(); - this.defaultEditor.onAction("suspend", () => this.handleCtrlZ()); - this.defaultEditor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel()); - this.defaultEditor.onAction("cycleModelForward", () => this.cycleModel("forward")); - this.defaultEditor.onAction("cycleModelBackward", () => this.cycleModel("backward")); + this.defaultEditor.onAction("app.suspend", () => this.handleCtrlZ()); + this.defaultEditor.onAction("app.thinking.cycle", () => this.cycleThinkingLevel()); + this.defaultEditor.onAction("app.model.cycleForward", () => this.cycleModel("forward")); + this.defaultEditor.onAction("app.model.cycleBackward", () => this.cycleModel("backward")); // Global debug handler on TUI (works regardless of focus) this.ui.onDebug = () => this.handleDebugCommand(); - this.defaultEditor.onAction("selectModel", () => this.showModelSelector()); - this.defaultEditor.onAction("expandTools", () => this.toggleToolOutputExpansion()); - this.defaultEditor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility()); - this.defaultEditor.onAction("externalEditor", () => this.openExternalEditor()); - this.defaultEditor.onAction("followUp", () => this.handleFollowUp()); - this.defaultEditor.onAction("dequeue", () => this.handleDequeue()); - this.defaultEditor.onAction("newSession", () => this.handleClearCommand()); - this.defaultEditor.onAction("tree", () => this.showTreeSelector()); - this.defaultEditor.onAction("fork", () => this.showUserMessageSelector()); - this.defaultEditor.onAction("resume", () => this.showSessionSelector()); + this.defaultEditor.onAction("app.model.select", () => this.showModelSelector()); + this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion()); + this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility()); + this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor()); + this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp()); + this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue()); + this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand()); + this.defaultEditor.onAction("app.session.tree", () => this.showTreeSelector()); + this.defaultEditor.onAction("app.session.fork", () => this.showUserMessageSelector()); + this.defaultEditor.onAction("app.session.resume", () => this.showSessionSelector()); this.defaultEditor.onChange = (text: string) => { const wasBashMode = this.isBashMode; @@ -2331,7 +2331,7 @@ export class InteractiveMode { this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), - `${reasonText}Auto-compacting... (${appKey(this.keybindings, "interrupt")} to cancel)`, + `${reasonText}Auto-compacting... (${keyText("app.interrupt")} to cancel)`, ); this.statusContainer.addChild(this.autoCompactionLoader); this.ui.requestRender(); @@ -2388,7 +2388,7 @@ export class InteractiveMode { this.ui, (spinner) => theme.fg("warning", spinner), (text) => theme.fg("muted", text), - `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s... (${appKey(this.keybindings, "interrupt")} to cancel)`, + `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s... (${keyText("app.interrupt")} to cancel)`, ); this.statusContainer.addChild(this.retryLoader); this.ui.requestRender(); @@ -2988,7 +2988,7 @@ export class InteractiveMode { const text = theme.fg("dim", `Follow-up: ${message}`); this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); } - const dequeueHint = this.getAppKeyDisplay("dequeue"); + const dequeueHint = this.getAppKeyDisplay("app.message.dequeue"); const hintText = theme.fg("dim", `↳ ${dequeueHint} to edit all queued messages`); this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0)); } @@ -3580,7 +3580,7 @@ export class InteractiveMode { this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), - `Summarizing branch... (${appKey(this.keybindings, "interrupt")} to cancel)`, + `Summarizing branch... (${keyText("app.interrupt")} to cancel)`, ); this.statusContainer.addChild(summaryLoader); this.ui.requestRender(); @@ -4178,59 +4178,59 @@ export class InteractiveMode { /** * Get capitalized display string for an app keybinding action. */ - private getAppKeyDisplay(action: AppAction): string { - return this.capitalizeKey(appKey(this.keybindings, action)); + private getAppKeyDisplay(action: AppKeybinding): string { + return this.capitalizeKey(keyText(action)); } /** * Get capitalized display string for an editor keybinding action. */ - private getEditorKeyDisplay(action: EditorAction): string { - return this.capitalizeKey(editorKey(action)); + private getEditorKeyDisplay(action: Keybinding): string { + return this.capitalizeKey(keyText(action)); } private handleHotkeysCommand(): void { // Navigation keybindings - const cursorUp = this.getEditorKeyDisplay("cursorUp"); - const cursorDown = this.getEditorKeyDisplay("cursorDown"); - const cursorLeft = this.getEditorKeyDisplay("cursorLeft"); - const cursorRight = this.getEditorKeyDisplay("cursorRight"); - const cursorWordLeft = this.getEditorKeyDisplay("cursorWordLeft"); - const cursorWordRight = this.getEditorKeyDisplay("cursorWordRight"); - const cursorLineStart = this.getEditorKeyDisplay("cursorLineStart"); - const cursorLineEnd = this.getEditorKeyDisplay("cursorLineEnd"); - const jumpForward = this.getEditorKeyDisplay("jumpForward"); - const jumpBackward = this.getEditorKeyDisplay("jumpBackward"); - const pageUp = this.getEditorKeyDisplay("pageUp"); - const pageDown = this.getEditorKeyDisplay("pageDown"); + const cursorUp = this.getEditorKeyDisplay("tui.editor.cursorUp"); + const cursorDown = this.getEditorKeyDisplay("tui.editor.cursorDown"); + const cursorLeft = this.getEditorKeyDisplay("tui.editor.cursorLeft"); + const cursorRight = this.getEditorKeyDisplay("tui.editor.cursorRight"); + const cursorWordLeft = this.getEditorKeyDisplay("tui.editor.cursorWordLeft"); + const cursorWordRight = this.getEditorKeyDisplay("tui.editor.cursorWordRight"); + const cursorLineStart = this.getEditorKeyDisplay("tui.editor.cursorLineStart"); + const cursorLineEnd = this.getEditorKeyDisplay("tui.editor.cursorLineEnd"); + const jumpForward = this.getEditorKeyDisplay("tui.editor.jumpForward"); + const jumpBackward = this.getEditorKeyDisplay("tui.editor.jumpBackward"); + const pageUp = this.getEditorKeyDisplay("tui.editor.pageUp"); + const pageDown = this.getEditorKeyDisplay("tui.editor.pageDown"); // Editing keybindings - const submit = this.getEditorKeyDisplay("submit"); - const newLine = this.getEditorKeyDisplay("newLine"); - const deleteWordBackward = this.getEditorKeyDisplay("deleteWordBackward"); - const deleteWordForward = this.getEditorKeyDisplay("deleteWordForward"); - const deleteToLineStart = this.getEditorKeyDisplay("deleteToLineStart"); - const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd"); - const yank = this.getEditorKeyDisplay("yank"); - const yankPop = this.getEditorKeyDisplay("yankPop"); - const undo = this.getEditorKeyDisplay("undo"); - const tab = this.getEditorKeyDisplay("tab"); + const submit = this.getEditorKeyDisplay("tui.input.submit"); + const newLine = this.getEditorKeyDisplay("tui.input.newLine"); + const deleteWordBackward = this.getEditorKeyDisplay("tui.editor.deleteWordBackward"); + const deleteWordForward = this.getEditorKeyDisplay("tui.editor.deleteWordForward"); + const deleteToLineStart = this.getEditorKeyDisplay("tui.editor.deleteToLineStart"); + const deleteToLineEnd = this.getEditorKeyDisplay("tui.editor.deleteToLineEnd"); + const yank = this.getEditorKeyDisplay("tui.editor.yank"); + const yankPop = this.getEditorKeyDisplay("tui.editor.yankPop"); + const undo = this.getEditorKeyDisplay("tui.editor.undo"); + const tab = this.getEditorKeyDisplay("tui.input.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 selectModel = this.getAppKeyDisplay("selectModel"); - const expandTools = this.getAppKeyDisplay("expandTools"); - const toggleThinking = this.getAppKeyDisplay("toggleThinking"); - const externalEditor = this.getAppKeyDisplay("externalEditor"); - const cycleModelBackward = this.getAppKeyDisplay("cycleModelBackward"); - const followUp = this.getAppKeyDisplay("followUp"); - const dequeue = this.getAppKeyDisplay("dequeue"); - const pasteImage = this.getAppKeyDisplay("pasteImage"); + const interrupt = this.getAppKeyDisplay("app.interrupt"); + const clear = this.getAppKeyDisplay("app.clear"); + const exit = this.getAppKeyDisplay("app.exit"); + const suspend = this.getAppKeyDisplay("app.suspend"); + const cycleThinkingLevel = this.getAppKeyDisplay("app.thinking.cycle"); + const cycleModelForward = this.getAppKeyDisplay("app.model.cycleForward"); + const selectModel = this.getAppKeyDisplay("app.model.select"); + const expandTools = this.getAppKeyDisplay("app.tools.expand"); + const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle"); + const externalEditor = this.getAppKeyDisplay("app.editor.external"); + const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward"); + const followUp = this.getAppKeyDisplay("app.message.followUp"); + const dequeue = this.getAppKeyDisplay("app.message.dequeue"); + const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage"); let hotkeys = ` **Navigation** @@ -4499,7 +4499,7 @@ export class InteractiveMode { // Show compacting status this.chatContainer.addChild(new Spacer(1)); - const cancelHint = `(${appKey(this.keybindings, "interrupt")} to cancel)`; + const cancelHint = `(${keyText("app.interrupt")} to cancel)`; const label = isAuto ? `Auto-compacting context... ${cancelHint}` : `Compacting context... ${cancelHint}`; const compactingLoader = new Loader( this.ui, diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 61c2fc4e..1b265a1d 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -10,7 +10,7 @@ import { AuthStorage } from "../src/core/auth-storage.js"; import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.js"; import { ExtensionRunner } from "../src/core/extensions/runner.js"; import type { ExtensionActions, ExtensionContextActions, ProviderConfig } from "../src/core/extensions/types.js"; -import { DEFAULT_KEYBINDINGS, type KeyId } from "../src/core/keybindings.js"; +import { KeybindingsManager, type KeyId } from "../src/core/keybindings.js"; import { ModelRegistry } from "../src/core/model-registry.js"; import { SessionManager } from "../src/core/session-manager.js"; @@ -19,6 +19,7 @@ describe("ExtensionRunner", () => { let extensionsDir: string; let sessionManager: SessionManager; let modelRegistry: ModelRegistry; + const defaultKeybindings = new KeybindingsManager().getEffectiveConfig(); beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-runner-test-")); @@ -94,7 +95,7 @@ describe("ExtensionRunner", () => { const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); - const shortcuts = runner.getShortcuts(DEFAULT_KEYBINDINGS); + const shortcuts = runner.getShortcuts(defaultKeybindings); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in")); expect(shortcuts.has("ctrl+c")).toBe(false); @@ -117,7 +118,7 @@ describe("ExtensionRunner", () => { const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); - const keybindings = { ...DEFAULT_KEYBINDINGS, cycleModelForward: "ctrl+n" as KeyId }; + const keybindings = { ...defaultKeybindings, "app.model.cycleForward": "ctrl+n" as KeyId }; const shortcuts = runner.getShortcuts(keybindings); expect(shortcuts.has("ctrl+p")).toBe(true); @@ -127,9 +128,9 @@ describe("ExtensionRunner", () => { }); it("warns but allows when extension uses non-reserved built-in shortcut", async () => { - const pasteImageKey = Array.isArray(DEFAULT_KEYBINDINGS.pasteImage) - ? (DEFAULT_KEYBINDINGS.pasteImage[0] ?? "") - : DEFAULT_KEYBINDINGS.pasteImage; + const pasteImageKey = Array.isArray(defaultKeybindings["app.clipboard.pasteImage"]) + ? (defaultKeybindings["app.clipboard.pasteImage"][0] ?? "") + : defaultKeybindings["app.clipboard.pasteImage"]; const extCode = ` export default function(pi) { pi.registerShortcut("${pasteImageKey}", { @@ -144,9 +145,11 @@ describe("ExtensionRunner", () => { const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); - const shortcuts = runner.getShortcuts(DEFAULT_KEYBINDINGS); + const shortcuts = runner.getShortcuts(defaultKeybindings); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("built-in shortcut for pasteImage")); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("built-in shortcut for app.clipboard.pasteImage"), + ); expect(shortcuts.has(pasteImageKey as KeyId)).toBe(true); warnSpy.mockRestore(); @@ -167,7 +170,7 @@ describe("ExtensionRunner", () => { const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); - const keybindings = { ...DEFAULT_KEYBINDINGS, interrupt: "ctrl+x" as KeyId }; + const keybindings = { ...defaultKeybindings, "app.interrupt": "ctrl+x" as KeyId }; const shortcuts = runner.getShortcuts(keybindings); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in")); @@ -191,7 +194,7 @@ describe("ExtensionRunner", () => { const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); - const keybindings = { ...DEFAULT_KEYBINDINGS, clear: ["ctrl+x", "ctrl+y"] as KeyId[] }; + const keybindings = { ...defaultKeybindings, "app.clear": ["ctrl+x", "ctrl+y"] as KeyId[] }; const shortcuts = runner.getShortcuts(keybindings); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in")); @@ -215,10 +218,12 @@ describe("ExtensionRunner", () => { const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); - const keybindings = { ...DEFAULT_KEYBINDINGS, pasteImage: ["ctrl+x", "ctrl+y"] as KeyId[] }; + const keybindings = { ...defaultKeybindings, "app.clipboard.pasteImage": ["ctrl+x", "ctrl+y"] as KeyId[] }; const shortcuts = runner.getShortcuts(keybindings); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("built-in shortcut for pasteImage")); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("built-in shortcut for app.clipboard.pasteImage"), + ); expect(shortcuts.has("ctrl+y")).toBe(true); warnSpy.mockRestore(); @@ -249,7 +254,7 @@ describe("ExtensionRunner", () => { const result = await discoverAndLoadExtensions([], tempDir, tempDir); const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); - const shortcuts = runner.getShortcuts(DEFAULT_KEYBINDINGS); + const shortcuts = runner.getShortcuts(defaultKeybindings); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("shortcut conflict")); // Last one wins diff --git a/packages/coding-agent/test/keybindings-migration.test.ts b/packages/coding-agent/test/keybindings-migration.test.ts new file mode 100644 index 00000000..8db89c66 --- /dev/null +++ b/packages/coding-agent/test/keybindings-migration.test.ts @@ -0,0 +1,74 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { KeybindingsManager, migrateKeybindingsConfigFile } from "../src/core/keybindings.js"; + +describe("keybindings migration", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function createAgentDir(config: Record): string { + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-keybindings-test-")); + tempDirs.push(agentDir); + fs.writeFileSync(path.join(agentDir, "keybindings.json"), `${JSON.stringify(config, null, 2)}\n`, "utf-8"); + return agentDir; + } + + it("rewrites old key names to namespaced ids", () => { + const agentDir = createAgentDir({ + cursorUp: ["up", "ctrl+p"], + expandTools: "ctrl+x", + }); + + expect(migrateKeybindingsConfigFile(agentDir)).toBe(true); + + const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "keybindings.json"), "utf-8")) as Record< + string, + unknown + >; + expect(migrated).toEqual({ + "tui.editor.cursorUp": ["up", "ctrl+p"], + "app.tools.expand": "ctrl+x", + }); + }); + + it("keeps the namespaced value when old and new names both exist", () => { + const agentDir = createAgentDir({ + expandTools: "ctrl+x", + "app.tools.expand": "ctrl+y", + }); + + expect(migrateKeybindingsConfigFile(agentDir)).toBe(true); + + const migrated = JSON.parse(fs.readFileSync(path.join(agentDir, "keybindings.json"), "utf-8")) as Record< + string, + unknown + >; + expect(migrated).toEqual({ + "app.tools.expand": "ctrl+y", + }); + }); + + it("loads old key names in memory before the file is rewritten", () => { + const agentDir = createAgentDir({ + selectConfirm: "enter", + interrupt: "ctrl+x", + }); + + const keybindings = KeybindingsManager.create(agentDir); + + expect(keybindings.getUserBindings()).toEqual({ + "tui.select.confirm": "enter", + "app.interrupt": "ctrl+x", + }); + const effective = keybindings.getEffectiveConfig(); + expect(effective["tui.select.confirm"]).toBe("enter"); + expect(effective["app.interrupt"]).toBe("ctrl+x"); + }); +}); diff --git a/packages/coding-agent/test/session-selector-path-delete.test.ts b/packages/coding-agent/test/session-selector-path-delete.test.ts index 65c368fb..0c14ced4 100644 --- a/packages/coding-agent/test/session-selector-path-delete.test.ts +++ b/packages/coding-agent/test/session-selector-path-delete.test.ts @@ -1,4 +1,4 @@ -import { DEFAULT_EDITOR_KEYBINDINGS, EditorKeybindingsManager, setEditorKeybindings } from "@mariozechner/pi-tui"; +import { setKeybindings } from "@mariozechner/pi-tui"; import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { KeybindingsManager } from "../src/core/keybindings.js"; import type { SessionInfo } from "../src/core/session-manager.js"; @@ -45,11 +45,11 @@ const CTRL_D = "\x04"; const CTRL_BACKSPACE = "\x1b[127;5u"; describe("session selector path/delete interactions", () => { - const keybindings = KeybindingsManager.inMemory(); + const keybindings = new KeybindingsManager(); beforeEach(() => { - // Ensure test isolation: editor keybindings are a global singleton - setEditorKeybindings(new EditorKeybindingsManager(DEFAULT_EDITOR_KEYBINDINGS)); + // Ensure test isolation: keybindings are a global singleton + setKeybindings(new KeybindingsManager()); }); beforeAll(() => { diff --git a/packages/coding-agent/test/session-selector-rename.test.ts b/packages/coding-agent/test/session-selector-rename.test.ts index dcf23ec7..b005fd6c 100644 --- a/packages/coding-agent/test/session-selector-rename.test.ts +++ b/packages/coding-agent/test/session-selector-rename.test.ts @@ -1,4 +1,4 @@ -import { DEFAULT_EDITOR_KEYBINDINGS, EditorKeybindingsManager, setEditorKeybindings } from "@mariozechner/pi-tui"; +import { setKeybindings } from "@mariozechner/pi-tui"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { KeybindingsManager } from "../src/core/keybindings.js"; import type { SessionInfo } from "../src/core/session-manager.js"; @@ -34,13 +34,13 @@ describe("session selector rename", () => { }); beforeEach(() => { - // Ensure test isolation: editor keybindings are a global singleton - setEditorKeybindings(new EditorKeybindingsManager(DEFAULT_EDITOR_KEYBINDINGS)); + // Ensure test isolation: keybindings are a global singleton + setKeybindings(new KeybindingsManager()); }); it("shows rename hint in interactive /resume picker configuration", async () => { const sessions = [makeSession({ id: "a" })]; - const keybindings = KeybindingsManager.inMemory(); + const keybindings = new KeybindingsManager(); const selector = new SessionSelectorComponent( async () => sessions, async () => [], @@ -59,7 +59,7 @@ describe("session selector rename", () => { it("does not show rename hint in --resume picker configuration", async () => { const sessions = [makeSession({ id: "a" })]; - const keybindings = KeybindingsManager.inMemory(); + const keybindings = new KeybindingsManager(); const selector = new SessionSelectorComponent( async () => sessions, async () => [], @@ -80,7 +80,7 @@ describe("session selector rename", () => { const sessions = [makeSession({ id: "a", name: "Old" })]; const renameSession = vi.fn(async () => {}); - const keybindings = KeybindingsManager.inMemory(); + const keybindings = new KeybindingsManager(); const selector = new SessionSelectorComponent( async () => sessions, async () => [], diff --git a/packages/coding-agent/test/tree-selector.test.ts b/packages/coding-agent/test/tree-selector.test.ts index 7ff9ef09..81adb314 100644 --- a/packages/coding-agent/test/tree-selector.test.ts +++ b/packages/coding-agent/test/tree-selector.test.ts @@ -1,5 +1,6 @@ -import { DEFAULT_EDITOR_KEYBINDINGS, EditorKeybindingsManager, setEditorKeybindings } from "@mariozechner/pi-tui"; +import { setKeybindings } from "@mariozechner/pi-tui"; import { beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.js"; import type { ModelChangeEntry, SessionEntry, @@ -14,8 +15,8 @@ beforeAll(() => { }); beforeEach(() => { - // Ensure test isolation: editor keybindings are a global singleton - setEditorKeybindings(new EditorKeybindingsManager(DEFAULT_EDITOR_KEYBINDINGS)); + // Ensure test isolation: keybindings are a global singleton + setKeybindings(new KeybindingsManager()); }); // Helper to create a user message entry diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 254f2a5e..82596ab8 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- Replaced the editor-only keybinding store with a single global keybindings manager in `@mariozechner/pi-tui`. TUI keybinding ids are now namespaced: `cursorUp` -> `tui.editor.cursorUp`, `cursorDown` -> `tui.editor.cursorDown`, `cursorLeft` -> `tui.editor.cursorLeft`, `cursorRight` -> `tui.editor.cursorRight`, `cursorWordLeft` -> `tui.editor.cursorWordLeft`, `cursorWordRight` -> `tui.editor.cursorWordRight`, `cursorLineStart` -> `tui.editor.cursorLineStart`, `cursorLineEnd` -> `tui.editor.cursorLineEnd`, `jumpForward` -> `tui.editor.jumpForward`, `jumpBackward` -> `tui.editor.jumpBackward`, `pageUp` -> `tui.editor.pageUp`, `pageDown` -> `tui.editor.pageDown`, `deleteCharBackward` -> `tui.editor.deleteCharBackward`, `deleteCharForward` -> `tui.editor.deleteCharForward`, `deleteWordBackward` -> `tui.editor.deleteWordBackward`, `deleteWordForward` -> `tui.editor.deleteWordForward`, `deleteToLineStart` -> `tui.editor.deleteToLineStart`, `deleteToLineEnd` -> `tui.editor.deleteToLineEnd`, `yank` -> `tui.editor.yank`, `yankPop` -> `tui.editor.yankPop`, `undo` -> `tui.editor.undo`, `newLine` -> `tui.input.newLine`, `submit` -> `tui.input.submit`, `tab` -> `tui.input.tab`, `copy` -> `tui.input.copy`, `selectUp` -> `tui.select.up`, `selectDown` -> `tui.select.down`, `selectPageUp` -> `tui.select.pageUp`, `selectPageDown` -> `tui.select.pageDown`, `selectConfirm` -> `tui.select.confirm`, `selectCancel` -> `tui.select.cancel`. `keybindings.json` stays backward compatible because each keybinding definition maps the new internal id back to the existing public config key. Apps extend `interface Keybindings` via declaration merging, create one manager with both TUI and app definitions, then install it with `setKeybindings(...)` ([#2391](https://github.com/badlogic/pi-mono/issues/2391)) + +### Fixed + +- Fixed user-defined keybindings to shadow conflicting default bindings across the shared registry, so app-level defaults no longer stay active when the same key is explicitly reassigned ([#2391](https://github.com/badlogic/pi-mono/issues/2391)) + ## [0.60.0] - 2026-03-18 ### Fixed diff --git a/packages/tui/src/components/cancellable-loader.ts b/packages/tui/src/components/cancellable-loader.ts index 506b763d..e8bd85b9 100644 --- a/packages/tui/src/components/cancellable-loader.ts +++ b/packages/tui/src/components/cancellable-loader.ts @@ -1,4 +1,4 @@ -import { getEditorKeybindings } from "../keybindings.js"; +import { getKeybindings } from "../keybindings.js"; import { Loader } from "./loader.js"; /** @@ -27,8 +27,8 @@ export class CancellableLoader extends Loader { } handleInput(data: string): void { - const kb = getEditorKeybindings(); - if (kb.matches(data, "selectCancel")) { + const kb = getKeybindings(); + if (kb.matches(data, "tui.select.cancel")) { this.abortController.abort(); this.onAbort?.(); } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 86e76608..c12219d3 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,5 +1,5 @@ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; -import { getEditorKeybindings } from "../keybindings.js"; +import { getKeybindings } from "../keybindings.js"; import { decodeKittyPrintable, matchesKey } from "../keys.js"; import { KillRing } from "../kill-ring.js"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.js"; @@ -517,12 +517,12 @@ export class Editor implements Component, Focusable { } handleInput(data: string): void { - const kb = getEditorKeybindings(); + const kb = getKeybindings(); // Handle character jump mode (awaiting next character to jump to) if (this.jumpMode !== null) { // Cancel if the hotkey is pressed again - if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) { + if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) { this.jumpMode = null; return; } @@ -566,29 +566,29 @@ export class Editor implements Component, Focusable { } // Ctrl+C - let parent handle (exit/clear) - if (kb.matches(data, "copy")) { + if (kb.matches(data, "tui.input.copy")) { return; } // Undo - if (kb.matches(data, "undo")) { + if (kb.matches(data, "tui.editor.undo")) { this.undo(); return; } // Handle autocomplete mode if (this.autocompleteState && this.autocompleteList) { - if (kb.matches(data, "selectCancel")) { + if (kb.matches(data, "tui.select.cancel")) { this.cancelAutocomplete(); return; } - if (kb.matches(data, "selectUp") || kb.matches(data, "selectDown")) { + if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) { this.autocompleteList.handleInput(data); return; } - if (kb.matches(data, "tab")) { + if (kb.matches(data, "tui.input.tab")) { const selected = this.autocompleteList.getSelectedItem(); if (selected && this.autocompleteProvider) { const shouldChainSlashArgumentAutocomplete = this.shouldChainSlashArgumentAutocompleteOnTabSelection(); @@ -615,7 +615,7 @@ export class Editor implements Component, Focusable { return; } - if (kb.matches(data, "selectConfirm")) { + if (kb.matches(data, "tui.select.confirm")) { const selected = this.autocompleteList.getSelectedItem(); if (selected && this.autocompleteProvider) { this.pushUndoSnapshot(); @@ -644,68 +644,68 @@ export class Editor implements Component, Focusable { } // Tab - trigger completion - if (kb.matches(data, "tab") && !this.autocompleteState) { + if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) { this.handleTabCompletion(); return; } // Deletion actions - if (kb.matches(data, "deleteToLineEnd")) { + if (kb.matches(data, "tui.editor.deleteToLineEnd")) { this.deleteToEndOfLine(); return; } - if (kb.matches(data, "deleteToLineStart")) { + if (kb.matches(data, "tui.editor.deleteToLineStart")) { this.deleteToStartOfLine(); return; } - if (kb.matches(data, "deleteWordBackward")) { + if (kb.matches(data, "tui.editor.deleteWordBackward")) { this.deleteWordBackwards(); return; } - if (kb.matches(data, "deleteWordForward")) { + if (kb.matches(data, "tui.editor.deleteWordForward")) { this.deleteWordForward(); return; } - if (kb.matches(data, "deleteCharBackward") || matchesKey(data, "shift+backspace")) { + if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) { this.handleBackspace(); return; } - if (kb.matches(data, "deleteCharForward") || matchesKey(data, "shift+delete")) { + if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) { this.handleForwardDelete(); return; } // Kill ring actions - if (kb.matches(data, "yank")) { + if (kb.matches(data, "tui.editor.yank")) { this.yank(); return; } - if (kb.matches(data, "yankPop")) { + if (kb.matches(data, "tui.editor.yankPop")) { this.yankPop(); return; } // Cursor movement actions - if (kb.matches(data, "cursorLineStart")) { + if (kb.matches(data, "tui.editor.cursorLineStart")) { this.moveToLineStart(); return; } - if (kb.matches(data, "cursorLineEnd")) { + if (kb.matches(data, "tui.editor.cursorLineEnd")) { this.moveToLineEnd(); return; } - if (kb.matches(data, "cursorWordLeft")) { + if (kb.matches(data, "tui.editor.cursorWordLeft")) { this.moveWordBackwards(); return; } - if (kb.matches(data, "cursorWordRight")) { + if (kb.matches(data, "tui.editor.cursorWordRight")) { this.moveWordForwards(); return; } // New line if ( - kb.matches(data, "newLine") || + kb.matches(data, "tui.input.newLine") || (data.charCodeAt(0) === 10 && data.length > 1) || data === "\x1b\r" || data === "\x1b[13;2~" || @@ -722,7 +722,7 @@ export class Editor implements Component, Focusable { } // Submit (Enter) - if (kb.matches(data, "submit")) { + if (kb.matches(data, "tui.input.submit")) { if (this.disableSubmit) return; // Workaround for terminals without Shift+Enter support: @@ -739,7 +739,7 @@ export class Editor implements Component, Focusable { } // Arrow key navigation (with history support) - if (kb.matches(data, "cursorUp")) { + if (kb.matches(data, "tui.editor.cursorUp")) { if (this.isEditorEmpty()) { this.navigateHistory(-1); } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) { @@ -752,7 +752,7 @@ export class Editor implements Component, Focusable { } return; } - if (kb.matches(data, "cursorDown")) { + if (kb.matches(data, "tui.editor.cursorDown")) { if (this.historyIndex > -1 && this.isOnLastVisualLine()) { this.navigateHistory(1); } else if (this.isOnLastVisualLine()) { @@ -763,31 +763,31 @@ export class Editor implements Component, Focusable { } return; } - if (kb.matches(data, "cursorRight")) { + if (kb.matches(data, "tui.editor.cursorRight")) { this.moveCursor(0, 1); return; } - if (kb.matches(data, "cursorLeft")) { + if (kb.matches(data, "tui.editor.cursorLeft")) { this.moveCursor(0, -1); return; } // Page up/down - scroll by page and move cursor - if (kb.matches(data, "pageUp")) { + if (kb.matches(data, "tui.editor.pageUp")) { this.pageScroll(-1); return; } - if (kb.matches(data, "pageDown")) { + if (kb.matches(data, "tui.editor.pageDown")) { this.pageScroll(1); return; } // Character jump mode triggers - if (kb.matches(data, "jumpForward")) { + if (kb.matches(data, "tui.editor.jumpForward")) { this.jumpMode = "forward"; return; } - if (kb.matches(data, "jumpBackward")) { + if (kb.matches(data, "tui.editor.jumpBackward")) { this.jumpMode = "backward"; return; } @@ -1149,10 +1149,10 @@ export class Editor implements Component, Focusable { } } - private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType): boolean { + private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType): boolean { if (this.disableSubmit) return false; if (!matchesKey(data, "enter")) return false; - const submitKeys = kb.getKeys("submit"); + const submitKeys = kb.getKeys("tui.input.submit"); const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return"); if (!hasShiftEnter) return false; diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 0ff795f1..9c97af95 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,4 +1,4 @@ -import { getEditorKeybindings } from "../keybindings.js"; +import { getKeybindings } from "../keybindings.js"; import { decodeKittyPrintable } from "../keys.js"; import { KillRing } from "../kill-ring.js"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js"; @@ -82,69 +82,69 @@ export class Input implements Component, Focusable { return; } - const kb = getEditorKeybindings(); + const kb = getKeybindings(); // Escape/Cancel - if (kb.matches(data, "selectCancel")) { + if (kb.matches(data, "tui.select.cancel")) { if (this.onEscape) this.onEscape(); return; } // Undo - if (kb.matches(data, "undo")) { + if (kb.matches(data, "tui.editor.undo")) { this.undo(); return; } // Submit - if (kb.matches(data, "submit") || data === "\n") { + if (kb.matches(data, "tui.input.submit") || data === "\n") { if (this.onSubmit) this.onSubmit(this.value); return; } // Deletion - if (kb.matches(data, "deleteCharBackward")) { + if (kb.matches(data, "tui.editor.deleteCharBackward")) { this.handleBackspace(); return; } - if (kb.matches(data, "deleteCharForward")) { + if (kb.matches(data, "tui.editor.deleteCharForward")) { this.handleForwardDelete(); return; } - if (kb.matches(data, "deleteWordBackward")) { + if (kb.matches(data, "tui.editor.deleteWordBackward")) { this.deleteWordBackwards(); return; } - if (kb.matches(data, "deleteWordForward")) { + if (kb.matches(data, "tui.editor.deleteWordForward")) { this.deleteWordForward(); return; } - if (kb.matches(data, "deleteToLineStart")) { + if (kb.matches(data, "tui.editor.deleteToLineStart")) { this.deleteToLineStart(); return; } - if (kb.matches(data, "deleteToLineEnd")) { + if (kb.matches(data, "tui.editor.deleteToLineEnd")) { this.deleteToLineEnd(); return; } // Kill ring actions - if (kb.matches(data, "yank")) { + if (kb.matches(data, "tui.editor.yank")) { this.yank(); return; } - if (kb.matches(data, "yankPop")) { + if (kb.matches(data, "tui.editor.yankPop")) { this.yankPop(); return; } // Cursor movement - if (kb.matches(data, "cursorLeft")) { + if (kb.matches(data, "tui.editor.cursorLeft")) { this.lastAction = null; if (this.cursor > 0) { const beforeCursor = this.value.slice(0, this.cursor); @@ -155,7 +155,7 @@ export class Input implements Component, Focusable { return; } - if (kb.matches(data, "cursorRight")) { + if (kb.matches(data, "tui.editor.cursorRight")) { this.lastAction = null; if (this.cursor < this.value.length) { const afterCursor = this.value.slice(this.cursor); @@ -166,24 +166,24 @@ export class Input implements Component, Focusable { return; } - if (kb.matches(data, "cursorLineStart")) { + if (kb.matches(data, "tui.editor.cursorLineStart")) { this.lastAction = null; this.cursor = 0; return; } - if (kb.matches(data, "cursorLineEnd")) { + if (kb.matches(data, "tui.editor.cursorLineEnd")) { this.lastAction = null; this.cursor = this.value.length; return; } - if (kb.matches(data, "cursorWordLeft")) { + if (kb.matches(data, "tui.editor.cursorWordLeft")) { this.moveWordBackwards(); return; } - if (kb.matches(data, "cursorWordRight")) { + if (kb.matches(data, "tui.editor.cursorWordRight")) { this.moveWordForwards(); return; } diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index f29b1be3..09353e54 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,4 +1,4 @@ -import { getEditorKeybindings } from "../keybindings.js"; +import { getKeybindings } from "../keybindings.js"; import type { Component } from "../tui.js"; import { truncateToWidth, visibleWidth } from "../utils.js"; @@ -110,26 +110,26 @@ export class SelectList implements Component { } handleInput(keyData: string): void { - const kb = getEditorKeybindings(); + const kb = getKeybindings(); // Up arrow - wrap to bottom when at top - if (kb.matches(keyData, "selectUp")) { + if (kb.matches(keyData, "tui.select.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 (kb.matches(keyData, "selectDown")) { + else if (kb.matches(keyData, "tui.select.down")) { this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1; this.notifySelectionChange(); } // Enter - else if (kb.matches(keyData, "selectConfirm")) { + else if (kb.matches(keyData, "tui.select.confirm")) { const selectedItem = this.filteredItems[this.selectedIndex]; if (selectedItem && this.onSelect) { this.onSelect(selectedItem); } } // Escape or Ctrl+C - else if (kb.matches(keyData, "selectCancel")) { + else if (kb.matches(keyData, "tui.select.cancel")) { if (this.onCancel) { this.onCancel(); } diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index e6d01348..6a6235dd 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -1,5 +1,5 @@ import { fuzzyFilter } from "../fuzzy.js"; -import { getEditorKeybindings } from "../keybindings.js"; +import { getKeybindings } from "../keybindings.js"; import type { Component } from "../tui.js"; import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js"; import { Input } from "./input.js"; @@ -174,17 +174,17 @@ export class SettingsList implements Component { } // Main list input handling - const kb = getEditorKeybindings(); + const kb = getKeybindings(); const displayItems = this.searchEnabled ? this.filteredItems : this.items; - if (kb.matches(data, "selectUp")) { + if (kb.matches(data, "tui.select.up")) { if (displayItems.length === 0) return; this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1; - } else if (kb.matches(data, "selectDown")) { + } else if (kb.matches(data, "tui.select.down")) { if (displayItems.length === 0) return; this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1; - } else if (kb.matches(data, "selectConfirm") || data === " ") { + } else if (kb.matches(data, "tui.select.confirm") || data === " ") { this.activateItem(); - } else if (kb.matches(data, "selectCancel")) { + } else if (kb.matches(data, "tui.select.cancel")) { this.onCancel(); } else if (this.searchEnabled && this.searchInput) { const sanitized = data.replace(/ /g, ""); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 437a3e39..ea145e4b 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -32,12 +32,16 @@ export type { EditorComponent } from "./editor-component.js"; export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js"; // Keybindings export { - DEFAULT_EDITOR_KEYBINDINGS, - type EditorAction, - type EditorKeybindingsConfig, - EditorKeybindingsManager, - getEditorKeybindings, - setEditorKeybindings, + getKeybindings, + type Keybinding, + type KeybindingConflict, + type KeybindingDefinition, + type KeybindingDefinitions, + type Keybindings, + type KeybindingsConfig, + KeybindingsManager, + setKeybindings, + TUI_KEYBINDINGS, } from "./keybindings.js"; // Keyboard input handling export { diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index cafc9e23..014e1a59 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -1,189 +1,253 @@ import { type KeyId, matchesKey } from "./keys.js"; /** - * Editor actions that can be bound to keys. + * Global keybinding registry. + * Downstream packages can add keybindings via declaration merging. */ -export type EditorAction = - // Cursor movement - | "cursorUp" - | "cursorDown" - | "cursorLeft" - | "cursorRight" - | "cursorWordLeft" - | "cursorWordRight" - | "cursorLineStart" - | "cursorLineEnd" - | "jumpForward" - | "jumpBackward" - | "pageUp" - | "pageDown" - // Deletion - | "deleteCharBackward" - | "deleteCharForward" - | "deleteWordBackward" - | "deleteWordForward" - | "deleteToLineStart" - | "deleteToLineEnd" - // Text input - | "newLine" - | "submit" - | "tab" - // Selection/autocomplete - | "selectUp" - | "selectDown" - | "selectPageUp" - | "selectPageDown" - | "selectConfirm" - | "selectCancel" - // Clipboard - | "copy" - // Kill ring - | "yank" - | "yankPop" - // Undo - | "undo" - // Tool output - | "expandTools" - // Tree navigation - | "treeFoldOrUp" - | "treeUnfoldOrDown" - // Session - | "toggleSessionPath" - | "toggleSessionSort" - | "renameSession" - | "deleteSession" - | "deleteSessionNoninvasive"; +export interface Keybindings { + // Editor navigation and editing + "tui.editor.cursorUp": true; + "tui.editor.cursorDown": true; + "tui.editor.cursorLeft": true; + "tui.editor.cursorRight": true; + "tui.editor.cursorWordLeft": true; + "tui.editor.cursorWordRight": true; + "tui.editor.cursorLineStart": true; + "tui.editor.cursorLineEnd": true; + "tui.editor.jumpForward": true; + "tui.editor.jumpBackward": true; + "tui.editor.pageUp": true; + "tui.editor.pageDown": true; + "tui.editor.deleteCharBackward": true; + "tui.editor.deleteCharForward": true; + "tui.editor.deleteWordBackward": true; + "tui.editor.deleteWordForward": true; + "tui.editor.deleteToLineStart": true; + "tui.editor.deleteToLineEnd": true; + "tui.editor.yank": true; + "tui.editor.yankPop": true; + "tui.editor.undo": true; + // Generic input actions + "tui.input.newLine": true; + "tui.input.submit": true; + "tui.input.tab": true; + "tui.input.copy": true; + // Generic selection actions + "tui.select.up": true; + "tui.select.down": true; + "tui.select.pageUp": true; + "tui.select.pageDown": true; + "tui.select.confirm": true; + "tui.select.cancel": true; +} -// Re-export KeyId from keys.ts -export type { KeyId }; +export type Keybinding = keyof Keybindings; -/** - * Editor keybindings configuration. - */ -export type EditorKeybindingsConfig = { - [K in EditorAction]?: KeyId | KeyId[]; -}; +export interface KeybindingDefinition { + defaultKeys: KeyId | KeyId[]; + description?: string; +} -/** - * Default editor keybindings. - */ -export const DEFAULT_EDITOR_KEYBINDINGS: Required = { - // Cursor movement - cursorUp: "up", - cursorDown: "down", - cursorLeft: ["left", "ctrl+b"], - cursorRight: ["right", "ctrl+f"], - cursorWordLeft: ["alt+left", "ctrl+left", "alt+b"], - cursorWordRight: ["alt+right", "ctrl+right", "alt+f"], - cursorLineStart: ["home", "ctrl+a"], - cursorLineEnd: ["end", "ctrl+e"], - jumpForward: "ctrl+]", - jumpBackward: "ctrl+alt+]", - pageUp: "pageUp", - pageDown: "pageDown", - // Deletion - deleteCharBackward: "backspace", - deleteCharForward: ["delete", "ctrl+d"], - deleteWordBackward: ["ctrl+w", "alt+backspace"], - deleteWordForward: ["alt+d", "alt+delete"], - deleteToLineStart: "ctrl+u", - deleteToLineEnd: "ctrl+k", - // Text input - newLine: "shift+enter", - submit: "enter", - tab: "tab", - // Selection/autocomplete - selectUp: "up", - selectDown: "down", - selectPageUp: "pageUp", - selectPageDown: "pageDown", - selectConfirm: "enter", - selectCancel: ["escape", "ctrl+c"], - // Clipboard - copy: "ctrl+c", - // Kill ring - yank: "ctrl+y", - yankPop: "alt+y", - // Undo - undo: "ctrl+-", - // Tool output - expandTools: "ctrl+o", - // Tree navigation - treeFoldOrUp: ["ctrl+left", "alt+left"], - treeUnfoldOrDown: ["ctrl+right", "alt+right"], - // Session - toggleSessionPath: "ctrl+p", - toggleSessionSort: "ctrl+s", - renameSession: "ctrl+r", - deleteSession: "ctrl+d", - deleteSessionNoninvasive: "ctrl+backspace", -}; +export type KeybindingDefinitions = Record; +export type KeybindingsConfig = Record; -/** - * Manages keybindings for the editor. - */ -export class EditorKeybindingsManager { - private actionToKeys: Map; +export const TUI_KEYBINDINGS = { + "tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" }, + "tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" }, + "tui.editor.cursorLeft": { + defaultKeys: ["left", "ctrl+b"], + description: "Move cursor left", + }, + "tui.editor.cursorRight": { + defaultKeys: ["right", "ctrl+f"], + description: "Move cursor right", + }, + "tui.editor.cursorWordLeft": { + defaultKeys: ["alt+left", "ctrl+left", "alt+b"], + description: "Move cursor word left", + }, + "tui.editor.cursorWordRight": { + defaultKeys: ["alt+right", "ctrl+right", "alt+f"], + description: "Move cursor word right", + }, + "tui.editor.cursorLineStart": { + defaultKeys: ["home", "ctrl+a"], + description: "Move to line start", + }, + "tui.editor.cursorLineEnd": { + defaultKeys: ["end", "ctrl+e"], + description: "Move to line end", + }, + "tui.editor.jumpForward": { + defaultKeys: "ctrl+]", + description: "Jump forward to character", + }, + "tui.editor.jumpBackward": { + defaultKeys: "ctrl+alt+]", + description: "Jump backward to character", + }, + "tui.editor.pageUp": { defaultKeys: "pageUp", description: "Page up" }, + "tui.editor.pageDown": { defaultKeys: "pageDown", description: "Page down" }, + "tui.editor.deleteCharBackward": { + defaultKeys: "backspace", + description: "Delete character backward", + }, + "tui.editor.deleteCharForward": { + defaultKeys: ["delete", "ctrl+d"], + description: "Delete character forward", + }, + "tui.editor.deleteWordBackward": { + defaultKeys: ["ctrl+w", "alt+backspace"], + description: "Delete word backward", + }, + "tui.editor.deleteWordForward": { + defaultKeys: ["alt+d", "alt+delete"], + description: "Delete word forward", + }, + "tui.editor.deleteToLineStart": { + defaultKeys: "ctrl+u", + description: "Delete to line start", + }, + "tui.editor.deleteToLineEnd": { + defaultKeys: "ctrl+k", + description: "Delete to line end", + }, + "tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" }, + "tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" }, + "tui.editor.undo": { defaultKeys: "ctrl+-", description: "Undo" }, + "tui.input.newLine": { defaultKeys: "shift+enter", description: "Insert newline" }, + "tui.input.submit": { defaultKeys: "enter", description: "Submit input" }, + "tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" }, + "tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" }, + "tui.select.up": { defaultKeys: "up", description: "Move selection up" }, + "tui.select.down": { defaultKeys: "down", description: "Move selection down" }, + "tui.select.pageUp": { defaultKeys: "pageUp", description: "Selection page up" }, + "tui.select.pageDown": { + defaultKeys: "pageDown", + description: "Selection page down", + }, + "tui.select.confirm": { defaultKeys: "enter", description: "Confirm selection" }, + "tui.select.cancel": { + defaultKeys: ["escape", "ctrl+c"], + description: "Cancel selection", + }, +} as const satisfies KeybindingDefinitions; - constructor(config: EditorKeybindingsConfig = {}) { - this.actionToKeys = new Map(); - this.buildMaps(config); +export interface KeybindingConflict { + key: KeyId; + keybindings: string[]; +} + +function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] { + if (keys === undefined) return []; + const keyList = Array.isArray(keys) ? keys : [keys]; + const seen = new Set(); + const result: KeyId[] = []; + for (const key of keyList) { + if (!seen.has(key)) { + seen.add(key); + result.push(key); + } + } + return result; +} + +export class KeybindingsManager { + private definitions: KeybindingDefinitions; + private userBindings: KeybindingsConfig; + private keysById = new Map(); + private conflicts: KeybindingConflict[] = []; + + constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) { + this.definitions = definitions; + this.userBindings = userBindings; + this.rebuild(); } - private buildMaps(config: EditorKeybindingsConfig): void { - this.actionToKeys.clear(); + private rebuild(): void { + this.keysById.clear(); + this.conflicts = []; - // 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]); + const userClaims = new Map>(); + for (const [keybinding, keys] of Object.entries(this.userBindings)) { + if (!(keybinding in this.definitions)) continue; + for (const key of normalizeKeys(keys)) { + const claimants = userClaims.get(key) ?? new Set(); + claimants.add(keybinding as Keybinding); + userClaims.set(key, claimants); + } } - // 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); + for (const [key, keybindings] of userClaims) { + if (keybindings.size > 1) { + this.conflicts.push({ key, keybindings: [...keybindings] }); + } + } + + for (const [id, definition] of Object.entries(this.definitions)) { + const defaults = normalizeKeys(definition.defaultKeys); + const keys = defaults.filter((key) => { + const claimants = userClaims.get(key); + if (!claimants) return true; + return claimants.size === 1 && claimants.has(id as Keybinding); + }); + this.keysById.set(id as Keybinding, keys); + } + + for (const [keybinding, keys] of Object.entries(this.userBindings)) { + if (!(keybinding in this.definitions)) continue; + this.keysById.set(keybinding as Keybinding, normalizeKeys(keys)); } } - /** - * Check if input matches a specific action. - */ - matches(data: string, action: EditorAction): boolean { - const keys = this.actionToKeys.get(action); - if (!keys) return false; + matches(data: string, keybinding: Keybinding): boolean { + const keys = this.keysById.get(keybinding) ?? []; 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) ?? []; + getKeys(keybinding: Keybinding): KeyId[] { + return [...(this.keysById.get(keybinding) ?? [])]; } - /** - * Update configuration. - */ - setConfig(config: EditorKeybindingsConfig): void { - this.buildMaps(config); + getDefinition(keybinding: Keybinding): KeybindingDefinition { + return this.definitions[keybinding]; + } + + getConflicts(): KeybindingConflict[] { + return this.conflicts.map((conflict) => ({ ...conflict, keybindings: [...conflict.keybindings] })); + } + + setUserBindings(userBindings: KeybindingsConfig): void { + this.userBindings = userBindings; + this.rebuild(); + } + + getUserBindings(): KeybindingsConfig { + return { ...this.userBindings }; + } + + getResolvedBindings(): KeybindingsConfig { + const resolved: KeybindingsConfig = {}; + for (const id of Object.keys(this.definitions)) { + const keys = this.keysById.get(id as Keybinding) ?? []; + resolved[id] = keys.length === 1 ? keys[0]! : [...keys]; + } + return resolved; } } -// Global instance -let globalEditorKeybindings: EditorKeybindingsManager | null = null; +let globalKeybindings: KeybindingsManager | null = null; -export function getEditorKeybindings(): EditorKeybindingsManager { - if (!globalEditorKeybindings) { - globalEditorKeybindings = new EditorKeybindingsManager(); +export function setKeybindings(keybindings: KeybindingsManager): void { + globalKeybindings = keybindings; +} + +export function getKeybindings(): KeybindingsManager { + if (!globalKeybindings) { + globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS); } - return globalEditorKeybindings; -} - -export function setEditorKeybindings(manager: EditorKeybindingsManager): void { - globalEditorKeybindings = manager; + return globalKeybindings; }