fix(vscode): improve file mention search ranking (#10053)

* fix(vscode): improve file mention search ranking

* fix(vscode): avoid stale file mention selections
This commit is contained in:
Marius
2026-05-08 12:06:05 +02:00
committed by GitHub
parent 0cbf9b040b
commit 09a7be8fcd
15 changed files with 519 additions and 185 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Improve @ file search relevance and keep mention suggestions responsive while typing.
+1
View File
@@ -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",
+1
View File
@@ -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:*",
@@ -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<string>
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))]
}
@@ -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<string>
}): 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<string>()
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]
}
@@ -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<typeof score>, b: ReturnType<typeof score>): 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<ReturnType<typeof score>> = []
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<string>
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)]
}
@@ -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<void> {
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 })
})
}
@@ -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", () => {
@@ -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" },
])
})
})
@@ -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")
})
})
@@ -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")
@@ -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?.()
})
})
@@ -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<FileSearchItem |
const results: MentionResult[] = items.map((item) => {
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.
@@ -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()
@@ -334,7 +334,7 @@ export interface ChatCompletionResultMessage {
export interface FileSearchItem {
path: string
type: "file" | "folder"
type: "file" | "folder" | "opened-file"
}
export interface FileSearchResultMessage {