Skip to content
Closed
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.
248 changes: 241 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,44 @@ 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_SCAN_PER_DIR = 2000;
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',
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 Preserve explicit mentions inside editor config dirs

When fd is missing in a non-git workdir, an explicit query like @.vscode/settings can never return .vscode/settings.json because the walker drops .vscode before buildFromReadDir can apply the existing query.startsWith('.') hidden-path opt-in. This leaves common project config files unmentionable in exactly the fallback path being added; consider only skipping these dirs for the default menu or allowing them for targeted hidden queries.

Useful? React with 👍 / 👎.

]);

/** 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 +95,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 +120,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 +159,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 +233,157 @@ 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;
Comment thread
zha-ji-tui marked this conversation as resolved.
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 });
Comment thread
zha-ji-tui marked this conversation as resolved.
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 Bound the directory read before materializing entries

Fresh evidence after the previous rebuttal: the current code still calls readdirSync before READDIR_MAX_SCAN_PER_DIR is applied, and Node materializes the whole directory listing into entries at this point. In a non-git workdir without fd, typing @ in a directory with tens of thousands of immediate children can still block the TUI before the later scan budget gets a chance to break; use an incremental/bounded directory read or avoid synchronous full materialization here.

Useful? React with 👍 / 👎.

} catch {
return;
}

// Classify entries into priority buckets so the four-phase
// ordering is preserved while keeping a single per-directory
// scan budget. Without this, the previous multi-pass loops
// would iterate the full entries array up to four times in a
// directory with thousands of children — `readdirSync` itself
// materialises all Dirents before any cap check, and the
// budget counters (files.length / dirsVisited) only guard
// subsequent operations.
const collectFile = (name: string): void => {
const absPath = join(absDir, name);
const relPath = relDir === '' ? name : `${relDir}/${name}`;
try {
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 = (name: string): void => {
if (SKIP_DIRS.has(name)) return;
const absChild = join(absDir, name);
const relChild = relDir === '' ? name : `${relDir}/${name}`;
this.walkDir(absChild, relChild, depth + 1, files, mtimeByPath);
};

const visibleFiles: string[] = [];
const visibleDirs: string[] = [];
const hiddenFiles: string[] = [];
const hiddenDirs: string[] = [];

let scanned = 0;
for (const entry of entries) {
if (scanned >= READDIR_MAX_SCAN_PER_DIR) break;
if (entry.name === '.' || entry.name === '..') continue;
scanned++;

if (entry.name.startsWith('.')) {
if (entry.isDirectory()) {
hiddenDirs.push(entry.name);
} else if (entry.isFile() || entry.isSymbolicLink()) {
hiddenFiles.push(entry.name);
}
} else {
if (entry.isDirectory()) {
visibleDirs.push(entry.name);
} else if (entry.isFile() || entry.isSymbolicLink()) {
visibleFiles.push(entry.name);
}
}
}

// Phase 1: visible files
for (const name of visibleFiles) {
if (files.length >= READDIR_MAX_ENTRIES) break;
collectFile(name);
}

// Phase 2: recurse into visible subdirectories
for (const name of visibleDirs) {
if (files.length >= READDIR_MAX_ENTRIES) break;
if (this.dirsVisited >= READDIR_MAX_ENTRIES) break;
recurseDir(name);
Comment on lines +366 to +369
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 Visit the queried subtree before unrelated directories

For a targeted query such as @src in a non-git workdir without fd, this loop recurses through visible directories in filesystem order, so an earlier sibling like big/ can fill READDIR_MAX_ENTRIES before src/ is ever visited. Because buildFromReadDir only ranks after the snapshot is built, src/keep.ts is absent and @src returns no suggestion even though the path exists; prioritize the queried path component before broad recursion or collect shallow directory matches first.

Useful? React with 👍 / 👎.

}

// Phase 3: hidden files (opt-in via `@.env`-style queries)
for (const name of hiddenFiles) {
if (files.length >= READDIR_MAX_ENTRIES) break;
collectFile(name);
}

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

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