diff --git a/src/features/sessions/ui/ModelPicker.test.ts b/src/features/sessions/ui/ModelPicker.test.ts index dfecccd46..bd25b07b8 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 7a63c6534..3486d1a3a 100644 --- a/src/features/sessions/ui/ModelPicker.tsx +++ b/src/features/sessions/ui/ModelPicker.tsx @@ -239,8 +239,38 @@ 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); +} + +/** 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] }; +} + +/** + * 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(); +} + +/** Build provider headings while retaining each model's keyboard navigation index. */ function modelGroups(tab: ModelPickerTab, models: AgentModel[]): ModelGroup[] { - if (tab !== "opencode") { + if (!groupsByProvider(tab, models)) { return [ { id: "models", @@ -251,7 +281,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: [] }; @@ -262,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, @@ -389,12 +420,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..7852e59cc --- /dev/null +++ b/src/integrations/harness/core/providerNames.ts @@ -0,0 +1,33 @@ +// 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", +}; + +/** 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(/[-_/]+/)) { + 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..db80aa75e 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,40 @@ 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:/, "")), + }; +} + +/** 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; +} + 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 cbf6b29f9..2fad8e0fe 100644 --- a/src/integrations/harness/providers/opencode/opencodeCatalog.ts +++ b/src/integrations/harness/providers/opencode/opencodeCatalog.ts @@ -20,6 +20,7 @@ import { stringField, titleCaseSlug, } from "./opencodeProtocol"; +import { upstreamProviderName } from "../../core/providerNames"; const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; @@ -37,14 +38,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; @@ -322,8 +315,9 @@ 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 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 15c5fbc99..601e925d9 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"; @@ -330,15 +331,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 cca4e5693..b0555f73e 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 7c5590ea2..74993278e 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`. */ @@ -739,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, @@ -772,6 +774,7 @@ export function modelsFromRpcData( harness: flavor.id, name, nativeId, + provider: { id: provider, name: upstreamProviderName(provider) }, ...(settings.length > 0 ? { settings } : {}), ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}), });