From a497fccd06abef3b3ed3c077ba064781b248fd02 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 14 Jan 2026 17:42:03 +0300 Subject: [PATCH] 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 --- packages/coding-agent/src/core/keybindings.ts | 15 +- .../interactive/components/bash-execution.ts | 12 +- .../interactive/components/bordered-loader.ts | 3 +- .../components/branch-summary-message.ts | 6 +- .../components/compaction-summary-message.ts | 6 +- .../interactive/components/custom-editor.ts | 6 +- .../components/extension-editor.ts | 23 ++- .../interactive/components/extension-input.ts | 3 +- .../components/extension-selector.ts | 13 +- .../src/modes/interactive/components/index.ts | 1 + .../components/keybinding-hints.ts | 66 ++++++++ .../interactive/components/login-dialog.ts | 9 +- .../interactive/components/tool-execution.ts | 38 ++--- .../interactive/components/tree-selector.ts | 13 +- .../src/modes/interactive/interactive-mode.ts | 151 ++++++------------ 15 files changed, 195 insertions(+), 170 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 4a339d7f..12661f79 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -27,7 +27,8 @@ export type AppAction = | "toggleThinking" | "externalEditor" | "followUp" - | "dequeue"; + | "dequeue" + | "pasteImage"; /** * All configurable actions. @@ -58,6 +59,7 @@ export const DEFAULT_APP_KEYBINDINGS: Record = { externalEditor: "ctrl+g", followUp: "alt+enter", dequeue: "alt+up", + pasteImage: "ctrl+v", }; /** @@ -83,6 +85,7 @@ const APP_ACTIONS: AppAction[] = [ "externalEditor", "followUp", "dequeue", + "pasteImage", ]; function isAppAction(action: string): action is AppAction { @@ -175,16 +178,6 @@ export class KeybindingsManager { return this.appActionToKeys.get(action) ?? []; } - /** - * Get display string for an action. - */ - getDisplayString(action: AppAction): string { - const keys = this.getKeys(action); - if (keys.length === 0) return ""; - if (keys.length === 1) return keys[0]!; - return keys.join("/"); - } - /** * Get the full effective config. */ diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index 00caff8c..fa78e030 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -2,7 +2,7 @@ * Component for displaying bash command execution with streaming output. */ -import { Container, getEditorKeybindings, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import { Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; import stripAnsi from "strip-ansi"; import { DEFAULT_MAX_BYTES, @@ -12,6 +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 { truncateToVisualLines } from "./visual-truncate.js"; // Preview line limit when not expanded (matches tool execution behavior) @@ -57,7 +58,7 @@ export class BashExecutionComponent extends Container { ui, (spinner) => theme.fg(colorKey, spinner), (text) => theme.fg("muted", text), - "Running... (esc to cancel)", + `Running... (${editorKey("selectCancel")} to cancel)`, // Plain text for loader ); this.contentContainer.addChild(this.loader); @@ -166,14 +167,11 @@ export class BashExecutionComponent extends Container { // Show how many lines are hidden (collapsed preview) if (hiddenLineCount > 0) { - const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; if (this.expanded) { - statusParts.push(`(${theme.fg("dim", expandKey)}${theme.fg("muted", " to collapse")})`); + statusParts.push(`(${keyHint("expandTools", "to collapse")})`); } else { statusParts.push( - theme.fg("muted", `... ${hiddenLineCount} more lines (`) + - theme.fg("dim", expandKey) + - theme.fg("muted", " to expand)"), + `${theme.fg("muted", `... ${hiddenLineCount} more lines`)} (${keyHint("expandTools", "to expand")})`, ); } } diff --git a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts index 497a62f7..1a91af03 100644 --- a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts +++ b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts @@ -1,6 +1,7 @@ import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; import type { Theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +import { keyHint } from "./keybinding-hints.js"; /** Loader wrapped with borders for extension UI */ export class BorderedLoader extends Container { @@ -18,7 +19,7 @@ export class BorderedLoader extends Container { ); this.addChild(this.loader); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", "esc cancel"), 1, 0)); + this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0)); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder(borderColor)); } diff --git a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts index 74198835..2c9f15cd 100644 --- a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts @@ -1,6 +1,7 @@ -import { Box, getEditorKeybindings, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { Box, Markdown, 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"; /** * Component that renders a branch summary message with collapsed/expanded state. @@ -41,11 +42,10 @@ export class BranchSummaryMessageComponent extends Box { }), ); } else { - const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; this.addChild( new Text( theme.fg("customMessageText", "Branch summary (") + - theme.fg("dim", expandKey) + + theme.fg("dim", editorKey("expandTools")) + theme.fg("customMessageText", " to expand)"), 0, 0, diff --git a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index 54ad755e..f8f7eb4e 100644 --- a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -1,6 +1,7 @@ -import { Box, getEditorKeybindings, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { Box, Markdown, 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"; /** * Component that renders a compaction message with collapsed/expanded state. @@ -42,11 +43,10 @@ export class CompactionSummaryMessageComponent extends Box { }), ); } else { - const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; this.addChild( new Text( theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (`) + - theme.fg("dim", expandKey) + + theme.fg("dim", editorKey("expandTools")) + theme.fg("customMessageText", " to expand)"), 0, 0, diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 93f5af78..6bf08dec 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,4 +1,4 @@ -import { Editor, type EditorTheme, matchesKey } from "@mariozechner/pi-tui"; +import { Editor, type EditorTheme } from "@mariozechner/pi-tui"; import type { AppAction, KeybindingsManager } from "../../../core/keybindings.js"; /** @@ -33,8 +33,8 @@ export class CustomEditor extends Editor { return; } - // Check for Ctrl+V to handle clipboard image paste - if (matchesKey(data, "ctrl+v")) { + // Check for paste image keybinding + if (this.keybindings.matches(data, "pasteImage")) { this.onPasteImage?.(); return; } diff --git a/packages/coding-agent/src/modes/interactive/components/extension-editor.ts b/packages/coding-agent/src/modes/interactive/components/extension-editor.ts index 109e397b..b4f737bb 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-editor.ts @@ -7,18 +7,22 @@ import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { Container, Editor, getEditorKeybindings, matchesKey, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import { Container, Editor, getEditorKeybindings, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +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"; export class ExtensionEditorComponent extends Container { private editor: Editor; private onSubmitCallback: (value: string) => void; private onCancelCallback: () => void; private tui: TUI; + private keybindings: KeybindingsManager; constructor( tui: TUI, + keybindings: KeybindingsManager, title: string, prefill: string | undefined, onSubmit: (value: string) => void, @@ -27,6 +31,7 @@ export class ExtensionEditorComponent extends Container { super(); this.tui = tui; + this.keybindings = keybindings; this.onSubmitCallback = onSubmit; this.onCancelCallback = onCancel; @@ -53,10 +58,14 @@ export class ExtensionEditorComponent extends Container { // Add hint const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR); - const hint = hasExternalEditor - ? "enter submit shift+enter newline esc cancel ctrl+g external editor" - : "enter submit shift+enter newline esc cancel"; - this.addChild(new Text(theme.fg("dim", hint), 1, 0)); + const hint = + keyHint("selectConfirm", "submit") + + " " + + keyHint("newLine", "newline") + + " " + + keyHint("selectCancel", "cancel") + + (hasExternalEditor ? ` ${appKeyHint(this.keybindings, "externalEditor", "external editor")}` : ""); + this.addChild(new Text(hint, 1, 0)); this.addChild(new Spacer(1)); @@ -72,8 +81,8 @@ export class ExtensionEditorComponent extends Container { return; } - // Ctrl+G for external editor (keep matchesKey for this app-specific action) - if (matchesKey(keyData, "ctrl+g")) { + // External editor (app keybinding) + if (this.keybindings.matches(keyData, "externalEditor")) { this.openExternalEditor(); return; } diff --git a/packages/coding-agent/src/modes/interactive/components/extension-input.ts b/packages/coding-agent/src/modes/interactive/components/extension-input.ts index 08545c39..0b7bcd21 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-input.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-input.ts @@ -6,6 +6,7 @@ import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from " import { theme } from "../theme/theme.js"; import { CountdownTimer } from "./countdown-timer.js"; import { DynamicBorder } from "./dynamic-border.js"; +import { keyHint } from "./keybinding-hints.js"; export interface ExtensionInputOptions { tui?: TUI; @@ -52,7 +53,7 @@ export class ExtensionInputComponent extends Container { this.input = new Input(); this.addChild(this.input); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 1, 0)); + this.addChild(new Text(`${keyHint("selectConfirm", "submit")} ${keyHint("selectCancel", "cancel")}`, 1, 0)); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); } diff --git a/packages/coding-agent/src/modes/interactive/components/extension-selector.ts b/packages/coding-agent/src/modes/interactive/components/extension-selector.ts index d60a0427..f4e7643d 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-selector.ts @@ -7,6 +7,7 @@ import { Container, getEditorKeybindings, Spacer, Text, type TUI } from "@marioz import { theme } from "../theme/theme.js"; import { CountdownTimer } from "./countdown-timer.js"; import { DynamicBorder } from "./dynamic-border.js"; +import { keyHint, rawKeyHint } from "./keybinding-hints.js"; export interface ExtensionSelectorOptions { tui?: TUI; @@ -56,7 +57,17 @@ export class ExtensionSelectorComponent extends Container { this.listContainer = new Container(); this.addChild(this.listContainer); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", "↑↓ navigate enter select esc cancel"), 1, 0)); + this.addChild( + new Text( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("selectConfirm", "select") + + " " + + keyHint("selectCancel", "cancel"), + 1, + 0, + ), + ); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index b06e9434..2676e2cd 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -13,6 +13,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 { LoginDialogComponent } from "./login-dialog.js"; export { ModelSelectorComponent } from "./model-selector.js"; export { OAuthSelectorComponent } from "./oauth-selector.js"; diff --git a/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts b/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts new file mode 100644 index 00000000..18d5fbd4 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts @@ -0,0 +1,66 @@ +/** + * 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 { 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)); +} + +/** + * Get display string for an app action. + */ +export function appKey(keybindings: KeybindingsManager, action: AppAction): string { + return formatKeys(keybindings.getKeys(action)); +} + +/** + * Format a keybinding hint with consistent styling: dim key, muted description. + * Looks up the key from editor keybindings automatically. + * + * @param action - Editor action name (e.g., "selectConfirm", "expandTools") + * @param description - Description text (e.g., "to expand", "cancel") + * @returns Formatted string with dim key and muted description + */ +export function keyHint(action: EditorAction, description: string): string { + return theme.fg("dim", editorKey(action)) + theme.fg("muted", ` ${description}`); +} + +/** + * Format a keybinding hint for app-level actions. + * Requires the KeybindingsManager instance. + * + * @param keybindings - KeybindingsManager instance + * @param action - App action name (e.g., "interrupt", "externalEditor") + * @param description - Description text + * @returns Formatted string with dim key and muted description + */ +export function appKeyHint(keybindings: KeybindingsManager, action: AppAction, description: string): string { + return theme.fg("dim", appKey(keybindings, action)) + theme.fg("muted", ` ${description}`); +} + +/** + * Format a raw key string with description (for non-configurable keys like ↑↓). + * + * @param key - Raw key string + * @param description - Description text + * @returns Formatted string with dim key and muted description + */ +export function rawKeyHint(key: string, description: string): string { + return theme.fg("dim", key) + theme.fg("muted", ` ${description}`); +} diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 01d58af7..f48f1dab 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -3,6 +3,7 @@ import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from " import { exec } from "child_process"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +import { keyHint } from "./keybinding-hints.js"; /** * Login dialog component - replaces editor during OAuth login flow @@ -98,7 +99,7 @@ export class LoginDialogComponent extends Container { 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(theme.fg("dim", "(Escape to cancel)"), 1, 0)); + this.contentContainer.addChild(new Text(`(${keyHint("selectCancel", "to cancel")})`, 1, 0)); this.tui.requestRender(); return new Promise((resolve, reject) => { @@ -118,7 +119,9 @@ export class LoginDialogComponent extends Container { this.contentContainer.addChild(new Text(theme.fg("dim", `e.g., ${placeholder}`), 1, 0)); } this.contentContainer.addChild(this.input); - this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel, Enter to submit)"), 1, 0)); + this.contentContainer.addChild( + new Text(`(${keyHint("selectCancel", "to cancel,")} ${keyHint("selectConfirm", "to submit")})`, 1, 0), + ); this.input.setValue(""); this.tui.requestRender(); @@ -135,7 +138,7 @@ export class LoginDialogComponent extends Container { 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(theme.fg("dim", "(Escape to cancel)"), 1, 0)); + this.contentContainer.addChild(new Text(`(${keyHint("selectCancel", "to cancel")})`, 1, 0)); this.tui.requestRender(); } diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 4156cc5d..cdb6ef59 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -3,7 +3,6 @@ import { Box, Container, getCapabilities, - getEditorKeybindings, getImageDimensions, Image, imageFallback, @@ -20,6 +19,7 @@ import { convertToPng } from "../../../utils/image-convert.js"; import { sanitizeBinaryOutput } from "../../../utils/shell.js"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; import { renderDiff } from "./diff.js"; +import { keyHint } from "./keybinding-hints.js"; import { truncateToVisualLines } from "./visual-truncate.js"; // Preview line limit for bash when not expanded @@ -376,11 +376,9 @@ export class ToolExecutionComponent extends Container { cachedWidth = width; } if (cachedSkipped && cachedSkipped > 0) { - const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; const hint = - theme.fg("muted", `... (${cachedSkipped} earlier lines, `) + - theme.fg("dim", expandKey) + - theme.fg("muted", " to expand)"); + theme.fg("muted", `... (${cachedSkipped} earlier lines,`) + + ` ${keyHint("expandTools", "to expand")})`; return ["", hint, ...cachedLines]; } return cachedLines; @@ -476,11 +474,7 @@ export class ToolExecutionComponent extends Container { .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))) .join("\n"); if (remaining > 0) { - const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; - text += - theme.fg("muted", `\n... (${remaining} more lines, `) + - theme.fg("dim", expandKey) + - theme.fg("muted", " to expand)"); + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`; } const truncation = this.result.details?.truncation; @@ -537,11 +531,9 @@ export class ToolExecutionComponent extends Container { .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))) .join("\n"); if (remaining > 0) { - const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; text += - theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total, `) + - theme.fg("dim", expandKey) + - theme.fg("muted", " to expand)"); + theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`) + + ` ${keyHint("expandTools", "to expand")})`; } } } else if (this.toolName === "edit") { @@ -599,11 +591,7 @@ export class ToolExecutionComponent extends Container { text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; - text += - theme.fg("muted", `\n... (${remaining} more lines, `) + - theme.fg("dim", expandKey) + - theme.fg("muted", " to expand)"); + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`; } } @@ -644,11 +632,7 @@ export class ToolExecutionComponent extends Container { text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; - text += - theme.fg("muted", `\n... (${remaining} more lines, `) + - theme.fg("dim", expandKey) + - theme.fg("muted", " to expand)"); + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`; } } @@ -693,11 +677,7 @@ export class ToolExecutionComponent extends Container { text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; - text += - theme.fg("muted", `\n... (${remaining} more lines, `) + - theme.fg("dim", expandKey) + - theme.fg("muted", " to expand)"); + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`; } } diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 08fced83..606082bf 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -12,6 +12,7 @@ import { import type { SessionTreeNode } from "../../../core/session-manager.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +import { keyHint } from "./keybinding-hints.js"; /** Gutter info: position (displayIndent where connector was) and whether to show │ */ interface GutterInfo { @@ -760,7 +761,9 @@ class LabelInput implements Component { const availableWidth = width - indent.length; 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}${theme.fg("dim", "enter: save esc: cancel")}`, width)); + lines.push( + truncateToWidth(`${indent}${keyHint("selectConfirm", "save")} ${keyHint("selectCancel", "cancel")}`, width), + ); return lines; } @@ -815,7 +818,13 @@ export class TreeSelectorComponent extends Container { this.addChild(new DynamicBorder()); this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); this.addChild( - new TruncatedText(theme.fg("muted", " ↑/↓: move. ←/→: page. l: label. ^O/⇧^O: filter. Type to search"), 0, 0), + new TruncatedText( + theme.fg("muted", " ↑/↓: move. ←/→: page. l: label. ") + + theme.fg("dim", "^O/⇧^O") + + theme.fg("muted", ": filter. Type to search"), + 0, + 0, + ), ); this.addChild(new SearchLine(this.treeList)); this.addChild(new DynamicBorder()); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 79f536b0..d792ebe4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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[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["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),