diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cddd78463..5e51c9c75 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,6 +15,7 @@ ### Changed +- Updated Model Hub sidebar to float providers with search matches to the top while searching - Added spatial navigation between the Model Hub sidebar and model list using left/right arrow keys - Removed "Chat about this" option from question dialogs - Redesigned ask dialogs with fixed height, removed explicit Next button, and simplified headers diff --git a/packages/coding-agent/src/modes/components/model-hub.ts b/packages/coding-agent/src/modes/components/model-hub.ts index 40b43e665..8f9718348 100644 --- a/packages/coding-agent/src/modes/components/model-hub.ts +++ b/packages/coding-agent/src/modes/components/model-hub.ts @@ -163,6 +163,11 @@ export class ModelHubComponent implements Component { #configError: string | undefined; #entries: SidebarEntry[] = []; + // Sidebar sections from the last registry sync; #composeEntries assembles + // #entries from these (reordered while searching). + #fixedEntries: SidebarEntry[] = []; + #unlockedProviderEntries: SidebarEntry[] = []; + #lockedProviderEntries: SidebarEntry[] = []; #activeEntryId = "all"; #sidebarScroll = 0; #sidebarHover: number | null = null; @@ -429,7 +434,7 @@ export class ModelHubComponent implements Component { if (assignment && !assignment.autoSelected) assignedCount++; } - const entries: SidebarEntry[] = [ + const fixed: SidebarEntry[] = [ { id: "recent", kind: "recent", @@ -438,29 +443,49 @@ export class ModelHubComponent implements Component { }, ]; if (this.#mode === "roles") { - entries.push({ + fixed.push({ id: "roles", kind: "roles", label: "Roles", annotation: `${assignedCount}/${visibleRoles.length}`, }); } - entries.push({ id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) }); + fixed.push({ id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) }); - const sortedUnlocked = [...unlocked].sort((a, b) => a.localeCompare(b)); - if (sortedUnlocked.length > 0) { - entries.push({ id: "sep:providers", kind: "separator", label: "" }); - for (const provider of sortedUnlocked) { - entries.push(providerEntry(provider, false)); - } + this.#fixedEntries = fixed; + this.#unlockedProviderEntries = [...unlocked] + .sort((a, b) => a.localeCompare(b)) + .map(provider => providerEntry(provider, false)); + this.#lockedProviderEntries = [...locked] + .sort((a, b) => a.localeCompare(b)) + .map(provider => providerEntry(provider, true)); + this.#composeEntries(); + } + + /** + * Assemble `#entries` from the stored sections. While a search is active, + * providers with matches float to the top of the provider section (each + * group stays alphabetical) so the hop order, mouse hit-testing, and the + * paint all agree. + */ + #composeEntries(): void { + const counts = this.#searchCounts; + let providers = this.#unlockedProviderEntries; + if (counts) { + providers = [...providers].sort((a, b) => { + const aMatched = (counts.get(a.providerId ?? "") ?? 0) > 0; + const bMatched = (counts.get(b.providerId ?? "") ?? 0) > 0; + if (aMatched !== bMatched) return aMatched ? -1 : 1; + return a.label.localeCompare(b.label); + }); } - const sortedLocked = [...locked].sort((a, b) => a.localeCompare(b)); - if (sortedLocked.length > 0) { - entries.push({ id: "sep:locked", kind: "separator", label: "" }); - for (const provider of sortedLocked) { - entries.push(providerEntry(provider, true)); - } + const entries: SidebarEntry[] = [...this.#fixedEntries]; + if (providers.length > 0) { + entries.push({ id: "sep:providers", kind: "separator", label: "" }, ...providers); + } + if (this.#lockedProviderEntries.length > 0) { + entries.push({ id: "sep:locked", kind: "separator", label: "" }, ...this.#lockedProviderEntries); } this.#entries = entries; @@ -533,6 +558,7 @@ export class ModelHubComponent implements Component { #onQueryChanged(query: string): void { if (!query.trim()) { this.#searchCounts = null; + this.#composeEntries(); return; } const matches = fuzzyFilter(this.#availableItems, query, ({ provider, id }) => `${provider}/${id}`); @@ -541,6 +567,7 @@ export class ModelHubComponent implements Component { counts.set(item.provider, (counts.get(item.provider) ?? 0) + 1); } this.#searchCounts = counts; + this.#composeEntries(); const entry = this.#activeEntry(); if ( this.#assigningRole === null && diff --git a/packages/coding-agent/test/model-hub.test.ts b/packages/coding-agent/test/model-hub.test.ts index 97f38dd4f..246bf2ecf 100644 --- a/packages/coding-agent/test/model-hub.test.ts +++ b/packages/coding-agent/test/model-hub.test.ts @@ -426,6 +426,30 @@ describe("ModelHub", () => { expect(normalize(hub.render(220))).toContain("openrouter ·"); }); + test("providers with matches float to the top of the sidebar while searching", () => { + const noMatch = makeModel("aaa-provider", "different-model"); + const withMatch = makeModel("zzz-provider", "target-model"); + const { hub } = createHub({ models: [noMatch, withMatch] }); + installTestTheme(); + + // Sidebar cell = the first `│`-delimited column of each split row; + // body rows may also mention provider names, so scope the check. + const sidebarIndexOf = (provider: string): number => + hub + .render(220) + .map(line => stripVTControlCharacters(line).split("│")[1] ?? "") + .findIndex(cell => cell.includes(provider)); + + expect(sidebarIndexOf("aaa-provider")).toBeLessThan(sidebarIndexOf("zzz-provider")); + + for (const ch of "target") hub.handleInput(ch); + expect(sidebarIndexOf("zzz-provider")).toBeLessThan(sidebarIndexOf("aaa-provider")); + + // Clearing the query restores the alphabetical order. + hub.handleInput("\x1b"); + expect(sidebarIndexOf("aaa-provider")).toBeLessThan(sidebarIndexOf("zzz-provider")); + }); + test("Escape clears an active query before closing the hub", () => { const model = makeModel("test", "escape-model"); const { hub, onCancel } = createHub({ models: [model] });