diff --git a/.changeset/smart-file-mentions.md b/.changeset/smart-file-mentions.md new file mode 100644 index 0000000000..43e78be726 --- /dev/null +++ b/.changeset/smart-file-mentions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Improve @ file search relevance and keep mention suggestions responsive while typing. diff --git a/bun.lock b/bun.lock index 2ff6998e10..81a0d2bc56 100644 --- a/bun.lock +++ b/bun.lock @@ -248,6 +248,7 @@ "dotenv": "^16.4.7", "fastest-levenshtein": "^1.0.16", "friendly-words": "1.3.1", + "fuzzysort": "3.1.0", "ignore": "^7.0.3", "js-tiktoken": "^1.0.18", "lru-cache": "^11.0.2", diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 48aad4bcc7..83ee754cad 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -927,6 +927,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "fuzzysort": "3.1.0", "@kilocode/kilo-gateway": "workspace:*", "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 8aa963f215..caa1f565e1 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -607,30 +607,3 @@ export function isEventFromForeignProject(event: Event, expectedProjectID: strin } return false } - -/** - * Merge open-tab paths with backend file search results for the @ mention dropdown. - * - * Ordering: active file → other open tabs → backend results (all deduplicated). - * When a query is present, open tabs are filtered to only include matches. - * The `active` path (if provided) is placed first when it exists in `open`. - */ -export function mergeFileSearchResults(input: { - query: string - backend: string[] - open: Set - active?: string -}): string[] { - const norm = (p: string) => p.replaceAll("\\", "/") - const query = norm(input.query).trim().toLowerCase() - const open = new Set([...input.open].map(norm)) - const active = input.active ? norm(input.active) : undefined - const backend = input.backend.map(norm) - const ok = (p: string) => !query || p.toLowerCase().includes(query) - const tabs = - active && open.has(active) && ok(active) - ? [active, ...[...open].filter((p) => p !== active && ok(p))] - : [...open].filter(ok) - const seen = new Set(tabs) - return [...tabs, ...backend.filter((p) => !seen.has(p))] -} diff --git a/packages/kilo-vscode/src/kilo-provider/file-search-items.ts b/packages/kilo-vscode/src/kilo-provider/file-search-items.ts index 83bb074b64..5b4876f1ad 100644 --- a/packages/kilo-vscode/src/kilo-provider/file-search-items.ts +++ b/packages/kilo-vscode/src/kilo-provider/file-search-items.ts @@ -1,4 +1,4 @@ -export type FileSearchItem = { path: string; type: "file" | "folder" } +export type FileSearchItem = { path: string; type: "file" | "folder" | "opened-file" } const normalize = (p: string) => p.replaceAll("\\", "/") const trim = (p: string) => normalize(p).replace(/\/+$/, "") @@ -18,9 +18,20 @@ function rank(query: string, p: string): number { return 4 } -export function mergeFileSearchItems(input: { query: string; files: string[]; folders: string[] }): FileSearchItem[] { +export function mergeFileSearchItems(input: { + query: string + files: string[] + folders: string[] + open?: Set +}): FileSearchItem[] { const query = normalize(input.query).trim().toLowerCase() - const files = input.files.map((p) => ({ path: normalize(p), type: "file" as const })) + const open = new Set([...(input.open ?? [])].map(normalize)) + const files = input.files.map((p) => { + const path = normalize(p) + return { path, type: open.has(path) ? ("opened-file" as const) : ("file" as const) } + }) + const pinned = files.filter((item) => item.type === "opened-file") + const rest = files.filter((item) => !open.has(item.path)) // Dedup folders against themselves; a file and a folder that share a stem are distinct entries. const seen = new Set() const folders = input.folders @@ -40,6 +51,6 @@ export function mergeFileSearchItems(input: { query: string; files: string[]; fo const sorted = [...folders].sort((a, b) => a.rank - b.rank || a.index - b.index) const boosted = sorted.filter((x) => x.rank <= 1).map((x) => x.item) - const rest = sorted.filter((x) => x.rank > 1).map((x) => x.item) - return [...boosted, ...files, ...rest] + const other = sorted.filter((x) => x.rank > 1).map((x) => x.item) + return [...pinned, ...boosted, ...rest, ...other] } diff --git a/packages/kilo-vscode/src/kilo-provider/file-search-results.ts b/packages/kilo-vscode/src/kilo-provider/file-search-results.ts new file mode 100644 index 0000000000..390fe8a7ea --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/file-search-results.ts @@ -0,0 +1,78 @@ +import fuzzysort from "fuzzysort" + +function base(p: string): string { + const clean = p.replace(/\/+$/, "") + return clean.split("/").pop() ?? clean +} + +function depth(p: string): number { + return p.split("/").length - 1 +} + +function score(query: string, p: string) { + const name = base(p) + const label = fuzzysort.single(query, name) + const path = fuzzysort.single(query, p) + return { + p, + label, + path, + depth: depth(p), + } +} + +function compare(a: ReturnType, b: ReturnType): number { + const alabel = a.label !== null + const blabel = b.label !== null + if (alabel !== blabel) return alabel ? -1 : 1 + + const ascore = a.label?.score ?? a.path?.score ?? 0 + const bscore = b.label?.score ?? b.path?.score ?? 0 + if (ascore !== bscore) return bscore - ascore + + const aname = base(a.p) + const bname = base(b.p) + if (aname.length !== bname.length) return aname.length - bname.length + if (a.depth !== b.depth) return a.depth - b.depth + if (a.p.length !== b.p.length) return a.p.length - b.p.length + return a.p.localeCompare(b.p) +} + +function rankOpen(query: string, paths: string[]): string[] { + if (!query || !paths.length) return paths + const scored: Array> = [] + for (const p of paths) { + const result = score(query, p) + if (result.path) scored.push(result) + } + return scored.sort(compare).map((x) => x.p) +} + +function rankBackend(query: string, paths: string[]): string[] { + if (!query || paths.length <= 1) return paths + return paths + .map((p) => score(query, p)) + .sort(compare) + .map((x) => x.p) +} + +export function mergeFileSearchResults(input: { + query: string + backend: string[] + open: Set + active?: string +}): string[] { + const norm = (p: string) => p.replaceAll("\\", "/") + const query = norm(input.query).trim().toLowerCase() + const open = new Set([...input.open].map(norm)) + const active = input.active ? norm(input.active) : undefined + const backend = input.backend.map(norm) + const matched = rankOpen(query, [...open]) + const tabs = (() => { + if (!active || !matched.includes(active)) return matched + return [active, ...matched.filter((p) => p !== active)] + })() + const seen = new Set(tabs) + const remaining = backend.filter((p) => !seen.has(p)) + return [...tabs, ...rankBackend(query, remaining)] +} diff --git a/packages/kilo-vscode/src/kilo-provider/file-search.ts b/packages/kilo-vscode/src/kilo-provider/file-search.ts index dbac4cba7f..712ef68ff4 100644 --- a/packages/kilo-vscode/src/kilo-provider/file-search.ts +++ b/packages/kilo-vscode/src/kilo-provider/file-search.ts @@ -1,7 +1,7 @@ import * as path from "path" import * as vscode from "vscode" import type { KiloClient } from "@kilocode/sdk/v2/client" -import { mergeFileSearchResults } from "../kilo-provider-utils" +import { mergeFileSearchResults } from "./file-search-results" import { mergeFileSearchItems } from "./file-search-items" type Message = { @@ -42,7 +42,12 @@ export async function handleFileSearch(input: Input): Promise { const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined const result = mergeFileSearchResults({ query, backend: files, open, active }) - const items = mergeFileSearchItems({ query, files: result, folders }) + const items = mergeFileSearchItems({ + query, + files: result, + folders, + open: new Set(active ? [active, ...open] : open), + }) input.post({ type: "fileSearchResult", paths: result, items, dir, requestId: input.message.requestId }) }) } diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index a35bb14822..2846dd030f 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -5,6 +5,7 @@ import { buildTextAfterMentionSelect, buildFileAttachments, buildMentionResults, + filterMentionResults, } from "../../webview-ui/src/hooks/file-mention-utils" describe("AT_PATTERN", () => { @@ -71,6 +72,21 @@ describe("buildMentionResults", () => { const result = buildMentionResults("src", [{ path: "src", type: "folder" }]) expect(result).toEqual([{ type: "folder", value: "src" }]) }) + + it("preserves opened file result type", () => { + const result = buildMentionResults("src", [{ path: "src/index.ts", type: "opened-file" }]) + expect(result).toEqual([{ type: "opened-file", value: "src/index.ts" }]) + }) +}) + +describe("filterMentionResults", () => { + it("keeps matching file results for the latest query", () => { + const result = filterMentionResults("gi", [ + { type: "file", value: "README.md" }, + { type: "file", value: "src/git.ts" }, + ]) + expect(result).toEqual([{ type: "file", value: "src/git.ts" }]) + }) }) describe("syncMentionedPaths", () => { diff --git a/packages/kilo-vscode/tests/unit/file-search-items.test.ts b/packages/kilo-vscode/tests/unit/file-search-items.test.ts new file mode 100644 index 0000000000..9b2f9af929 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/file-search-items.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "bun:test" +import { mergeFileSearchItems } from "../../src/kilo-provider/file-search-items" + +describe("mergeFileSearchItems", () => { + it("puts exact folder matches before file matches", () => { + const result = mergeFileSearchItems({ + query: "script", + files: ["script/hooks", "script/release", "script/beta.ts"], + folders: ["script/", "script/run-script/"], + }) + expect(result).toEqual([ + { path: "script/", type: "folder" }, + { path: "script/hooks", type: "file" }, + { path: "script/release", type: "file" }, + { path: "script/beta.ts", type: "file" }, + { path: "script/run-script/", type: "folder" }, + ]) + }) + + it("keeps file ordering before non-prefix folder matches", () => { + const result = mergeFileSearchItems({ + query: "test", + files: ["src/test.ts"], + folders: ["src/latest/"], + }) + expect(result).toEqual([ + { path: "src/test.ts", type: "file" }, + { path: "src/latest/", type: "folder" }, + ]) + }) + + it("normalizes Windows separators for matching and output", () => { + const result = mergeFileSearchItems({ + query: "kilo-vscode", + files: ["packages\\kilo-vscode\\src\\KiloProvider.ts"], + folders: ["packages\\kilo-vscode\\"], + }) + expect(result).toEqual([ + { path: "packages/kilo-vscode/", type: "folder" }, + { path: "packages/kilo-vscode/src/KiloProvider.ts", type: "file" }, + ]) + }) + + it("keeps active and open file results before prefix folder matches", () => { + const result = mergeFileSearchItems({ + query: "e", + files: ["sdks/vscode/src/extension.ts"], + folders: ["packages/extensions/", "packages/example/", "packages/core/src/effect/"], + open: new Set(["sdks/vscode/src/extension.ts"]), + }) + expect(result).toEqual([ + { path: "sdks/vscode/src/extension.ts", type: "opened-file" }, + { path: "packages/extensions/", type: "folder" }, + { path: "packages/example/", type: "folder" }, + { path: "packages/core/src/effect/", type: "folder" }, + ]) + }) + + it("keeps opened files as a distinct priority group before non-open files", () => { + const result = mergeFileSearchItems({ + query: "test", + files: ["src/test.ts", "src/test-helper.ts"], + folders: ["test/"], + open: new Set(["src/test-helper.ts"]), + }) + expect(result).toEqual([ + { path: "src/test-helper.ts", type: "opened-file" }, + { path: "test/", type: "folder" }, + { path: "src/test.ts", type: "file" }, + ]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/file-search-results.test.ts b/packages/kilo-vscode/tests/unit/file-search-results.test.ts new file mode 100644 index 0000000000..b1505d65ff --- /dev/null +++ b/packages/kilo-vscode/tests/unit/file-search-results.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "bun:test" +import { mergeFileSearchResults } from "../../src/kilo-provider/file-search-results" + +describe("mergeFileSearchResults", () => { + it("returns backend results when no open files", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts", "src/b.ts"], + open: new Set(), + }) + expect(result).toEqual(["src/a.ts", "src/b.ts"]) + }) + + it("places open files before backend results", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts", "src/b.ts", "src/c.ts"], + open: new Set(["src/c.ts", "src/d.ts"]), + }) + expect(result).toEqual(["src/c.ts", "src/d.ts", "src/a.ts", "src/b.ts"]) + }) + + it("places active file first among open files", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts"], + open: new Set(["src/b.ts", "src/c.ts"]), + active: "src/c.ts", + }) + expect(result).toEqual(["src/c.ts", "src/b.ts", "src/a.ts"]) + }) + + it("ignores active file when it is not in open set", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts"], + open: new Set(["src/b.ts"]), + active: "src/x.ts", + }) + expect(result).toEqual(["src/b.ts", "src/a.ts"]) + }) + + it("deduplicates open files from backend results", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts", "src/b.ts"], + open: new Set(["src/a.ts"]), + }) + expect(result).toEqual(["src/a.ts", "src/b.ts"]) + }) + + it("filters open files by query", () => { + const result = mergeFileSearchResults({ + query: "config", + backend: ["src/config.ts", "src/util.ts"], + open: new Set(["src/index.ts", "src/config.ts", "README.md"]), + }) + expect(result).toEqual(["src/config.ts", "src/util.ts"]) + }) + + it("query filtering is case-insensitive", () => { + const result = mergeFileSearchResults({ + query: "READ", + backend: [], + open: new Set(["README.md", "src/index.ts"]), + }) + expect(result).toEqual(["README.md"]) + }) + + it("shows all open files on empty query", () => { + const result = mergeFileSearchResults({ + query: "", + backend: [], + open: new Set(["src/a.ts", "src/b.ts"]), + }) + expect(result).toEqual(["src/a.ts", "src/b.ts"]) + }) + + it("shows all open files on whitespace-only query", () => { + const result = mergeFileSearchResults({ + query: " ", + backend: ["src/x.ts"], + open: new Set(["src/a.ts"]), + }) + expect(result).toEqual(["src/a.ts", "src/x.ts"]) + }) + + it("handles forward-slash paths (Windows-normalized)", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/utils/path.ts"], + open: new Set(["src/utils/path.ts", "src/index.ts"]), + active: "src/utils/path.ts", + }) + expect(result).toEqual(["src/utils/path.ts", "src/index.ts"]) + }) + + it("normalizes backslash paths before filtering and deduping", () => { + const result = mergeFileSearchResults({ + query: "utils/path", + backend: ["src\\utils\\path.ts"], + open: new Set(["src/utils/path.ts"]), + active: "src\\utils\\path.ts", + }) + expect(result).toEqual(["src/utils/path.ts"]) + }) + + it("includes open tabs that fuzzy-match but are not substring matches", () => { + const result = mergeFileSearchResults({ + query: "authn", + backend: [], + open: new Set(["authentication.ts", "unrelated.ts"]), + }) + expect(result).toContain("authentication.ts") + expect(result).not.toContain("unrelated.ts") + }) + + it("ranks open tabs by fuzzy match quality, not insertion order", () => { + const result = mergeFileSearchResults({ + query: "auth", + backend: [], + open: new Set(["long-authentication-module.ts", "auth.ts"]), + }) + expect(result[0]).toBe("auth.ts") + }) + + it("ranks open tabs by filename before directory matches", () => { + const result = mergeFileSearchResults({ + query: "provider", + backend: [], + open: new Set(["src/provider/auth.ts", "src/provider.ts"]), + }) + expect(result).toEqual(["src/provider.ts", "src/provider/auth.ts"]) + }) + + it("boosts backend results where query matches the basename over full-path matches", () => { + const result = mergeFileSearchResults({ + query: "auth", + backend: ["src/authentication-module.ts", "packages/a/b/c/d/auth.ts"], + open: new Set(), + }) + expect(result.indexOf("packages/a/b/c/d/auth.ts")).toBeLessThan(result.indexOf("src/authentication-module.ts")) + }) + + it("uses path depth as tiebreaker when basename scores are equal", () => { + const result = mergeFileSearchResults({ + query: "auth", + backend: ["a/b/c/d/e/auth-service.ts", "src/auth-service.ts"], + open: new Set(), + }) + expect(result.indexOf("src/auth-service.ts")).toBeLessThan(result.indexOf("a/b/c/d/e/auth-service.ts")) + }) + + it("uses filename length as tiebreaker before path depth", () => { + const result = mergeFileSearchResults({ + query: "auth", + backend: ["src/authentication-module.ts", "a/b/c/auth.ts"], + open: new Set(), + }) + expect(result[0]).toBe("a/b/c/auth.ts") + }) + + it("preserves camel-case scoring for acronym-style queries", () => { + const result = mergeFileSearchResults({ + query: "amprov", + backend: [ + "packages/kilo-docs/pages/contributing/architecture/onboarding-improvements.md", + "packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts", + ], + open: new Set(), + }) + expect(result[0]).toBe("packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 06641023b9..d54327ad3e 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -8,12 +8,10 @@ import { isEventFromForeignProject, mapCloudSessionMessageToWebviewMessage, MessageConfirmation, - mergeFileSearchResults, getErrorMessage, getConfigErrorDetails, type ProviderInfo, } from "../../src/kilo-provider-utils" -import { mergeFileSearchItems } from "../../src/kilo-provider/file-search-items" import type { CloudSessionMessage } from "../../src/services/cli-backend/types" import type { Session, @@ -607,152 +605,6 @@ describe("mapCloudSessionMessage", () => { }) }) -describe("mergeFileSearchItems", () => { - it("puts exact folder matches before file matches", () => { - const result = mergeFileSearchItems({ - query: "script", - files: ["script/hooks", "script/release", "script/beta.ts"], - folders: ["script/", "script/run-script/"], - }) - expect(result).toEqual([ - { path: "script/", type: "folder" }, - { path: "script/hooks", type: "file" }, - { path: "script/release", type: "file" }, - { path: "script/beta.ts", type: "file" }, - { path: "script/run-script/", type: "folder" }, - ]) - }) - - it("keeps file ordering before non-prefix folder matches", () => { - const result = mergeFileSearchItems({ - query: "test", - files: ["src/test.ts"], - folders: ["src/latest/"], - }) - expect(result).toEqual([ - { path: "src/test.ts", type: "file" }, - { path: "src/latest/", type: "folder" }, - ]) - }) - - it("normalizes Windows separators for matching and output", () => { - const result = mergeFileSearchItems({ - query: "kilo-vscode", - files: ["packages\\kilo-vscode\\src\\KiloProvider.ts"], - folders: ["packages\\kilo-vscode\\"], - }) - expect(result).toEqual([ - { path: "packages/kilo-vscode/", type: "folder" }, - { path: "packages/kilo-vscode/src/KiloProvider.ts", type: "file" }, - ]) - }) -}) - -describe("mergeFileSearchResults", () => { - it("returns backend results when no open files", () => { - const result = mergeFileSearchResults({ - query: "", - backend: ["src/a.ts", "src/b.ts"], - open: new Set(), - }) - expect(result).toEqual(["src/a.ts", "src/b.ts"]) - }) - - it("places open files before backend results", () => { - const result = mergeFileSearchResults({ - query: "", - backend: ["src/a.ts", "src/b.ts", "src/c.ts"], - open: new Set(["src/c.ts", "src/d.ts"]), - }) - expect(result).toEqual(["src/c.ts", "src/d.ts", "src/a.ts", "src/b.ts"]) - }) - - it("places active file first among open files", () => { - const result = mergeFileSearchResults({ - query: "", - backend: ["src/a.ts"], - open: new Set(["src/b.ts", "src/c.ts"]), - active: "src/c.ts", - }) - expect(result).toEqual(["src/c.ts", "src/b.ts", "src/a.ts"]) - }) - - it("ignores active file when it is not in open set", () => { - const result = mergeFileSearchResults({ - query: "", - backend: ["src/a.ts"], - open: new Set(["src/b.ts"]), - active: "src/x.ts", - }) - expect(result).toEqual(["src/b.ts", "src/a.ts"]) - }) - - it("deduplicates open files from backend results", () => { - const result = mergeFileSearchResults({ - query: "", - backend: ["src/a.ts", "src/b.ts"], - open: new Set(["src/a.ts"]), - }) - expect(result).toEqual(["src/a.ts", "src/b.ts"]) - }) - - it("filters open files by query", () => { - const result = mergeFileSearchResults({ - query: "config", - backend: ["src/config.ts", "src/util.ts"], - open: new Set(["src/index.ts", "src/config.ts", "README.md"]), - }) - expect(result).toEqual(["src/config.ts", "src/util.ts"]) - }) - - it("query filtering is case-insensitive", () => { - const result = mergeFileSearchResults({ - query: "READ", - backend: [], - open: new Set(["README.md", "src/index.ts"]), - }) - expect(result).toEqual(["README.md"]) - }) - - it("shows all open files on empty query", () => { - const result = mergeFileSearchResults({ - query: "", - backend: [], - open: new Set(["src/a.ts", "src/b.ts"]), - }) - expect(result).toEqual(["src/a.ts", "src/b.ts"]) - }) - - it("shows all open files on whitespace-only query", () => { - const result = mergeFileSearchResults({ - query: " ", - backend: ["src/x.ts"], - open: new Set(["src/a.ts"]), - }) - expect(result).toEqual(["src/a.ts", "src/x.ts"]) - }) - - it("handles forward-slash paths (Windows-normalized)", () => { - const result = mergeFileSearchResults({ - query: "", - backend: ["src/utils/path.ts"], - open: new Set(["src/utils/path.ts", "src/index.ts"]), - active: "src/utils/path.ts", - }) - expect(result).toEqual(["src/utils/path.ts", "src/index.ts"]) - }) - - it("normalizes backslash paths before filtering and deduping", () => { - const result = mergeFileSearchResults({ - query: "utils/path", - backend: ["src\\utils\\path.ts"], - open: new Set(["src/utils/path.ts"]), - active: "src\\utils\\path.ts", - }) - expect(result).toEqual(["src/utils/path.ts"]) - }) -}) - describe("getErrorMessage", () => { it("extracts message from an Error instance", () => { expect(getErrorMessage(new Error("boom"))).toBe("boom") diff --git a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts new file mode 100644 index 0000000000..af846de7b7 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "bun:test" +import { createRoot } from "solid-js" +import { useFileMention } from "../../webview-ui/src/hooks/useFileMention" +import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages" + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe("useFileMention", () => { + it("keeps previous file results visible while the next search is pending", async () => { + const posted: WebviewMessage[] = [] + const handlers = new Set<(message: ExtensionMessage) => void>() + const ctx = { + postMessage: (message: WebviewMessage) => posted.push(message), + onMessage: (handler: (message: ExtensionMessage) => void) => { + handlers.add(handler) + return () => handlers.delete(handler) + }, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + mention.onInput("@e", 2) + await wait(170) + + const first = posted.at(-1) + expect(first?.type).toBe("requestFileSearch") + expect(first).toMatchObject({ query: "e", requestId: "file-search-1" }) + + for (const handler of handlers) { + handler({ + type: "fileSearchResult", + requestId: "file-search-1", + dir: "/repo", + paths: ["sdks/vscode/src/extension.ts"], + items: [{ path: "sdks/vscode/src/extension.ts", type: "opened-file" }], + }) + } + + expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "sdks/vscode/src/extension.ts" }]) + + mention.onInput("@ex", 3) + + expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "sdks/vscode/src/extension.ts" }]) + + dispose.fn?.() + }) + + it("does not keep stale file results visible for unrelated queries", async () => { + const posted: WebviewMessage[] = [] + const handlers = new Set<(message: ExtensionMessage) => void>() + const ctx = { + postMessage: (message: WebviewMessage) => posted.push(message), + onMessage: (handler: (message: ExtensionMessage) => void) => { + handlers.add(handler) + return () => handlers.delete(handler) + }, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + mention.onInput("@read", 5) + await wait(170) + + for (const handler of handlers) { + handler({ + type: "fileSearchResult", + requestId: "file-search-1", + dir: "/repo", + paths: ["README.md"], + items: [{ path: "README.md", type: "file" }], + }) + } + + mention.onInput("@zz", 3) + + expect(mention.mentionResults()).toEqual([]) + + dispose.fn?.() + }) + + it("filters visible results synchronously while a new search is pending", async () => { + const posted: WebviewMessage[] = [] + const handlers = new Set<(message: ExtensionMessage) => void>() + const ctx = { + postMessage: (message: WebviewMessage) => posted.push(message), + onMessage: (handler: (message: ExtensionMessage) => void) => { + handlers.add(handler) + return () => handlers.delete(handler) + }, + } + + const dispose: { fn?: () => void } = {} + const mention = createRoot((root) => { + dispose.fn = root + return useFileMention(ctx, undefined, () => false) + }) + + mention.onInput("@g", 2) + await wait(170) + + for (const handler of handlers) { + handler({ + type: "fileSearchResult", + requestId: "file-search-1", + dir: "/repo", + paths: ["README.md", "src/git.ts"], + items: [ + { path: "README.md", type: "file" }, + { path: "src/git.ts", type: "file" }, + ], + }) + } + + mention.onInput("@gi", 3) + + expect(mention.mentionResults()).toEqual([{ type: "file", value: "src/git.ts" }]) + + dispose.fn?.() + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts index 9617fcac5e..43d704e55a 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts @@ -8,6 +8,7 @@ export type MentionResult = | { type: "terminal"; value: typeof TERMINAL_MENTION; label: string; description: string } | { type: "git-changes"; value: typeof GIT_CHANGES_MENTION; label: string; description: string } | { type: "file"; value: string } + | { type: "opened-file"; value: string } | { type: "folder"; value: string } export const TERMINAL_RESULT: MentionResult = { @@ -47,11 +48,22 @@ export function buildMentionResults(query: string, items: Array { if (typeof item === "string") return { type: "file", value: item } if (item.type === "folder") return { type: "folder", value: item.path } + if (item.type === "opened-file") return { type: "opened-file", value: item.path } return { type: "file", value: item.path } }) return [...getTerminalMentionResult(query), ...(git ? getGitChangesMentionResult(query) : []), ...results] } +export function filterMentionResults(query: string, items: MentionResult[]): MentionResult[] { + const value = query.toLowerCase() + if (!value) return items + return items.filter((item) => { + if (item.type === "terminal") return TERMINAL_MENTION.startsWith(value) + if (item.type === "git-changes") return GIT_CHANGES_MENTION.startsWith(value) || "git".startsWith(value) + return item.value.toLowerCase().includes(value) + }) +} + /** * Sync the set of mentioned paths against the current text. * Removes any paths that are no longer present in the text as @path mentions. diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts index 0060e0b63c..479f7a158b 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts @@ -7,6 +7,7 @@ import { buildTextAfterMentionSelect, buildFileAttachments, buildMentionResults, + filterMentionResults, type MentionResult, } from "./file-mention-utils" @@ -120,7 +121,7 @@ export function useFileMention( textarea.setSelectionRange(pos, pos) textarea.focus() - if (result.type === "file" || result.type === "folder") + if (result.type === "file" || result.type === "folder" || result.type === "opened-file") setMentionedPaths((prev) => new Set([...prev, result.value])) closeMention() onSelect?.() @@ -133,7 +134,12 @@ export function useFileMention( if (match) { const query = match[1] ?? "" setMentionQuery(query) - setMentionResults(buildMentionResults(query, [], git?.() ?? true)) + setMentionResults((prev) => { + const next = filterMentionResults(query, prev) + if (next.length) return next + return buildMentionResults(query, [], git?.() ?? true) + }) + setMentionIndex(0) requestFileSearch(query) } else { closeMention() diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 611573b2fa..9f253bf037 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -334,7 +334,7 @@ export interface ChatCompletionResultMessage { export interface FileSearchItem { path: string - type: "file" | "folder" + type: "file" | "folder" | "opened-file" } export interface FileSearchResultMessage {