Merge pull request #11352 from Kilo-Org/fix-a11y-model-navigation

fix(vscode): improve screen reader model navigation
This commit is contained in:
Marius
2026-06-17 15:42:36 +02:00
committed by GitHub
5 changed files with 382 additions and 179 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Improve screen reader navigation and provider group controls in the model picker.
@@ -16,13 +16,14 @@ test("model selector exposes combobox relationships and active option movement",
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
const listbox = page.getByRole("listbox", { name: "Review model" })
const alpha = page.getByRole("option", { name: "Alpha" })
const bravo = page.getByRole("option", { name: "Bravo" })
const tree = page.getByRole("tree", { name: "Review model" })
const alpha = page.getByRole("treeitem", { name: "Alpha" })
const bravo = page.getByRole("treeitem", { name: "Bravo" })
await expect(combobox).toBeFocused()
await expect(combobox).toHaveAttribute("aria-expanded", "true")
await expect(combobox).toHaveAttribute("aria-controls", await listbox.getAttribute("id"))
await expect(combobox).toHaveAttribute("aria-haspopup", "tree")
await expect(combobox).toHaveAttribute("aria-controls", await tree.getAttribute("id"))
await expect(combobox).toHaveAttribute("aria-activedescendant", await alpha.getAttribute("id"))
await expect(combobox).toHaveAccessibleDescription("Choose the model used for code review tasks.")
await expect(alpha.locator("button")).toHaveCount(0)
@@ -46,12 +47,81 @@ test("model selector exposes combobox relationships and active option movement",
await expect(preview.getByRole("button", { name: "Add to favorites" })).toBeVisible()
})
test("typing a provider initial moves the active descendant to matching results", async ({ page }) => {
await load(page, "shared--model-selector-accessible")
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
await combobox.fill("N")
const nova = page.getByRole("treeitem", { name: "Nova" })
await expect(nova).toBeVisible()
await expect(combobox).toHaveAttribute("aria-activedescendant", await nova.getAttribute("id"))
await expect(page.getByRole("treeitem", { name: "NVIDIA" })).toHaveAttribute("aria-expanded", "true")
})
test("provider groups collapse, expand, and skip their model rows", async ({ page }) => {
await load(page, "shared--model-selector-accessible")
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
const kilo = page.getByRole("treeitem", { name: "Kilo" })
const nvidia = page.getByRole("treeitem", { name: "NVIDIA" })
await combobox.press("ArrowDown")
await combobox.press("ArrowLeft")
await expect(combobox).toHaveAttribute("aria-activedescendant", await kilo.getAttribute("id"))
await combobox.press("ArrowLeft")
await expect(kilo).toHaveAttribute("aria-expanded", "false")
await expect(page.getByRole("treeitem", { name: "Bravo" })).toBeHidden()
await combobox.press("ArrowDown")
await expect(combobox).toHaveAttribute("aria-activedescendant", await nvidia.getAttribute("id"))
await combobox.press("ArrowLeft")
await expect(nvidia).toHaveAttribute("aria-expanded", "false")
await combobox.press("ArrowRight")
await expect(nvidia).toHaveAttribute("aria-expanded", "true")
await combobox.press("ArrowRight")
await expect(combobox).toHaveAttribute(
"aria-activedescendant",
await page.getByRole("treeitem", { name: "Nemotron" }).getAttribute("id"),
)
})
test("active descendant always identifies a visible tree item", async ({ page }) => {
await load(page, "shared--model-selector-accessible")
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
const active = async () => {
await expect.poll(() => combobox.getAttribute("aria-activedescendant")).toBeTruthy()
const id = await combobox.getAttribute("aria-activedescendant")
await expect(page.locator(`[id="${id}"]`)).toBeVisible()
}
await active()
await combobox.press("ArrowDown")
await active()
await combobox.press("ArrowLeft")
await active()
await combobox.press("ArrowRight")
await active()
await combobox.fill("N")
await active()
await combobox.press("ArrowLeft")
await combobox.press("ArrowDown")
await combobox.press("ArrowLeft")
await active()
await combobox.fill("no matching model")
await active()
})
test("expanded preview waits for explicit pointer selection", async ({ page }) => {
await load(page, "shared--model-selector-accessible")
await page.getByRole("button", { name: "Review model: Alpha" }).click()
await page.getByRole("button", { name: "Expand" }).click()
await page.getByRole("option", { name: "Bravo" }).click()
await page.getByRole("treeitem", { name: "Bravo" }).click()
await expect(page.getByTestId("model-selector-value")).toHaveText("alpha")
await expect(page.getByRole("combobox", { name: "Review model: Alpha. Search models" })).toBeVisible()
@@ -66,15 +136,15 @@ test("selected favorite remains selected when its duplicate group is collapsed",
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
const alpha = page.getByRole("option", { name: "Alpha" })
const favorites = page.getByRole("button", { name: "Collapse Favorites" })
const alpha = page.getByRole("treeitem", { name: "Alpha" })
const favorites = page.getByRole("treeitem", { name: "Favorites" })
await expect(alpha.first()).toHaveAttribute("aria-selected", "true")
await expect.poll(() => favorites.evaluate((el) => getComputedStyle(el).borderTopStyle)).toBe("solid")
await favorites.click()
await expect(alpha).toHaveCount(1)
await expect(alpha).toHaveAttribute("aria-selected", "true")
await expect(combobox).toHaveAttribute("aria-activedescendant", await alpha.getAttribute("id"))
await expect(combobox).toHaveAttribute("aria-activedescendant", await favorites.getAttribute("id"))
})
test("Enter selects the active option and Escape restores selector focus", async ({ page }) => {
@@ -106,7 +176,7 @@ test("no-match search announces the empty result and can choose the default opti
await combobox.fill("no matching model")
await expect(page.locator(".model-selector-empty")).toHaveText("No model results")
const clear = page.getByRole("option", { name: "Use default model" })
const clear = page.getByRole("treeitem", { name: "Use default model" })
await expect(combobox).toHaveAttribute("aria-activedescendant", await clear.getAttribute("id"))
await combobox.press("Enter")
@@ -156,3 +226,17 @@ test("chat picker Escape returns focus to the prompt", async ({ page }) => {
await expect(page.locator("textarea.prompt-input")).toBeFocused()
})
test("slash model picker Escape returns focus to the prompt", async ({ page }) => {
await load(page, "prompt-input--default-420")
const prompt = page.locator("textarea.prompt-input")
await prompt.evaluate((el) => el.setAttribute("aria-disabled", "false"))
await prompt.fill("/model")
await prompt.press("Enter")
const combobox = page.getByRole("combobox", { name: /^Select model:.*Search models$/ })
await expect(combobox).toBeFocused()
await combobox.press("Escape")
await expect(prompt).toBeFocused()
})
@@ -61,6 +61,10 @@ function rowKey(kind: "model" | "favorite", providerID: string, modelID: string)
return `${kind}:${providerID}/${modelID}`
}
function groupKey(key: string) {
return `group:${key}`
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -77,6 +81,13 @@ interface ModelGroup {
rows: ModelRow[]
}
interface ModelNode {
key: string
kind: "group" | "row"
group?: ModelGroup
row?: ModelRow
}
interface ScrollAnchor {
key: string
top: number | undefined
@@ -129,7 +140,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const previewID = `${uid}-preview`
const descriptionID = `${uid}-description`
const optionID = (key: string) => `${uid}-option-${encodeURIComponent(key)}`
const groupID = (key: string) => `${uid}-group-${encodeURIComponent(key)}`
const activeModel = () => {
const items = props.models
if (items) return items.find((m) => m.providerID === props.value?.providerID && m.id === props.value?.modelID)
@@ -141,6 +151,8 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const [search, setSearch] = createSignal("")
const [debouncedSearch, setDebouncedSearch] = createSignal("")
const [selectedKey, setSelectedKey] = createSignal(CLEAR_KEY)
const [browsing, setBrowsing] = createSignal(false)
const [navigating, setNavigating] = createSignal(false)
const [preActiveKey, setPreActiveKey] = createSignal<string | null>(null)
const [previewKey, setPreviewKey] = createSignal<string | null>(null)
const [previewHeight, setPreviewHeight] = createSignal(500)
@@ -162,7 +174,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const [pointer, setPointer] = createSignal(true)
// Ref map: row key → DOM element. Populated by each row's ref callback,
// avoids DOM queries for scroll anchoring and scrollIntoView.
const refs = new Map<string, HTMLDivElement>()
const refs = new Map<string, HTMLElement>()
function onSplitterMouseDown(e: MouseEvent) {
e.preventDefault()
@@ -313,12 +325,17 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const isGroupOpen = (key: string) => !collapsed().has(key)
function toggleGroup(key: string) {
const target = groupKey(key)
setSelectedKey(target)
setBrowsing(true)
setNavigating(true)
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
scrollSelectedIntoView()
}
const rows = createMemo<ModelRow[]>(() => {
@@ -328,11 +345,22 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
return [{ key: CLEAR_KEY, kind: "clear" }, ...list]
})
const nodes = createMemo<ModelNode[]>(() => {
const result: ModelNode[] = []
if (props.allowClear) result.push({ key: CLEAR_KEY, kind: "row", row: { key: CLEAR_KEY, kind: "clear" } })
for (const group of groups()) {
result.push({ key: groupKey(group.key), kind: "group", group })
if (!isGroupOpen(group.key)) continue
result.push(...group.rows.map((row) => ({ key: row.key, kind: "row" as const, row, group })))
}
return result
})
const nodeMap = createMemo(() => new Map(nodes().map((node) => [node.key, node] as const)))
const nodeIndex = createMemo(() => new Map(nodes().map((node, i) => [node.key, i] as const)))
const rowMap = createMemo(() => new Map(rows().map((row) => [row.key, row] as const)))
const rowIndex = createMemo(() => new Map(rows().map((row, i) => [row.key, i] as const)))
const canonicalKey = (m: EnrichedModel) => rowKey("model", m.providerID, m.id)
const favoriteKey = (m: EnrichedModel) => rowKey("favorite", m.providerID, m.id)
const defaultKey = () => rows()[0]?.key ?? CLEAR_KEY
const defaultKey = () => nodes()[0]?.key ?? CLEAR_KEY
const activeKey = (m?: EnrichedModel | null) => {
if (!m) return props.allowClear ? CLEAR_KEY : defaultKey()
const key = modelKey(m.providerID, m.id)
@@ -345,7 +373,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
if (!row.model || !isActive(row.model)) return false
return activeKey(row.model) === row.key
}
const activeOptionID = () => (rowMap().has(selectedKey()) ? optionID(selectedKey()) : undefined)
const activeOptionID = () => (browsing() && nodeMap().has(selectedKey()) ? optionID(selectedKey()) : undefined)
const [anchor, setAnchor] = createSignal<ScrollAnchor | null>(null)
const previewModel = createMemo(() => rowMap().get(previewKey() ?? "")?.model ?? null)
@@ -353,17 +381,15 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const isSelected = createSelector(selectedKey)
const isPreActive = createSelector(preActiveKey)
// When the row list changes (filter, favorite toggle, provider connect),
// preserve the current selection if it still exists; otherwise fall back
// to the first row. This must NOT read activeModel() — doing so would
// cause a reactive loop where picking a model triggers a rows rebuild
// which resets selection.
// When the visible tree changes, preserve virtual focus only while its
// active descendant remains rendered. Collapsing a group moves focus to
// its heading before removing the child nodes.
createEffect(() => {
rows() // track
nodes() // track
setSelectedKey((prev) => {
if (rowMap().has(prev)) return prev
if (nodeMap().has(prev)) return prev
const next = untrack(() => activeKey(activeModel()))
return rowMap().has(next) ? next : defaultKey()
return nodeMap().has(next) ? next : defaultKey()
})
setPreActiveKey((prev) => (prev && rowMap().has(prev) ? prev : null))
setPreviewKey((prev) => (prev && rowMap().has(prev) ? prev : null))
@@ -393,13 +419,26 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
// which would cause star/unstar to reset selection mid-interaction.
// Falls back to defaultKey when the active model is filtered out.
createEffect(() => {
filtered() // track
const active = activeModel()
const canon = active ? canonicalKey(active) : null
const next = canon && rowMap().has(canon) ? canon : props.allowClear ? CLEAR_KEY : defaultKey()
setSelectedKey(next)
setPreActiveKey(next)
setPreviewKey(next)
const list = filtered()
untrack(() => {
const active = activeModel()
const canon = active ? canonicalKey(active) : null
const match = list[0]
const first = match ? canonicalKey(match) : null
const next =
canon && rowMap().has(canon)
? canon
: first && rowMap().has(first)
? first
: props.allowClear
? CLEAR_KEY
: defaultKey()
setSelectedKey(next)
setBrowsing(!!debouncedSearch() && nodeMap().has(next))
setNavigating(false)
setPreActiveKey(next)
setPreviewKey(next)
})
})
createEffect(() => {
@@ -411,7 +450,9 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
// recompute with the snapshot before we try to resolve the key.
queueMicrotask(() => {
const next = activeKey(activeModel())
setSelectedKey(next ?? CLEAR_KEY)
setSelectedKey(next ?? defaultKey())
setBrowsing(true)
setNavigating(false)
setPreActiveKey(next)
setPreviewKey(next)
requestAnimationFrame(() => {
@@ -422,28 +463,28 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
return
}
setOpenSnapshot(null)
setBrowsing(false)
setNavigating(false)
setSearch("")
setDebouncedSearch("")
clearTimeout(previewTimer)
})
// Listen for slash command trigger
// Register before the popover mounts so programmatic slash-command opens
// always restore the prompt before the popover's own Escape handler runs.
const onTrigger = () => setOpen(true)
window.addEventListener("openModelPicker", onTrigger)
onCleanup(() => {
window.removeEventListener("openModelPicker", onTrigger)
clearTimeout(previewTimer)
})
const onEscape = (e: KeyboardEvent) => {
if (!open() || e.key !== "Escape") return
e.preventDefault()
e.stopImmediatePropagation()
cancel()
}
createEffect(() => {
if (!open()) return
window.addEventListener("keydown", onEscape, true)
onCleanup(() => window.removeEventListener("keydown", onEscape, true))
window.addEventListener("openModelPicker", onTrigger)
window.addEventListener("keydown", onEscape, true)
onCleanup(() => {
window.removeEventListener("openModelPicker", onTrigger)
window.removeEventListener("keydown", onEscape, true)
clearTimeout(previewTimer)
})
function pick(model: EnrichedModel) {
@@ -481,17 +522,51 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
if (key) refs.get(key)?.scrollIntoView({ block })
}
function move(step: number) {
const list = rows()
if (list.length === 0) return
const idx = rowIndex().get(selectedKey()) ?? 0
const next = Math.max(0, Math.min(idx + step, list.length - 1))
const key = list[next]?.key ?? CLEAR_KEY
setRow(key)
schedulePreview(key)
function activate(key: string) {
setSelectedKey(key)
setBrowsing(true)
setNavigating(true)
const row = nodeMap().get(key)?.row
setPreActiveKey(row?.model ? key : null)
schedulePreview(row?.model ? key : null)
scrollSelectedIntoView()
}
function move(step: number) {
const list = nodes()
if (list.length === 0) return
const idx = nodeIndex().get(selectedKey()) ?? (step > 0 ? -1 : list.length)
const next = Math.max(0, Math.min(idx + step, list.length - 1))
const key = list[next]?.key
if (key) activate(key)
}
function edge(index: number) {
const key = nodes()[index]?.key
if (key) activate(key)
}
function horizontal(step: -1 | 1) {
const node = nodeMap().get(selectedKey())
if (!node) return
if (node.kind === "group" && node.group) {
if (step === -1 && isGroupOpen(node.group.key)) {
toggleGroup(node.group.key)
return
}
if (step === 1 && !isGroupOpen(node.group.key)) {
toggleGroup(node.group.key)
return
}
if (step === 1) {
const key = node.group.rows[0]?.key
if (key) activate(key)
}
return
}
if (step === -1 && node.group) activate(groupKey(node.group.key))
}
function selectRow(row: ModelRow) {
if (row.kind === "clear") {
pickClear()
@@ -524,7 +599,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
}
function handleKeyDown(e: KeyboardEvent) {
const list = rows()
const list = nodes()
if (e.key === "Escape") {
e.preventDefault()
@@ -532,28 +607,38 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
return
}
if (list.length === 0) {
if (list.length === 0) return
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault()
setPointer(false)
move(e.key === "ArrowDown" ? 1 : -1)
return
}
if (e.key === "ArrowDown") {
if (navigating() && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
e.preventDefault()
setPointer(false)
move(1)
horizontal(e.key === "ArrowLeft" ? -1 : 1)
return
}
if (e.key === "ArrowUp") {
if (navigating() && (e.key === "Home" || e.key === "End")) {
e.preventDefault()
setPointer(false)
move(-1)
edge(e.key === "Home" ? 0 : list.length - 1)
return
}
if (isEnterKeyCommitNotIme(e)) {
const node = nodeMap().get(selectedKey())
if (!node) return
e.preventDefault()
const row = rowMap().get(selectedKey())
if (row) selectRow(row)
if (node.kind === "group" && node.group) {
toggleGroup(node.group.key)
return
}
if (node.row) selectRow(node.row)
}
}
@@ -673,13 +758,24 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
aria-label={searchLabel()}
aria-describedby={describedBy()}
aria-autocomplete="list"
aria-haspopup="listbox"
aria-haspopup="tree"
aria-expanded={open()}
aria-controls={listID}
aria-activedescendant={activeOptionID()}
placeholder={language.t("dialog.model.search.placeholder")}
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
onInput={(e) => {
setBrowsing(false)
setNavigating(false)
setSearch(e.currentTarget.value)
}}
onMouseDown={(e) => {
const input = e.currentTarget
if (input.selectionStart !== input.selectionEnd || input.selectionStart !== input.value.length) {
setBrowsing(false)
setNavigating(false)
}
}}
/>
<Tooltip
value={expanded() ? language.t("dialog.model.collapse") : language.t("dialog.model.expand")}
@@ -709,7 +805,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
</Tooltip>
</div>
<div id={listID} class="model-selector-list" role="listbox" aria-label={label()} ref={listRef}>
<div id={listID} class="model-selector-list" role="tree" aria-label={label()} ref={listRef}>
<Show when={groups().length === 0}>
<div class="model-selector-empty" role="status" aria-live="polite">
{language.t("dialog.model.empty")}
@@ -719,8 +815,12 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
<Show when={props.allowClear}>
<div
id={optionID(CLEAR_KEY)}
ref={(el) => {
refs.set(CLEAR_KEY, el)
onCleanup(() => refs.delete(CLEAR_KEY))
}}
class={`model-selector-item${isSelected(CLEAR_KEY) && !pointer() ? " keyboard-focused" : ""}${isSelected(CLEAR_KEY) ? " selected" : ""}${!props.value?.providerID ? " active" : ""}`}
role="option"
role="treeitem"
aria-selected={!props.value?.providerID}
onClick={() => pickClear()}
onMouseMove={() => {
@@ -740,17 +840,22 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
{(group) => {
const shown = () => isGroupOpen(group.key)
return (
<div class="model-selector-group" role="group" aria-labelledby={groupID(group.key)}>
<button
id={groupID(group.key)}
type="button"
class="model-selector-group-label"
<div class="model-selector-group" role="presentation">
<div
id={optionID(groupKey(group.key))}
ref={(el) => {
refs.set(groupKey(group.key), el)
onCleanup(() => refs.delete(groupKey(group.key)))
}}
class={`model-selector-group-label${isSelected(groupKey(group.key)) ? " selected" : ""}${isSelected(groupKey(group.key)) && !pointer() ? " keyboard-focused" : ""}`}
role="treeitem"
aria-expanded={shown()}
aria-label={language.t(shown() ? "model.group.collapse" : "model.group.expand", {
group: group.label,
})}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggleGroup(group.key)}
onMouseMove={() => setPointer(true)}
onMouseEnter={() => {
if (pointer()) setSelectedKey(groupKey(group.key))
}}
>
<svg
class={`model-selector-group-chevron${shown() ? "" : " model-selector-group-chevron--collapsed"}`}
@@ -766,120 +871,123 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
<Show when={!shown() && !!debouncedSearch()}>
<span class="model-selector-group-match-dot" aria-hidden="true" />
</Show>
</button>
</div>
<Show when={shown()}>
<For each={group.rows}>
{(row) => {
if (!row.model) return null
const model = row.model
const hovered = () => isSelected(row.key)
const preActive = () => isPreActive(row.key)
const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id))
const showProvider = () => row.kind === "favorite"
const showSelect = () => expanded() && preActive() && !isActive(model)
const starLabel = () =>
`${starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")}: ${sanitizeName(model.name)}`
return (
<div
role="presentation"
class={`model-selector-row${hovered() || preActive() ? " selected" : ""}`}
>
<div role="group" aria-label={group.label}>
<For each={group.rows}>
{(row) => {
if (!row.model) return null
const model = row.model
const hovered = () => isSelected(row.key)
const preActive = () => isPreActive(row.key)
const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id))
const showProvider = () => row.kind === "favorite"
const showSelect = () => expanded() && preActive() && !isActive(model)
const starLabel = () =>
`${starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")}: ${sanitizeName(model.name)}`
return (
<div
id={optionID(row.key)}
ref={(el) => {
refs.set(row.key, el)
onCleanup(() => refs.delete(row.key))
}}
class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${chosen(row) ? " active" : ""}`}
role="option"
aria-selected={chosen(row)}
onClick={() => {
if (!expanded()) {
selectRow(row)
return
}
setRow(row.key)
setPreviewKey(row.key)
searchRef?.focus()
}}
onDblClick={() => {
if (expanded()) selectRow(row)
}}
onMouseMove={() => {
setPointer(true)
}}
onMouseEnter={() => {
if (pointer()) setSelectedKey(row.key)
schedulePreview(row.key)
}}
role="presentation"
class={`model-selector-row${hovered() || preActive() ? " selected" : ""}`}
>
<div class="model-selector-item-left">
<span class="model-selector-item-name">
{(() => {
const full = sanitizeName(model.name)
const sep = full.indexOf(": ")
if (sep < 0) return <span class="model-selector-item-name-main">{full}</span>
return (
<>
<span class="model-selector-item-name-provider">
{full.slice(0, sep)}
</span>
<span class="model-selector-item-name-main">{full.slice(sep + 2)}</span>
</>
)
})()}
</span>
<Show when={isFree(model) || isDataCollectedModel(model)}>
<span class="model-selector-free-data">
<Show when={isFree(model)}>
<span class="model-selector-data-badge">
<Tag data-variant="member">{freeLabel()}</Tag>
</span>
</Show>
<Show when={isDataCollectedModel(model)}>
<Tooltip value={dataLabel()} placement="top">
<span class="model-selector-free-data-icon" aria-label={dataLabel()}>
<Icon name="book-open-check" size="small" />
</span>
</Tooltip>
</Show>
</span>
</Show>
<Show when={showProvider()}>
<span class="model-selector-item-provider-tag">{model.providerName}</span>
</Show>
</div>
</div>
<Show when={session && props.favorites !== false}>
<button
type="button"
class={`model-selector-star${starred() ? " model-selector-star--active" : ""}`}
aria-label={starLabel()}
aria-pressed={starred()}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation()
toggleFavorite(model, row)
<div
id={optionID(row.key)}
ref={(el) => {
refs.set(row.key, el)
onCleanup(() => refs.delete(row.key))
}}
class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${chosen(row) ? " active" : ""}`}
role="treeitem"
aria-selected={chosen(row)}
onClick={() => {
if (!expanded()) {
selectRow(row)
return
}
setRow(row.key)
setPreviewKey(row.key)
searchRef?.focus()
}}
onDblClick={() => {
if (expanded()) selectRow(row)
}}
onMouseMove={() => {
setPointer(true)
}}
onMouseEnter={() => {
if (pointer()) setSelectedKey(row.key)
schedulePreview(row.key)
}}
>
<Icon name={starred() ? "star-filled" : "star"} size="small" />
</button>
</Show>
<Show when={showSelect()}>
<button
type="button"
class="model-selector-item-select-btn"
aria-label={`${language.t("dialog.model.select")}: ${sanitizeName(model.name)}`}
onClick={() => selectRow(row)}
>
{language.t("dialog.model.select")}
</button>
</Show>
</div>
)
}}
</For>
<div class="model-selector-item-left">
<span class="model-selector-item-name">
{(() => {
const full = sanitizeName(model.name)
const sep = full.indexOf(": ")
if (sep < 0)
return <span class="model-selector-item-name-main">{full}</span>
return (
<>
<span class="model-selector-item-name-provider">
{full.slice(0, sep)}
</span>
<span class="model-selector-item-name-main">{full.slice(sep + 2)}</span>
</>
)
})()}
</span>
<Show when={isFree(model) || isDataCollectedModel(model)}>
<span class="model-selector-free-data">
<Show when={isFree(model)}>
<span class="model-selector-data-badge">
<Tag data-variant="member">{freeLabel()}</Tag>
</span>
</Show>
<Show when={isDataCollectedModel(model)}>
<Tooltip value={dataLabel()} placement="top">
<span class="model-selector-free-data-icon" aria-label={dataLabel()}>
<Icon name="book-open-check" size="small" />
</span>
</Tooltip>
</Show>
</span>
</Show>
<Show when={showProvider()}>
<span class="model-selector-item-provider-tag">{model.providerName}</span>
</Show>
</div>
</div>
<Show when={session && props.favorites !== false}>
<button
type="button"
class={`model-selector-star${starred() ? " model-selector-star--active" : ""}`}
aria-label={starLabel()}
aria-pressed={starred()}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation()
toggleFavorite(model, row)
searchRef?.focus()
}}
>
<Icon name={starred() ? "star-filled" : "star"} size="small" />
</button>
</Show>
<Show when={showSelect()}>
<button
type="button"
class="model-selector-item-select-btn"
aria-label={`${language.t("dialog.model.select")}: ${sanitizeName(model.name)}`}
onClick={() => selectRow(row)}
>
{language.t("dialog.model.select")}
</button>
</Show>
</div>
)
}}
</For>
</div>
</Show>
</div>
)
@@ -41,6 +41,10 @@ const ACCESSIBLE_MODELS: EnrichedModel[] = [
{ id: "alpha", name: "Alpha", providerID: "kilo", providerName: "Kilo" },
{ id: "bravo", name: "Bravo", providerID: "kilo", providerName: "Kilo" },
{ id: "charlie", name: "Charlie", providerID: "kilo", providerName: "Kilo" },
{ id: "delta", name: "Delta", providerID: "kilo", providerName: "Kilo" },
{ id: "echo", name: "Echo", providerID: "kilo", providerName: "Kilo" },
{ id: "nova", name: "Nova", providerID: "nvidia", providerName: "NVIDIA" },
{ id: "nemotron", name: "Nemotron", providerID: "nvidia", providerName: "NVIDIA" },
]
const AccessibleModelSelector = () => {
@@ -325,11 +325,13 @@
font-family: inherit;
}
.model-selector-group-label:hover {
.model-selector-group-label:hover,
.model-selector-group-label.selected {
color: var(--text-base, var(--vscode-foreground));
background: var(--surface-interactive-hover, var(--vscode-list-hoverBackground));
}
.model-selector-group-label:focus-visible {
.model-selector-group-label.keyboard-focused {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}