Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/at-mention-readdir-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

`@` file completion now works in non-git directories.
235 changes: 228 additions & 7 deletions apps/kimi-code/src/tui/components/editor/file-mention-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@
*
* When `fd` is available the inner pi-tui provider owns the `@` branch
* verbatim — its fd invocation respects `.gitignore` and is strictly
* better than anything we can cheaply reproduce in TS. We only kick in
* when `fd` is missing AND we're in a git repo.
* better than anything we can cheaply reproduce in TS. When `fd` is
* missing, we only fall back to our own recursive readdir when the
* work dir is not a git repository; inside a git repo we trust the
* `git ls-files` snapshot to honor `.gitignore`.
*/

import { basename } from 'node:path';
import { existsSync, readdirSync, statSync } from 'node:fs';
import { basename, join } from 'node:path';

import {
CombinedAutocompleteProvider,
Expand All @@ -46,13 +49,43 @@ import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files';

const MAX_SUGGESTIONS_WHEN_QUERY = 50;
const MAX_SUGGESTIONS_WHEN_EMPTY = 15;
const READDIR_TTL_MS = 2000;
const READDIR_MAX_ENTRIES = 1000;
const READDIR_MAX_DEPTH = 8;

// Directories that are typically too large or too auto-generated to be
// useful for @-completion. Skipping them keeps the walk snappy on
// real-world repos that don't have fd or git.
const SKIP_DIRS = new Set([
'.git',
'node_modules',
'dist',
'build',
'.next',
'.turbo',
'.parcel-cache',
'.cache',
'__pycache__',
'.venv',
'target',
'.idea',
'.vscode',
]);

/** Structurally compatible with `GitSnapshot` so existing rankers accept it. */
interface ReadDirSnapshot {
readonly files: readonly string[];
readonly mtimeByPath: ReadonlyMap<string, number>;
readonly recencyOrder: ReadonlyMap<string, number>;
}

// Mirrors pi-tui's PATH_DELIMITERS. Keeping a local copy so @-detection
// stays aligned even if pi-tui extends its set.
const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']);

export class FileMentionProvider implements AutocompleteProvider {
private readonly inner: CombinedAutocompleteProvider;
private readonly readDirWalker: ReadDirWalker;

constructor(
slashCommands: SlashCommand[],
Expand All @@ -61,6 +94,7 @@ export class FileMentionProvider implements AutocompleteProvider {
private readonly gitCache: GitLsFilesCache,
) {
this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath);
this.readDirWalker = new ReadDirWalker(workDir);
}

async getSuggestions(
Expand All @@ -85,8 +119,30 @@ export class FileMentionProvider implements AutocompleteProvider {
return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
}

if (!this.gitCache.isGitRepo()) {
// Not in a git repo (stable for the cache's lifetime — `isGitRepo`
// is captured at TUI startup by `git rev-parse --show-toplevel`).
// Transient `git ls-files` failures inside a real repo leave
// `getSnapshot()` returning null but `isGitRepo()` still true, in
// which case we deliberately do NOT fall back to raw readdir
// (that would bypass `.gitignore`). Inner's getFuzzyFileSuggestions
// is a dead end without `fd`, so we own the candidate source here.
// See issue #266.
const readdirResult = this.buildFromReadDir(atPrefix);
if (readdirResult !== null) {
return { items: readdirResult, prefix: atPrefix };
}
return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
}
const snapshot = this.gitCache.getSnapshot();
if (snapshot === null || snapshot.files.length === 0) {
if (snapshot === null) {
// Inside a git repo but the snapshot fetch failed transiently
// (e.g. `git ls-files` returned non-zero, lock contention, or
// the index mtime lookup raced). Don't consult raw readdir —
// it would bypass `.gitignore` and could surface ignored files.
// Fall through to the inner provider, which can still resolve
// `/path` or quoted-path completions; on failure it returns
// null and the editor dismisses the menu.
return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
}

Expand All @@ -102,14 +158,40 @@ export class FileMentionProvider implements AutocompleteProvider {
: rankForQuery(candidates, query, snapshot);

if (items.length === 0) {
// Git cache had nothing useful — fall through to readdir (user
// may be typing a path that exists but isn't tracked, e.g. a
// freshly created file not yet in the 2s cache).
// Git ls-files had no match for this query. Inside a git repo we
// do NOT consult readdir — a recursive readdir would bypass
// `git ls-files --exclude-standard` and could surface
// .gitignored paths. Fall through to the inner provider, which
// can still resolve `/path` or quoted-path completions.
return this.inner.getSuggestions(lines, cursorLine, cursorCol, options);
}
return { items, prefix: atPrefix };
}

private buildFromReadDir(atPrefix: string): AutocompleteItem[] | null {
const snapshot = this.readDirWalker.getSnapshot();
if (snapshot === null || snapshot.files.length === 0) {
return null;
}
const query = atPrefix.slice(1);
const includeDotDirs = query.startsWith('.');
const candidates = includeDotDirs
? snapshot.files
: snapshot.files.filter((p) => !containsDotSegment(p));
if (candidates.length === 0) {
return null;
}
const ranked =
query.length === 0
? rankForEmptyQuery(candidates, snapshot)
: rankForQuery(candidates, query, snapshot);
// An empty ranking means the walker saw files but none matched the
// query. Returning `null` (rather than `{ items: [] }`) lets the
// caller dismiss the autocomplete menu instead of presenting an
// empty state.
return ranked.length === 0 ? null : ranked;
}

applyCompletion(
lines: string[],
cursorLine: number,
Expand Down Expand Up @@ -150,6 +232,145 @@ function containsDotSegment(path: string): boolean {
return false;
}

/**
* Recursive readdir of the work dir, used as the @-completion source
* when `fd` is missing and we're not in a git repository. Caches the
* result for `READDIR_TTL_MS` to keep keystroke latency low. Skips
* well-known build/dependency directories so a `node_modules`-laden
* repo still walks in under ~50ms.
*
* The walker collects dot entries too (so callers can opt in via
* `@.env` / `@.github/`); the actual dot-filtering is the caller's
* responsibility, mirroring the git-backed path.
*/
class ReadDirWalker {
private snapshot: ReadDirSnapshot | null = null;
private fetchedAt = 0;
// Cap on the number of directories visited during a single
// walk. Without this, a directory-heavy tree (e.g. 10 000 empty
// subdirectories) would synchronously recurse into every one
// even though the file cap is never hit. Reset at the start of
// each walk so the snapshot TTL does not leak the counter
// across refreshes.
private dirsVisited = 0;

constructor(private readonly workDir: string) {}

getSnapshot(): ReadDirSnapshot | null {
if (!existsSync(this.workDir)) return null;
const now = Date.now();
if (this.snapshot !== null && now - this.fetchedAt < READDIR_TTL_MS) {
return this.snapshot;
}
const next = this.walk();
if (next === null) return null;
this.snapshot = next;
this.fetchedAt = now;
return next;
}

private walk(): ReadDirSnapshot | null {
this.dirsVisited = 0;
const files: string[] = [];
const mtimeByPath = new Map<string, number>();
try {
this.walkDir(this.workDir, '', 0, files, mtimeByPath);
} catch {
return null;
}
files.sort();
const capped = files.length > READDIR_MAX_ENTRIES ? files.slice(0, READDIR_MAX_ENTRIES) : files;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop the walk when the entry cap is reached

In a non-git workdir without fd, the new fallback still synchronously recurses and statSyncs every eligible file before this slice is applied. For large directories, typing @ can block the TUI for the full traversal even though READDIR_MAX_ENTRIES suggests the work is capped; the walker should short-circuit once the cap is reached instead of only truncating the completed list.

Useful? React with 👍 / 👎.

return { files: capped, mtimeByPath, recencyOrder: new Map() };
}

private walkDir(
absDir: string,
relDir: string,
depth: number,
files: string[],
mtimeByPath: Map<string, number>,
): void {
if (depth > READDIR_MAX_DEPTH) return;
if (files.length >= READDIR_MAX_ENTRIES) return;
if (this.dirsVisited >= READDIR_MAX_ENTRIES) return;
this.dirsVisited++;
let entries: import('node:fs').Dirent[];
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
return;
}
// Four-phase traversal in priority order so a single large
// directory of any kind (visible or hidden) cannot fill
// READDIR_MAX_ENTRIES and starve entries of other types:
// 1. Visible files at this level — captured first.
// 2. Recurse into visible subdirectories.
// 3. Hidden files at this level — captured after visible
// dirs so an explicit `@.env` query isn't starved by
// a sibling large visible dir.
// 4. Recurse into hidden subdirectories.
// Hidden paths are still collected, so the opt-in `@.env` /
// `@.github/` queries still work.
const collectFile = (entry: import('node:fs').Dirent): void => {
if (entry.name === '.' || entry.name === '..') return;
if (!entry.isFile() && !entry.isSymbolicLink()) return;
const absPath = join(absDir, entry.name);
const relPath = relDir === '' ? entry.name : `${relDir}/${entry.name}`;
try {
// statSync follows symlinks; if the target is a file we
// record it, otherwise we skip.
const stat = statSync(absPath);
if (!stat.isFile()) return;
files.push(relPath);
mtimeByPath.set(relPath, stat.mtimeMs);
} catch {
// File/link disappeared or unresolvable — skip it.
}
};

const recurseDir = (entry: import('node:fs').Dirent): void => {
if (entry.name === '.' || entry.name === '..') return;
if (!entry.isDirectory()) return;
if (SKIP_DIRS.has(entry.name)) return;
const absChild = join(absDir, entry.name);
const relChild = relDir === '' ? entry.name : `${relDir}/${entry.name}`;
this.walkDir(absChild, relChild, depth + 1, files, mtimeByPath);
};

// Phase 1: visible files at this level
for (const entry of entries) {
if (files.length >= READDIR_MAX_ENTRIES) break;
if (this.dirsVisited >= READDIR_MAX_ENTRIES) break;
if (entry.name.startsWith('.')) continue;
collectFile(entry);
Comment on lines +341 to +345
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Budget every directory entry before the file pass

In a non-git workdir without fd that contains many subdirectories but few files, this first pass still scans every Dirent in the directory before the dirsVisited cap is checked in phase 2. Since collectFile just returns for directories, a root with thousands of folders can still synchronously iterate the whole listing on each cache refresh when the user types @, so the traversal budget does not actually bound this common directory-heavy case; consider applying an entry/iteration budget before the four passes or using an incremental directory read.

Useful? React with 👍 / 👎.

}

// Phase 2: recurse into visible subdirectories
for (const entry of entries) {
if (files.length >= READDIR_MAX_ENTRIES) break;
if (this.dirsVisited >= READDIR_MAX_ENTRIES) break;
if (entry.name.startsWith('.')) continue;
recurseDir(entry);
}

// Phase 3: hidden files at this level
for (const entry of entries) {
if (files.length >= READDIR_MAX_ENTRIES) break;
if (this.dirsVisited >= READDIR_MAX_ENTRIES) break;
if (!entry.name.startsWith('.')) continue;
collectFile(entry);
}

// Phase 4: recurse into hidden subdirectories
for (const entry of entries) {
if (files.length >= READDIR_MAX_ENTRIES) break;
if (this.dirsVisited >= READDIR_MAX_ENTRIES) break;
if (!entry.name.startsWith('.')) continue;
recurseDir(entry);
}
}
}

/**
* Empty-query ranking: stratified by signal strength.
*
Expand Down
Loading