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" | "toggleThinking"
| "externalEditor" | "externalEditor"
| "followUp" | "followUp"
| "dequeue"; | "dequeue"
| "pasteImage";
/** /**
* All configurable actions. * All configurable actions.
@ -58,6 +59,7 @@ export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
externalEditor: "ctrl+g", externalEditor: "ctrl+g",
followUp: "alt+enter", followUp: "alt+enter",
dequeue: "alt+up", dequeue: "alt+up",
pasteImage: "ctrl+v",
}; };
/** /**
@ -83,6 +85,7 @@ const APP_ACTIONS: AppAction[] = [
"externalEditor", "externalEditor",
"followUp", "followUp",
"dequeue", "dequeue",
"pasteImage",
]; ];
function isAppAction(action: string): action is AppAction { function isAppAction(action: string): action is AppAction {
@ -175,16 +178,6 @@ export class KeybindingsManager {
return this.appActionToKeys.get(action) ?? []; 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. * Get the full effective config.
*/ */

View file

@ -2,7 +2,7 @@
* Component for displaying bash command execution with streaming output. * 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 stripAnsi from "strip-ansi";
import { import {
DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES,
@ -12,6 +12,7 @@ import {
} from "../../../core/tools/truncate.js"; } from "../../../core/tools/truncate.js";
import { theme } from "../theme/theme.js"; import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
import { editorKey, keyHint } from "./keybinding-hints.js";
import { truncateToVisualLines } from "./visual-truncate.js"; import { truncateToVisualLines } from "./visual-truncate.js";
// Preview line limit when not expanded (matches tool execution behavior) // Preview line limit when not expanded (matches tool execution behavior)
@ -57,7 +58,7 @@ export class BashExecutionComponent extends Container {
ui, ui,
(spinner) => theme.fg(colorKey, spinner), (spinner) => theme.fg(colorKey, spinner),
(text) => theme.fg("muted", text), (text) => theme.fg("muted", text),
"Running... (esc to cancel)", `Running... (${editorKey("selectCancel")} to cancel)`, // Plain text for loader
); );
this.contentContainer.addChild(this.loader); this.contentContainer.addChild(this.loader);
@ -166,14 +167,11 @@ export class BashExecutionComponent extends Container {
// Show how many lines are hidden (collapsed preview) // Show how many lines are hidden (collapsed preview)
if (hiddenLineCount > 0) { if (hiddenLineCount > 0) {
const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!;
if (this.expanded) { if (this.expanded) {
statusParts.push(`(${theme.fg("dim", expandKey)}${theme.fg("muted", " to collapse")})`); statusParts.push(`(${keyHint("expandTools", "to collapse")})`);
} else { } else {
statusParts.push( statusParts.push(
theme.fg("muted", `... ${hiddenLineCount} more lines (`) + `${theme.fg("muted", `... ${hiddenLineCount} more lines`)} (${keyHint("expandTools", "to expand")})`,
theme.fg("dim", expandKey) +
theme.fg("muted", " to expand)"),
); );
} }
} }

View file

@ -1,6 +1,7 @@
import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../theme/theme.js"; import type { Theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
/** Loader wrapped with borders for extension UI */ /** Loader wrapped with borders for extension UI */
export class BorderedLoader extends Container { export class BorderedLoader extends Container {
@ -18,7 +19,7 @@ export class BorderedLoader extends Container {
); );
this.addChild(this.loader); this.addChild(this.loader);
this.addChild(new Spacer(1)); 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 Spacer(1));
this.addChild(new DynamicBorder(borderColor)); 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 type { BranchSummaryMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js"; import { getMarkdownTheme, theme } from "../theme/theme.js";
import { editorKey } from "./keybinding-hints.js";
/** /**
* Component that renders a branch summary message with collapsed/expanded state. * Component that renders a branch summary message with collapsed/expanded state.
@ -41,11 +42,10 @@ export class BranchSummaryMessageComponent extends Box {
}), }),
); );
} else { } else {
const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!;
this.addChild( this.addChild(
new Text( new Text(
theme.fg("customMessageText", "Branch summary (") + theme.fg("customMessageText", "Branch summary (") +
theme.fg("dim", expandKey) + theme.fg("dim", editorKey("expandTools")) +
theme.fg("customMessageText", " to expand)"), theme.fg("customMessageText", " to expand)"),
0, 0,
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 type { CompactionSummaryMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js"; import { getMarkdownTheme, theme } from "../theme/theme.js";
import { editorKey } from "./keybinding-hints.js";
/** /**
* Component that renders a compaction message with collapsed/expanded state. * Component that renders a compaction message with collapsed/expanded state.
@ -42,11 +43,10 @@ export class CompactionSummaryMessageComponent extends Box {
}), }),
); );
} else { } else {
const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!;
this.addChild( this.addChild(
new Text( new Text(
theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (`) + theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (`) +
theme.fg("dim", expandKey) + theme.fg("dim", editorKey("expandTools")) +
theme.fg("customMessageText", " to expand)"), theme.fg("customMessageText", " to expand)"),
0, 0,
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"; import type { AppAction, KeybindingsManager } from "../../../core/keybindings.js";
/** /**
@ -33,8 +33,8 @@ export class CustomEditor extends Editor {
return; return;
} }
// Check for Ctrl+V to handle clipboard image paste // Check for paste image keybinding
if (matchesKey(data, "ctrl+v")) { if (this.keybindings.matches(data, "pasteImage")) {
this.onPasteImage?.(); this.onPasteImage?.();
return; return;
} }

View file

@ -7,18 +7,22 @@ import { spawnSync } from "node:child_process";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; 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 { getEditorTheme, theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
import { appKeyHint, keyHint } from "./keybinding-hints.js";
export class ExtensionEditorComponent extends Container { export class ExtensionEditorComponent extends Container {
private editor: Editor; private editor: Editor;
private onSubmitCallback: (value: string) => void; private onSubmitCallback: (value: string) => void;
private onCancelCallback: () => void; private onCancelCallback: () => void;
private tui: TUI; private tui: TUI;
private keybindings: KeybindingsManager;
constructor( constructor(
tui: TUI, tui: TUI,
keybindings: KeybindingsManager,
title: string, title: string,
prefill: string | undefined, prefill: string | undefined,
onSubmit: (value: string) => void, onSubmit: (value: string) => void,
@ -27,6 +31,7 @@ export class ExtensionEditorComponent extends Container {
super(); super();
this.tui = tui; this.tui = tui;
this.keybindings = keybindings;
this.onSubmitCallback = onSubmit; this.onSubmitCallback = onSubmit;
this.onCancelCallback = onCancel; this.onCancelCallback = onCancel;
@ -53,10 +58,14 @@ export class ExtensionEditorComponent extends Container {
// Add hint // Add hint
const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR); const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR);
const hint = hasExternalEditor const hint =
? "enter submit shift+enter newline esc cancel ctrl+g external editor" keyHint("selectConfirm", "submit") +
: "enter submit shift+enter newline esc cancel"; " " +
this.addChild(new Text(theme.fg("dim", hint), 1, 0)); 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)); this.addChild(new Spacer(1));
@ -72,8 +81,8 @@ export class ExtensionEditorComponent extends Container {
return; return;
} }
// Ctrl+G for external editor (keep matchesKey for this app-specific action) // External editor (app keybinding)
if (matchesKey(keyData, "ctrl+g")) { if (this.keybindings.matches(keyData, "externalEditor")) {
this.openExternalEditor(); this.openExternalEditor();
return; return;
} }

View file

@ -6,6 +6,7 @@ import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "
import { theme } from "../theme/theme.js"; import { theme } from "../theme/theme.js";
import { CountdownTimer } from "./countdown-timer.js"; import { CountdownTimer } from "./countdown-timer.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
export interface ExtensionInputOptions { export interface ExtensionInputOptions {
tui?: TUI; tui?: TUI;
@ -52,7 +53,7 @@ export class ExtensionInputComponent extends Container {
this.input = new Input(); this.input = new Input();
this.addChild(this.input); this.addChild(this.input);
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
this.addChild(new Text(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 Spacer(1));
this.addChild(new DynamicBorder()); 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 { theme } from "../theme/theme.js";
import { CountdownTimer } from "./countdown-timer.js"; import { CountdownTimer } from "./countdown-timer.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
import { keyHint, rawKeyHint } from "./keybinding-hints.js";
export interface ExtensionSelectorOptions { export interface ExtensionSelectorOptions {
tui?: TUI; tui?: TUI;
@ -56,7 +57,17 @@ export class ExtensionSelectorComponent extends Container {
this.listContainer = new Container(); this.listContainer = new Container();
this.addChild(this.listContainer); this.addChild(this.listContainer);
this.addChild(new Spacer(1)); 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 Spacer(1));
this.addChild(new DynamicBorder()); this.addChild(new DynamicBorder());

View file

@ -13,6 +13,7 @@ export { ExtensionEditorComponent } from "./extension-editor.js";
export { ExtensionInputComponent } from "./extension-input.js"; export { ExtensionInputComponent } from "./extension-input.js";
export { ExtensionSelectorComponent } from "./extension-selector.js"; export { ExtensionSelectorComponent } from "./extension-selector.js";
export { FooterComponent } from "./footer.js"; export { FooterComponent } from "./footer.js";
export { appKey, appKeyHint, editorKey, keyHint, rawKeyHint } from "./keybinding-hints.js";
export { LoginDialogComponent } from "./login-dialog.js"; export { LoginDialogComponent } from "./login-dialog.js";
export { ModelSelectorComponent } from "./model-selector.js"; export { ModelSelectorComponent } from "./model-selector.js";
export { OAuthSelectorComponent } from "./oauth-selector.js"; export { OAuthSelectorComponent } from "./oauth-selector.js";

View file

@ -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 { exec } from "child_process";
import { theme } from "../theme/theme.js"; import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
/** /**
* Login dialog component - replaces editor during OAuth login flow * 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 Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0));
this.contentContainer.addChild(this.input); this.contentContainer.addChild(this.input);
this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); this.contentContainer.addChild(new Text(`(${keyHint("selectCancel", "to cancel")})`, 1, 0));
this.tui.requestRender(); this.tui.requestRender();
return new Promise((resolve, reject) => { 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(new Text(theme.fg("dim", `e.g., ${placeholder}`), 1, 0));
} }
this.contentContainer.addChild(this.input); 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.input.setValue("");
this.tui.requestRender(); this.tui.requestRender();
@ -135,7 +138,7 @@ export class LoginDialogComponent extends Container {
showWaiting(message: string): void { showWaiting(message: string): void {
this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0));
this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); this.contentContainer.addChild(new Text(`(${keyHint("selectCancel", "to cancel")})`, 1, 0));
this.tui.requestRender(); this.tui.requestRender();
} }

View file

@ -3,7 +3,6 @@ import {
Box, Box,
Container, Container,
getCapabilities, getCapabilities,
getEditorKeybindings,
getImageDimensions, getImageDimensions,
Image, Image,
imageFallback, imageFallback,
@ -20,6 +19,7 @@ import { convertToPng } from "../../../utils/image-convert.js";
import { sanitizeBinaryOutput } from "../../../utils/shell.js"; import { sanitizeBinaryOutput } from "../../../utils/shell.js";
import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
import { renderDiff } from "./diff.js"; import { renderDiff } from "./diff.js";
import { keyHint } from "./keybinding-hints.js";
import { truncateToVisualLines } from "./visual-truncate.js"; import { truncateToVisualLines } from "./visual-truncate.js";
// Preview line limit for bash when not expanded // Preview line limit for bash when not expanded
@ -376,11 +376,9 @@ export class ToolExecutionComponent extends Container {
cachedWidth = width; cachedWidth = width;
} }
if (cachedSkipped && cachedSkipped > 0) { if (cachedSkipped && cachedSkipped > 0) {
const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!;
const hint = const hint =
theme.fg("muted", `... (${cachedSkipped} earlier lines, `) + theme.fg("muted", `... (${cachedSkipped} earlier lines,`) +
theme.fg("dim", expandKey) + ` ${keyHint("expandTools", "to expand")})`;
theme.fg("muted", " to expand)");
return ["", hint, ...cachedLines]; return ["", hint, ...cachedLines];
} }
return cachedLines; return cachedLines;
@ -476,11 +474,7 @@ export class ToolExecutionComponent extends Container {
.map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))) .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
.join("\n"); .join("\n");
if (remaining > 0) { if (remaining > 0) {
const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`;
text +=
theme.fg("muted", `\n... (${remaining} more lines, `) +
theme.fg("dim", expandKey) +
theme.fg("muted", " to expand)");
} }
const truncation = this.result.details?.truncation; 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)))) .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line))))
.join("\n"); .join("\n");
if (remaining > 0) { if (remaining > 0) {
const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!;
text += text +=
theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total, `) + theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`) +
theme.fg("dim", expandKey) + ` ${keyHint("expandTools", "to expand")})`;
theme.fg("muted", " to expand)");
} }
} }
} else if (this.toolName === "edit") { } 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")}`; text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) { if (remaining > 0) {
const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`;
text +=
theme.fg("muted", `\n... (${remaining} more lines, `) +
theme.fg("dim", expandKey) +
theme.fg("muted", " to expand)");
} }
} }
@ -644,11 +632,7 @@ export class ToolExecutionComponent extends Container {
text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`; text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) { if (remaining > 0) {
const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`;
text +=
theme.fg("muted", `\n... (${remaining} more lines, `) +
theme.fg("dim", expandKey) +
theme.fg("muted", " to expand)");
} }
} }
@ -693,11 +677,7 @@ export class ToolExecutionComponent extends Container {
text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`; text += `\n\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) { if (remaining > 0) {
const expandKey = getEditorKeybindings().getKeys("expandTools")[0]!; text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("expandTools", "to expand")})`;
text +=
theme.fg("muted", `\n... (${remaining} more lines, `) +
theme.fg("dim", expandKey) +
theme.fg("muted", " to expand)");
} }
} }

View file

@ -12,6 +12,7 @@ import {
import type { SessionTreeNode } from "../../../core/session-manager.js"; import type { SessionTreeNode } from "../../../core/session-manager.js";
import { theme } from "../theme/theme.js"; import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js"; import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
/** Gutter info: position (displayIndent where connector was) and whether to show │ */ /** Gutter info: position (displayIndent where connector was) and whether to show │ */
interface GutterInfo { interface GutterInfo {
@ -760,7 +761,9 @@ class LabelInput implements Component {
const availableWidth = width - indent.length; const availableWidth = width - indent.length;
lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width)); lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width));
lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width))); lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width)));
lines.push(truncateToWidth(`${indent}${theme.fg("dim", "enter: save esc: cancel")}`, width)); lines.push(
truncateToWidth(`${indent}${keyHint("selectConfirm", "save")} ${keyHint("selectCancel", "cancel")}`, width),
);
return lines; return lines;
} }
@ -815,7 +818,13 @@ export class TreeSelectorComponent extends Container {
this.addChild(new DynamicBorder()); this.addChild(new DynamicBorder());
this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); this.addChild(new Text(theme.bold(" Session Tree"), 1, 0));
this.addChild( 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 SearchLine(this.treeList));
this.addChild(new DynamicBorder()); this.addChild(new DynamicBorder());

View file

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