feat(agents): group model picker by provider and improve search (#486)

Agents can report model ids with nested provider paths, which made long rows that lost their provider context. The model picker now shows the short name under a provider heading with the remaining path as detail. Searchable Select matches several tokens across label, detail and hidden keywords, with fuzzy matching, and keeps a heading while one of its items matches.

Select now has an explicit section option, separate from disabled, so a heading can never be selected and a disabled option is not rendered as a heading. Existing section rows in provider options and Find in Files scopes use it. Keyboard navigation skips headings in filtered lists.

Co-authored-by: dimassfeb-09 <[email protected]>
Цей коміт міститься в:
DIMAS FEBRIYANTO authored and GitHub committed 2026-10-31 00:55:29 +08:00
1 джерело 7aad01b035
коміт 986b0ade58
7 files changed
+282 -28

No files matched your search

+55 -2
Переглянути файл
@@ -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(<ConfigPickers config={[config([value("9router/cmc/Beam")])]} onChange={onChange} />);
fireEvent.click(screen.getByRole("button", { name: "Model" }));
fireEvent.click(screen.getByRole("option", { name: "Beamcmc/Beam" }));
expect(onChange).toHaveBeenCalledExactlyOnceWith("model", "9router/cmc/Beam");
});
+30 -6
Переглянути файл
@@ -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<string, SelectOption[]>();
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 (
<Select
key={o.id}
@@ -31,18 +57,16 @@ export function ConfigPickers({
aria-label={o.name}
value={value}
placeholder={tx(o.name)}
options={o.values.map((v) => ({
value: v.value,
label: v.name,
options={options.map((opt) => ({
...opt,
icon:
o.category === "approval"
? v.value === "bypass"
? opt.value === "bypass"
? "unlock"
: "lock"
: isMode
? modeIcon(v.value)
? modeIcon(opt.value)
: undefined,
detail: v.description ?? undefined,
}))}
searchable={o.category === "model"}
onChange={(v) => onChange(o.id, v)}
+1 -1
Переглянути файл
@@ -21,7 +21,7 @@ export function providerOptions(
const missing = providers.filter((p) => !p.installed);
return [
...installed.map((p) => ({ value: p.id, label: p.name, icon: icon(p.id), detail: version(p) })),
...(missing.length ? [{ value: "__missing__", label: tx("Not installed"), disabled: true }] : []),
...(missing.length ? [{ value: "__missing__", label: tx("Not installed"), section: true }] : []),
...missing.map((p) => ({ value: p.id, label: p.name, icon: icon(p.id), detail: p.command })),
{ value: ADD_AGENT, label: tx("More agents…") },
];
+1 -1
Переглянути файл
@@ -640,7 +640,7 @@ function FindInFilesDialog() {
...NAMED_SCOPES,
...(customScopes.length
? [
{ value: "custom:" as NamedScope, label: "Custom Scopes", disabled: true },
{ value: "custom:" as NamedScope, label: "Custom Scopes", section: true },
...customScopes.map((c) => ({
value: customScopeValue(c.name),
label: c.name,
+4
Переглянути файл
@@ -136,3 +136,7 @@
box-shadow: inset 0 0 0 1px var(--border-button);
color: var(--fg-strong);
}
.option {
display: contents;
}
+108
Переглянути файл
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, within, waitFor } from "@testing-library/react";
import { Select, type SelectOption } from "./Select";
const options: SelectOption[] = [
{ value: "9router", label: "9router", section: true },
{ value: "9router/cmc/Beam", label: "Beam", detail: "cmc/Beam · Fast", keywords: "9router" },
{ value: "9router/blocked", label: "Blocked", disabled: true, keywords: "9router" },
{ value: "other", label: "Other", section: true },
{ value: "other/BlueMoon", label: "Blue Moon", keywords: "other" },
{ value: "__section__:real", label: "Plain" },
];
function setup(searchable = true, rows = options) {
const onChange = vi.fn();
render(<Select aria-label="Model" options={rows} value={null} searchable={searchable} onChange={onChange} />);
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");
});
});
+83 -18
Переглянути файл
@@ -12,7 +12,11 @@ export interface SelectOption<V extends string = string> {
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<V extends string = string> {
@@ -33,6 +37,17 @@ export interface SelectProps<V extends string = string> {
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<V extends string = string>({
value,
@@ -49,40 +64,80 @@ export function Select<V extends string = string>({
const tx = useTx();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(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<V>) => !!o.disabled;
const isSection = (o: SelectOption<V>) => !!o.section;
const unavailable = (o: SelectOption<V>) => !!(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<V>) => {
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<V>[] = [];
let section: SelectOption<V> | null = null;
let bucket: SelectOption<V>[] = [];
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<V> | 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<V extends string = string>({
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<V extends string = string>({
role="presentation"
className={styles.popup}
>
<div tabIndex={-1} data-autofocus onKeyDown={onPopupKey} className={styles.popupInner}>
<div
tabIndex={-1}
data-autofocus={searchable ? undefined : true}
onKeyDown={onPopupKey}
className={styles.popupInner}
>
{searchable && (
<div className={styles.searchWrap}>
<SearchField
@@ -153,7 +213,7 @@ export function Select<V extends string = string>({
placeholder={tx("Search…")}
onChange={(v) => {
setQuery(v);
setActive(0);
setActive(-1);
}}
size="normal"
variant="framed"
@@ -164,7 +224,7 @@ export function Select<V extends string = string>({
{/* Virtualised rows are absolutely positioned; this sizes the popup to the widest label. */}
<div aria-hidden className={styles.sizer}>
{options.map((o) => (
<div key={o.value} className={styles.sizerRow}>
<div key={`${o.section ? "section" : "option"}:${o.value}`} className={styles.sizerRow}>
<span className={styles.check} />
{o.icon && <Icon name={o.icon} size={14} />}
<span>{tx(o.label)}</span>
@@ -174,11 +234,13 @@ export function Select<V extends string = string>({
</div>
<List
items={filtered}
getKey={(o) => o.value}
getKey={(o) => `${o.section ? "section" : "option"}:${o.value}`}
isSection={isSection}
renderSection={(o) => <span className={styles.disabled}>{tx(o.label)}</span>}
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<V extends string = string>({
aria-label={tx(aria["aria-label"])}
className={styles.list}
renderItem={(o) => (
<>
<span
className={clsx(styles.option, o.disabled && styles.disabled)}
aria-disabled={o.disabled || undefined}
>
<span className={styles.check}>{o.value === value && <Icon name="check" size={12} />}</span>
{o.icon && <Icon name={o.icon} size={14} />}
<span className={clsx(styles.optionLabel, o.detail && styles.withDetail)}>{tx(o.label)}</span>
@@ -195,7 +260,7 @@ export function Select<V extends string = string>({
{tx(o.detail)}
</span>
)}
</>
</span>
)}
/>
</div>