Make temporary model selector keybinding configurable (#539)

Fixes #533
This commit is contained in:
daandden
2026-03-26 21:33:26 +07:00
committed by GitHub
parent 51715304e3
commit 6ea9504b03
10 changed files with 343 additions and 8 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added configurable `app.model.selectTemporary` keybinding for temporary model selection.
## [13.15.0] - 2026-03-23
### Breaking Changes
@@ -25,6 +25,7 @@ interface AppKeybindings {
"app.model.cycleForward": true;
"app.model.cycleBackward": true;
"app.model.select": true;
"app.model.selectTemporary": true;
"app.tools.expand": true;
"app.editor.external": true;
"app.message.followUp": true;
@@ -95,6 +96,10 @@ export const KEYBINDINGS = {
defaultKeys: "ctrl+l",
description: "Select model",
},
"app.model.selectTemporary": {
defaultKeys: "alt+p",
description: "Select temporary model for current session",
},
"app.tools.expand": {
defaultKeys: "ctrl+o",
description: "Expand tools",
@@ -194,6 +199,7 @@ const KEYBINDING_NAME_MIGRATIONS = {
cycleModelForward: "app.model.cycleForward",
cycleModelBackward: "app.model.cycleBackward",
selectModel: "app.model.select",
selectModelTemporary: "app.model.selectTemporary",
togglePlanMode: "app.plan.toggle",
historySearch: "app.history.search",
expandTools: "app.tools.expand",
@@ -11,6 +11,7 @@ type ConfigurableEditorAction = Extract<
| "app.model.cycleForward"
| "app.model.cycleBackward"
| "app.model.select"
| "app.model.selectTemporary"
| "app.tools.expand"
| "app.thinking.toggle"
| "app.editor.external"
@@ -29,6 +30,7 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
"app.model.cycleForward": ["ctrl+p"],
"app.model.cycleBackward": ["shift+ctrl+p"],
"app.model.select": ["ctrl+l"],
"app.model.selectTemporary": ["alt+p"],
"app.tools.expand": ["ctrl+o"],
"app.thinking.toggle": ["ctrl+t"],
"app.editor.external": ["ctrl+g"],
@@ -56,7 +58,7 @@ export class CustomEditor extends Editor {
onHistorySearch?: () => void;
onSuspend?: () => void;
onShowHotkeys?: () => void;
onQuickSelectModel?: () => void;
onSelectModelTemporary?: () => void;
/** Called when the configured copy-prompt shortcut is pressed. */
onCopyPrompt?: () => void;
/** Called when the configured image-paste shortcut is pressed. */
@@ -126,9 +128,9 @@ export class CustomEditor extends Editor {
return;
}
// Intercept Alt+P for quick model switching
if (matchesKey(data, "alt+p") && this.onQuickSelectModel) {
this.onQuickSelectModel();
// Intercept configured temporary model selector shortcut
if (this.#matchesAction(data, "app.model.selectTemporary") && this.onSelectModelTemporary) {
this.onSelectModelTemporary();
return;
}
@@ -96,7 +96,11 @@ export class InputController {
this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel();
this.ctx.editor.setActionKeys("app.model.cycleBackward", this.ctx.keybindings.getKeys("app.model.cycleBackward"));
this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel({ temporary: true });
this.ctx.editor.onQuickSelectModel = () => this.ctx.showModelSelector({ temporaryOnly: true });
this.ctx.editor.setActionKeys(
"app.model.selectTemporary",
this.ctx.keybindings.getKeys("app.model.selectTemporary"),
);
this.ctx.editor.onSelectModelTemporary = () => this.ctx.showModelSelector({ temporaryOnly: true });
// Global debug handler on TUI (works regardless of focus)
this.ctx.ui.onDebug = () => this.ctx.showDebugSelector();
@@ -40,7 +40,7 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
`| \`${appKey(bindings, "app.thinking.cycle")}\` | Cycle thinking level |`,
`| \`${appKey(bindings, "app.model.cycleForward")}\` | Cycle role models (slow/default/smol) |`,
`| \`${appKey(bindings, "app.model.cycleBackward")}\` | Cycle role models (temporary) |`,
"| `Alt+P` | Select model (temporary) |",
`| \`${appKey(bindings, "app.model.selectTemporary")}\` | Select model (temporary) |`,
`| \`${appKey(bindings, "app.model.select")}\` | Select model (set roles) |`,
`| \`${appKey(bindings, "app.plan.toggle")}\` | Toggle plan mode |`,
`| \`${appKey(bindings, "app.history.search")}\` | Search prompt history |`,
@@ -0,0 +1,105 @@
import { describe, expect, it, mock, vi } from "bun:test";
import { defaultEditorTheme } from "../../tui/test/test-themes";
function createPiNativesMock() {
function parseCtrl(data: string): string | undefined {
if (data.length !== 1) return undefined;
const code = data.charCodeAt(0);
if (code < 1 || code > 26) return undefined;
return `ctrl+${String.fromCharCode(code + 96)}`;
}
function parseKey(data: string): string | undefined {
if (data === "\x1bp") return "alt+p";
return parseCtrl(data);
}
return {
Ellipsis: { Left: "left", Center: "center", Right: "right", Omit: "omit" },
FileType: { File: "file", Dir: "dir" },
ImageFormat: { Png: "png", Jpeg: "jpeg", WebP: "webp" },
KeyEventType: { Press: 1, Repeat: 2, Release: 3 },
SamplingFilter: { Nearest: "nearest" },
PhotonImage: class PhotonImage {},
PtySession: class PtySession {},
Shell: class Shell {},
astEdit: vi.fn(),
astGrep: vi.fn(),
copyToClipboard: vi.fn(),
detectMacOSAppearance: vi.fn(),
encodeSixel: vi.fn(),
executeShell: vi.fn(),
extractSegments: vi.fn((text: string) => ({
before: text,
target: "",
after: "",
beforeWidth: text.length,
targetWidth: 0,
afterWidth: 0,
})),
fuzzyFind: vi.fn(async () => ({ matches: [] })),
getWorkProfile: vi.fn(async () => ({ cpu: [], memory: [] })),
glob: vi.fn(async () => ({ matches: [] })),
grep: vi.fn(async () => ({ matches: [], count: 0, files: [] })),
hasMatch: vi.fn(() => false),
highlightCode: vi.fn((code: string) => code),
htmlToMarkdown: vi.fn((html: string) => html),
invalidateFsScanCache: vi.fn(),
matchesKey: vi.fn((data: string, keyId: string) => parseKey(data) === keyId),
matchesKittySequence: vi.fn(() => false),
matchesLegacySequence: vi.fn(() => false),
parseKey: vi.fn((data: string) => parseKey(data)),
parseKittySequence: vi.fn(() => undefined),
projfsOverlayProbe: vi.fn(),
projfsOverlayStart: vi.fn(),
projfsOverlayStop: vi.fn(),
readImageFromClipboard: vi.fn(async () => null),
sanitizeText: (text: string) => text,
searchContent: vi.fn(async () => ({ matches: [] })),
sliceWithWidth: vi.fn((text: string) => ({ text, width: text.length })),
startMacAppearanceObserver: vi.fn(),
supportsLanguage: vi.fn(() => false),
truncateToWidth: vi.fn((text: string) => text),
visibleWidth: vi.fn((text: string) => text.length),
wrapTextWithAnsi: vi.fn((text: string) => [text]),
};
}
mock.module("@oh-my-pi/pi-natives", () => createPiNativesMock());
function ctrl(key: string): string {
return String.fromCharCode(key.toLowerCase().charCodeAt(0) & 31);
}
async function createEditor() {
const { CustomEditor } = await import("../src/modes/components/custom-editor");
return new CustomEditor(defaultEditorTheme);
}
describe("CustomEditor temporary model selector keybinding", () => {
it("triggers the temporary selector from a remapped action key instead of Alt+P", async () => {
const editor = await createEditor();
const onSelectModelTemporary = vi.fn();
editor.onSelectModelTemporary = onSelectModelTemporary;
editor.setActionKeys("app.model.selectTemporary", ["ctrl+y"]);
editor.handleInput(ctrl("y"));
expect(onSelectModelTemporary).toHaveBeenCalledTimes(1);
editor.handleInput("\x1bp");
expect(onSelectModelTemporary).toHaveBeenCalledTimes(1);
});
it("removes the default Alt+P shortcut when the action is disabled", async () => {
const editor = await createEditor();
const onSelectModelTemporary = vi.fn();
editor.onSelectModelTemporary = onSelectModelTemporary;
editor.handleInput("\x1bp");
expect(onSelectModelTemporary).toHaveBeenCalledTimes(1);
editor.setActionKeys("app.model.selectTemporary", []);
editor.handleInput("\x1bp");
expect(onSelectModelTemporary).toHaveBeenCalledTimes(1);
});
});
@@ -12,7 +12,7 @@ type FakeEditor = {
onCycleThinkingLevel?: () => void;
onCycleModelForward?: () => void;
onCycleModelBackward?: () => void;
onQuickSelectModel?: () => void;
onSelectModelTemporary?: () => void;
onSelectModel?: () => void;
onHistorySearch?: () => void;
onShowHotkeys?: () => void;
@@ -0,0 +1,188 @@
import { describe, expect, it, mock, vi } from "bun:test";
import type { InteractiveModeContext } from "../src/modes/types";
function createPiNativesMock() {
function parseCtrl(data: string): string | undefined {
if (data.length !== 1) return undefined;
const code = data.charCodeAt(0);
if (code < 1 || code > 26) return undefined;
return `ctrl+${String.fromCharCode(code + 96)}`;
}
function parseKey(data: string): string | undefined {
if (data === "\x1bp") return "alt+p";
return parseCtrl(data);
}
return {
Ellipsis: { Left: "left", Center: "center", Right: "right", Omit: "omit" },
FileType: { File: "file", Dir: "dir" },
ImageFormat: { Png: "png", Jpeg: "jpeg", WebP: "webp" },
KeyEventType: { Press: 1, Repeat: 2, Release: 3 },
SamplingFilter: { Nearest: "nearest" },
PhotonImage: class PhotonImage {},
PtySession: class PtySession {},
Shell: class Shell {},
astEdit: vi.fn(),
astGrep: vi.fn(),
copyToClipboard: vi.fn(),
detectMacOSAppearance: vi.fn(),
encodeSixel: vi.fn(),
executeShell: vi.fn(),
extractSegments: vi.fn((text: string) => ({
before: text,
target: "",
after: "",
beforeWidth: text.length,
targetWidth: 0,
afterWidth: 0,
})),
fuzzyFind: vi.fn(async () => ({ matches: [] })),
getWorkProfile: vi.fn(async () => ({ cpu: [], memory: [] })),
glob: vi.fn(async () => ({ matches: [] })),
grep: vi.fn(async () => ({ matches: [], count: 0, files: [] })),
hasMatch: vi.fn(() => false),
highlightCode: vi.fn((code: string) => code),
htmlToMarkdown: vi.fn((html: string) => html),
invalidateFsScanCache: vi.fn(),
matchesKey: vi.fn((data: string, keyId: string) => parseKey(data) === keyId),
matchesKittySequence: vi.fn(() => false),
matchesLegacySequence: vi.fn(() => false),
parseKey: vi.fn((data: string) => parseKey(data)),
parseKittySequence: vi.fn(() => undefined),
projfsOverlayProbe: vi.fn(),
projfsOverlayStart: vi.fn(),
projfsOverlayStop: vi.fn(),
readImageFromClipboard: vi.fn(async () => null),
sanitizeText: (text: string) => text,
searchContent: vi.fn(async () => ({ matches: [] })),
sliceWithWidth: vi.fn((text: string) => ({ text, width: text.length })),
startMacAppearanceObserver: vi.fn(),
supportsLanguage: vi.fn(() => false),
truncateToWidth: vi.fn((text: string) => text),
visibleWidth: vi.fn((text: string) => text.length),
wrapTextWithAnsi: vi.fn((text: string) => [text]),
};
}
mock.module("@oh-my-pi/pi-natives", () => createPiNativesMock());
type FakeEditor = {
onEscape?: () => void;
shouldBypassAutocompleteOnEscape?: () => boolean;
onClear?: () => void;
onExit?: () => void;
onSuspend?: () => void;
onCycleThinkingLevel?: () => void;
onCycleModelForward?: () => void;
onCycleModelBackward?: () => void;
onSelectModelTemporary?: () => void;
onSelectModel?: () => void;
onHistorySearch?: () => void;
onShowHotkeys?: () => void;
onPasteImage?: () => Promise<boolean>;
onCopyPrompt?: () => 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;
};
async function createContext() {
const { InputController } = await import("../src/modes/controllers/input-controller");
let editorText = "";
const keyMap: Record<string, string[]> = {
"app.model.selectTemporary": ["ctrl+y"],
"app.model.select": ["ctrl+l"],
};
const setActionKeys = vi.fn();
const showModelSelector = vi.fn();
const editor: FakeEditor = {
setText(text: string) {
editorText = text;
},
getText() {
return editorText;
},
addToHistory: vi.fn(),
setActionKeys,
setCustomKeyHandler: vi.fn(),
clearCustomKeyHandlers: vi.fn(),
};
const ctx = {
editor: editor as unknown as InteractiveModeContext["editor"],
ui: {} as InteractiveModeContext["ui"],
loadingAnimation: undefined,
autoCompactionLoader: undefined,
retryLoader: undefined,
autoCompactionEscapeHandler: undefined,
retryEscapeHandler: undefined,
session: {
isStreaming: false,
isCompacting: false,
isGeneratingHandoff: false,
isBashRunning: false,
isPythonRunning: false,
extensionRunner: undefined,
} as InteractiveModeContext["session"],
keybindings: {
getKeys(action: string) {
return keyMap[action] ? [...keyMap[action]] : [];
},
} as InteractiveModeContext["keybindings"],
pendingImages: [],
isBashMode: false,
isPythonMode: false,
handleHotkeysCommand: vi.fn(),
handlePlanModeCommand: vi.fn(),
handleClearCommand: vi.fn(),
showTreeSelector: vi.fn(),
showUserMessageSelector: vi.fn(),
showSessionSelector: vi.fn(),
handleSTTToggle: vi.fn(),
showDebugSelector: vi.fn(),
showHistorySearch: vi.fn(),
toggleThinkingBlockVisibility: vi.fn(),
showModelSelector,
updateEditorBorderColor: vi.fn(),
hasActiveBtw: vi.fn(() => false),
} as unknown as InteractiveModeContext;
return {
InputController,
ctx,
editor,
spies: {
setActionKeys,
showModelSelector,
},
};
}
describe("InputController keybinding setup", () => {
it("registers temporary and persisted model selector actions separately", async () => {
const { InputController, ctx, editor, spies } = await createContext();
const controller = new InputController(ctx);
controller.setupKeyHandlers();
expect(spies.setActionKeys).toHaveBeenCalledWith("app.model.selectTemporary", ["ctrl+y"]);
expect(spies.setActionKeys).toHaveBeenCalledWith("app.model.select", ["ctrl+l"]);
expect(editor.onSelectModelTemporary).toBeDefined();
expect(editor.onSelectModel).toBeDefined();
expect(editor.onSelectModelTemporary).not.toBe(editor.onSelectModel);
editor.onSelectModelTemporary?.();
editor.onSelectModel?.();
expect(spies.showModelSelector).toHaveBeenNthCalledWith(1, { temporaryOnly: true });
expect(spies.showModelSelector).toHaveBeenNthCalledWith(2);
});
});
@@ -25,6 +25,7 @@ describe("KeybindingsManager.create", () => {
fork: "ctrl+f",
selectConfirm: "enter",
cursorUp: "ctrl+p",
selectModelTemporary: "alt+y",
},
null,
2,
@@ -38,11 +39,14 @@ describe("KeybindingsManager.create", () => {
expect(manager.getKeys("app.session.fork")).toEqual(["ctrl+f"]);
expect(manager.getKeys("tui.select.confirm")).toEqual(["enter"]);
expect(manager.getKeys("tui.editor.cursorUp")).toEqual(["ctrl+p"]);
expect(manager.getKeys("app.model.selectTemporary")).toEqual(["alt+y"]);
expect(writtenConfig).toEqual({
"app.model.selectTemporary": "alt+y",
"app.session.fork": "ctrl+f",
"tui.editor.cursorUp": "ctrl+p",
"tui.select.confirm": "enter",
});
expect(writtenConfig).not.toHaveProperty("selectModelTemporary");
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
@@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test";
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", () => {
it("emits flush-left markdown and uses the configured temporary selector hint", () => {
const displayStrings: Record<string, string> = {
"app.clipboard.copyLine": "Alt+Shift+L",
"app.clipboard.copyPrompt": "Ctrl+Shift+P",
@@ -15,6 +15,7 @@ describe("buildHotkeysMarkdown", () => {
"app.thinking.cycle": "Shift+Tab",
"app.model.cycleForward": "Ctrl+P",
"app.model.cycleBackward": "Shift+Ctrl+P",
"app.model.selectTemporary": "Ctrl+Shift+L",
"app.model.select": "Ctrl+L",
"app.history.search": "Ctrl+R",
"app.thinking.toggle": "Ctrl+T",
@@ -33,6 +34,8 @@ describe("buildHotkeysMarkdown", () => {
const lines = markdown.split("\n");
expect(lines[0]).toBe("**Navigation**");
expect(markdown).toContain("| `Ctrl+Shift+P` | Copy whole prompt |");
expect(markdown).toContain("| `Ctrl+Shift+L` | Select model (temporary) |");
expect(markdown).toContain("| `Ctrl+L` | Select model (set roles) |");
expect(markdown).toContain("| `Alt+M` | Toggle plan mode |");
expect(markdown).toContain("| `#` | Open prompt actions |");
for (const line of lines) {
@@ -41,4 +44,23 @@ describe("buildHotkeysMarkdown", () => {
expect(line.startsWith("\t")).toBe(false);
}
});
it("renders the temporary selector row as disabled when no display string is configured", () => {
const markdown = buildHotkeysMarkdown({
keybindings: {
getDisplayString(action) {
if (action === "app.model.selectTemporary") {
return "";
}
if (action === "app.model.select") {
return "Ctrl+L";
}
return "Ctrl+K";
},
},
});
expect(markdown).toContain("| `Disabled` | Select model (temporary) |");
expect(markdown).toContain("| `Ctrl+L` | Select model (set roles) |");
});
});