Skip to content
5 changes: 5 additions & 0 deletions .changeset/cli-multi-root-mention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix `@` file completion missing deeply nested files in large projects after adding extra workspace directories.
5 changes: 5 additions & 0 deletions .changeset/pi-tui-multi-root-fd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---

Support searching multiple workspace roots for `@` file completion through fd, deduplicated by absolute path.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ docker-compose.yml
docs/superpowers/
reports/
.superpowers/
/plan/
39 changes: 29 additions & 10 deletions apps/kimi-code/src/tui/components/editor/file-mention-provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readdirSync, statSync } from 'node:fs';
import { accessSync, constants as fsConstants, readdirSync, statSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';

import {
Expand Down Expand Up @@ -27,13 +27,13 @@ interface FsMentionCandidate {
/**
* Kimi wrapper around pi-tui's combined autocomplete provider.
*
* File / folder mention behavior uses pi-tui's fd-backed provider when fd is
* available and only the current working directory is involved. While managed fd
* is downloading, when it is unavailable, or when the session has additional
* roots, a small filesystem fallback keeps `@` file and folder completion usable
* across every root. Ordinary path completion is still handled by pi-tui's
* readdir-backed path completer. This wrapper also keeps Kimi-specific
* slash-command guards.
* File / folder mention behavior uses pi-tui's fd-backed provider whenever fd
* is available, fanning out across the working directory and any additional
* roots so `@` completion pushes the query down to fd instead of enumerating
* every file. A small filesystem fallback is used only while managed fd is
* downloading, when it is unavailable, or if fd fails to spawn. Ordinary path
* completion is still handled by pi-tui's readdir-backed path completer. This
* wrapper also keeps Kimi-specific slash-command guards.
*/
export class FileMentionProvider implements AutocompleteProvider {
private readonly inner: CombinedAutocompleteProvider;
Expand All @@ -56,7 +56,7 @@ export class FileMentionProvider implements AutocompleteProvider {
expanded.push({ ...cmd, name: alias });
}
}
this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath);
this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath, this.additionalDirs);
}

async getSuggestions(
Expand All @@ -75,7 +75,11 @@ export class FileMentionProvider implements AutocompleteProvider {
// runs, so the file list never opens.
const atPrefix = extractAtPrefix(textBeforeCursor);
if (atPrefix !== null) {
if (this.fdPath === null || this.additionalDirs.length > 0) {
// fd backs `@` completion across every root (cwd + additional dirs). Fall
// back to the filesystem scanner when fd is unavailable, not executable
// (e.g. the managed binary was removed or lost execute permission), or if
// spawning it fails below. A genuine fd no-match still returns null.
if (this.fdPath === null || !isExecutableFd(this.fdPath)) {
return getFsMentionSuggestions(
this.workDir,
this.additionalDirs,
Expand Down Expand Up @@ -227,6 +231,21 @@ export function extractAtPrefix(text: string): string | null {
return text.slice(tokenStart);
}

function isExecutableFd(fdPath: string): boolean {
// Bare command names (for example "fd" discovered on the system PATH) are
// trusted: spawn resolves them through PATH. Only absolute/relative paths are
// probed, which is how the managed fd is referenced and which can go stale.
if (!fdPath.includes('/') && !fdPath.includes('\\')) {
return true;
}
try {
accessSync(fdPath, fsConstants.X_OK);
Comment thread
liruifengv marked this conversation as resolved.
return true;
} catch {
return false;
}
}

/**
* Match the `/add-dir` directory completer, which skips every entry whose name
* starts with `.` (see registry.ts). pi-tui's path completer sets `label` to
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand All @@ -11,6 +12,17 @@ function ctrl(): AbortSignal {
}

const NO_FD = null;

function resolveFdPath(): string | null {
const command = process.platform === 'win32' ? 'where' : 'which';
const result = spawnSync(command, ['fd'], { encoding: 'utf-8' });
if (result.status !== 0 || !result.stdout) return null;
const firstLine = result.stdout.split(/\r?\n/).find(Boolean);
return firstLine ? firstLine.trim() : null;
}

const FD_PATH = resolveFdPath();
const IS_FD_INSTALLED = Boolean(FD_PATH);
const GOAL_COMMAND = {
name: 'goal',
description: 'Start or manage a goal',
Expand Down Expand Up @@ -298,6 +310,56 @@ describe('FileMentionProvider', () => {
);
});

it.runIf(IS_FD_INSTALLED)(
'uses fd for additionalDirs even when cwd is large enough to exhaust the fallback scanner',
async () => {
// Fill cwd with enough entries to push the filesystem fallback past its
// 2000-entry scan cap, so it would never reach the additional root. fd
// searches each root independently and still finds the deep target.
for (let i = 0; i < 2000; i++) {
writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};');
}
const extraDir = createExtraDir();
mkdirSync(join(extraDir, 'deep'), { recursive: true });
writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};');
const provider = new FileMentionProvider([], workDir, FD_PATH!, [extraDir]);

const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, {
signal: ctrl(),
});

expect(result).not.toBeNull();
expect(result!.items.map((item) => item.value)).toContain(
`@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`,
);
},
);

it.runIf(IS_FD_INSTALLED)(
'treats a bare fd command name as executable and resolves it via PATH',
async () => {
// A bare "fd" (system PATH lookup) must not be mistaken for unavailable;
// otherwise the large cwd would push the fallback scanner past its cap
// and hide the deep target in the additional root.
for (let i = 0; i < 2000; i++) {
writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};');
}
const extraDir = createExtraDir();
mkdirSync(join(extraDir, 'deep'), { recursive: true });
writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};');
const provider = new FileMentionProvider([], workDir, 'fd', [extraDir]);

const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, {
signal: ctrl(),
});

expect(result).not.toBeNull();
expect(result!.items.map((item) => item.value)).toContain(
`@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`,
);
},
);

it('keeps cwd @ mention values relative and additionalDir values absolute', async () => {
mkdirSync(join(workDir, 'src'), { recursive: true });
writeFileSync(join(workDir, 'src', 'Cwd.ts'), 'export {};');
Expand Down Expand Up @@ -332,14 +394,19 @@ describe('FileMentionProvider', () => {
expect(overlapItems).toHaveLength(1);
});

it('does not bypass fd filtering with filesystem suggestions when fd returns no matches', async () => {
writeFileSync(join(workDir, 'README.md'), 'readme');
const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd'));
it.runIf(IS_FD_INSTALLED)(
'does not bypass fd filtering with filesystem suggestions when fd returns no matches',
async () => {
writeFileSync(join(workDir, 'README.md'), 'readme');
const provider = new FileMentionProvider([], workDir, FD_PATH!);

const result = await provider.getSuggestions(['@read'], 0, 5, { signal: ctrl() });
const result = await provider.getSuggestions(['@zzz-no-match-xyz'], 0, '@zzz-no-match-xyz'.length, {
signal: ctrl(),
});

expect(result).toBeNull();
});
expect(result).toBeNull();
},
);

it('filesystem fallback returns folders and excludes .git', async () => {
mkdirSync(join(workDir, 'src'));
Expand Down
Loading
Loading