From f9c2003ee1f22cb0d019cd3be0b1875d53c290e4 Mon Sep 17 00:00:00 2001 From: tangyun Date: Mon, 1 Jun 2026 16:48:53 +0800 Subject: [PATCH 1/9] fix(tui): add readdir fallback for @-mention in non-git directories (#266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `fd` is not installed and the work dir is not a git repository (or the git cache is empty), the pi-tui inner provider's `getFuzzyFileSuggestions` returns `[]` (no fd → no candidates), so `@` failed to surface any completions. Add a recursive readdir walker (2s TTL, skips node_modules and other heavy dirs) as a fallback in `FileMentionProvider` so `@` works anywhere. Closes #266 --- .changeset/at-mention-readdir-fallback.md | 5 + .../editor/file-mention-provider.ts | 142 +++++++++++++++++- .../editor/file-mention-provider.test.ts | 85 ++++++++++- 3 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 .changeset/at-mention-readdir-fallback.md diff --git a/.changeset/at-mention-readdir-fallback.md b/.changeset/at-mention-readdir-fallback.md new file mode 100644 index 00000000..b5d4de16 --- /dev/null +++ b/.changeset/at-mention-readdir-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Fix `@` file-mention completion in non-git directories. Previously the autocomplete only surfaced files when `fd` was installed or the working directory was inside a git worktree; the new readdir fallback recursively walks the work dir (with a 2s TTL cache, skipping `node_modules` and other heavy directories) so `@` works anywhere. diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index d2a7d39c..23237ff6 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -30,7 +30,8 @@ * when `fd` is missing AND we're in a git repo. */ -import { basename } from 'node:path'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { basename, join } from 'node:path'; import { CombinedAutocompleteProvider, @@ -46,6 +47,35 @@ 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; + readonly recencyOrder: ReadonlyMap; +} // Mirrors pi-tui's PATH_DELIMITERS. Keeping a local copy so @-detection // stays aligned even if pi-tui extends its set. @@ -53,6 +83,7 @@ const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); export class FileMentionProvider implements AutocompleteProvider { private readonly inner: CombinedAutocompleteProvider; + private readonly readDirWalker: ReadDirWalker; constructor( slashCommands: SlashCommand[], @@ -61,6 +92,7 @@ export class FileMentionProvider implements AutocompleteProvider { private readonly gitCache: GitLsFilesCache, ) { this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath); + this.readDirWalker = new ReadDirWalker(workDir); } async getSuggestions( @@ -87,6 +119,14 @@ export class FileMentionProvider implements AutocompleteProvider { const snapshot = this.gitCache.getSnapshot(); if (snapshot === null || snapshot.files.length === 0) { + // No git snapshot: try a recursive readdir of the work dir as a + // fallback before giving up. This is the non-git-repo case — + // see issue #266. Inner's getFuzzyFileSuggestions is a dead end + // without `fd`, so we own the candidate source here. + const readdirResult = this.buildFromReadDir(atPrefix); + if (readdirResult !== null) { + return { items: readdirResult, prefix: atPrefix }; + } return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } @@ -105,11 +145,33 @@ export class FileMentionProvider implements AutocompleteProvider { // 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). + const readdirResult = this.buildFromReadDir(atPrefix); + if (readdirResult !== null) { + return { items: readdirResult, prefix: atPrefix }; + } 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; + } + return query.length === 0 + ? rankForEmptyQuery(candidates, snapshot) + : rankForQuery(candidates, query, snapshot); + } + applyCompletion( lines: string[], cursorLine: number, @@ -150,6 +212,84 @@ 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 repo (or the git cache + * is empty). 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. + */ +class ReadDirWalker { + private snapshot: ReadDirSnapshot | null = null; + private fetchedAt = 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 { + const files: string[] = []; + const mtimeByPath = new Map(); + 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; + return { files: capped, mtimeByPath, recencyOrder: new Map() }; + } + + private walkDir( + absDir: string, + relDir: string, + depth: number, + files: string[], + mtimeByPath: Map, + ): void { + if (depth > READDIR_MAX_DEPTH) return; + let entries: import('node:fs').Dirent[]; + try { + entries = readdirSync(absDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + // Hidden entries (dotfiles) require explicit query opt-in + // (handled in buildFromReadDir, not here — we always skip them + // at the walk level to keep cost bounded; opt-in re-includes). + if (entry.name.startsWith('.')) continue; + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + const absChild = join(absDir, entry.name); + const relChild = relDir === '' ? entry.name : `${relDir}/${entry.name}`; + this.walkDir(absChild, relChild, depth + 1, files, mtimeByPath); + } else if (entry.isFile()) { + const absPath = join(absDir, entry.name); + const relPath = relDir === '' ? entry.name : `${relDir}/${entry.name}`; + try { + const stat = statSync(absPath); + files.push(relPath); + mtimeByPath.set(relPath, stat.mtimeMs); + } catch { + // File disappeared between readdir and stat — skip it. + } + } + } + } +} + /** * Empty-query ranking: stratified by signal strength. * diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index 496aa4ce..e597ab64 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -1,4 +1,8 @@ -import { describe, it, expect } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files'; @@ -213,3 +217,82 @@ describe('FileMentionProvider — @ prefix detection + git-backed suggestions', expect(result).toBeNull(); }); }); + +describe('FileMentionProvider — readdir fallback when no git cache', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'file-mention-readdir-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('surfaces files recursively when @ is typed in a non-git directory', async () => { + writeFileSync(join(dir, 'a.ts'), ''); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src/b.ts'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('@'); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@a.ts'); + expect(values).toContain('@src/b.ts'); + }); + + it('skips files under blacklisted directories (node_modules, dist, etc.)', async () => { + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src/keep.ts'), ''); + mkdirSync(join(dir, 'node_modules')); + writeFileSync(join(dir, 'node_modules/skip.ts'), ''); + mkdirSync(join(dir, 'dist')); + writeFileSync(join(dir, 'dist/skip.ts'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@src/keep.ts'); + expect(values.some((v) => v.includes('node_modules'))).toBe(false); + expect(values.some((v) => v.includes('/dist/'))).toBe(false); + }); + + it('skips hidden entries (dotfiles, .git/, etc.)', async () => { + writeFileSync(join(dir, 'visible.ts'), ''); + writeFileSync(join(dir, '.hidden.ts'), ''); + mkdirSync(join(dir, '.git')); + writeFileSync(join(dir, '.git/HEAD'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@visible.ts'); + expect(values.some((v) => v.includes('.hidden'))).toBe(false); + expect(values.some((v) => v.includes('.git/'))).toBe(false); + }); + + it('caches the walk result: new files do not appear within the 2s TTL window', async () => { + writeFileSync(join(dir, 'old.ts'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const first = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + const firstValues = first!.items.map((i) => i.value); + expect(firstValues).toContain('@old.ts'); + + // Immediately create a new file and re-query. Within the TTL + // window the walker should return the cached snapshot, so the + // new file must NOT surface until the cache expires. + writeFileSync(join(dir, 'new.ts'), ''); + const second = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + const secondValues = second!.items.map((i) => i.value); + expect(secondValues).toContain('@old.ts'); + expect(secondValues).not.toContain('@new.ts'); + }); +}); From 0f3cc3674aed4447e4d55dba11a58c859dd7430b Mon Sep 17 00:00:00 2001 From: tangyun Date: Mon, 1 Jun 2026 17:05:41 +0800 Subject: [PATCH 2/9] chore(changeset): comply with gen-changesets skill wording Lower bump from minor to patch (bug fix per skill), rewrite wording as a single user-facing sentence without file/class names. --- .changeset/at-mention-readdir-fallback.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/at-mention-readdir-fallback.md b/.changeset/at-mention-readdir-fallback.md index b5d4de16..ea9df9d7 100644 --- a/.changeset/at-mention-readdir-fallback.md +++ b/.changeset/at-mention-readdir-fallback.md @@ -1,5 +1,5 @@ --- -"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code": patch --- -Fix `@` file-mention completion in non-git directories. Previously the autocomplete only surfaced files when `fd` was installed or the working directory was inside a git worktree; the new readdir fallback recursively walks the work dir (with a 2s TTL cache, skipping `node_modules` and other heavy directories) so `@` works anywhere. +`@` file completion now works in non-git directories. From f56582454b8035ab7462d443b4f2e981caae3ead Mon Sep 17 00:00:00 2001 From: tangyun Date: Mon, 1 Jun 2026 17:35:42 +0800 Subject: [PATCH 3/9] fix(tui): tighten readdir fallback behavior for @-mention (#266) Address review feedback on the non-git-directory fallback: - Short-circuit the recursive walk once READDIR_MAX_ENTRIES is reached, so typing @ no longer blocks the TUI on large trees. - Stop dropping dot entries at the walk level so the existing query.startsWith('.') opt-in can surface dotfiles like .env or .github/, matching the git-backed path. - Restrict the fallback to non-git workdirs only, so .gitignored files in a git repo are never exposed via readdir. - Return null from buildFromReadDir when ranking produces no matches, so the menu dismisses instead of showing an empty autocomplete state. Refs PR #268. --- .../editor/file-mention-provider.ts | 57 +++++++++++-------- .../editor/file-mention-provider.test.ts | 45 ++++++++++++++- 2 files changed, 76 insertions(+), 26 deletions(-) diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 23237ff6..8109be28 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -26,8 +26,10 @@ * * 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 { existsSync, readdirSync, statSync } from 'node:fs'; @@ -118,11 +120,10 @@ export class FileMentionProvider implements AutocompleteProvider { } const snapshot = this.gitCache.getSnapshot(); - if (snapshot === null || snapshot.files.length === 0) { - // No git snapshot: try a recursive readdir of the work dir as a - // fallback before giving up. This is the non-git-repo case — - // see issue #266. Inner's getFuzzyFileSuggestions is a dead end - // without `fd`, so we own the candidate source here. + if (snapshot === null) { + // Not in a git repo. 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 }; @@ -142,13 +143,11 @@ 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). - const readdirResult = this.buildFromReadDir(atPrefix); - if (readdirResult !== null) { - return { items: readdirResult, prefix: atPrefix }; - } + // 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 }; @@ -167,9 +166,15 @@ export class FileMentionProvider implements AutocompleteProvider { if (candidates.length === 0) { return null; } - return query.length === 0 - ? rankForEmptyQuery(candidates, snapshot) - : rankForQuery(candidates, query, snapshot); + 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( @@ -214,10 +219,14 @@ function containsDotSegment(path: string): boolean { /** * Recursive readdir of the work dir, used as the @-completion source - * when `fd` is missing and we're not in a git repo (or the git cache - * is empty). 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. + * 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; @@ -259,6 +268,7 @@ class ReadDirWalker { mtimeByPath: Map, ): void { if (depth > READDIR_MAX_DEPTH) return; + if (files.length >= READDIR_MAX_ENTRIES) return; let entries: import('node:fs').Dirent[]; try { entries = readdirSync(absDir, { withFileTypes: true }); @@ -266,10 +276,7 @@ class ReadDirWalker { return; } for (const entry of entries) { - // Hidden entries (dotfiles) require explicit query opt-in - // (handled in buildFromReadDir, not here — we always skip them - // at the walk level to keep cost bounded; opt-in re-includes). - if (entry.name.startsWith('.')) continue; + if (entry.name === '.' || entry.name === '..') continue; if (entry.isDirectory()) { if (SKIP_DIRS.has(entry.name)) continue; const absChild = join(absDir, entry.name); diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index e597ab64..12824cac 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -262,7 +262,11 @@ describe('FileMentionProvider — readdir fallback when no git cache', () => { expect(values.some((v) => v.includes('/dist/'))).toBe(false); }); - it('skips hidden entries (dotfiles, .git/, etc.)', async () => { + it('hides dotfiles by default but keeps .git/ unmentionable', async () => { + // The walker collects all entries (including dotfiles) so the + // opt-in below can surface them. Default filtering happens in + // buildFromReadDir via containsDotSegment, mirroring the git-backed + // path. .git/ is filtered earlier by the SKIP_DIRS set. writeFileSync(join(dir, 'visible.ts'), ''); writeFileSync(join(dir, '.hidden.ts'), ''); mkdirSync(join(dir, '.git')); @@ -278,6 +282,45 @@ describe('FileMentionProvider — readdir fallback when no git cache', () => { expect(values.some((v) => v.includes('.git/'))).toBe(false); }); + it('surfaces dotfiles when the query explicitly opts in (e.g. @.env)', async () => { + writeFileSync(join(dir, '.env'), ''); + writeFileSync(join(dir, 'foo.ts'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@.env'], 0, 5, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@.env'); + expect(values).not.toContain('@foo.ts'); + }); + + it('returns null from getSuggestions when the readdir ranking has no matches', async () => { + // The walker finds foo.ts, but `does-not-exist` matches nothing — + // ranking returns an empty array. buildFromReadDir must turn that + // into null so the editor dismisses the menu instead of showing + // an empty autocomplete state. + writeFileSync(join(dir, 'foo.ts'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@does-not-exist'], 0, 16, { signal: ctrl() }); + + expect(result).toBeNull(); + }); + + it('does not invoke the readdir fallback inside a git repo, even when the snapshot is empty', async () => { + // stubGitCache([]) simulates a git repo with an empty snapshot + // (e.g. a fresh repo with no files). The readdir fallback MUST + // NOT be consulted — otherwise .gitignored paths in a real repo + // could leak through raw readdir. + writeFileSync(join(dir, 'a.ts'), ''); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache([])); + const result = await provider.getSuggestions(['@a'], 0, 2, { signal: ctrl() }); + + expect(result).toBeNull(); + }); + it('caches the walk result: new files do not appear within the 2s TTL window', async () => { writeFileSync(join(dir, 'old.ts'), ''); From cdbd38236691305f144dde509c4752dc12e9c0ce Mon Sep 17 00:00:00 2001 From: ty Date: Mon, 1 Jun 2026 19:23:35 +0800 Subject: [PATCH 4/9] fix(tui): gate readdir fallback on isGitRepo() and cap walkDir loop - Gate the readdir fallback on `gitCache.isGitRepo()` instead of `snapshot === null`, so a transient `git ls-files` failure inside a real git repo does not expose `.gitignore`d files. The previous gate conflated "not a git repo" with "git ls-files failed transiently". - Break out of the per-entry `walkDir` loop once `READDIR_MAX_ENTRIES` is reached, so a single large directory does not `statSync` every remaining file after the cap is filled. The cap was previously only checked on entry to `walkDir`. - Handle the snapshot-null case after the `isGitRepo()` gate to avoid NPE when `getSnapshot()` returns null transiently inside a real git repo. - Add a transient-failure test (isGitRepo=true, getSnapshot=null) and extend `stubGitCache` to decouple `isGitRepo` from snapshot presence. Refs PR #268 codex review (P2 #5, #6). --- .../editor/file-mention-provider.ts | 30 +++++++++++++++---- .../editor/file-mention-provider.test.ts | 29 ++++++++++++++---- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 8109be28..6333f4bf 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -119,17 +119,32 @@ export class FileMentionProvider implements AutocompleteProvider { return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } - const snapshot = this.gitCache.getSnapshot(); - if (snapshot === null) { - // Not in a git repo. Inner's getFuzzyFileSuggestions is a dead - // end without `fd`, so we own the candidate source here. See - // issue #266. + 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) { + // 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); + } const query = atPrefix.slice(1); // strip leading '@' const includeDotDirs = query.startsWith('.'); @@ -276,6 +291,11 @@ class ReadDirWalker { return; } for (const entry of entries) { + // Short-circuit the loop once the cap is reached. The top-of- + // function check guards the recursion entry; this one stops the + // per-entry iteration so a single large directory doesn't + // statSync every remaining file after the cap is filled. + if (files.length >= READDIR_MAX_ENTRIES) break; if (entry.name === '.' || entry.name === '..') continue; if (entry.isDirectory()) { if (SKIP_DIRS.has(entry.name)) continue; diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index 12824cac..f5be1ed1 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -9,7 +9,7 @@ import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files'; function stubGitCache( files: string[] | null, - opts: { mtimes?: Record; recency?: string[] } = {}, + opts: { mtimes?: Record; recency?: string[]; isGitRepo?: boolean } = {}, ): GitLsFilesCache { const snapshot: GitSnapshot | null = files === null @@ -20,7 +20,7 @@ function stubGitCache( recencyOrder: new Map((opts.recency ?? []).map((p, i) => [p, i])), }; return { - isGitRepo: () => files !== null, + isGitRepo: () => opts.isGitRepo ?? files !== null, getSnapshot: () => snapshot, list: () => (files === null ? null : files.slice()), }; @@ -310,9 +310,9 @@ describe('FileMentionProvider — readdir fallback when no git cache', () => { it('does not invoke the readdir fallback inside a git repo, even when the snapshot is empty', async () => { // stubGitCache([]) simulates a git repo with an empty snapshot - // (e.g. a fresh repo with no files). The readdir fallback MUST - // NOT be consulted — otherwise .gitignored paths in a real repo - // could leak through raw readdir. + // (e.g. a fresh repo with no files). Gated on `!isGitRepo()`, the + // readdir fallback MUST NOT be consulted — otherwise .gitignored + // paths in a real repo could leak through raw readdir. writeFileSync(join(dir, 'a.ts'), ''); const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache([])); @@ -321,6 +321,25 @@ describe('FileMentionProvider — readdir fallback when no git cache', () => { expect(result).toBeNull(); }); + it('does not invoke the readdir fallback when git ls-files transiently fails inside a git repo', async () => { + // The transient-failure case the codex review flagged: a real git + // repo whose `git ls-files` invocation returned null. `isGitRepo()` + // is still true (the repo exists), `getSnapshot()` is null (the + // spawn failed). The readdir fallback MUST NOT run — that would + // bypass `.gitignore` and could surface ignored files. + writeFileSync(join(dir, 'a.ts'), ''); + + const provider = new FileMentionProvider( + [], + dir, + NO_FD, + stubGitCache(null, { isGitRepo: true }), + ); + const result = await provider.getSuggestions(['@a'], 0, 2, { signal: ctrl() }); + + expect(result).toBeNull(); + }); + it('caches the walk result: new files do not appear within the 2s TTL window', async () => { writeFileSync(join(dir, 'old.ts'), ''); From 3da0d7f18d8985156af88e3d7363f5a79838ef7b Mon Sep 17 00:00:00 2001 From: ty Date: Mon, 1 Jun 2026 19:56:56 +0800 Subject: [PATCH 5/9] fix(tui): walk visible entries before hidden ones in readdir fallback Process non-dot entries first inside walkDir so a hidden subtree like `.config/` cannot exhaust READDIR_MAX_ENTRIES with hidden paths and push a visible file out of the snapshot. Hidden paths are still collected, just behind visible ones, so opt-in queries (`@.env`, `@.github/`) continue to work. Add a regression test that creates 1 visible file plus 1000 hidden files in `.config/` and verifies the visible file is still surfaced in the default `@` menu. Refs PR #268 codex review. --- .../editor/file-mention-provider.ts | 12 ++++++++- .../editor/file-mention-provider.test.ts | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 6333f4bf..90849d1f 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -290,7 +290,17 @@ class ReadDirWalker { } catch { return; } - for (const entry of entries) { + // Walk visible (non-dot) entries before hidden ones so a hidden + // subtree like `.config/` cannot exhaust READDIR_MAX_ENTRIES with + // hidden paths and push a visible file out of the snapshot. Hidden + // paths are still collected — they fill any remaining capacity, so + // the opt-in `@.env` / `@.github/` queries still work. + const ordered = entries.toSorted((a, b) => { + const aHidden = a.name.startsWith('.') ? 1 : 0; + const bHidden = b.name.startsWith('.') ? 1 : 0; + return aHidden - bHidden; + }); + for (const entry of ordered) { // Short-circuit the loop once the cap is reached. The top-of- // function check guards the recursion entry; this one stops the // per-entry iteration so a single large directory doesn't diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index f5be1ed1..f5736844 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -340,6 +340,33 @@ describe('FileMentionProvider — readdir fallback when no git cache', () => { expect(result).toBeNull(); }); + it('prioritizes visible entries when a hidden subtree exhausts the entry cap', async () => { + // Codex P2 follow-up: a hidden dir like `.config/` with 1000+ + // entries used to fill READDIR_MAX_ENTRIES with hidden paths, so + // a visible root-level file was missing from the default `@` menu. + // The walker now sorts entries to process visible ones first, so + // the visible file makes it into the snapshot even when the cap + // is reached by a hidden subtree. + writeFileSync(join(dir, 'visible.ts'), ''); + mkdirSync(join(dir, '.config')); + for (let i = 0; i < 1000; i += 1) { + writeFileSync(join(dir, '.config', `entry${i}.txt`), ''); + } + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + // The visible file must survive even though the hidden subtree + // alone has more entries than READDIR_MAX_ENTRIES. + expect(values).toContain('@visible.ts'); + // The default filter still strips dot-segments from suggestions, + // so .config/* entries don't surface in the empty-query menu + // regardless of the cap behavior. + expect(values.every((v) => !v.startsWith('@.'))).toBe(true); + }); + it('caches the walk result: new files do not appear within the 2s TTL window', async () => { writeFileSync(join(dir, 'old.ts'), ''); From d9eb8754c8b4a3a04f5aef6717aef0031667a2c9 Mon Sep 17 00:00:00 2001 From: ty Date: Mon, 1 Jun 2026 20:31:11 +0800 Subject: [PATCH 6/9] fix(tui): sort files before directories within the same visibility bucket When the cap fills inside a large subdirectory, sibling files in the same parent directory were left unmentioned because the walker recursed into the first subdirectory depth-first. Add a secondary sort key in walkDir's toSorted comparator: within the same visibility bucket, files sort before directories, so the current directory's files are captured before any subdirectory recursion. Add a regression test that creates a README.md alongside a `big/` subdirectory with 1000 files and verifies README.md is still surfaced via a targeted `@README` query. The targeted query (rather than a bare `@`) is required because rankForEmptyQuery returns only the top 15 by mtime, and README.md has the oldest mtime in the fixture. Refs PR #268 codex review. --- .../editor/file-mention-provider.ts | 18 ++++++++++--- .../editor/file-mention-provider.test.ts | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 90849d1f..8b16f6ba 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -292,13 +292,23 @@ class ReadDirWalker { } // Walk visible (non-dot) entries before hidden ones so a hidden // subtree like `.config/` cannot exhaust READDIR_MAX_ENTRIES with - // hidden paths and push a visible file out of the snapshot. Hidden - // paths are still collected — they fill any remaining capacity, so - // the opt-in `@.env` / `@.github/` queries still work. + // hidden paths and push a visible file out of the snapshot. Within + // the same visibility bucket, files come before directories so a + // single large subdirectory cannot fill the cap and leave sibling + // files in the same parent unmentioned. Hidden paths are still + // collected — they fill any remaining capacity, so the opt-in + // `@.env` / `@.github/` queries still work. const ordered = entries.toSorted((a, b) => { + // Hidden (dot-prefixed) entries sort after visible ones. const aHidden = a.name.startsWith('.') ? 1 : 0; const bHidden = b.name.startsWith('.') ? 1 : 0; - return aHidden - bHidden; + if (aHidden !== bHidden) return aHidden - bHidden; + // Within the same visibility bucket, files sort before + // directories so the current directory's files are captured + // before the cap fills inside a sibling subdirectory. + const aDir = a.isDirectory() ? 1 : 0; + const bDir = b.isDirectory() ? 1 : 0; + return aDir - bDir; }); for (const entry of ordered) { // Short-circuit the loop once the cap is reached. The top-of- diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index f5736844..7b39ea19 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -367,6 +367,33 @@ describe('FileMentionProvider — readdir fallback when no git cache', () => { expect(values.every((v) => !v.startsWith('@.'))).toBe(true); }); + it('captures sibling files before recursing into a large subdirectory', async () => { + // Codex P2 follow-up: the walker used to recurse into the first + // subdirectory depth-first, so a large sibling subdirectory + // could fill READDIR_MAX_ENTRIES and leave sibling files in + // the same parent directory unmentioned. The walker now sorts + // files before subdirectories within the same visibility + // bucket, so the parent file is captured before the cap fills. + writeFileSync(join(dir, 'README.md'), ''); + mkdirSync(join(dir, 'big')); + for (let i = 0; i < 1000; i += 1) { + writeFileSync(join(dir, 'big', `file${i}.ts`), ''); + } + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + // Query specifically for `@README` rather than a bare `@`: the + // empty-query ranking returns only the top 15 by mtime, and + // README.md is the oldest entry (it was created first), so it + // wouldn't surface in the menu even when present in the snapshot. + // A targeted query makes the test directly exercise snapshot + // membership. + const result = await provider.getSuggestions(['@README'], 0, 7, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@README.md'); + }); + it('caches the walk result: new files do not appear within the 2s TTL window', async () => { writeFileSync(join(dir, 'old.ts'), ''); From e48823a294cd38639a681266c013c43c26044db7 Mon Sep 17 00:00:00 2001 From: ty Date: Mon, 1 Jun 2026 20:45:58 +0800 Subject: [PATCH 7/9] fix(tui): process current-directory files before recursing + follow symlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split walkDir's iteration into two phases. Phase 1 collects files in the current directory (regular files and symlinks that resolve to files) before any subdirectory recursion. Phase 2 recurses into subdirectories. This guarantees that a single large sibling subdirectory cannot fill READDIR_MAX_ENTRIES and leave current-directory files — including opt-in hidden files like root-level `.env` — unmentioned. Also fix symlink handling: `Dirent.isFile()` is false for symlinks, so a `real.ts` linked as `link.ts` was silently dropped. The walker now follows symlinks and records the entry if the resolved target is a file. Symlinks to directories are not recursed into (cycle risk) and not added to the snapshot. Add two regression tests: - Root-level `.env` surfaces via `@.env` even when a sibling subdirectory has 1000 files. - `link.ts` (a symlink to `real.ts`) surfaces via `@link` (skipped on Windows when symlink creation requires developer mode). Refs PR #268 codex review. --- .../editor/file-mention-provider.ts | 77 +++++++++++-------- .../editor/file-mention-provider.test.ts | 61 ++++++++++++++- 2 files changed, 106 insertions(+), 32 deletions(-) diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index 8b16f6ba..ed11acf6 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -290,50 +290,65 @@ class ReadDirWalker { } catch { return; } - // Walk visible (non-dot) entries before hidden ones so a hidden - // subtree like `.config/` cannot exhaust READDIR_MAX_ENTRIES with - // hidden paths and push a visible file out of the snapshot. Within - // the same visibility bucket, files come before directories so a - // single large subdirectory cannot fill the cap and leave sibling - // files in the same parent unmentioned. Hidden paths are still - // collected — they fill any remaining capacity, so the opt-in - // `@.env` / `@.github/` queries still work. + // Two-tier priority: + // 1. Visibility — visible (non-dot) entries before hidden ones, + // so a hidden subtree like `.config/` cannot exhaust + // READDIR_MAX_ENTRIES with hidden paths and push a visible + // file out of the snapshot. + // 2. Within each visibility bucket, files before directories — + // so a single large subdirectory cannot fill the cap and + // leave sibling files (including opt-in hidden files like + // `.env` at the root) unmentioned. + // Hidden paths are still collected — they fill any remaining + // capacity, so the opt-in `@.env` / `@.github/` queries still + // work. const ordered = entries.toSorted((a, b) => { - // Hidden (dot-prefixed) entries sort after visible ones. const aHidden = a.name.startsWith('.') ? 1 : 0; const bHidden = b.name.startsWith('.') ? 1 : 0; if (aHidden !== bHidden) return aHidden - bHidden; - // Within the same visibility bucket, files sort before - // directories so the current directory's files are captured - // before the cap fills inside a sibling subdirectory. const aDir = a.isDirectory() ? 1 : 0; const bDir = b.isDirectory() ? 1 : 0; return aDir - bDir; }); + + // Phase 1: collect files in this directory (regular files and + // symlinks that resolve to files) before recursing into any + // subdirectory. Doing this in a separate pass — instead of + // interleaving with directory recursion — guarantees that + // current-directory files are captured first. Symlinks that + // resolve to directories are intentionally NOT recursed into + // (cycle risk) and NOT added to the snapshot. for (const entry of ordered) { - // Short-circuit the loop once the cap is reached. The top-of- - // function check guards the recursion entry; this one stops the - // per-entry iteration so a single large directory doesn't - // statSync every remaining file after the cap is filled. if (files.length >= READDIR_MAX_ENTRIES) break; if (entry.name === '.' || entry.name === '..') continue; - if (entry.isDirectory()) { - if (SKIP_DIRS.has(entry.name)) continue; - const absChild = join(absDir, entry.name); - const relChild = relDir === '' ? entry.name : `${relDir}/${entry.name}`; - this.walkDir(absChild, relChild, depth + 1, files, mtimeByPath); - } else if (entry.isFile()) { - const absPath = join(absDir, entry.name); - const relPath = relDir === '' ? entry.name : `${relDir}/${entry.name}`; - try { - const stat = statSync(absPath); - files.push(relPath); - mtimeByPath.set(relPath, stat.mtimeMs); - } catch { - // File disappeared between readdir and stat — skip it. - } + if (!entry.isFile() && !entry.isSymbolicLink()) continue; + 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 + // add it; if it is a directory or unresolvable we skip. + const stat = statSync(absPath); + if (!stat.isFile()) continue; + files.push(relPath); + mtimeByPath.set(relPath, stat.mtimeMs); + } catch { + // File/link disappeared or unresolvable — skip it. } } + + // Phase 2: recurse into subdirectories. The cap check at the + // top of walkDir guards the recursion entry; the inner check + // at the top of this loop stops further descent once the cap + // is filled. + for (const entry of ordered) { + if (files.length >= READDIR_MAX_ENTRIES) break; + if (entry.name === '.' || entry.name === '..') continue; + if (!entry.isDirectory()) continue; + if (SKIP_DIRS.has(entry.name)) continue; + const absChild = join(absDir, entry.name); + const relChild = relDir === '' ? entry.name : `${relDir}/${entry.name}`; + this.walkDir(absChild, relChild, depth + 1, files, mtimeByPath); + } } } diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index 7b39ea19..37cb189f 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -7,6 +7,23 @@ import { afterEach, beforeEach, describe, it, expect } from 'vitest'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files'; +// Probe whether symlink creation works in this environment. On +// Windows this requires developer mode or admin; on Linux/macOS it +// just works. The symlink test is gated on this so CI without +// symlink support does not get a noisy failure. +let supportsSymlinks = false; +try { + const probeDir = mkdtempSync(join(tmpdir(), 'symlink-probe-')); + try { + symlinkSync('target', join(probeDir, 'link')); + supportsSymlinks = true; + } finally { + rmSync(probeDir, { recursive: true, force: true }); + } +} catch { + supportsSymlinks = false; +} + function stubGitCache( files: string[] | null, opts: { mtimes?: Record; recency?: string[]; isGitRepo?: boolean } = {}, @@ -394,6 +411,48 @@ describe('FileMentionProvider — readdir fallback when no git cache', () => { expect(values).toContain('@README.md'); }); + it('surfaces a root-level hidden file when a visible subdirectory would otherwise exhaust the cap', async () => { + // Codex P2 follow-up: even with the visible-before-hidden sort, + // root-level hidden files like `.env` were still sorted behind + // visible directories. A large visible subdirectory could fill + // READDIR_MAX_ENTRIES and leave `.env` out of the snapshot, + // making `@.env` fail. The walker now processes all files in + // the current directory (visible and hidden) before recursing. + writeFileSync(join(dir, '.env'), ''); + mkdirSync(join(dir, 'big')); + for (let i = 0; i < 1000; i += 1) { + writeFileSync(join(dir, 'big', `file${i}.ts`), ''); + } + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@.env'], 0, 5, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@.env'); + }); + + it.runIf(supportsSymlinks)( + 'follows symlinks to files in the readdir fallback', + async () => { + // Codex P2 follow-up: symlink entries are skipped because + // `Dirent.isFile()` is false for symlinks. A `real.ts` file + // linked as `link.ts` would not surface in @-mention. The + // walker now follows symlinks and records the entry if the + // target is a file. Symlinks to directories are not recursed + // into (cycle risk) and not added to the snapshot. + writeFileSync(join(dir, 'real.ts'), ''); + symlinkSync(join(dir, 'real.ts'), join(dir, 'link.ts')); + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@link'], 0, 5, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@link.ts'); + }, + ); + it('caches the walk result: new files do not appear within the 2s TTL window', async () => { writeFileSync(join(dir, 'old.ts'), ''); From 4950e9da732f54b77830147b0ce50afcc016bf97 Mon Sep 17 00:00:00 2001 From: ty Date: Mon, 1 Jun 2026 21:21:48 +0800 Subject: [PATCH 8/9] fix(tui): four-phase readdir traversal with directory cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure walkDir into four priority-ordered phases: visible files at the current level, recurse into visible subdirectories, hidden files at the current level, recurse into hidden subdirectories. This handles the conflicting requirements that 1000 root dotfiles should not starve a visible subdirectory like `src/` (the directory-heavy-tree case), while a single root-level `.env` should still be reachable via `@.env` even when a sibling visible subdirectory has many files. Add a `dirsVisited` counter on the walker (reset per walk, capped at READDIR_MAX_ENTRIES) so a directory-heavy tree — 10 000 empty subdirs, say — does not synchronously recurse into every one. Both the file cap and the dir cap are checked at the top of each walkDir invocation and at the top of each phase loop. Update the existing 'surfaces a root-level hidden file' test to use 999 files (so the cap leaves exactly one slot for `.env` in phase 3). Add a new test 'captures a visible subdirectory before processing many root dotfiles' for the directory-heavy-tree case. Refs PR #268 codex review. --- .../editor/file-mention-provider.ts | 107 ++++++++++-------- .../editor/file-mention-provider.test.ts | 36 ++++-- 2 files changed, 91 insertions(+), 52 deletions(-) diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index ed11acf6..aab682aa 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -246,6 +246,13 @@ function containsDotSegment(path: string): boolean { 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) {} @@ -263,6 +270,7 @@ class ReadDirWalker { } private walk(): ReadDirSnapshot | null { + this.dirsVisited = 0; const files: string[] = []; const mtimeByPath = new Map(); try { @@ -284,70 +292,79 @@ class ReadDirWalker { ): 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; } - // Two-tier priority: - // 1. Visibility — visible (non-dot) entries before hidden ones, - // so a hidden subtree like `.config/` cannot exhaust - // READDIR_MAX_ENTRIES with hidden paths and push a visible - // file out of the snapshot. - // 2. Within each visibility bucket, files before directories — - // so a single large subdirectory cannot fill the cap and - // leave sibling files (including opt-in hidden files like - // `.env` at the root) unmentioned. - // Hidden paths are still collected — they fill any remaining - // capacity, so the opt-in `@.env` / `@.github/` queries still - // work. - const ordered = entries.toSorted((a, b) => { - const aHidden = a.name.startsWith('.') ? 1 : 0; - const bHidden = b.name.startsWith('.') ? 1 : 0; - if (aHidden !== bHidden) return aHidden - bHidden; - const aDir = a.isDirectory() ? 1 : 0; - const bDir = b.isDirectory() ? 1 : 0; - return aDir - bDir; - }); - - // Phase 1: collect files in this directory (regular files and - // symlinks that resolve to files) before recursing into any - // subdirectory. Doing this in a separate pass — instead of - // interleaving with directory recursion — guarantees that - // current-directory files are captured first. Symlinks that - // resolve to directories are intentionally NOT recursed into - // (cycle risk) and NOT added to the snapshot. - for (const entry of ordered) { - if (files.length >= READDIR_MAX_ENTRIES) break; - if (entry.name === '.' || entry.name === '..') continue; - if (!entry.isFile() && !entry.isSymbolicLink()) continue; + // 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 - // add it; if it is a directory or unresolvable we skip. + // statSync follows symlinks; if the target is a file we + // record it, otherwise we skip. const stat = statSync(absPath); - if (!stat.isFile()) continue; + if (!stat.isFile()) return; files.push(relPath); mtimeByPath.set(relPath, stat.mtimeMs); } catch { // File/link disappeared or unresolvable — skip it. } - } + }; - // Phase 2: recurse into subdirectories. The cap check at the - // top of walkDir guards the recursion entry; the inner check - // at the top of this loop stops further descent once the cap - // is filled. - for (const entry of ordered) { - if (files.length >= READDIR_MAX_ENTRIES) break; - if (entry.name === '.' || entry.name === '..') continue; - if (!entry.isDirectory()) continue; - if (SKIP_DIRS.has(entry.name)) continue; + 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 (entry.name.startsWith('.')) continue; + collectFile(entry); + } + + // 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 (!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); } } } diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index 37cb189f..9a17078e 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -412,15 +412,16 @@ describe('FileMentionProvider — readdir fallback when no git cache', () => { }); it('surfaces a root-level hidden file when a visible subdirectory would otherwise exhaust the cap', async () => { - // Codex P2 follow-up: even with the visible-before-hidden sort, - // root-level hidden files like `.env` were still sorted behind - // visible directories. A large visible subdirectory could fill - // READDIR_MAX_ENTRIES and leave `.env` out of the snapshot, - // making `@.env` fail. The walker now processes all files in - // the current directory (visible and hidden) before recursing. + // Codex P2 follow-up: with the four-phase traversal, root-level + // hidden files like `.env` are still collected because phase 2 + // (recurse into visible dirs) is bounded by the cap. `big/` + // can fill READDIR_MAX_ENTRIES - 1 slots, leaving exactly one + // slot for `.env` in phase 3. (A `big/` with exactly + // READDIR_MAX_ENTRIES files would fill the cap and starve + // `.env` — the cap edge case is a known trade-off.) writeFileSync(join(dir, '.env'), ''); mkdirSync(join(dir, 'big')); - for (let i = 0; i < 1000; i += 1) { + for (let i = 0; i < 999; i += 1) { writeFileSync(join(dir, 'big', `file${i}.ts`), ''); } @@ -432,6 +433,27 @@ describe('FileMentionProvider — readdir fallback when no git cache', () => { expect(values).toContain('@.env'); }); + it('captures a visible subdirectory before processing many root dotfiles', async () => { + // Codex P2 follow-up: with the four-phase traversal, phase 2 + // (recurse into visible subdirectories) runs before phase 3 + // (collect hidden files). So 1000 root dotfiles cannot starve + // a visible subdirectory like `src/` — the src/* entries are + // captured first, and only then are the dotfiles enumerated + // to fill the remaining cap. + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src', 'keep.ts'), ''); + for (let i = 0; i < 1000; i += 1) { + writeFileSync(join(dir, `.dot${i}.ts`), ''); + } + + const provider = new FileMentionProvider([], dir, NO_FD, stubGitCache(null)); + const result = await provider.getSuggestions(['@src'], 0, 4, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((i) => i.value); + expect(values).toContain('@src/keep.ts'); + }); + it.runIf(supportsSymlinks)( 'follows symlinks to files in the readdir fallback', async () => { From 5b813e63abcfa22a9c22e69bf677a29fc9292bdd Mon Sep 17 00:00:00 2001 From: ty Date: Mon, 1 Jun 2026 22:50:13 +0800 Subject: [PATCH 9/9] fix(tui): add dirsVisited budget check to Phase 1 and Phase 3 of readdir walker Phase 2 and Phase 4 already check dirsVisited >= READDIR_MAX_ENTRIES before iterating entries, but Phase 1 (visible files) and Phase 3 (hidden files) did not. In a directory-heavy tree with few files, Phase 1 would scan every Dirent before the cap was checked in Phase 2, making the traversal budget ineffective for that common case. Refs PR #268 codex review (commit 4950e9d). --- .../src/tui/components/editor/file-mention-provider.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index aab682aa..630e3200 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -340,6 +340,7 @@ class ReadDirWalker { // 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); } @@ -355,6 +356,7 @@ class ReadDirWalker { // 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); }