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/goofy-candies-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

include folders in git-backed @ mentions
30 changes: 19 additions & 11 deletions apps/kimi-code/src/tui/components/editor/file-mention-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Comment thread
sunjie21 marked this conversation as resolved.

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
Expand Down Expand Up @@ -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<string>,
): AutocompleteItem[] {
const picked = new Set<string>();
const result: string[] = [];
const cap = MAX_SUGGESTIONS_WHEN_EMPTY;
Expand Down Expand Up @@ -205,7 +209,7 @@ function rankForEmptyQuery(files: readonly string[], snapshot: GitSnapshot): Aut
}
}

return result.map(toItem);
return result.map((path) => toItem(path, dirs));
}

/**
Expand All @@ -217,6 +221,7 @@ function rankForQuery(
files: readonly string[],
query: string,
snapshot: GitSnapshot,
dirs: ReadonlySet<string>,
): AutocompleteItem[] {
const lowerQuery = query.toLowerCase();
const scored: Array<{ path: string; cat: number; fuzzyScore: number }> = [];
Expand All @@ -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) => {
Expand All @@ -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<string>): AutocompleteItem {
const isDir = dirs.has(path);
return {
value: `@${path}`,
label: basename(path),
value: isDir ? `@${path}/` : `@${path}`,
label: isDir ? `${basename(path)}/` : basename(path),
description: path,
};
}
26 changes: 23 additions & 3 deletions apps/kimi-code/src/utils/git/git-ls-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, number>;
/** Path → 0-indexed recency rank (earlier = more recent). */
readonly recencyOrder: ReadonlyMap<string, number>;
/** Derived parent directories, sorted and deduped. */
readonly dirs: readonly string[];
}

export interface GitLsFilesCache {
Expand Down Expand Up @@ -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);
Comment thread
sunjie21 marked this conversation as resolved.

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 {
Expand Down Expand Up @@ -187,3 +190,20 @@ function collectRecencyOrder(gitRoot: string, trackedSet: Set<string>): Map<stri
}
return result;
}

function collectDirs(files: readonly string[]): readonly string[] {
const seen = new Set<string>();
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files';

function stubGitCache(
files: string[] | null,
opts: { mtimes?: Record<string, number>; recency?: string[] } = {},
opts: { mtimes?: Record<string, number>; 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])),
};
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/test/utils/git/git-ls-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down