diff --git a/src/features/agents/ConfigPickers.test.tsx b/src/features/agents/ConfigPickers.test.tsx index 635fdf068..2c7c737de 100644 --- a/src/features/agents/ConfigPickers.test.tsx +++ b/src/features/agents/ConfigPickers.test.tsx @@ -1,7 +1,52 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { AgentConfigOption } from "@/bindings"; -import { ConfigPickers } from "./ConfigPickers"; +import { ConfigPickers, configPickerOptions } from "./ConfigPickers"; + +const config = (values: AgentConfigOption["values"], category = "model"): AgentConfigOption => ({ + id: "model", + name: "Model", + category, + description: null, + current: null, + via: "option", + values, +}); +const value = (id: string, name = id, description: string | null = null) => ({ value: id, name, description }); + +describe("configPickerOptions", () => { + it("groups nested paths by their first provider and preserves the full value and description", () => { + expect(configPickerOptions(config([value("9router/cmc/Beam", "Beam display", "Fast")]))).toEqual([ + { value: "9router", label: "9router", section: true }, + { value: "9router/cmc/Beam", label: "Beam", detail: "cmc/Beam · Fast", keywords: "9router" }, + ]); + }); + it("keeps plain ids ungrouped even when the display name contains a slash", () => { + expect(configPickerOptions(config([value("plain", "Local / Beam", "Offline")]))).toEqual([ + { value: "plain", label: "Local / Beam", detail: "Offline", keywords: "" }, + ]); + }); + it("collects several providers in encounter order and preserves order inside each group", () => { + const options = configPickerOptions( + config([value("a/One"), value("b/Two"), value("a/Three"), value("plain"), value("__section__:a")]), + ); + expect(options.map((o) => [o.value, !!o.section])).toEqual([ + ["plain", false], + ["__section__:a", false], + ["a", true], + ["a/One", false], + ["a/Three", false], + ["b", true], + ["b/Two", false], + ]); + expect(options[3]?.detail).toBe("One"); + }); + it("leaves non-model option labels and descriptions intact", () => { + expect(configPickerOptions(config([value("a/One", "Custom", "Description")], "mode"))).toEqual([ + { value: "a/One", label: "Custom", detail: "Description" }, + ]); + }); +}); it("shows Cursor approvals separately from its execution mode and sends the choice", () => { const option = (id: string, category: string, values: string[]): AgentConfigOption => ({ @@ -32,3 +77,11 @@ it("shows Cursor approvals separately from its execution mode and sends the choi fireEvent.click(screen.getByRole("option", { name: "Bypass approvals" })); expect(onChange).toHaveBeenCalledWith("openidea.cursor.approval", "bypass"); }); + +it("sends the complete nested model id through ConfigPickers", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Model" })); + fireEvent.click(screen.getByRole("option", { name: "Beamcmc/Beam" })); + expect(onChange).toHaveBeenCalledExactlyOnceWith("model", "9router/cmc/Beam"); +}); diff --git a/src/features/agents/ConfigPickers.tsx b/src/features/agents/ConfigPickers.tsx index c9d26a002..08a0e8af8 100644 --- a/src/features/agents/ConfigPickers.tsx +++ b/src/features/agents/ConfigPickers.tsx @@ -5,9 +5,33 @@ import type { AgentConfigOption } from "@/bindings"; import { useTx } from "@/platform/i18n"; import { Select } from "@/ui"; +import type { SelectOption } from "@/ui/Select"; import { mainOptions, modeIcon } from "./composerModel"; import styles from "./Composer.module.css"; +export function configPickerOptions(option: AgentConfigOption): SelectOption[] { + if (option.category !== "model") { + return option.values.map((v) => ({ value: v.value, label: v.name, detail: v.description ?? undefined })); + } + const groups = new Map(); + for (const v of option.values) { + const slash = v.value.indexOf("/"); + const provider = slash >= 0 ? v.value.slice(0, slash) : ""; + const path = slash >= 0 ? v.value.slice(slash + 1) : ""; + const label = path ? path.slice(path.lastIndexOf("/") + 1) : v.name || v.value; + const detail = path ? [path, v.description].filter(Boolean).join(" · ") : (v.description ?? undefined); + const bucket = groups.get(provider) ?? []; + bucket.push({ value: v.value, label, detail, keywords: provider }); + groups.set(provider, bucket); + } + const plain = groups.get("") ?? []; + groups.delete(""); + return [ + ...plain, + ...[...groups].flatMap(([provider, items]) => [{ value: provider, label: provider, section: true }, ...items]), + ]; +} + export function ConfigPickers({ config, values, @@ -24,6 +48,8 @@ export function ConfigPickers({ {mainOptions(config).map((o) => { const isMode = o.category === "mode"; const value = values?.[o.id] ?? o.current ?? null; + const options = configPickerOptions(o); + return ( ); + fireEvent.click(screen.getByRole("button", { name: "Model" })); + return onChange; +} +const list = () => screen.getByRole("listbox"); +const labels = () => + within(list()) + .queryAllByRole("option") + .map((o) => o.textContent); +const query = (value: string) => fireEvent.change(screen.getByRole("textbox"), { target: { value } }); +const key = (key: string, target = list()) => fireEvent.keyDown(target, { key }); + +describe("Select", () => { + it("focuses the search input when opened", async () => { + setup(); + await waitFor(() => expect(screen.getByRole("textbox")).toHaveFocus()); + }); + it("never selects sections by click, Enter or type-ahead, and preserves full model values", () => { + const changed = setup(false); + fireEvent.click(within(list()).getByText("9router")); + expect(changed).not.toHaveBeenCalled(); + key("9"); + key("Enter"); + expect(changed).toHaveBeenCalledExactlyOnceWith("9router/cmc/Beam"); + }); + it("does not select a list containing only headings", () => { + const changed = setup(false, [{ value: "a", label: "A", section: true }]); + key("a"); + key("Enter"); + expect(changed).not.toHaveBeenCalled(); + }); + it("skips sections and disabled options with arrows, Home and End", () => { + const changed = setup(false); + key("ArrowDown"); + expect(within(list()).getByRole("option", { name: "Blue Moon" })).toHaveAttribute("aria-selected", "true"); + key("End"); + key("Home"); + key("ArrowUp"); + key("Enter"); + expect(changed).toHaveBeenCalledExactlyOnceWith("__section__:real"); + }); + it("renders disabled models as options, retains them in search, and rejects selection", () => { + const changed = setup(); + query("blocked"); + const blocked = within(list()).getByRole("option", { name: "Blocked" }); + expect(within(list()).getByText("9router")).toBeInTheDocument(); + fireEvent.click(blocked); + key("Enter"); + expect(changed).not.toHaveBeenCalled(); + }); + it("matches provider, model, nested path, description, and multiple case-insensitive tokens", () => { + setup(); + for (const search of ["9router Beam", "CMC fast", "9ROUTER bm"]) { + query(search); + expect(labels()).toEqual(["Beamcmc/Beam · Fast"]); + expect(within(list()).getByText("9router")).toBeInTheDocument(); + expect(within(list()).queryByText("Other")).not.toBeInTheDocument(); + } + }); + it("navigates filtered results and lets search input handle spaces and caret movement", () => { + const changed = setup(); + query("other"); + const input = screen.getByRole("textbox"); + expect(fireEvent.keyDown(input, { key: " " })).toBe(true); + expect(fireEvent.keyDown(input, { key: "Home" })).toBe(true); + key("ArrowDown", input); + key("Enter", input); + expect(changed).toHaveBeenCalledExactlyOnceWith("other/BlueMoon"); + }); + it("keeps exact substrings alongside fuzzy matches in original order", () => { + setup(true, [ + { value: "exact", label: "Beam" }, + { value: "fuzzy", label: "Beta amber" }, + ]); + query("beam"); + expect(labels()).toEqual(["Beam", "Beta amber"]); + }); + it("keeps non-searchable type-ahead prefix-only and shows every option", () => { + const changed = setup(false); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + key("b"); + key("m"); + key("Enter"); + expect(changed).toHaveBeenCalledExactlyOnceWith("other/BlueMoon"); + }); + it("does not confuse a heading value with a real option value", () => { + const changed = setup(false, [ + { value: "a", label: "Provider", section: true }, + { value: "a", label: "Real" }, + ]); + key("Enter"); + expect(changed).toHaveBeenCalledExactlyOnceWith("a"); + }); +}); diff --git a/src/ui/Select.tsx b/src/ui/Select.tsx index d6fb90437..8d46fbe2e 100644 --- a/src/ui/Select.tsx +++ b/src/ui/Select.tsx @@ -12,7 +12,11 @@ export interface SelectOption { label: string; icon?: IconName | (string & {}); detail?: string; + /** Extra text (not shown) that the search should also match. */ + keywords?: string; disabled?: boolean; + /** A non-selectable group heading, independent of disabled options. */ + section?: boolean; } export interface SelectProps { @@ -33,6 +37,17 @@ export interface SelectProps { const TYPE_AHEAD_MS = 800; +/** Substring or subsequence (fuzzy) match. */ +function containsOrFuzzy(haystack: string, needle: string): boolean { + if (haystack.includes(needle)) return true; + let i = 0; + for (const ch of haystack) { + if (ch === needle[i]) i++; + if (i >= needle.length) return true; + } + return false; +} + /** Dropdown select with a listbox popup (↑↓ Enter Esc, type-ahead). */ export function Select({ value, @@ -49,40 +64,80 @@ export function Select({ const tx = useTx(); const [open, setOpen] = useState(false); const triggerRef = useRef(null); - const current = options.find((o) => o.value === value); + const current = options.find((o) => !o.section && o.value === value); const [active, setActive] = useState(0); const [query, setQuery] = useState(""); const typeAhead = useRef({ text: "", at: -Infinity }); - const isSection = (o: SelectOption) => !!o.disabled; + const isSection = (o: SelectOption) => !!o.section; + const unavailable = (o: SelectOption) => !!(o.section || o.disabled); const filtered = searchable && query.trim() - ? options.filter((o) => !o.disabled && tx(o.label).toLowerCase().includes(query.trim().toLowerCase())) + ? (() => { + const q = query.trim().toLowerCase(); + const tokens = q.split(/\s+/).filter(Boolean); + const matches = (o: SelectOption) => { + const haystack = `${tx(o.label)} ${o.detail != null ? tx(o.detail) : ""} ${o.keywords ?? ""}`.toLowerCase(); + return tokens.every((tok) => containsOrFuzzy(haystack, tok)); + }; + // Keep section headers visible as long as at least one of their + // items matches, so the provider grouping survives filtering. + const out: SelectOption[] = []; + let section: SelectOption | null = null; + let bucket: SelectOption[] = []; + let sectionMatched = false; + const flush = () => { + if (section && sectionMatched) { + out.push(section, ...bucket); + } else if (!section && bucket.length) { + out.push(...bucket); + } + section = null; + bucket = []; + sectionMatched = false; + }; + for (const o of options) { + if (o.section) { + flush(); + section = o; + } else if (matches(o)) { + bucket.push(o); + sectionMatched = true; + } + } + flush(); + return out; + })() : options; + const activeIndex = + filtered[active] && !unavailable(filtered[active]!) ? active : filtered.findIndex((o) => !unavailable(o)); + const openPopup = () => { setQuery(""); setActive( Math.max( 0, - options.findIndex((o) => o.value === value), + options.findIndex((o) => !unavailable(o) && o.value === value), ), ); setOpen(true); }; const choose = (o: SelectOption | undefined) => { - if (!o || o.disabled) return; + if (!o || unavailable(o)) return; onChange(o.value); setOpen(false); }; const onPopupKey = (e: KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { + const searching = searchable && e.target instanceof HTMLInputElement; + if (e.key === "Enter" || (e.key === " " && !searching)) { e.preventDefault(); - choose(filtered[active]); + choose(filtered[activeIndex]); return; } - const next = listNavigate(e.key, { items: options, active, isSection, wrap: true }); + if (searching && !["ArrowDown", "ArrowUp"].includes(e.key)) return; + const next = listNavigate(e.key, { items: filtered, active: activeIndex, isSection: unavailable, wrap: true }); if (next !== null) { e.preventDefault(); setActive(next); @@ -97,8 +152,8 @@ export function Select({ const prefix = repeat ? ahead.text[0]! : ahead.text; const n = filtered.length; for (let s = prefix.length > 1 ? 0 : 1; s <= n; s++) { - const i = (active + s) % n; - if (!filtered[i]!.disabled && tx(filtered[i]!.label).toLowerCase().startsWith(prefix)) { + const i = (activeIndex + s) % n; + if (!unavailable(filtered[i]!) && tx(filtered[i]!.label).toLowerCase().startsWith(prefix)) { setActive(i); break; } @@ -144,7 +199,12 @@ export function Select({ role="presentation" className={styles.popup} > -
+
{searchable && (
({ placeholder={tx("Search…")} onChange={(v) => { setQuery(v); - setActive(0); + setActive(-1); }} size="normal" variant="framed" @@ -164,7 +224,7 @@ export function Select({ {/* Virtualised rows are absolutely positioned; this sizes the popup to the widest label. */}
{options.map((o) => ( -
+
{o.icon && } {tx(o.label)} @@ -174,11 +234,13 @@ export function Select({
o.value} + getKey={(o) => `${o.section ? "section" : "option"}:${o.value}`} isSection={isSection} renderSection={(o) => {tx(o.label)}} - activeIndex={active} - onActiveChange={setActive} + activeIndex={activeIndex} + onActiveChange={(i) => { + if (!unavailable(filtered[i]!)) setActive(i); + }} onActivate={(o) => choose(o)} focusable={false} hoverSelects @@ -186,7 +248,10 @@ export function Select({ aria-label={tx(aria["aria-label"])} className={styles.list} renderItem={(o) => ( - <> + {o.value === value && } {o.icon && } {tx(o.label)} @@ -195,7 +260,7 @@ export function Select({ {tx(o.detail)} )} - + )} />