fix(vscode): optimize model selector search and auto-jump to active match (#12810)

* fix(vscode): optimize model selector search and auto-jump to active match

Remove the 250 ms search debounce so the model list updates on every
keystroke. Add a latest-search-only animation-frame scroll that keeps
the active match visible when filtering from a deeply scrolled list,
and track pointer coordinates so rows moving beneath a stationary
pointer no longer steal selection while real hover still transfers
immediately.

* fix(vscode): stabilize model selector search navigation

* chore: update kilo-vscode visual regression baselines

* chore: update kilo-vscode visual regression baselines

---------

Co-authored-by: kilo-maintainer[bot] <kilo-maintainer[bot]@users.noreply.github.com>
This commit is contained in:
Marius
2026-08-03 16:57:39 +02:00
committed by GitHub
parent ce7984fc42
commit e04f6531bf
4 changed files with 66 additions and 29 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Update model search results instantly and keep the active match visible while typing.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a7d7d047f14440d3dafc8a202131af6e755f80875012fd934a56580de21d36d5
size 52887
oid sha256:c3863da7e15cc40467319046521fbbfed0ee6ceab9f1f3ad03adb8941f454666
size 52895
@@ -193,6 +193,18 @@ test("large catalogs keep the rendered tree bounded and navigate to distant mode
// The window mounts before we measure it, yet stays far smaller than the catalog.
await expect.poll(() => tree.getByRole("treeitem").count()).toBeGreaterThan(0)
await expect.poll(() => tree.getByRole("treeitem").count()).toBeLessThan(50)
await expect(page.getByRole("treeitem", { name: "Model 300" })).toBeVisible()
// Searching from deep in the catalog scrolls the first active match into view.
await tree.getByRole("treeitem").last().hover()
await tree.evaluate((el) => el.scrollTo({ top: el.scrollHeight }))
await combobox.pressSequentially("Model 5")
const first = page.getByRole("treeitem", { name: "Model 500" })
await expect(first).toBeVisible()
await expect(combobox).toHaveAttribute("aria-activedescendant", await first.getAttribute("id"))
const hovered = page.getByRole("treeitem", { name: "Model 501" })
await hovered.hover()
await expect(combobox).toHaveAttribute("aria-activedescendant", await hovered.getAttribute("id"))
// Reaching a distant model scrolls it into the mounted window and activates it.
await combobox.fill("Model 599")
@@ -159,7 +159,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const expanded = vscode.getModelSelectorExpanded
const setExpanded = vscode.setModelSelectorExpanded
const [search, setSearch] = createSignal("")
const [debouncedSearch, setDebouncedSearch] = createSignal("")
const [selectedKey, setSelectedKey] = createSignal(CLEAR_KEY)
const [browsing, setBrowsing] = createSignal(false)
const [navigating, setNavigating] = createSignal(false)
@@ -181,6 +180,10 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
let listRef: HTMLDivElement | undefined
let bodyRef: HTMLDivElement | undefined
let previewTimer: ReturnType<typeof setTimeout> | undefined
let scrollFrame: number | undefined
let pointerX: number | undefined
let pointerY: number | undefined
let previousSearch: string | undefined
const [virtualizer, setVirtualizer] = createSignal<VirtualizerHandle>()
const [pointer, setPointer] = createSignal(true)
@@ -223,16 +226,9 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const hasProviders = () => visibleModels().length > 0
const canOpen = () => hasProviders() || ((props.allowClear ?? false) && !!props.value)
// Debounce search input to avoid re-filtering on every keystroke
createEffect(() => {
const q = search()
const t = setTimeout(() => setDebouncedSearch(q), 250)
onCleanup(() => clearTimeout(t))
})
// Flat filtered list for keyboard navigation
const filtered = createMemo(() => {
const q = debouncedSearch().trim()
const q = search().trim()
if (!q) {
return visibleModels()
}
@@ -251,7 +247,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const favoriteModels = createMemo(() => {
if (props.favorites === false) return []
if (!session || debouncedSearch()) return []
if (!session || search()) return []
const map = new Map(visibleModels().map((m) => [modelKey(m.providerID, m.id), m]))
const list = session
.favoriteModels()
@@ -400,7 +396,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
if (!m) return props.allowClear ? CLEAR_KEY : defaultKey()
const key = modelKey(m.providerID, m.id)
const favorite = favoriteKey(m)
if (!debouncedSearch() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite
if (!search() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite
return canonicalKey(m)
}
const chosen = (row: ModelRow) => {
@@ -453,7 +449,10 @@ 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(() => {
const query = search()
const list = filtered()
const searchChanged = query !== previousSearch
previousSearch = query
untrack(() => {
const active = activeModel()
const canon = active ? canonicalKey(active) : null
@@ -468,10 +467,16 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
? CLEAR_KEY
: defaultKey()
setSelectedKey(next)
setBrowsing(!!debouncedSearch() && nodeMap().has(next))
setBrowsing(!!search() && nodeMap().has(next))
setNavigating(false)
setPreActiveKey(next)
setPreviewKey(next)
if (!open() || !searchChanged) return
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
scrollRow(next, "nearest")
})
})
})
@@ -500,8 +505,9 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
setBrowsing(false)
setNavigating(false)
setSearch("")
setDebouncedSearch("")
clearTimeout(previewTimer)
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
scrollFrame = undefined
})
// Register before the popover mounts so programmatic slash-command opens
@@ -519,6 +525,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
window.removeEventListener("openModelPicker", onTrigger)
window.removeEventListener("keydown", onEscape, true)
clearTimeout(previewTimer)
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
})
function pick(model: EnrichedModel) {
@@ -552,6 +559,20 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
previewTimer = setTimeout(() => setPreviewKey(key), 200)
}
function pointerMove(e: MouseEvent) {
const target = e.target
if (!(target instanceof Element)) return
const item = target.closest<HTMLElement>('[role="treeitem"][data-key]')
const key = item?.dataset.key
if (!key) return
const moved = pointerX !== undefined && pointerY !== undefined && (e.clientX !== pointerX || e.clientY !== pointerY)
pointerX = e.clientX
pointerY = e.clientY
if (!moved) return
setPointer(true)
setSelectedKey(key)
}
function scrollRow(key: string | null | undefined, block: ScrollLogicalPosition = "nearest") {
if (!key) return
const idx = nodeIndex().get(key)
@@ -808,6 +829,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
placeholder={language.t("dialog.model.search.placeholder")}
value={search()}
onInput={(e) => {
setPointer(false)
setBrowsing(false)
setNavigating(false)
setSearch(e.currentTarget.value)
@@ -846,7 +868,14 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
</Tooltip>
</div>
<div id={listID} class="model-selector-list" role="tree" aria-label={label()} ref={listRef}>
<div
id={listID}
class="model-selector-list"
role="tree"
aria-label={label()}
ref={listRef}
onMouseMove={pointerMove}
>
<Show when={groups().length === 0}>
<div class="model-selector-empty" role="status" aria-live="polite">
{language.t("dialog.model.empty")}
@@ -871,16 +900,13 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
return (
<div
id={optionID(key)}
data-key={key}
class={`model-selector-group-label${props.allowClear || group.key !== groups()[0]?.key ? " model-selector-group-label--divided" : ""}${isSelected(key) ? " selected" : ""}${isSelected(key) && !pointer() ? " keyboard-focused" : ""}`}
role="treeitem"
aria-level={1}
aria-expanded={shown()}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggleGroup(group.key)}
onMouseMove={() => setPointer(true)}
onMouseEnter={() => {
if (pointer()) setSelectedKey(key)
}}
>
<svg
class={`model-selector-group-chevron${shown() ? "" : " model-selector-group-chevron--collapsed"}`}
@@ -893,7 +919,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
<path d="M4 6l4 5 4-5H4z" />
</svg>
<span>{group.label}</span>
<Show when={!shown() && !!debouncedSearch()}>
<Show when={!shown() && !!search()}>
<span class="model-selector-group-match-dot" aria-hidden="true" />
</Show>
</div>
@@ -906,15 +932,12 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
return (
<div
id={optionID(CLEAR_KEY)}
data-key={CLEAR_KEY}
class={`model-selector-item${isSelected(CLEAR_KEY) && !pointer() ? " keyboard-focused" : ""}${isSelected(CLEAR_KEY) ? " selected" : ""}${!props.value?.providerID ? " active" : ""}`}
role="treeitem"
aria-level={1}
aria-selected={!props.value?.providerID}
onClick={() => pickClear()}
onMouseMove={() => setPointer(true)}
onMouseEnter={() => {
if (pointer()) setSelectedKey(CLEAR_KEY)
}}
>
<span class="model-selector-item-name" style={{ "font-style": "italic", opacity: 0.7 }}>
{props.clearLabel ?? language.t("dialog.model.notSet")}
@@ -939,6 +962,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
>
<div
id={optionID(row.key)}
data-key={row.key}
class={`model-selector-item${(hovered() && !pointer()) || preActive() ? " keyboard-focused" : ""}${hovered() || preActive() ? " selected" : ""}${chosen(row) ? " active" : ""}`}
role="treeitem"
aria-level={2}
@@ -955,10 +979,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
onDblClick={() => {
if (expanded()) selectRow(row)
}}
onMouseMove={() => setPointer(true)}
onMouseEnter={() => {
if (pointer()) setSelectedKey(row.key)
}}
>
<div class="model-selector-item-left">
<span class="model-selector-item-name">