diff --git a/.changeset/quick-model-search.md b/.changeset/quick-model-search.md new file mode 100644 index 00000000000..5b01ee44f97 --- /dev/null +++ b/.changeset/quick-model-search.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Update model search results instantly and keep the active match visible while typing. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png index 85a8ddb7b65..22ffe596c1a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a7d7d047f14440d3dafc8a202131af6e755f80875012fd934a56580de21d36d5 -size 52887 +oid sha256:c3863da7e15cc40467319046521fbbfed0ee6ceab9f1f3ad03adb8941f454666 +size 52895 diff --git a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts index 45a7a339fdc..6fda7cdbb95 100644 --- a/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/model-selector-accessibility.spec.ts @@ -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") diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index b6e83e2836d..2b3b6cac507 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -159,7 +159,6 @@ export const ModelSelectorBase: Component = (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 = (props) => { let listRef: HTMLDivElement | undefined let bodyRef: HTMLDivElement | undefined let previewTimer: ReturnType | undefined + let scrollFrame: number | undefined + let pointerX: number | undefined + let pointerY: number | undefined + let previousSearch: string | undefined const [virtualizer, setVirtualizer] = createSignal() const [pointer, setPointer] = createSignal(true) @@ -223,16 +226,9 @@ export const ModelSelectorBase: Component = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (props) => { previewTimer = setTimeout(() => setPreviewKey(key), 200) } + function pointerMove(e: MouseEvent) { + const target = e.target + if (!(target instanceof Element)) return + const item = target.closest('[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 = (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 = (props) => { -
+
{language.t("dialog.model.empty")} @@ -871,16 +900,13 @@ export const ModelSelectorBase: Component = (props) => { return (
e.preventDefault()} onClick={() => toggleGroup(group.key)} - onMouseMove={() => setPointer(true)} - onMouseEnter={() => { - if (pointer()) setSelectedKey(key) - }} > = (props) => { {group.label} - +
@@ -906,15 +932,12 @@ export const ModelSelectorBase: Component = (props) => { return (
pickClear()} - onMouseMove={() => setPointer(true)} - onMouseEnter={() => { - if (pointer()) setSelectedKey(CLEAR_KEY) - }} > {props.clearLabel ?? language.t("dialog.model.notSet")} @@ -939,6 +962,7 @@ export const ModelSelectorBase: Component = (props) => { >
= (props) => { onDblClick={() => { if (expanded()) selectRow(row) }} - onMouseMove={() => setPointer(true)} - onMouseEnter={() => { - if (pointer()) setSelectedKey(row.key) - }} >