From 21fef914fa6cdb80ac9979c2e4d27392f6d6fff1 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 8 Jun 2026 19:07:04 +0800 Subject: [PATCH 1/5] feat: rework TUI file references --- .changeset/fd-file-mentions.md | 5 + apps/kimi-code/src/constant/app.ts | 1 + .../editor/file-mention-provider.ts | 361 ++++++++---------- apps/kimi-code/src/tui/kimi-tui.ts | 27 +- apps/kimi-code/src/utils/git/git-ls-files.ts | 189 --------- apps/kimi-code/src/utils/paths.ts | 8 + apps/kimi-code/src/utils/process/fd-detect.ts | 207 +++++++++- .../editor/file-mention-provider.test.ts | 265 +++++-------- .../test/utils/git/git-ls-files.test.ts | 113 ------ apps/kimi-code/test/utils/paths.test.ts | 12 + .../test/utils/process/fd-detect.test.ts | 63 +++ docs/en/configuration/data-locations.md | 11 +- docs/en/configuration/env-vars.md | 2 +- docs/en/guides/interaction.md | 4 +- docs/zh/configuration/data-locations.md | 11 +- docs/zh/configuration/env-vars.md | 2 +- docs/zh/guides/interaction.md | 4 +- 17 files changed, 560 insertions(+), 725 deletions(-) create mode 100644 .changeset/fd-file-mentions.md delete mode 100644 apps/kimi-code/src/utils/git/git-ls-files.ts delete mode 100644 apps/kimi-code/test/utils/git/git-ls-files.test.ts create mode 100644 apps/kimi-code/test/utils/process/fd-detect.test.ts diff --git a/.changeset/fd-file-mentions.md b/.changeset/fd-file-mentions.md new file mode 100644 index 0000000000..25eb0d8fd5 --- /dev/null +++ b/.changeset/fd-file-mentions.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Rework file reference completion in the TUI. diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index c7f8b1f0f3..535ec138eb 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -19,6 +19,7 @@ export const KIMI_CODE_HOME_ENV = 'KIMI_CODE_HOME'; export const KIMI_CODE_DATA_DIR_NAME = '.kimi-code'; export const KIMI_CODE_LOG_DIR_NAME = 'logs'; export const KIMI_CODE_UPDATE_DIR_NAME = 'updates'; +export const KIMI_CODE_BIN_DIR_NAME = 'bin'; export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json'; export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json'; export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock'; 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 dee3192d15..d6395f8b37 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 @@ -1,64 +1,39 @@ -/** - * `@file` autocomplete provider for the input box. - * - * pi-tui's `CombinedAutocompleteProvider` handles the mechanical parts - * (extract `@…` prefix, insert completion with the right quoting). This - * wrapper adds kimi-specific ranking + filtering so the default "empty - * `@`" list surfaces files the user actually wants, not alphabetical - * noise from `.agents/skills/*` et al. - * - * Sort order — empty query: - * 1. recently edited (from `git log --name-only`) - * 2. recent fs mtime - * 3. basename alphabetical - * (first 15, not 50 — pi-tui's menu height is ~6-10 lines anyway) - * - * Sort order — non-empty query (strict to fuzzy): - * cat 0: basename starts-with query - * cat 1: basename contains query - * cat 2: fuzzyMatch succeeds on full path - * tie-break within each cat: recency rank → mtime → basename length - * (first 50) - * - * Filter — dot directories are hidden by default. User can opt in by starting the query - * with `.` (e.g. `@.github/`), since those paths rarely need - * completion. - * - * 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. - */ - -import { basename } from 'node:path'; +import { readdirSync, statSync } from 'node:fs'; +import { basename, join } from 'node:path'; import { CombinedAutocompleteProvider, - fuzzyFilter, - fuzzyMatch, type AutocompleteItem, type AutocompleteProvider, type AutocompleteSuggestions, type SlashCommand, } from '@earendil-works/pi-tui'; -import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files'; - -const MAX_SUGGESTIONS_WHEN_QUERY = 50; -const MAX_SUGGESTIONS_WHEN_EMPTY = 15; - -// 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', '"', "'", '=']); +const MAX_FALLBACK_SCAN = 2000; +const MAX_FALLBACK_SUGGESTIONS = 50; +interface FsMentionCandidate { + readonly path: string; + readonly isDirectory: boolean; +} + +/** + * Kimi wrapper around pi-tui's combined autocomplete provider. + * + * File / folder mention behavior uses pi-tui's fd-backed provider when fd is + * available. While managed fd is downloading (or when it is unavailable), a + * small filesystem fallback keeps basic `@` file and folder completion usable. + * Ordinary path completion is still handled by pi-tui's readdir-backed path + * completer. This wrapper also keeps Kimi-specific slash-command guards. + */ export class FileMentionProvider implements AutocompleteProvider { private readonly inner: CombinedAutocompleteProvider; constructor( slashCommands: SlashCommand[], - workDir: string, + private readonly workDir: string, private readonly fdPath: string | null, - private readonly gitCache: GitLsFilesCache, ) { this.inner = new CombinedAutocompleteProvider(slashCommands, workDir, fdPath); } @@ -71,54 +46,37 @@ export class FileMentionProvider implements AutocompleteProvider { ): Promise { const currentLine = lines[cursorLine] ?? ''; const textBeforeCursor = currentLine.slice(0, cursorCol); - const atPrefix = extractAtPrefix(textBeforeCursor); - // Non-`@` branch (slash commands, `/path`, quoted paths) — pi-tui - // already owns the edge cases. We only suppress slash-argument - // completions when accepting one would insert before existing text. - if (atPrefix === null) { - if ( - shouldSuppressSlashArgumentCompletion( - textBeforeCursor, - currentLine.slice(cursorCol), - options.force, - ) - ) { - return null; - } - return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + if (shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force)) { + return null; } - // `fd` available → inner's fuzzy search is strictly better than our - // git fallback (fd respects .gitignore AND covers unstaged paths - // without a second spawn). Accept its output as-is. - if (this.fdPath !== null) { - return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + if ( + shouldSuppressSlashArgumentCompletion( + textBeforeCursor, + currentLine.slice(cursorCol), + options.force, + ) + ) { + return null; } - const snapshot = this.gitCache.getSnapshot(); - if (snapshot === null || snapshot.files.length === 0) { - return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + const atPrefix = extractAtPrefix(textBeforeCursor); + if (atPrefix !== null) { + try { + const innerResult = await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + if (innerResult !== null) return innerResult; + } catch { + // If fd is missing, deleted mid-session, or fails to spawn, keep @ completion usable. + } + return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); } - const query = atPrefix.slice(1); // strip leading '@' - const includeDotDirs = query.startsWith('.'); - const candidates = includeDotDirs - ? snapshot.files - : snapshot.files.filter((p) => !containsDotSegment(p)); - - const items = - query.length === 0 - ? rankForEmptyQuery(candidates, snapshot) - : 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). - return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + try { + return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + } catch { + return null; } - return { items, prefix: atPrefix }; } applyCompletion( @@ -128,19 +86,10 @@ export class FileMentionProvider implements AutocompleteProvider { item: AutocompleteItem, prefix: string, ): { lines: string[]; cursorLine: number; cursorCol: number } { - // Reuse pi-tui's insertion logic — it handles `@` prefix, quoted - // paths, directory trailing slash. Our item shape matches what - // pi-tui produces. return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix); } } -/** - * Return the `@…` token ending at the cursor, or `null` if we're not in - * an `@` mention. Mirrors pi-tui's `extractAtPrefix` — the token - * boundary is the last PATH_DELIMITER before the cursor, and the token - * must start with `@`. - */ function extractAtPrefix(text: string): string | null { let tokenStart = 0; for (let i = text.length - 1; i >= 0; i -= 1) { @@ -153,142 +102,136 @@ function extractAtPrefix(text: string): string | null { return text.slice(tokenStart); } -function shouldSuppressSlashArgumentCompletion( - textBeforeCursor: string, - textAfterCursor: string, - force: boolean | undefined, -): boolean { - if (force === true) return false; - if (!textBeforeCursor.startsWith('/')) return false; - if (!textBeforeCursor.includes(' ')) return false; - return textAfterCursor.trimStart().length > 0; -} +function getFsMentionSuggestions( + workDir: string, + atPrefix: string, + signal: AbortSignal, +): AutocompleteSuggestions | null { + if (signal.aborted) return null; -/** True when any path segment starts with a dot (e.g. `.github/x.yml`). */ -function containsDotSegment(path: string): boolean { - for (const segment of path.split('/')) { - if (segment.startsWith('.')) return true; - } - return false; -} + const query = atPrefix.slice(1); + const candidates = collectFsMentionCandidates(workDir, signal); + if (candidates.length === 0 || signal.aborted) return null; -/** - * Empty-query ranking: stratified by signal strength. - * - * Layer 1: files touched in the last RECENT_COMMIT_DEPTH commits, - * ordered by how recently. Strongest signal — if the user - * just worked on it, they probably want to mention it. - * Layer 2: files with the newest fs mtime (covers uncommitted edits - * and files edited but not yet added to git). - * Layer 3: everything else, alphabetical by basename so - * README/package.json-style top-level files bubble up - * relative to deeply-nested alphabetical paths. - * - * 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[] { - const picked = new Set(); - const result: string[] = []; - const cap = MAX_SUGGESTIONS_WHEN_EMPTY; - const inFiles = new Set(files); - - // Layer 1 — git log recency. - const byRecency = [...snapshot.recencyOrder.entries()] - .filter(([path]) => inFiles.has(path)) - .toSorted((a, b) => a[1] - b[1]); - for (const [path] of byRecency) { - if (result.length >= cap) break; - if (picked.has(path)) continue; - picked.add(path); - result.push(path); - } + const ranked = rankFsMentionCandidates(candidates, query).slice(0, MAX_FALLBACK_SUGGESTIONS); + if (ranked.length === 0) return null; - // Layer 2 — fs mtime. - if (result.length < cap) { - const byMtime = files - .filter((p) => !picked.has(p) && snapshot.mtimeByPath.has(p)) - .toSorted((a, b) => (snapshot.mtimeByPath.get(b) ?? 0) - (snapshot.mtimeByPath.get(a) ?? 0)); - for (const path of byMtime) { - if (result.length >= cap) break; - picked.add(path); - result.push(path); + return { + prefix: atPrefix, + items: ranked.map(toMentionItem), + }; +} + +function collectFsMentionCandidates(workDir: string, signal: AbortSignal): FsMentionCandidate[] { + const result: FsMentionCandidate[] = []; + const stack = ['']; + + while (stack.length > 0 && result.length < MAX_FALLBACK_SCAN) { + if (signal.aborted) break; + const relativeDir = stack.pop() ?? ''; + const absoluteDir = relativeDir.length === 0 ? workDir : join(workDir, relativeDir); + let entries; + try { + entries = readdirSync(absoluteDir, { withFileTypes: true }); + } catch { + continue; } - } - // Layer 3 — alphabetical by basename. - if (result.length < cap) { - const rest = files - .filter((p) => !picked.has(p)) - .toSorted((a, b) => basename(a).localeCompare(basename(b)) || a.localeCompare(b)); - for (const path of rest) { - if (result.length >= cap) break; - result.push(path); + for (const entry of entries) { + if (signal.aborted || result.length >= MAX_FALLBACK_SCAN) break; + if (entry.name === '.git') continue; + + const relativePath = normalizePath(relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name)); + let isDirectory = entry.isDirectory(); + if (!isDirectory && entry.isSymbolicLink()) { + try { + isDirectory = statSync(join(workDir, relativePath)).isDirectory(); + } catch { + // Broken symlink or permission error — keep it as a file candidate. + } + } + + result.push({ path: relativePath, isDirectory }); + if (isDirectory) { + stack.push(relativePath); + } } } - return result.map(toItem); + return result; } -/** - * Non-empty-query ranking: three strictness tiers, with recency / - * mtime as tie-breakers inside each tier so "the readme you just - * edited" beats "a readme deep in a vendor dir". - */ -function rankForQuery( - files: readonly string[], +function rankFsMentionCandidates( + candidates: readonly FsMentionCandidate[], query: string, - snapshot: GitSnapshot, -): AutocompleteItem[] { +): FsMentionCandidate[] { const lowerQuery = query.toLowerCase(); - const scored: Array<{ path: string; cat: number; fuzzyScore: number }> = []; - for (const path of files) { - const base = basename(path).toLowerCase(); - if (base.startsWith(lowerQuery)) { - scored.push({ path, cat: 0, fuzzyScore: 0 }); - continue; - } - if (base.includes(lowerQuery)) { - scored.push({ path, cat: 1, fuzzyScore: 0 }); - continue; - } - const fuzzy = fuzzyMatch(query, path); - if (fuzzy.matches) { - scored.push({ path, cat: 2, fuzzyScore: fuzzy.score }); - } - } + const scored: Array<{ candidate: FsMentionCandidate; score: number }> = []; - if (scored.length === 0) { - // pi-tui's fuzzyFilter is slightly different (token-splitting); - // try it as a last-resort safety net. - return fuzzyFilter([...files], query, (p) => p) - .slice(0, MAX_SUGGESTIONS_WHEN_QUERY) - .map(toItem); + for (const candidate of candidates) { + const score = scoreCandidate(candidate, lowerQuery); + if (score > 0) scored.push({ candidate, score }); } scored.sort((a, b) => { - if (a.cat !== b.cat) return a.cat - b.cat; - if (a.cat === 2 && a.fuzzyScore !== b.fuzzyScore) return a.fuzzyScore - b.fuzzyScore; - const ra = snapshot.recencyOrder.get(a.path); - const rb = snapshot.recencyOrder.get(b.path); - if (ra !== undefined && rb !== undefined && ra !== rb) return ra - rb; - if (ra !== undefined && rb === undefined) return -1; - if (ra === undefined && rb !== undefined) return 1; - const ma = snapshot.mtimeByPath.get(a.path) ?? 0; - const mb = snapshot.mtimeByPath.get(b.path) ?? 0; - if (ma !== mb) return mb - ma; - const baseLenDiff = basename(a.path).length - basename(b.path).length; - if (baseLenDiff !== 0) return baseLenDiff; - return a.path.localeCompare(b.path); + if (a.score !== b.score) return b.score - a.score; + if (a.candidate.isDirectory !== b.candidate.isDirectory) { + return a.candidate.isDirectory ? -1 : 1; + } + return a.candidate.path.localeCompare(b.candidate.path); }); - return scored.slice(0, MAX_SUGGESTIONS_WHEN_QUERY).map((entry) => toItem(entry.path)); + return scored.map((entry) => entry.candidate); +} + +function scoreCandidate(candidate: FsMentionCandidate, lowerQuery: string): number { + if (lowerQuery.length === 0) { + const depthPenalty = candidate.path.split('/').length - 1; + return (candidate.isDirectory ? 120 : 100) - depthPenalty; + } + + const lowerPath = candidate.path.toLowerCase(); + const lowerBase = basename(candidate.path).toLowerCase(); + let score = 0; + if (lowerBase === lowerQuery) score = 100; + else if (lowerBase.startsWith(lowerQuery)) score = 80; + else if (lowerBase.includes(lowerQuery)) score = 50; + else if (lowerPath.includes(lowerQuery)) score = 30; + if (candidate.isDirectory && score > 0) score += 10; + return score; } -function toItem(path: string): AutocompleteItem { +function toMentionItem(candidate: FsMentionCandidate): AutocompleteItem { + const valuePath = candidate.isDirectory ? `${candidate.path}/` : candidate.path; + const value = valuePath.includes(' ') ? `@"${valuePath}"` : `@${valuePath}`; + const label = `${basename(candidate.path)}${candidate.isDirectory ? '/' : ''}`; return { - value: `@${path}`, - label: basename(path), - description: path, + value, + label, + description: valuePath, }; } + +function normalizePath(path: string): string { + return path.replaceAll('\\', '/'); +} + +function shouldSuppressLeadingWhitespaceSlashPath( + textBeforeCursor: string, + force: boolean | undefined, +): boolean { + if (force === true) return false; + if (textBeforeCursor.startsWith('/')) return false; + return textBeforeCursor.trimStart().startsWith('/'); +} + +function shouldSuppressSlashArgumentCompletion( + textBeforeCursor: string, + textAfterCursor: string, + force: boolean | undefined, +): boolean { + if (force === true) return false; + if (!textBeforeCursor.startsWith('/')) return false; + if (!textBeforeCursor.includes(' ')) return false; + return textAfterCursor.trimStart().length > 0; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f162fcc971..ecbd735481 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -26,11 +26,9 @@ import { resolve } from 'pathe'; import type { CLIOptions } from '#/cli/options'; import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; -import type { GitLsFilesCache } from '#/utils/git/git-ls-files'; -import { createGitLsFilesCache } from '#/utils/git/git-ls-files'; import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history'; import { getInputHistoryFile } from '#/utils/paths'; -import { detectFdPath } from '#/utils/process/fd-detect'; +import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect'; import { BUILTIN_SLASH_COMMANDS, @@ -202,8 +200,8 @@ export class KimiTUI { private skillCommands: readonly KimiSlashCommand[] = []; readonly skillCommandMap = new Map(); private readonly imageStore = new ImageAttachmentStore(); - private readonly fdPath: string | null = detectFdPath(); - private readonly gitLsFilesCache: GitLsFilesCache; + private fdPath: string | null = detectFdPath(); + private fdDownloadStarted = false; sessionEventUnsubscribe: (() => void) | undefined; cancelInFlight: (() => void) | undefined; deferUserMessages = false; @@ -272,7 +270,6 @@ export class KimiTUI { this.uninstallRainbowDance = installRainbowDance(() => { this.state.ui.requestRender(); }); - this.gitLsFilesCache = createGitLsFilesCache(tuiOptions.initialAppState.workDir); this.reverseRpcDisposers.push( ...registerReverseRPCHandlers(this.approvalController, this.questionController, { @@ -328,7 +325,6 @@ export class KimiTUI { slashCommands, this.state.appState.workDir, this.fdPath, - this.gitLsFilesCache, ); this.state.editor.setAutocompleteProvider(provider); } @@ -383,6 +379,7 @@ export class KimiTUI { return; } const shouldReplayHistory = await this.initMainTui(); + this.startBackgroundFdAutocomplete(); await this.finishStartup(shouldReplayHistory); } catch (error) { this.disposeTerminalTracking(); @@ -395,6 +392,7 @@ export class KimiTUI { const shouldReplayHistory = await this.initMainTui(); this.startEventLoop(); try { + this.startBackgroundFdAutocomplete(); await this.finishStartup(shouldReplayHistory); } catch (error) { this.disposeTerminalTracking(); @@ -427,6 +425,21 @@ export class KimiTUI { this.refreshTerminalThemeTracking(); } + private startBackgroundFdAutocomplete(): void { + if (this.fdPath !== null || this.fdDownloadStarted) return; + this.fdDownloadStarted = true; + + void ensureFdPath() + .then((fdPath) => { + if (fdPath === null) return; + this.fdPath = fdPath; + this.setupAutocomplete(); + }) + .catch(() => { + // Best-effort background bootstrap: autocomplete keeps using the filesystem fallback. + }); + } + private async refreshProviderModelsInBackground(): Promise { try { const result = await this.authFlow.refreshProviderModels(); diff --git a/apps/kimi-code/src/utils/git/git-ls-files.ts b/apps/kimi-code/src/utils/git/git-ls-files.ts deleted file mode 100644 index a686132846..0000000000 --- a/apps/kimi-code/src/utils/git/git-ls-files.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Git-aware file listing + relevance signals with a short-TTL cache. - * Used as the cross-directory `@file` completion source when `fd` is - * not installed. - * - * 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 - * - `recencyOrder` file path → position in recent git history (0-indexed; smaller = more recent) - * - * Rebuild strategy: 2s TTL plus `.git/index` mtime invalidation so - * rapid edits surface without paying the full spawn+stat cost on every - * keystroke. When outside a git worktree, - * `getSnapshot()` returns `null` and callers fall back further. - */ - -import { spawnSync } from 'node:child_process'; -import { existsSync, statSync } from 'node:fs'; -import { join } from 'node:path'; - -const TTL_MS = 2000; -const MAX_ENTRIES = 1000; -// Number of most-recent commits to scan for "recently edited" hotness. -// 200 is enough to cover a week of active work in a typical repo while -// staying fast (<100ms even on large repos). -const RECENT_COMMIT_DEPTH = 200; - -export interface GitSnapshot { - readonly files: readonly string[]; - /** Absolute path → mtime (ms). Missing entries = stat failed. */ - readonly mtimeByPath: ReadonlyMap; - /** Path → 0-indexed recency rank (earlier = more recent). */ - readonly recencyOrder: ReadonlyMap; -} - -export interface GitLsFilesCache { - /** Full snapshot, or `null` when the work dir is not a git repo. */ - getSnapshot(): GitSnapshot | null; - /** Convenience shortcut; identical to `getSnapshot()?.files ?? null`. */ - list(): string[] | null; - isGitRepo(): boolean; -} - -interface SnapshotState { - snapshot: GitSnapshot; - fetchedAt: number; - indexMtime: number; -} - -export function createGitLsFilesCache(workDir: string): GitLsFilesCache { - const gitRoot = resolveGitRoot(workDir); - const indexPath = gitRoot === null ? null : join(gitRoot, '.git', 'index'); - let state: SnapshotState | undefined; - - return { - isGitRepo: () => gitRoot !== null, - getSnapshot: () => { - if (gitRoot === null) return null; - - const now = Date.now(); - const currentIndexMtime = indexMtime(indexPath); - const fresh = - state !== undefined && - now - state.fetchedAt < TTL_MS && - state.indexMtime === currentIndexMtime; - if (fresh) return state!.snapshot; - - const snapshot = fetchSnapshot(gitRoot); - if (snapshot === null) return null; // transient git failure — retry next call - - state = { snapshot, fetchedAt: now, indexMtime: currentIndexMtime }; - return snapshot; - }, - list: function listCompat() { - return this.getSnapshot()?.files.slice() ?? null; - }, - }; -} - -function resolveGitRoot(workDir: string): string | null { - try { - const result = spawnSync('git', ['-C', workDir, 'rev-parse', '--show-toplevel'], { - encoding: 'utf8', - }); - if (result.status !== 0) return null; - const stdout = result.stdout.trim(); - return stdout.length > 0 ? stdout : null; - } catch { - return null; - } -} - -function indexMtime(indexPath: string | null): number { - if (indexPath === null || !existsSync(indexPath)) return 0; - try { - return statSync(indexPath).mtimeMs; - } catch { - return 0; - } -} - -function fetchSnapshot(gitRoot: string): GitSnapshot | null { - const tracked = runLsFiles(gitRoot, ['-z']); - if (tracked === null) return null; - const untracked = runLsFiles(gitRoot, ['-z', '--others', '--exclude-standard']); - if (untracked === null) return null; - - const seen = new Set(); - for (const path of tracked) seen.add(path); - 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 mtimeByPath = collectMtimes(gitRoot, files); - const recencyOrder = collectRecencyOrder(gitRoot, new Set(files)); - - return { files, mtimeByPath, recencyOrder }; -} - -function runLsFiles(gitRoot: string, args: readonly string[]): string[] | null { - try { - const result = spawnSync('git', ['-C', gitRoot, 'ls-files', ...args], { - encoding: 'utf8', - maxBuffer: 16 * 1024 * 1024, - }); - if (result.status !== 0) return null; - return result.stdout.split('\0').filter((entry) => entry.length > 0); - } catch { - return null; - } -} - -function collectMtimes(gitRoot: string, files: readonly string[]): Map { - const result = new Map(); - for (const path of files) { - try { - const stat = statSync(join(gitRoot, path)); - result.set(path, stat.mtimeMs); - } catch { - // File was deleted between ls-files and stat, or permission error. - // Missing entry → ranker treats it as "no mtime signal". - } - } - return result; -} - -/** - * Walk the last RECENT_COMMIT_DEPTH commits and record the first time - * each path is seen (a file touched in HEAD wins over a file touched - * 50 commits ago). Runs on the whole repo, not the work dir, so rename - * tracking stays consistent even when the user cd's into a subdir. - * - * `trackedSet` filters out paths that were renamed away / deleted — we - * only care about files that still appear in `ls-files`, since those - * are the ones we could actually complete. - */ -function collectRecencyOrder(gitRoot: string, trackedSet: Set): Map { - const result = new Map(); - try { - const proc = spawnSync( - 'git', - [ - '-C', - gitRoot, - 'log', - `-n`, - String(RECENT_COMMIT_DEPTH), - '--name-only', - '--pretty=format:', - '--no-renames', - ], - { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, - ); - if (proc.status !== 0) return result; - let rank = 0; - for (const raw of proc.stdout.split('\n')) { - const line = raw.trim(); - if (line.length === 0) continue; - if (result.has(line)) continue; // keep the earliest (most recent) occurrence - if (!trackedSet.has(line)) continue; // drop deleted / renamed-away paths - result.set(line, rank); - rank += 1; - } - } catch { - // Fall through with whatever we've collected — an incomplete - // recency map just means fewer entries get a hotness boost. - } - return result; -} diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts index 445f71fa6a..c5e246876e 100644 --- a/apps/kimi-code/src/utils/paths.ts +++ b/apps/kimi-code/src/utils/paths.ts @@ -10,6 +10,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { + KIMI_CODE_BIN_DIR_NAME, KIMI_CODE_DATA_DIR_NAME, KIMI_CODE_HOME_ENV, KIMI_CODE_INPUT_HISTORY_DIR_NAME, @@ -40,6 +41,13 @@ export function getLogDir(): string { return join(getDataDir(), KIMI_CODE_LOG_DIR_NAME); } +/** + * Return the managed tools directory: `/bin/`. + */ +export function getBinDir(): string { + return join(getDataDir(), KIMI_CODE_BIN_DIR_NAME); +} + /** * Return the update cache file: `/updates/latest.json`. */ diff --git a/apps/kimi-code/src/utils/process/fd-detect.ts b/apps/kimi-code/src/utils/process/fd-detect.ts index d8fc662b5b..9b87e12c7e 100644 --- a/apps/kimi-code/src/utils/process/fd-detect.ts +++ b/apps/kimi-code/src/utils/process/fd-detect.ts @@ -1,27 +1,206 @@ -/** - * Probe for the `fd` binary so the pi-tui `CombinedAutocompleteProvider` - * can enable its cross-directory fuzzy file search. - * - * Naming differs across distros: - * - Homebrew / Arch / most Linuxes: `fd` - * - Debian / Ubuntu: `fdfind` - * - * We use `spawnSync(..., { stdio: 'ignore' })` rather than shelling out - * to `which` so the check doesn't depend on the parent shell's PATH - * resolution semantics and stays cheap (~ms) on startup. - */ - +import { createHash } from 'node:crypto'; import { spawnSync } from 'node:child_process'; +import { + chmodSync, + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, +} from 'node:fs'; +import { arch, platform } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { KIMI_CODE_CDN_BASE } from '#/constant/app'; +import { getBinDir } from '#/utils/paths'; const CANDIDATES = ['fd', 'fdfind']; +const FD_BASE_URL = `${KIMI_CODE_CDN_BASE}/fd`; +const DOWNLOAD_TIMEOUT_MS = 120_000; + +const FD_ARCHIVE_SHA256: Record = { + 'fd-v10.4.2-aarch64-apple-darwin.tar.gz': + '623dc0afc81b92e4d4606b380d7bc91916ba7b97814263e554d50923a39e480a', + 'fd-v10.3.0-x86_64-apple-darwin.tar.gz': + '50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3', + 'fd-v10.4.2-aarch64-unknown-linux-gnu.tar.gz': + '6c51f7c5446b3338b1e401ff15dc194c590bb2fa64fd43ff3278300f073adec5', + 'fd-v10.4.2-x86_64-unknown-linux-gnu.tar.gz': + 'def59805cd14b5651b68990855f426ad087f3b96881296d963910431ba3143c8', + 'fd-v10.4.2-aarch64-pc-windows-msvc.zip': + '4f9110c2d5b33a7f760bfa5510f4c113d828109f7277d421b1053a9943c0fc92', + 'fd-v10.4.2-x86_64-pc-windows-msvc.zip': + 'b2816e506390a89941c63c9187d58a3cc10e9a55f2ef0685f9ea0eccaf7c98c8', +}; export function detectFdPath(): string | null { + const managed = getManagedFdPath(); + if (managed !== null) return managed; + return detectSystemFdPath(); +} + +export async function ensureFdPath(): Promise { + const existing = detectFdPath(); + if (existing !== null) return existing; + + try { + return await downloadFd(); + } catch { + return null; + } +} + +function detectSystemFdPath(): string | null { for (const name of CANDIDATES) { try { const result = spawnSync(name, ['--version'], { stdio: 'ignore' }); if (result.status === 0) return name; } catch { - // ENOENT, EACCES, etc. — try next candidate + // ENOENT, EACCES, etc. — try next candidate. + } + } + return null; +} + +function getManagedFdPath(): string | null { + const binaryPath = getManagedFdBinaryPath(); + if (!existsSync(binaryPath)) return null; + try { + const result = spawnSync(binaryPath, ['--version'], { stdio: 'ignore' }); + return result.status === 0 ? binaryPath : null; + } catch { + return null; + } +} + +function getManagedFdBinaryPath(): string { + return join(getBinDir(), platform() === 'win32' ? 'fd.exe' : 'fd'); +} + +export function getFdAssetName(plat = platform(), architecture = arch()): string | null { + if (plat === 'darwin') { + if (architecture === 'arm64') return 'fd-v10.4.2-aarch64-apple-darwin.tar.gz'; + if (architecture === 'x64') return 'fd-v10.3.0-x86_64-apple-darwin.tar.gz'; + return null; + } + if (plat === 'linux') { + if (architecture === 'arm64') return 'fd-v10.4.2-aarch64-unknown-linux-gnu.tar.gz'; + if (architecture === 'x64') return 'fd-v10.4.2-x86_64-unknown-linux-gnu.tar.gz'; + return null; + } + if (plat === 'win32') { + if (architecture === 'arm64') return 'fd-v10.4.2-aarch64-pc-windows-msvc.zip'; + if (architecture === 'x64') return 'fd-v10.4.2-x86_64-pc-windows-msvc.zip'; + return null; + } + return null; +} + +async function downloadFd(): Promise { + const assetName = getFdAssetName(); + if (assetName === null) return null; + const expectedSha256 = FD_ARCHIVE_SHA256[assetName]; + if (expectedSha256 === undefined) return null; + + const binDir = getBinDir(); + mkdirSync(binDir, { recursive: true }); + + const archivePath = join(binDir, assetName); + const binaryPath = getManagedFdBinaryPath(); + const extractDir = join( + binDir, + `fd_extract_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + ); + mkdirSync(extractDir, { recursive: true }); + + try { + const downloadUrl = `${FD_BASE_URL}/${assetName}`; + await downloadFile(downloadUrl, archivePath); + verifyArchive(archivePath, expectedSha256); + extractArchive(archivePath, extractDir, assetName); + + const binaryName = platform() === 'win32' ? 'fd.exe' : 'fd'; + const extractedBinary = findBinaryRecursively(extractDir, binaryName); + if (extractedBinary === null) return null; + + rmSync(binaryPath, { force: true }); + renameSync(extractedBinary, binaryPath); + if (platform() !== 'win32') { + chmodSync(binaryPath, 0o755); + } + return binaryPath; + } finally { + rmSync(archivePath, { force: true }); + rmSync(extractDir, { recursive: true, force: true }); + } +} + +function verifyArchive(path: string, expectedSha256: string): void { + const actualSha256 = createHash('sha256').update(readFileSync(path)).digest('hex'); + if (actualSha256 !== expectedSha256) { + throw new Error(`fd archive checksum mismatch: ${actualSha256} !== ${expectedSha256}`); + } +} + +async function downloadFile(url: string, dest: string): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error(`Failed to download fd: ${response.status}`); + } + if (response.body === null) { + throw new Error('Failed to download fd: empty response body'); + } + await pipeline(Readable.fromWeb(response.body), createWriteStream(dest)); +} + +function extractArchive(archivePath: string, extractDir: string, assetName: string): void { + if (assetName.endsWith('.tar.gz')) { + runExtractionCommand('tar', ['xzf', archivePath, '-C', extractDir]); + return; + } + if (assetName.endsWith('.zip')) { + if (platform() === 'win32') { + runExtractionCommand(getWindowsTarCommand(), ['xf', archivePath, '-C', extractDir]); + return; + } + runExtractionCommand('unzip', ['-q', archivePath, '-d', extractDir]); + return; + } + throw new Error(`Unsupported fd archive format: ${assetName}`); +} + +function runExtractionCommand(command: string, args: readonly string[]): void { + const result = spawnSync(command, [...args], { stdio: 'pipe' }); + if (!result.error && result.status === 0) return; + const stderr = result.stderr.toString().trim(); + const detail = + result.error?.message ?? (stderr.length > 0 ? stderr : `exit status ${String(result.status)}`); + throw new Error(`Failed to extract fd with ${command}: ${detail}`); +} + +function getWindowsTarCommand(): string { + const systemRoot = process.env['SystemRoot'] ?? process.env['WINDIR']; + if (systemRoot !== undefined) { + const systemTar = join(systemRoot, 'System32', 'tar.exe'); + if (existsSync(systemTar)) return systemTar; + } + return 'tar.exe'; +} + +function findBinaryRecursively(rootDir: string, binaryName: string): string | null { + const stack = [rootDir]; + while (stack.length > 0) { + const currentDir = stack.pop(); + if (currentDir === undefined) continue; + const entries = readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(currentDir, entry.name); + if (entry.isFile() && entry.name === binaryName) return fullPath; + if (entry.isDirectory()) stack.push(fullPath); } } return null; 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 209ab050b3..c24c3e8e3c 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,26 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; -import type { GitLsFilesCache, GitSnapshot } from '#/utils/git/git-ls-files'; - -function stubGitCache( - files: string[] | null, - opts: { mtimes?: Record; recency?: string[] } = {}, -): GitLsFilesCache { - const snapshot: GitSnapshot | null = - files === null - ? null - : { - files, - mtimeByPath: new Map(Object.entries(opts.mtimes ?? {})), - recencyOrder: new Map((opts.recency ?? []).map((p, i) => [p, i])), - }; - return { - isGitRepo: () => files !== null, - getSnapshot: () => snapshot, - list: () => (files === null ? null : files.slice()), - }; -} function ctrl(): AbortSignal { return new AbortController().signal; @@ -41,23 +25,32 @@ const GOAL_COMMAND = { : null, }; -describe('FileMentionProvider — @ prefix detection + git-backed suggestions', () => { - it('returns null when there is no @ mention and the dir is empty', async () => { - const provider = new FileMentionProvider([], '/nonexistent', NO_FD, stubGitCache([])); +describe('FileMentionProvider', () => { + let workDir: string; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'kimi-file-mention-')); + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + it('returns null when there is no completable prefix', async () => { + const provider = new FileMentionProvider([], workDir, NO_FD); const result = await provider.getSuggestions(['hello world'], 0, 11, { signal: ctrl() }); - // pi-tui inner will also return null for non-path plain text. expect(result).toBeNull(); }); it('does not complete slash arguments before existing free text', async () => { - const provider = new FileMentionProvider([GOAL_COMMAND], '/repo', NO_FD, stubGitCache([])); + const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); const line = '/goal Fix the checkout docs'; const result = await provider.getSuggestions([line], 0, '/goal '.length, { signal: ctrl() }); expect(result).toBeNull(); }); it('still completes slash arguments at the end of an empty argument', async () => { - const provider = new FileMentionProvider([GOAL_COMMAND], '/repo', NO_FD, stubGitCache([])); + const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); const line = '/goal '; const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); expect(result).not.toBeNull(); @@ -65,180 +58,102 @@ describe('FileMentionProvider — @ prefix detection + git-backed suggestions', expect(result!.items.map((item) => item.value)).toEqual(['status']); }); - it('bare @ surfaces the first files as a starting list', async () => { - const files = ['a.ts', 'b.ts', 'src/c.ts']; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); - expect(result).not.toBeNull(); - expect(result!.prefix).toBe('@'); - expect(result!.items.map((i) => i.value)).toEqual(['@a.ts', '@b.ts', '@src/c.ts']); + it('does not turn leading-whitespace slash into root path completion', async () => { + const provider = new FileMentionProvider([], workDir, NO_FD); + const result = await provider.getSuggestions([' /'], 0, 2, { signal: ctrl() }); + expect(result).toBeNull(); }); - it('ranks basename-prefix > substring > fuzzy', async () => { - const files = [ - 'docs/readme.md', // basename starts with "read" - 'src/readability.ts', // basename starts with "read" - 'lib/threader.ts', // basename contains "read" (substring) - ]; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - const result = await provider.getSuggestions(['@read'], 0, 5, { signal: ctrl() }); + it('still allows forced root path completion after leading whitespace', async () => { + const provider = new FileMentionProvider([], workDir, NO_FD); + const result = await provider.getSuggestions([' /'], 0, 2, { signal: ctrl(), force: true }); expect(result).not.toBeNull(); - const values = result!.items.map((i) => i.value); - const readabilityIdx = values.indexOf('@src/readability.ts'); - const readmeIdx = values.indexOf('@docs/readme.md'); - const threadIdx = values.indexOf('@lib/threader.ts'); - // Both starts-with entries rank ahead of the substring entry. - expect(readabilityIdx).toBeGreaterThanOrEqual(0); - expect(readmeIdx).toBeGreaterThanOrEqual(0); - expect(threadIdx).toBeGreaterThan(Math.max(readabilityIdx, readmeIdx)); + expect(result!.prefix).toBe('/'); }); - it('empty query prefers recently-edited files over everything else', async () => { - const files = ['a.ts', 'b.ts', 'c.ts', 'd.ts', 'e.ts']; - const provider = new FileMentionProvider( - [], - '/repo', - NO_FD, - stubGitCache(files, { - recency: ['d.ts', 'b.ts'], // d most recent, then b - }), - ); - const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); - const values = result!.items.map((i) => i.value); - // Recency layer fills first, then alphabetical layer. - expect(values.slice(0, 2)).toEqual(['@d.ts', '@b.ts']); - expect(values.slice(2)).toEqual(['@a.ts', '@c.ts', '@e.ts']); + it('does not trigger the @ branch when @ is preceded by a non-delimiter', async () => { + const provider = new FileMentionProvider([], workDir, NO_FD); + const result = await provider.getSuggestions(['email@example'], 0, 13, { signal: ctrl() }); + expect(result).toBeNull(); }); - it('empty query falls back to mtime when no recency info', async () => { - const files = ['old.ts', 'newer.ts', 'newest.ts']; - const provider = new FileMentionProvider( - [], - '/repo', - NO_FD, - stubGitCache(files, { - mtimes: { 'old.ts': 1000, 'newer.ts': 2000, 'newest.ts': 3000 }, - }), - ); - const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); - const values = result!.items.map((i) => i.value); - expect(values).toEqual(['@newest.ts', '@newer.ts', '@old.ts']); + it('uses a filesystem fallback for @ mentions when fd is not available', async () => { + mkdirSync(join(workDir, 'src', 'components'), { recursive: true }); + writeFileSync(join(workDir, 'src', 'components', 'Button.tsx'), 'export {};'); + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([], workDir, NO_FD); + + const result = await provider.getSuggestions(['@but'], 0, 4, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('@but'); + expect(result!.items.map((item) => item.value)).toContain('@src/components/Button.tsx'); }); - it('empty query falls back to basename alphabetical when no signals', async () => { - const files = ['zoo/apple.ts', 'banana.ts', 'cherry.ts']; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); - const values = result!.items.map((i) => i.value); - // Sorted by basename alphabetical: apple, banana, cherry - expect(values).toEqual(['@zoo/apple.ts', '@banana.ts', '@cherry.ts']); + it('falls back to filesystem suggestions when the configured fd path is unavailable', async () => { + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd')); + + const result = await provider.getSuggestions(['@read'], 0, 5, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain('@README.md'); }); - it('hides dot-dir files from the default list', async () => { - const files = ['.agents/skills/x.md', '.github/workflows/y.yml', 'src/a.ts', 'README.md']; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); + it('filesystem fallback returns folders and excludes .git', async () => { + mkdirSync(join(workDir, 'src')); + mkdirSync(join(workDir, '.git')); + writeFileSync(join(workDir, '.git', 'config'), 'secret'); + const provider = new FileMentionProvider([], workDir, NO_FD); + const result = await provider.getSuggestions(['@'], 0, 1, { signal: ctrl() }); - const values = result!.items.map((i) => i.value); - expect(values).toContain('@README.md'); - expect(values).toContain('@src/a.ts'); - expect(values).not.toContain('@.agents/skills/x.md'); - expect(values).not.toContain('@.github/workflows/y.yml'); - }); - it('shows dot-dir files when the query explicitly opts in (starts with .)', async () => { - const files = ['.agents/skills/foo.md', '.agents/README.md', 'src/a.ts']; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - const result = await provider.getSuggestions(['@.agents'], 0, 8, { signal: ctrl() }); expect(result).not.toBeNull(); - const values = result!.items.map((i) => i.value); - expect(values.some((v) => v.startsWith('@.agents/'))).toBe(true); + const values = result!.items.map((item) => item.value); + expect(values).toContain('@src/'); + expect(values.some((value) => value.startsWith('@.git'))).toBe(false); }); - it('within a category, recency ranks higher than mtime', async () => { - const files = ['older-recent.ts', 'never-touched-but-new.ts']; - const provider = new FileMentionProvider( - [], - '/repo', - NO_FD, - stubGitCache(files, { - mtimes: { 'older-recent.ts': 1000, 'never-touched-but-new.ts': 9999 }, - recency: ['older-recent.ts'], - }), - ); - // Query hits both via fuzzy (they both contain letters from 'nr'). - // Use basename-startswith shared prefix to force cat 0 tie. - const tied = ['aa-recent.ts', 'aa-newer.ts']; - const provider2 = new FileMentionProvider( - [], - '/repo', - NO_FD, - stubGitCache(tied, { - mtimes: { 'aa-recent.ts': 1000, 'aa-newer.ts': 9999 }, - recency: ['aa-recent.ts'], - }), - ); - const result = await provider2.getSuggestions(['@aa'], 0, 3, { signal: ctrl() }); - const values = result!.items.map((i) => i.value); - expect(values[0]).toBe('@aa-recent.ts'); - expect(values[1]).toBe('@aa-newer.ts'); - void provider; // silence unused - }); + it('filesystem fallback quotes paths with spaces', async () => { + mkdirSync(join(workDir, 'my folder')); + const provider = new FileMentionProvider([], workDir, NO_FD); + + const result = await provider.getSuggestions(['@my'], 0, 3, { signal: ctrl() }); - it('scoped @src/ limits to files under src/', async () => { - const files = ['src/a.ts', 'src/b.ts', 'lib/c.ts']; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - const result = await provider.getSuggestions(['@src/'], 0, 5, { signal: ctrl() }); expect(result).not.toBeNull(); - const values = result!.items.map((i) => i.value); - // Empty query after src/ shouldn't match lib/c.ts via basename ranking; - // but our git-backed path doesn't apply scope directly — the query is - // "src/" and we fall back to fuzzy on the raw path. Both src/ paths - // contain "src/" and rank higher than lib/c.ts. - expect(values[0]).toMatch(/^@src\//); - expect(values[1]).toMatch(/^@src\//); + expect(result!.items.map((item) => item.value)).toContain('@"my folder/"'); }); - it('does not trigger the @ branch when @ is preceded by a non-delimiter', async () => { - // "email@example" — @ is not at a token boundary; our extractAtPrefix - // returns null and the inner provider handles the text. - const provider = new FileMentionProvider([], '/nonexistent', NO_FD, stubGitCache(['a.ts'])); - const result = await provider.getSuggestions(['email@example'], 0, 13, { signal: ctrl() }); - // Inner provider returns null for this kind of free text. - expect(result).toBeNull(); - }); + it('delegates path suggestions to pi-tui for regular path completion', async () => { + mkdirSync(join(workDir, 'src')); + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([], workDir, NO_FD); + + const result = await provider.getSuggestions([''], 0, 0, { signal: ctrl(), force: true }); - it('handles multiple @ mentions on one line by completing the last one', async () => { - const files = ['alpha.ts', 'beta.ts']; - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); - // "read @alpha.ts and @bet" — cursor at end, inside the second @. - const line = 'read @alpha.ts and @bet'; - const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); expect(result).not.toBeNull(); - expect(result!.prefix).toBe('@bet'); - const values = result!.items.map((i) => i.value); - expect(values).toContain('@beta.ts'); - expect(values).not.toContain('@alpha.ts'); + expect(result!.items.map((item) => item.value)).toEqual(['src/', 'README.md']); }); - it('applyCompletion delegates to inner (replaces prefix with value)', () => { - const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(['src/a.ts'])); - const out = provider.applyCompletion( - ['hey @src'], + it('applyCompletion delegates file and directory insertion to pi-tui', () => { + const provider = new FileMentionProvider([], workDir, NO_FD); + + const file = provider.applyCompletion( + ['hey @read'], 0, - 8, // cursor just after @src - { value: '@src/a.ts', label: 'a.ts' }, - '@src', + 9, + { value: '@README.md', label: 'README.md' }, + '@read', ); - // pi-tui appends a trailing space after a non-directory completion - // so the user can type the next token immediately. - expect(out.lines[0]).toBe('hey @src/a.ts '); - }); + expect(file.lines[0]).toBe('hey @README.md '); - 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. - const result = await provider.getSuggestions(['@foo'], 0, 4, { signal: ctrl() }); - // pi-tui readdir on a nonexistent basePath returns [] → null. - expect(result).toBeNull(); + const dir = provider.applyCompletion( + ['hey @sr'], + 0, + 7, + { value: '@src/', label: 'src/' }, + '@sr', + ); + expect(dir.lines[0]).toBe('hey @src/'); }); }); 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 deleted file mode 100644 index d7b193a5cc..0000000000 --- a/apps/kimi-code/test/utils/git/git-ls-files.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync, utimesSync, statSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; - -import { createGitLsFilesCache } from '#/utils/git/git-ls-files'; - -function git(cwd: string, ...args: string[]): void { - execFileSync('git', args, { cwd, stdio: 'ignore' }); -} - -function commit(cwd: string, message: string): void { - git(cwd, '-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', message); -} - -describe('createGitLsFilesCache', () => { - let dir: string; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'git-ls-files-test-')); - }); - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - it('returns null for a non-git directory', () => { - const cache = createGitLsFilesCache(dir); - expect(cache.isGitRepo()).toBe(false); - expect(cache.list()).toBeNull(); - }); - - it('lists tracked files in a git repo', () => { - git(dir, 'init'); - writeFileSync(join(dir, 'a.ts'), ''); - mkdirSync(join(dir, 'src')); - writeFileSync(join(dir, 'src/b.ts'), ''); - git(dir, 'add', '.'); - commit(dir, 'init'); - - const cache = createGitLsFilesCache(dir); - expect(cache.isGitRepo()).toBe(true); - const snap = cache.getSnapshot(); - expect(snap).not.toBeNull(); - expect(snap!.files).toContain('a.ts'); - expect(snap!.files).toContain('src/b.ts'); - expect(snap!.mtimeByPath.has('a.ts')).toBe(true); - expect(snap!.mtimeByPath.get('a.ts')!).toBeGreaterThan(0); - expect(cache.getSnapshot()).toBe(snap); - }); - - it('includes untracked-but-not-ignored files', () => { - git(dir, 'init'); - writeFileSync(join(dir, '.gitignore'), 'ignored.ts\n'); - writeFileSync(join(dir, 'tracked.ts'), ''); - git(dir, 'add', 'tracked.ts'); - commit(dir, 'init'); - - // Create both an untracked file and one that matches .gitignore. - writeFileSync(join(dir, 'new.ts'), ''); - writeFileSync(join(dir, 'ignored.ts'), ''); - - const cache = createGitLsFilesCache(dir); - const files = cache.list()!; - expect(files).toContain('tracked.ts'); - expect(files).toContain('new.ts'); - expect(files).not.toContain('ignored.ts'); - }); - - it('builds a recency order from recent commits', () => { - git(dir, 'init'); - writeFileSync(join(dir, 'old.ts'), ''); - git(dir, 'add', 'old.ts'); - commit(dir, 'old'); - writeFileSync(join(dir, 'new.ts'), ''); - git(dir, 'add', 'new.ts'); - commit(dir, 'new'); - - const cache = createGitLsFilesCache(dir); - const snap = cache.getSnapshot()!; - const newRank = snap.recencyOrder.get('new.ts'); - const oldRank = snap.recencyOrder.get('old.ts'); - expect(newRank).toBeDefined(); - expect(oldRank).toBeDefined(); - expect(newRank!).toBeLessThan(oldRank!); - }); - - it('invalidates when .git/index mtime changes', () => { - git(dir, 'init'); - writeFileSync(join(dir, 'a.ts'), ''); - git(dir, 'add', '.'); - commit(dir, 'init'); - - const cache = createGitLsFilesCache(dir); - const first = cache.list()!; - expect(first).toContain('a.ts'); - - // Touch .git/index forward to simulate a new commit / add. - const indexPath = join(dir, '.git', 'index'); - const s = statSync(indexPath); - const future = new Date(s.mtimeMs + 5000); - utimesSync(indexPath, future, future); - - writeFileSync(join(dir, 'b.ts'), ''); - git(dir, 'add', 'b.ts'); - - const second = cache.list()!; - expect(second).not.toBe(first); // new snapshot - expect(second).toContain('b.ts'); - }); -}); diff --git a/apps/kimi-code/test/utils/paths.test.ts b/apps/kimi-code/test/utils/paths.test.ts index b7d61e60fe..3ebeb37116 100644 --- a/apps/kimi-code/test/utils/paths.test.ts +++ b/apps/kimi-code/test/utils/paths.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + getBinDir, getDataDir, getInputHistoryFile, getLogDir, @@ -49,6 +50,17 @@ describe('getLogDir', () => { }); }); +describe('getBinDir', () => { + it('returns /bin', () => { + expect(getBinDir()).toBe(join(homedir(), '.kimi-code', 'bin')); + }); + + it('respects KIMI_CODE_HOME', () => { + process.env['KIMI_CODE_HOME'] = '/custom-bin-home'; + expect(getBinDir()).toBe(join('/custom-bin-home', 'bin')); + }); +}); + describe('getUpdateStateFile', () => { it('returns /updates/latest.json', () => { expect(getUpdateStateFile()).toBe(join(homedir(), '.kimi-code', 'updates', 'latest.json')); diff --git a/apps/kimi-code/test/utils/process/fd-detect.test.ts b/apps/kimi-code/test/utils/process/fd-detect.test.ts new file mode 100644 index 0000000000..7c1ea0fe56 --- /dev/null +++ b/apps/kimi-code/test/utils/process/fd-detect.test.ts @@ -0,0 +1,63 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { detectFdPath, getFdAssetName } from '#/utils/process/fd-detect'; +import { getBinDir } from '#/utils/paths'; + +const originalEnv = { ...process.env }; +let tempHome: string | undefined; + +afterEach(() => { + if (tempHome !== undefined) { + rmSync(tempHome, { recursive: true, force: true }); + tempHome = undefined; + } + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); +}); + +describe('getFdAssetName', () => { + it('returns the macOS arm64 asset name', () => { + expect(getFdAssetName('darwin', 'arm64')).toBe('fd-v10.4.2-aarch64-apple-darwin.tar.gz'); + }); + + it('returns the macOS x64 asset name pinned to the available upstream release', () => { + expect(getFdAssetName('darwin', 'x64')).toBe('fd-v10.3.0-x86_64-apple-darwin.tar.gz'); + }); + + it('returns the Linux x64 asset name', () => { + expect(getFdAssetName('linux', 'x64')).toBe('fd-v10.4.2-x86_64-unknown-linux-gnu.tar.gz'); + }); + + it('returns the Windows x64 asset name', () => { + expect(getFdAssetName('win32', 'x64')).toBe('fd-v10.4.2-x86_64-pc-windows-msvc.zip'); + }); + + it('returns null for unsupported platforms or architectures', () => { + expect(getFdAssetName('freebsd', 'x64')).toBeNull(); + expect(getFdAssetName('darwin', 'arm')).toBeNull(); + }); +}); + +describe('detectFdPath', () => { + it('prefers the managed fd binary under KIMI_CODE_HOME', () => { + tempHome = mkdtempSync(join(tmpdir(), 'kimi-fd-home-')); + process.env['KIMI_CODE_HOME'] = tempHome; + mkdirSync(getBinDir(), { recursive: true }); + + const binaryPath = join(getBinDir(), process.platform === 'win32' ? 'fd.exe' : 'fd'); + if (process.platform === 'win32') { + // Creating a real Windows PE executable in a unit test is not practical; + // the asset-name tests still cover Windows selection logic. + return; + } + + writeFileSync(binaryPath, '#!/bin/sh\necho fd 10.4.2\n'); + chmodSync(binaryPath, 0o755); + + expect(detectFdPath()).toBe(binaryPath); + }); +}); diff --git a/docs/en/configuration/data-locations.md b/docs/en/configuration/data-locations.md index 2f08ca0c18..b7d53c1bfa 100644 --- a/docs/en/configuration/data-locations.md +++ b/docs/en/configuration/data-locations.md @@ -18,9 +18,7 @@ export KIMI_CODE_HOME="$HOME/.config/kimi-code" Once set, **all** data — config, sessions, logs, OAuth credentials, and more — lands under the new path. For the full reference on `KIMI_CODE_HOME`, see [Environment variables](./env-vars.md). -::: tip Two types of data are not affected by `KIMI_CODE_HOME` - -**Built-in tool cache** (ripgrep binary) uses `KIMI_CODE_CACHE_DIR` instead. When that is unset, the platform cache directory is used: `~/Library/Caches/kimi-code` on macOS, `~/.cache/kimi-code` on Linux, and `%LOCALAPPDATA%\kimi-code` on Windows. +::: tip One type of data is not affected by `KIMI_CODE_HOME` **Agent Skills** search paths are `~/.kimi-code/skills` and `~/.agents/skills` (user level), and `.kimi-code/skills` and `.agents/skills` under the working directory (project level). See [Agent Skills](../customization/skills.md). ::: @@ -43,7 +41,8 @@ $KIMI_CODE_HOME (default: ~/.kimi-code) ├── sessions/ # Session data (see below) │ └── // ├── bin/ -│ └── rg # ripgrep cache (rg.exe on Windows) +│ ├── rg # managed ripgrep binary for Grep (rg.exe on Windows) +│ └── fd # managed fd binary for file references (fd.exe on Windows) ├── logs/ │ └── kimi-code.log # Global diagnostic log ├── updates/ @@ -81,7 +80,7 @@ Inside each session directory: ## Built-in tool cache -The first time the CLI needs ripgrep, it automatically downloads and caches it at `bin/rg` (`bin/rg.exe` on Windows); subsequent runs reuse the cached binary. If `rg` is already available on the system `PATH`, that version takes precedence. Deleting the `bin/` directory triggers a fresh download on the next use. +The first time the `Grep` tool needs ripgrep, the CLI can automatically download `rg` and cache it at `bin/rg` (`bin/rg.exe` on Windows). File-reference completion in the terminal UI uses `fd`; the CLI downloads and caches it at `bin/fd` (`bin/fd.exe` on Windows) in the background when needed. Subsequent runs reuse the cached binaries. `rg` prefers the system `PATH` before the cache, while `fd` checks the managed cache before falling back to system `fd` / `fdfind`. Deleting the `bin/` directory triggers a fresh download on the next use. ## Logs and update state @@ -108,7 +107,7 @@ Deleting the data root directory (`~/.kimi-code/` or the path set by `KIMI_CODE_ | Clear diagnostic logs | Delete `~/.kimi-code/logs/` | | Clear input history | Delete `~/.kimi-code/user-history/` | | Reset update state | Delete `~/.kimi-code/updates/latest.json` | -| Force re-download of ripgrep | Delete `~/.kimi-code/bin/` | +| Force re-download of managed `rg` and `fd` | Delete `~/.kimi-code/bin/` | | Clear provider OAuth login state | Run `/logout`, or delete the corresponding `credentials/.json` | | Clear MCP server OAuth login state | Delete `credentials/mcp/` (`/logout` does not clear MCP credentials) | | Remove user-level MCP declarations | Delete `~/.kimi-code/mcp.json` | diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 48472b2cb9..9a61e23e0e 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -155,7 +155,7 @@ The CLI also reads several standard system variables to detect the runtime envir - `HOME`: used to resolve the default data path - `VISUAL`, `EDITOR`: external editor command (`VISUAL` takes precedence) -- `PATH`: used to locate dependencies such as `rg` and `git`; on Windows, Git Bash detection checks each `git.exe` found on `PATH`, including package-manager shims such as Scoop +- `PATH`: used to locate dependencies such as `rg`, `fd`, `fdfind`, and `git`; on Windows, Git Bash detection checks each `git.exe` found on `PATH`, including package-manager shims such as Scoop - `NO_COLOR`, `FORCE_COLOR`: control color output (following the [no-color.org](https://no-color.org) convention) - `CI`: when non-empty and not `"0"`, disables theme detection and falls back to the dark theme - `TERM_PROGRAM`, `TERM`, `TMUX`: detect terminal features and notification support diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index 92907d2c55..8a1c915319 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -31,9 +31,9 @@ Some commands are only available when the agent is idle — you need to press `E ## File references -Type `@` to trigger file-path completion. Selecting a path inserts its relative form into your message; the agent loads the file content directly when it reads the message. Directories beginning with a dot are hidden by default — type the prefix explicitly (e.g. `@.github/`) to access them. +Type `@` to trigger file-path completion. Selecting a path inserts its relative form into your message; the agent loads the file content directly when it reads the message. File references work in both git and non-git directories, and folder suggestions end with `/` so you can keep completing paths inside them. If the fast search helper is still downloading, Kimi Code falls back to a basic filesystem scan. Hidden paths are available, but `.git` is excluded from suggestions. -> `@` references and slash commands are two separate mechanisms: `@` gives the agent file context, while `/` invokes built-in features or Skills. +> `@` references and slash commands are two separate mechanisms: `@` gives the agent file context, while `/` invokes built-in features or Skills. A `/` typed after leading whitespace is treated as normal text, not as the slash-command menu. ## Approval flow diff --git a/docs/zh/configuration/data-locations.md b/docs/zh/configuration/data-locations.md index 1325a74513..a449edae6b 100644 --- a/docs/zh/configuration/data-locations.md +++ b/docs/zh/configuration/data-locations.md @@ -18,9 +18,7 @@ export KIMI_CODE_HOME="$HOME/.config/kimi-code" 设置后,配置、会话、日志、OAuth 凭据等**全部**数据都会落到新路径下。`KIMI_CODE_HOME` 的完整说明见[环境变量](./env-vars.md)。 -::: tip 两类数据不受 `KIMI_CODE_HOME` 影响 - -**内置工具缓存**(ripgrep 二进制)走的是 `KIMI_CODE_CACHE_DIR`,未设时使用平台缓存目录:macOS 的 `~/Library/Caches/kimi-code`、Linux 的 `~/.cache/kimi-code`、Windows 的 `%LOCALAPPDATA%\kimi-code`。 +::: tip 一类数据不受 `KIMI_CODE_HOME` 影响 **Agent Skills** 的搜索路径是 `~/.kimi-code/skills` 和 `~/.agents/skills`(用户级),以及工作目录下的 `.kimi-code/skills` 和 `.agents/skills`(项目级)。详见 [Agent Skills](../customization/skills.md)。 ::: @@ -43,7 +41,8 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code) ├── sessions/ # 会话数据(详见下文) │ └── // ├── bin/ -│ └── rg # ripgrep 缓存(Windows 为 rg.exe) +│ ├── rg # Grep 使用的托管 ripgrep 二进制(Windows 为 rg.exe) +│ └── fd # 文件引用使用的托管 fd 二进制(Windows 为 fd.exe) ├── logs/ │ └── kimi-code.log # 全局诊断日志 ├── updates/ @@ -81,7 +80,7 @@ $KIMI_CODE_HOME (默认 ~/.kimi-code) ## 内置工具缓存 -CLI 第一次需要 ripgrep 时会自动下载并缓存到 `bin/rg`(Windows 为 `bin/rg.exe`),之后直接复用。如果系统 `PATH` 里本来就有 `rg`,优先使用系统版本。删除 `bin/` 目录会在下次需要时触发重新下载。 +`Grep` 工具第一次需要 ripgrep 时,CLI 可自动下载 `rg` 并缓存到 `bin/rg`(Windows 为 `bin/rg.exe`)。终端界面的文件引用补全使用 `fd`;需要时 CLI 会在后台自动下载并缓存到 `bin/fd`(Windows 为 `bin/fd.exe`)。之后的运行会直接复用缓存的二进制。`rg` 优先使用系统 `PATH`,再使用缓存;`fd` 优先检查托管缓存,再回退到系统 `fd` / `fdfind`。删除 `bin/` 目录会在下次需要时触发重新下载。 ## 日志与更新状态 @@ -108,7 +107,7 @@ CLI 第一次需要 ripgrep 时会自动下载并缓存到 `bin/rg`(Windows | 清理诊断日志 | 删除 `~/.kimi-code/logs/` | | 清理输入历史 | 删除 `~/.kimi-code/user-history/` | | 重置更新状态 | 删除 `~/.kimi-code/updates/latest.json` | -| 强制重新下载 ripgrep | 删除 `~/.kimi-code/bin/` | +| 强制重新下载托管 `rg` 和 `fd` | 删除 `~/.kimi-code/bin/` | | 清除供应商 OAuth 登录态 | 运行 `/logout`,或删除对应的 `credentials/.json` | | 清除 MCP server OAuth 登录态 | 删除 `credentials/mcp/`(`/logout` 不会清理 MCP 凭据) | | 移除用户级 MCP 声明 | 删除 `~/.kimi-code/mcp.json` | diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 44bab884d3..36d88ca80e 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -155,7 +155,7 @@ CLI 还会读取一些标准系统变量来检测运行环境,不会修改它 - `HOME`:解析默认数据路径 - `VISUAL`、`EDITOR`:外部编辑器命令(`VISUAL` 优先) -- `PATH`:定位 `rg`、`git` 等依赖;在 Windows 上,Git Bash 探测会检查 `PATH` 中找到的每个 `git.exe`,包括 Scoop 等包管理器提供的 shim +- `PATH`:定位 `rg`、`fd`、`fdfind`、`git` 等依赖;在 Windows 上,Git Bash 探测会检查 `PATH` 中找到的每个 `git.exe`,包括 Scoop 等包管理器提供的 shim - `NO_COLOR`、`FORCE_COLOR`:控制颜色输出(遵循 [no-color.org](https://no-color.org) 约定) - `CI`:非空且非 `"0"` 时关闭主题检测,回退深色主题 - `TERM_PROGRAM`、`TERM`、`TMUX`:检测终端特性和通知支持 diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index ca61d1f626..6da8066a5b 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -31,9 +31,9 @@ Kimi Code CLI 支持在输入框中直接粘贴图片和视频,让 AI 结合 ## 文件引用 -键入 `@` 触发文件路径补全,选中后在输入中插入相对路径,Agent 读取时会直接加载该文件内容。以点开头的目录默认隐藏,可显式输入如 `@.github/` 访问。 +键入 `@` 触发文件路径补全,选中后在输入中插入相对路径,Agent 读取时会直接加载该文件内容。文件引用在 git 和非 git 目录都可用;文件夹候选会以 `/` 结尾,方便继续补全其下路径。如果快速搜索辅助工具仍在下载,Kimi Code 会先回退到基础的文件系统扫描。隐藏路径也可补全,但 `.git` 会从候选中排除。 -> `@` 引用和斜杠命令是两套不同的机制:`@` 向 Agent 提供文件上下文,`/` 调用内置功能或 Skill。 +> `@` 引用和斜杠命令是两套不同的机制:`@` 向 Agent 提供文件上下文,`/` 调用内置功能或 Skill。前面有空白字符时输入 `/` 会按普通文本处理,不会打开斜杠命令菜单。 ## 审批流程 From e1586ce1dced2d36f79c4652b6a7801261cd25b6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 8 Jun 2026 19:31:56 +0800 Subject: [PATCH 2/5] fix: respect fd file reference filtering --- .../src/tui/components/editor/file-mention-provider.ts | 10 ++++++---- .../components/editor/file-mention-provider.test.ts | 5 ++--- 2 files changed, 8 insertions(+), 7 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 d6395f8b37..515f393eee 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 @@ -63,13 +63,15 @@ export class FileMentionProvider implements AutocompleteProvider { const atPrefix = extractAtPrefix(textBeforeCursor); if (atPrefix !== null) { + if (this.fdPath === null) { + return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); + } try { - const innerResult = await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); - if (innerResult !== null) return innerResult; + return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } catch { - // If fd is missing, deleted mid-session, or fails to spawn, keep @ completion usable. + // If fd fails to spawn unexpectedly, keep @ completion usable. + return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); } - return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); } try { 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 c24c3e8e3c..7b5f7ddf8f 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 @@ -90,14 +90,13 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toContain('@src/components/Button.tsx'); }); - it('falls back to filesystem suggestions when the configured fd path is unavailable', async () => { + it('does not bypass fd filtering with filesystem suggestions when fd returns no matches', async () => { writeFileSync(join(workDir, 'README.md'), 'readme'); const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd')); const result = await provider.getSuggestions(['@read'], 0, 5, { signal: ctrl() }); - expect(result).not.toBeNull(); - expect(result!.items.map((item) => item.value)).toContain('@README.md'); + expect(result).toBeNull(); }); it('filesystem fallback returns folders and excludes .git', async () => { From 4d682fa5ce4555380ab921c6b71845d3fbaf22e1 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 8 Jun 2026 19:39:52 +0800 Subject: [PATCH 3/5] chore: adjust file reference changeset --- .changeset/fd-file-mentions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fd-file-mentions.md b/.changeset/fd-file-mentions.md index 25eb0d8fd5..d162d9201e 100644 --- a/.changeset/fd-file-mentions.md +++ b/.changeset/fd-file-mentions.md @@ -1,5 +1,5 @@ --- -"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code": patch --- Rework file reference completion in the TUI. From 0c19e02e8bfaf22c82b96d41650ef7cccd1edad1 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 8 Jun 2026 19:54:53 +0800 Subject: [PATCH 4/5] fix: harden fd helper downloads --- apps/kimi-code/src/utils/process/fd-detect.ts | 9 ++++----- apps/kimi-code/test/utils/process/fd-detect.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/kimi-code/src/utils/process/fd-detect.ts b/apps/kimi-code/src/utils/process/fd-detect.ts index 9b87e12c7e..ed97a0000f 100644 --- a/apps/kimi-code/src/utils/process/fd-detect.ts +++ b/apps/kimi-code/src/utils/process/fd-detect.ts @@ -29,8 +29,8 @@ const FD_ARCHIVE_SHA256: Record = { '50d30f13fe3d5914b14c4fff5abcbd4d0cdab4b855970a6956f4f006c17117a3', 'fd-v10.4.2-aarch64-unknown-linux-gnu.tar.gz': '6c51f7c5446b3338b1e401ff15dc194c590bb2fa64fd43ff3278300f073adec5', - 'fd-v10.4.2-x86_64-unknown-linux-gnu.tar.gz': - 'def59805cd14b5651b68990855f426ad087f3b96881296d963910431ba3143c8', + 'fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz': + 'e3257d48e29a6be965187dbd24ce9af564e0fe67b3e73c9bdcd180f4ec11bdde', 'fd-v10.4.2-aarch64-pc-windows-msvc.zip': '4f9110c2d5b33a7f760bfa5510f4c113d828109f7277d421b1053a9943c0fc92', 'fd-v10.4.2-x86_64-pc-windows-msvc.zip': @@ -89,7 +89,7 @@ export function getFdAssetName(plat = platform(), architecture = arch()): string } if (plat === 'linux') { if (architecture === 'arm64') return 'fd-v10.4.2-aarch64-unknown-linux-gnu.tar.gz'; - if (architecture === 'x64') return 'fd-v10.4.2-x86_64-unknown-linux-gnu.tar.gz'; + if (architecture === 'x64') return 'fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz'; return null; } if (plat === 'win32') { @@ -109,13 +109,13 @@ async function downloadFd(): Promise { const binDir = getBinDir(); mkdirSync(binDir, { recursive: true }); - const archivePath = join(binDir, assetName); const binaryPath = getManagedFdBinaryPath(); const extractDir = join( binDir, `fd_extract_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, ); mkdirSync(extractDir, { recursive: true }); + const archivePath = join(extractDir, assetName); try { const downloadUrl = `${FD_BASE_URL}/${assetName}`; @@ -134,7 +134,6 @@ async function downloadFd(): Promise { } return binaryPath; } finally { - rmSync(archivePath, { force: true }); rmSync(extractDir, { recursive: true, force: true }); } } diff --git a/apps/kimi-code/test/utils/process/fd-detect.test.ts b/apps/kimi-code/test/utils/process/fd-detect.test.ts index 7c1ea0fe56..cd6fd249cc 100644 --- a/apps/kimi-code/test/utils/process/fd-detect.test.ts +++ b/apps/kimi-code/test/utils/process/fd-detect.test.ts @@ -28,8 +28,8 @@ describe('getFdAssetName', () => { expect(getFdAssetName('darwin', 'x64')).toBe('fd-v10.3.0-x86_64-apple-darwin.tar.gz'); }); - it('returns the Linux x64 asset name', () => { - expect(getFdAssetName('linux', 'x64')).toBe('fd-v10.4.2-x86_64-unknown-linux-gnu.tar.gz'); + it('returns the Linux x64 musl asset name', () => { + expect(getFdAssetName('linux', 'x64')).toBe('fd-v10.4.2-x86_64-unknown-linux-musl.tar.gz'); }); it('returns the Windows x64 asset name', () => { From b88e64188a400a19a97928df823474cedeba15c5 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 8 Jun 2026 20:21:04 +0800 Subject: [PATCH 5/5] fix: avoid symlink recursion in file fallback --- .../components/editor/file-mention-provider.ts | 5 +++-- .../editor/file-mention-provider.test.ts | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 3 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 515f393eee..dda016a2f4 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 @@ -144,8 +144,9 @@ function collectFsMentionCandidates(workDir: string, signal: AbortSignal): FsMen if (entry.name === '.git') continue; const relativePath = normalizePath(relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name)); + const isSymlink = entry.isSymbolicLink(); let isDirectory = entry.isDirectory(); - if (!isDirectory && entry.isSymbolicLink()) { + if (!isDirectory && isSymlink) { try { isDirectory = statSync(join(workDir, relativePath)).isDirectory(); } catch { @@ -154,7 +155,7 @@ function collectFsMentionCandidates(workDir: string, signal: AbortSignal): FsMen } result.push({ path: relativePath, isDirectory }); - if (isDirectory) { + if (isDirectory && !isSymlink) { stack.push(relativePath); } } 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 7b5f7ddf8f..ec0bea1cdb 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 { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -123,6 +123,19 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toContain('@"my folder/"'); }); + it('filesystem fallback does not recurse into symlinked directories', async () => { + writeFileSync(join(workDir, 'target.txt'), 'target'); + symlinkSync('.', join(workDir, 'current'), 'dir'); + const provider = new FileMentionProvider([], workDir, NO_FD); + + const result = await provider.getSuggestions(['@target'], 0, 7, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const values = result!.items.map((item) => item.value); + expect(values).toContain('@target.txt'); + expect(values.some((value) => value.startsWith('@current/'))).toBe(false); + }); + it('delegates path suggestions to pi-tui for regular path completion', async () => { mkdirSync(join(workDir, 'src')); writeFileSync(join(workDir, 'README.md'), 'readme');