From f42e1de761249c60714398b1b2b6a54215729455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=A7=80=E5=A5=87?= Date: Wed, 21 Oct 2026 21:09:46 +0800 Subject: [PATCH] fix(cli): save model selection as default on Enter The /model picker had two save paths: Enter switched the model for the current session only, while Ctrl+S additionally persisted it as the startup default. The session model and the startup default therefore drifted apart silently whenever Enter was used, and the only way to re-align them was to discover the Ctrl+S chord. Peer tools confirm-and-save on a single Enter. Enter now selects and saves as default, and the Ctrl+S branch is removed from the picker. Exact `/model ` matches persist as well, so every selection path shares one rule: picking a model saves it. The picker hint, status messages, tests, and docs follow. * fix(cli): Enter in the model picker saves the selection as default * fix(cli): drop the Ctrl+S save-as-default branch from the picker * fix(cli): persist exact /model matches and report them as defaults * test(cli): cover the new picker hint and drop the Ctrl+S case * docs: describe Enter as the save path in the model docs --- apps/cli/src/ui/interactive-mode.ts | 13 ++++--- .../cli/src/ui/view/dialogs/model-selector.ts | 18 +--------- apps/cli/test/model-selector.test.ts | 36 +++++++++++++------ docs/recent-models.md | 2 +- packages/coding-agent/README.md | 4 +-- packages/coding-agent/docs/quickstart.md | 2 +- packages/coding-agent/docs/settings.md | 6 ++-- packages/coding-agent/docs/usage.md | 2 +- 8 files changed, 41 insertions(+), 42 deletions(-) diff --git a/apps/cli/src/ui/interactive-mode.ts b/apps/cli/src/ui/interactive-mode.ts index 504cf4b9..272b9ff8 100644 --- a/apps/cli/src/ui/interactive-mode.ts +++ b/apps/cli/src/ui/interactive-mode.ts @@ -4896,11 +4896,11 @@ export class InteractiveMode { const model = await this.findExactModelMatch(searchTerm); if (model) { try { - await this.session.setModel(model, { persist: false }); + await this.session.setModel(model, { persist: true }); this.settingsManager.recordRecentModel(model.provider, model.id); this.footer.invalidate(); this.updateEditorBorderColor(); - this.showStatus(`Model: ${model.id}`); + this.showStatus(`Default model: ${model.provider}/${model.id}`); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); } catch (error) { this.showError(error instanceof Error ? error.message : String(error)); @@ -5038,15 +5038,15 @@ export class InteractiveMode { showModelSelector(initialSearchInput?: string): void { this.showSelector((done) => { - const selectModel = async (model: Model, persist: boolean) => { + const selectModel = async (model: Model) => { try { - await this.session.setModel(model, { persist }); + await this.session.setModel(model, { persist: true }); this.settingsManager.recordRecentModel(model.provider, model.id); this.updateAvailableProviderCount(); this.footer.invalidate(); this.updateEditorBorderColor(); done(); - this.showStatus(persist ? `Default model: ${model.provider}/${model.id}` : `Model: ${model.id}`); + this.showStatus(`Default model: ${model.provider}/${model.id}`); void this.maybeWarnAboutAnthropicSubscriptionAuth(model); } catch (error) { done(); @@ -5060,13 +5060,12 @@ export class InteractiveMode { this.session.model, this.session.modelRuntime, this.session.scopedModels, - (model) => selectModel(model, false), + selectModel, () => { done(); this.redraw.requestRender(); }, initialSearchInput, - (model) => selectModel(model, true), defaultProvider && defaultModel ? { provider: defaultProvider, id: defaultModel } : undefined, this.settingsManager.getRecentModels(), ); diff --git a/apps/cli/src/ui/view/dialogs/model-selector.ts b/apps/cli/src/ui/view/dialogs/model-selector.ts index 75e6f8c5..2c891155 100644 --- a/apps/cli/src/ui/view/dialogs/model-selector.ts +++ b/apps/cli/src/ui/view/dialogs/model-selector.ts @@ -6,7 +6,6 @@ import { fuzzyFilter, getKeybindings, Input, - matchesKey, Spacer, Text, type TUI, @@ -57,7 +56,6 @@ export class ModelSelectorComponent extends Container implements Focusable { private currentModel?: Model; private modelRuntime: ModelRuntime; private onSelectCallback: (model: Model) => void; - private onSelectAsDefaultCallback?: (model: Model) => void; private onCancelCallback: () => void; private errorMessage?: string; private refreshStatusMessage = "Refreshing model catalogs…"; @@ -82,7 +80,6 @@ export class ModelSelectorComponent extends Container implements Focusable { onSelect: (model: Model) => void, onCancel: () => void, initialSearchInput?: string, - onSelectAsDefault?: (model: Model) => void, defaultModel?: DefaultModelReference, recentModels: ReadonlyArray = [], ) { @@ -96,7 +93,6 @@ export class ModelSelectorComponent extends Container implements Focusable { this.recentModels = recentModels; this.scope = scopedModels.length > 0 ? "scoped" : "all"; this.onSelectCallback = onSelect; - this.onSelectAsDefaultCallback = onSelectAsDefault; this.onCancelCallback = onCancel; // Add top border @@ -137,11 +133,7 @@ export class ModelSelectorComponent extends Container implements Focusable { this.addChild(new Spacer(1)); // 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), - ); - } + this.addChild(new Text(theme.fg("dim", " Enter to select and save as default \u00b7 Esc to cancel"), 0, 0)); // Add bottom border this.addChild(new DynamicBorder()); @@ -424,14 +416,6 @@ 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) { - const selectedModel = this.filteredModels[this.selectedIndex]; - if (selectedModel) { - this.dispose(); - this.onSelectAsDefaultCallback(selectedModel.model); - } - } // Pass everything else to search input else { this.searchInput.handleInput(keyData); diff --git a/apps/cli/test/model-selector.test.ts b/apps/cli/test/model-selector.test.ts index 04c9a0a9..db6fd1ef 100644 --- a/apps/cli/test/model-selector.test.ts +++ b/apps/cli/test/model-selector.test.ts @@ -51,6 +51,22 @@ describe("model selector", () => { }); }); + it("advertises Enter as select-and-save and no longer mentions Ctrl+S", async () => { + harness = await createHarness(); + selector = new ModelSelectorComponent( + createFakeTui(), + harness.getModel(), + harness.session.modelRuntime, + [], + () => {}, + () => {}, + ); + + const rendered = stripAnsi(selector.render(120).join("\n")); + expect(rendered).toContain("Enter to select and save as default"); + expect(rendered).not.toContain("Ctrl+S"); + }); + it("shows recent models first once, with current/default badges and keyboard selection", async () => { harness = await createHarness({ models: [{ id: "alpha", name: "Alpha" }, { id: "beta", name: "Beta" }, { id: "gamma", name: "Gamma" }], @@ -59,7 +75,7 @@ describe("model selector", () => { const onSelect = vi.fn(); selector = new ModelSelectorComponent( createFakeTui(), beta, harness.session.modelRuntime, [], onSelect, () => {}, - undefined, undefined, beta, [gamma, beta, gamma, { provider: "missing", id: "missing" }], + undefined, beta, [gamma, beta, gamma, { provider: "missing", id: "missing" }], ); await vi.waitFor(() => expect(stripAnsi(selector!.render(120).join("\n"))).toContain("Model catalogs refreshed.")); const rendered = stripAnsi(selector.render(120).join("\n")); @@ -79,10 +95,10 @@ describe("model selector", () => { models: [{ id: "alpha", name: "Alpha" }, { id: "beta", name: "Beta" }], }); const [alpha, beta] = harness.models; - const onDefault = vi.fn(); + const onSelect = vi.fn(); selector = new ModelSelectorComponent( - createFakeTui(), alpha, harness.session.modelRuntime, [], () => {}, () => {}, - undefined, onDefault, beta, [beta], + createFakeTui(), alpha, harness.session.modelRuntime, [], onSelect, () => {}, + undefined, beta, [beta], ); for (const char of "default") selector.handleInput(char); const searched = stripAnsi(selector.render(120).join("\n")); @@ -92,8 +108,8 @@ describe("model selector", () => { expect(searched).toContain(`→ ${beta.id}`); for (const char of "default") selector.handleInput("\x7f"); expect(stripAnsi(selector.render(120).join("\n"))).toContain("Recent"); - selector.handleInput("\x13"); - expect(onDefault).toHaveBeenCalledWith(expect.objectContaining({ id: beta.id })); + selector.handleInput("\r"); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: beta.id })); }); it("filters recent models to the active scope and restores them in All", async () => { @@ -103,7 +119,7 @@ describe("model selector", () => { const [alpha, beta, gamma] = harness.models; selector = new ModelSelectorComponent( createFakeTui(), beta, harness.session.modelRuntime, [{ model: beta }, { model: alpha }], - () => {}, () => {}, undefined, undefined, undefined, [gamma, alpha], + () => {}, () => {}, undefined, undefined, [gamma, alpha], ); const scoped = stripAnsi(selector.render(120).join("\n")); expect(scoped).toContain("Recent"); @@ -120,7 +136,7 @@ describe("model selector", () => { harness = await createHarness(); selector = new ModelSelectorComponent( createFakeTui(), harness.getModel(), harness.session.modelRuntime, [], () => {}, () => {}, - undefined, undefined, undefined, [{ provider: "missing", id: "missing" }], + undefined, undefined, [{ provider: "missing", id: "missing" }], ); expect(stripAnsi(selector.render(120).join("\n"))).not.toContain("Recent"); }); @@ -133,7 +149,7 @@ describe("model selector", () => { const onSelect = vi.fn(); selector = new ModelSelectorComponent( createFakeTui(), model, harness.session.modelRuntime, [], onSelect, () => {}, - undefined, undefined, undefined, [other, model], + undefined, undefined, [other, model], ); const rows = stripAnsi(selector.render(120).join("\n")) .split("\n") @@ -152,7 +168,7 @@ describe("model selector", () => { const onSelect = vi.fn(); selector = new ModelSelectorComponent( createFakeTui(), harness.models[0], harness.session.modelRuntime, [], onSelect, () => {}, - undefined, undefined, undefined, harness.models.slice(0, 10), + undefined, undefined, harness.models.slice(0, 10), ); for (let i = 0; i < 10; i++) selector.handleInput("\x1b[B"); const rendered = stripAnsi(selector.render(120).join("\n")); diff --git a/docs/recent-models.md b/docs/recent-models.md index 87079d48..3ca38af1 100644 --- a/docs/recent-models.md +++ b/docs/recent-models.md @@ -2,7 +2,7 @@ The `/model` selector shows a **Recent** section above the existing model list when the search is empty and history contains models available in the active scope. Recent rows are removed from the remaining list. Search hides the sections and searches the original list once, preserving fuzzy ranking and default-model search. -Successful interactive selections (Enter, Ctrl+S, exact `/model provider/id`, and model cycling) record a provider/id pair. Selecting the same pair again moves it to the front. The history keeps at most ten distinct pairs; identical IDs from different providers remain separate. Cancelling or a failed selection does not record history. Startup and session restoration do not rewrite it. +Successful interactive selections (Enter, exact `/model provider/id`, and model cycling) record a provider/id pair. Selecting the same pair again moves it to the front. The history keeps at most ten distinct pairs; identical IDs from different providers remain separate. Cancelling or a failed selection does not record history. Startup and session restoration do not rewrite it. History is stored as the global settings field `recentModels`, using Step's existing `config.toml` storage adapter (or the embedded host's settings storage). It is global-only, is validated when read, and concurrent sessions merge selections under the existing storage lock. No credentials or model objects are stored. Recent history does not change startup defaults or model cycling order. With `STEPCODE_CONFIG_PATH`, default selection still writes the external active model; history uses the wrapped global settings storage. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index e2e35dcd..c7f35407 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -76,7 +76,7 @@ Then just talk to StepCode. By default, it gives the model four tools: `read`, ` ## Providers & Models -The shipped build authenticates against the **Step provider** by default. Log in with `/login` (or set `STEP_API_KEY`), then select a Step model via `/model` (or Ctrl+L). Press Ctrl+S in the model picker to save the highlighted model as the startup default. +The shipped build authenticates against the **Step provider** by default. Log in with `/login` (or set `STEP_API_KEY`), then select a Step model via `/model` (or Ctrl+L). Press Enter in the model picker to select the highlighted model and save it as the startup default. No other provider catalog is bundled. Add OpenAI- or Anthropic-compatible providers yourself via `~/.stepcode/agent/models.json`; configured catalogs refresh automatically, and `step update --models` forces an immediate refresh. For custom APIs or OAuth, use extensions. @@ -120,7 +120,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist |---------|-------------| | `/login`, `/logout` | Manage provider credentials | | [`/llama`](docs/llama-cpp.md) | Download, load, and unload llama.cpp router models | -| `/model` | Switch models; Ctrl+S in the picker saves the startup default | +| `/model` | Switch models; Enter in the picker saves the startup default | | `/effort` | Switch thinking level and save it as the startup default | | `/scoped-models` | Enable/disable models for Ctrl+P cycling | | `/settings` | Theme, message delivery, transport, and other preferences | diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index 61bd3835..1bf9eed0 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -129,7 +129,7 @@ The command output is sent to the model. Use `!!command` to run a command withou ### Switch models -Use `/model` or Ctrl+L to choose a model for the current session. Press Ctrl+S in the model picker to save the highlighted model as the startup default. Use `/thinking` to choose a thinking level for the current session, or Ctrl+S in that picker to save the startup default thinking level. Use Shift+Tab to cycle thinking level. Use Ctrl+P / Shift+Ctrl+P to cycle through scoped models. +Use `/model` or Ctrl+L to choose a model; the selection is saved as the startup default. Use `/thinking` to choose a thinking level for the current session, or Ctrl+S in that picker to save the startup default thinking level. Use Shift+Tab to cycle thinking level. Use Ctrl+P / Shift+Ctrl+P to cycle through scoped models. ### Continue later diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index bbe2fe1f..4b3a12bc 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -7,7 +7,7 @@ Step uses JSON settings files with project settings overriding global settings. | `~/.stepcode/agent/settings.json` | Global (all projects) | | `.stepcode/settings.json` | Project (current directory) | -Edit directly or use `/settings` for common options. To save startup model defaults interactively, use `/model` and press Ctrl+S on the desired model. To save the startup thinking level, use `/effort` and press Enter. +Edit directly or use `/settings` for common options. To save startup model defaults interactively, use `/model` and press Enter on the desired model. To save the startup thinking level, use `/effort` and press Enter. ## Project Trust @@ -27,8 +27,8 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `defaultProvider` | string | - | Startup provider (e.g., `"anthropic"`, `"openai"`; saved with Ctrl+S in `/model`, or edited manually) | -| `defaultModel` | string | - | Startup model ID (saved with Ctrl+S in `/model`, or edited manually) | +| `defaultProvider` | string | - | Startup provider (e.g., `"anthropic"`, `"openai"`; saved with Enter in `/model`, or edited manually) | +| `defaultModel` | string | - | Startup model ID (saved with Enter in `/model`, or edited manually) | | `defaultThinkingLevel` | string | - | Startup thinking level (saved with Enter in `/effort`, or edited manually): `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` | | `modelThinkingLevels` | object | - | Per-model startup thinking levels keyed by `"provider/modelId"`; configure from `/settings` → Default thinking level per model or edit manually | | `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index b423bc8e..9580ff29 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -38,7 +38,7 @@ Type `/` in the editor to open command completion. Extensions can register custo |---------|-------------| | `/login`, `/logout` | Manage OAuth or API-key credentials | | [`/llama`](llama-cpp.md) | Download, load, and unload llama.cpp router models | -| `/model` | Switch models; Ctrl+S in the picker saves the startup default | +| `/model` | Switch models; Enter in the picker saves the startup default | | `/thinking` | Switch thinking level; Ctrl+S in the picker saves the startup default | | `/scoped-models` | Enable/disable models for Ctrl+P cycling | | `/settings` | Theme, message delivery, transport, and other preferences |