diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 23abc4022..e53cda579 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -86,7 +86,6 @@ import { type SessionSelectorSetting, } from "@/components/chat/session-selectors-panel" import { - deriveModelGroups, isModelConfigOption, modelListGroups, MODEL_LIST_VIRTUALIZE_THRESHOLD, @@ -1475,7 +1474,7 @@ export function MessageInput({ onConfigOptionChange?.(configId, valueId) } @@ -1526,40 +1525,10 @@ export function MessageInput({ } if (option.kind.type !== "select") continue const kind = option.kind - // Model values that carry a `provider/` prefix group by provider; every - // other option keeps its server groups or stays flat (`null` derived). - const derived = deriveModelGroups(option) - const groups: SessionSelectorGroup[] = derived - ? derived.map((group) => ({ - key: group.key, - name: group.name, - options: group.options.map((item) => ({ - value: item.value, - name: item.name, - description: item.description, - })), - })) - : kind.groups.length > 0 - ? kind.groups.map((group) => ({ - key: group.group, - name: group.name, - options: group.options.map((item) => ({ - value: item.value, - name: item.name, - description: item.description, - })), - })) - : [ - { - key: "__flat__", - name: null, - options: kind.options.map((item) => ({ - value: item.value, - name: item.name, - description: item.description, - })), - }, - ] + // Same grouping + display polish as the wide picker (provider headers, + // versioned names, no duplicate sibling blurbs) so the collapsed rail + // cannot drift from the composer chips. + const groups: SessionSelectorGroup[] = modelListGroups(option) // Resolve the left-rail summary against the built groups so a grouped // model shows its prefix-stripped name (the provider is implied) rather // than repeating `provider/`. diff --git a/src/components/chat/session-config-selector.test.tsx b/src/components/chat/session-config-selector.test.tsx index 2b09e6466..ae974f3b6 100644 --- a/src/components/chat/session-config-selector.test.tsx +++ b/src/components/chat/session-config-selector.test.tsx @@ -170,6 +170,46 @@ describe("InlineSessionConfigSelector — model grouping", () => { // No provider headers for an ungroupable flat list. expect(screen.queryByText("anthropic")).toBeNull() }) + + it("promotes Fable 5 onto the trigger and drops Default's sibling blurb", async () => { + const user = userEvent.setup() + const option = modelOption( + [ + { + value: "default", + name: "Default (recommended)", + description: "Opus (1M context)", + }, + { value: "opus[1m]", name: "Opus (1M context)" }, + { + value: "fable", + name: "Fable", + description: "Fable 5 · Most capable, complex agents", + }, + { value: "sonnet", name: "Sonnet" }, + { value: "haiku", name: "Haiku" }, + ], + "fable" + ) + render() + + // Trigger uses the promoted name, not the short ACP label. + expect(screen.getByRole("button", { name: /Fable 5/ })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: /^Model: Fable$/ })).toBeNull() + + await user.click(screen.getByRole("button", { name: /Fable 5/ })) + + const fable = await screen.findByRole("menuitemradio", { name: /Fable 5/ }) + expect(fable).toHaveTextContent("Most capable, complex agents") + expect(fable).not.toHaveTextContent("Fable 5 ·") + + const def = screen.getByRole("menuitemradio", { + name: /Default \(recommended\)/, + }) + // The sibling title used to be Default's description, which made the + // list look like two copies of Opus (1M context). + expect(def).not.toHaveTextContent("Opus (1M context)") + }) }) // Cline 3.0.50's `auto_approve` — the first boolean config option any pinned diff --git a/src/components/chat/session-config-selector.tsx b/src/components/chat/session-config-selector.tsx index 57ec1afc8..40b2110f3 100644 --- a/src/components/chat/session-config-selector.tsx +++ b/src/components/chat/session-config-selector.tsx @@ -14,7 +14,10 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { DropdownRadioItemContent } from "@/components/chat/dropdown-radio-item-content" -import type { ModelOptionGroup } from "@/lib/model-config-groups" +import { + polishSelectOptions, + type ModelOptionGroup, +} from "@/lib/model-config-groups" import type { SessionConfigOptionInfo } from "@/lib/types" interface SessionConfigSelectorProps { @@ -41,12 +44,15 @@ export function InlineSessionConfigSelector({ // `name === null` is a headerless bucket (the leading prefix-less models). const renderGroups: ModelOptionGroup[] | null = derivedGroups && derivedGroups.length > 0 - ? derivedGroups + ? derivedGroups.map((group) => ({ + ...group, + options: polishSelectOptions(group.options), + })) : option.kind.groups.length > 0 ? option.kind.groups.map((group) => ({ key: group.group, name: group.name, - options: group.options, + options: polishSelectOptions(group.options), })) : null @@ -55,7 +61,7 @@ export function InlineSessionConfigSelector({ // the group it sits in) rather than repeating `provider/`. const renderedOptions = renderGroups ? renderGroups.flatMap((group) => group.options) - : option.kind.options + : polishSelectOptions(option.kind.options) const selected = renderedOptions.find( (item) => item.value === option.kind.current_value ) @@ -112,7 +118,7 @@ export function InlineSessionConfigSelector({ ))} )) - : option.kind.options.map((item) => ( + : renderedOptions.map((item) => ( { + // Claude's short ACP catalog: short names, versioned heads in the + // description, and Default's blurb repeating a sibling title. + const claudeCatalog = [ + opt("default", "Default (recommended)", "Opus (1M context)"), + opt("opus[1m]", "Opus (1M context)"), + opt("fable", "Fable", "Fable 5 · Most capable, complex agents"), + opt("sonnet", "Sonnet"), + opt("haiku", "Haiku"), + ] + + it("promotes a versioned description head so the trigger matches the row", () => { + const polished = polishSelectOptions(claudeCatalog) + const fable = polished.find((o) => o.value === "fable") + expect(fable).toMatchObject({ + value: "fable", + name: "Fable 5", + description: "Most capable, complex agents", + }) + }) + + it("drops a description that is exactly a sibling option's name", () => { + const polished = polishSelectOptions(claudeCatalog) + const def = polished.find((o) => o.value === "default") + expect(def).toMatchObject({ + value: "default", + name: "Default (recommended)", + description: null, + }) + }) + + it("leaves already-clean sibling names and values alone", () => { + const polished = polishSelectOptions(claudeCatalog) + expect(polished.map((o) => o.value)).toEqual([ + "default", + "opus[1m]", + "fable", + "sonnet", + "haiku", + ]) + expect(polished.find((o) => o.value === "opus[1m]")?.name).toBe( + "Opus (1M context)" + ) + expect(polished.find((o) => o.value === "sonnet")?.name).toBe("Sonnet") + expect(polished.find((o) => o.value === "haiku")?.name).toBe("Haiku") + }) + + it("promotes a version-only description with no blurb separator", () => { + const polished = polishSelectOptions([ + opt("fable", "Fable", "Fable 5"), + opt("opus", "Opus", "Opus 4.6 (1M context)"), + ]) + expect(polished[0]).toMatchObject({ + name: "Fable 5", + description: null, + }) + expect(polished[1]).toMatchObject({ + name: "Opus 4.6 (1M context)", + description: null, + }) + }) + + it("does not steal a unique blurb that does not start with the name", () => { + const polished = polishSelectOptions([ + opt("sonnet", "Sonnet", "Balanced speed and quality"), + ]) + expect(polished[0]).toMatchObject({ + name: "Sonnet", + description: "Balanced speed and quality", + }) + }) + + it("is idempotent", () => { + const once = polishSelectOptions(claudeCatalog) + expect(polishSelectOptions(once)).toEqual(once) + }) +}) + describe("modelListGroups", () => { it("uses the derived provider groups when applicable", () => { const option = modelOption([ @@ -335,6 +414,46 @@ describe("modelListGroups", () => { expect(groups[0].name).toBeNull() expect(groups[0].options.map((o) => o.value)).toEqual(["a", "b", "c"]) }) + + it("polishes a flat Claude list so the trigger can show Fable 5", () => { + const catalog = modelOption([ + opt("default", "Default (recommended)", "Opus (1M context)"), + opt("opus[1m]", "Opus (1M context)"), + opt("fable", "Fable", "Fable 5 · Most capable, complex agents"), + opt("sonnet", "Sonnet"), + opt("haiku", "Haiku"), + ]) + catalog.kind.current_value = "fable" + const groups = modelListGroups(catalog) + expect(groups).toHaveLength(1) + expect(groups[0].name).toBeNull() + const fable = groups[0].options.find((o) => o.value === "fable") + expect(fable?.name).toBe("Fable 5") + expect( + groups[0].options.find((o) => o.value === "default")?.description + ).toBeNull() + }) + + it("polishes before stripping a shared provider prefix", () => { + // If grouping ran first, the name would become "Fable" while the + // description still started with "OpenCode Zen/Fable 5", and promotion + // would miss. + const option = modelOption([ + opt( + "opencode/fable", + "OpenCode Zen/Fable", + "OpenCode Zen/Fable 5 · Most capable" + ), + opt("opencode/sonnet", "OpenCode Zen/Sonnet"), + ]) + const groups = modelListGroups(option) + expect(groups.map((g) => g.name)).toEqual(["OpenCode Zen"]) + expect(groups[0].options.map((o) => o.name)).toEqual(["Fable 5", "Sonnet"]) + expect(groups[0].options[0]).toMatchObject({ + value: "opencode/fable", + description: "Most capable", + }) + }) }) describe("filterModelGroups", () => { diff --git a/src/lib/model-config-groups.ts b/src/lib/model-config-groups.ts index 147ade3bd..3548f83cb 100644 --- a/src/lib/model-config-groups.ts +++ b/src/lib/model-config-groups.ts @@ -21,6 +21,107 @@ export function isModelConfigOption(option: SessionConfigOptionInfo): boolean { return option.id === "model" || option.category === "model" } +const DESCRIPTION_SEPARATORS = [" · ", " – ", " - ", ": "] as const + +/** + * Display-only cleanup for a select option list. Values never change. + * + * Agents often ship a short `name` ("Fable") and put the versioned name in + * the description ("Fable 5 · Most capable…"). The trigger then reads + * "Fable" while the row says "Fable 5". When the description starts with + * the name and then a longer head before a separator, promote that head + * to the label and keep the remainder as the blurb. + * + * A description that is exactly another option's name (Claude's + * "Default (recommended)" whose blurb is "Opus (1M context)") is dropped: + * the sibling row already carries that title, so repeating it looks like + * two copies of the same model. + * + * Run this BEFORE provider-prefix grouping. Stripping `OpenCode Zen/` first + * leaves a name ("Fable") that no longer prefixes the still-full description + * ("OpenCode Zen/Fable 5 · …"), so the promotion would miss. + */ +export function polishSelectOptions( + options: SessionConfigSelectOptionInfo[] +): SessionConfigSelectOptionInfo[] { + const promoted = options.map(promoteVersionedName) + const names = new Set(promoted.map((opt) => opt.name.trim()).filter(Boolean)) + return promoted.map((opt) => dropRedundantDescription(opt, names)) +} + +/** True when `candidate` is `name` plus a version token (`5`, `4.6`, `4.5`). */ +function isVersionedExtension(name: string, candidate: string): boolean { + if (!candidate.startsWith(name) || candidate.length <= name.length) { + return false + } + const rest = candidate.slice(name.length).trim() + return /^\d[\w.-]*(?:\s*\([^)]+\))?$/.test(rest) +} + +function promoteVersionedName( + opt: SessionConfigSelectOptionInfo +): SessionConfigSelectOptionInfo { + const name = opt.name.trim() + const description = opt.description?.trim() ?? "" + if (!description) { + return name === opt.name ? opt : { ...opt, name } + } + if (description === name) { + return { ...opt, name, description: null } + } + for (const sep of DESCRIPTION_SEPARATORS) { + const idx = description.indexOf(sep) + if (idx <= 0) continue + const head = description.slice(0, idx).trim() + const tail = description.slice(idx + sep.length).trim() + if (head.startsWith(name) && head.length > name.length) { + return { ...opt, name: head, description: tail || null } + } + } + if (isVersionedExtension(name, description)) { + return { ...opt, name: description, description: null } + } + return name === opt.name && description === (opt.description ?? "").trim() + ? opt + : { ...opt, name, description } +} + +function dropRedundantDescription( + opt: SessionConfigSelectOptionInfo, + siblingNames: Set +): SessionConfigSelectOptionInfo { + const description = opt.description?.trim() ?? "" + if (!description) return opt + if (description === opt.name.trim()) { + return { ...opt, description: null } + } + if (siblingNames.has(description) && description !== opt.name.trim()) { + return { ...opt, description: null } + } + return opt +} + +/** + * Same display polish as {@link polishSelectOptions}, applied to a select + * config option's flat list AND any server-provided groups (groups are remapped + * by value so they pick up the promoted names). + */ +export function polishConfigOption( + option: SessionConfigOptionInfo +): SessionConfigOptionInfo { + if (option.kind.type !== "select") return option + const kind = option.kind + const options = polishSelectOptions(kind.options) + const byValue = new Map(options.map((item) => [item.value, item])) + const groups = kind.groups.map((group) => ({ + ...group, + options: group.options.map( + (item) => byValue.get(item.value) ?? polishSelectOptions([item])[0] + ), + })) + return { ...option, kind: { ...kind, options, groups } } +} + // The namespace before the FIRST "/", or `null` when there is no usable prefix // (no slash, a leading slash, or a trailing slash with an empty suffix). Values // like `openrouter/anthropic/claude` group under their first segment. @@ -178,8 +279,13 @@ export function modelListGroups( option: SessionConfigOptionInfo ): ModelOptionGroup[] { if (option.kind.type !== "select") return [] - const kind = option.kind - const derived = deriveModelGroups(option) + // Polish first so a versioned description head ("Fable 5") is the name that + // prefix-stripping and the trigger both see. Grouping after polish still + // keys off values, which never change. + const polished = polishConfigOption(option) + if (polished.kind.type !== "select") return [] + const kind = polished.kind + const derived = deriveModelGroups(polished) if (derived) return derived if (kind.groups.length > 0) { return kind.groups.map((group) => ({