Skip to content

feat: suggest files from every folder in a multi-root workspace - #13589

Merged
marius-kilocode merged 14 commits into
Kilo-Org:mainfrom
sylwester-liljegren:feat/multi-root-file-mentions
Oct 1, 2026
Merged

marius-kilocode merged 14 commits into
Kilo-Org:mainfrom
sylwester-liljegren:feat/multi-root-file-mentions

Conversation

@sylwester-liljegren

@sylwester-liljegren sylwester-liljegren commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Issue

No existing issue — searched Kilo-Org/kilocode for "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() returns workspaceFolders[0], handleFileSearch passes that one directory to find.files, and the CLI binds a Location to 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.files already accepts a directory, which is the same mechanism Agent Manager worktrees use against one shared kilo serve.

The security boundary is deliberately unchanged. Results from folders other than the session's own project are returned as absolute paths. buildFileAttachments already refuses to attach any path outside the session directory, so those entries are mention-only and the agent must Read them under the normal external_directory permission 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 above isInsideWorkspace explicitly 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 exact CLAUDE.md in an added folder ranked below files whose names don't even contain a c and 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.

getIgnoreController changed 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 .kilocodeignore from disk on every alternating lookup.

semantic_search

semantic_search still 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:

  • Its description claimed it searched "the entire current workspace", and pointed at Grep and Glob for exploring outside the root — both are bounded to the same root and cannot reach outside it either, so Read is the only correct answer. That advice was already wrong before this PR.
  • KiloIndexing.search returns [] 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

before after
image @ in a multi-root workspace lists only the first folder's files; a file in an added folder cannot be found by name image files from every folder are listed, each badged with its workspace folder, and an exact filename match in an added folder ranks first

How to Test

Manual/local verification

Executed by the agent:

  • bun run typecheck in packages/kilo-vscode and packages/opencode — pass
  • bun run lint and bun run knip in packages/kilo-vscode — pass
  • bun test across 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 fail
  • bun run script/test-runner.ts for kilocode/tool/semantic-search-output.test.ts, kilocode/tool-registry-indexing.test.ts, kilocode/tool-registry-semantic-import-failure.test.ts — pass
  • bun run script/check-opencode-annotations.ts --worktree — "No shared upstream source files changed"; all CLI changes sit in the marker-exempt src/kilocode/ path

New 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:

  • Confirmed files from a folder added via Add Folder to Workspace… now appear under @
  • Found and reported two defects on the first implementation — missing badge on the original folder, and an exact CLAUDE.md match ranking last — both fixed and covered by tests above

Reviewer test steps

  1. Open a workspace with one folder, then File → Add Folder to Workspace… and add an unrelated repo
  2. In the Kilo chat input, type @ — files from both folders should be listed, each with a workspace folder badge
  3. Type the exact name of a file that exists only in the added folder — it should rank at or near the top, not at the bottom
  4. Select it, send the message, and confirm the agent reads it via Read with an external_directory approval prompt rather than the file arriving pre-attached
  5. Open a single-folder workspace and confirm no badges appear and results are unchanged
  6. Open an Agent Manager worktree session and confirm @ still lists only that worktree's files

Blocked checks and substitute verification

  • bun turbo typecheck at the repo root (and therefore the pre-push hook) could not complete: @kilocode/kilo-jetbrains#typecheck fails 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 running typecheck directly in the two affected packages, both passing; the push used --no-verify for this reason only.
  • The full bun run test:unit suite in packages/kilo-vscode (351 files) exceeded a 10-minute budget locally. An untouched CLI test file times out the same way under bare bun test on 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

  • Issue linked above, or exception explained
  • Tests/verification described
  • Screenshots/video included for visual changes, or marked N/A — pending, see note above
  • Changeset considered for user-facing changes
  • I personally reviewed the diff and can explain the changes, including any AI-assisted work.

