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
4 changes: 4 additions & 0 deletions apps/cli/src/ui/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4202,6 +4202,7 @@ export class InteractiveMode {
const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
this.showStatus(msg);
} else {
this.settingsManager.recordRecentModel(result.model.provider, result.model.id);
this.footer.invalidate();
this.updateEditorBorderColor();
const thinkingStr =
Expand Down Expand Up @@ -4895,6 +4896,7 @@ export class InteractiveMode {
if (model) {
try {
await this.session.setModel(model, { persist: false });
this.settingsManager.recordRecentModel(model.provider, model.id);
this.footer.invalidate();
this.updateEditorBorderColor();
this.showStatus(`Model: ${model.id}`);
Expand Down Expand Up @@ -5038,6 +5040,7 @@ export class InteractiveMode {
const selectModel = async (model: Model<any>, persist: boolean) => {
try {
await this.session.setModel(model, { persist });
this.settingsManager.recordRecentModel(model.provider, model.id);
this.updateAvailableProviderCount();
this.footer.invalidate();
this.updateEditorBorderColor();
Expand All @@ -5064,6 +5067,7 @@ export class InteractiveMode {
initialSearchInput,
(model) => selectModel(model, true),
defaultProvider && defaultModel ? { provider: defaultProvider, id: defaultModel } : undefined,
this.settingsManager.getRecentModels(),
);
return {
component: selector,
Expand Down
37 changes: 33 additions & 4 deletions apps/cli/src/ui/view/dialogs/model-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export class ModelSelectorComponent extends Container implements Focusable {
private scopedModels: ReadonlyArray<ScopedModelItem>;
private defaultModel?: DefaultModelReference;
private scope: ModelScope = "all";
private readonly recentModels: ReadonlyArray<DefaultModelReference>;
private recentModelCount = 0;
private scopeText?: Text;
private scopeHintText?: Text;
private readonly refreshAbortController = new AbortController();
Expand All @@ -82,6 +84,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
initialSearchInput?: string,
onSelectAsDefault?: (model: Model<any>) => void,
defaultModel?: DefaultModelReference,
recentModels: ReadonlyArray<DefaultModelReference> = [],
) {
super();

Expand All @@ -90,6 +93,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
this.modelRuntime = modelRuntime;
this.scopedModels = scopedModels;
this.defaultModel = defaultModel;
this.recentModels = recentModels;
this.scope = scopedModels.length > 0 ? "scoped" : "all";
this.onSelectCallback = onSelect;
this.onSelectAsDefaultCallback = onSelectAsDefault;
Expand Down Expand Up @@ -166,7 +170,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
id: scoped.model.id,
model: scoped.model,
}));
this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
this.updateActiveModels();
this.filteredModels = this.activeModels;
const currentIndex = this.filteredModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model));
this.selectedIndex =
Expand Down Expand Up @@ -259,7 +263,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
private setScope(scope: ModelScope): void {
if (this.scope === scope) return;
this.scope = scope;
this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
this.updateActiveModels();
const currentIndex = this.activeModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model));
this.selectedIndex = currentIndex >= 0 ? currentIndex : 0;
this.filterModels(this.searchInput.getValue());
Expand All @@ -269,13 +273,15 @@ export class ModelSelectorComponent extends Container implements Focusable {
}

private filterModels(query: string): void {
query = query.trim();
if (query) {
const filtered = fuzzyFilter(this.activeModels, query, (item) => {
const models = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
const filtered = fuzzyFilter(models, query, (item) => {
const defaultText = this.isDefaultModel(item.model) ? " default" : "";
return `${getModelSelectorSearchText({ id: item.id, provider: item.provider, name: item.model.name })}${defaultText}`;
});
if (this.isDefaultSearch(query)) {
const defaultItems = this.activeModels.filter((item) => this.isDefaultModel(item.model));
const defaultItems = models.filter((item) => this.isDefaultModel(item.model));
const defaultKeys = new Set(defaultItems.map((item) => `${item.provider}\0${item.id}`));
this.filteredModels = [
...defaultItems,
Expand All @@ -294,6 +300,22 @@ export class ModelSelectorComponent extends Container implements Focusable {
this.updateList();
}

private updateActiveModels(): void {
const models = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
const available = new Map(models.map((item) => [`${item.provider}\0${item.id}`, item]));
const recent: ModelItem[] = [];
for (const reference of this.recentModels) {
const key = `${reference.provider}\0${reference.id}`;
const item = available.get(key);
if (!item) continue;
recent.push(item);
available.delete(key);
if (recent.length === 10) break;
}
this.recentModelCount = recent.length;
this.activeModels = [...recent, ...models.filter((item) => available.has(`${item.provider}\0${item.id}`))];
}

private updateList(): void {
this.listContainer.clear();

Expand All @@ -303,11 +325,18 @@ export class ModelSelectorComponent extends Container implements Focusable {
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible),
);
const endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length);
const showSections = !this.searchInput.getValue().trim() && this.recentModelCount > 0;

// Show visible slice of filtered models
for (let i = startIndex; i < endIndex; i++) {
const item = this.filteredModels[i];
if (!item) continue;
if (showSections && (i === startIndex || i === this.recentModelCount)) {
if (i > startIndex) this.listContainer.addChild(new Spacer(1));
const heading =
i < this.recentModelCount ? "Recent" : this.scope === "scoped" ? "Scoped models" : "All models";
this.listContainer.addChild(new Text(theme.bold(theme.fg("accent", ` ${heading}`)), 0, 0));
}

const isSelected = i === this.selectedIndex;
const isCurrent = modelsAreEqual(this.currentModel, item.model);
Expand Down
127 changes: 122 additions & 5 deletions apps/cli/test/model-selector.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import type { TUI } from "@step-harness/pi-tui";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { ModelSelectorComponent } from "../src/ui/view/dialogs/model-selector.ts";
import { setKeybindings, type TUI } from "@step-harness/pi-tui";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../../../packages/coding-agent/src/core/keybindings.ts";
import { initTheme } from "../../../packages/coding-agent/src/theme/theme.ts";
import { stripAnsi } from "../../../packages/coding-agent/src/utils/ansi.ts";
import { createHarness, type Harness } from "../../../packages/coding-agent/test/suite/harness.ts";
import { ModelSelectorComponent } from "../src/ui/view/dialogs/model-selector.ts";

function createFakeTui(): TUI {
return { requestRender: () => {} } as unknown as TUI;
}

describe("model selector", () => {
let harness: Harness | undefined;
let selector: ModelSelectorComponent | undefined;

beforeAll(() => {
initTheme("dark");
});
beforeEach(() => setKeybindings(new KeybindingsManager()));

afterEach(() => {
selector?.dispose();
selector = undefined;
harness?.cleanup();
harness = undefined;
});
Expand All @@ -31,7 +36,7 @@ describe("model selector", () => {
]),
});

const selector = new ModelSelectorComponent(
selector = new ModelSelectorComponent(
createFakeTui(),
harness.getModel(),
harness.session.modelRuntime,
Expand All @@ -41,8 +46,120 @@ describe("model selector", () => {
);

await vi.waitFor(() => {
const rendered = stripAnsi(selector.render(120).join("\n"));
const rendered = stripAnsi(selector!.render(120).join("\n"));
expect(rendered).toContain("Could not refresh 2 model catalogs (openai, anthropic); showing cached models.");
});
});

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" }],
});
const [alpha, beta, gamma] = harness.models;
const onSelect = vi.fn();
selector = new ModelSelectorComponent(
createFakeTui(), beta, harness.session.modelRuntime, [], onSelect, () => {},
undefined, 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"));
const rows = rendered.split("\n").filter((line) => line.includes(`[${alpha.provider}]`));
expect(rows.map((line) => line.trim().replace(/^→\s*/, "").split(" [")[0])).toEqual([gamma.id, beta.id, alpha.id]);
expect(rendered).toContain("Recent");
expect(rendered).toContain("All models");
expect(rendered).toContain(`→ ${beta.id} [${beta.provider}] · default ✓`);
expect(rendered).not.toContain("missing");
selector.handleInput("\x1b[A");
selector.handleInput("\r");
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: gamma.id, provider: gamma.provider }));
});

it("hides sections while searching, preserves default search, and restores recents when cleared", async () => {
harness = await createHarness({
models: [{ id: "alpha", name: "Alpha" }, { id: "beta", name: "Beta" }],
});
const [alpha, beta] = harness.models;
const onDefault = vi.fn();
selector = new ModelSelectorComponent(
createFakeTui(), alpha, harness.session.modelRuntime, [], () => {}, () => {},
undefined, onDefault, beta, [beta],
);
for (const char of "default") selector.handleInput(char);
const searched = stripAnsi(selector.render(120).join("\n"));
expect(searched).not.toContain("Recent");
expect(searched).not.toContain("All models");
expect(searched.split("\n").filter((line) => line.includes(`[${beta.provider}]`))).toHaveLength(1);
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 }));
});

it("filters recent models to the active scope and restores them in All", async () => {
harness = await createHarness({
models: [{ id: "alpha", name: "Alpha" }, { id: "beta", name: "Beta" }, { id: "gamma", name: "Gamma" }],
});
const [alpha, beta, gamma] = harness.models;
selector = new ModelSelectorComponent(
createFakeTui(), beta, harness.session.modelRuntime, [{ model: beta }, { model: alpha }],
() => {}, () => {}, undefined, undefined, undefined, [gamma, alpha],
);
const scoped = stripAnsi(selector.render(120).join("\n"));
expect(scoped).toContain("Recent");
expect(scoped).toContain("Scoped models");
expect(scoped).not.toContain(gamma.id);
selector.handleInput("\t");
expect(stripAnsi(selector.render(120).join("\n"))).toContain(gamma.id);
selector.handleInput("\x1b[A");
selector.handleInput("\x1b[A");
expect(stripAnsi(selector.render(120).join("\n"))).toContain(`→ ${gamma.id}`);
});

it("does not show an empty Recent section", async () => {
harness = await createHarness();
selector = new ModelSelectorComponent(
createFakeTui(), harness.getModel(), harness.session.modelRuntime, [], () => {}, () => {},
undefined, undefined, undefined, [{ provider: "missing", id: "missing" }],
);
expect(stripAnsi(selector.render(120).join("\n"))).not.toContain("Recent");
});

it("keeps identical model IDs from different providers distinct", async () => {
harness = await createHarness({ models: [{ id: "shared", name: "Shared" }] });
const model = harness.getModel();
const other = { ...model, provider: "other" };
vi.spyOn(harness.session.modelRuntime, "getAvailableSnapshot").mockReturnValue([model, other]);
const onSelect = vi.fn();
selector = new ModelSelectorComponent(
createFakeTui(), model, harness.session.modelRuntime, [], onSelect, () => {},
undefined, undefined, undefined, [other, model],
);
const rows = stripAnsi(selector.render(120).join("\n"))
.split("\n")
.filter((line) => line.includes(`${model.id} [`));
expect(rows).toHaveLength(2);
expect(rows[0]).toContain("[other]");
selector.handleInput("\x1b[A");
selector.handleInput("\r");
expect(onSelect).toHaveBeenCalledWith(other);
});

it("scrolls across the Recent boundary without selecting headings", async () => {
harness = await createHarness({
models: Array.from({ length: 15 }, (_, i) => ({ id: `model-${i}`, name: `Model ${i}` })),
});
const onSelect = vi.fn();
selector = new ModelSelectorComponent(
createFakeTui(), harness.models[0], harness.session.modelRuntime, [], onSelect, () => {},
undefined, 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"));
expect(rendered).toContain("Recent");
expect(rendered).toContain("All models");
expect(rendered).toContain("(11/15)");
selector.handleInput("\r");
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "model-10" }));
});
});
16 changes: 16 additions & 0 deletions docs/recent-models.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Recent models

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.

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.

The selector only receives history references. Settings persistence stays in `packages/coding-agent`; `packages/tui` is unchanged. Unavailable references are hidden rather than deleting offline history. Scoped mode filters Recent to scoped models and preserves the order of the remaining scoped models.

## Reference

Behavior was checked against OpenCode v1.2.0, not copied into StepCode:

- [Model dialog](https://github.com/anomalyco/opencode/blob/v1.2.0/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx): Recent section, unavailable-model filtering, search without duplicated rows.
- [Local model state](https://github.com/anomalyco/opencode/blob/v1.2.0/packages/opencode/src/cli/cmd/tui/context/local.tsx): provider/model identity, move-to-front history, ten-entry limit.
38 changes: 38 additions & 0 deletions packages/coding-agent/src/core/settings-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export interface Settings {
lastChangelogVersion?: string;
defaultProvider?: string;
defaultModel?: string;
recentModels?: { provider: string; id: string }[];
defaultThinkingLevel?: ThinkingLevel;
modelThinkingLevels?: Record<string, ThinkingLevel>; // per-model default thinking level overrides keyed by "provider/modelId"
transport?: TransportSetting; // default: "auto"
Expand Down Expand Up @@ -193,6 +194,23 @@ function parseTimeoutSetting(value: unknown, settingName: string): number | unde

export type SettingsScope = "global" | "project";

function normalizeRecentModels(value: unknown): { provider: string; id: string }[] {
if (!Array.isArray(value)) return [];
const result: { provider: string; id: string }[] = [];
const seen = new Set<string>();
for (const entry of value) {
if (!isMergeableObject(entry)) continue;
const { provider, id } = entry;
if (typeof provider !== "string" || !provider.trim() || typeof id !== "string" || !id.trim()) continue;
const key = `${provider}\0${id}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({ provider, id });
if (result.length === 10) break;
}
return result;
}

export interface SettingsManagerCreateOptions {
projectTrusted?: boolean;
/** Project resource directory name. Defaults to Pi's configured value. */
Expand Down Expand Up @@ -738,6 +756,26 @@ export class SettingsManager {
return this.settings.defaultModel;
}

getRecentModels(): { provider: string; id: string }[] {
return normalizeRecentModels(this.globalSettings.recentModels);
}

recordRecentModel(provider: string, id: string): void {
const model = { provider, id };
this.globalSettings.recentModels = normalizeRecentModels([model, ...this.getRecentModels()]);
if (this.globalSettingsLoadError) return;
this.enqueueWrite("global", () => {
this.storage.withLock("global", (current) => {
const settings = current
? SettingsManager.migrateSettings(JSON.parse(stripBom(current)) as Record<string, unknown>)
: {};
// Merge under the lock so another session's selections are not lost.
settings.recentModels = normalizeRecentModels([model, ...normalizeRecentModels(settings.recentModels)]);
return JSON.stringify(settings, null, 2);
});
});
}

setDefaultProvider(provider: string): void {
this.globalSettings.defaultProvider = provider;
this.markModified("defaultProvider");
Expand Down
Loading
Loading