fix(coding-agent): selector save keybindings (#9149)

- fixes #8797
This commit is contained in:
Ramiz Wachtler 2026-09-04 18:06:21 +02:00 • committed by GitHub
parent 47236c8445
commit 92d8e2d17d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 84 additions and 19 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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",

View file

@ -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();

View file

@ -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

View file

@ -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") ||

View file

@ -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({

View file

@ -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");
});
});