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

@ -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<AppAction, KeyId | KeyId[]> = {
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.
*/

View file

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

View file

@ -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));
}

View file

@ -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,

View file

@ -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,

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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());
}

View file

@ -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());

View file

@ -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";

View file

@ -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}`);
}

View file

@ -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();
}

View file

@ -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")})`;
}
}

View file

@ -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());

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),