refactor: use configurable keybindings for all UI hints (#724)

Follow-up to #717. Replaces all remaining hardcoded keybinding hints with configurable ones.

- Add pasteImage to AppAction so it can be configured in keybindings.json
- Create keybinding-hints.ts with reusable helper functions:
  - editorKey(action) / appKey(keybindings, action) - get key display string
  - keyHint(action, desc) / appKeyHint(kb, action, desc) / rawKeyHint(key, desc) - styled hints
- Export helpers from components/index.ts for extensions
- Update all components to use configured keybindings
- Remove now-unused getDisplayString() from KeybindingsManager and EditorKeybindingsManager
- Use keybindings.matches() instead of matchesKey() for pasteImage in custom-editor.ts
This commit is contained in:
Danila Poyarkov 2026-01-14 17:42:03 +03:00 committed by GitHub
parent 558a77b45f
commit a497fccd06
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 195 additions and 170 deletions

View file

@ -18,6 +18,7 @@ import {
} from "@mariozechner/pi-ai";
import type {
AutocompleteItem,
EditorAction,
EditorComponent,
EditorTheme,
KeyId,
@ -30,7 +31,6 @@ import {
type Component,
Container,
fuzzyFilter,
getEditorKeybindings,
Loader,
Markdown,
matchesKey,
@ -51,7 +51,7 @@ import type {
ExtensionUIDialogOptions,
} from "../../core/extensions/index.js";
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js";
import { KeybindingsManager } from "../../core/keybindings.js";
import { type AppAction, KeybindingsManager } from "../../core/keybindings.js";
import { createCompactionSummaryMessage } from "../../core/messages.js";
import { resolveModelScope } from "../../core/model-resolver.js";
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
@ -75,6 +75,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 { LoginDialogComponent } from "./components/login-dialog.js";
import { ModelSelectorComponent } from "./components/model-selector.js";
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
@ -147,7 +148,7 @@ export class InteractiveMode {
private isInitialized = false;
private onInputCallback?: (text: string) => void;
private loadingAnimation: Loader | undefined = undefined;
private readonly defaultWorkingMessage = "Working... (esc to interrupt)";
private readonly defaultWorkingMessage = "Working...";
private lastSigintTime = 0;
private lastEscapeTime = 0;
@ -358,85 +359,31 @@ export class InteractiveMode {
// Add header with keybindings from config
const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`);
// Format keybinding for startup display (lowercase, compact)
const formatStartupKey = (keys: string | string[]): string => {
const keyArray = Array.isArray(keys) ? keys : [keys];
return keyArray.join("/");
};
// Build startup instructions using keybinding hint helpers
const kb = this.keybindings;
const interrupt = formatStartupKey(kb.getKeys("interrupt"));
const clear = formatStartupKey(kb.getKeys("clear"));
const exit = formatStartupKey(kb.getKeys("exit"));
const suspend = formatStartupKey(kb.getKeys("suspend"));
const deleteToLineEnd = formatStartupKey(getEditorKeybindings().getKeys("deleteToLineEnd"));
const cycleThinkingLevel = formatStartupKey(kb.getKeys("cycleThinkingLevel"));
const cycleModelForward = formatStartupKey(kb.getKeys("cycleModelForward"));
const cycleModelBackward = formatStartupKey(kb.getKeys("cycleModelBackward"));
const selectModel = formatStartupKey(kb.getKeys("selectModel"));
const expandTools = formatStartupKey(kb.getKeys("expandTools"));
const toggleThinking = formatStartupKey(kb.getKeys("toggleThinking"));
const externalEditor = formatStartupKey(kb.getKeys("externalEditor"));
const followUp = formatStartupKey(kb.getKeys("followUp"));
const dequeue = formatStartupKey(kb.getKeys("dequeue"));
const hint = (action: AppAction, desc: string) => appKeyHint(kb, action, desc);
const instructions =
theme.fg("dim", interrupt) +
theme.fg("muted", " to interrupt") +
"\n" +
theme.fg("dim", clear) +
theme.fg("muted", " to clear") +
"\n" +
theme.fg("dim", `${clear} twice`) +
theme.fg("muted", " to exit") +
"\n" +
theme.fg("dim", exit) +
theme.fg("muted", " to exit (empty)") +
"\n" +
theme.fg("dim", suspend) +
theme.fg("muted", " to suspend") +
"\n" +
theme.fg("dim", deleteToLineEnd) +
theme.fg("muted", " to delete to end") +
"\n" +
theme.fg("dim", cycleThinkingLevel) +
theme.fg("muted", " to cycle thinking") +
"\n" +
theme.fg("dim", `${cycleModelForward}/${cycleModelBackward}`) +
theme.fg("muted", " to cycle models") +
"\n" +
theme.fg("dim", selectModel) +
theme.fg("muted", " to select model") +
"\n" +
theme.fg("dim", expandTools) +
theme.fg("muted", " to expand tools") +
"\n" +
theme.fg("dim", toggleThinking) +
theme.fg("muted", " to toggle thinking") +
"\n" +
theme.fg("dim", externalEditor) +
theme.fg("muted", " for external editor") +
"\n" +
theme.fg("dim", "/") +
theme.fg("muted", " for commands") +
"\n" +
theme.fg("dim", "!") +
theme.fg("muted", " to run bash") +
"\n" +
theme.fg("dim", "!!") +
theme.fg("muted", " to run bash (no context)") +
"\n" +
theme.fg("dim", followUp) +
theme.fg("muted", " to queue follow-up") +
"\n" +
theme.fg("dim", dequeue) +
theme.fg("muted", " to edit all queued messages") +
"\n" +
theme.fg("dim", "ctrl+v") +
theme.fg("muted", " to paste image") +
"\n" +
theme.fg("dim", "drop files") +
theme.fg("muted", " to attach");
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"),
rawKeyHint(`${appKey(kb, "cycleModelForward")}/${appKey(kb, "cycleModelBackward")}`, "to cycle models"),
hint("selectModel", "to select model"),
hint("expandTools", "to expand tools"),
hint("toggleThinking", "to toggle thinking"),
hint("externalEditor", "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"),
rawKeyHint("drop files", "to attach"),
].join("\n");
this.builtInHeader = new Text(`${logo}\n${instructions}`, 1, 0);
// Setup UI layout
@ -989,7 +936,13 @@ export class InteractiveMode {
setStatus: (key, text) => this.setExtensionStatus(key, text),
setWorkingMessage: (message) => {
if (this.loadingAnimation) {
this.loadingAnimation.setMessage(message ?? this.defaultWorkingMessage);
if (message) {
this.loadingAnimation.setMessage(message);
} else {
this.loadingAnimation.setMessage(
`${this.defaultWorkingMessage} (${appKey(this.keybindings, "interrupt")} to interrupt)`,
);
}
}
},
setWidget: (key, content) => this.setExtensionWidget(key, content),
@ -1150,6 +1103,7 @@ export class InteractiveMode {
return new Promise((resolve) => {
this.extensionEditor = new ExtensionEditorComponent(
this.ui,
this.keybindings,
title,
prefill,
(value) => {
@ -1810,7 +1764,7 @@ export class InteractiveMode {
this.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
`${reasonText}Auto-compacting... (esc to cancel)`,
`${reasonText}Auto-compacting... (${appKey(this.keybindings, "interrupt")} to cancel)`,
);
this.statusContainer.addChild(this.autoCompactionLoader);
this.ui.requestRender();
@ -1863,7 +1817,7 @@ export class InteractiveMode {
this.ui,
(spinner) => theme.fg("warning", spinner),
(text) => theme.fg("muted", text),
`Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s... (esc to cancel)`,
`Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s... (${appKey(this.keybindings, "interrupt")} to cancel)`,
);
this.statusContainer.addChild(this.retryLoader);
this.ui.requestRender();
@ -2935,7 +2889,7 @@ export class InteractiveMode {
this.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
"Summarizing branch... (esc to cancel)",
`Summarizing branch... (${appKey(this.keybindings, "interrupt")} to cancel)`,
);
this.statusContainer.addChild(summaryLoader);
this.ui.requestRender();
@ -3388,13 +3342,13 @@ export class InteractiveMode {
}
/**
* Format keybindings for display (e.g., "ctrl+c" -> "Ctrl+C").
* Capitalize keybinding for display (e.g., "ctrl+c" -> "Ctrl+C").
*/
private formatKeyDisplay(keys: string | string[]): string {
const keyArray = Array.isArray(keys) ? keys : [keys];
return keyArray
.map((key) =>
key
private capitalizeKey(key: string): string {
return key
.split("/")
.map((k) =>
k
.split("+")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("+"),
@ -3403,19 +3357,17 @@ export class InteractiveMode {
}
/**
* Get display string for an app keybinding action.
* Get capitalized display string for an app keybinding action.
*/
private getAppKeyDisplay(action: Parameters<KeybindingsManager["getDisplayString"]>[0]): string {
const display = this.keybindings.getDisplayString(action);
return this.formatKeyDisplay(display);
private getAppKeyDisplay(action: AppAction): string {
return this.capitalizeKey(appKey(this.keybindings, action));
}
/**
* Get display string for an editor keybinding action.
* Get capitalized display string for an editor keybinding action.
*/
private getEditorKeyDisplay(action: Parameters<ReturnType<typeof getEditorKeybindings>["getKeys"]>[0]): string {
const keys = getEditorKeybindings().getKeys(action);
return this.formatKeyDisplay(keys);
private getEditorKeyDisplay(action: EditorAction): string {
return this.capitalizeKey(editorKey(action));
}
private handleHotkeysCommand(): void {
@ -3690,7 +3642,8 @@ export class InteractiveMode {
// Show compacting status
this.chatContainer.addChild(new Spacer(1));
const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
const cancelHint = `(${appKey(this.keybindings, "interrupt")} to cancel)`;
const label = isAuto ? `Auto-compacting context... ${cancelHint}` : `Compacting context... ${cancelHint}`;
const compactingLoader = new Loader(
this.ui,
(spinner) => theme.fg("accent", spinner),