mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 23:01:30 +00:00
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:
parent
558a77b45f
commit
a497fccd06
15 changed files with 195 additions and 170 deletions
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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")})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")})`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue