fix: route all app-level shortcuts through KeybindingsManager

fixes #468
This commit is contained in:
can1357
2026-03-18 22:29:38 +01:00
parent 66c80fd613
commit fb76484aa4
9 changed files with 229 additions and 162 deletions
+1 -1
View File
@@ -2214,7 +2214,7 @@
- New `appendModeChange()` method in SessionManager to record mode transitions
- New `mode` and `modeData` fields in SessionContext to track active agent mode
- Support for `PI_PACKAGE_DIR` environment variable to override package directory (useful for Nix/Guix store paths)
- New keybindings for session management: `toggleSessionNamedFilter` (Ctrl+N), `newSession`, `tree`, `fork`, and `resume` actions
- New keybindings for session management: `newSession`, `tree`, `fork`, and `resume` actions
- Support for shell command execution in configuration values (API keys, headers) using `!` prefix, with result caching
- New `clearOnShrink` display setting to control whether empty rows are cleared when content shrinks
- New `SlashCommandInfo`, `SlashCommandLocation`, and `SlashCommandSource` types for extension slash command discovery
@@ -25,7 +25,6 @@ export type AppAction =
| "togglePlanMode"
| "expandTools"
| "toggleThinking"
| "toggleSessionNamedFilter"
| "externalEditor"
| "historySearch"
| "followUp"
@@ -67,7 +66,6 @@ export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
historySearch: "ctrl+r",
expandTools: "ctrl+o",
toggleThinking: "ctrl+t",
toggleSessionNamedFilter: "ctrl+n",
externalEditor: "ctrl+g",
followUp: "ctrl+enter",
dequeue: "alt+up",
@@ -80,7 +78,6 @@ export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
resume: [],
toggleSTT: "alt+h",
};
/**
* All default keybindings (app + editor).
*/
@@ -103,7 +100,6 @@ const APP_ACTIONS: AppAction[] = [
"historySearch",
"expandTools",
"toggleThinking",
"toggleSessionNamedFilter",
"externalEditor",
"followUp",
"dequeue",
@@ -1,34 +1,89 @@
import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
import type { AppAction } from "../../config/keybindings";
type ConfigurableEditorAction = Extract<
AppAction,
| "interrupt"
| "clear"
| "exit"
| "suspend"
| "cycleThinkingLevel"
| "cycleModelForward"
| "cycleModelBackward"
| "selectModel"
| "expandTools"
| "toggleThinking"
| "externalEditor"
| "historySearch"
| "dequeue"
| "pasteImage"
| "copyPrompt"
>;
const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
interrupt: ["escape"],
clear: ["ctrl+c"],
exit: ["ctrl+d"],
suspend: ["ctrl+z"],
cycleThinkingLevel: ["shift+tab"],
cycleModelForward: ["ctrl+p"],
cycleModelBackward: ["shift+ctrl+p"],
selectModel: ["ctrl+l"],
expandTools: ["ctrl+o"],
toggleThinking: ["ctrl+t"],
externalEditor: ["ctrl+g"],
historySearch: ["ctrl+r"],
dequeue: ["alt+up"],
pasteImage: ["ctrl+v"],
copyPrompt: ["alt+shift+c"],
};
/**
* Custom editor that handles Escape and Ctrl+C keys for coding-agent
* Custom editor that handles configurable app-level shortcuts for coding-agent.
*/
export class CustomEditor extends Editor {
onEscape?: () => void;
shouldBypassAutocompleteOnEscape?: () => boolean;
onCtrlC?: () => void;
onCtrlD?: () => void;
onShiftTab?: () => void;
onCtrlP?: () => void;
onShiftCtrlP?: () => void;
onCtrlL?: () => void;
onCtrlR?: () => void;
onCtrlO?: () => void;
onCtrlT?: () => void;
onCtrlG?: () => void;
onCtrlZ?: () => void;
onQuestionMark?: () => void;
onCapsLock?: () => void;
onAltP?: () => void;
/** Called when Alt+Shift+C is pressed to copy prompt to clipboard. */
onClear?: () => void;
onExit?: () => void;
onCycleThinkingLevel?: () => void;
onCycleModelForward?: () => void;
onCycleModelBackward?: () => void;
onSelectModel?: () => void;
onExpandTools?: () => void;
onToggleThinking?: () => void;
onExternalEditor?: () => void;
onHistorySearch?: () => void;
onSuspend?: () => void;
onShowHotkeys?: () => void;
onQuickSelectModel?: () => void;
/** Called when the configured copy-prompt shortcut is pressed. */
onCopyPrompt?: () => void;
/** Called when Ctrl+V is pressed. Returns true if handled (image found), false to fall through to text paste. */
onCtrlV?: () => Promise<boolean>;
/** Called when Alt+Up is pressed (dequeue keybinding). */
onAltUp?: () => void;
/** Called when the configured image-paste shortcut is pressed. */
onPasteImage?: () => Promise<boolean>;
/** Called when the configured dequeue shortcut is pressed. */
onDequeue?: () => void;
/** Called when Caps Lock is pressed. */
onCapsLock?: () => void;
/** Custom key handlers from extensions */
/** Custom key handlers from extensions and non-built-in app actions. */
#customKeyHandlers = new Map<KeyId, () => void>();
#actionKeys = new Map<ConfigurableEditorAction, KeyId[]>(
Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]),
);
setActionKeys(action: ConfigurableEditorAction, keys: KeyId[]): void {
this.#actionKeys.set(action, [...keys]);
}
#matchesAction(data: string, action: ConfigurableEditorAction): boolean {
const keys = this.#actionKeys.get(action);
if (!keys) return false;
for (const key of keys) {
if (matchesKey(data, key)) return true;
}
return false;
}
/**
* Register a custom key handler. Extensions use this for shortcuts.
@@ -59,111 +114,111 @@ export class CustomEditor extends Editor {
return;
}
// Intercept Ctrl+V for image paste (async - fires and handles result)
if (matchesKey(data, "ctrl+v") && this.onCtrlV) {
void this.onCtrlV();
// Intercept configured image paste (async - fires and handles result)
if (this.#matchesAction(data, "pasteImage") && this.onPasteImage) {
void this.onPasteImage();
return;
}
// Intercept Ctrl+G for external editor
if (matchesKey(data, "ctrl+g") && this.onCtrlG) {
this.onCtrlG();
// Intercept configured external editor shortcut
if (this.#matchesAction(data, "externalEditor") && this.onExternalEditor) {
this.onExternalEditor();
return;
}
// Intercept Alt+P for quick model switching
if (matchesKey(data, "alt+p") && this.onAltP) {
this.onAltP();
if (matchesKey(data, "alt+p") && this.onQuickSelectModel) {
this.onQuickSelectModel();
return;
}
// Intercept Ctrl+Z for suspend
if (matchesKey(data, "ctrl+z") && this.onCtrlZ) {
this.onCtrlZ();
// Intercept configured suspend shortcut
if (this.#matchesAction(data, "suspend") && this.onSuspend) {
this.onSuspend();
return;
}
// Intercept Ctrl+T for thinking block visibility toggle
if (matchesKey(data, "ctrl+t") && this.onCtrlT) {
this.onCtrlT();
// Intercept configured thinking block visibility toggle
if (this.#matchesAction(data, "toggleThinking") && this.onToggleThinking) {
this.onToggleThinking();
return;
}
// Intercept Ctrl+L for model selector
if (matchesKey(data, "ctrl+l") && this.onCtrlL) {
this.onCtrlL();
// Intercept configured model selector shortcut
if (this.#matchesAction(data, "selectModel") && this.onSelectModel) {
this.onSelectModel();
return;
}
// Intercept Ctrl+R for history search
if (matchesKey(data, "ctrl+r") && this.onCtrlR) {
this.onCtrlR();
// Intercept configured history search shortcut
if (this.#matchesAction(data, "historySearch") && this.onHistorySearch) {
this.onHistorySearch();
return;
}
// Intercept Ctrl+O for tool output expansion
if (matchesKey(data, "ctrl+o") && this.onCtrlO) {
this.onCtrlO();
// Intercept configured tool output expansion shortcut
if (this.#matchesAction(data, "expandTools") && this.onExpandTools) {
this.onExpandTools();
return;
}
// Intercept Shift+Ctrl+P for backward model cycling (check before Ctrl+P)
if ((matchesKey(data, "shift+ctrl+p") || matchesKey(data, "ctrl+shift+p")) && this.onShiftCtrlP) {
this.onShiftCtrlP();
// Intercept configured backward model cycling (check before forward cycling)
if (this.#matchesAction(data, "cycleModelBackward") && this.onCycleModelBackward) {
this.onCycleModelBackward();
return;
}
// Intercept Ctrl+P for model cycling
if (matchesKey(data, "ctrl+p") && this.onCtrlP) {
this.onCtrlP();
// Intercept configured forward model cycling
if (this.#matchesAction(data, "cycleModelForward") && this.onCycleModelForward) {
this.onCycleModelForward();
return;
}
// Intercept Shift+Tab for thinking level cycling
if (matchesKey(data, "shift+tab") && this.onShiftTab) {
this.onShiftTab();
// Intercept configured thinking level cycling
if (this.#matchesAction(data, "cycleThinkingLevel") && this.onCycleThinkingLevel) {
this.onCycleThinkingLevel();
return;
}
// Intercept Escape key.
// Default behavior keeps autocomplete dismissal, but parent can prioritize global escape handling.
if ((matchesKey(data, "escape") || matchesKey(data, "esc")) && this.onEscape) {
// Intercept configured interrupt shortcut.
// Default behavior keeps autocomplete dismissal, but parent can prioritize global interrupt handling.
if (this.#matchesAction(data, "interrupt") && this.onEscape) {
if (!this.isShowingAutocomplete() || this.shouldBypassAutocompleteOnEscape?.()) {
this.onEscape();
return;
}
}
// Intercept Ctrl+C
if (matchesKey(data, "ctrl+c") && this.onCtrlC) {
this.onCtrlC();
// Intercept configured clear shortcut
if (this.#matchesAction(data, "clear") && this.onClear) {
this.onClear();
return;
}
// Intercept Ctrl+D (only when editor is empty)
if (matchesKey(data, "ctrl+d")) {
if (this.getText().length === 0 && this.onCtrlD) {
this.onCtrlD();
// Intercept configured exit shortcut (only when editor is empty)
if (this.#matchesAction(data, "exit")) {
if (this.getText().length === 0 && this.onExit) {
this.onExit();
}
// Always consume Ctrl+D (don't pass to parent)
// Always consume exit shortcut (don't pass to parent)
return;
}
// Intercept Alt+Up for dequeue (restore queued message to editor)
if (matchesKey(data, "alt+up") && this.onAltUp) {
this.onAltUp();
// Intercept configured dequeue shortcut (restore queued message to editor)
if (this.#matchesAction(data, "dequeue") && this.onDequeue) {
this.onDequeue();
return;
}
// Intercept Alt+Shift+C to copy prompt to clipboard
if (matchesKey(data, "alt+shift+c") && this.onCopyPrompt) {
// Intercept configured copy-prompt shortcut
if (this.#matchesAction(data, "copyPrompt") && this.onCopyPrompt) {
this.onCopyPrompt();
return;
}
// Intercept ? when editor is empty to show hotkeys
if (data === "?" && this.getText().length === 0 && this.onQuestionMark) {
this.onQuestionMark();
if (data === "?" && this.getText().length === 0 && this.onShowHotkeys) {
this.onShowHotkeys();
return;
}
@@ -505,18 +505,7 @@ export class CommandController {
}
handleHotkeysCommand(): void {
const expandToolsKey = this.ctx.keybindings.getDisplayString("expandTools") || "Ctrl+O";
const planModeKey = this.ctx.keybindings.getDisplayString("togglePlanMode") || "Alt+Shift+P";
const sttKey = this.ctx.keybindings.getDisplayString("toggleSTT") || "Alt+H";
const copyLineKey = this.ctx.keybindings.getDisplayString("copyLine") || "Alt+Shift+L";
const copyPromptKey = this.ctx.keybindings.getDisplayString("copyPrompt") || "Alt+Shift+C";
const hotkeys = buildHotkeysMarkdown({
expandToolsKey,
planModeKey,
sttKey,
copyLineKey,
copyPromptKey,
});
const hotkeys = buildHotkeysMarkdown({ keybindings: this.ctx.keybindings });
this.ctx.chatContainer.addChild(new Spacer(1));
this.ctx.chatContainer.addChild(new DynamicBorder());
this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));
@@ -26,6 +26,7 @@ export class InputController {
constructor(private ctx: InteractiveModeContext) {}
setupKeyHandlers(): void {
this.ctx.editor.setActionKeys("interrupt", this.ctx.keybindings.getKeys("interrupt"));
this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
Boolean(
this.ctx.loadingAnimation ||
@@ -64,7 +65,7 @@ export class InputController {
} else if (this.ctx.session.isStreaming) {
void this.ctx.session.abort();
} else if (!this.ctx.editor.getText().trim()) {
// Double-escape with empty editor triggers /tree, /branch, or nothing based on setting
// Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting
const action = settings.get("doubleEscapeAction");
if (action !== "none") {
const now = Date.now();
@@ -82,42 +83,44 @@ export class InputController {
}
};
this.ctx.editor.onCtrlC = () => this.handleCtrlC();
this.ctx.editor.onCtrlD = () => this.handleCtrlD();
this.ctx.editor.onCtrlZ = () => this.handleCtrlZ();
this.ctx.editor.onShiftTab = () => this.cycleThinkingLevel();
this.ctx.editor.onCtrlP = () => this.cycleRoleModel();
this.ctx.editor.onShiftCtrlP = () => this.cycleRoleModel({ temporary: true });
this.ctx.editor.onAltP = () => this.ctx.showModelSelector({ temporaryOnly: true });
this.ctx.editor.setActionKeys("clear", this.ctx.keybindings.getKeys("clear"));
this.ctx.editor.onClear = () => this.handleCtrlC();
this.ctx.editor.setActionKeys("exit", this.ctx.keybindings.getKeys("exit"));
this.ctx.editor.onExit = () => this.handleCtrlD();
this.ctx.editor.setActionKeys("suspend", this.ctx.keybindings.getKeys("suspend"));
this.ctx.editor.onSuspend = () => this.handleCtrlZ();
this.ctx.editor.setActionKeys("cycleThinkingLevel", this.ctx.keybindings.getKeys("cycleThinkingLevel"));
this.ctx.editor.onCycleThinkingLevel = () => this.cycleThinkingLevel();
this.ctx.editor.setActionKeys("cycleModelForward", this.ctx.keybindings.getKeys("cycleModelForward"));
this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel();
this.ctx.editor.setActionKeys("cycleModelBackward", this.ctx.keybindings.getKeys("cycleModelBackward"));
this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel({ temporary: true });
this.ctx.editor.onQuickSelectModel = () => this.ctx.showModelSelector({ temporaryOnly: true });
// Global debug handler on TUI (works regardless of focus)
this.ctx.ui.onDebug = () => this.ctx.showDebugSelector();
this.ctx.editor.onCtrlL = () => this.ctx.showModelSelector();
this.ctx.editor.onCtrlR = () => this.ctx.showHistorySearch();
this.ctx.editor.onCtrlT = () => this.ctx.toggleTodoExpansion();
this.ctx.editor.onCtrlG = () => void this.openExternalEditor();
this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand();
this.ctx.editor.onCtrlV = () => this.handleImagePaste();
const copyPromptKeys = this.ctx.keybindings.getKeys("copyPrompt");
this.ctx.editor.onCopyPrompt = copyPromptKeys.includes("alt+shift+c") ? () => this.handleCopyPrompt() : undefined;
this.ctx.editor.setActionKeys("selectModel", this.ctx.keybindings.getKeys("selectModel"));
this.ctx.editor.onSelectModel = () => this.ctx.showModelSelector();
this.ctx.editor.setActionKeys("historySearch", this.ctx.keybindings.getKeys("historySearch"));
this.ctx.editor.onHistorySearch = () => this.ctx.showHistorySearch();
this.ctx.editor.setActionKeys("toggleThinking", this.ctx.keybindings.getKeys("toggleThinking"));
this.ctx.editor.onToggleThinking = () => this.ctx.toggleThinkingBlockVisibility();
this.ctx.editor.setActionKeys("externalEditor", this.ctx.keybindings.getKeys("externalEditor"));
this.ctx.editor.onExternalEditor = () => void this.openExternalEditor();
this.ctx.editor.onShowHotkeys = () => this.ctx.handleHotkeysCommand();
this.ctx.editor.setActionKeys("pasteImage", this.ctx.keybindings.getKeys("pasteImage"));
this.ctx.editor.onPasteImage = () => this.handleImagePaste();
this.ctx.editor.setActionKeys("copyPrompt", this.ctx.keybindings.getKeys("copyPrompt"));
this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
this.ctx.editor.setActionKeys("expandTools", this.ctx.keybindings.getKeys("expandTools"));
this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion();
this.ctx.editor.setActionKeys("dequeue", this.ctx.keybindings.getKeys("dequeue"));
this.ctx.editor.onDequeue = () => this.handleDequeue();
this.ctx.editor.clearCustomKeyHandlers();
// Wire up extension shortcuts
this.registerExtensionShortcuts();
const expandToolsKeys = this.ctx.keybindings.getKeys("expandTools");
this.ctx.editor.onCtrlO = expandToolsKeys.includes("ctrl+o") ? () => this.toggleToolOutputExpansion() : undefined;
for (const key of expandToolsKeys) {
if (key === "ctrl+o") continue;
this.ctx.editor.setCustomKeyHandler(key, () => this.toggleToolOutputExpansion());
}
const dequeueKeys = this.ctx.keybindings.getKeys("dequeue");
this.ctx.editor.onAltUp = dequeueKeys.includes("alt+up") ? () => this.handleDequeue() : undefined;
for (const key of dequeueKeys) {
if (key === "alt+up") continue;
this.ctx.editor.setCustomKeyHandler(key, () => this.handleDequeue());
}
const planModeKeys = this.ctx.keybindings.getKeys("togglePlanMode");
for (const key of planModeKeys) {
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
@@ -144,10 +147,6 @@ export class InputController {
for (const key of this.ctx.keybindings.getKeys("copyLine")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
}
for (const key of copyPromptKeys) {
if (key === "alt+shift+c") continue;
this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyPrompt());
}
this.ctx.editor.onChange = (text: string) => {
const wasBashMode = this.ctx.isBashMode;
@@ -555,7 +555,7 @@ export class InteractiveMode implements InteractiveModeContext {
});
if (visibleTasks.length < activePhase.tasks.length) {
const remaining = activePhase.tasks.length - visibleTasks.length;
lines.push(theme.fg("muted", `${indent} ${hook} +${remaining} more (Ctrl+T to expand)`));
lines.push(theme.fg("muted", `${indent} ${hook} +${remaining} more`));
}
this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
return;
@@ -1,13 +1,14 @@
import type { AppAction, KeybindingsManager } from "../../config/keybindings";
export interface HotkeysMarkdownBindings {
expandToolsKey: string;
planModeKey: string;
sttKey: string;
copyLineKey: string;
copyPromptKey: string;
keybindings: Pick<KeybindingsManager, "getDisplayString">;
}
function appKey(bindings: HotkeysMarkdownBindings, action: AppAction): string {
return bindings.keybindings.getDisplayString(action) || "Disabled";
}
export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string {
const { expandToolsKey, planModeKey, sttKey, copyLineKey, copyPromptKey } = bindings;
return [
"**Navigation**",
"| Key | Action |",
@@ -25,28 +26,29 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
"| `Ctrl+W` / `Option+Backspace` | Delete word backwards |",
"| `Ctrl+U` | Delete to start of line |",
"| `Ctrl+K` | Delete to end of line |",
`| \`${copyLineKey}\` | Copy current line |`,
`| \`${copyPromptKey}\` | Copy whole prompt |`,
`| \`${appKey(bindings, "copyLine")}\` | Copy current line |`,
`| \`${appKey(bindings, "copyPrompt")}\` | Copy whole prompt |`,
"",
"**Other**",
"| Key | Action |",
"|-----|--------|",
"| `Tab` | Path completion / accept autocomplete |",
"| `Escape` | Cancel autocomplete / abort streaming |",
"| `Ctrl+C` | Clear editor (first) / exit (second) |",
"| `Ctrl+D` | Exit (when editor is empty) |",
"| `Ctrl+Z` | Suspend to background |",
"| `Shift+Tab` | Cycle thinking level |",
"| `Ctrl+P` | Cycle role models (slow/default/smol) |",
"| `Shift+Ctrl+P` | Cycle role models (temporary) |",
`| \`${appKey(bindings, "interrupt")}\` | Cancel autocomplete / interrupt active work |`,
`| \`${appKey(bindings, "clear")}\` | Clear editor (first) / exit (second) |`,
`| \`${appKey(bindings, "exit")}\` | Exit (when editor is empty) |`,
`| \`${appKey(bindings, "suspend")}\` | Suspend to background |`,
`| \`${appKey(bindings, "cycleThinkingLevel")}\` | Cycle thinking level |`,
`| \`${appKey(bindings, "cycleModelForward")}\` | Cycle role models (slow/default/smol) |`,
`| \`${appKey(bindings, "cycleModelBackward")}\` | Cycle role models (temporary) |`,
"| `Alt+P` | Select model (temporary) |",
"| `Ctrl+L` | Select model (set roles) |",
`| \`${planModeKey}\` | Toggle plan mode |`,
"| `Ctrl+R` | Search prompt history |",
`| \`${expandToolsKey}\` | Toggle tool output expansion |`,
"| `Ctrl+T` | Toggle todo list expansion |",
"| `Ctrl+G` | Edit message in external editor |",
`| \`${sttKey}\` | Toggle speech-to-text recording |`,
`| \`${appKey(bindings, "selectModel")}\` | Select model (set roles) |`,
`| \`${appKey(bindings, "togglePlanMode")}\` | Toggle plan mode |`,
`| \`${appKey(bindings, "historySearch")}\` | Search prompt history |`,
`| \`${appKey(bindings, "expandTools")}\` | Toggle tool output expansion |`,
`| \`${appKey(bindings, "toggleThinking")}\` | Toggle thinking block visibility |`,
`| \`${appKey(bindings, "externalEditor")}\` | Edit message in external editor |`,
`| \`${appKey(bindings, "pasteImage")}\` | Paste image from clipboard |`,
`| \`${appKey(bindings, "toggleSTT")}\` | Toggle speech-to-text recording |`,
"| `#` | Open prompt actions |",
"| `/` | Slash commands |",
"| `!` | Run bash command |",
@@ -6,24 +6,29 @@ type FakeEditor = {
onEscape?: () => void;
onSubmit?: (text: string) => Promise<void>;
shouldBypassAutocompleteOnEscape?: () => boolean;
onCtrlC?: () => void;
onCtrlD?: () => void;
onCtrlZ?: () => void;
onShiftTab?: () => void;
onCtrlP?: () => void;
onShiftCtrlP?: () => void;
onAltP?: () => void;
onCtrlL?: () => void;
onCtrlR?: () => void;
onQuestionMark?: () => void;
onCtrlV?: () => void;
onClear?: () => void;
onExit?: () => void;
onSuspend?: () => void;
onCycleThinkingLevel?: () => void;
onCycleModelForward?: () => void;
onCycleModelBackward?: () => void;
onQuickSelectModel?: () => void;
onSelectModel?: () => void;
onHistorySearch?: () => void;
onShowHotkeys?: () => void;
onPasteImage?: () => void;
onCopyPrompt?: () => void;
onAltUp?: () => void;
onExpandTools?: () => void;
onToggleThinking?: () => void;
onExternalEditor?: () => void;
onDequeue?: () => void;
onChange?: (text: string) => void;
setText(text: string): void;
getText(): string;
addToHistory(text: string): void;
setActionKeys(action: string, keys: string[]): void;
setCustomKeyHandler(key: string, handler: () => void): void;
clearCustomKeyHandlers(): void;
};
function createSubmission(input: {
@@ -83,7 +88,9 @@ function createContext(): {
return editorText;
},
addToHistory: vi.fn(),
setActionKeys: vi.fn(),
setCustomKeyHandler: vi.fn(),
clearCustomKeyHandlers: vi.fn(),
};
let ctx!: InteractiveModeContext;
@@ -3,12 +3,31 @@ import { buildHotkeysMarkdown } from "../../../src/modes/utils/hotkeys-markdown"
describe("buildHotkeysMarkdown", () => {
it("emits flush-left markdown so headings and tables are parsed instead of treated as indented text", () => {
const displayStrings: Record<string, string> = {
copyLine: "Alt+Shift+L",
copyPrompt: "Ctrl+Shift+P",
togglePlanMode: "Alt+M",
expandTools: "Ctrl+O",
interrupt: "Esc",
clear: "Ctrl+C",
exit: "Ctrl+D",
suspend: "Ctrl+Z",
cycleThinkingLevel: "Shift+Tab",
cycleModelForward: "Ctrl+P",
cycleModelBackward: "Shift+Ctrl+P",
selectModel: "Ctrl+L",
historySearch: "Ctrl+R",
toggleThinking: "Ctrl+T",
externalEditor: "Ctrl+G",
pasteImage: "Ctrl+V",
toggleSTT: "Alt+H",
};
const markdown = buildHotkeysMarkdown({
expandToolsKey: "Ctrl+O",
planModeKey: "Alt+M",
sttKey: "Alt+H",
copyLineKey: "Alt+Shift+L",
copyPromptKey: "Ctrl+Shift+P",
keybindings: {
getDisplayString(action) {
return displayStrings[action] ?? "Disabled";
},
},
});
const lines = markdown.split("\n");