fix(tui): complete @ file mentions across additional workspace roots with fd (#1408)

* fix(tui): complete @ file mentions across additional workspace roots with fd

When additional workspace directories are added via /add-dir, @ file completion fell back to a readdir-based scanner capped at 2000 entries, so deeply nested files in large projects never appeared. Route @ completion through fd across every root instead, keeping the query pushed down to fd and deduplicating by absolute path. The readdir fallback remains for when fd is unavailable.

* fix(tui): preserve per-root full-path fallback for @ mentions

Address review feedback: decide the scoped-versus-full-path fallback per root instead of globally. When one root has the scoped directory but another does not, the latter still runs a whole-tree --full-path search with the original query, so a match that only exists under that root is not hidden just because a sibling root happens to contain the prefix directory.

* fix(tui): fall back to filesystem when fd binary is not executable

Address review: when fdPath is non-null but the binary cannot be spawned (managed fd removed or lost execute permission), @ completion returned null because pi-tui swallows the spawn error into an empty result, so the catch never ran. Probe fd with accessSync(X_OK) before delegating and use the filesystem fallback when it is not executable, while still returning null for genuine no-match results.

* fix(tui): trust bare fd command names when probing executability

Address review: when fd is discovered on the system PATH, detectSystemFdPath returns the bare name (fd/fdfind). accessSync checked that literal string relative to cwd and never searched PATH, so a valid system fd was treated as unavailable and @ completion fell back to the capped scanner. Trust bare names (spawn resolves them via PATH) and only probe absolute/relative paths, which is how the managed fd is referenced and which can go stale.

* chore: remove accidentally committed plan files

* test(pi-tui): stabilize paste-burst test by freezing the clock

The paste-burst heuristic uses an 8ms inter-character interval that a slow or busy CI runner can exceed between synchronous handleInput calls, which resets the burst and lets Enter submit. Freeze Date so the synchronous keystrokes always register as one burst, making the assertion deterministic.

* chore: ignore top-level plan directory
This commit is contained in:
liruifengv 2026-07-06 15:02:02 +08:00 committed by GitHub
parent e6e6dd53ce
commit fc259abdb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 385 additions and 1504 deletions

View file

@ -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 {
@ -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;
@ -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(
@ -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,
@ -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);
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

View file

@ -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';
@ -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',
@ -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 {};');
@ -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'));