diff --git a/.changeset/multi-root-file-mentions.md b/.changeset/multi-root-file-mentions.md new file mode 100644 index 00000000000..e10f791bbe0 --- /dev/null +++ b/.changeset/multi-root-file-mentions.md @@ -0,0 +1,6 @@ +--- +"kilo-code": minor +"@kilocode/cli": patch +--- + +Suggest files from every folder in a multi-root VS Code workspace when typing `@`, so folders added through "Add Folder to Workspace..." are mentionable without the file picker. Files outside the session's own project are still read only after the usual approval, and `semantic_search` now reports which root it covers and whether its index was complete, so an empty result is no longer mistaken for missing code. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 1458891d6e4..6cf426b43d8 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -63,7 +63,7 @@ import { slimInfo, slimPart, slimParts } from "./kilo-provider/slim-metadata" import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree" import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files" import { renameSession } from "./kilo-provider/rename-session" -import { handleFileSearch } from "./kilo-provider/file-search" +import { handleFileSearch, type SearchRoot } from "./kilo-provider/file-search" import { handleSessionSearch } from "./kilo-provider/session-search" import { handleFilePicker } from "./kilo-provider/file-picker" import { watchFontSizeConfig } from "./kilo-provider/font-size" @@ -464,8 +464,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private autoApproveBridge: ReturnType | null = null private readonly marketplaceRemove = createMarketplaceRemover() - private ignoreController: FileIgnoreController | null = null - private ignoreControllerDir: string | null = null + /** Workspace folders plus any session directories recently asked about. */ + private static readonly IGNORE_CONTROLLER_LIMIT = 16 + private readonly ignoreControllers = new Map>() private chatAutocomplete: ChatTextAreaAutocomplete | null = null private projectDirectory: string | null | undefined private settingsGeneration = 0 @@ -2337,6 +2338,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper context: this.contextSessionID, dir: (id) => this.getWorkspaceDirectory(id), open: (dir) => this.getOpenTabPaths(dir), + roots: () => this.getWorkspaceRoots(), post: (msg) => this.postMessage(msg), }) return @@ -5132,18 +5134,55 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } /** - * Get or create a FileIgnoreController for the current workspace directory. - * Reinitializes if the workspace directory has changed. + * Every folder in the editor workspace, as candidate file-mention sources. + * + * File search fans out across these so files in folders added via "Add Folder + * to Workspace..." are mentionable. Fan-out is declined downstream unless the + * session's own directory is one of them. + */ + private getWorkspaceRoots(): SearchRoot[] { + return (vscode.workspace.workspaceFolders ?? []).map((folder) => ({ + path: folder.uri.fsPath, + name: folder.name, + })) + } + + /** + * Get or create a FileIgnoreController for a workspace directory. + * + * Keyed by directory rather than holding a single controller: multi-root file + * search asks about several roots per keystroke, and a one-entry cache would + * re-read .kilocodeignore from disk on every alternating lookup. Bounded by + * insertion order because session directories, not just workspace folders, + * reach this cache. + * + * A failed init is evicted rather than cached. `initialize()` lets permission + * errors from reading .kilocodeignore propagate, and caching that rejection + * would keep failing every later lookup for the same directory. */ private async getIgnoreController(workspaceDir: string): Promise { - if (this.ignoreController && this.ignoreControllerDir === workspaceDir) { - return this.ignoreController + const cached = this.ignoreControllers.get(workspaceDir) + if (cached) return cached + const pending = (async () => { + const controller = new FileIgnoreController(workspaceDir) + await controller.initialize() + return controller + })() + void pending.catch(() => { + if (this.ignoreControllers.get(workspaceDir) === pending) this.ignoreControllers.delete(workspaceDir) + }) + this.ignoreControllers.set(workspaceDir, pending) + while (this.ignoreControllers.size > KiloProvider.IGNORE_CONTROLLER_LIMIT) { + const oldest = this.ignoreControllers.keys().next().value + if (oldest === undefined || oldest === workspaceDir) break + const evicted = this.ignoreControllers.get(oldest) + this.ignoreControllers.delete(oldest) + void evicted?.then( + (controller) => controller.dispose(), + (err) => console.warn("[Kilo New] Failed to dispose ignore controller:", err), + ) } - const controller = new FileIgnoreController(workspaceDir) - await controller.initialize() - this.ignoreController = controller - this.ignoreControllerDir = workspaceDir - return controller + return pending } private async gatherEditorContext(dir?: string): Promise { @@ -5569,7 +5608,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.requests.clear() this.epochs.clear() this.sessionStatusMap.clear() - this.ignoreController?.dispose() + for (const pending of this.ignoreControllers.values()) { + void pending.then( + (controller) => controller.dispose(), + (err) => console.warn("[Kilo New] Failed to dispose ignore controller:", err), + ) + } + this.ignoreControllers.clear() this.chatAutocomplete?.dispose() disposeGitChangesTarget() } 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 5b4876f1ad3..81e8d1029e1 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,15 @@ -export type FileSearchItem = { path: string; type: "file" | "folder" | "opened-file" } +export type FileSearchItem = { + path: string + type: "file" | "folder" | "opened-file" + /** Owning workspace folder name, set only when the workspace has more than one folder. */ + root?: string + /** + * Path within the owning folder, set only when `path` is absolute. The webview + * ranks the whole `@` menu again, and scoring an absolute path there would let + * the filesystem prefix match on every entry under that folder. + */ + relative?: string +} const normalize = (p: string) => p.replaceAll("\\", "/") const trim = (p: string) => normalize(p).replace(/\/+$/, "") @@ -23,12 +34,21 @@ export function mergeFileSearchItems(input: { files: string[] folders: string[] open?: Set + /** Path to owning workspace-folder name. Empty in a single-folder workspace, where a badge would say nothing. */ + labels?: Map + /** Absolute path to its form relative to the owning workspace folder. */ + relative?: Map }): FileSearchItem[] { const query = normalize(input.query).trim().toLowerCase() const open = new Set([...(input.open ?? [])].map(normalize)) + const label = (p: string) => { + const root = input.labels?.get(p) + const rel = input.relative?.get(p) + return { ...(root ? { root } : {}), ...(rel ? { relative: rel } : {}) } + } const files = input.files.map((p) => { const path = normalize(p) - return { path, type: open.has(path) ? ("opened-file" as const) : ("file" as const) } + return { path, type: open.has(path) ? ("opened-file" as const) : ("file" as const), ...label(p) } }) const pinned = files.filter((item) => item.type === "opened-file") const rest = files.filter((item) => !open.has(item.path)) @@ -42,7 +62,7 @@ export function mergeFileSearchItems(input: { return true }) .map((p, index) => ({ - item: { path: normalize(p), type: "folder" as const }, + item: { path: normalize(p), type: "folder" as const, ...label(p) }, index, rank: query ? rank(query, p) : 4, })) diff --git a/packages/kilo-vscode/src/kilo-provider/file-search-results.ts b/packages/kilo-vscode/src/kilo-provider/file-search-results.ts index 390fe8a7eae..8216f2c3626 100644 --- a/packages/kilo-vscode/src/kilo-provider/file-search-results.ts +++ b/packages/kilo-vscode/src/kilo-provider/file-search-results.ts @@ -9,15 +9,22 @@ 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) +/** + * `basis` is the path the match is judged on, which is not always the path that + * gets inserted. Entries from other workspace folders are absolute, and scoring + * those in full would let a query match the filesystem prefix — a username or a + * parent directory — on every one of them, and would inflate their depth. + */ +function score(query: string, p: string, priority: number, basis: string) { + const name = base(basis) return { p, - label, - path, - depth: depth(p), + basis, + name, + label: fuzzysort.single(query, name), + path: fuzzysort.single(query, basis), + depth: depth(basis), + priority, } } @@ -30,28 +37,37 @@ function compare(a: ReturnType, b: ReturnType): numb 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 + // Only once match quality ties does the owning workspace folder matter, so a + // strong match in an added folder still beats a weak one in the session's own + // project. Reversing these two would bury exact filename matches. + if (a.priority !== b.priority) return a.priority - b.priority + + if (a.name.length !== b.name.length) return a.name.length - b.name.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 + if (a.basis.length !== b.basis.length) return a.basis.length - b.basis.length return a.p.localeCompare(b.p) } -function rankOpen(query: string, paths: string[]): string[] { +type Basis = (p: string) => { priority: number; basis: string } + +function rankOpen(query: string, paths: string[], of: Basis): string[] { if (!query || !paths.length) return paths const scored: Array> = [] for (const p of paths) { - const result = score(query, p) + const meta = of(p) + const result = score(query, p, meta.priority, meta.basis) if (result.path) scored.push(result) } return scored.sort(compare).map((x) => x.p) } -function rankBackend(query: string, paths: string[]): string[] { +function rankBackend(query: string, paths: string[], of: Basis): string[] { if (!query || paths.length <= 1) return paths return paths - .map((p) => score(query, p)) + .map((p) => { + const meta = of(p) + return score(query, p, meta.priority, meta.basis) + }) .sort(compare) .map((x) => x.p) } @@ -61,18 +77,29 @@ export function mergeFileSearchResults(input: { backend: string[] open: Set active?: string + /** + * Path to owning workspace-folder index, used only to break ties between + * equally good matches. Absent entries rank as the session's own project. + */ + priority?: Map + /** + * Path to the root-relative path it should be judged on. Absent entries are + * judged on themselves, which is what single-root search does. + */ + relative?: Map }): 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 of: Basis = (p) => ({ priority: input.priority?.get(p) ?? 0, basis: input.relative?.get(p) ?? p }) + const matched = rankOpen(query, [...open], of) 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)] + return [...tabs, ...rankBackend(query, remaining, of)] } diff --git a/packages/kilo-vscode/src/kilo-provider/file-search.ts b/packages/kilo-vscode/src/kilo-provider/file-search.ts index f7093610997..d3f209912fd 100644 --- a/packages/kilo-vscode/src/kilo-provider/file-search.ts +++ b/packages/kilo-vscode/src/kilo-provider/file-search.ts @@ -4,6 +4,25 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" import { mergeFileSearchResults } from "./file-search-results" import { mergeFileSearchItems, type FileSearchItem } from "./file-search-items" +/** + * Bounds on the merged multi-root result, applied after ranking so the best + * matches survive. Not applied to single-root workspaces, which keep the + * backend's own limits untouched. + */ +const MULTI_FILE_LIMIT = 100 +const MULTI_FOLDER_LIMIT = 50 + +/** + * How many folders beyond the session's own project a single query may search. + * + * Each one costs its own file index and watcher in the backend, kept for an + * hour, so an unusually large workspace must not grow that cost without bound. + */ +const MAX_EXTRA_ROOTS = 4 + +/** A folder open in the editor workspace, as a candidate mention source. */ +export type SearchRoot = { path: string; name: string } + type Message = { query: string requestId: string @@ -18,6 +37,36 @@ type Input = { dir: (id?: string) => string open: (dir: string) => Promise> post: (message: unknown) => void + /** + * Every folder in the editor workspace. Fan-out only happens when the + * session's own directory is one of them, so worktree and Agent Manager + * sessions stay scoped to their own tree. + */ + roots?: () => readonly SearchRoot[] +} + +const slash = (value: string) => value.replaceAll("\\", "/") + +function same(a: string, b: string): boolean { + if (!a || !b) return false + return path.relative(a, b) === "" +} + +/** + * Split the workspace folders into the session's own project and the rest. + * + * `secondary` is empty unless `dir` is itself one of the workspace folders. A + * session routed to a git worktree or an Agent Manager project has a directory + * outside the folder list, and silently widening its search to unrelated + * projects would be wrong. + */ +export function splitRoots( + roots: readonly SearchRoot[], + dir: string, +): { primary?: SearchRoot; secondary: SearchRoot[] } { + const primary = roots.find((root) => same(root.path, dir)) + if (!primary) return { secondary: [] } + return { primary, secondary: roots.filter((root) => root.path && !same(root.path, dir)) } } async function fetchBackend(client: KiloClient, dir: string, query: string): Promise<[string[], string[]]> { @@ -29,24 +78,76 @@ async function fetchBackend(client: KiloClient, dir: string, query: string): Pro return [settled(fileRes, "file"), settled(folderRes, "folder")] } -function assemble( - query: string, - dir: string, - files: string[], - folders: string[], - open: Set, -): { paths: string[]; items: FileSearchItem[] } { +/** Path of the active editor relative to `dir`, or undefined when it lives elsewhere. */ +function activeIn(dir: string): string | undefined { const uri = vscode.window.activeTextEditor?.document.uri - const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined - const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined - const paths = mergeFileSearchResults({ query, backend: files, open, active }) - const items = mergeFileSearchItems({ - query, - files: paths, - folders, - open: new Set(active ? [active, ...open] : open), - }) - return { paths, items } + if (uri?.scheme !== "file" || !dir) return undefined + const rel = path.relative(dir, uri.fsPath) + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return undefined + return slash(rel) +} + +type Gathered = { + files: string[] + folders: string[] + open: Set + active?: string + /** Inserted path to its root-relative form, so ranking is not skewed by the filesystem prefix. */ + relative: Map +} + +const EMPTY: Gathered = { files: [], folders: [], open: new Set(), relative: new Map() } + +/** + * Collect one root's candidates, without ranking them. + * + * Ranking is deliberately left to the caller: scoring each root separately and + * concatenating would let fuzzy noise in the first root outrank an exact + * filename match in another. + * + * Primary-root paths stay relative, preserving today's attachment behavior. + * Secondary-root paths are made absolute: that is what makes them insertable as + * mentions while `buildFileAttachments` still refuses to auto-read them, which + * is the same boundary the "Browse files..." picker relies on. Their relative + * form is kept alongside so ranking still judges them on the same basis. + * + * A root that cannot be read yields nothing rather than throwing. Extra folders + * are arbitrary user-chosen directories, and one unreadable `.kilocodeignore` + * must not empty the whole mention list. + */ +async function gather( + client: KiloClient, + root: string, + query: string, + open: (dir: string) => Promise>, + absolute: boolean, +): Promise { + if (!root) return EMPTY + try { + const [files, folders] = await fetchBackend(client, root, query) + const tabs = await open(root) + const active = activeIn(root) + if (!absolute) { + return { files: files.map(slash), folders: folders.map(slash), open: tabs, active, relative: new Map() } + } + const relative = new Map() + const abs = (value: string) => { + const rel = slash(value) + const full = slash(path.resolve(root, value)) + relative.set(full, rel) + return full + } + return { + files: files.map(abs), + folders: folders.map(abs), + open: new Set([...tabs].map(abs)), + active: active ? abs(active) : undefined, + relative, + } + } catch (err) { + console.error(`[Kilo New] File search failed for ${root}:`, err) + return EMPTY + } } export async function handleFileSearch(input: Input): Promise { @@ -59,12 +160,96 @@ export async function handleFileSearch(input: Input): Promise { const id = input.message.sessionID ?? input.current ?? input.context const dir = input.dir(id) const query = input.message.query - const [files, folders] = await fetchBackend(client, dir, query) - const open = dir ? await input.open(dir) : new Set() - const { paths, items } = assemble(query, dir, files, folders, open) + // A root list that throws must not take the mention dropdown down with it; + // fall back to searching the session's own directory alone. + const split = (() => { + try { + return splitRoots(input.roots?.() ?? [], dir) + } catch (err) { + console.error("[Kilo New] Failed to read workspace folders:", err) + return { secondary: [] as SearchRoot[] } + } + })() + // A bare `@` searches the session's own project only. Every other workspace + // folder costs a file index the backend then holds for an hour, and opening + // the menu without searching is not a reason to build them — the same reason + // past chats are fetched on the first character rather than on every `@`. + const extras = query.trim() ? split.secondary.slice(0, MAX_EXTRA_ROOTS) : [] + const multi = extras.length > 0 + // Badges follow the shape of the workspace, not what this particular query + // happened to search, so rows do not sprout a badge on the first keystroke. + const labelled = split.secondary.length > 0 + + const [primary, secondary] = await Promise.all([ + gather(client, dir, query, input.open, false), + Promise.all(extras.map((root) => gather(client, root.path, query, input.open, true))), + ]) + + // In a multi-root workspace every entry is labelled, including the session's + // own project: labelling only the added folders leaves the unlabelled ones + // looking like they belong to no folder at all. Priority is the workspace + // folder order, and only breaks ties between equally good matches. + const labels = new Map() + const priority = new Map() + const relative = new Map() + const groups: Array<{ hits: Gathered; root?: SearchRoot }> = [ + { hits: primary, root: split.primary }, + ...secondary.map((hits, index) => ({ hits, root: extras[index] })), + ] + groups.forEach((group, index) => { + for (const [full, rel] of group.hits.relative) relative.set(full, rel) + for (const value of [...group.hits.files, ...group.hits.folders, ...group.hits.open]) { + if (!priority.has(value)) priority.set(value, index) + if (labelled && group.root && !labels.has(value)) labels.set(value, group.root.name) + } + }) + + const opened = new Set(groups.flatMap((group) => [...group.hits.open])) + for (const group of groups) { + if (group.hits.active) opened.add(group.hits.active) + } + + const ranked = mergeFileSearchResults({ + query, + backend: groups.flatMap((group) => group.hits.files), + open: opened, + active: groups.find((group) => group.hits.active)?.hits.active, + priority, + relative, + }) + const paths = multi ? ranked.slice(0, MULTI_FILE_LIMIT) : ranked + // Folders need no priority map: mergeFileSearchItems sorts them by match rank + // and breaks ties on input order, which is already workspace-folder order. + const merged = mergeFileSearchItems({ + query, + files: paths, + folders: groups.flatMap((group) => group.hits.folders), + open: opened, + labels, + relative, + }) + // Cap folders only after ranking. Slicing the input would hand the whole + // allowance to the first root, dropping every added folder before its + // entries ever competed. + const items = multi ? capFolders(merged, MULTI_FOLDER_LIMIT) : merged + input.post({ type: "fileSearchResult", paths, items, dir, requestId: input.message.requestId }) } +/** Keep the best `limit` folder entries, leaving files and their order untouched. */ +function capFolders(items: FileSearchItem[], limit: number): FileSearchItem[] { + const kept: FileSearchItem[] = [] + let folders = 0 + for (const item of items) { + if (item.type === "folder") { + if (folders >= limit) continue + folders++ + } + kept.push(item) + } + return kept +} + function settled(result: PromiseSettledResult<{ data: string[] }>, kind: "file" | "folder"): string[] { if (result.status === "fulfilled") return result.value.data console.error(`[Kilo New] File search (${kind}) failed:`, result.reason) 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 6b66713a985..85eb91a67ef 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -135,6 +135,34 @@ describe("buildMentionResults", () => { expect(buildMentionResults("browse files", [])).toEqual([FILE_PICKER_RESULT]) }) + it("ranks a file in another workspace folder on its path within that folder", () => { + // The absolute path is what gets inserted, but "nested" occurs only in the + // folder's own prefix, never in the file's path within it. Scoring the + // absolute form made it a match and lifted it above a file the query misses + // just as much, so the order here is what distinguishes the two. + const result = buildMentionResults("nested", [ + { path: "src/aaa.ts", type: "file", root: "repo" }, + { path: "/deep-nested-name/src/zzz.ts", type: "file", root: "deep-nested-name", relative: "src/zzz.ts" }, + ]) + expect(result.filter((item) => item.type === "file").map((item) => item.value)).toEqual([ + "src/aaa.ts", + "/deep-nested-name/src/zzz.ts", + ]) + }) + + it("keeps the owning folder ahead of an equally good match elsewhere", () => { + // Equal scores must preserve the order the host sent, which is the session's + // own project first. + const result = buildMentionResults("notes.md", [ + { path: "notes.md", type: "file", root: "repo" }, + { path: "/other/notes.md", type: "file", root: "other", relative: "notes.md" }, + ]) + expect(result.filter((item) => item.type === "file").map((item) => item.value)).toEqual([ + "notes.md", + "/other/notes.md", + ]) + }) + it("keeps browse files last among the entries of a bare @", () => { const result = buildMentionResults("", ["src/index.ts"], true, true) const types = result.map((item) => item.type) @@ -423,6 +451,16 @@ describe("buildFileAttachments", () => { expect(result).toEqual([]) }) + it("does not attach a file from another workspace folder", () => { + // Multi-root file search offers folders added via "Add Folder to Workspace..." + // as absolute mentions. They must stay mention-only: attaching would read the + // file on the backend through a path that bypasses external_directory + // approval. The agent has to Read them instead. + const paths = new Set(["/other-folder/src/app.ts"]) + const result = buildFileAttachments("@/other-folder/src/app.ts", paths, "/workspace") + expect(result).toEqual([]) + }) + it("does not attach an absolute path that escapes the workspace via ../ segments", () => { const paths = new Set(["/workspace/../../etc/passwd"]) const result = buildFileAttachments("@/workspace/../../etc/passwd", paths, "/workspace") diff --git a/packages/kilo-vscode/tests/unit/file-search.test.ts b/packages/kilo-vscode/tests/unit/file-search.test.ts index 742735d2704..4c7589088e7 100644 --- a/packages/kilo-vscode/tests/unit/file-search.test.ts +++ b/packages/kilo-vscode/tests/unit/file-search.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" -import { handleFileSearch } from "../../src/kilo-provider/file-search" +import * as path from "path" +import { handleFileSearch, splitRoots } from "../../src/kilo-provider/file-search" type Query = { query: string; directory: string; type: "file" | "directory"; limit: number } @@ -18,6 +19,25 @@ function client(data: { files: string[]; folders: string[] }) { } } +/** Per-directory backend, for asserting fan-out across several roots. */ +function multiClient(data: Record) { + const calls: Query[] = [] + return { + calls, + value: { + find: { + files: async (query: Query) => { + calls.push(query) + const entry = data[query.directory] ?? { files: [], folders: [] } + return { data: query.type === "file" ? entry.files : entry.folders } + }, + }, + }, + } +} + +const abs = (root: string, rel: string) => path.resolve(root, rel).replaceAll("\\", "/") + describe("handleFileSearch", () => { it("posts one fresh response for each request", async () => { const api = client({ files: ["src/a.ts"], folders: ["src"] }) @@ -71,4 +91,327 @@ describe("handleFileSearch", () => { }, ]) }) + + it("searches every workspace folder and returns outside roots as labelled absolute paths", async () => { + const api = multiClient({ + "/repo": { files: ["src/a.ts"], folders: ["src"] }, + "/other": { files: ["lib/b.ts"], folders: ["lib"] }, + }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: "ts", requestId: "request-multi" }, + dir: () => "/repo", + roots: () => [ + { path: "/repo", name: "repo" }, + { path: "/other", name: "other" }, + ], + open: async () => new Set(), + post: (message) => posted.push(message as Record), + }) + + expect(api.calls.map((call) => call.directory)).toEqual(["/repo", "/repo", "/other", "/other"]) + // The session's own project stays relative and stays first; the added + // folder is absolute so it can be mentioned without being auto-attached. + expect(posted[0]!.paths).toEqual(["src/a.ts", abs("/other", "lib/b.ts")]) + // Every entry is labelled once the workspace has more than one folder, + // including the session's own project. + // Outside entries also carry their path within the owning folder, which is + // what the webview ranks them on. + expect(posted[0]!.items).toEqual([ + { path: "src/a.ts", type: "file", root: "repo" }, + { path: abs("/other", "lib/b.ts"), type: "file", root: "other", relative: "lib/b.ts" }, + { path: "src", type: "folder", root: "repo" }, + { path: abs("/other", "lib"), type: "folder", root: "other", relative: "lib" }, + ]) + }) + + it("leaves entries unlabelled when the workspace has a single folder", async () => { + const api = multiClient({ "/repo": { files: ["src/a.ts"], folders: [] } }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: "", requestId: "request-single" }, + dir: () => "/repo", + roots: () => [{ path: "/repo", name: "repo" }], + open: async () => new Set(), + post: (message) => posted.push(message as Record), + }) + + expect(posted[0]!.items).toEqual([{ path: "src/a.ts", type: "file" }]) + }) + + it("ranks an exact filename match in an added folder above fuzzy matches in the session's project", async () => { + const api = multiClient({ + // None of these is a real match for "CLAUDE.md"; they only match as a + // scattered subsequence of the full path. + "/repo": { + files: ["docs/error-handling/extension-refresh-on-update.md", "docs/features/background-agent-visibility.md"], + folders: [], + }, + "/other": { files: ["CLAUDE.md"], folders: [] }, + }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: "CLAUDE.md", requestId: "request-exact" }, + dir: () => "/repo", + roots: () => [ + { path: "/repo", name: "repo" }, + { path: "/other", name: "other" }, + ], + open: async () => new Set(), + post: (message) => posted.push(message as Record), + }) + + expect((posted[0]!.paths as string[])[0]).toBe(abs("/other", "CLAUDE.md")) + }) + + it("prefers the session's own project when matches are equally good", async () => { + const api = multiClient({ + "/repo": { files: ["notes.md"], folders: [] }, + "/other": { files: ["notes.md"], folders: [] }, + }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: "notes.md", requestId: "request-tie" }, + dir: () => "/repo", + roots: () => [ + { path: "/repo", name: "repo" }, + { path: "/other", name: "other" }, + ], + open: async () => new Set(), + post: (message) => posted.push(message as Record), + }) + + expect(posted[0]!.paths).toEqual(["notes.md", abs("/other", "notes.md")]) + }) + + it("does not widen the search when the session runs outside the workspace folders", async () => { + const api = multiClient({ "/worktree": { files: ["src/a.ts"], folders: [] } }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: "", requestId: "request-worktree" }, + dir: () => "/worktree", + roots: () => [{ path: "/repo", name: "repo" }], + open: async () => new Set(), + post: (message) => posted.push(message as Record), + }) + + expect(api.calls.map((call) => call.directory)).toEqual(["/worktree", "/worktree"]) + expect(posted[0]!.paths).toEqual(["src/a.ts"]) + }) +}) + +describe("handleFileSearch resilience and ranking basis", () => { + const roots = [ + { path: "/repo", name: "repo" }, + { path: "/other", name: "other" }, + ] + + it("still returns the session's own files when an added folder cannot be read", async () => { + const api = multiClient({ + "/repo": { files: ["src/a.ts"], folders: ["src"] }, + "/other": { files: ["lib/b.ts"], folders: [] }, + }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: "b", requestId: "request-broken-root" }, + dir: () => "/repo", + roots: () => roots, + // A .kilocodeignore that cannot be read propagates out of the ignore + // controller; it must not empty the whole mention list. + open: async (dir) => { + if (dir === "/other") throw new Error("EACCES: permission denied") + return new Set() + }, + post: (message) => posted.push(message as Record), + }) + + expect(posted).toHaveLength(1) + expect(posted[0]!.paths).toEqual(["src/a.ts"]) + expect(posted[0]!.items).toEqual([ + { path: "src/a.ts", type: "file", root: "repo" }, + { path: "src", type: "folder", root: "repo" }, + ]) + }) + + it("posts a result even when the workspace folder list throws", async () => { + const api = multiClient({ "/repo": { files: ["src/a.ts"], folders: [] } }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: "", requestId: "request-broken-roots" }, + dir: () => "/repo", + roots: () => { + throw new Error("workspace unavailable") + }, + open: async () => new Set(), + post: (message) => posted.push(message as Record), + }) + + expect(posted).toHaveLength(1) + expect(posted[0]!.paths).toEqual(["src/a.ts"]) + }) + + it("does not search added folders for a bare @", async () => { + // Each added folder costs a file index the backend holds for an hour. + // Opening the menu is not a reason to build them. + const api = multiClient({ + "/repo": { files: ["src/a.ts"], folders: [] }, + "/other": { files: ["lib/b.ts"], folders: [] }, + }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: "", requestId: "request-bare" }, + dir: () => "/repo", + roots: () => roots, + open: async () => new Set(), + post: (message) => posted.push(message as Record), + }) + + expect(api.calls.map((call) => call.directory)).toEqual(["/repo", "/repo"]) + expect(posted[0]!.paths).toEqual(["src/a.ts"]) + // The badge still reflects the workspace, so rows do not gain one the + // moment a character is typed. + expect(posted[0]!.items).toEqual([{ path: "src/a.ts", type: "file", root: "repo" }]) + }) + + it("searches added folders as soon as there is something to search for", async () => { + const api = multiClient({ + "/repo": { files: ["src/a.ts"], folders: [] }, + "/other": { files: ["lib/b.ts"], folders: [] }, + }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: "b", requestId: "request-typed" }, + dir: () => "/repo", + roots: () => roots, + open: async () => new Set(), + post: (message) => posted.push(message as Record), + }) + + expect(api.calls.map((call) => call.directory)).toEqual(["/repo", "/repo", "/other", "/other"]) + }) + + it("treats a whitespace-only query as a bare @", async () => { + const api = multiClient({ "/repo": { files: ["src/a.ts"], folders: [] } }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: " ", requestId: "request-spaces" }, + dir: () => "/repo", + roots: () => roots, + open: async () => new Set(), + post: (message) => posted.push(message as Record), + }) + + expect(api.calls.map((call) => call.directory)).toEqual(["/repo", "/repo"]) + }) + + it("bounds how many added folders one query can search", async () => { + const many = [ + { path: "/repo", name: "repo" }, + ...Array.from({ length: 8 }, (_, i) => ({ path: `/extra-${i}`, name: `extra-${i}` })), + ] + const api = multiClient({ "/repo": { files: [], folders: [] } }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: "x", requestId: "request-cap" }, + dir: () => "/repo", + roots: () => many, + open: async () => new Set(), + post: (message) => posted.push(message as Record), + }) + + const searched = [...new Set(api.calls.map((call) => call.directory))] + expect(searched).toEqual(["/repo", "/extra-0", "/extra-1", "/extra-2", "/extra-3"]) + }) + + it("does not let the filesystem prefix of an added folder count as a match", async () => { + // "nested" occurs in the added folder's own path but nowhere in the file's + // relative path. Scoring the absolute form matched every file under that + // folder on a query that describes none of them. + const api = multiClient({ + "/repo": { files: ["src/a.ts"], folders: [] }, + "/deep-nested-name": { files: [], folders: [] }, + }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: "nested", requestId: "request-prefix" }, + dir: () => "/repo", + roots: () => [ + { path: "/repo", name: "repo" }, + { path: "/deep-nested-name", name: "deep-nested-name" }, + ], + open: async (dir) => (dir === "/deep-nested-name" ? new Set(["src/zzz.ts"]) : new Set()), + post: (message) => posted.push(message as Record), + }) + + expect(posted[0]!.paths).not.toContain(abs("/deep-nested-name", "src/zzz.ts")) + }) + + it("keeps folders from added roots when the primary root fills the cap", async () => { + // The primary root alone exceeds the multi-root folder allowance. Slicing + // before ranking handed it the whole budget and dropped every added folder. + const api = multiClient({ + "/repo": { files: [], folders: Array.from({ length: 60 }, (_, i) => `pkg-${i}`) }, + "/other": { files: [], folders: ["target"] }, + }) + const posted: Array> = [] + + await handleFileSearch({ + client: api.value as never, + message: { query: "target", requestId: "request-folder-cap" }, + dir: () => "/repo", + roots: () => roots, + open: async () => new Set(), + post: (message) => posted.push(message as Record), + }) + + const items = posted[0]!.items as Array<{ path: string; root?: string }> + expect(items.some((item) => item.path === abs("/other", "target"))).toBe(true) + }) +}) + +describe("splitRoots", () => { + const roots = [ + { path: "/repo", name: "repo" }, + { path: "/other", name: "other" }, + ] + + it("separates the session's own folder from the rest", () => { + expect(splitRoots(roots, "/repo")).toEqual({ + primary: { path: "/repo", name: "repo" }, + secondary: [{ path: "/other", name: "other" }], + }) + }) + + it("finds no roots when the directory is not a workspace folder", () => { + // Worktree and Agent Manager sessions must not inherit unrelated projects. + expect(splitRoots(roots, "/worktree")).toEqual({ secondary: [] }) + }) + + it("finds no roots when there is no directory", () => { + expect(splitRoots(roots, "")).toEqual({ secondary: [] }) + }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 8a0a3b8eb84..d37a1484cd5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -205,6 +205,8 @@ function MentionItemContent(props: { item: MentionResult }) { {item.type === "folder" ? `${fileName(item.value)}/` : fileName(item.value)} + {/* Without the folder name, two roots holding the same relative path render identically. */} + {(root) => {root()}} {dirName(item.value)} ) 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 3a6d0b96cdc..05420888f76 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 @@ -81,9 +81,9 @@ type MentionEntry = (typeof entries)[number]["result"] export type MentionResult = | MentionEntry - | { type: "file"; value: string } - | { type: "opened-file"; value: string } - | { type: "folder"; value: string } + | { type: "file"; value: string; root?: string; relative?: string } + | { type: "opened-file"; value: string; root?: string; relative?: string } + | { type: "folder"; value: string; root?: string; relative?: string } | { type: "session"; value: string; session: SessionSearchItem } /** @@ -140,6 +140,13 @@ function labels(item: MentionResult): string[] { const entry = entries.find((candidate) => candidate.result.type === item.type) if (entry) return [...("label" in item ? [item.label] : []), item.value, ...entry.aliases] if (item.type === "session") return [item.session.title, item.session.worktreeName ?? ""].filter(Boolean) + // Files in another workspace folder carry an absolute path so they can be + // mentioned without being auto-attached. Score them on their path within that + // folder: including the filesystem prefix would let a query match a username + // or a parent directory on every file under it. + if (item.type === "file" || item.type === "folder" || item.type === "opened-file") { + return [item.relative ?? item.value] + } return [item.value] } @@ -205,9 +212,10 @@ export function buildMentionResults( const references = entries.filter((entry) => entry.gate === null || gates[entry.gate]).map((entry) => entry.result) 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 } + const owner = { ...(item.root ? { root: item.root } : {}), ...(item.relative ? { relative: item.relative } : {}) } + if (item.type === "folder") return { type: "folder", value: item.path, ...owner } + if (item.type === "opened-file") return { type: "opened-file", value: item.path, ...owner } + return { type: "file", value: item.path, ...owner } }) return rankMentionResults(query, [...references, ...sessions, ...results]) } diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css index 581074de74d..e44b2dd64fc 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css @@ -48,6 +48,21 @@ max-width: 180px; } +/* Owning workspace folder badge, shown only in multi-root workspaces so + identical relative paths in two folders stay distinguishable. */ +.file-mention-root { + flex-shrink: 0; + padding: 0 4px; + border-radius: 3px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: var(--kilo-font-size-11); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100px; +} + .file-mention-dir { flex: 1; overflow: hidden; 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 42d7690b958..382db6282d6 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 @@ -580,6 +580,19 @@ export interface SpeechToTextErrorMessage { export interface FileSearchItem { path: string type: "file" | "folder" | "opened-file" + /** + * Owning workspace folder name, set only when the workspace has more than one + * folder. Entries outside the session's own project carry an absolute path and + * are mention-only: they are never auto-attached, so the agent must Read them + * under the normal external-directory permission check. + */ + root?: string + /** + * Path within the owning folder, set only when `path` is absolute. The `@` + * menu is ranked again in the webview, and scoring an absolute path there + * would let the filesystem prefix match every entry under that folder. + */ + relative?: string } export interface FileSearchResultMessage { diff --git a/packages/opencode/src/kilocode/tool/semantic-search-output.ts b/packages/opencode/src/kilocode/tool/semantic-search-output.ts new file mode 100644 index 00000000000..d0e4caf8f7f --- /dev/null +++ b/packages/opencode/src/kilocode/tool/semantic-search-output.ts @@ -0,0 +1,55 @@ +import type { IndexingStatus } from "@kilocode/kilo-indexing/status" + +/** + * Output text for `semantic_search`. + * + * Split from the tool so it can be exercised without booting the indexing + * worker: the wording is the whole point of the behavior, not an incidental + * detail of it. + */ + +export function normalizePath(value: string): string { + return value.replaceAll("\\", "/") +} + +/** Human-readable description of what was actually searched. */ +export function scope(root: string, prefix?: string): string { + return prefix ? `${root}/${normalizePath(prefix)}` : root +} + +/** + * Explain an empty result set in terms of index state. + * + * `KiloIndexing.search` returns `[]` when the index is disabled, unbuilt, or + * broken, which is indistinguishable from a genuine miss. Left unexplained, a + * model reads "no results" as "this code does not exist" and acts on it. + */ +export function reason(status?: IndexingStatus): string { + if (!status) return "The index could not be queried, so this is not evidence that no matching code exists." + const detail = status.message.trim() + const suffix = detail ? ` ${detail}` : "" + if (status.state === "Disabled") { + return `Codebase indexing is disabled for this project, so nothing was searched.${suffix}` + } + if (status.state === "Error") return `Codebase indexing failed, so nothing was searched.${suffix}` + if (status.state === "In Progress") { + return `The index is still building (${status.percent}%, ${status.processedFiles}/${status.totalFiles} files), so results are incomplete.` + } + if (status.state === "Standby") return `The index is not active, so results are incomplete.${suffix}` + return "The index is up to date, so no semantically similar code exists in this scope." +} + +/** + * Full output for a search that matched nothing. + * + * Always names the indexed root: only one root is indexed, but files from other + * editor workspace folders are mentionable, so the caller needs to know that a + * miss here does not cover them. + */ +export function empty(query: string, root: string, prefix?: string, status?: IndexingStatus): string { + return [ + `No results for "${query}" in ${scope(root, prefix)}.`, + reason(status), + `Only ${root} is indexed. Files in other workspace folders are not searchable here — use Read with an absolute path.`, + ].join("\n") +} diff --git a/packages/opencode/src/kilocode/tool/semantic-search.ts b/packages/opencode/src/kilocode/tool/semantic-search.ts index 9bbbc85a3d5..dfa632b7392 100644 --- a/packages/opencode/src/kilocode/tool/semantic-search.ts +++ b/packages/opencode/src/kilocode/tool/semantic-search.ts @@ -3,6 +3,7 @@ import path from "path" import * as Tool from "@/tool/tool" import { KiloIndexing } from "@/kilocode/indexing" import { Instance } from "@/kilocode/instance" +import { empty, normalizePath, scope } from "./semantic-search-output" import DESCRIPTION from "./semantic-search.txt" @@ -12,7 +13,7 @@ const Parameters = Schema.Struct({ }), path: Schema.optional(Schema.String).annotate({ description: - "Limit search to specific subdirectory (relative to the current workspace directory). Leave empty for entire workspace.", + "Limit search to a subdirectory, relative to the indexed root. Leave empty to search the whole indexed root.", }), }) @@ -26,6 +27,10 @@ type SearchResult = { type Meta = { results: SearchResult[] + /** Absolute root the index covers, so a caller can tell what was actually searched. */ + root: string + /** Index state at query time; only resolved when nothing matched. */ + state?: KiloIndexing.Status["state"] } export const SemanticSearchTool = Tool.define( @@ -53,6 +58,7 @@ export const SemanticSearchTool = Tool.define( }) const prefix = normalizeSearchPath(params.path) + const root = normalizePath(Instance.directory) const matches = yield* Effect.promise(() => KiloIndexing.search(params.query, prefix)) const results = matches.flatMap((item) => { @@ -79,17 +85,28 @@ export const SemanticSearchTool = Tool.define( }) if (results.length === 0) { + // An empty result set is ambiguous: the index may be disabled, still + // building, or broken. Report which, so the caller does not read this + // as proof that no matching code exists. + const status = yield* Effect.promise(() => + KiloIndexing.current().then( + (value) => value, + () => undefined, + ), + ) return { title: "Codebase Search", metadata: { results, + root, + state: status?.state, }, - output: `No relevant code found for "${params.query}"${prefix ? ` in ${normalizePath(prefix)}` : ""}.`, + output: empty(params.query, root, prefix, status), } } const output = [ - `Found ${results.length} result${results.length === 1 ? "" : "s"} for "${params.query}"${prefix ? ` in ${normalizePath(prefix)}` : ""}.`, + `Found ${results.length} result${results.length === 1 ? "" : "s"} for "${params.query}" in ${scope(root, prefix)}.`, "", ...results.flatMap((item, index) => { return [ @@ -104,6 +121,7 @@ export const SemanticSearchTool = Tool.define( title: "Codebase Search", metadata: { results, + root, }, output: output.join("\n").trim(), } @@ -122,7 +140,3 @@ function normalizeSearchPath(input?: string): string | undefined { } return path.normalize(relative) } - -function normalizePath(value: string): string { - return value.replaceAll("\\", "/") -} diff --git a/packages/opencode/src/kilocode/tool/semantic-search.txt b/packages/opencode/src/kilocode/tool/semantic-search.txt index 63b8ef158b8..d54086b1d8f 100644 --- a/packages/opencode/src/kilocode/tool/semantic-search.txt +++ b/packages/opencode/src/kilocode/tool/semantic-search.txt @@ -11,7 +11,7 @@ follow up with Grep and Read. Prefer Grep directly when exact terms are already - Search for an exact symbol or regex pattern — use `Grep` - Find files by filename or extension — use `Glob` - Read the contents of a known file — use `Read` -- Explore files outside the current workspace - use `Grep`, `Glob`, and `Read` +- Explore files outside the indexed root — use `Read` with an absolute path. `Grep` and `Glob` are bounded to the same root as this tool and cannot reach outside it either. ## Examples @@ -23,5 +23,7 @@ follow up with Grep and Read. Prefer Grep directly when exact terms are already ## Constraints - Write the query in English. -- Searches the entire current workspace by default. Limit semantic search to one subdirectory with `path`. -- Cannot search outside the current workspace. Use other tools if this functionality is needed. +- Searches one indexed root: the project directory of the current session. Limit the search to a subdirectory of that root with `path`. +- In a multi-root editor workspace, only that one root is indexed. Files in other workspace folders are not searchable here even though they may appear as `@` mentions. +- Cannot search outside the indexed root. Use `Read` with an absolute path instead. +- Empty results are not proof that no matching code exists. The output states whether the index was complete, still building, disabled, or failed; read it before concluding anything. diff --git a/packages/opencode/test/kilocode/semantic-search.test.ts b/packages/opencode/test/kilocode/semantic-search.test.ts index 5d54531d390..ae4bab37ab2 100644 --- a/packages/opencode/test/kilocode/semantic-search.test.ts +++ b/packages/opencode/test/kilocode/semantic-search.test.ts @@ -14,6 +14,17 @@ import { Truncate } from "../../src/tool/truncate" const rt = ManagedRuntime.make(Layer.mergeAll(AppNodeBuilder.build(Truncate.node), AppNodeBuilder.build(Agent.node))) +const slash = (value: string) => value.replaceAll("\\", "/") + +/** A finished index, so empty-result wording does not depend on real indexing progress. */ +const complete = { + state: "Complete", + message: "Index up-to-date.", + processedFiles: 0, + totalFiles: 0, + percent: 100, +} satisfies KiloIndexing.Status + async function initTool() { return rt.runPromise( Effect.gen(function* () { @@ -54,6 +65,7 @@ describe("tool.semantic_search", () => { fn: async () => { const requests: Array> = [] const search = spyOn(KiloIndexing, "search").mockResolvedValue([]) + const status = spyOn(KiloIndexing, "current").mockResolvedValue(complete) try { const tool = await initTool() @@ -80,9 +92,15 @@ describe("tool.semantic_search", () => { path: "./src/../src/tool", }) expect(search).toHaveBeenCalledWith("authentication middleware", path.normalize("src/tool")) - expect(result.output).toBe('No relevant code found for "authentication middleware" in src/tool.') + // The searched scope is named in full, so a caller can tell what an + // empty result actually covered. + expect(result.output.split("\n")[0]).toBe( + `No results for "authentication middleware" in ${slash(tmp.path)}/src/tool.`, + ) + expect(result.metadata.root).toBe(slash(tmp.path)) } finally { search.mockRestore() + status.mockRestore() } }, }) @@ -94,16 +112,23 @@ describe("tool.semantic_search", () => { directory: tmp.path, fn: async () => { const search = spyOn(KiloIndexing, "search").mockResolvedValue([]) + const status = spyOn(KiloIndexing, "current").mockResolvedValue(complete) try { const tool = await initTool() const result = await rt.runPromise(tool.execute({ query: "database connection" }, baseCtx)) expect(search).toHaveBeenCalledWith("database connection", undefined) - expect(result.output).toBe('No relevant code found for "database connection".') + expect(result.output.split("\n")).toEqual([ + `No results for "database connection" in ${slash(tmp.path)}.`, + "The index is up to date, so no semantically similar code exists in this scope.", + `Only ${slash(tmp.path)} is indexed. Files in other workspace folders are not searchable here — use Read with an absolute path.`, + ]) expect(result.metadata.results).toEqual([]) + expect(result.metadata.state).toBe("Complete") } finally { search.mockRestore() + status.mockRestore() } }, }) @@ -155,7 +180,7 @@ describe("tool.semantic_search", () => { codeChunk: "export const verify = () => true", }, ]) - expect(result.output).toContain('Found 1 result for "verify token".') + expect(result.output).toContain(`Found 1 result for "verify token" in ${slash(tmp.path)}.`) expect(result.output).toContain("1. src/auth/index.ts:10-18 (score 0.8123)") expect(result.output).toContain("export const verify = () => true") } finally { diff --git a/packages/opencode/test/kilocode/tool/semantic-search-output.test.ts b/packages/opencode/test/kilocode/tool/semantic-search-output.test.ts new file mode 100644 index 00000000000..dfa75ebde01 --- /dev/null +++ b/packages/opencode/test/kilocode/tool/semantic-search-output.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test" +import type { IndexingStatus } from "@kilocode/kilo-indexing/status" +import { empty, reason, scope } from "@/kilocode/tool/semantic-search-output" + +function status(over: Partial): IndexingStatus { + return { state: "Complete", message: "", processedFiles: 0, totalFiles: 0, percent: 0, ...over } +} + +describe("scope", () => { + test("names the root when the whole index was searched", () => { + expect(scope("/repo")).toBe("/repo") + }) + + test("names the subdirectory when the search was narrowed", () => { + expect(scope("/repo", "src/app")).toBe("/repo/src/app") + }) + + test("reports a Windows prefix with forward slashes", () => { + expect(scope("C:/repo", "src\\app")).toBe("C:/repo/src/app") + }) +}) + +describe("reason", () => { + test("distinguishes a complete index from an unavailable one", () => { + expect(reason(status({ state: "Complete" }))).toContain("up to date") + expect(reason(undefined)).toContain("not evidence") + }) + + test("reports progress while the index is still building", () => { + const text = reason(status({ state: "In Progress", percent: 40, processedFiles: 120, totalFiles: 300 })) + expect(text).toContain("still building (40%, 120/300 files)") + expect(text).toContain("incomplete") + }) + + test("says nothing was searched when indexing is disabled", () => { + const text = reason(status({ state: "Disabled", message: "Enable it in Kilo Settings." })) + expect(text).toContain("disabled for this project") + expect(text).toContain("nothing was searched") + expect(text).toContain("Enable it in Kilo Settings.") + }) + + test("says nothing was searched when indexing failed", () => { + expect(reason(status({ state: "Error", message: "Failed to initialize: bad model" }))).toContain( + "Codebase indexing failed, so nothing was searched. Failed to initialize: bad model", + ) + }) + + test("omits an empty status message rather than leaving trailing space", () => { + expect(reason(status({ state: "Disabled", message: " " }))).toBe( + "Codebase indexing is disabled for this project, so nothing was searched.", + ) + }) +}) + +describe("empty", () => { + test("states the query, the searched scope, the index state, and the multi-root caveat", () => { + const text = empty("auth flow", "/repo", undefined, status({ state: "Complete" })) + expect(text.split("\n")).toEqual([ + 'No results for "auth flow" in /repo.', + "The index is up to date, so no semantically similar code exists in this scope.", + "Only /repo is indexed. Files in other workspace folders are not searchable here — use Read with an absolute path.", + ]) + }) + + test("does not claim the code is absent when the index never ran", () => { + const text = empty("auth flow", "/repo", undefined, status({ state: "Disabled" })) + expect(text).not.toContain("no semantically similar code exists") + expect(text).toContain("nothing was searched") + }) +})