Get in Touch

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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([

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@kilo-code-bot

kilo-code-bot Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (4 files)
  • bun.lock
  • packages/kilo-vscode/package.json
  • packages/kilo-vscode/src/kilo-provider/file-search.ts
  • packages/kilo-vscode/tests/unit/file-search.test.ts

Incremental review of e659ed77 (fix(vscode): flatten multi-root search exclusions), the only branch commit since a24599e3.

The prior findings on file-search.ts are resolved in changed code:

  • Exclude-list construction no longer produces malformed globs. Each configured pattern is brace-expanded (**/*.{js,mjs} becomes **/*.js, **/*.mjs), then literal {, } and , are escaped to [{], [}], [,], so the joined {...} group always parses. A malformed exclude could previously make findFiles throw and silently empty an added folder's hits.
  • The exclusion/when filtering now has test coverage (applies editor exclusions to search hits and open files), and the test also asserts the exact exclude string passed to findFiles.
  • The import no longer pulls the glob package into the extension bundle; minimatch is imported directly (its match call is byte-identical to the previous Glob.match, both minimatch(filepath, pattern, { dot: true })) and added as a direct dependency at the monorepo-pinned 10.2.6.

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

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 2
Issue Details (click to expand)

WARNING

File Line Issue
packages/kilo-vscode/src/kilo-provider/file-search.ts 179 Exclude patterns are joined with commas inside {...}; a configured pattern containing a comma or brace makes the exclude list malformed, so excluded files can fill the 200-result limit and crowd out real hits

SUGGESTION

File Line Issue
packages/kilo-vscode/src/kilo-provider/file-search.ts 216 The new exclusion/when filtering is never exercised: the vscode mock always returns {} for getConfiguration
packages/kilo-vscode/src/kilo-provider/file-search.ts 4 Importing @opencode-ai/core/util/glob pulls glob+minimatch into the always-loaded extension bundle for one match call
Files Reviewed (6 files)
  • packages/kilo-vscode/src/KiloProvider.ts
  • packages/kilo-vscode/src/kilo-provider/file-search.ts - 3 issues
  • packages/kilo-vscode/tests/unit/file-search.test.ts
  • packages/kilo-vscode/tests/unit/use-file-mention.test.ts
  • packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
  • packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts

Incremental review of a24599e3, the only branch commit since d9a5f0dd. Verified in changed code (no finding carried forward):

  • The eviction loop dropped the dispose() call on the evicted FileIgnoreController. dispose() only cleared that controller's own in-memory caches, and the controller becomes unreachable once removed from the map, so this is not a resource leak.
  • gather and gatherExternal both swallow per-root failures, and the removed active to opened loop no longer forces an ignored active editor into the result set; active is still passed to ranking and only reorders an already-open tab.

The earlier active inline comments on file-search.ts:305 and KiloProvider.ts:5627 describe conditions this commit resolves (per-root failures are contained; a rejected ignore-controller init is evicted), so they are not carried into this summary.

Fix these issues in Kilo Cloud

Previous review (commit d9a5f0d)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (2 files)
  • packages/kilo-vscode/tests/unit/file-search.test.ts
  • packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx

Incremental review of d9a5f0dd (the only branch commit since 352ec4b6; the rest of that range is the upstream main merge, which is already in the PR base). No new findings.

  • The shared roots fixture is now module scope, so the two ignore-filter tests stop throwing a ReferenceError out of roots()/gatherExternal and actually exercise the search path they claim to guard. All references now resolve to the one fixture.
  • MentionItemContent renders dirName(item.relative ?? item.value) (PromptInput.tsx:253). relative is set only for absolute entries from added folders, so the label shows the path within its own badge-named folder, and primary-root entries keep the previous relative rendering.

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)
  • packages/kilo-vscode/src/kilo-provider/file-search-items.ts
  • packages/kilo-vscode/tests/unit/file-search-items.test.ts

Previous suggestion resolved in 352ec4b6: folder ranking now scores input.relative?.get(p) ?? p, matching the relative basis used for files in file-search-results.ts:96. Primary-root folders fall back to their already-relative path, so single-root behavior is unchanged, and the two new tests cover both the filesystem-prefix and path-prefix-boost consequences.

Previous review (commit 194b3c4)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1

Fix these issues in Kilo Cloud

Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/kilo-vscode/src/kilo-provider/file-search-items.ts 67 Folder rank() still scores the absolute path; the relative basis this PR introduced elsewhere is not applied to folder ranking, so filesystem-prefix matches can promote added-folder folders and the path-branch boost never applies to them
Files Reviewed (12 files)
  • .changeset/multi-root-file-mentions.md
  • packages/kilo-vscode/src/KiloProvider.ts
  • packages/kilo-vscode/src/kilo-provider/file-search-items.ts - 1 issue
  • packages/kilo-vscode/src/kilo-provider/file-search-results.ts
  • packages/kilo-vscode/src/kilo-provider/file-search.ts
  • packages/kilo-vscode/tests/setup/vscode-mock.ts
  • packages/kilo-vscode/tests/unit/file-mention-utils.test.ts
  • packages/kilo-vscode/tests/unit/file-search.test.ts
  • packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
  • packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts
  • packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css
  • packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts

Previous review

Status: No Issues Found | Recommendation: Merge

Files Reviewed (9 files)
  • packages/kilo-vscode/src/KiloProvider.ts
  • packages/kilo-vscode/src/kilo-provider/file-search-items.ts
  • packages/kilo-vscode/src/kilo-provider/file-search.ts
  • packages/kilo-vscode/tests/unit/file-mention-utils.test.ts
  • packages/kilo-vscode/tests/unit/file-search.test.ts
  • packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
  • packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts
  • packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css
  • packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts

Previous review (commit 3661761)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (5 files)
  • packages/kilo-vscode/src/KiloProvider.ts
  • packages/kilo-vscode/src/kilo-provider/file-search-results.ts
  • packages/kilo-vscode/src/kilo-provider/file-search.ts
  • packages/kilo-vscode/tests/unit/file-search.test.ts
  • packages/opencode/test/kilocode/semantic-search.test.ts

Previous review (commit 29fc848)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 1

Fix these issues in Kilo Cloud

Issue Details (click to expand)

WARNING

File Line Issue
packages/kilo-vscode/src/kilo-provider/file-search.ts 172 Multi-root folder cap is applied before ranking
packages/kilo-vscode/src/kilo-provider/file-search.ts 131 One failing extra root drops the whole @ search
packages/kilo-vscode/src/KiloProvider.ts 5103 A rejected ignore-controller init is cached forever

SUGGESTION

File Line Issue
packages/kilo-vscode/src/kilo-provider/file-search.ts 108 Ranking secondary hits on absolute paths skews fuzzy match quality
Files Reviewed (15 files)
  • .changeset/multi-root-file-mentions.md
  • packages/kilo-vscode/src/KiloProvider.ts - 1 issue
  • packages/kilo-vscode/src/kilo-provider/file-search-items.ts
  • packages/kilo-vscode/src/kilo-provider/file-search-results.ts
  • packages/kilo-vscode/src/kilo-provider/file-search.ts - 3 issues
  • packages/kilo-vscode/tests/unit/file-mention-utils.test.ts
  • packages/kilo-vscode/tests/unit/file-search.test.ts
  • packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
  • packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts
  • packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css
  • packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts
  • packages/opencode/src/kilocode/tool/semantic-search-output.ts
  • packages/opencode/src/kilocode/tool/semantic-search.ts
  • packages/opencode/src/kilocode/tool/semantic-search.txt
  • packages/opencode/test/kilocode/tool/semantic-search-output.test.ts

Reviewed by deepseek-v4.1-flash · Input: 0 · Output: 0 · Cached: 0

Review guidance: REVIEW.md from base branch main

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.
@sylwester-liljegren

Copy link
Copy Markdown
Contributor Author

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 mergeFileSearchItems ranks them, via a helper that trims folder entries while leaving files and their order untouched. Regression test constructs exactly that shape: 60 primary folders against one exactly-matching folder in an added root, and asserts the added one survives.

One failing extra root drops the whole @ search — correct on both counts. gather now isolates per-root failures and yields nothing for a root it cannot read, and the workspace-folder lookup is guarded separately, so a result is always posted. Two tests cover it: an added folder whose ignore controller throws EACCES still returns the session's own files, and a throwing roots() still posts a result.

Ranking secondary hits on absolute paths skews fuzzy match quality — correct, and sharper than it first looks. rankOpen drops entries with no path match, so scoring the absolute form changed inclusion, not just order: an open tab in an added folder was surfaced whenever the query happened to match that folder's own path. Ranking now runs on the root-relative path while the absolute path remains what gets inserted, so the attachment boundary is unaffected. score takes an explicit basis, and the name-length, depth, and path-length tiebreaks all use it consistently. I verified the regression test fails against the previous implementation before keeping it.

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 initialize(). The in-flight promise now removes itself from the map on rejection, guarded on identity so it cannot evict a newer entry for the same directory.

CI failure was separate and my miss: packages/opencode/test/kilocode/semantic-search.test.ts asserts semantic_search output, which this PR changes. My earlier grep for consumers hit a result limit and cut that file off, and I only ran the registry tests. Three assertions updated, and index status is now stubbed there so the wording no longer depends on real indexing progress — the failure output showed it picking up a genuine "still building (0%, 0/0 files)" state.

Local verification after the fixes: 270 extension unit tests across the 9 files covering every changed module, plus semantic-search, semantic-search-output, and tool-registry-semantic-import-failure on the CLI side; typecheck, lint, and knip clean in both packages. CI is green.

@marius-kilocode

Copy link
Copy Markdown
Collaborator

Unsure on the implications of this, all of this indexing has been historically tricky and hard to get right.

@sylwester-liljegren

Copy link
Copy Markdown
Contributor Author

@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?

@marius-kilocode

Copy link
Copy Markdown
Collaborator

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.

@sylwester-liljegren

Copy link
Copy Markdown
Contributor Author

@marius-kilocode Are you talking about indexing as in local codebase indexing / semantic_search or the index for searching files using "@"?

@marius-kilocode

Copy link
Copy Markdown
Collaborator

I am talking about [index for searching files using "@"]. I don't mean semantic search.

@marius-kilocode

Copy link
Copy Markdown
Collaborator

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.

@sylwester-liljegren

Copy link
Copy Markdown
Contributor Author

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.

@sylwester-liljegren

Copy link
Copy Markdown
Contributor Author

@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.

Sylwester Liljegren added 2 commits September 4, 2026 13:53
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.
@sylwester-liljegren

sylwester-liljegren commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor Author

@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.

@sylwester-liljegren

Copy link
Copy Markdown
Contributor Author

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 marius-kilocode left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • InstanceContextMiddleware runs InstanceStore.load for 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. That InstanceStore entry has no TTL, it stays until the server exits.
  • Then the LocationServiceMap stack 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@marius-kilocode

Copy link
Copy Markdown
Collaborator

@sylwester-liljegren thanks, the change seems tricky to get right.

@sylwester-liljegren

sylwester-liljegren commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor Author

@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.

Sylwester Liljegren added 2 commits September 20, 2026 19:01
…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.
Sylwester Liljegren added 2 commits September 20, 2026 21:10
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.
@sylwester-liljegren

Copy link
Copy Markdown
Contributor Author

@marius-kilocode Both gaps I flagged when swapping to findFiles are now closed, but one of them ended up somewhere interesting and I'd rather describe it than let it be found in review.

Subsequence matching. Added folders were matched by literal substring, because a glob is what findFiles takes, while your own project is matched by subsequence. That meant the same keystrokes answered a narrower question away from home. A glob's * spans one path segment, so interleaving them — **/*f*m*u* — asks for the query's characters in order within a segment, which is the shape fuzzysort already matches on. fmu now finds file-mention-utils.ts in an added folder. Both the literal and the interleaved glob run, because maxResults 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.

.kilocodeignore, and the asymmetry. findFiles honours files.exclude, search.exclude and .gitignore, but knows nothing of .kilocodeignore. Added-folder results now pass through the same per-directory FileIgnoreController that already filters open tabs, covering the files, the directories derived from them, and the active editor.

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 .kilocodeignore containing secrets/, and both contain secrets/credentials.ts. Type @credentials:

  • In the added folder it never appears. It is filtered out before the dropdown is built.
  • In the session's own project it does appear — find.files never consults .kilocodeignore. The backend reads that file, but ignore-migrator.ts:184-190 turns it into permission: { read: rules, edit: rules } with "deny", which bites at tool-call time, not at search time.
  • Worse, selecting it auto-attaches it, since it is a relative path inside the workspace. Per the comment at file-mention-utils.ts:501-502, attaching "reads its content on the backend through a path that bypasses the permission system, including any prior deny decision for that file". So the ignored file's contents reach the model anyway.

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 getOpenTabPaths filters through validateAccess (KiloProvider.ts:5486), and it is excluded from the editor-context payload too (:5590, :5604). Yet backend search still returns it as a plain file result in the same dropdown. The same file is simultaneously filtered as a tab and unfiltered as a search hit.

The pattern is consistent once you see it: .kilocodeignore is honoured everywhere the extension controls, and nowhere in backend file search. This PR puts added folders on the extension side of that line, where every other extension-side path already sits.

I have deliberately not closed it on the primary root here. That means filtering find.files results, which changes existing single-root behaviour for every user, has nothing to do with multi-root, and would bury a behavioural change inside a mentions PR. The attachment-bypass half is a separate concern again. Happy to open either as a follow-up if you want them scoped.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, so src/a fell through to the incidental clean.includes(query) at rank 3 and tied with folders matching only by coincidence.
  • clean.includes(query) matched on the filesystem prefix, so a query containing dev, 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

Sylwester Liljegren added 3 commits September 21, 2026 14:06
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.
@sylwester-liljegren

Copy link
Copy Markdown
Contributor Author

@marius-kilocode I think now that you may go through this PR one more time before hopefully merging this PR into main.

@marius-kilocode marius-kilocode left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@marius-kilocode
marius-kilocode merged commit 2d6d0a0 into Kilo-Org:main Oct 1, 2026
33 checks passed
@marius-kilocode

Copy link
Copy Markdown
Collaborator

Merged @sylwester-liljegren thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants