Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions apps/cli/src/ui/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -5038,15 +5038,15 @@ export class InteractiveMode {

showModelSelector(initialSearchInput?: string): void {
this.showSelector((done) => {
const selectModel = async (model: Model<any>, persist: boolean) => {
const selectModel = async (model: Model<any>) => {
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();
Expand All @@ -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(),
);
Expand Down
18 changes: 1 addition & 17 deletions apps/cli/src/ui/view/dialogs/model-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
fuzzyFilter,
getKeybindings,
Input,
matchesKey,
Spacer,
Text,
type TUI,
Expand Down Expand Up @@ -57,7 +56,6 @@ export class ModelSelectorComponent extends Container implements Focusable {
private currentModel?: Model<any>;
private modelRuntime: ModelRuntime;
private onSelectCallback: (model: Model<any>) => void;
private onSelectAsDefaultCallback?: (model: Model<any>) => void;
private onCancelCallback: () => void;
private errorMessage?: string;
private refreshStatusMessage = "Refreshing model catalogs…";
Expand All @@ -82,7 +80,6 @@ export class ModelSelectorComponent extends Container implements Focusable {
onSelect: (model: Model<any>) => void,
onCancel: () => void,
initialSearchInput?: string,
onSelectAsDefault?: (model: Model<any>) => void,
defaultModel?: DefaultModelReference,
recentModels: ReadonlyArray<DefaultModelReference> = [],
) {
Expand All @@ -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
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 26 additions & 10 deletions apps/cli/test/model-selector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
Expand All @@ -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"));
Expand All @@ -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"));
Expand All @@ -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 () => {
Expand All @@ -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");
Expand All @@ -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");
});
Expand All @@ -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")
Expand All @@ -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"));
Expand Down
2 changes: 1 addition & 1 deletion docs/recent-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions packages/coding-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions packages/coding-agent/docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading