diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ea9a513c..a8ef1aac 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -580,18 +580,19 @@ export class AgentSession { } /** - * Cycle to next model. + * Cycle to next/previous model. * Uses scoped models (from --models flag) if available, otherwise all available models. + * @param direction - "forward" (default) or "backward" * @returns The new model info, or null if only one model available */ - async cycleModel(): Promise { + async cycleModel(direction: "forward" | "backward" = "forward"): Promise { if (this._scopedModels.length > 0) { - return this._cycleScopedModel(); + return this._cycleScopedModel(direction); } - return this._cycleAvailableModel(); + return this._cycleAvailableModel(direction); } - private async _cycleScopedModel(): Promise { + private async _cycleScopedModel(direction: "forward" | "backward"): Promise { if (this._scopedModels.length <= 1) return null; const currentModel = this.model; @@ -600,7 +601,8 @@ export class AgentSession { ); if (currentIndex === -1) currentIndex = 0; - const nextIndex = (currentIndex + 1) % this._scopedModels.length; + const len = this._scopedModels.length; + const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const next = this._scopedModels[nextIndex]; // Validate API key @@ -620,7 +622,7 @@ export class AgentSession { return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } - private async _cycleAvailableModel(): Promise { + private async _cycleAvailableModel(direction: "forward" | "backward"): Promise { const availableModels = await this._modelRegistry.getAvailable(); if (availableModels.length <= 1) return null; @@ -630,7 +632,8 @@ export class AgentSession { ); if (currentIndex === -1) currentIndex = 0; - const nextIndex = (currentIndex + 1) % availableModels.length; + const len = availableModels.length; + const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const nextModel = availableModels[nextIndex]; const apiKey = await this._modelRegistry.getApiKey(nextModel); diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index a485b1fc..8a75f0d9 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -3,11 +3,13 @@ import { isCtrlC, isCtrlD, isCtrlG, + isCtrlL, isCtrlO, isCtrlP, isCtrlT, isCtrlZ, isEscape, + isShiftCtrlP, isShiftTab, } from "@mariozechner/pi-tui"; @@ -20,6 +22,8 @@ export class CustomEditor extends Editor { public onCtrlD?: () => void; public onShiftTab?: () => void; public onCtrlP?: () => void; + public onShiftCtrlP?: () => void; + public onCtrlL?: () => void; public onCtrlO?: () => void; public onCtrlT?: () => void; public onCtrlG?: () => void; @@ -44,12 +48,24 @@ export class CustomEditor extends Editor { return; } + // Intercept Ctrl+L for model selector + if (isCtrlL(data) && this.onCtrlL) { + this.onCtrlL(); + return; + } + // Intercept Ctrl+O for tool output expansion if (isCtrlO(data) && this.onCtrlO) { this.onCtrlO(); return; } + // Intercept Shift+Ctrl+P for backward model cycling (check before Ctrl+P) + if (isShiftCtrlP(data) && this.onShiftCtrlP) { + this.onShiftCtrlP(); + return; + } + // Intercept Ctrl+P for model cycling if (isCtrlP(data) && this.onCtrlP) { this.onCtrlP(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 228148d5..cdf451e6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -225,9 +225,12 @@ export class InteractiveMode { theme.fg("dim", "shift+tab") + theme.fg("muted", " to cycle thinking") + "\n" + - theme.fg("dim", "ctrl+p") + + theme.fg("dim", "ctrl+p/shift+ctrl+p") + theme.fg("muted", " to cycle models") + "\n" + + theme.fg("dim", "ctrl+l") + + theme.fg("muted", " to select model") + + "\n" + theme.fg("dim", "ctrl+o") + theme.fg("muted", " to expand tools") + "\n" + @@ -592,7 +595,9 @@ export class InteractiveMode { this.editor.onCtrlD = () => this.handleCtrlD(); this.editor.onCtrlZ = () => this.handleCtrlZ(); this.editor.onShiftTab = () => this.cycleThinkingLevel(); - this.editor.onCtrlP = () => this.cycleModel(); + this.editor.onCtrlP = () => this.cycleModel("forward"); + this.editor.onShiftCtrlP = () => this.cycleModel("backward"); + this.editor.onCtrlL = () => this.showModelSelector(); this.editor.onCtrlO = () => this.toggleToolOutputExpansion(); this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility(); this.editor.onCtrlG = () => this.openExternalEditor(); @@ -1232,9 +1237,9 @@ export class InteractiveMode { } } - private async cycleModel(): Promise { + private async cycleModel(direction: "forward" | "backward"): Promise { try { - const result = await this.session.cycleModel(); + const result = await this.session.cycleModel(direction); if (result === null) { const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available"; this.showStatus(msg); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 1e8bb605..1170c6d8 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -35,6 +35,7 @@ export { isCtrlE, isCtrlG, isCtrlK, + isCtrlL, isCtrlLeft, isCtrlO, isCtrlP, @@ -48,6 +49,7 @@ export { isEnter, isEscape, isHome, + isShiftCtrlP, isShiftEnter, isShiftTab, isTab, diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index d7a82cd4..edd2c06d 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -30,6 +30,7 @@ const CODEPOINTS = { e: 101, g: 103, k: 107, + l: 108, o: 111, p: 112, t: 116, @@ -164,6 +165,7 @@ export const Keys = { CTRL_E: kittySequence(CODEPOINTS.e, MODIFIERS.ctrl), CTRL_G: kittySequence(CODEPOINTS.g, MODIFIERS.ctrl), CTRL_K: kittySequence(CODEPOINTS.k, MODIFIERS.ctrl), + CTRL_L: kittySequence(CODEPOINTS.l, MODIFIERS.ctrl), CTRL_O: kittySequence(CODEPOINTS.o, MODIFIERS.ctrl), CTRL_P: kittySequence(CODEPOINTS.p, MODIFIERS.ctrl), CTRL_T: kittySequence(CODEPOINTS.t, MODIFIERS.ctrl), @@ -220,6 +222,7 @@ const RAW = { CTRL_E: "\x05", CTRL_G: "\x07", CTRL_K: "\x0b", + CTRL_L: "\x0c", CTRL_O: "\x0f", CTRL_P: "\x10", CTRL_T: "\x14", @@ -285,6 +288,14 @@ export function isCtrlK(data: string): boolean { ); } +/** + * Check if input matches Ctrl+L (raw byte or Kitty protocol). + * Ignores lock key bits. + */ +export function isCtrlL(data: string): boolean { + return data === RAW.CTRL_L || data === Keys.CTRL_L || matchesKittySequence(data, CODEPOINTS.l, MODIFIERS.ctrl); +} + /** * Check if input matches Ctrl+O (raw byte or Kitty protocol). * Ignores lock key bits. @@ -301,6 +312,14 @@ export function isCtrlP(data: string): boolean { return data === RAW.CTRL_P || data === Keys.CTRL_P || matchesKittySequence(data, CODEPOINTS.p, MODIFIERS.ctrl); } +/** + * Check if input matches Shift+Ctrl+P (Kitty protocol only). + * Ignores lock key bits. + */ +export function isShiftCtrlP(data: string): boolean { + return matchesKittySequence(data, CODEPOINTS.p, MODIFIERS.shift + MODIFIERS.ctrl); +} + /** * Check if input matches Ctrl+T (raw byte or Kitty protocol). * Ignores lock key bits.