fix(keybindings): migrate to namespaced ids closes #2391

This commit is contained in:
Mario Zechner
2026-03-20 01:50:47 +01:00
parent 74a46fc7ea
commit e3fee7a511
43 changed files with 1077 additions and 883 deletions

View File

@@ -2,9 +2,15 @@
## [Unreleased] ## [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 ### 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)) - 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. - 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 ## [0.60.0] - 2026-03-18

View File

@@ -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` - 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` - 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()`. 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 #### Best Practices

View File

@@ -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. 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. After editing `keybindings.json`, run `/reload` in pi to apply the changes without restarting the session.
## Key Format ## Key Format
@@ -18,127 +22,112 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1
## All Actions ## All Actions
### Cursor Movement ### TUI Editor Cursor Movement
| Action | Default | Description | | Keybinding id | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `cursorUp` | `up` | Move cursor up | | `tui.editor.cursorUp` | `up` | Move cursor up |
| `cursorDown` | `down` | Move cursor down | | `tui.editor.cursorDown` | `down` | Move cursor down |
| `cursorLeft` | `left`, `ctrl+b` | Move cursor left | | `tui.editor.cursorLeft` | `left`, `ctrl+b` | Move cursor left |
| `cursorRight` | `right`, `ctrl+f` | Move cursor right | | `tui.editor.cursorRight` | `right`, `ctrl+f` | Move cursor right |
| `cursorWordLeft` | `alt+left`, `ctrl+left`, `alt+b` | Move cursor word left | | `tui.editor.cursorWordLeft` | `alt+left`, `ctrl+left`, `alt+b` | Move cursor word left |
| `cursorWordRight` | `alt+right`, `ctrl+right`, `alt+f` | Move cursor word right | | `tui.editor.cursorWordRight` | `alt+right`, `ctrl+right`, `alt+f` | Move cursor word right |
| `cursorLineStart` | `home`, `ctrl+a` | Move to line start | | `tui.editor.cursorLineStart` | `home`, `ctrl+a` | Move to line start |
| `cursorLineEnd` | `end`, `ctrl+e` | Move to line end | | `tui.editor.cursorLineEnd` | `end`, `ctrl+e` | Move to line end |
| `jumpForward` | `ctrl+]` | Jump forward to character | | `tui.editor.jumpForward` | `ctrl+]` | Jump forward to character |
| `jumpBackward` | `ctrl+alt+]` | Jump backward to character | | `tui.editor.jumpBackward` | `ctrl+alt+]` | Jump backward to character |
| `pageUp` | `pageUp` | Scroll up by page | | `tui.editor.pageUp` | `pageUp` | Scroll up by page |
| `pageDown` | `pageDown` | Scroll down 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 | | `tui.editor.deleteCharBackward` | `backspace` | Delete character backward |
| `deleteCharForward` | `delete`, `ctrl+d` | Delete character forward | | `tui.editor.deleteCharForward` | `delete`, `ctrl+d` | Delete character forward |
| `deleteWordBackward` | `ctrl+w`, `alt+backspace` | Delete word backward | | `tui.editor.deleteWordBackward` | `ctrl+w`, `alt+backspace` | Delete word backward |
| `deleteWordForward` | `alt+d`, `alt+delete` | Delete word forward | | `tui.editor.deleteWordForward` | `alt+d`, `alt+delete` | Delete word forward |
| `deleteToLineStart` | `ctrl+u` | Delete to line start | | `tui.editor.deleteToLineStart` | `ctrl+u` | Delete to line start |
| `deleteToLineEnd` | `ctrl+k` | Delete to line end | | `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 | | `tui.input.newLine` | `shift+enter` | Insert new line |
| `submit` | `enter` | Submit input | | `tui.input.submit` | `enter` | Submit input |
| `tab` | `tab` | Tab / autocomplete | | `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 | | `tui.editor.yank` | `ctrl+y` | Paste most recently deleted text |
| `yankPop` | `alt+y` | Cycle through deleted text after yank | | `tui.editor.yankPop` | `alt+y` | Cycle through deleted text after yank |
| `undo` | `ctrl+-` | Undo last edit | | `tui.editor.undo` | `ctrl+-` | Undo last edit |
### Clipboard ### TUI Clipboard and Selection
| Action | Default | Description | | Keybinding id | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `copy` | `ctrl+c` | Copy selection | | `tui.input.copy` | `ctrl+c` | Copy selection |
| `pasteImage` | `ctrl+v` | Paste image from clipboard | | `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 ### Application
| Action | Default | Description | | Keybinding id | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `interrupt` | `escape` | Cancel / abort | | `app.interrupt` | `escape` | Cancel / abort |
| `clear` | `ctrl+c` | Clear editor | | `app.clear` | `ctrl+c` | Clear editor |
| `exit` | `ctrl+d` | Exit (when editor empty) | | `app.exit` | `ctrl+d` | Exit (when editor empty) |
| `suspend` | `ctrl+z` | Suspend to background | | `app.suspend` | `ctrl+z` | Suspend to background |
| `externalEditor` | `ctrl+g` | Open in external editor (`$VISUAL` or `$EDITOR`) | | `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`) | | `app.session.new` | *(none)* | Start a new session (`/new`) |
| `tree` | *(none)* | Open session tree navigator (`/tree`) | | `app.session.tree` | *(none)* | Open session tree navigator (`/tree`) |
| `fork` | *(none)* | Fork current session (`/fork`) | | `app.session.fork` | *(none)* | Fork current session (`/fork`) |
| `resume` | *(none)* | Open session resume picker (`/resume`) | | `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 | | `app.model.select` | `ctrl+l` | Open model selector |
| `cycleModelForward` | `ctrl+p` | Cycle to next model | | `app.model.cycleForward` | `ctrl+p` | Cycle to next model |
| `cycleModelBackward` | `shift+ctrl+p` | Cycle to previous model | | `app.model.cycleBackward` | `shift+ctrl+p` | Cycle to previous model |
| `cycleThinkingLevel` | `shift+tab` | Cycle thinking level | | `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 | | `app.tools.expand` | `ctrl+o` | Collapse or expand tool output |
| `toggleThinking` | `ctrl+t` | Collapse/expand thinking blocks | | `app.message.followUp` | `alt+enter` | Queue follow-up message |
| `app.message.dequeue` | `alt+up` | Restore queued messages to editor |
### 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 |
### Tree Navigation ### 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 | | `app.tree.foldOrUp` | `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 | | `app.tree.unfoldOrDown` | `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) |
## Custom Configuration ## Custom Configuration
@@ -146,9 +135,9 @@ Create `~/.pi/agent/keybindings.json`:
```json ```json
{ {
"cursorUp": ["up", "ctrl+p"], "tui.editor.cursorUp": ["up", "ctrl+p"],
"cursorDown": ["down", "ctrl+n"], "tui.editor.cursorDown": ["down", "ctrl+n"],
"deleteWordBackward": ["ctrl+w", "alt+backspace"] "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 ```json
{ {
"cursorUp": ["up", "ctrl+p"], "tui.editor.cursorUp": ["up", "ctrl+p"],
"cursorDown": ["down", "ctrl+n"], "tui.editor.cursorDown": ["down", "ctrl+n"],
"cursorLeft": ["left", "ctrl+b"], "tui.editor.cursorLeft": ["left", "ctrl+b"],
"cursorRight": ["right", "ctrl+f"], "tui.editor.cursorRight": ["right", "ctrl+f"],
"cursorWordLeft": ["alt+left", "alt+b"], "tui.editor.cursorWordLeft": ["alt+left", "alt+b"],
"cursorWordRight": ["alt+right", "alt+f"], "tui.editor.cursorWordRight": ["alt+right", "alt+f"],
"deleteCharForward": ["delete", "ctrl+d"], "tui.editor.deleteCharForward": ["delete", "ctrl+d"],
"deleteCharBackward": ["backspace", "ctrl+h"], "tui.editor.deleteCharBackward": ["backspace", "ctrl+h"],
"newLine": ["shift+enter", "ctrl+j"] "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 ```json
{ {
"cursorUp": ["up", "alt+k"], "tui.editor.cursorUp": ["up", "alt+k"],
"cursorDown": ["down", "alt+j"], "tui.editor.cursorDown": ["down", "alt+j"],
"cursorLeft": ["left", "alt+h"], "tui.editor.cursorLeft": ["left", "alt+h"],
"cursorRight": ["right", "alt+l"], "tui.editor.cursorRight": ["right", "alt+l"],
"cursorWordLeft": ["alt+left", "alt+b"], "tui.editor.cursorWordLeft": ["alt+left", "alt+b"],
"cursorWordRight": ["alt+right", "alt+w"] "tui.editor.cursorWordRight": ["alt+right", "alt+w"]
} }
``` ```

View File

@@ -2,7 +2,7 @@
* TUI session selector for --resume flag * 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 { KeybindingsManager } from "../core/keybindings.js";
import type { SessionInfo, SessionListProgress } from "../core/session-manager.js"; import type { SessionInfo, SessionListProgress } from "../core/session-manager.js";
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js"; import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";
@@ -17,6 +17,7 @@ export async function selectSession(
return new Promise((resolve) => { return new Promise((resolve) => {
const ui = new TUI(new ProcessTerminal()); const ui = new TUI(new ProcessTerminal());
const keybindings = KeybindingsManager.create(); const keybindings = KeybindingsManager.create();
setKeybindings(keybindings);
let resolved = false; let resolved = false;
const selector = new SessionSelectorComponent( const selector = new SessionSelectorComponent(

View File

@@ -24,9 +24,9 @@ export type {
// Re-exports // Re-exports
AgentToolResult, AgentToolResult,
AgentToolUpdateCallback, AgentToolUpdateCallback,
// App keybindings (for custom editors)
AppAction,
AppendEntryHandler, AppendEntryHandler,
// App keybindings (for custom editors)
AppKeybinding,
// Events - Tool (ToolCallEvent types) // Events - Tool (ToolCallEvent types)
BashToolCallEvent, BashToolCallEvent,
BashToolResultEvent, BashToolResultEvent,

View File

@@ -7,7 +7,7 @@ import type { ImageContent, Model } from "@mariozechner/pi-ai";
import type { KeyId } from "@mariozechner/pi-tui"; import type { KeyId } from "@mariozechner/pi-tui";
import { type Theme, theme } from "../../modes/interactive/theme/theme.js"; import { type Theme, theme } from "../../modes/interactive/theme/theme.js";
import type { ResourceDiagnostic } from "../diagnostics.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 { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js"; import type { SessionManager } from "../session-manager.js";
import type { import type {
@@ -51,40 +51,41 @@ import type {
UserBashEventResult, UserBashEventResult,
} from "./types.js"; } from "./types.js";
// Keybindings for these actions cannot be overridden by extensions // Extension shortcuts compete with canonical keybinding ids from keybindings.json.
const RESERVED_ACTIONS_FOR_EXTENSION_CONFLICTS: ReadonlyArray<KeyAction> = [ // Only editor-global shortcuts are reserved here. Picker-specific bindings are not.
"interrupt", const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [
"clear", "app.interrupt",
"exit", "app.clear",
"suspend", "app.exit",
"cycleThinkingLevel", "app.suspend",
"cycleModelForward", "app.thinking.cycle",
"cycleModelBackward", "app.model.cycleForward",
"selectModel", "app.model.cycleBackward",
"expandTools", "app.model.select",
"toggleThinking", "app.tools.expand",
"externalEditor", "app.thinking.toggle",
"followUp", "app.editor.external",
"submit", "app.message.followUp",
"selectConfirm", "tui.input.submit",
"selectCancel", "tui.select.confirm",
"copy", "tui.select.cancel",
"deleteToLineEnd", "tui.input.copy",
]; "tui.editor.deleteToLineEnd",
] as const;
type BuiltInKeyBindings = Partial<Record<KeyId, { action: KeyAction; restrictOverride: boolean }>>; type BuiltInKeyBindings = Partial<Record<KeyId, { keybinding: string; restrictOverride: boolean }>>;
const buildBuiltinKeybindings = (effectiveKeybindings: Required<KeybindingsConfig>): BuiltInKeyBindings => { const buildBuiltinKeybindings = (resolvedKeybindings: KeybindingsConfig): BuiltInKeyBindings => {
const builtinKeybindings = {} as BuiltInKeyBindings; const builtinKeybindings = {} as BuiltInKeyBindings;
for (const [action, keys] of Object.entries(effectiveKeybindings)) { for (const [keybinding, keys] of Object.entries(resolvedKeybindings)) {
const keyAction = action as KeyAction; if (keys === undefined) continue;
const keyList = Array.isArray(keys) ? keys : [keys]; 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) { for (const key of keyList) {
const normalizedKey = key.toLowerCase() as KeyId; const normalizedKey = key.toLowerCase() as KeyId;
builtinKeybindings[normalizedKey] = { builtinKeybindings[normalizedKey] = {
action: keyAction, keybinding,
restrictOverride: restrictOverride, restrictOverride,
}; };
} }
} }
@@ -386,9 +387,9 @@ export class ExtensionRunner {
return new Map(this.runtime.flagValues); return new Map(this.runtime.flagValues);
} }
getShortcuts(effectiveKeybindings: Required<KeybindingsConfig>): Map<KeyId, ExtensionShortcut> { getShortcuts(resolvedKeybindings: KeybindingsConfig): Map<KeyId, ExtensionShortcut> {
this.shortcutDiagnostics = []; this.shortcutDiagnostics = [];
const builtinKeybindings = buildBuiltinKeybindings(effectiveKeybindings); const builtinKeybindings = buildBuiltinKeybindings(resolvedKeybindings);
const extensionShortcuts = new Map<KeyId, ExtensionShortcut>(); const extensionShortcuts = new Map<KeyId, ExtensionShortcut>();
const addDiagnostic = (message: string, extensionPath: string) => { const addDiagnostic = (message: string, extensionPath: string) => {
@@ -413,7 +414,7 @@ export class ExtensionRunner {
if (builtInKeybinding?.restrictOverride === false) { if (builtInKeybinding?.restrictOverride === false) {
addDiagnostic( 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, shortcut.extensionPath,
); );
} }

View File

@@ -74,7 +74,7 @@ import type {
export type { ExecOptions, ExecResult } from "../exec.js"; export type { ExecOptions, ExecResult } from "../exec.js";
export type { AgentToolResult, AgentToolUpdateCallback }; export type { AgentToolResult, AgentToolUpdateCallback };
export type { AppAction, KeybindingsManager } from "../keybindings.js"; export type { AppKeybinding, KeybindingsManager } from "../keybindings.js";
// ============================================================================ // ============================================================================
// UI Context // UI Context

View File

@@ -1,220 +1,302 @@
import { import {
DEFAULT_EDITOR_KEYBINDINGS, type Keybinding,
type EditorAction, type KeybindingDefinitions,
type EditorKeybindingsConfig, type KeybindingsConfig,
EditorKeybindingsManager,
type KeyId, type KeyId,
matchesKey, TUI_KEYBINDINGS,
setEditorKeybindings, KeybindingsManager as TuiKeybindingsManager,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import { existsSync, readFileSync } from "fs"; import { existsSync, readFileSync, writeFileSync } from "fs";
import { join } from "path"; import { join } from "path";
import { getAgentDir } from "../config.js"; import { getAgentDir } from "../config.js";
/** export interface AppKeybindings {
* Application-level actions (coding agent specific). "app.interrupt": true;
*/ "app.clear": true;
export type AppAction = "app.exit": true;
| "interrupt" "app.suspend": true;
| "clear" "app.thinking.cycle": true;
| "exit" "app.model.cycleForward": true;
| "suspend" "app.model.cycleBackward": true;
| "cycleThinkingLevel" "app.model.select": true;
| "cycleModelForward" "app.tools.expand": true;
| "cycleModelBackward" "app.thinking.toggle": true;
| "selectModel" "app.session.toggleNamedFilter": true;
| "expandTools" "app.editor.external": true;
| "toggleThinking" "app.message.followUp": true;
| "toggleSessionNamedFilter" "app.message.dequeue": true;
| "externalEditor" "app.clipboard.pasteImage": true;
| "followUp" "app.session.new": true;
| "dequeue" "app.session.tree": true;
| "pasteImage" "app.session.fork": true;
| "newSession" "app.session.resume": true;
| "tree" "app.tree.foldOrUp": true;
| "fork" "app.tree.unfoldOrDown": true;
| "resume"; "app.session.togglePath": true;
"app.session.toggleSort": true;
/** "app.session.rename": true;
* All configurable actions. "app.session.delete": true;
*/ "app.session.deleteNoninvasive": true;
export type KeyAction = AppAction | EditorAction;
/**
* Full keybindings configuration (app + editor actions).
*/
export type KeybindingsConfig = {
[K in KeyAction]?: KeyId | KeyId[];
};
/**
* Default application keybindings.
*/
export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
interrupt: "escape",
clear: "ctrl+c",
exit: "ctrl+d",
suspend: "ctrl+z",
cycleThinkingLevel: "shift+tab",
cycleModelForward: "ctrl+p",
cycleModelBackward: "shift+ctrl+p",
selectModel: "ctrl+l",
expandTools: "ctrl+o",
toggleThinking: "ctrl+t",
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<KeybindingsConfig> = {
...DEFAULT_EDITOR_KEYBINDINGS,
...DEFAULT_APP_KEYBINDINGS,
};
// App actions list for type checking
const APP_ACTIONS: AppAction[] = [
"interrupt",
"clear",
"exit",
"suspend",
"cycleThinkingLevel",
"cycleModelForward",
"cycleModelBackward",
"selectModel",
"expandTools",
"toggleThinking",
"toggleSessionNamedFilter",
"externalEditor",
"followUp",
"dequeue",
"pasteImage",
"newSession",
"tree",
"fork",
"resume",
];
function isAppAction(action: string): action is AppAction {
return APP_ACTIONS.includes(action as AppAction);
} }
/** export type AppKeybinding = keyof AppKeybindings;
* Manages all keybindings (app + editor).
*/
export class KeybindingsManager {
private config: KeybindingsConfig;
private configPath: string | undefined;
private appActionToKeys: Map<AppAction, KeyId[]>;
private constructor(config: KeybindingsConfig, configPath?: string) { declare module "@mariozechner/pi-tui" {
this.config = config; interface Keybindings extends AppKeybindings {}
this.configPath = configPath; }
this.appActionToKeys = new Map();
this.buildMaps(); 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<string, Keybinding>;
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>): {
config: Record<string, unknown>;
migrated: boolean;
} {
const config: Record<string, unknown> = {};
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<string, unknown>): Record<string, unknown> {
const ordered: Record<string, unknown> = {};
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<string, unknown> | 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 { static create(agentDir: string = getAgentDir()): KeybindingsManager {
const configPath = join(agentDir, "keybindings.json"); const configPath = join(agentDir, "keybindings.json");
const config = KeybindingsManager.loadFromFile(configPath); const userBindings = KeybindingsManager.loadFromFile(configPath);
const manager = new KeybindingsManager(config, configPath); return new KeybindingsManager(userBindings, configPath);
manager.applyEditorKeybindings();
return manager;
}
/**
* Create in-memory.
*/
static inMemory(config: KeybindingsConfig = {}): KeybindingsManager {
return new KeybindingsManager(config);
} }
reload(): void { reload(): void {
if (!this.configPath) return; if (!this.configPath) return;
this.config = KeybindingsManager.loadFromFile(this.configPath); this.setUserBindings(KeybindingsManager.loadFromFile(this.configPath));
this.buildMaps(); }
this.applyEditorKeybindings();
getEffectiveConfig(): KeybindingsConfig {
return this.getResolvedBindings();
} }
private static loadFromFile(path: string): KeybindingsConfig { private static loadFromFile(path: string): KeybindingsConfig {
if (!existsSync(path)) return {}; const rawConfig = loadRawConfig(path);
try { if (!rawConfig) return {};
return JSON.parse(readFileSync(path, "utf-8")); return toKeybindingsConfig(migrateKeybindingNames(rawConfig).config);
} 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<KeybindingsConfig> {
const result = { ...DEFAULT_KEYBINDINGS };
for (const [action, keys] of Object.entries(this.config)) {
if (keys !== undefined) {
(result as KeybindingsConfig)[action as KeyAction] = keys;
}
}
return result;
} }
} }
// Re-export for convenience export type { Keybinding, KeyId, KeybindingsConfig };
export type { EditorAction, KeyId };

View File

@@ -18,7 +18,7 @@ import { APP_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
import { AuthStorage } from "./core/auth-storage.js"; import { AuthStorage } from "./core/auth-storage.js";
import { exportFromFile } from "./core/export-html/index.js"; import { exportFromFile } from "./core/export-html/index.js";
import type { LoadExtensionsResult } from "./core/extensions/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 { ModelRegistry } from "./core/model-registry.js";
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
import { DefaultPackageManager } from "./core/package-manager.js"; import { DefaultPackageManager } from "./core/package-manager.js";
@@ -739,6 +739,8 @@ export async function main(args: string[]) {
process.exit(0); process.exit(0);
} }
migrateKeybindingsConfigFile(agentDir);
if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) {
console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));
process.exit(1); process.exit(1);
@@ -771,9 +773,6 @@ export async function main(args: string[]) {
// Handle --resume: show session picker // Handle --resume: show session picker
if (parsed.resume) { if (parsed.resume) {
// Initialize keybindings so session picker respects user config
KeybindingsManager.create();
// Compute effective session dir for resume (same logic as createSessionManager) // Compute effective session dir for resume (same logic as createSessionManager)
const effectiveSessionDir = parsed.sessionDir || (await callSessionDirectoryHook(extensionsResult, cwd)); const effectiveSessionDir = parsed.sessionDir || (await callSessionDirectoryHook(extensionsResult, cwd));

View File

@@ -12,7 +12,7 @@ import {
} from "../../../core/tools/truncate.js"; } from "../../../core/tools/truncate.js";
import { theme } from "../theme/theme.js"; import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.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"; import { truncateToVisualLines } from "./visual-truncate.js";
// Preview line limit when not expanded (matches tool execution behavior) // Preview line limit when not expanded (matches tool execution behavior)
@@ -58,7 +58,7 @@ export class BashExecutionComponent extends Container {
ui, ui,
(spinner) => theme.fg(colorKey, spinner), (spinner) => theme.fg(colorKey, spinner),
(text) => theme.fg("muted", text), (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); this.contentContainer.addChild(this.loader);
@@ -168,10 +168,10 @@ export class BashExecutionComponent extends Container {
// Show how many lines are hidden (collapsed preview) // Show how many lines are hidden (collapsed preview)
if (hiddenLineCount > 0) { if (hiddenLineCount > 0) {
if (this.expanded) { if (this.expanded) {
statusParts.push(`(${keyHint("expandTools", "to collapse")})`); statusParts.push(`(${keyHint("app.tools.expand", "to collapse")})`);
} else { } else {
statusParts.push( statusParts.push(
`${theme.fg("muted", `... ${hiddenLineCount} more lines`)} (${keyHint("expandTools", "to expand")})`, `${theme.fg("muted", `... ${hiddenLineCount} more lines`)} (${keyHint("app.tools.expand", "to expand")})`,
); );
} }
} }

View File

@@ -33,7 +33,7 @@ export class BorderedLoader extends Container {
this.addChild(this.loader); this.addChild(this.loader);
if (this.cancellable) { if (this.cancellable) {
this.addChild(new Spacer(1)); 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 Spacer(1));
this.addChild(new DynamicBorder(borderColor)); this.addChild(new DynamicBorder(borderColor));

View File

@@ -1,7 +1,7 @@
import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui"; import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui";
import type { BranchSummaryMessage } from "../../../core/messages.js"; import type { BranchSummaryMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.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. * Component that renders a branch summary message with collapsed/expanded state.
@@ -47,7 +47,7 @@ export class BranchSummaryMessageComponent extends Box {
this.addChild( this.addChild(
new Text( new Text(
theme.fg("customMessageText", "Branch summary (") + theme.fg("customMessageText", "Branch summary (") +
theme.fg("dim", editorKey("expandTools")) + theme.fg("dim", keyText("app.tools.expand")) +
theme.fg("customMessageText", " to expand)"), theme.fg("customMessageText", " to expand)"),
0, 0,
0, 0,

View File

@@ -1,7 +1,7 @@
import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui"; import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@mariozechner/pi-tui";
import type { CompactionSummaryMessage } from "../../../core/messages.js"; import type { CompactionSummaryMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.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. * Component that renders a compaction message with collapsed/expanded state.
@@ -48,7 +48,7 @@ export class CompactionSummaryMessageComponent extends Box {
this.addChild( this.addChild(
new Text( new Text(
theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (`) + theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (`) +
theme.fg("dim", editorKey("expandTools")) + theme.fg("dim", keyText("app.tools.expand")) +
theme.fg("customMessageText", " to expand)"), theme.fg("customMessageText", " to expand)"),
0, 0,
0, 0,

View File

@@ -7,7 +7,7 @@ import {
type Component, type Component,
Container, Container,
type Focusable, type Focusable,
getEditorKeybindings, getKeybindings,
Input, Input,
matchesKey, matchesKey,
Spacer, Spacer,
@@ -355,17 +355,17 @@ class ResourceList implements Component, Focusable {
} }
handleInput(data: string): void { 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); this.selectedIndex = this.findNextItem(this.selectedIndex, -1);
return; return;
} }
if (kb.matches(data, "selectDown")) { if (kb.matches(data, "tui.select.down")) {
this.selectedIndex = this.findNextItem(this.selectedIndex, 1); this.selectedIndex = this.findNextItem(this.selectedIndex, 1);
return; return;
} }
if (kb.matches(data, "selectPageUp")) { if (kb.matches(data, "tui.select.pageUp")) {
// Jump up by maxVisible, then find nearest item // Jump up by maxVisible, then find nearest item
let target = Math.max(0, this.selectedIndex - this.maxVisible); let target = Math.max(0, this.selectedIndex - this.maxVisible);
while (target < this.filteredItems.length && this.filteredItems[target].type !== "item") { while (target < this.filteredItems.length && this.filteredItems[target].type !== "item") {
@@ -376,7 +376,7 @@ class ResourceList implements Component, Focusable {
} }
return; return;
} }
if (kb.matches(data, "selectPageDown")) { if (kb.matches(data, "tui.select.pageDown")) {
// Jump down by maxVisible, then find nearest item // Jump down by maxVisible, then find nearest item
let target = Math.min(this.filteredItems.length - 1, this.selectedIndex + this.maxVisible); let target = Math.min(this.filteredItems.length - 1, this.selectedIndex + this.maxVisible);
while (target >= 0 && this.filteredItems[target].type !== "item") { while (target >= 0 && this.filteredItems[target].type !== "item") {
@@ -387,7 +387,7 @@ class ResourceList implements Component, Focusable {
} }
return; return;
} }
if (kb.matches(data, "selectCancel")) { if (kb.matches(data, "tui.select.cancel")) {
this.onCancel?.(); this.onCancel?.();
return; return;
} }
@@ -395,7 +395,7 @@ class ResourceList implements Component, Focusable {
this.onExit?.(); this.onExit?.();
return; return;
} }
if (data === " " || kb.matches(data, "selectConfirm")) { if (data === " " || kb.matches(data, "tui.select.confirm")) {
const entry = this.filteredItems[this.selectedIndex]; const entry = this.filteredItems[this.selectedIndex];
if (entry?.type === "item") { if (entry?.type === "item") {
const newEnabled = !entry.item.enabled; const newEnabled = !entry.item.enabled;

View File

@@ -1,12 +1,12 @@
import { Editor, type EditorOptions, type EditorTheme, type TUI } from "@mariozechner/pi-tui"; 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. * Custom editor that handles app-level keybindings for coding-agent.
*/ */
export class CustomEditor extends Editor { export class CustomEditor extends Editor {
private keybindings: KeybindingsManager; private keybindings: KeybindingsManager;
public actionHandlers: Map<AppAction, () => void> = new Map(); public actionHandlers: Map<AppKeybinding, () => void> = new Map();
// Special handlers that can be dynamically replaced // Special handlers that can be dynamically replaced
public onEscape?: () => void; public onEscape?: () => void;
@@ -23,7 +23,7 @@ export class CustomEditor extends Editor {
/** /**
* Register a handler for an app action. * Register a handler for an app action.
*/ */
onAction(action: AppAction, handler: () => void): void { onAction(action: AppKeybinding, handler: () => void): void {
this.actionHandlers.set(action, handler); this.actionHandlers.set(action, handler);
} }
@@ -34,7 +34,7 @@ export class CustomEditor extends Editor {
} }
// Check for paste image keybinding // Check for paste image keybinding
if (this.keybindings.matches(data, "pasteImage")) { if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
this.onPasteImage?.(); this.onPasteImage?.();
return; return;
} }
@@ -42,10 +42,10 @@ export class CustomEditor extends Editor {
// Check app keybindings first // Check app keybindings first
// Escape/interrupt - only if autocomplete is NOT active // Escape/interrupt - only if autocomplete is NOT active
if (this.keybindings.matches(data, "interrupt")) { if (this.keybindings.matches(data, "app.interrupt")) {
if (!this.isShowingAutocomplete()) { if (!this.isShowingAutocomplete()) {
// Use dynamic onEscape if set, otherwise registered handler // 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) { if (handler) {
handler(); handler();
return; return;
@@ -57,9 +57,9 @@ export class CustomEditor extends Editor {
} }
// Exit (Ctrl+D) - only when editor is empty // 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) { 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(); if (handler) handler();
return; return;
} }
@@ -68,7 +68,7 @@ export class CustomEditor extends Editor {
// Check all other app actions // Check all other app actions
for (const [action, handler] of this.actionHandlers) { 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(); handler();
return; return;
} }

View File

@@ -12,7 +12,7 @@ import {
Editor, Editor,
type EditorOptions, type EditorOptions,
type Focusable, type Focusable,
getEditorKeybindings, getKeybindings,
Spacer, Spacer,
Text, Text,
type TUI, type TUI,
@@ -20,7 +20,7 @@ import {
import type { KeybindingsManager } from "../../../core/keybindings.js"; import type { KeybindingsManager } from "../../../core/keybindings.js";
import { getEditorTheme, theme } from "../theme/theme.js"; import { getEditorTheme, theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.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 { export class ExtensionEditorComponent extends Container implements Focusable {
private editor: Editor; private editor: Editor;
@@ -78,12 +78,12 @@ export class ExtensionEditorComponent extends Container implements Focusable {
// Add hint // Add hint
const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR); const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR);
const hint = const hint =
keyHint("selectConfirm", "submit") + keyHint("tui.select.confirm", "submit") +
" " + " " +
keyHint("newLine", "newline") + keyHint("tui.input.newLine", "newline") +
" " + " " +
keyHint("selectCancel", "cancel") + keyHint("tui.select.cancel", "cancel") +
(hasExternalEditor ? ` ${appKeyHint(this.keybindings, "externalEditor", "external editor")}` : ""); (hasExternalEditor ? ` ${keyHint("app.editor.external", "external editor")}` : "");
this.addChild(new Text(hint, 1, 0)); this.addChild(new Text(hint, 1, 0));
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
@@ -93,15 +93,15 @@ export class ExtensionEditorComponent extends Container implements Focusable {
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
// Escape or Ctrl+C to cancel // Escape or Ctrl+C to cancel
if (kb.matches(keyData, "selectCancel")) { if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback(); this.onCancelCallback();
return; return;
} }
// External editor (app keybinding) // External editor (app keybinding)
if (this.keybindings.matches(keyData, "externalEditor")) { if (this.keybindings.matches(keyData, "app.editor.external")) {
this.openExternalEditor(); this.openExternalEditor();
return; return;
} }

View File

@@ -2,7 +2,7 @@
* Simple text input component for extensions. * 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 { theme } from "../theme/theme.js";
import { CountdownTimer } from "./countdown-timer.js"; import { CountdownTimer } from "./countdown-timer.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
@@ -63,16 +63,18 @@ export class ExtensionInputComponent extends Container implements Focusable {
this.input = new Input(); this.input = new Input();
this.addChild(this.input); this.addChild(this.input);
this.addChild(new Spacer(1)); 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 Spacer(1));
this.addChild(new DynamicBorder()); this.addChild(new DynamicBorder());
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
if (kb.matches(keyData, "selectConfirm") || keyData === "\n") { if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
this.onSubmitCallback(this.input.getValue()); this.onSubmitCallback(this.input.getValue());
} else if (kb.matches(keyData, "selectCancel")) { } else if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback(); this.onCancelCallback();
} else { } else {
this.input.handleInput(keyData); this.input.handleInput(keyData);

View File

@@ -3,7 +3,7 @@
* Displays a list of string options with keyboard navigation. * 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 { theme } from "../theme/theme.js";
import { CountdownTimer } from "./countdown-timer.js"; import { CountdownTimer } from "./countdown-timer.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
@@ -61,9 +61,9 @@ export class ExtensionSelectorComponent extends Container {
new Text( new Text(
rawKeyHint("↑↓", "navigate") + rawKeyHint("↑↓", "navigate") +
" " + " " +
keyHint("selectConfirm", "select") + keyHint("tui.select.confirm", "select") +
" " + " " +
keyHint("selectCancel", "cancel"), keyHint("tui.select.cancel", "cancel"),
1, 1,
0, 0,
), ),
@@ -86,17 +86,17 @@ export class ExtensionSelectorComponent extends Container {
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
if (kb.matches(keyData, "selectUp") || keyData === "k") { if (kb.matches(keyData, "tui.select.up") || keyData === "k") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.updateList(); 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.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1);
this.updateList(); 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]; const selected = this.options[this.selectedIndex];
if (selected) this.onSelectCallback(selected); if (selected) this.onSelectCallback(selected);
} else if (kb.matches(keyData, "selectCancel")) { } else if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback(); this.onCancelCallback();
} }
} }

View File

@@ -14,7 +14,7 @@ export { ExtensionEditorComponent } from "./extension-editor.js";
export { ExtensionInputComponent } from "./extension-input.js"; export { ExtensionInputComponent } from "./extension-input.js";
export { ExtensionSelectorComponent } from "./extension-selector.js"; export { ExtensionSelectorComponent } from "./extension-selector.js";
export { FooterComponent } from "./footer.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 { LoginDialogComponent } from "./login-dialog.js";
export { ModelSelectorComponent } from "./model-selector.js"; export { ModelSelectorComponent } from "./model-selector.js";
export { OAuthSelectorComponent } from "./oauth-selector.js"; export { OAuthSelectorComponent } from "./oauth-selector.js";

View File

@@ -2,65 +2,23 @@
* Utilities for formatting keybinding hints in the UI. * Utilities for formatting keybinding hints in the UI.
*/ */
import { type EditorAction, getEditorKeybindings, type KeyId } from "@mariozechner/pi-tui"; import { getKeybindings, type Keybinding, type KeyId } from "@mariozechner/pi-tui";
import type { AppAction, KeybindingsManager } from "../../../core/keybindings.js";
import { theme } from "../theme/theme.js"; 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 { function formatKeys(keys: KeyId[]): string {
if (keys.length === 0) return ""; if (keys.length === 0) return "";
if (keys.length === 1) return keys[0]!; if (keys.length === 1) return keys[0]!;
return keys.join("/"); return keys.join("/");
} }
/** export function keyText(keybinding: Keybinding): string {
* Get display string for an editor action. return formatKeys(getKeybindings().getKeys(keybinding));
*/
export function editorKey(action: EditorAction): string {
return formatKeys(getEditorKeybindings().getKeys(action));
} }
/** export function keyHint(keybinding: Keybinding, description: string): string {
* Get display string for an app action. return theme.fg("dim", keyText(keybinding)) + theme.fg("muted", ` ${description}`);
*/
export function appKey(keybindings: KeybindingsManager, action: AppAction): string {
return formatKeys(keybindings.getKeys(action));
} }
/**
* 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 { export function rawKeyHint(key: string, description: string): string {
return theme.fg("dim", key) + theme.fg("muted", ` ${description}`); return theme.fg("dim", key) + theme.fg("muted", ` ${description}`);
} }

View File

@@ -1,5 +1,5 @@
import { getOAuthProviders } from "@mariozechner/pi-ai/oauth"; 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 { exec } from "child_process";
import { theme } from "../theme/theme.js"; import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.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 Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
this.contentContainer.addChild(this.input); 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(); this.tui.requestRender();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -130,7 +130,11 @@ export class LoginDialogComponent extends Container implements Focusable {
} }
this.contentContainer.addChild(this.input); this.contentContainer.addChild(this.input);
this.contentContainer.addChild( 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(""); this.input.setValue("");
@@ -148,7 +152,7 @@ export class LoginDialogComponent extends Container implements Focusable {
showWaiting(message: string): void { showWaiting(message: string): void {
this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); 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(); this.tui.requestRender();
} }
@@ -161,9 +165,9 @@ export class LoginDialogComponent extends Container implements Focusable {
} }
handleInput(data: string): void { handleInput(data: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
if (kb.matches(data, "selectCancel")) { if (kb.matches(data, "tui.select.cancel")) {
this.cancel(); this.cancel();
return; return;
} }

View File

@@ -3,7 +3,7 @@ import {
Container, Container,
type Focusable, type Focusable,
fuzzyFilter, fuzzyFilter,
getEditorKeybindings, getKeybindings,
Input, Input,
Spacer, Spacer,
Text, Text,
@@ -200,7 +200,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
} }
private getScopeHintText(): string { 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 { private setScope(scope: ModelScope): void {
@@ -284,8 +284,8 @@ export class ModelSelectorComponent extends Container implements Focusable {
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
if (kb.matches(keyData, "tab")) { if (kb.matches(keyData, "tui.input.tab")) {
if (this.scopedModelItems.length > 0) { if (this.scopedModelItems.length > 0) {
const nextScope: ModelScope = this.scope === "all" ? "scoped" : "all"; const nextScope: ModelScope = this.scope === "all" ? "scoped" : "all";
this.setScope(nextScope); this.setScope(nextScope);
@@ -296,26 +296,26 @@ export class ModelSelectorComponent extends Container implements Focusable {
return; return;
} }
// Up arrow - wrap to bottom when at top // 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; if (this.filteredModels.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1; this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
this.updateList(); this.updateList();
} }
// Down arrow - wrap to top when at bottom // 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; if (this.filteredModels.length === 0) return;
this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1; this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
this.updateList(); this.updateList();
} }
// Enter // Enter
else if (kb.matches(keyData, "selectConfirm")) { else if (kb.matches(keyData, "tui.select.confirm")) {
const selectedModel = this.filteredModels[this.selectedIndex]; const selectedModel = this.filteredModels[this.selectedIndex];
if (selectedModel) { if (selectedModel) {
this.handleSelect(selectedModel.model); this.handleSelect(selectedModel.model);
} }
} }
// Escape or Ctrl+C // Escape or Ctrl+C
else if (kb.matches(keyData, "selectCancel")) { else if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback(); this.onCancelCallback();
} }
// Pass everything else to search input // Pass everything else to search input

View File

@@ -1,6 +1,6 @@
import type { OAuthProviderInterface } from "@mariozechner/pi-ai"; import type { OAuthProviderInterface } from "@mariozechner/pi-ai";
import { getOAuthProviders } from "@mariozechner/pi-ai/oauth"; 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 type { AuthStorage } from "../../../core/auth-storage.js";
import { theme } from "../theme/theme.js"; import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
@@ -95,26 +95,26 @@ export class OAuthSelectorComponent extends Container {
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
// Up arrow // Up arrow
if (kb.matches(keyData, "selectUp")) { if (kb.matches(keyData, "tui.select.up")) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.updateList(); this.updateList();
} }
// Down arrow // 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.selectedIndex = Math.min(this.allProviders.length - 1, this.selectedIndex + 1);
this.updateList(); this.updateList();
} }
// Enter // Enter
else if (kb.matches(keyData, "selectConfirm")) { else if (kb.matches(keyData, "tui.select.confirm")) {
const selectedProvider = this.allProviders[this.selectedIndex]; const selectedProvider = this.allProviders[this.selectedIndex];
if (selectedProvider) { if (selectedProvider) {
this.onSelectCallback(selectedProvider.id); this.onSelectCallback(selectedProvider.id);
} }
} }
// Escape or Ctrl+C // Escape or Ctrl+C
else if (kb.matches(keyData, "selectCancel")) { else if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancelCallback(); this.onCancelCallback();
} }
} }

View File

@@ -3,7 +3,7 @@ import {
Container, Container,
type Focusable, type Focusable,
fuzzyFilter, fuzzyFilter,
getEditorKeybindings, getKeybindings,
Input, Input,
Key, Key,
matchesKey, matchesKey,
@@ -224,16 +224,16 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
} }
handleInput(data: string): void { handleInput(data: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
// Navigation // Navigation
if (kb.matches(data, "selectUp")) { if (kb.matches(data, "tui.select.up")) {
if (this.filteredItems.length === 0) return; if (this.filteredItems.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1; this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
this.updateList(); this.updateList();
return; return;
} }
if (kb.matches(data, "selectDown")) { if (kb.matches(data, "tui.select.down")) {
if (this.filteredItems.length === 0) return; if (this.filteredItems.length === 0) return;
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1; this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
this.updateList(); this.updateList();

View File

@@ -6,9 +6,8 @@ import {
type Component, type Component,
Container, Container,
type Focusable, type Focusable,
getEditorKeybindings, getKeybindings,
Input, Input,
matchesKey,
Spacer, Spacer,
Text, Text,
truncateToWidth, truncateToWidth,
@@ -18,7 +17,7 @@ import { KeybindingsManager } from "../../../core/keybindings.js";
import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.js"; import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.js";
import { theme } from "../theme/theme.js"; import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.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"; import { filterAndSortSessions, hasSessionName, type NameFilter, type SortMode } from "./session-selector-search.js";
type SessionScope = "current" | "all"; type SessionScope = "current" | "all";
@@ -52,7 +51,6 @@ class SessionSelectorHeader implements Component {
private scope: SessionScope; private scope: SessionScope;
private sortMode: SortMode; private sortMode: SortMode;
private nameFilter: NameFilter; private nameFilter: NameFilter;
private keybindings: KeybindingsManager;
private requestRender: () => void; private requestRender: () => void;
private loading = false; private loading = false;
private loadProgress: { loaded: number; total: number } | null = null; private loadProgress: { loaded: number; total: number } | null = null;
@@ -62,17 +60,10 @@ class SessionSelectorHeader implements Component {
private statusTimeout: ReturnType<typeof setTimeout> | null = null; private statusTimeout: ReturnType<typeof setTimeout> | null = null;
private showRenameHint = false; private showRenameHint = false;
constructor( constructor(scope: SessionScope, sortMode: SortMode, nameFilter: NameFilter, requestRender: () => void) {
scope: SessionScope,
sortMode: SortMode,
nameFilter: NameFilter,
keybindings: KeybindingsManager,
requestRender: () => void,
) {
this.scope = scope; this.scope = scope;
this.sortMode = sortMode; this.sortMode = sortMode;
this.nameFilter = nameFilter; this.nameFilter = nameFilter;
this.keybindings = keybindings;
this.requestRender = requestRender; this.requestRender = requestRender;
} }
@@ -159,7 +150,7 @@ class SessionSelectorHeader implements Component {
let hintLine1: string; let hintLine1: string;
let hintLine2: string; let hintLine2: string;
if (this.confirmingDeletePath !== null) { 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, "…")); hintLine1 = theme.fg("error", truncateToWidth(confirmHint, width, "…"));
hintLine2 = ""; hintLine2 = "";
} else if (this.statusMessage) { } else if (this.statusMessage) {
@@ -169,15 +160,16 @@ class SessionSelectorHeader implements Component {
} else { } else {
const pathState = this.showPath ? "(on)" : "(off)"; const pathState = this.showPath ? "(on)" : "(off)";
const sep = theme.fg("muted", " · "); const sep = theme.fg("muted", " · ");
const hint1 = keyHint("tab", "scope") + sep + theme.fg("muted", 're:<pattern> regex · "phrase" exact'); const hint1 =
keyHint("tui.input.tab", "scope") + sep + theme.fg("muted", 're:<pattern> regex · "phrase" exact');
const hint2Parts = [ const hint2Parts = [
keyHint("toggleSessionSort", "sort"), keyHint("app.session.toggleSort", "sort"),
appKeyHint(this.keybindings, "toggleSessionNamedFilter", "named"), keyHint("app.session.toggleNamedFilter", "named"),
keyHint("deleteSession", "delete"), keyHint("app.session.delete", "delete"),
keyHint("toggleSessionPath", `path ${pathState}`), keyHint("app.session.togglePath", `path ${pathState}`),
]; ];
if (this.showRenameHint) { if (this.showRenameHint) {
hint2Parts.push(keyHint("renameSession", "rename")); hint2Parts.push(keyHint("app.session.rename", "rename"));
} }
const hint2 = hint2Parts.join(sep); const hint2 = hint2Parts.join(sep);
hintLine1 = truncateToWidth(hint1, width, "…"); hintLine1 = truncateToWidth(hint1, width, "…");
@@ -402,7 +394,7 @@ class SessionList implements Component, Focusable {
if (this.filteredSessions.length === 0) { if (this.filteredSessions.length === 0) {
let emptyMessage: string; let emptyMessage: string;
if (this.nameFilter === "named") { if (this.nameFilter === "named") {
const toggleKey = appKey(this.keybindings, "toggleSessionNamedFilter"); const toggleKey = keyText("app.session.toggleNamedFilter");
if (this.showCwd) { if (this.showCwd) {
emptyMessage = ` No named sessions found. Press ${toggleKey} to show all.`; emptyMessage = ` No named sessions found. Press ${toggleKey} to show all.`;
} else { } else {
@@ -511,18 +503,17 @@ class SessionList implements Component, Focusable {
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
// Handle delete confirmation state first - intercept all keys // Handle delete confirmation state first - intercept all keys
if (this.confirmingDeletePath !== null) { if (this.confirmingDeletePath !== null) {
if (kb.matches(keyData, "selectConfirm")) { if (kb.matches(keyData, "tui.select.confirm")) {
const pathToDelete = this.confirmingDeletePath; const pathToDelete = this.confirmingDeletePath;
this.setConfirmingDeletePath(null); this.setConfirmingDeletePath(null);
void this.onDeleteSession?.(pathToDelete); void this.onDeleteSession?.(pathToDelete);
return; return;
} }
// Allow both Escape and Ctrl+C to cancel (consistent with pi UX) if (kb.matches(keyData, "tui.select.cancel")) {
if (kb.matches(keyData, "selectCancel") || matchesKey(keyData, "ctrl+c")) {
this.setConfirmingDeletePath(null); this.setConfirmingDeletePath(null);
return; return;
} }
@@ -530,38 +521,38 @@ class SessionList implements Component, Focusable {
return; return;
} }
if (kb.matches(keyData, "tab")) { if (kb.matches(keyData, "tui.input.tab")) {
if (this.onToggleScope) { if (this.onToggleScope) {
this.onToggleScope(); this.onToggleScope();
} }
return; return;
} }
if (kb.matches(keyData, "toggleSessionSort")) { if (kb.matches(keyData, "app.session.toggleSort")) {
this.onToggleSort?.(); this.onToggleSort?.();
return; return;
} }
if (this.keybindings.matches(keyData, "toggleSessionNamedFilter")) { if (this.keybindings.matches(keyData, "app.session.toggleNamedFilter")) {
this.onToggleNameFilter?.(); this.onToggleNameFilter?.();
return; return;
} }
// Ctrl+P: toggle path display // Ctrl+P: toggle path display
if (kb.matches(keyData, "toggleSessionPath")) { if (kb.matches(keyData, "app.session.togglePath")) {
this.showPath = !this.showPath; this.showPath = !this.showPath;
this.onTogglePath?.(this.showPath); this.onTogglePath?.(this.showPath);
return; return;
} }
// Ctrl+D: initiate delete confirmation (useful on terminals that don't distinguish Ctrl+Backspace from Backspace) // 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(); this.startDeleteConfirmationForSelectedSession();
return; return;
} }
// Ctrl+R: rename selected session // Rename selected session
if (matchesKey(keyData, "ctrl+r")) { if (kb.matches(keyData, "app.session.rename")) {
const selected = this.filteredSessions[this.selectedIndex]; const selected = this.filteredSessions[this.selectedIndex];
if (selected) { if (selected) {
this.onRenameSession?.(selected.session.path); this.onRenameSession?.(selected.session.path);
@@ -571,7 +562,7 @@ class SessionList implements Component, Focusable {
// Ctrl+Backspace: non-invasive convenience alias for delete // Ctrl+Backspace: non-invasive convenience alias for delete
// Only triggers deletion when the query is empty; otherwise it is forwarded to the input // 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) { if (this.searchInput.getValue().length > 0) {
this.searchInput.handleInput(keyData); this.searchInput.handleInput(keyData);
this.filterSessions(this.searchInput.getValue()); this.filterSessions(this.searchInput.getValue());
@@ -583,30 +574,30 @@ class SessionList implements Component, Focusable {
} }
// Up arrow // Up arrow
if (kb.matches(keyData, "selectUp")) { if (kb.matches(keyData, "tui.select.up")) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.selectedIndex = Math.max(0, this.selectedIndex - 1);
} }
// Down arrow // 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); this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1);
} }
// Page up - jump up by maxVisible items // 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); this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible);
} }
// Page down - jump down by maxVisible items // 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); this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + this.maxVisible);
} }
// Enter // Enter
else if (kb.matches(keyData, "selectConfirm")) { else if (kb.matches(keyData, "tui.select.confirm")) {
const selected = this.filteredSessions[this.selectedIndex]; const selected = this.filteredSessions[this.selectedIndex];
if (selected && this.onSelect) { if (selected && this.onSelect) {
this.onSelect(selected.session.path); this.onSelect(selected.session.path);
} }
} }
// Escape - cancel // Escape - cancel
else if (kb.matches(keyData, "selectCancel")) { else if (kb.matches(keyData, "tui.select.cancel")) {
if (this.onCancel) { if (this.onCancel) {
this.onCancel(); this.onCancel();
} }
@@ -667,8 +658,8 @@ async function deleteSessionFile(
export class SessionSelectorComponent extends Container implements Focusable { export class SessionSelectorComponent extends Container implements Focusable {
handleInput(data: string): void { handleInput(data: string): void {
if (this.mode === "rename") { if (this.mode === "rename") {
const kb = getEditorKeybindings(); const kb = getKeybindings();
if (kb.matches(data, "selectCancel") || matchesKey(data, "ctrl+c")) { if (kb.matches(data, "tui.select.cancel")) {
this.exitRenameMode(); this.exitRenameMode();
return; return;
} }
@@ -749,13 +740,7 @@ export class SessionSelectorComponent extends Container implements Focusable {
this.allSessionsLoader = allSessionsLoader; this.allSessionsLoader = allSessionsLoader;
this.onCancel = onCancel; this.onCancel = onCancel;
this.requestRender = requestRender; this.requestRender = requestRender;
this.header = new SessionSelectorHeader( this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.nameFilter, this.requestRender);
this.scope,
this.sortMode,
this.nameFilter,
this.keybindings,
this.requestRender,
);
const renameSession = options?.renameSession; const renameSession = options?.renameSession;
this.renameSession = renameSession; this.renameSession = renameSession;
this.canRename = !!renameSession; this.canRename = !!renameSession;
@@ -864,7 +849,13 @@ export class SessionSelectorComponent extends Container implements Focusable {
panel.addChild(new Spacer(1)); panel.addChild(new Spacer(1));
panel.addChild(this.renameInput); panel.addChild(this.renameInput);
panel.addChild(new Spacer(1)); 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.buildBaseLayout(panel, { showHeader: false });
this.requestRender(); this.requestRender();

View File

@@ -1,7 +1,7 @@
import { Box, Markdown, type MarkdownTheme, Text } from "@mariozechner/pi-tui"; import { Box, Markdown, type MarkdownTheme, Text } from "@mariozechner/pi-tui";
import type { ParsedSkillBlock } from "../../../core/agent-session.js"; import type { ParsedSkillBlock } from "../../../core/agent-session.js";
import { getMarkdownTheme, theme } from "../theme/theme.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. * Component that renders a skill invocation message with collapsed/expanded state.
@@ -48,7 +48,7 @@ export class SkillInvocationMessageComponent extends Box {
const line = const line =
theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) +
theme.fg("customMessageText", this.skillBlock.name) + 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)); this.addChild(new Text(line, 0, 0));
} }
} }

View File

@@ -588,7 +588,7 @@ export class ToolExecutionComponent extends Container {
if (cachedSkipped && cachedSkipped > 0) { if (cachedSkipped && cachedSkipped > 0) {
const hint = const hint =
theme.fg("muted", `... (${cachedSkipped} earlier lines,`) + theme.fg("muted", `... (${cachedSkipped} earlier lines,`) +
` ${keyHint("expandTools", "to expand")})`; ` ${keyHint("app.tools.expand", "to expand")})`;
return ["", truncateToWidth(hint, width, "..."), ...cachedLines]; return ["", truncateToWidth(hint, width, "..."), ...cachedLines];
} }
// Add blank line for spacing (matches expanded case) // 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)))) .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
.join("\n"); .join("\n");
if (remaining > 0) { 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; const truncation = this.result.details?.truncation;
@@ -772,7 +772,7 @@ export class ToolExecutionComponent extends Container {
if (remaining > 0) { if (remaining > 0) {
text += text +=
theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`) + 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")}`; text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) { 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")}`; text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) { 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")}`; text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) { 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")})`;
} }
} }

View File

@@ -2,7 +2,7 @@ import {
type Component, type Component,
Container, Container,
type Focusable, type Focusable,
getEditorKeybindings, getKeybindings,
Input, Input,
matchesKey, matchesKey,
Spacer, Spacer,
@@ -860,12 +860,12 @@ class TreeList implements Component {
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
if (kb.matches(keyData, "selectUp")) { if (kb.matches(keyData, "tui.select.up")) {
this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; 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; 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; const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
if (currentId && this.isFoldable(currentId) && !this.foldedNodes.has(currentId)) { if (currentId && this.isFoldable(currentId) && !this.foldedNodes.has(currentId)) {
this.foldedNodes.add(currentId); this.foldedNodes.add(currentId);
@@ -873,7 +873,7 @@ class TreeList implements Component {
} else { } else {
this.selectedIndex = this.findBranchSegmentStart("up"); 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; const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
if (currentId && this.foldedNodes.has(currentId)) { if (currentId && this.foldedNodes.has(currentId)) {
this.foldedNodes.delete(currentId); this.foldedNodes.delete(currentId);
@@ -881,18 +881,18 @@ class TreeList implements Component {
} else { } else {
this.selectedIndex = this.findBranchSegmentStart("down"); 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 // Page up
this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines); 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 // Page down
this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines); 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]; const selected = this.filteredNodes[this.selectedIndex];
if (selected && this.onSelect) { if (selected && this.onSelect) {
this.onSelect(selected.node.entry.id); this.onSelect(selected.node.entry.id);
} }
} else if (kb.matches(keyData, "selectCancel")) { } else if (kb.matches(keyData, "tui.select.cancel")) {
if (this.searchQuery) { if (this.searchQuery) {
this.searchQuery = ""; this.searchQuery = "";
this.foldedNodes.clear(); this.foldedNodes.clear();
@@ -939,7 +939,7 @@ class TreeList implements Component {
this.filterMode = modes[(currentIndex + 1) % modes.length]; this.filterMode = modes[(currentIndex + 1) % modes.length];
this.foldedNodes.clear(); this.foldedNodes.clear();
this.applyFilter(); this.applyFilter();
} else if (kb.matches(keyData, "deleteCharBackward")) { } else if (kb.matches(keyData, "tui.editor.deleteCharBackward")) {
if (this.searchQuery.length > 0) { if (this.searchQuery.length > 0) {
this.searchQuery = this.searchQuery.slice(0, -1); this.searchQuery = this.searchQuery.slice(0, -1);
this.foldedNodes.clear(); 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(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width));
lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width))); lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width)));
lines.push( 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; return lines;
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
if (kb.matches(keyData, "selectConfirm")) { if (kb.matches(keyData, "tui.select.confirm")) {
const value = this.input.getValue().trim(); const value = this.input.getValue().trim();
this.onSubmit?.(this.entryId, value || undefined); this.onSubmit?.(this.entryId, value || undefined);
} else if (kb.matches(keyData, "selectCancel")) { } else if (kb.matches(keyData, "tui.select.cancel")) {
this.onCancel?.(); this.onCancel?.();
} else { } else {
this.input.handleInput(keyData); this.input.handleInput(keyData);

View File

@@ -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 { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
@@ -78,24 +78,24 @@ class UserMessageList implements Component {
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
// Up arrow - go to previous (older) message, wrap to bottom when at top // 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; 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 // 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; this.selectedIndex = this.selectedIndex === this.messages.length - 1 ? 0 : this.selectedIndex + 1;
} }
// Enter - select message and branch // 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]; const selected = this.messages[this.selectedIndex];
if (selected && this.onSelect) { if (selected && this.onSelect) {
this.onSelect(selected.id); this.onSelect(selected.id);
} }
} }
// Escape - cancel // Escape - cancel
else if (kb.matches(keyData, "selectCancel")) { else if (kb.matches(keyData, "tui.select.cancel")) {
if (this.onCancel) { if (this.onCancel) {
this.onCancel(); this.onCancel();
} }

View File

@@ -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 { AssistantMessage, ImageContent, Message, Model, OAuthProviderId } from "@mariozechner/pi-ai";
import type { import type {
AutocompleteItem, AutocompleteItem,
EditorAction,
EditorComponent, EditorComponent,
EditorTheme, EditorTheme,
Keybinding,
KeyId, KeyId,
MarkdownTheme, MarkdownTheme,
OverlayHandle, OverlayHandle,
@@ -30,6 +30,7 @@ import {
matchesKey, matchesKey,
ProcessTerminal, ProcessTerminal,
Spacer, Spacer,
setKeybindings,
Text, Text,
TruncatedText, TruncatedText,
TUI, TUI,
@@ -55,7 +56,7 @@ import type {
ExtensionWidgetOptions, ExtensionWidgetOptions,
} from "../../core/extensions/index.js"; } from "../../core/extensions/index.js";
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.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 { createCompactionSummaryMessage } from "../../core/messages.js";
import { findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js"; import { findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.js";
import { DefaultPackageManager } from "../../core/package-manager.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 { ExtensionInputComponent } from "./components/extension-input.js";
import { ExtensionSelectorComponent } from "./components/extension-selector.js"; import { ExtensionSelectorComponent } from "./components/extension-selector.js";
import { FooterComponent } from "./components/footer.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 { LoginDialogComponent } from "./components/login-dialog.js";
import { ModelSelectorComponent } from "./components/model-selector.js"; import { ModelSelectorComponent } from "./components/model-selector.js";
import { OAuthSelectorComponent } from "./components/oauth-selector.js"; import { OAuthSelectorComponent } from "./components/oauth-selector.js";
@@ -154,6 +155,7 @@ export class InteractiveMode {
private editorContainer: Container; private editorContainer: Container;
private footer: FooterComponent; private footer: FooterComponent;
private footerDataProvider: FooterDataProvider; private footerDataProvider: FooterDataProvider;
// Stored so the same manager can be injected into custom editors, selectors, and extension UI.
private keybindings: KeybindingsManager; private keybindings: KeybindingsManager;
private version: string; private version: string;
private isInitialized = false; private isInitialized = false;
@@ -262,6 +264,7 @@ export class InteractiveMode {
this.widgetContainerAbove = new Container(); this.widgetContainerAbove = new Container();
this.widgetContainerBelow = new Container(); this.widgetContainerBelow = new Container();
this.keybindings = KeybindingsManager.create(); this.keybindings = KeybindingsManager.create();
setKeybindings(this.keybindings);
const editorPaddingX = this.settingsManager.getEditorPaddingX(); const editorPaddingX = this.settingsManager.getEditorPaddingX();
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, { 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}`); const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
// Build startup instructions using keybinding hint helpers // Build startup instructions using keybinding hint helpers
const kb = this.keybindings; const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description);
const hint = (action: AppAction, desc: string) => appKeyHint(kb, action, desc);
const instructions = [ const instructions = [
hint("interrupt", "to interrupt"), hint("app.interrupt", "to interrupt"),
hint("clear", "to clear"), hint("app.clear", "to clear"),
rawKeyHint(`${appKey(kb, "clear")} twice`, "to exit"), rawKeyHint(`${keyText("app.clear")} twice`, "to exit"),
hint("exit", "to exit (empty)"), hint("app.exit", "to exit (empty)"),
hint("suspend", "to suspend"), hint("app.suspend", "to suspend"),
keyHint("deleteToLineEnd", "to delete to end"), keyHint("tui.editor.deleteToLineEnd", "to delete to end"),
hint("cycleThinkingLevel", "to cycle thinking level"), hint("app.thinking.cycle", "to cycle thinking level"),
rawKeyHint(`${appKey(kb, "cycleModelForward")}/${appKey(kb, "cycleModelBackward")}`, "to cycle models"), rawKeyHint(`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`, "to cycle models"),
hint("selectModel", "to select model"), hint("app.model.select", "to select model"),
hint("expandTools", "to expand tools"), hint("app.tools.expand", "to expand tools"),
hint("toggleThinking", "to expand thinking"), hint("app.thinking.toggle", "to expand thinking"),
hint("externalEditor", "for external editor"), hint("app.editor.external", "for external editor"),
rawKeyHint("/", "for commands"), rawKeyHint("/", "for commands"),
rawKeyHint("!", "to run bash"), rawKeyHint("!", "to run bash"),
rawKeyHint("!!", "to run bash (no context)"), rawKeyHint("!!", "to run bash (no context)"),
hint("followUp", "to queue follow-up"), hint("app.message.followUp", "to queue follow-up"),
hint("dequeue", "to edit all queued messages"), hint("app.message.dequeue", "to edit all queued messages"),
hint("pasteImage", "to paste image"), hint("app.clipboard.pasteImage", "to paste image"),
rawKeyHint("drop files", "to attach"), rawKeyHint("drop files", "to attach"),
].join("\n"); ].join("\n");
this.builtInHeader = new Text(`${logo}\n${instructions}`, 1, 0); this.builtInHeader = new Text(`${logo}\n${instructions}`, 1, 0);
@@ -1324,9 +1326,7 @@ export class InteractiveMode {
this.defaultEditor.onExtensionShortcut = undefined; this.defaultEditor.onExtensionShortcut = undefined;
this.updateTerminalTitle(); this.updateTerminalTitle();
if (this.loadingAnimation) { if (this.loadingAnimation) {
this.loadingAnimation.setMessage( this.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`);
`${this.defaultWorkingMessage} (${appKey(this.keybindings, "interrupt")} to interrupt)`,
);
} }
} }
@@ -1472,7 +1472,7 @@ export class InteractiveMode {
this.loadingAnimation.setMessage(message); this.loadingAnimation.setMessage(message);
} else { } else {
this.loadingAnimation.setMessage( this.loadingAnimation.setMessage(
`${this.defaultWorkingMessage} (${appKey(this.keybindings, "interrupt")} to interrupt)`, `${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`,
); );
} }
} else { } else {
@@ -1894,25 +1894,25 @@ export class InteractiveMode {
}; };
// Register app action handlers // Register app action handlers
this.defaultEditor.onAction("clear", () => this.handleCtrlC()); this.defaultEditor.onAction("app.clear", () => this.handleCtrlC());
this.defaultEditor.onCtrlD = () => this.handleCtrlD(); this.defaultEditor.onCtrlD = () => this.handleCtrlD();
this.defaultEditor.onAction("suspend", () => this.handleCtrlZ()); this.defaultEditor.onAction("app.suspend", () => this.handleCtrlZ());
this.defaultEditor.onAction("cycleThinkingLevel", () => this.cycleThinkingLevel()); this.defaultEditor.onAction("app.thinking.cycle", () => this.cycleThinkingLevel());
this.defaultEditor.onAction("cycleModelForward", () => this.cycleModel("forward")); this.defaultEditor.onAction("app.model.cycleForward", () => this.cycleModel("forward"));
this.defaultEditor.onAction("cycleModelBackward", () => this.cycleModel("backward")); this.defaultEditor.onAction("app.model.cycleBackward", () => this.cycleModel("backward"));
// Global debug handler on TUI (works regardless of focus) // Global debug handler on TUI (works regardless of focus)
this.ui.onDebug = () => this.handleDebugCommand(); this.ui.onDebug = () => this.handleDebugCommand();
this.defaultEditor.onAction("selectModel", () => this.showModelSelector()); this.defaultEditor.onAction("app.model.select", () => this.showModelSelector());
this.defaultEditor.onAction("expandTools", () => this.toggleToolOutputExpansion()); this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion());
this.defaultEditor.onAction("toggleThinking", () => this.toggleThinkingBlockVisibility()); this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
this.defaultEditor.onAction("externalEditor", () => this.openExternalEditor()); this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor());
this.defaultEditor.onAction("followUp", () => this.handleFollowUp()); this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp());
this.defaultEditor.onAction("dequeue", () => this.handleDequeue()); this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue());
this.defaultEditor.onAction("newSession", () => this.handleClearCommand()); this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand());
this.defaultEditor.onAction("tree", () => this.showTreeSelector()); this.defaultEditor.onAction("app.session.tree", () => this.showTreeSelector());
this.defaultEditor.onAction("fork", () => this.showUserMessageSelector()); this.defaultEditor.onAction("app.session.fork", () => this.showUserMessageSelector());
this.defaultEditor.onAction("resume", () => this.showSessionSelector()); this.defaultEditor.onAction("app.session.resume", () => this.showSessionSelector());
this.defaultEditor.onChange = (text: string) => { this.defaultEditor.onChange = (text: string) => {
const wasBashMode = this.isBashMode; const wasBashMode = this.isBashMode;
@@ -2331,7 +2331,7 @@ export class InteractiveMode {
this.ui, this.ui,
(spinner) => theme.fg("accent", spinner), (spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text), (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.statusContainer.addChild(this.autoCompactionLoader);
this.ui.requestRender(); this.ui.requestRender();
@@ -2388,7 +2388,7 @@ export class InteractiveMode {
this.ui, this.ui,
(spinner) => theme.fg("warning", spinner), (spinner) => theme.fg("warning", spinner),
(text) => theme.fg("muted", text), (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.statusContainer.addChild(this.retryLoader);
this.ui.requestRender(); this.ui.requestRender();
@@ -2988,7 +2988,7 @@ export class InteractiveMode {
const text = theme.fg("dim", `Follow-up: ${message}`); const text = theme.fg("dim", `Follow-up: ${message}`);
this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); 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`); const hintText = theme.fg("dim", `${dequeueHint} to edit all queued messages`);
this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0)); this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
} }
@@ -3580,7 +3580,7 @@ export class InteractiveMode {
this.ui, this.ui,
(spinner) => theme.fg("accent", spinner), (spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text), (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.statusContainer.addChild(summaryLoader);
this.ui.requestRender(); this.ui.requestRender();
@@ -4178,59 +4178,59 @@ export class InteractiveMode {
/** /**
* Get capitalized display string for an app keybinding action. * Get capitalized display string for an app keybinding action.
*/ */
private getAppKeyDisplay(action: AppAction): string { private getAppKeyDisplay(action: AppKeybinding): string {
return this.capitalizeKey(appKey(this.keybindings, action)); return this.capitalizeKey(keyText(action));
} }
/** /**
* Get capitalized display string for an editor keybinding action. * Get capitalized display string for an editor keybinding action.
*/ */
private getEditorKeyDisplay(action: EditorAction): string { private getEditorKeyDisplay(action: Keybinding): string {
return this.capitalizeKey(editorKey(action)); return this.capitalizeKey(keyText(action));
} }
private handleHotkeysCommand(): void { private handleHotkeysCommand(): void {
// Navigation keybindings // Navigation keybindings
const cursorUp = this.getEditorKeyDisplay("cursorUp"); const cursorUp = this.getEditorKeyDisplay("tui.editor.cursorUp");
const cursorDown = this.getEditorKeyDisplay("cursorDown"); const cursorDown = this.getEditorKeyDisplay("tui.editor.cursorDown");
const cursorLeft = this.getEditorKeyDisplay("cursorLeft"); const cursorLeft = this.getEditorKeyDisplay("tui.editor.cursorLeft");
const cursorRight = this.getEditorKeyDisplay("cursorRight"); const cursorRight = this.getEditorKeyDisplay("tui.editor.cursorRight");
const cursorWordLeft = this.getEditorKeyDisplay("cursorWordLeft"); const cursorWordLeft = this.getEditorKeyDisplay("tui.editor.cursorWordLeft");
const cursorWordRight = this.getEditorKeyDisplay("cursorWordRight"); const cursorWordRight = this.getEditorKeyDisplay("tui.editor.cursorWordRight");
const cursorLineStart = this.getEditorKeyDisplay("cursorLineStart"); const cursorLineStart = this.getEditorKeyDisplay("tui.editor.cursorLineStart");
const cursorLineEnd = this.getEditorKeyDisplay("cursorLineEnd"); const cursorLineEnd = this.getEditorKeyDisplay("tui.editor.cursorLineEnd");
const jumpForward = this.getEditorKeyDisplay("jumpForward"); const jumpForward = this.getEditorKeyDisplay("tui.editor.jumpForward");
const jumpBackward = this.getEditorKeyDisplay("jumpBackward"); const jumpBackward = this.getEditorKeyDisplay("tui.editor.jumpBackward");
const pageUp = this.getEditorKeyDisplay("pageUp"); const pageUp = this.getEditorKeyDisplay("tui.editor.pageUp");
const pageDown = this.getEditorKeyDisplay("pageDown"); const pageDown = this.getEditorKeyDisplay("tui.editor.pageDown");
// Editing keybindings // Editing keybindings
const submit = this.getEditorKeyDisplay("submit"); const submit = this.getEditorKeyDisplay("tui.input.submit");
const newLine = this.getEditorKeyDisplay("newLine"); const newLine = this.getEditorKeyDisplay("tui.input.newLine");
const deleteWordBackward = this.getEditorKeyDisplay("deleteWordBackward"); const deleteWordBackward = this.getEditorKeyDisplay("tui.editor.deleteWordBackward");
const deleteWordForward = this.getEditorKeyDisplay("deleteWordForward"); const deleteWordForward = this.getEditorKeyDisplay("tui.editor.deleteWordForward");
const deleteToLineStart = this.getEditorKeyDisplay("deleteToLineStart"); const deleteToLineStart = this.getEditorKeyDisplay("tui.editor.deleteToLineStart");
const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd"); const deleteToLineEnd = this.getEditorKeyDisplay("tui.editor.deleteToLineEnd");
const yank = this.getEditorKeyDisplay("yank"); const yank = this.getEditorKeyDisplay("tui.editor.yank");
const yankPop = this.getEditorKeyDisplay("yankPop"); const yankPop = this.getEditorKeyDisplay("tui.editor.yankPop");
const undo = this.getEditorKeyDisplay("undo"); const undo = this.getEditorKeyDisplay("tui.editor.undo");
const tab = this.getEditorKeyDisplay("tab"); const tab = this.getEditorKeyDisplay("tui.input.tab");
// App keybindings // App keybindings
const interrupt = this.getAppKeyDisplay("interrupt"); const interrupt = this.getAppKeyDisplay("app.interrupt");
const clear = this.getAppKeyDisplay("clear"); const clear = this.getAppKeyDisplay("app.clear");
const exit = this.getAppKeyDisplay("exit"); const exit = this.getAppKeyDisplay("app.exit");
const suspend = this.getAppKeyDisplay("suspend"); const suspend = this.getAppKeyDisplay("app.suspend");
const cycleThinkingLevel = this.getAppKeyDisplay("cycleThinkingLevel"); const cycleThinkingLevel = this.getAppKeyDisplay("app.thinking.cycle");
const cycleModelForward = this.getAppKeyDisplay("cycleModelForward"); const cycleModelForward = this.getAppKeyDisplay("app.model.cycleForward");
const selectModel = this.getAppKeyDisplay("selectModel"); const selectModel = this.getAppKeyDisplay("app.model.select");
const expandTools = this.getAppKeyDisplay("expandTools"); const expandTools = this.getAppKeyDisplay("app.tools.expand");
const toggleThinking = this.getAppKeyDisplay("toggleThinking"); const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle");
const externalEditor = this.getAppKeyDisplay("externalEditor"); const externalEditor = this.getAppKeyDisplay("app.editor.external");
const cycleModelBackward = this.getAppKeyDisplay("cycleModelBackward"); const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward");
const followUp = this.getAppKeyDisplay("followUp"); const followUp = this.getAppKeyDisplay("app.message.followUp");
const dequeue = this.getAppKeyDisplay("dequeue"); const dequeue = this.getAppKeyDisplay("app.message.dequeue");
const pasteImage = this.getAppKeyDisplay("pasteImage"); const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage");
let hotkeys = ` let hotkeys = `
**Navigation** **Navigation**
@@ -4499,7 +4499,7 @@ export class InteractiveMode {
// Show compacting status // Show compacting status
this.chatContainer.addChild(new Spacer(1)); 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 label = isAuto ? `Auto-compacting context... ${cancelHint}` : `Compacting context... ${cancelHint}`;
const compactingLoader = new Loader( const compactingLoader = new Loader(
this.ui, this.ui,

View File

@@ -10,7 +10,7 @@ import { AuthStorage } from "../src/core/auth-storage.js";
import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.js"; import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.js";
import { ExtensionRunner } from "../src/core/extensions/runner.js"; import { ExtensionRunner } from "../src/core/extensions/runner.js";
import type { ExtensionActions, ExtensionContextActions, ProviderConfig } from "../src/core/extensions/types.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 { ModelRegistry } from "../src/core/model-registry.js";
import { SessionManager } from "../src/core/session-manager.js"; import { SessionManager } from "../src/core/session-manager.js";
@@ -19,6 +19,7 @@ describe("ExtensionRunner", () => {
let extensionsDir: string; let extensionsDir: string;
let sessionManager: SessionManager; let sessionManager: SessionManager;
let modelRegistry: ModelRegistry; let modelRegistry: ModelRegistry;
const defaultKeybindings = new KeybindingsManager().getEffectiveConfig();
beforeEach(() => { beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-runner-test-")); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-runner-test-"));
@@ -94,7 +95,7 @@ describe("ExtensionRunner", () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir); const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); 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(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"));
expect(shortcuts.has("ctrl+c")).toBe(false); expect(shortcuts.has("ctrl+c")).toBe(false);
@@ -117,7 +118,7 @@ describe("ExtensionRunner", () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir); const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); 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); const shortcuts = runner.getShortcuts(keybindings);
expect(shortcuts.has("ctrl+p")).toBe(true); 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 () => { it("warns but allows when extension uses non-reserved built-in shortcut", async () => {
const pasteImageKey = Array.isArray(DEFAULT_KEYBINDINGS.pasteImage) const pasteImageKey = Array.isArray(defaultKeybindings["app.clipboard.pasteImage"])
? (DEFAULT_KEYBINDINGS.pasteImage[0] ?? "") ? (defaultKeybindings["app.clipboard.pasteImage"][0] ?? "")
: DEFAULT_KEYBINDINGS.pasteImage; : defaultKeybindings["app.clipboard.pasteImage"];
const extCode = ` const extCode = `
export default function(pi) { export default function(pi) {
pi.registerShortcut("${pasteImageKey}", { pi.registerShortcut("${pasteImageKey}", {
@@ -144,9 +145,11 @@ describe("ExtensionRunner", () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir); const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); 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); expect(shortcuts.has(pasteImageKey as KeyId)).toBe(true);
warnSpy.mockRestore(); warnSpy.mockRestore();
@@ -167,7 +170,7 @@ describe("ExtensionRunner", () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir); const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); 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); const shortcuts = runner.getShortcuts(keybindings);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in")); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"));
@@ -191,7 +194,7 @@ describe("ExtensionRunner", () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir); const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); 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); const shortcuts = runner.getShortcuts(keybindings);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in")); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"));
@@ -215,10 +218,12 @@ describe("ExtensionRunner", () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir); const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); 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); 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); expect(shortcuts.has("ctrl+y")).toBe(true);
warnSpy.mockRestore(); warnSpy.mockRestore();
@@ -249,7 +254,7 @@ describe("ExtensionRunner", () => {
const result = await discoverAndLoadExtensions([], tempDir, tempDir); const result = await discoverAndLoadExtensions([], tempDir, tempDir);
const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); 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")); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("shortcut conflict"));
// Last one wins // Last one wins

View File

@@ -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, unknown>): 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");
});
});

View File

@@ -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 { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { KeybindingsManager } from "../src/core/keybindings.js"; import { KeybindingsManager } from "../src/core/keybindings.js";
import type { SessionInfo } from "../src/core/session-manager.js"; import type { SessionInfo } from "../src/core/session-manager.js";
@@ -45,11 +45,11 @@ const CTRL_D = "\x04";
const CTRL_BACKSPACE = "\x1b[127;5u"; const CTRL_BACKSPACE = "\x1b[127;5u";
describe("session selector path/delete interactions", () => { describe("session selector path/delete interactions", () => {
const keybindings = KeybindingsManager.inMemory(); const keybindings = new KeybindingsManager();
beforeEach(() => { beforeEach(() => {
// Ensure test isolation: editor keybindings are a global singleton // Ensure test isolation: keybindings are a global singleton
setEditorKeybindings(new EditorKeybindingsManager(DEFAULT_EDITOR_KEYBINDINGS)); setKeybindings(new KeybindingsManager());
}); });
beforeAll(() => { beforeAll(() => {

View File

@@ -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 { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../src/core/keybindings.js"; import { KeybindingsManager } from "../src/core/keybindings.js";
import type { SessionInfo } from "../src/core/session-manager.js"; import type { SessionInfo } from "../src/core/session-manager.js";
@@ -34,13 +34,13 @@ describe("session selector rename", () => {
}); });
beforeEach(() => { beforeEach(() => {
// Ensure test isolation: editor keybindings are a global singleton // Ensure test isolation: keybindings are a global singleton
setEditorKeybindings(new EditorKeybindingsManager(DEFAULT_EDITOR_KEYBINDINGS)); setKeybindings(new KeybindingsManager());
}); });
it("shows rename hint in interactive /resume picker configuration", async () => { it("shows rename hint in interactive /resume picker configuration", async () => {
const sessions = [makeSession({ id: "a" })]; const sessions = [makeSession({ id: "a" })];
const keybindings = KeybindingsManager.inMemory(); const keybindings = new KeybindingsManager();
const selector = new SessionSelectorComponent( const selector = new SessionSelectorComponent(
async () => sessions, async () => sessions,
async () => [], async () => [],
@@ -59,7 +59,7 @@ describe("session selector rename", () => {
it("does not show rename hint in --resume picker configuration", async () => { it("does not show rename hint in --resume picker configuration", async () => {
const sessions = [makeSession({ id: "a" })]; const sessions = [makeSession({ id: "a" })];
const keybindings = KeybindingsManager.inMemory(); const keybindings = new KeybindingsManager();
const selector = new SessionSelectorComponent( const selector = new SessionSelectorComponent(
async () => sessions, async () => sessions,
async () => [], async () => [],
@@ -80,7 +80,7 @@ describe("session selector rename", () => {
const sessions = [makeSession({ id: "a", name: "Old" })]; const sessions = [makeSession({ id: "a", name: "Old" })];
const renameSession = vi.fn(async () => {}); const renameSession = vi.fn(async () => {});
const keybindings = KeybindingsManager.inMemory(); const keybindings = new KeybindingsManager();
const selector = new SessionSelectorComponent( const selector = new SessionSelectorComponent(
async () => sessions, async () => sessions,
async () => [], async () => [],

View File

@@ -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 { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { KeybindingsManager } from "../src/core/keybindings.js";
import type { import type {
ModelChangeEntry, ModelChangeEntry,
SessionEntry, SessionEntry,
@@ -14,8 +15,8 @@ beforeAll(() => {
}); });
beforeEach(() => { beforeEach(() => {
// Ensure test isolation: editor keybindings are a global singleton // Ensure test isolation: keybindings are a global singleton
setEditorKeybindings(new EditorKeybindingsManager(DEFAULT_EDITOR_KEYBINDINGS)); setKeybindings(new KeybindingsManager());
}); });
// Helper to create a user message entry // Helper to create a user message entry

View File

@@ -2,6 +2,14 @@
## [Unreleased] ## [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 ## [0.60.0] - 2026-03-18
### Fixed ### Fixed

View File

@@ -1,4 +1,4 @@
import { getEditorKeybindings } from "../keybindings.js"; import { getKeybindings } from "../keybindings.js";
import { Loader } from "./loader.js"; import { Loader } from "./loader.js";
/** /**
@@ -27,8 +27,8 @@ export class CancellableLoader extends Loader {
} }
handleInput(data: string): void { handleInput(data: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
if (kb.matches(data, "selectCancel")) { if (kb.matches(data, "tui.select.cancel")) {
this.abortController.abort(); this.abortController.abort();
this.onAbort?.(); this.onAbort?.();
} }

View File

@@ -1,5 +1,5 @@
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
import { getEditorKeybindings } from "../keybindings.js"; import { getKeybindings } from "../keybindings.js";
import { decodeKittyPrintable, matchesKey } from "../keys.js"; import { decodeKittyPrintable, matchesKey } from "../keys.js";
import { KillRing } from "../kill-ring.js"; import { KillRing } from "../kill-ring.js";
import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.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 { handleInput(data: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
// Handle character jump mode (awaiting next character to jump to) // Handle character jump mode (awaiting next character to jump to)
if (this.jumpMode !== null) { if (this.jumpMode !== null) {
// Cancel if the hotkey is pressed again // 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; this.jumpMode = null;
return; return;
} }
@@ -566,29 +566,29 @@ export class Editor implements Component, Focusable {
} }
// Ctrl+C - let parent handle (exit/clear) // Ctrl+C - let parent handle (exit/clear)
if (kb.matches(data, "copy")) { if (kb.matches(data, "tui.input.copy")) {
return; return;
} }
// Undo // Undo
if (kb.matches(data, "undo")) { if (kb.matches(data, "tui.editor.undo")) {
this.undo(); this.undo();
return; return;
} }
// Handle autocomplete mode // Handle autocomplete mode
if (this.autocompleteState && this.autocompleteList) { if (this.autocompleteState && this.autocompleteList) {
if (kb.matches(data, "selectCancel")) { if (kb.matches(data, "tui.select.cancel")) {
this.cancelAutocomplete(); this.cancelAutocomplete();
return; 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); this.autocompleteList.handleInput(data);
return; return;
} }
if (kb.matches(data, "tab")) { if (kb.matches(data, "tui.input.tab")) {
const selected = this.autocompleteList.getSelectedItem(); const selected = this.autocompleteList.getSelectedItem();
if (selected && this.autocompleteProvider) { if (selected && this.autocompleteProvider) {
const shouldChainSlashArgumentAutocomplete = this.shouldChainSlashArgumentAutocompleteOnTabSelection(); const shouldChainSlashArgumentAutocomplete = this.shouldChainSlashArgumentAutocompleteOnTabSelection();
@@ -615,7 +615,7 @@ export class Editor implements Component, Focusable {
return; return;
} }
if (kb.matches(data, "selectConfirm")) { if (kb.matches(data, "tui.select.confirm")) {
const selected = this.autocompleteList.getSelectedItem(); const selected = this.autocompleteList.getSelectedItem();
if (selected && this.autocompleteProvider) { if (selected && this.autocompleteProvider) {
this.pushUndoSnapshot(); this.pushUndoSnapshot();
@@ -644,68 +644,68 @@ export class Editor implements Component, Focusable {
} }
// Tab - trigger completion // Tab - trigger completion
if (kb.matches(data, "tab") && !this.autocompleteState) { if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) {
this.handleTabCompletion(); this.handleTabCompletion();
return; return;
} }
// Deletion actions // Deletion actions
if (kb.matches(data, "deleteToLineEnd")) { if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
this.deleteToEndOfLine(); this.deleteToEndOfLine();
return; return;
} }
if (kb.matches(data, "deleteToLineStart")) { if (kb.matches(data, "tui.editor.deleteToLineStart")) {
this.deleteToStartOfLine(); this.deleteToStartOfLine();
return; return;
} }
if (kb.matches(data, "deleteWordBackward")) { if (kb.matches(data, "tui.editor.deleteWordBackward")) {
this.deleteWordBackwards(); this.deleteWordBackwards();
return; return;
} }
if (kb.matches(data, "deleteWordForward")) { if (kb.matches(data, "tui.editor.deleteWordForward")) {
this.deleteWordForward(); this.deleteWordForward();
return; return;
} }
if (kb.matches(data, "deleteCharBackward") || matchesKey(data, "shift+backspace")) { if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
this.handleBackspace(); this.handleBackspace();
return; return;
} }
if (kb.matches(data, "deleteCharForward") || matchesKey(data, "shift+delete")) { if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
this.handleForwardDelete(); this.handleForwardDelete();
return; return;
} }
// Kill ring actions // Kill ring actions
if (kb.matches(data, "yank")) { if (kb.matches(data, "tui.editor.yank")) {
this.yank(); this.yank();
return; return;
} }
if (kb.matches(data, "yankPop")) { if (kb.matches(data, "tui.editor.yankPop")) {
this.yankPop(); this.yankPop();
return; return;
} }
// Cursor movement actions // Cursor movement actions
if (kb.matches(data, "cursorLineStart")) { if (kb.matches(data, "tui.editor.cursorLineStart")) {
this.moveToLineStart(); this.moveToLineStart();
return; return;
} }
if (kb.matches(data, "cursorLineEnd")) { if (kb.matches(data, "tui.editor.cursorLineEnd")) {
this.moveToLineEnd(); this.moveToLineEnd();
return; return;
} }
if (kb.matches(data, "cursorWordLeft")) { if (kb.matches(data, "tui.editor.cursorWordLeft")) {
this.moveWordBackwards(); this.moveWordBackwards();
return; return;
} }
if (kb.matches(data, "cursorWordRight")) { if (kb.matches(data, "tui.editor.cursorWordRight")) {
this.moveWordForwards(); this.moveWordForwards();
return; return;
} }
// New line // New line
if ( if (
kb.matches(data, "newLine") || kb.matches(data, "tui.input.newLine") ||
(data.charCodeAt(0) === 10 && data.length > 1) || (data.charCodeAt(0) === 10 && data.length > 1) ||
data === "\x1b\r" || data === "\x1b\r" ||
data === "\x1b[13;2~" || data === "\x1b[13;2~" ||
@@ -722,7 +722,7 @@ export class Editor implements Component, Focusable {
} }
// Submit (Enter) // Submit (Enter)
if (kb.matches(data, "submit")) { if (kb.matches(data, "tui.input.submit")) {
if (this.disableSubmit) return; if (this.disableSubmit) return;
// Workaround for terminals without Shift+Enter support: // Workaround for terminals without Shift+Enter support:
@@ -739,7 +739,7 @@ export class Editor implements Component, Focusable {
} }
// Arrow key navigation (with history support) // Arrow key navigation (with history support)
if (kb.matches(data, "cursorUp")) { if (kb.matches(data, "tui.editor.cursorUp")) {
if (this.isEditorEmpty()) { if (this.isEditorEmpty()) {
this.navigateHistory(-1); this.navigateHistory(-1);
} else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) { } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) {
@@ -752,7 +752,7 @@ export class Editor implements Component, Focusable {
} }
return; return;
} }
if (kb.matches(data, "cursorDown")) { if (kb.matches(data, "tui.editor.cursorDown")) {
if (this.historyIndex > -1 && this.isOnLastVisualLine()) { if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
this.navigateHistory(1); this.navigateHistory(1);
} else if (this.isOnLastVisualLine()) { } else if (this.isOnLastVisualLine()) {
@@ -763,31 +763,31 @@ export class Editor implements Component, Focusable {
} }
return; return;
} }
if (kb.matches(data, "cursorRight")) { if (kb.matches(data, "tui.editor.cursorRight")) {
this.moveCursor(0, 1); this.moveCursor(0, 1);
return; return;
} }
if (kb.matches(data, "cursorLeft")) { if (kb.matches(data, "tui.editor.cursorLeft")) {
this.moveCursor(0, -1); this.moveCursor(0, -1);
return; return;
} }
// Page up/down - scroll by page and move cursor // Page up/down - scroll by page and move cursor
if (kb.matches(data, "pageUp")) { if (kb.matches(data, "tui.editor.pageUp")) {
this.pageScroll(-1); this.pageScroll(-1);
return; return;
} }
if (kb.matches(data, "pageDown")) { if (kb.matches(data, "tui.editor.pageDown")) {
this.pageScroll(1); this.pageScroll(1);
return; return;
} }
// Character jump mode triggers // Character jump mode triggers
if (kb.matches(data, "jumpForward")) { if (kb.matches(data, "tui.editor.jumpForward")) {
this.jumpMode = "forward"; this.jumpMode = "forward";
return; return;
} }
if (kb.matches(data, "jumpBackward")) { if (kb.matches(data, "tui.editor.jumpBackward")) {
this.jumpMode = "backward"; this.jumpMode = "backward";
return; return;
} }
@@ -1149,10 +1149,10 @@ export class Editor implements Component, Focusable {
} }
} }
private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType<typeof getEditorKeybindings>): boolean { private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType<typeof getKeybindings>): boolean {
if (this.disableSubmit) return false; if (this.disableSubmit) return false;
if (!matchesKey(data, "enter")) 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"); const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
if (!hasShiftEnter) return false; if (!hasShiftEnter) return false;

View File

@@ -1,4 +1,4 @@
import { getEditorKeybindings } from "../keybindings.js"; import { getKeybindings } from "../keybindings.js";
import { decodeKittyPrintable } from "../keys.js"; import { decodeKittyPrintable } from "../keys.js";
import { KillRing } from "../kill-ring.js"; import { KillRing } from "../kill-ring.js";
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js";
@@ -82,69 +82,69 @@ export class Input implements Component, Focusable {
return; return;
} }
const kb = getEditorKeybindings(); const kb = getKeybindings();
// Escape/Cancel // Escape/Cancel
if (kb.matches(data, "selectCancel")) { if (kb.matches(data, "tui.select.cancel")) {
if (this.onEscape) this.onEscape(); if (this.onEscape) this.onEscape();
return; return;
} }
// Undo // Undo
if (kb.matches(data, "undo")) { if (kb.matches(data, "tui.editor.undo")) {
this.undo(); this.undo();
return; return;
} }
// Submit // Submit
if (kb.matches(data, "submit") || data === "\n") { if (kb.matches(data, "tui.input.submit") || data === "\n") {
if (this.onSubmit) this.onSubmit(this.value); if (this.onSubmit) this.onSubmit(this.value);
return; return;
} }
// Deletion // Deletion
if (kb.matches(data, "deleteCharBackward")) { if (kb.matches(data, "tui.editor.deleteCharBackward")) {
this.handleBackspace(); this.handleBackspace();
return; return;
} }
if (kb.matches(data, "deleteCharForward")) { if (kb.matches(data, "tui.editor.deleteCharForward")) {
this.handleForwardDelete(); this.handleForwardDelete();
return; return;
} }
if (kb.matches(data, "deleteWordBackward")) { if (kb.matches(data, "tui.editor.deleteWordBackward")) {
this.deleteWordBackwards(); this.deleteWordBackwards();
return; return;
} }
if (kb.matches(data, "deleteWordForward")) { if (kb.matches(data, "tui.editor.deleteWordForward")) {
this.deleteWordForward(); this.deleteWordForward();
return; return;
} }
if (kb.matches(data, "deleteToLineStart")) { if (kb.matches(data, "tui.editor.deleteToLineStart")) {
this.deleteToLineStart(); this.deleteToLineStart();
return; return;
} }
if (kb.matches(data, "deleteToLineEnd")) { if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
this.deleteToLineEnd(); this.deleteToLineEnd();
return; return;
} }
// Kill ring actions // Kill ring actions
if (kb.matches(data, "yank")) { if (kb.matches(data, "tui.editor.yank")) {
this.yank(); this.yank();
return; return;
} }
if (kb.matches(data, "yankPop")) { if (kb.matches(data, "tui.editor.yankPop")) {
this.yankPop(); this.yankPop();
return; return;
} }
// Cursor movement // Cursor movement
if (kb.matches(data, "cursorLeft")) { if (kb.matches(data, "tui.editor.cursorLeft")) {
this.lastAction = null; this.lastAction = null;
if (this.cursor > 0) { if (this.cursor > 0) {
const beforeCursor = this.value.slice(0, this.cursor); const beforeCursor = this.value.slice(0, this.cursor);
@@ -155,7 +155,7 @@ export class Input implements Component, Focusable {
return; return;
} }
if (kb.matches(data, "cursorRight")) { if (kb.matches(data, "tui.editor.cursorRight")) {
this.lastAction = null; this.lastAction = null;
if (this.cursor < this.value.length) { if (this.cursor < this.value.length) {
const afterCursor = this.value.slice(this.cursor); const afterCursor = this.value.slice(this.cursor);
@@ -166,24 +166,24 @@ export class Input implements Component, Focusable {
return; return;
} }
if (kb.matches(data, "cursorLineStart")) { if (kb.matches(data, "tui.editor.cursorLineStart")) {
this.lastAction = null; this.lastAction = null;
this.cursor = 0; this.cursor = 0;
return; return;
} }
if (kb.matches(data, "cursorLineEnd")) { if (kb.matches(data, "tui.editor.cursorLineEnd")) {
this.lastAction = null; this.lastAction = null;
this.cursor = this.value.length; this.cursor = this.value.length;
return; return;
} }
if (kb.matches(data, "cursorWordLeft")) { if (kb.matches(data, "tui.editor.cursorWordLeft")) {
this.moveWordBackwards(); this.moveWordBackwards();
return; return;
} }
if (kb.matches(data, "cursorWordRight")) { if (kb.matches(data, "tui.editor.cursorWordRight")) {
this.moveWordForwards(); this.moveWordForwards();
return; return;
} }

View File

@@ -1,4 +1,4 @@
import { getEditorKeybindings } from "../keybindings.js"; import { getKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js"; import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth } from "../utils.js"; import { truncateToWidth, visibleWidth } from "../utils.js";
@@ -110,26 +110,26 @@ export class SelectList implements Component {
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
const kb = getEditorKeybindings(); const kb = getKeybindings();
// Up arrow - wrap to bottom when at top // 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.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
this.notifySelectionChange(); this.notifySelectionChange();
} }
// Down arrow - wrap to top when at bottom // 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.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
this.notifySelectionChange(); this.notifySelectionChange();
} }
// Enter // Enter
else if (kb.matches(keyData, "selectConfirm")) { else if (kb.matches(keyData, "tui.select.confirm")) {
const selectedItem = this.filteredItems[this.selectedIndex]; const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem && this.onSelect) { if (selectedItem && this.onSelect) {
this.onSelect(selectedItem); this.onSelect(selectedItem);
} }
} }
// Escape or Ctrl+C // Escape or Ctrl+C
else if (kb.matches(keyData, "selectCancel")) { else if (kb.matches(keyData, "tui.select.cancel")) {
if (this.onCancel) { if (this.onCancel) {
this.onCancel(); this.onCancel();
} }

View File

@@ -1,5 +1,5 @@
import { fuzzyFilter } from "../fuzzy.js"; import { fuzzyFilter } from "../fuzzy.js";
import { getEditorKeybindings } from "../keybindings.js"; import { getKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js"; import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js"; import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js";
import { Input } from "./input.js"; import { Input } from "./input.js";
@@ -174,17 +174,17 @@ export class SettingsList implements Component {
} }
// Main list input handling // Main list input handling
const kb = getEditorKeybindings(); const kb = getKeybindings();
const displayItems = this.searchEnabled ? this.filteredItems : this.items; 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; if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1; 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; if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1; 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(); this.activateItem();
} else if (kb.matches(data, "selectCancel")) { } else if (kb.matches(data, "tui.select.cancel")) {
this.onCancel(); this.onCancel();
} else if (this.searchEnabled && this.searchInput) { } else if (this.searchEnabled && this.searchInput) {
const sanitized = data.replace(/ /g, ""); const sanitized = data.replace(/ /g, "");

View File

@@ -32,12 +32,16 @@ export type { EditorComponent } from "./editor-component.js";
export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js"; export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js";
// Keybindings // Keybindings
export { export {
DEFAULT_EDITOR_KEYBINDINGS, getKeybindings,
type EditorAction, type Keybinding,
type EditorKeybindingsConfig, type KeybindingConflict,
EditorKeybindingsManager, type KeybindingDefinition,
getEditorKeybindings, type KeybindingDefinitions,
setEditorKeybindings, type Keybindings,
type KeybindingsConfig,
KeybindingsManager,
setKeybindings,
TUI_KEYBINDINGS,
} from "./keybindings.js"; } from "./keybindings.js";
// Keyboard input handling // Keyboard input handling
export { export {

View File

@@ -1,189 +1,253 @@
import { type KeyId, matchesKey } from "./keys.js"; 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 = export interface Keybindings {
// Cursor movement // Editor navigation and editing
| "cursorUp" "tui.editor.cursorUp": true;
| "cursorDown" "tui.editor.cursorDown": true;
| "cursorLeft" "tui.editor.cursorLeft": true;
| "cursorRight" "tui.editor.cursorRight": true;
| "cursorWordLeft" "tui.editor.cursorWordLeft": true;
| "cursorWordRight" "tui.editor.cursorWordRight": true;
| "cursorLineStart" "tui.editor.cursorLineStart": true;
| "cursorLineEnd" "tui.editor.cursorLineEnd": true;
| "jumpForward" "tui.editor.jumpForward": true;
| "jumpBackward" "tui.editor.jumpBackward": true;
| "pageUp" "tui.editor.pageUp": true;
| "pageDown" "tui.editor.pageDown": true;
// Deletion "tui.editor.deleteCharBackward": true;
| "deleteCharBackward" "tui.editor.deleteCharForward": true;
| "deleteCharForward" "tui.editor.deleteWordBackward": true;
| "deleteWordBackward" "tui.editor.deleteWordForward": true;
| "deleteWordForward" "tui.editor.deleteToLineStart": true;
| "deleteToLineStart" "tui.editor.deleteToLineEnd": true;
| "deleteToLineEnd" "tui.editor.yank": true;
// Text input "tui.editor.yankPop": true;
| "newLine" "tui.editor.undo": true;
| "submit" // Generic input actions
| "tab" "tui.input.newLine": true;
// Selection/autocomplete "tui.input.submit": true;
| "selectUp" "tui.input.tab": true;
| "selectDown" "tui.input.copy": true;
| "selectPageUp" // Generic selection actions
| "selectPageDown" "tui.select.up": true;
| "selectConfirm" "tui.select.down": true;
| "selectCancel" "tui.select.pageUp": true;
// Clipboard "tui.select.pageDown": true;
| "copy" "tui.select.confirm": true;
// Kill ring "tui.select.cancel": true;
| "yank" }
| "yankPop"
// Undo
| "undo"
// Tool output
| "expandTools"
// Tree navigation
| "treeFoldOrUp"
| "treeUnfoldOrDown"
// Session
| "toggleSessionPath"
| "toggleSessionSort"
| "renameSession"
| "deleteSession"
| "deleteSessionNoninvasive";
// Re-export KeyId from keys.ts export type Keybinding = keyof Keybindings;
export type { KeyId };
/** export interface KeybindingDefinition {
* Editor keybindings configuration. defaultKeys: KeyId | KeyId[];
*/ description?: string;
export type EditorKeybindingsConfig = { }
[K in EditorAction]?: KeyId | KeyId[];
};
/** export type KeybindingDefinitions = Record<string, KeybindingDefinition>;
* Default editor keybindings. export type KeybindingsConfig = Record<string, KeyId | KeyId[] | undefined>;
*/
export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
// 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 const TUI_KEYBINDINGS = {
* Manages keybindings for the editor. "tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" },
*/ "tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" },
export class EditorKeybindingsManager { "tui.editor.cursorLeft": {
private actionToKeys: Map<EditorAction, KeyId[]>; 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 = {}) { export interface KeybindingConflict {
this.actionToKeys = new Map(); key: KeyId;
this.buildMaps(config); keybindings: string[];
}
function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
if (keys === undefined) return [];
const keyList = Array.isArray(keys) ? keys : [keys];
const seen = new Set<KeyId>();
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<Keybinding, KeyId[]>();
private conflicts: KeybindingConflict[] = [];
constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
this.definitions = definitions;
this.userBindings = userBindings;
this.rebuild();
} }
private buildMaps(config: EditorKeybindingsConfig): void { private rebuild(): void {
this.actionToKeys.clear(); this.keysById.clear();
this.conflicts = [];
// Start with defaults const userClaims = new Map<KeyId, Set<Keybinding>>();
for (const [action, keys] of Object.entries(DEFAULT_EDITOR_KEYBINDINGS)) { for (const [keybinding, keys] of Object.entries(this.userBindings)) {
const keyArray = Array.isArray(keys) ? keys : [keys]; if (!(keybinding in this.definitions)) continue;
this.actionToKeys.set(action as EditorAction, [...keyArray]); for (const key of normalizeKeys(keys)) {
const claimants = userClaims.get(key) ?? new Set<Keybinding>();
claimants.add(keybinding as Keybinding);
userClaims.set(key, claimants);
}
} }
// Override with user config for (const [key, keybindings] of userClaims) {
for (const [action, keys] of Object.entries(config)) { if (keybindings.size > 1) {
if (keys === undefined) continue; this.conflicts.push({ key, keybindings: [...keybindings] });
const keyArray = Array.isArray(keys) ? keys : [keys]; }
this.actionToKeys.set(action as EditorAction, keyArray); }
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));
} }
} }
/** matches(data: string, keybinding: Keybinding): boolean {
* Check if input matches a specific action. const keys = this.keysById.get(keybinding) ?? [];
*/
matches(data: string, action: EditorAction): boolean {
const keys = this.actionToKeys.get(action);
if (!keys) return false;
for (const key of keys) { for (const key of keys) {
if (matchesKey(data, key)) return true; if (matchesKey(data, key)) return true;
} }
return false; return false;
} }
/** getKeys(keybinding: Keybinding): KeyId[] {
* Get keys bound to an action. return [...(this.keysById.get(keybinding) ?? [])];
*/
getKeys(action: EditorAction): KeyId[] {
return this.actionToKeys.get(action) ?? [];
} }
/** getDefinition(keybinding: Keybinding): KeybindingDefinition {
* Update configuration. return this.definitions[keybinding];
*/ }
setConfig(config: EditorKeybindingsConfig): void {
this.buildMaps(config); 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 globalKeybindings: KeybindingsManager | null = null;
let globalEditorKeybindings: EditorKeybindingsManager | null = null;
export function getEditorKeybindings(): EditorKeybindingsManager { export function setKeybindings(keybindings: KeybindingsManager): void {
if (!globalEditorKeybindings) { globalKeybindings = keybindings;
globalEditorKeybindings = new EditorKeybindingsManager(); }
export function getKeybindings(): KeybindingsManager {
if (!globalKeybindings) {
globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS);
} }
return globalEditorKeybindings; return globalKeybindings;
}
export function setEditorKeybindings(manager: EditorKeybindingsManager): void {
globalEditorKeybindings = manager;
} }