From 92d8e2d17d4f357788381c49ce2cdb3f4ed1f21c Mon Sep 17 00:00:00 2001 From: Ramiz Wachtler Date: Fri, 4 Sep 2026 18:06:21 +0200 Subject: [PATCH] fix(coding-agent): selector save keybindings (#9149) - fixes #8797 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/docs/keybindings.md | 3 ++- packages/coding-agent/src/core/keybindings.ts | 5 ++++ .../interactive/components/model-selector.ts | 16 +++++++++---- .../components/scoped-models-selector.ts | 16 ++++++------- .../components/thinking-selector.ts | 16 +++++++++---- .../coding-agent/test/model-selector.test.ts | 23 +++++++++++++++++++ .../test/thinking-selector.test.ts | 20 +++++++++++++++- 8 files changed, 84 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9bf6a3c70..cd3495b24 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed configurable save keybindings in the model and thinking selectors ([#8797](https://github.com/earendil-works/pi/issues/8797)). + ## [0.85.0] - 2026-09-04 ### New Features diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index ff99ac67d..e2ae16b1a 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -151,7 +151,9 @@ This routing remains configurable through the ordinary action bindings. For exam | `app.model.select` | `ctrl+l` | Open model selector | | `app.model.cycleForward` | `ctrl+p` | Cycle to next model | | `app.model.cycleBackward` | `shift+ctrl+p` (`alt+p` on Windows and WSL) | Cycle to previous model | +| `app.models.save` | `ctrl+s` | Save the selected default model or scoped model configuration to settings | | `app.thinking.cycle` | `shift+tab` | Cycle thinking level | +| `app.thinking.save` | `ctrl+s` | Save current thinking level to settings | | `app.thinking.toggle` | `ctrl+t` | Collapse or expand thinking blocks | ### Display and Message Queue @@ -185,7 +187,6 @@ Used inside the scoped models selector (opened via `/scoped-models`). | Keybinding id | Default | Description | |--------|---------|-------------| -| `app.models.save` | `ctrl+s` | Save current model selection to settings | | `app.models.enableAll` | `ctrl+a` | Enable all models (or all matching the current search) | | `app.models.clearAll` | `ctrl+x` | Clear all models (or all matching the current search) | | `app.models.toggleProvider` | `ctrl+p` | Toggle all models for the current provider | diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 4022bdace..ea70dee80 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -17,6 +17,7 @@ export interface AppKeybindings { "app.exit": true; "app.suspend": true; "app.thinking.cycle": true; + "app.thinking.save": true; "app.model.cycleForward": true; "app.model.cycleBackward": true; "app.model.select": true; @@ -100,6 +101,10 @@ export const KEYBINDINGS = { defaultKeys: "shift+tab", description: "Cycle thinking level", }, + "app.thinking.save": { + defaultKeys: "ctrl+s", + description: "Save thinking level", + }, "app.model.cycleForward": { defaultKeys: "ctrl+p", description: "Cycle to next model", diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 96dd81d7b..c8489c845 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -5,7 +5,6 @@ import { fuzzyFilter, getKeybindings, Input, - matchesKey, Spacer, Text, type TUI, @@ -15,7 +14,7 @@ import { refreshModelCatalogs } from "../model-catalog-refresh.ts"; import { getModelSelectorSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; -import { keyHint } from "./keybinding-hints.ts"; +import { keyDisplayText, keyHint } from "./keybinding-hints.ts"; interface ModelItem { provider: string; @@ -137,7 +136,14 @@ export class ModelSelectorComponent extends Container implements Focusable { // Hint if (this.onSelectAsDefaultCallback) { this.addChild( - new Text(theme.fg("dim", " Enter to select \u00b7 Ctrl+S to set as default \u00b7 Esc to cancel"), 0, 0), + new Text( + theme.fg( + "dim", + ` ${keyDisplayText("tui.select.confirm")} to select · ${keyDisplayText("app.models.save")} to set as default · ${keyDisplayText("tui.select.cancel")} to cancel`, + ), + 0, + 0, + ), ); } @@ -389,8 +395,8 @@ export class ModelSelectorComponent extends Container implements Focusable { this.dispose(); this.onCancelCallback(); } - // Ctrl+S — select and save as default - else if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefaultCallback) { + // Select and save as default + else if (kb.matches(keyData, "app.models.save") && this.onSelectAsDefaultCallback) { const selectedModel = this.filteredModels[this.selectedIndex]; if (selectedModel) { this.dispose(); diff --git a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts index 2ab8e4e06..89c486de6 100644 --- a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -13,7 +13,7 @@ import { import { getModelSearchText } from "../model-search.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; -import { keyText } from "./keybinding-hints.ts"; +import { keyDisplayText } from "./keybinding-hints.ts"; // EnabledIds: null = all enabled (no filter), string[] = explicit ordered list type EnabledIds = string[] | null; @@ -136,7 +136,7 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("accent", theme.bold("Model Configuration")), 0, 0)); this.addChild( - new Text(theme.fg("muted", `Session-only. ${keyText("app.models.save")} to save to settings.`), 0, 0), + new Text(theme.fg("muted", `Session-only. ${keyDisplayText("app.models.save")} to save to settings.`), 0, 0), ); this.addChild(new Spacer(1)); @@ -200,12 +200,12 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl ? "all enabled" : `${enabledCount}/${this.allIds.length} enabled${unavailableCount ? ` · ${unavailableCount} unavailable` : ""}`; const parts = [ - `${keyText("tui.select.confirm")} toggle`, - `${keyText("app.models.enableAll")} all`, - `${keyText("app.models.clearAll")} clear`, - `${keyText("app.models.toggleProvider")} provider`, - `${keyText("app.models.reorderUp")}/${keyText("app.models.reorderDown")} reorder`, - `${keyText("app.models.save")} save`, + `${keyDisplayText("tui.select.confirm")} toggle`, + `${keyDisplayText("app.models.enableAll")} all`, + `${keyDisplayText("app.models.clearAll")} clear`, + `${keyDisplayText("app.models.toggleProvider")} provider`, + `${keyDisplayText("app.models.reorderUp")}/${keyDisplayText("app.models.reorderDown")} reorder`, + `${keyDisplayText("app.models.save")} save`, countText, ]; return this.isDirty diff --git a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts index 1fbf6e0b2..81bf85a8a 100644 --- a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts @@ -5,7 +5,6 @@ import { fuzzyFilter, getKeybindings, Input, - matchesKey, type SelectItem, SelectList, type SelectListLayoutOptions, @@ -91,7 +90,16 @@ export class ThinkingSelectorComponent extends Container implements Focusable { this.selectListChildIndex = this.children.length; this.addChild(this.selectList); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to select · Ctrl+S to set as default · Esc to cancel"), 0, 0)); + this.addChild( + new Text( + theme.fg( + "dim", + ` ${keyDisplayText("tui.select.confirm")} to select · ${keyDisplayText("app.thinking.save")} to set as default · ${keyDisplayText("tui.select.cancel")} to cancel`, + ), + 0, + 0, + ), + ); // Add bottom border this.addChild(new DynamicBorder()); @@ -119,13 +127,13 @@ export class ThinkingSelectorComponent extends Container implements Focusable { } handleInput(keyData: string): void { - if (matchesKey(keyData, "ctrl+s") && this.onSelectAsDefault) { + const kb = getKeybindings(); + if (kb.matches(keyData, "app.thinking.save") && this.onSelectAsDefault) { const item = this.selectList.getSelectedItem(); if (item) this.onSelectAsDefault(item.value as ThinkingLevel); return; } - const kb = getKeybindings(); const isNav = kb.matches(keyData, "tui.select.up") || kb.matches(keyData, "tui.select.down") || diff --git a/packages/coding-agent/test/model-selector.test.ts b/packages/coding-agent/test/model-selector.test.ts index f25e9b103..ac011e309 100644 --- a/packages/coding-agent/test/model-selector.test.ts +++ b/packages/coding-agent/test/model-selector.test.ts @@ -56,6 +56,29 @@ describe("model selector", () => { selector.dispose(); }); + it("uses the configured save binding", async () => { + setKeybindings(new KeybindingsManager({ "app.models.save": "ctrl+r" })); + harness = await createHarness(); + const currentModel = harness.getModel()!; + const saveDefault = vi.fn(); + const selector = new ModelSelectorComponent( + createFakeTui(), + currentModel, + harness.session.modelRuntime, + [], + () => {}, + () => {}, + undefined, + saveDefault, + ); + + expect(stripAnsi(selector.render(120).join("\n"))).toContain("Ctrl+R to set as default"); + selector.handleInput("\x13"); + expect(saveDefault).not.toHaveBeenCalled(); + selector.handleInput("\x12"); + expect(saveDefault).toHaveBeenCalledWith(currentModel); + }); + it("lists every catalog that failed to refresh", async () => { harness = await createHarness(); vi.spyOn(harness.session.modelRuntime, "refresh").mockResolvedValue({ diff --git a/packages/coding-agent/test/thinking-selector.test.ts b/packages/coding-agent/test/thinking-selector.test.ts index a8b41bd96..bd0f1e337 100644 --- a/packages/coding-agent/test/thinking-selector.test.ts +++ b/packages/coding-agent/test/thinking-selector.test.ts @@ -1,5 +1,5 @@ import { setKeybindings } from "@earendil-works/pi-tui"; -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { KeybindingsManager } from "../src/core/keybindings.ts"; import { ThinkingSelectorComponent } from "../src/modes/interactive/components/thinking-selector.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; @@ -34,4 +34,22 @@ describe("thinking selector", () => { expect(getLevelRow("medium")?.startsWith(" ✓ medium")).toBe(true); expect(getLevelRow("high")?.startsWith("→ high")).toBe(true); }); + + it("uses the configured save binding", () => { + setKeybindings(new KeybindingsManager({ "app.thinking.save": "ctrl+r" })); + const saveDefault = vi.fn(); + const selector = new ThinkingSelectorComponent( + "medium", + ["medium", "high"], + () => {}, + () => {}, + saveDefault, + ); + + expect(stripAnsi(selector.render(80).join("\n"))).toContain("Ctrl+R to set as default"); + selector.handleInput("\x13"); + expect(saveDefault).not.toHaveBeenCalled(); + selector.handleInput("\x12"); + expect(saveDefault).toHaveBeenCalledWith("medium"); + }); });