Repository navigation
feat: suggest files from every folder in a multi-root workspace - #13589
marius-kilocode merged 14 commits into
Conversation
Typing @ only searched workspaceFolders[0], so files in folders added through "Add Folder to Workspace..." were unreachable without the file picker. File search now fans out across every workspace folder. Entries outside the session's own project are returned as absolute paths, which keeps them mention-only: buildFileAttachments already refuses to attach paths outside the session directory, so the agent must Read them under the usual external_directory permission check. That is the same boundary "Browse files..." relies on, and it is unchanged. Fan-out is declined unless the session's directory is itself one of the workspace folders, so worktree and Agent Manager sessions stay scoped to their own tree. All roots are ranked in one pass rather than per root, because scoring each root separately and concatenating let fuzzy path noise in the first root outrank an exact filename match in another. Workspace folder order now only breaks ties between equally good matches. semantic_search still covers a single indexed root. Its description claimed it searched the entire current workspace, which is wrong once mentions span several folders, and it recommended Grep and Glob for outside-root work even though both are bounded to the same root. Empty results now also report whether the index was complete, still building, disabled, or failed, so a miss is no longer mistaken for absent code.
| const items = mergeFileSearchItems({ | ||
| query, | ||
| files: paths, | ||
| folders: multi ? folders.slice(0, MULTI_FOLDER_LIMIT) : folders, |
There was a problem hiding this comment.
[WARNING]: Multi-root folder cap is applied before ranking
MULTI_FOLDER_LIMIT slices folders before mergeFileSearchItems ranks them, while files correctly cap after ranking. fetchBackend already returns up to 50 folders per root, so in a typical repo the primary root fills the cap and every secondary folder is dropped.
On empty @, folders from added workspace folders will not appear — the same inversion this PR already fixed for files. Rank first, then slice.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| const extras = split.secondary | ||
| const multi = extras.length > 0 | ||
|
|
||
| const [primary, secondary] = await Promise.all([ |
There was a problem hiding this comment.
[WARNING]: One failing extra root drops the whole @ search
Promise.all waits on every root and rejects if any gather throws. fetchBackend uses allSettled, but gather still await open(root) → getIgnoreController.initialize(), which is documented to let readFileSync permission errors propagate.
handleFileSearch has no try/catch and never posts a fileSearchResult on throw, so a single unreadable extra folder can leave the mention dropdown empty. Isolate per-root failures and still return primary hits.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| const tabs = await open(root) | ||
| const active = activeIn(root) | ||
| if (!absolute) return { files: files.map(slash), folders: folders.map(slash), open: tabs, active } | ||
| const abs = (value: string) => slash(path.resolve(root, value)) |
There was a problem hiding this comment.
[SUGGESTION]: Ranking secondary hits on absolute paths skews fuzzy match quality
Secondary paths are absolutized before mergeFileSearchResults scores them. Basename scoring is fine, but path scoring and depth() run on the full filesystem prefix, so a query that matches Users, a username, or a parent directory path-matches every extra-root file while primary hits stay relative.
Rank on the per-root relative path, then convert secondary results to absolute for the mention/attachment boundary.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| await controller.initialize() | ||
| return controller | ||
| })() | ||
| this.ignoreControllers.set(workspaceDir, pending) |
There was a problem hiding this comment.
[WARNING]: A rejected ignore-controller init is cached forever
The in-flight promise is stored before initialize() completes. The old one-entry cache only assigned after a successful init, so a failure was retried. A rejected entry now sticks until 16 other directories evict it — typical workspaces never reach that.
The next @ search for that root reuses the rejected promise, and combined with Promise.all in handleFileSearch that can disable mentions until reload. Delete the map entry on rejection if it is still this promise.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Files Reviewed (4 files)
Incremental review of The prior findings on
No new issues found in the changed lines. Previous Review Summaries (7 snapshots, latest commit a24599e)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit a24599e)Status: 3 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (6 files)
Incremental review of
The earlier active inline comments on Fix these issues in Kilo Cloud Previous review (commit d9a5f0d)Status: No Issues Found | Recommendation: Merge Files Reviewed (2 files)
Incremental review of
Active inline comments from earlier rounds sit on unchanged files (not in this increment) and are not carried forward. Previous review (commit 352ec4b)Status: No Issues Found | Recommendation: Merge Files Reviewed (2 files)
Previous suggestion resolved in Previous review (commit 194b3c4)Status: 1 Issue Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)SUGGESTION
Files Reviewed (12 files)
Previous reviewStatus: No Issues Found | Recommendation: Merge Files Reviewed (9 files)
Previous review (commit 3661761)Status: No Issues Found | Recommendation: Merge Files Reviewed (5 files)
Previous review (commit 29fc848)Status: 4 Issues Found | Recommendation: Address before merge Overview
Fix these issues in Kilo Cloud Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (15 files)
Reviewed by deepseek-v4.1-flash · Input: 0 · Output: 0 · Cached: 0 Review guidance: REVIEW.md from base branch |
Update semantic_search tests for the new output. The tool now names the searched root and explains an empty result in terms of index state, so three assertions in the existing suite were stale. Index status is stubbed there so the wording no longer depends on real indexing progress. Rank entries from added folders on their root-relative path. They are absolutised before insertion so the attachment boundary still rejects them, but scoring the absolute form let a query match the filesystem prefix — a username or a parent directory — on every file under that root, and inflated their depth in tiebreaks. Cap folders after ranking rather than before. The primary root can return more folders than the multi-root allowance on its own, so slicing the input handed it the whole budget and dropped every added folder before its entries competed. This is the same inversion already fixed for files. Isolate per-root failures. Extra folders are arbitrary user-chosen directories, and FileIgnoreController.initialize lets permission errors from reading .kilocodeignore propagate. One unreadable folder rejected the Promise.all and left the mention dropdown empty, since the handler never posted a result on throw. Evict a rejected ignore-controller init instead of caching it. The in-flight promise is stored before initialize resolves, so a failure stuck until 16 other directories displaced it; the old one-entry cache only stored a controller after a successful init.
|
All four review findings addressed in 3661761, plus the CI failure. Confirming each: Multi-root folder cap applied before ranking — correct, and it was the same inversion this PR had already fixed for files. The primary root can return up to 50 folders on its own, so it consumed the entire allowance and every added folder was dropped before its entries competed. Folders are now capped after One failing extra root drops the whole Ranking secondary hits on absolute paths skews fuzzy match quality — correct, and sharper than it first looks. A rejected ignore-controller init is cached forever — correct, and a regression I introduced: the old one-entry cache only stored a controller after a successful CI failure was separate and my miss: Local verification after the fixes: 270 extension unit tests across the 9 files covering every changed module, plus |
|
Unsure on the implications of this, all of this indexing has been historically tricky and hard to get right. |
|
@marius-kilocode My preliminary plan was to avoid touching on indexing parts, but my agent did push back and told me that if left unchanged, it would have caused confusion for Kilo Code when using the semantic_search tool in a multi-root workspace setting, hence the changes on those parts. What if I split this PR into two minor PRs: One PR that actually does make the mentions search work appropriately in a multi-root workspace setting and the other PR focuses on implementing appropriate indexing management in a multi-root workspace setting. Because you seem to be more worried about the indexing parts and not much about the mentions part, am I right? |
|
The problem is that you will need an index to keep @ fast or not? Right now that could be fine within the same vscode workspace as long as we don't have any performance/memory implications for users. |
|
@marius-kilocode Are you talking about indexing as in local codebase indexing / semantic_search or the index for searching files using "@"? |
|
I am talking about [index for searching files using "@"]. I don't mean semantic search. |
|
We build up a bunch of indexes that affect performance. The larger the scope of such an index get's the more likely this happens, especially since user repos tend to be very noisy on file level. |
|
Okay, now I get your point. I'll look into this later and see what options we have at our disposal to mitigate the risks of performance degradation. |
|
@marius-kilocode Could you look at #13592 first? If we can agree on a solution that is seemingly more performant over there, then we can take address this PR afterwards. |
The @ menu was reworked upstream: mention entries are centralised behind MentionEntry, and rankMentionResults now scores every offer — entries, past chats and files — against one query on a single scale. Resolved MentionResult by taking upstream's MentionEntry collapse and re-applying the owning-folder field to the file variants. That reranking reaches files this PR returns as absolute paths, and labels() fed it the whole path, so the filesystem prefix could match a username or a parent directory on every file under an added folder. This is the same skew already fixed host-side in mergeFileSearchResults, and the webview would have re-introduced it. FileSearchItem now carries the path relative to its owning folder, and labels() scores that instead. The absolute path is still what gets inserted, so the attachment boundary is unchanged. Equal scores keep the host's order because the sort is stable, so the session's own project still wins ties.
Each workspace folder searched costs its own file index and watcher in the backend, held for an hour, and repos tend to be noisy at file level. Fanning out on the empty search that fills the initial dropdown meant merely opening the menu paid for every folder, whether or not the user went on to search across them. A bare @ now searches the session's own project alone, exactly as before this feature, and the other folders are searched from the first typed character. Past chats already work this way, fetched on the first character rather than on every @. The number of added folders one query can search is also bounded, so an unusually large workspace cannot grow the backend's index count without limit. Badges follow the shape of the workspace rather than what a particular query happened to search, so rows do not sprout a badge on the first keystroke.
|
@marius-kilocode Merge conflicts resolved, and I've pushed two changes that address the index cost directly. A bare @ no longer touches the other folders. It searches the session's own project only — exactly as before this PR — and the other workspace folders are searched from the first typed character. This is the same rule the past-chats list already follows (useFileMention.ts:661-663: "Only a typed query can match a chat title, so the list is fetched on the first character rather than on every bare @"). Opening the menu and moving on now costs nothing extra. The number of folders one query can search is capped at four. Worth flagging explicitly: in a workspace with more than five folders, the extras beyond the cap aren't searched. That's a deliberate trade to bound the index count rather than an oversight — happy to change the number or the policy if you'd prefer a different one. On what you'd still see: the first search in each added folder pays for that folder's index, then it's fast, with the backend holding it for the 60-minute idle TTL. So this changes when the cost is paid and bounds how many you can pay for, but it doesn't make the per-folder cost disappear. I don't want to claim more than that. |
|
Hi @marius-kilocode , what is the status with this PR? Do you desire any further changes or perhaps you see some other blockers that need to be addressed? |
marius-kilocode
left a comment
There was a problem hiding this comment.
Thanks for sticking with this. The feature itself makes sense and the ranking/badging work is good, but I traced what find.files costs on the backend for a fresh directory and I think the fan-out has to move off the backend. Details on the lines. The rest of the extension side can stay as is once that is swapped.
|
|
||
| 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))), |
There was a problem hiding this comment.
This is the part I want to change. A find.files call with a new directory does much more than build a file index in kilo serve:
InstanceContextMiddlewarerunsInstanceStore.loadfor the directory, which runs the full instance bootstrap (packages/opencode/src/project/bootstrap.ts): project detection,config.get(),plugin.init(), git subprocesses for branch detection, LSP/format config. ThatInstanceStoreentry has no TTL, it stays until the server exits.- Then the
LocationServiceMapstack compiles ~35 service layers for that directory, including the FFF native index (full tree scan) and a parcel/FSEvents watcher (packages/core/src/location-services.ts). That is the 60 minute TTL you mention, but it only covers this half.
The bigger problem is plugin.init() and config.get(): typing @a loads the other folder's kilo.json and executes its configured plugins. Adding an unrelated repo to the workspace should not do that.
Can we use vscode.workspace.findFiles(pattern, exclude, limit) for the secondary roots instead? VS Code already has an index for every workspace folder, respects files.exclude / search.exclude, spawns nothing, and does not touch the backend at all. Keep find.files for the session's own directory only, and feed the findFiles results into the same mergeFileSearchResults / mergeFileSearchItems path with the relative-basis map you already built. I think that is a swap inside gather rather than a rewrite.
| * 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 |
There was a problem hiding this comment.
With findFiles on the extension side this cap should not be needed anymore. I would prefer no cap over silently not searching folder 6+.
| import * as Tool from "@/tool/tool" | ||
| import { KiloIndexing } from "@/kilocode/indexing" | ||
| import { Instance } from "@/kilocode/instance" | ||
| import { empty, normalizePath, scope } from "./semantic-search-output" |
There was a problem hiding this comment.
Can we move the semantic_search changes (this file, semantic-search-output.ts, the .txt and the tests) into a separate PR? They change tool output and wording for every user, not only multi-root, and I want to review that on its own. Keeps this one focused on @ mentions.
|
@sylwester-liljegren thanks, the change seems tricky to get right. |
|
@marius-kilocode , I can look into this of course. I checked with Codex and Claude Code extensions in VS Code for similar feature and it seems like they do support searches across several roots in a multi-root workspace setting, which is why I think it would be awesome to make it work for Kilo Code as well. I'll look into this after my work and then get back to you with updates. |
…e-mentions # Conflicts: # packages/kilo-vscode/src/KiloProvider.ts
Naming a directory on the backend's find.files does far more than build a file index. The request routes through InstanceContextMiddleware, which calls InstanceStore.load for that directory, and on a miss that runs the full instance bootstrap: project detection, config.get(), and plugin.init(). So typing @A loaded an unrelated repo's config and ran the plugins it declares. That InstanceStore entry has no expiry, and the location stack behind it adds a native file index and a filesystem watcher per directory on top. Adding a folder to the workspace should buy none of that. Added folders are now searched with vscode.workspace.findFiles, which already has an index for every workspace folder, honours files.exclude and search.exclude, spawns nothing, and never reaches the backend. find.files is kept for the session's own directory, which the instance already belongs to. Results feed the same ranking and merge path as before, including the relative-basis map, so ordering and the attachment boundary are unchanged. The cap on how many folders one query could search is gone with the cost that motivated it; every added folder is searched again. Files taken from any one folder are bounded instead, which findFiles enforces itself through maxResults. Glob metacharacters are dropped from the query rather than escaped, so a query cannot be read as a pattern; the real query still decides the ranking afterwards. Directories are derived from matching paths, since findFiles returns files only.
Two differences remained between how an added workspace folder was searched and how the session's own project is, and neither had to stay. Added folders were matched by literal substring, because a glob is what findFiles takes, while the session's own project is matched by subsequence. The same keystrokes therefore answered a narrower question away from home: "fmu" found file-mention-utils.ts in the session's own project and nothing in an added folder. A glob's * spans one path segment, so interleaving them asks for the query's characters in order within a segment, which is the shape fuzzysort already matches on. Both globs are issued because the result limit truncates before anything is ranked, and a literal hit must not be crowded out by looser ones; hits are deduplicated and the real query still decides the order. findFiles honours files.exclude, search.exclude and .gitignore, but knows nothing of .kilocodeignore, so a folder the user had told Kilo to leave alone still showed up in the mention list. Results are now passed through the same per-directory FileIgnoreController that already filters open tabs. A controller that cannot be built lets the files through rather than hiding everything, since this is a relevance filter and not a permission boundary.
…fers Folders were derived from the glob hits before the ignore filter ran, so an excluded file reintroduced the directory holding it, and the active editor skipped the filter entirely. One pass now covers the files, the directories derived from them, and the active editor. The two globs also share a single result bound rather than one each, which is what the constant already claimed. EMPTY became a factory: it held a Set and a Map that any caller could have mutated for every later result.
|
@marius-kilocode Both gaps I flagged when swapping to Subsequence matching. Added folders were matched by literal substring, because a glob is what
That leaves added folders more filtered than the session's own project, and it's worth being precise about why. Picture a workspace with two folders — the session's own project, and a second one added beside it. Both carry the same
The added folder's behaviour is the correct one. And the inconsistency predates this PR — you can see it without multi-root at all: open that same ignored file in a tab in your own project and it will not be pinned as an opened-file, because The pattern is consistent once you see it: I have deliberately not closed it on the primary root here. That means filtering |
| 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, |
There was a problem hiding this comment.
[SUGGESTION]: Folder ranking still scores the absolute path
rank(query, p) judges external folders on their inserted absolute path, while this PR fixes that same skew everywhere else: score() ranks files on the relative basis (file-search-results.ts), the webview's labels() scores item.relative ?? item.value, and the relative map is already passed into this function. Two consequences for added folders: the query.includes("/") && clean.startsWith(query) branch can never boost them (an absolute path never starts with the typed relative path), while clean.includes(query) promotes every folder under an added root whose filesystem prefix coincidentally contains the query (/Users, /home/<name>, a parent directory name) ahead of non-matching folders from the session's own project — and that ordering decides which folders survive capFolders.
Judging on the same relative basis keeps both directions consistent:
| rank: query ? rank(query, p) : 4, | |
| rank: query ? rank(query, input.relative?.get(p) ?? p) : 4, |
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
There was a problem hiding this comment.
Correct, and a genuine miss on my part — fixed in 352ec4b.
This PR moved three places onto the relative basis (score() in file-search-results.ts, labels() in the webview, and the relative field on the item itself) and I left the fourth scoring the absolute path, in the one function that already had the map in hand for a different purpose.
Both consequences you name are real, and both decide which folders survive capFolders:
query.includes("/") && clean.startsWith(query)could never fire for an added folder, sosrc/afell through to the incidentalclean.includes(query)at rank 3 and tied with folders matching only by coincidence.clean.includes(query)matched on the filesystem prefix, so a query containingdev,Users, or any parent directory name promoted every folder beneath that root above folders from the session's own project that the query matched just as little.
Taken the suggested change. base() reads the last segment either way, so the name-based branches are unchanged and only clean moves from absolute to relative — which is the intent.
Added two regression tests at the mergeFileSearchItems level, one per consequence, and verified both fail against the previous implementation before keeping them:
- a query matching only the filesystem prefix no longer reorders an added folder ahead of the session's own
- an added folder can now earn the path-prefix boost and outrank an incidental substring hit
Folder ranking still scored the inserted absolute path while files, the webview menu, and the item metadata had all moved to the relative basis. Two effects, both deciding which folders survive the cap: the src/auth style prefix boost was unreachable, since an absolute path never starts with a typed relative one, and a filesystem prefix the query happened to contain promoted every folder beneath an added root above folders the query matched just as little. The relative map was already threaded into this function for the item field; ranking now uses it too.
Two ignore-filter tests referenced a roots const declared in a later describe block. The ReferenceError propagated out of the roots callback, was caught by the fallback in handleFileSearch, and left extras empty — so no added folder was searched and both assertions held trivially. The fixture is now module scope, and both tests were confirmed to fail against the behaviour they guard. Nothing flagged this: tsconfig only includes src, so test files are never typechecked. Also show an added folder's directory relative to its own folder. dirName returns the whole directory when it is short enough, so a file near the root of an added folder spelled out the local filesystem path beside a badge already naming that folder.
|
@marius-kilocode I think now that you may go through this PR one more time before hopefully merging this PR into main. |
marius-kilocode
left a comment
There was a problem hiding this comment.
Thanks for the contribution and for moving the added-root search to VS Code’s API.
I pushed a small follow-up in a24599e. We now honour workspace/folder exclusions and sibling rules, keep ignored active files out, preserve root labels, show paths on hover, and match directory names correctly. I also removed the controller disposal that could clear ignore rules during a search.
Retested the real multi-root flow locally, including root add/remove and external mention selection. The build checks and 385 focused tests pass. Approving this head; CI is still running. Not merging yet.
| const find = (match: string) => | ||
| vscode.workspace.findFiles( | ||
| new vscode.RelativePattern(folder, `{**/*${match}*,**/*${match}*/**}`), | ||
| excluded.length ? `{${excluded.join(",")}}` : undefined, |
There was a problem hiding this comment.
[WARNING]: Joining the exclude patterns into one brace list is unsafe
excluded.join(",") wrapped in {...} breaks whenever a configured pattern itself contains a comma or brace, e.g. search.exclude "**/*.{js,mjs}": true becomes {...,**/*.{js,mjs}}. Nested braces do not parse as intended, and a pattern that fails to parse/match stops excluding anything. findFiles would then spend its EXTERNAL_LIMIT of 200 results on files the user excluded (node_modules, build output) and crowd out the real matches the manual filter below would have kept.
findFiles already applies the folder's files.exclude/search.exclude (including when rules) when the exclude argument is undefined, so this hand-built list is redundant and more fragile than the default. Consider dropping it and letting the editor apply the configured excludes, keeping rules only for the tab/active filter below.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| const active = activeIn(root.path) | ||
| const opened = [...(await open(root.path))].map(record) | ||
| const candidates = [...new Set([...hits.map(record), ...opened, ...(active ? [record(active)] : [])])] | ||
| const filtered = ( |
There was a problem hiding this comment.
[SUGGESTION]: The new exclude filtering has no test coverage
The shared vscode mock returns the default {} for getConfiguration (tests/setup/vscode-mock.ts:63), so rules is empty in every file-search test and this whole exclusion/when block never executes. A test that configures a files.exclude/search.exclude entry (ideally one true rule plus one when rule) would guard the behavior this commit adds and would have surfaced the comma/brace case above.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| import * as path from "path" | ||
| import * as vscode from "vscode" | ||
| import type { KiloClient } from "@kilocode/sdk/v2/client" | ||
| import { Glob } from "@opencode-ai/core/util/glob" |
There was a problem hiding this comment.
[SUGGESTION]: This import pulls glob and minimatch into the extension bundle
@opencode-ai/core/util/glob is backed by minimatch (packages/core/src/util/glob.ts:31), and this extension bundles everything except vscode, so importing it adds glob+minimatch to the always-loaded Node bundle for one match call. src/services/cli-backend/retry.ts deliberately replicates a tiny core util to avoid exactly this kind of dependency. A small local matcher, or reusing a dependency the extension already ships, would keep the bundle lean; if the shared helper is intentional, a one-line note would help future readers.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
|
Merged @sylwester-liljegren thanks |
Issue
No existing issue — searched
Kilo-Org/kilocodefor "multi-root workspace mention" and "Add Folder to Workspace" and found nothing related. Reported directly by a user hitting it in a multi-root workspace. Happy to file one if maintainers prefer the issue-first flow.Context
In a multi-root VS Code workspace, typing
@only ever suggested files from the first workspace folder. Any folder added through Add Folder to Workspace… was invisible to file search, so the only way to reference a file in it was the "Browse files…" picker, one file at a time.The cause is a single-root assumption that runs the whole way down:
KiloProvider.getRootDirectory()returnsworkspaceFolders[0],handleFileSearchpasses that one directory tofind.files, and the CLI binds aLocationto it that hard-fails on anything outside. Open tabs and the active editor from other folders were dropped by the same relative-path filter.Implementation
File search now fans out across every workspace folder in parallel and merges the results. No backend change was needed:
find.filesalready accepts adirectory, which is the same mechanism Agent Manager worktrees use against one sharedkilo serve.The security boundary is deliberately unchanged. Results from folders other than the session's own project are returned as absolute paths.
buildFileAttachmentsalready refuses to attach any path outside the session directory, so those entries are mention-only and the agent mustReadthem under the normalexternal_directorypermission check. This is exactly the boundary "Browse files…" already relies on, and the guard itself is untouched — widening it would let attachment bypass the permission system, which the comment aboveisInsideWorkspaceexplicitly warns against. There is a regression test named for this feature so a future change cannot quietly loosen it.Fan-out is declined unless the session's directory is itself one of the workspace folders. A session routed to a git worktree or an Agent Manager project has a directory outside that list, and silently widening its search to unrelated projects would be wrong.
Ranking is the part worth reviewing closely. The first version ranked each root separately and concatenated, primary first. That looked reasonable but was wrong:
mergeFileSearchResults's strongest rule — a basename match beats a path-only match — never got to compare across roots, so an exactCLAUDE.mdin an added folder ranked below files whose names don't even contain acand only matched as a scattered subsequence of their full path. All roots are now ranked in one pass, with workspace folder order demoted to a tiebreak that applies only between equally good matches. Anti-flooding moved from ordering to a post-ranking cap, applied only in multi-root workspaces.Every entry is labelled with its workspace folder when the workspace has more than one, including the session's own project — labelling only the added folders left the rest looking like they belonged to nowhere. Single-folder workspaces get no badges and no caps; that path is byte-identical to before, which the existing suite covers.
getIgnoreControllerchanged from a one-entry cache to a bounded map. Multi-root search asks about several roots per keystroke, and the old cache would have re-read.kilocodeignorefrom disk on every alternating lookup.semantic_search
semantic_searchstill covers a single indexed root, and this PR does not change that. But it becomes actively misleading once mentions span several folders, so two things are fixed:GrepandGlobfor exploring outside the root — both are bounded to the same root and cannot reach outside it either, soReadis the only correct answer. That advice was already wrong before this PR.KiloIndexing.searchreturns[]when the index is disabled, still building, or broken, which was indistinguishable from a genuine miss. Empty results now state which, so a model does not read "no results" as "this code does not exist".Making the tool actually search other roots would require registering them as indexing projects, which means separate consent and a full embedding pass per folder. That is deliberately out of scope here.
Screenshots / Video
@in a multi-root workspace lists only the first folder's files; a file in an added folder cannot be found by nameHow to Test
Manual/local verification
Executed by the agent:
bun run typecheckinpackages/kilo-vscodeandpackages/opencode— passbun run lintandbun run knipinpackages/kilo-vscode— passbun testacross the 9 unit files covering every changed module (file-search,file-search-results,file-search-items,file-mention-utils,use-file-mention,file-ignore-controller,notebook-context,agent-manager-worktree-reference,agent-manager-arch) — 266 pass, 0 failbun run script/test-runner.tsforkilocode/tool/semantic-search-output.test.ts,kilocode/tool-registry-indexing.test.ts,kilocode/tool-registry-semantic-import-failure.test.ts— passbun run script/check-opencode-annotations.ts --worktree— "No shared upstream source files changed"; all CLI changes sit in the marker-exemptsrc/kilocode/pathNew tests added: exact filename match in an added folder outranks fuzzy matches in the session's project; identical filenames in two roots resolve to the session's project first; labels present in multi-root and absent in single-root; fan-out declined when the session directory is not a workspace folder; and a file from another workspace folder is not auto-attached.
Executed by a human (PR author), in the Extension Development Host:
@CLAUDE.mdmatch ranking last — both fixed and covered by tests aboveReviewer test steps
@— files from both folders should be listed, each with a workspace folder badgeReadwith anexternal_directoryapproval prompt rather than the file arriving pre-attached@still lists only that worktree's filesBlocked checks and substitute verification
bun turbo typecheckat the repo root (and therefore thepre-pushhook) could not complete:@kilocode/kilo-jetbrains#typecheckfails with "Cannot find a Java installation on your machine matching languageVersion=21" on this machine. This PR touches no Kotlin and no JetBrains files. Substitute verification was runningtypecheckdirectly in the two affected packages, both passing; the push used--no-verifyfor this reason only.bun run test:unitsuite inpackages/kilo-vscode(351 files) exceeded a 10-minute budget locally. An untouched CLI test file times out the same way under barebun teston this machine, so the slowness is environmental rather than introduced here. Substitute verification was running every unit file that references the changed modules, listed above.Checklist
Get in Touch