diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1c450c1a9..fe0eb10ff 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added configurable `app.model.selectTemporary` keybinding for temporary model selection. + ## [13.15.0] - 2026-03-23 ### Breaking Changes diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 44d954e4a..c7b92e832 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -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", diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index 94ec30856..2d424c971 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -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 = { "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; } diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index bee61c4de..dcccb433f 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -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(); diff --git a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts index 3f1b75f49..e19b42a2a 100644 --- a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts +++ b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts @@ -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 |`, diff --git a/packages/coding-agent/test/custom-editor-keybindings.test.ts b/packages/coding-agent/test/custom-editor-keybindings.test.ts new file mode 100644 index 000000000..3c59d23bf --- /dev/null +++ b/packages/coding-agent/test/custom-editor-keybindings.test.ts @@ -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); + }); +}); diff --git a/packages/coding-agent/test/input-controller-escape.test.ts b/packages/coding-agent/test/input-controller-escape.test.ts index a419d434d..c8b73c1d8 100644 --- a/packages/coding-agent/test/input-controller-escape.test.ts +++ b/packages/coding-agent/test/input-controller-escape.test.ts @@ -12,7 +12,7 @@ type FakeEditor = { onCycleThinkingLevel?: () => void; onCycleModelForward?: () => void; onCycleModelBackward?: () => void; - onQuickSelectModel?: () => void; + onSelectModelTemporary?: () => void; onSelectModel?: () => void; onHistorySearch?: () => void; onShowHotkeys?: () => void; diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts new file mode 100644 index 000000000..1e0b17e24 --- /dev/null +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -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; + 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 = { + "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); + }); +}); diff --git a/packages/coding-agent/test/keybindings-migration.test.ts b/packages/coding-agent/test/keybindings-migration.test.ts index 4a83ba06b..cf21a1d8c 100644 --- a/packages/coding-agent/test/keybindings-migration.test.ts +++ b/packages/coding-agent/test/keybindings-migration.test.ts @@ -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 }); } diff --git a/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts b/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts index cecd42a47..3bc19bfef 100644 --- a/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts +++ b/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts @@ -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 = { "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) |"); + }); });