diff --git a/.changeset/goofy-candies-follow.md b/.changeset/goofy-candies-follow.md new file mode 100644 index 00000000..cc98a791 --- /dev/null +++ b/.changeset/goofy-candies-follow.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +include folders in git-backed @ mentions 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..9ef924ea 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 @@ -93,13 +93,13 @@ export class FileMentionProvider implements AutocompleteProvider { const query = atPrefix.slice(1); // strip leading '@' const includeDotDirs = query.startsWith('.'); const candidates = includeDotDirs - ? snapshot.files - : snapshot.files.filter((p) => !containsDotSegment(p)); + ? [...snapshot.files, ...snapshot.dirs] + : [...snapshot.files, ...snapshot.dirs].filter((p) => !containsDotSegment(p)); const items = query.length === 0 - ? rankForEmptyQuery(candidates, snapshot) - : rankForQuery(candidates, query, snapshot); + ? rankForEmptyQuery(candidates, snapshot, new Set(snapshot.dirs)) + : rankForQuery(candidates, query, snapshot, new Set(snapshot.dirs)); if (items.length === 0) { // Git cache had nothing useful — fall through to readdir (user @@ -165,7 +165,11 @@ function containsDotSegment(path: string): boolean { * Cap at MAX_SUGGESTIONS_WHEN_EMPTY. Layers fill in order; dedup by * path so a recently-edited file isn't also listed in layer 2. */ -function rankForEmptyQuery(files: readonly string[], snapshot: GitSnapshot): AutocompleteItem[] { +function rankForEmptyQuery( + files: readonly string[], + snapshot: GitSnapshot, + dirs: ReadonlySet, +): AutocompleteItem[] { const picked = new Set(); const result: string[] = []; const cap = MAX_SUGGESTIONS_WHEN_EMPTY; @@ -205,7 +209,7 @@ function rankForEmptyQuery(files: readonly string[], snapshot: GitSnapshot): Aut } } - return result.map(toItem); + return result.map((path) => toItem(path, dirs)); } /** @@ -217,6 +221,7 @@ function rankForQuery( files: readonly string[], query: string, snapshot: GitSnapshot, + dirs: ReadonlySet, ): AutocompleteItem[] { const lowerQuery = query.toLowerCase(); const scored: Array<{ path: string; cat: number; fuzzyScore: number }> = []; @@ -241,7 +246,7 @@ function rankForQuery( // try it as a last-resort safety net. return fuzzyFilter([...files], query, (p) => p) .slice(0, MAX_SUGGESTIONS_WHEN_QUERY) - .map(toItem); + .map((path) => toItem(path, dirs)); } scored.sort((a, b) => { @@ -260,13 +265,16 @@ function rankForQuery( return a.path.localeCompare(b.path); }); - return scored.slice(0, MAX_SUGGESTIONS_WHEN_QUERY).map((entry) => toItem(entry.path)); + return scored.slice(0, MAX_SUGGESTIONS_WHEN_QUERY).map((entry) => + toItem(entry.path, dirs), + ); } -function toItem(path: string): AutocompleteItem { +function toItem(path: string, dirs: ReadonlySet): AutocompleteItem { + const isDir = dirs.has(path); return { - value: `@${path}`, - label: basename(path), + value: isDir ? `@${path}/` : `@${path}`, + label: isDir ? `${basename(path)}/` : basename(path), description: path, }; } diff --git a/apps/kimi-code/src/utils/git/git-ls-files.ts b/apps/kimi-code/src/utils/git/git-ls-files.ts index a6861328..4902e0e5 100644 --- a/apps/kimi-code/src/utils/git/git-ls-files.ts +++ b/apps/kimi-code/src/utils/git/git-ls-files.ts @@ -5,7 +5,7 @@ * * Tracks three things per snapshot, all refreshed atomically: * - `files` deduped (tracked + untracked-not-ignored), capped at 1000 - * - `mtimeByPath` absolute-path → fs mtime (ms), for recency ranking + * - `mtimeByPath` relative path → fs mtime (ms), for recency ranking * - `recencyOrder` file path → position in recent git history (0-indexed; smaller = more recent) * * Rebuild strategy: 2s TTL plus `.git/index` mtime invalidation so @@ -27,10 +27,12 @@ const RECENT_COMMIT_DEPTH = 200; export interface GitSnapshot { readonly files: readonly string[]; - /** Absolute path → mtime (ms). Missing entries = stat failed. */ + /** Relative path → mtime (ms). Missing entries = stat failed. */ readonly mtimeByPath: ReadonlyMap; /** Path → 0-indexed recency rank (earlier = more recent). */ readonly recencyOrder: ReadonlyMap; + /** Derived parent directories, sorted and deduped. */ + readonly dirs: readonly string[]; } export interface GitLsFilesCache { @@ -110,11 +112,12 @@ function fetchSnapshot(gitRoot: string): GitSnapshot | null { for (const path of untracked) seen.add(path); const merged = [...seen].toSorted(); const files = merged.length > MAX_ENTRIES ? merged.slice(0, MAX_ENTRIES) : merged; + const dirs = collectDirs(files); const mtimeByPath = collectMtimes(gitRoot, files); const recencyOrder = collectRecencyOrder(gitRoot, new Set(files)); - return { files, mtimeByPath, recencyOrder }; + return { files, dirs, mtimeByPath, recencyOrder }; } function runLsFiles(gitRoot: string, args: readonly string[]): string[] | null { @@ -187,3 +190,20 @@ function collectRecencyOrder(gitRoot: string, trackedSet: Set): Map(); + const dirs: string[] = []; + for (const file of files) { + const segments = file.split('/'); + if (segments.length <= 1) continue; + let current = ''; + for (const segment of segments.slice(0, -1)) { + current = current.length === 0 ? segment : `${current}/${segment}`; + if (seen.has(current)) continue; + seen.add(current); + dirs.push(current); + } + } + return dirs; +} 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..b8250418 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 @@ -5,13 +5,14 @@ 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[]; dirs?: string[] } = {}, ): GitLsFilesCache { const snapshot: GitSnapshot | null = files === null ? null : { files, + dirs: opts.dirs ?? [], mtimeByPath: new Map(Object.entries(opts.mtimes ?? {})), recencyOrder: new Map((opts.recency ?? []).map((p, i) => [p, i])), }; @@ -45,6 +46,25 @@ describe('FileMentionProvider — @ prefix detection + git-backed suggestions', expect(result!.items.map((i) => i.value)).toEqual(['@a.ts', '@b.ts', '@src/c.ts']); }); + it('bare @ includes folders derived from files', async () => { + const files = ['src/a.ts', 'src/components/Button.tsx', 'README.md']; + const provider = new FileMentionProvider( + [], + '/repo', + NO_FD, + stubGitCache(files, { dirs: ['src', 'src/components'] }), + ); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); + expect(result).not.toBeNull(); + expect(result!.items.map((i) => i.value)).toEqual([ + '@src/a.ts', + '@src/components/Button.tsx', + '@src/components/', + '@README.md', + '@src/', + ]); + }); + it('ranks basename-prefix > substring > fuzzy', async () => { const files = [ 'docs/readme.md', // basename starts with "read" @@ -205,6 +225,25 @@ describe('FileMentionProvider — @ prefix detection + git-backed suggestions', expect(out.lines[0]).toBe('hey @src/a.ts '); }); + it('applyCompletion preserves trailing slash for directory completion', () => { + const provider = new FileMentionProvider( + [], + '/repo', + NO_FD, + stubGitCache(['src/a.ts'], { dirs: ['src'] }), + ); + const out = provider.applyCompletion( + ['hey @src'], + 0, + 8, + { value: '@src/', label: 'src/' }, + '@src', + ); + // Directory completion should end with '/' so the user can continue + // completing paths inside that folder. + expect(out.lines[0]).toBe('hey @src/'); + }); + it('falls through to inner when the git cache is null (non-git dir)', async () => { const provider = new FileMentionProvider([], '/nonexistent', NO_FD, stubGitCache(null)); // No files visible via readdir either, but it shouldn't throw. diff --git a/apps/kimi-code/test/utils/git/git-ls-files.test.ts b/apps/kimi-code/test/utils/git/git-ls-files.test.ts index d7b193a5..f2048834 100644 --- a/apps/kimi-code/test/utils/git/git-ls-files.test.ts +++ b/apps/kimi-code/test/utils/git/git-ls-files.test.ts @@ -46,6 +46,7 @@ describe('createGitLsFilesCache', () => { expect(snap).not.toBeNull(); expect(snap!.files).toContain('a.ts'); expect(snap!.files).toContain('src/b.ts'); + expect(snap!.dirs).toEqual(['src']); expect(snap!.mtimeByPath.has('a.ts')).toBe(true); expect(snap!.mtimeByPath.get('a.ts')!).toBeGreaterThan(0); expect(cache.getSnapshot()).toBe(snap);