From f5fe3f01a4866660bb533f27a9c7344e04cd61f1 Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Mon, 5 Oct 2026 20:26:49 -0400 Subject: [PATCH 1/2] Group model picker by provider for omp, Pi, and Hermes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider headings were limited to the OpenCode tab, so multi-provider harnesses listed same-name models (e.g. Sonnet via Anthropic, Copilot, and Antigravity) with nothing to tell them apart. - Tag omp/Pi models with their RPC provider and Hermes models with the provider from their `provider:model` id and `Provider:` description, stripping the redundant ` · ` name prefix. - Group any harness tab whose models carry a provider. - Keep each provider's models contiguous so arrow-key navigation follows the visible order instead of jumping between groups. - Share upstream provider display names across harnesses. Refs #333 Co-Authored-By: Claude Opus 5.5 --- src/features/sessions/ui/ModelPicker.test.ts | 67 +++++++++++++++++++ src/features/sessions/ui/ModelPicker.tsx | 46 ++++++++++--- .../harness/core/providerNames.ts | 31 +++++++++ .../providers/hermes/hermesProtocol.test.ts | 30 +++++++-- .../providers/hermes/hermesProtocol.ts | 39 ++++++++++- .../providers/opencode/opencodeCatalog.ts | 11 +-- .../providers/opencode/opencodeProtocol.ts | 11 +-- .../harness/providers/pi/piProtocol.test.ts | 4 ++ .../harness/providers/pi/piProtocol.ts | 2 + 9 files changed, 210 insertions(+), 31 deletions(-) create mode 100644 src/integrations/harness/core/providerNames.ts diff --git a/src/features/sessions/ui/ModelPicker.test.ts b/src/features/sessions/ui/ModelPicker.test.ts index 86559b98b..ccac2770b 100644 --- a/src/features/sessions/ui/ModelPicker.test.ts +++ b/src/features/sessions/ui/ModelPicker.test.ts @@ -338,6 +338,73 @@ describe("model picker", () => { expect(container.querySelectorAll('[role="option"]')).toHaveLength(2); }); + it("groups omp models by provider in keyboard order", () => { + // omp sorts by model name, so providers arrive interleaved. + setHarnessModels("omp", [ + { + id: "omp:anthropic/claude-sonnet-5", + harness: "omp", + name: "Claude Sonnet 5", + nativeId: "anthropic/claude-sonnet-5", + provider: { id: "anthropic", name: "Anthropic" }, + }, + { + id: "omp:github-copilot/claude-sonnet-5", + harness: "omp", + name: "Claude Sonnet 5", + nativeId: "github-copilot/claude-sonnet-5", + provider: { id: "github-copilot", name: "GitHub Copilot" }, + }, + { + id: "omp:anthropic/claude-opus-5", + harness: "omp", + name: "Claude Opus 5", + nativeId: "anthropic/claude-opus-5", + provider: { id: "anthropic", name: "Anthropic" }, + }, + ]); + const onChange = vi.fn(); + + act(() => + root.render( + createElement(ModelPicker, { + harness: "omp", + model: "omp:anthropic/claude-sonnet-5", + values: {}, + hideSettings: true, + onChange, + onSettingsChange: vi.fn(), + }), + ), + ); + + act(() => + container + .querySelector('button[aria-haspopup="dialog"]')! + .click(), + ); + expect( + [...container.querySelectorAll('[role="group"]')].map((group) => + group.getAttribute("aria-label"), + ), + ).toEqual(["Anthropic", "GitHub Copilot"]); + const options = [ + ...container.querySelectorAll('[role="option"]'), + ]; + expect(options.map((option) => option.getAttribute("aria-label"))).toEqual( + [ + "Claude Sonnet 5, Anthropic", + "Claude Opus 5, Anthropic", + "Claude Sonnet 5, GitHub Copilot", + ], + ); + // The next row down is the next visible row, not the next catalog entry. + options[0].focus(); + keyDown(options[0], "ArrowDown"); + keyDown(options[0], "Enter"); + expect(onChange).toHaveBeenCalledWith("omp", "omp:anthropic/claude-opus-5"); + }); + it("names the source of same-name favorites from different providers", () => { setHarnessModels("cursor", [ { diff --git a/src/features/sessions/ui/ModelPicker.tsx b/src/features/sessions/ui/ModelPicker.tsx index 3fc61854a..2e6b2fdb7 100644 --- a/src/features/sessions/ui/ModelPicker.tsx +++ b/src/features/sessions/ui/ModelPicker.tsx @@ -234,8 +234,36 @@ function recentMenuModels( return models.slice(0, 6); } +/** Harness tabs whose catalog names upstream providers list models under them. */ +function groupsByProvider(tab: ModelPickerTab, models: AgentModel[]): boolean { + return tab !== "favorites" && models.some((item) => item.provider); +} + +function modelProvider(item: AgentModel): { id: string; name: string } { + return item.provider ?? { id: item.harness, name: HARNESS_TITLE[item.harness] }; +} + +/** + * Keeps each provider's models contiguous (in first-seen provider order) so + * keyboard navigation, which walks this list, matches the grouped headings. + */ +function inProviderOrder( + tab: ModelPickerTab, + models: AgentModel[], +): AgentModel[] { + if (!groupsByProvider(tab, models)) return models; + const groups = new Map(); + for (const item of models) { + const id = modelProvider(item).id; + const group = groups.get(id); + if (group) group.push(item); + else groups.set(id, [item]); + } + return [...groups.values()].flat(); +} + function modelGroups(tab: ModelPickerTab, models: AgentModel[]): ModelGroup[] { - if (tab !== "opencode") { + if (!groupsByProvider(tab, models)) { return [ { id: "models", @@ -246,7 +274,7 @@ function modelGroups(tab: ModelPickerTab, models: AgentModel[]): ModelGroup[] { const groups = new Map(); models.forEach((item, index) => { - const provider = item.provider ?? { id: "opencode", name: "OpenCode" }; + const provider = modelProvider(item); let group = groups.get(provider.id); if (!group) { group = { id: provider.id, name: provider.name, models: [] }; @@ -381,12 +409,14 @@ export function ModelPicker({ item != null && pickerHarnesses.includes(item.harness), ) : source.modelsFor(visibleTab); - if (!needle) return pool; - return pool.filter((item) => - `${item.name} ${HARNESS_TITLE[item.harness]} ${item.provider?.name ?? ""} ${item.provider?.id ?? ""}` - .toLowerCase() - .includes(needle), - ); + const matches = needle + ? pool.filter((item) => + `${item.name} ${HARNESS_TITLE[item.harness]} ${item.provider?.name ?? ""} ${item.provider?.id ?? ""}` + .toLowerCase() + .includes(needle), + ) + : pool; + return inProviderOrder(visibleTab, matches); }, [source, catalogVersion, favorites, providerKey, query, visibleTab]); const dismiss = (restore: boolean) => { diff --git a/src/integrations/harness/core/providerNames.ts b/src/integrations/harness/core/providerNames.ts new file mode 100644 index 000000000..faa722364 --- /dev/null +++ b/src/integrations/harness/core/providerNames.ts @@ -0,0 +1,31 @@ +// Display names for upstream providers that multi-provider harnesses +// (OpenCode, Pi/omp, Hermes) report only as slugs. +const PROVIDER_NAMES: Record = { + anthropic: "Anthropic", + deepseek: "DeepSeek", + "github-copilot": "GitHub Copilot", + "google-antigravity": "Antigravity", + "google-gemini-cli": "Gemini CLI", + "lm-studio": "LM Studio", + ollama: "Ollama", + openai: "OpenAI", + "openai-codex": "OpenAI Codex", + opencode: "OpenCode", + "opencode-go": "OpenCode Go", + openrouter: "OpenRouter", + xai: "xAI", +}; + +export function upstreamProviderName(providerID: string): string { + return PROVIDER_NAMES[providerID] ?? titleCaseSlug(providerID); +} + +export function titleCaseSlug(value: string): string { + const segments: string[] = []; + for (const segment of value.split(/[-_/]+/)) { + if (segment.length > 0) { + segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); + } + } + return segments.join(" "); +} diff --git a/src/integrations/harness/providers/hermes/hermesProtocol.test.ts b/src/integrations/harness/providers/hermes/hermesProtocol.test.ts index f558d8acd..a2ffb533a 100644 --- a/src/integrations/harness/providers/hermes/hermesProtocol.test.ts +++ b/src/integrations/harness/providers/hermes/hermesProtocol.test.ts @@ -77,9 +77,22 @@ describe("Hermes ACP protocol", () => { models: { currentModelId: "nous:hermes-4", availableModels: [ - { modelId: "openrouter:gpt-5", name: "OpenRouter · GPT-5" }, - { modelId: "nous:hermes-4", name: "Nous · Hermes 4" }, + { + modelId: "openrouter:gpt-5", + name: "OpenRouter · gpt-5", + description: "Provider: OpenRouter", + }, + { + modelId: "nous:hermes-4", + name: "Nous Portal · hermes-4", + description: "Provider: Nous Portal • current", + }, { modelId: "nous:hermes-4", name: "duplicate" }, + { + modelId: "custom:ollama:llama3:8b", + name: "llama3:8b", + description: "Provider: Ollama • Local • current", + }, ], }, }; @@ -90,14 +103,23 @@ describe("Hermes ACP protocol", () => { { id: "hermes:nous:hermes-4", harness: "hermes", - name: "Nous · Hermes 4", + name: "hermes-4", nativeId: "nous:hermes-4", + provider: { id: "nous", name: "Nous Portal" }, }, { id: "hermes:openrouter:gpt-5", harness: "hermes", - name: "OpenRouter · GPT-5", + name: "gpt-5", nativeId: "openrouter:gpt-5", + provider: { id: "openrouter", name: "OpenRouter" }, + }, + { + id: "hermes:custom:ollama:llama3:8b", + harness: "hermes", + name: "llama3:8b", + nativeId: "custom:ollama:llama3:8b", + provider: { id: "custom:ollama", name: "Ollama" }, }, ]); }); diff --git a/src/integrations/harness/providers/hermes/hermesProtocol.ts b/src/integrations/harness/providers/hermes/hermesProtocol.ts index 974c65e56..ced4c3eae 100644 --- a/src/integrations/harness/providers/hermes/hermesProtocol.ts +++ b/src/integrations/harness/providers/hermes/hermesProtocol.ts @@ -1,6 +1,7 @@ import { promptBlocks, type PromptContentBlock } from "../../../../features/sessions/model/attachments"; import type { AgentModel } from "../../../../features/sessions/model/models"; import type { Attachment, RuntimeMode } from "../../../../features/sessions/model/session"; +import { upstreamProviderName } from "../../core/providerNames"; export type HermesBackgroundDispatch = { callId: string; @@ -146,12 +147,15 @@ export function modelsFromHermesSession(result: unknown): AgentModel[] { ).trim(); if (!nativeId || seen.has(nativeId)) continue; seen.add(nativeId); - const name = String(model.name ?? model.title ?? nativeId).trim(); + const rawName = String(model.name ?? model.title ?? nativeId).trim(); + const provider = hermesModelProvider(nativeId, rawName, model.description); + const name = provider ? stripProviderPrefix(rawName, provider.name) : rawName; models.push({ id: `hermes:${nativeId}`, harness: "hermes", name: name || displayName(nativeId), nativeId, + ...(provider ? { provider } : {}), }); } @@ -164,6 +168,39 @@ export function modelsFromHermesSession(result: unknown): AgentModel[] { return models; } +/** + * Hermes encodes picker ids as `provider:model` (`custom::model` for + * user-defined endpoints) and labels rows `Provider: ` in their + * description, with the inventory rows also named ` · `. + */ +function hermesModelProvider( + nativeId: string, + name: string, + description: unknown, +): AgentModel["provider"] { + const parts = nativeId.split(":"); + if (parts.length < 2) return undefined; + const id = parts[0] === "custom" && parts.length > 2 + ? `${parts[0]}:${parts[1]}` + : parts[0]; + const described = + typeof description === "string" + ? /^Provider:\s*([^•]+)/.exec(description)?.[1]?.trim() + : undefined; + const prefixed = name.includes(" · ") + ? name.slice(0, name.indexOf(" · ")).trim() + : undefined; + return { + id, + name: described || prefixed || upstreamProviderName(id.replace(/^custom:/, "")), + }; +} + +function stripProviderPrefix(name: string, provider: string): string { + const prefix = `${provider} · `; + return name.startsWith(prefix) ? name.slice(prefix.length).trim() : name; +} + function displayName(nativeId: string): string { const slug = nativeId.includes(":") ? nativeId.slice(nativeId.lastIndexOf(":") + 1) diff --git a/src/integrations/harness/providers/opencode/opencodeCatalog.ts b/src/integrations/harness/providers/opencode/opencodeCatalog.ts index a653169bc..247d1ee4f 100644 --- a/src/integrations/harness/providers/opencode/opencodeCatalog.ts +++ b/src/integrations/harness/providers/opencode/opencodeCatalog.ts @@ -17,6 +17,7 @@ import { sortOpenCodeVariants, titleCaseSlug, } from "./opencodeProtocol"; +import { upstreamProviderName } from "../../core/providerNames"; const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; @@ -34,14 +35,6 @@ type ParsedProvider = { models: Record; }; -const PROVIDER_NAMES: Record = { - opencode: "OpenCode", - "opencode-go": "OpenCode Go", - openai: "OpenAI", - xai: "xAI", - "github-copilot": "GitHub Copilot", -}; - export type OpenCodeAgent = { name: string; mode: string; @@ -222,7 +215,7 @@ export function flattenOpenCodeModels( } export function openCodeProviderName(providerID: string): string { - return PROVIDER_NAMES[providerID] ?? titleCaseSlug(providerID); + return upstreamProviderName(providerID); } function openCodeModelSettings( diff --git a/src/integrations/harness/providers/opencode/opencodeProtocol.ts b/src/integrations/harness/providers/opencode/opencodeProtocol.ts index 93d260442..43a9c8fb9 100644 --- a/src/integrations/harness/providers/opencode/opencodeProtocol.ts +++ b/src/integrations/harness/providers/opencode/opencodeProtocol.ts @@ -12,6 +12,7 @@ import { } from "../../../../features/sessions/model/attachments"; import { isTaskListToolName } from "../../../../features/sessions/model/taskList"; import { extractToolPreview } from "../../core/preview"; +import { titleCaseSlug } from "../../core/providerNames"; import type { HarnessEvent } from "../../core/types"; export const MINIMUM_OPENCODE_VERSION = "1.14.19"; @@ -242,15 +243,7 @@ function commonPrefixLength(left: string, right: string): number { return index; } -export function titleCaseSlug(value: string): string { - const segments: string[] = []; - for (const segment of value.split(/[-_/]+/)) { - if (segment.length > 0) { - segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); - } - } - return segments.join(" "); -} +export { titleCaseSlug }; export function inferDefaultVariant( providerID: string, diff --git a/src/integrations/harness/providers/pi/piProtocol.test.ts b/src/integrations/harness/providers/pi/piProtocol.test.ts index d7142f3bb..a321a2fb8 100644 --- a/src/integrations/harness/providers/pi/piProtocol.test.ts +++ b/src/integrations/harness/providers/pi/piProtocol.test.ts @@ -397,6 +397,10 @@ describe("tools and models", () => { ]); expect(models[0]?.settings?.[0]?.id).toBe("thinking"); expect(models[0]?.contextWindow).toBe(200000); + expect(models.map((model) => model.provider)).toEqual([ + { id: "anthropic", name: "Anthropic" }, + { id: "openai", name: "OpenAI" }, + ]); expect(models[1]?.settings).toBeUndefined(); }); diff --git a/src/integrations/harness/providers/pi/piProtocol.ts b/src/integrations/harness/providers/pi/piProtocol.ts index 14799fe5e..a666891d1 100644 --- a/src/integrations/harness/providers/pi/piProtocol.ts +++ b/src/integrations/harness/providers/pi/piProtocol.ts @@ -7,6 +7,7 @@ import type { AgentModel, ModelSetting } from "../../../../features/sessions/mod import { isTaskListToolName } from "../../../../features/sessions/model/taskList"; import type { PiFlavor } from "./piFlavor"; import { extractToolPreview, titleFromToolInput } from "../../core/preview"; +import { upstreamProviderName } from "../../core/providerNames"; import { streamTextDelta } from "../../core/streamText"; /** Images Pi RPC accepts on `prompt` / `steer`. */ @@ -745,6 +746,7 @@ export function modelsFromRpcData( harness: flavor.id, name, nativeId, + provider: { id: provider, name: upstreamProviderName(provider) }, ...(settings.length > 0 ? { settings } : {}), ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}), }); From 0664a37e4da8d83e12c021ebcd6d7c8a7966b2df Mon Sep 17 00:00:00 2001 From: Nate Woolls Date: Wed, 7 Oct 2026 11:24:36 -0400 Subject: [PATCH 2/2] Document model picker provider grouping helpers --- src/features/sessions/ui/ModelPicker.tsx | 3 +++ src/integrations/harness/core/providerNames.ts | 2 ++ src/integrations/harness/providers/hermes/hermesProtocol.ts | 1 + src/integrations/harness/providers/opencode/opencodeCatalog.ts | 1 + src/integrations/harness/providers/pi/piProtocol.ts | 1 + 5 files changed, 8 insertions(+) diff --git a/src/features/sessions/ui/ModelPicker.tsx b/src/features/sessions/ui/ModelPicker.tsx index f42760875..3486d1a3a 100644 --- a/src/features/sessions/ui/ModelPicker.tsx +++ b/src/features/sessions/ui/ModelPicker.tsx @@ -244,6 +244,7 @@ function groupsByProvider(tab: ModelPickerTab, models: AgentModel[]): boolean { return tab !== "favorites" && models.some((item) => item.provider); } +/** Use the upstream provider when present, otherwise the model's harness. */ function modelProvider(item: AgentModel): { id: string; name: string } { return item.provider ?? { id: item.harness, name: HARNESS_TITLE[item.harness] }; } @@ -267,6 +268,7 @@ function inProviderOrder( return [...groups.values()].flat(); } +/** Build provider headings while retaining each model's keyboard navigation index. */ function modelGroups(tab: ModelPickerTab, models: AgentModel[]): ModelGroup[] { if (!groupsByProvider(tab, models)) { return [ @@ -290,6 +292,7 @@ function modelGroups(tab: ModelPickerTab, models: AgentModel[]): ModelGroup[] { return [...groups.values()]; } +/** Choose a harness model and its settings, with favorites and provider groups. */ export function ModelPicker({ harness, model, diff --git a/src/integrations/harness/core/providerNames.ts b/src/integrations/harness/core/providerNames.ts index faa722364..7852e59cc 100644 --- a/src/integrations/harness/core/providerNames.ts +++ b/src/integrations/harness/core/providerNames.ts @@ -16,10 +16,12 @@ const PROVIDER_NAMES: Record = { xai: "xAI", }; +/** Resolve a provider slug to its display name, title-casing unknown providers. */ export function upstreamProviderName(providerID: string): string { return PROVIDER_NAMES[providerID] ?? titleCaseSlug(providerID); } +/** Capitalize slug segments separated by hyphens, underscores, or slashes. */ export function titleCaseSlug(value: string): string { const segments: string[] = []; for (const segment of value.split(/[-_/]+/)) { diff --git a/src/integrations/harness/providers/hermes/hermesProtocol.ts b/src/integrations/harness/providers/hermes/hermesProtocol.ts index ced4c3eae..db80aa75e 100644 --- a/src/integrations/harness/providers/hermes/hermesProtocol.ts +++ b/src/integrations/harness/providers/hermes/hermesProtocol.ts @@ -196,6 +196,7 @@ function hermesModelProvider( }; } +/** Remove a matching provider label now shown by the picker's group heading. */ function stripProviderPrefix(name: string, provider: string): string { const prefix = `${provider} · `; return name.startsWith(prefix) ? name.slice(prefix.length).trim() : name; diff --git a/src/integrations/harness/providers/opencode/opencodeCatalog.ts b/src/integrations/harness/providers/opencode/opencodeCatalog.ts index 247d1ee4f..d0b8e3827 100644 --- a/src/integrations/harness/providers/opencode/opencodeCatalog.ts +++ b/src/integrations/harness/providers/opencode/opencodeCatalog.ts @@ -214,6 +214,7 @@ export function flattenOpenCodeModels( return models.sort((left, right) => left.name.localeCompare(right.name)); } +/** Resolve OpenCode provider slugs using the shared upstream display names. */ export function openCodeProviderName(providerID: string): string { return upstreamProviderName(providerID); } diff --git a/src/integrations/harness/providers/pi/piProtocol.ts b/src/integrations/harness/providers/pi/piProtocol.ts index 967878cd2..74993278e 100644 --- a/src/integrations/harness/providers/pi/piProtocol.ts +++ b/src/integrations/harness/providers/pi/piProtocol.ts @@ -740,6 +740,7 @@ export function isHiddenCopilotModel(provider: string, modelId: string) { ); } +/** Build the Pi/omp picker catalog with provider labels, sorted by model name. */ export function modelsFromRpcData( flavor: PiFlavor, data: unknown,