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