From a6a637078ec76ab9ff3fca40f2ae21494e38455b Mon Sep 17 00:00:00 2001 From: UncertaintyDeterminesYou4ndMe <72533078+UncertaintyDeterminesYou4ndMe@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:53:07 +0800 Subject: [PATCH 1/3] feat(tui): resolve @ file mentions to real paths at submit time The @path token is an input affordance: autocomplete inserts it, but the literal @ used to be sent through to the model, which then went looking for a file whose name starts with @. Two-layer fix: - Submit time: existence-gated resolution rewrites @mentions (relative, absolute, ~/, quoted, CJK trailing-punctuation) to verified absolute paths and strips the @; unresolved tokens (npm scopes, emails) pass through untouched. Applied in sendNormalUserInput and steerMessage; the transcript keeps the text as typed. - Input time: a / in the mention query means the user is spelling a path hierarchy. List that single directory shell-style (prefix > substring, dirs first, hidden entries only for dot fragments) instead of recursive fuzzy search from the base, which surfaced deep junk like ~/.Trash/** above ~/Downloads. Bare fragments keep fuzzy search. --- .../editor/file-mention-provider.ts | 109 +++++++++- apps/kimi-code/src/tui/kimi-tui.ts | 25 ++- apps/kimi-code/src/tui/utils/file-mention.ts | 190 ++++++++++++++++++ .../editor/file-mention-provider.test.ts | 115 ++++++++++- .../test/tui/utils/file-mention.test.ts | 98 +++++++++ 5 files changed, 529 insertions(+), 8 deletions(-) create mode 100644 apps/kimi-code/src/tui/utils/file-mention.ts create mode 100644 apps/kimi-code/test/tui/utils/file-mention.test.ts 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 ff489a09f..d10698ab0 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,5 +1,6 @@ -import { readdirSync, statSync } from 'node:fs'; -import { basename, join, resolve } from 'node:path'; +import { readdirSync, statSync, type Dirent } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, isAbsolute, join, resolve } from 'node:path'; import { CombinedAutocompleteProvider, @@ -10,7 +11,11 @@ import { type SlashCommand, } from '@moonshot-ai/pi-tui'; -const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); +import { MENTION_DELIMITERS } from '../../utils/file-mention'; + +// Token boundaries shared with submit-time mention resolution so that a +// token the autocomplete inserts is tokenized identically on submit. +const PATH_DELIMITERS = MENTION_DELIMITERS; const MAX_FALLBACK_SCAN = 2000; const MAX_FALLBACK_SUGGESTIONS = 50; @@ -75,6 +80,13 @@ export class FileMentionProvider implements AutocompleteProvider { // runs, so the file list never opens. const atPrefix = extractAtPrefix(textBeforeCursor); if (atPrefix !== null) { + // A `/` in the mention is the user spelling out a hierarchy — list + // that directory shell-style instead of fuzzy-searching the whole + // base, which surfaces deep junk (`~/.Trash/**`) above `~/Downloads`. + // Falls through to the fuzzy paths when the directory part does not + // resolve or nothing in it matches. + const navigation = getDirectoryNavigationSuggestions(this.workDir, atPrefix); + if (navigation !== null) return navigation; if (this.fdPath === null || this.additionalDirs.length > 0) { return getFsMentionSuggestions( this.workDir, @@ -227,6 +239,97 @@ export function extractAtPrefix(text: string): string | null { return text.slice(tokenStart); } +/** + * Shell-style directory navigation for scoped `@` mentions. + * + * A `/` in the query means the user is navigating a path hierarchy: + * treat the token as `/`, list that single directory, and + * rank entries basename-prefix first, then substring (CJK file names are + * often matched mid-name). Hidden entries follow the shell convention — + * shown only when the fragment itself starts with `.`. The typed dir + * part (`~/`, relative, absolute) is preserved in the inserted value; + * submit-time resolution expands it. + * + * Returns null when the query has no `/`, the directory part does not + * resolve, or nothing matches — callers fall back to fuzzy search. + */ +export function getDirectoryNavigationSuggestions( + workDir: string, + atPrefix: string, +): AutocompleteSuggestions | null { + const query = atPrefix.slice(1); + const lastSlash = query.lastIndexOf('/'); + if (lastSlash === -1) return null; + + const dirPartAsTyped = query.slice(0, lastSlash + 1); + const fragment = query.slice(lastSlash + 1); + const absoluteDir = expandMentionDir(dirPartAsTyped, workDir); + + let entries: Dirent[]; + try { + entries = readdirSync(absoluteDir, { withFileTypes: true }); + } catch { + return null; + } + + const lowerFragment = fragment.toLowerCase(); + const showHidden = fragment.startsWith('.'); + const scored: Array<{ name: string; isDirectory: boolean; score: number }> = []; + for (const entry of entries) { + if (!showHidden && entry.name.startsWith('.')) continue; + let score = 0; + if (lowerFragment.length === 0) score = 1; + else { + const lowerName = entry.name.toLowerCase(); + if (lowerName.startsWith(lowerFragment)) score = 2; + else if (lowerName.includes(lowerFragment)) score = 1; + } + if (score === 0) continue; + scored.push({ + name: entry.name, + isDirectory: isDirectoryEntry(entry, absoluteDir), + score, + }); + } + if (scored.length === 0) return null; + + scored.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return { + prefix: atPrefix, + items: scored.slice(0, MAX_FALLBACK_SUGGESTIONS).map((entry) => { + const valuePath = `${dirPartAsTyped}${entry.name}${entry.isDirectory ? '/' : ''}`; + return { + value: valuePath.includes(' ') ? `@"${valuePath}"` : `@${valuePath}`, + label: `${entry.name}${entry.isDirectory ? '/' : ''}`, + description: normalizePath(join(absoluteDir, entry.name)), + }; + }), + }; +} + +function expandMentionDir(dirPart: string, workDir: string): string { + if (dirPart === '~' || dirPart.startsWith('~/')) { + return resolve(homedir(), dirPart.slice(2)); + } + if (isAbsolute(dirPart)) return resolve(dirPart); + return resolve(workDir, dirPart); +} + +function isDirectoryEntry(entry: Dirent, parentDir: string): boolean { + if (entry.isDirectory()) return true; + if (!entry.isSymbolicLink()) return false; + try { + return statSync(join(parentDir, entry.name)).isDirectory(); + } catch { + return false; + } +} + /** * Match the `/add-dir` directory completer, which skips every entry whose name * starts with `.` (see registry.ts). pi-tui's path completer sets `label` to diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 2d92ec2e4..1448b1aac 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -132,6 +132,7 @@ import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; +import { resolveFileMentions } from './utils/file-mention'; import { extractMediaAttachments } from './utils/image-placeholder'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; @@ -1071,7 +1072,10 @@ export class KimiTUI { this.showError(LLM_NOT_SET_MESSAGE); return; } - const extraction = extractMediaAttachments(text, this.imageStore); + // Resolve `@` file mentions before media extraction so both rewrites + // land in the parts we send; the transcript keeps the text as typed. + const mentionResolution = resolveFileMentions(text, this.state.appState.workDir); + const extraction = extractMediaAttachments(mentionResolution.text, this.imageStore); if (!this.validateMediaCapabilities(extraction)) return; const session = this.session; if (session === undefined) { @@ -1084,6 +1088,10 @@ export class KimiTUI { parts: extraction.parts, imageAttachmentIds: extraction.imageAttachmentIds, }); + } else if (mentionResolution.mentions.length > 0) { + this.sendMessage(session, text, { + parts: [{ type: 'text', text: mentionResolution.text }], + }); } else { this.sendMessage(session, text); } @@ -1272,15 +1280,23 @@ export class KimiTUI { } steerMessage(session: Session, input: string[]): void { + // Same `@` mention treatment as sendNormalUserInput: the payload + // carries resolved paths, the transcript/queue keeps the typed text. + const workDir = this.state.appState.workDir; + const resolveOptions = (part: string): SendMessageOptions | undefined => { + const resolution = resolveFileMentions(part, workDir); + if (resolution.mentions.length === 0) return undefined; + return { parts: [{ type: 'text', text: resolution.text }] }; + }; if (this.deferUserMessages || this.state.appState.isCompacting) { for (const part of input) { - this.enqueueMessage(part); + this.enqueueMessage(part, resolveOptions(part)); } return; } if (this.state.appState.streamingPhase === 'idle') { for (const part of input) { - this.sendMessageInternal(session, part); + this.sendMessageInternal(session, part, resolveOptions(part)); } return; } @@ -1295,7 +1311,8 @@ export class KimiTUI { }); } - void session.steer(input.join('\n\n')).catch((error: unknown) => { + const steerText = input.map((part) => resolveFileMentions(part, workDir).text).join('\n\n'); + void session.steer(steerText).catch((error: unknown) => { const message = formatErrorMessage(error); this.showError(`Failed to steer: ${message}`); }); diff --git a/apps/kimi-code/src/tui/utils/file-mention.ts b/apps/kimi-code/src/tui/utils/file-mention.ts new file mode 100644 index 000000000..0d5062a30 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/file-mention.ts @@ -0,0 +1,190 @@ +/** + * Submit-time resolution of `@` file mentions. + * + * The `@path` token is an input affordance: the editor's autocomplete + * inserts `@relative/path` (or `@"path with spaces"`), but the `@` is + * only meaningful to the harness — the model must never see it as a + * literal part of a file name. Resolution is existence-gated: a token + * that resolves to a real file or directory is rewritten to its + * absolute path (stripping the `@`); anything else (npm scopes like + * `@types/node`, emails, plain prose) passes through untouched. + * + * Tokenization mirrors the editor's mention autocomplete + * (`extractAtPrefix` in file-mention-provider.ts): a mention starts at + * `@` preceded by start-of-text or a delimiter, and runs until the next + * delimiter — or, for `@"..."`, until the closing quote. + */ + +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { isAbsolute, resolve } from 'node:path'; + +/** + * Characters that end an unquoted mention token. Superset of the + * editor autocomplete's PATH_DELIMITERS (adds newlines, which a + * single-line completion context never sees but submitted multi-line + * text can contain). Keep in sync with file-mention-provider.ts. + */ +export const MENTION_DELIMITERS: ReadonlySet = new Set([ + ' ', + '\t', + '\n', + '\r', + '"', + "'", + '=', +]); + +/** + * Trailing punctuation (ASCII + CJK) retried without when the raw token + * does not resolve — covers `看看 @报告.docx。` style sentences where + * the full stop glues onto the token. Punctuation-only suffixes: a + * character run containing letters/digits (e.g. a real extension) never + * matches, so `@foo.md` is not truncated to `foo`. + */ +const TRAILING_PUNCTUATION = /[.,;:!?)\]}>。,、;:!?)】》〉…]+$/; + +/** + * CJK sentence punctuation splits a token into retry prefixes: CJK prose + * puts no space after a clause (`@a.ts,接着…`), so the token may run past + * the path. ASCII `.`/`,` are NOT split points — they legitimately appear + * inside file names (`main.ts`), and splitting there could mis-resolve a + * missing `@src.md` onto an existing `src/`. CJK punctuation in real file + * names is rare, and existence gating tries the full token first anyway. + */ +const CJK_PUNCTUATION = /[。,、;:!?)】》〉… ]/g; +const MAX_SPLIT_CANDIDATES = 8; + +export interface ResolvedFileMention { + /** The literal token as typed, including `@` (and quotes if any). */ + readonly raw: string; + /** Absolute filesystem path the token resolved to. */ + readonly absolutePath: string; +} + +export interface FileMentionResolution { + /** Input text with every resolved mention rewritten to its absolute path. */ + readonly text: string; + /** Resolved mentions in the order they appeared; empty when none hit. */ + readonly mentions: readonly ResolvedFileMention[]; +} + +/** + * Rewrite existence-verified `@` mentions in `text` to absolute paths. + * + * `workDir` must be the same base the mention autocomplete scans + * (appState.workDir) so that every path the completion inserted is + * guaranteed to resolve here. + */ +export function resolveFileMentions(text: string, workDir: string): FileMentionResolution { + const mentions: ResolvedFileMention[] = []; + let out = ''; + let cursor = 0; + + for (let i = 0; i < text.length; i += 1) { + if (text[i] !== '@') continue; + if (i > 0 && !MENTION_DELIMITERS.has(text[i - 1] ?? '')) continue; + + const token = readMentionToken(text, i); + if (token === null) continue; + + const hit = resolveCandidate(token.candidates, workDir); + if (hit === null) { + i += token.rawLength - 1; + continue; + } + + out += text.slice(cursor, i); + out += formatResolvedPath(hit.absolutePath); + mentions.push({ raw: text.slice(i, i + hit.consumedLength), absolutePath: hit.absolutePath }); + cursor = i + hit.consumedLength; + i = cursor - 1; + } + + if (mentions.length === 0) return { text, mentions }; + out += text.slice(cursor); + return { text: out, mentions }; +} + +interface MentionToken { + /** Total length of the token including `@` (and quotes if any). */ + readonly rawLength: number; + /** + * Path candidates to try in order, longest first. Each carries the + * token length it would consume when it resolves, so punctuation + * dropped by the retry stays in the surrounding text. + */ + readonly candidates: readonly { path: string; consumedLength: number }[]; +} + +function readMentionToken(text: string, atIndex: number): MentionToken | null { + // Quoted form: @"path with spaces" — consume through the closing quote. + if (text[atIndex + 1] === '"') { + const close = text.indexOf('"', atIndex + 2); + if (close === -1) return null; + const inner = text.slice(atIndex + 2, close); + if (inner.length === 0) return null; + return { + rawLength: close + 1 - atIndex, + candidates: [{ path: inner, consumedLength: close + 1 - atIndex }], + }; + } + + let end = atIndex + 1; + while (end < text.length && !MENTION_DELIMITERS.has(text[end] ?? '')) end += 1; + const token = text.slice(atIndex + 1, end); + if (token.length === 0) return null; + + // Longest candidate first: the full token, then progressively shorter + // retries (trailing punctuation stripped, prefixes cut at CJK + // punctuation). Existence gating picks the first that resolves. + const paths = [token]; + const trimmed = token.replace(TRAILING_PUNCTUATION, ''); + if (trimmed.length > 0 && trimmed !== token) paths.push(trimmed); + CJK_PUNCTUATION.lastIndex = 0; + const splitOffsets: number[] = []; + let match: RegExpExecArray | null; + while ((match = CJK_PUNCTUATION.exec(token)) !== null) { + if (match.index === 0) break; + splitOffsets.push(match.index); + if (splitOffsets.length >= MAX_SPLIT_CANDIDATES) break; + } + for (const offset of splitOffsets.toReversed()) { + paths.push(token.slice(0, offset)); + } + + const seen = new Set(); + const candidates: { path: string; consumedLength: number }[] = []; + for (const path of paths) { + if (seen.has(path)) continue; + seen.add(path); + candidates.push({ path, consumedLength: 1 + path.length }); + } + return { rawLength: 1 + token.length, candidates }; +} + +function resolveCandidate( + candidates: readonly { path: string; consumedLength: number }[], + workDir: string, +): { absolutePath: string; consumedLength: number } | null { + for (const candidate of candidates) { + const absolutePath = toAbsolutePath(candidate.path, workDir); + if (existsSync(absolutePath)) { + return { absolutePath, consumedLength: candidate.consumedLength }; + } + } + return null; +} + +function toAbsolutePath(path: string, workDir: string): string { + if (path === '~' || path.startsWith('~/')) { + return resolve(homedir(), path.slice(2)); + } + if (isAbsolute(path)) return resolve(path); + return resolve(workDir, path); +} + +/** Paths containing whitespace are quoted so the model sees one token. */ +function formatResolvedPath(absolutePath: string): string { + return /\s/.test(absolutePath) ? `"${absolutePath}"` : absolutePath; +} 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 5e2fa6ef3..2176c37ba 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 @@ -4,7 +4,22 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; +import { + FileMentionProvider, + getDirectoryNavigationSuggestions, +} from '#/tui/components/editor/file-mention-provider'; + +// Overridable homedir so `@~/...` navigation is testable against a temp +// directory; every other node:os export keeps its real behavior. vitest +// hoists vi.mock above the imports, so placement here is fine. +const mockHome = vi.hoisted(() => ({ path: '' })); +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => (mockHome.path.length > 0 ? mockHome.path : actual.homedir()), + }; +}); function ctrl(): AbortSignal { return new AbortController().signal; @@ -574,3 +589,101 @@ describe('FileMentionProvider', () => { }); }); }); + +describe('getDirectoryNavigationSuggestions', () => { + let workDir: string; + let fakeHome: string; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'nav-work-')); + fakeHome = mkdtempSync(join(tmpdir(), 'nav-home-')); + mockHome.path = fakeHome; + + mkdirSync(join(workDir, 'src')); + writeFileSync(join(workDir, 'src', 'main.ts'), ''); + writeFileSync(join(workDir, 'src', 'ROADMAP深度研究报告.docx'), ''); + writeFileSync(join(workDir, 'src', 'with space.md'), ''); + mkdirSync(join(workDir, 'src', 'deep')); + writeFileSync(join(workDir, 'src', 'deep', 'main-deep.ts'), ''); + writeFileSync(join(workDir, 'src', '.hidden'), ''); + + // Junk that recursive fuzzy search would surface above ~/Downloads. + mkdirSync(join(fakeHome, 'Downloads')); + mkdirSync(join(fakeHome, '.Trash', 'app', 'XPCServices', 'Downloader.xpc'), { + recursive: true, + }); + }); + + afterEach(() => { + mockHome.path = ''; + rmSync(workDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + }); + + it('returns null for bare fragments (no slash) so fuzzy search handles them', () => { + expect(getDirectoryNavigationSuggestions(workDir, '@Down')).toBeNull(); + }); + + it('lists only the single directory level, prefix matches before substring', () => { + const result = getDirectoryNavigationSuggestions(workDir, '@src/ma'); + // `main.ts` prefix-matches `ma`; `ROADMAP…` only substring-matches (roadMAp). + // `deep/main-deep.ts` must not leak up from the nested level. + expect(result?.items.map((item) => item.label)).toEqual(['main.ts', 'ROADMAP深度研究报告.docx']); + expect(result?.items[0]?.value).toBe('@src/main.ts'); + }); + + it('matches CJK fragments by substring', () => { + const result = getDirectoryNavigationSuggestions(workDir, '@src/深度'); + expect(result?.items.map((item) => item.label)).toEqual(['ROADMAP深度研究报告.docx']); + }); + + it('lists a directory on trailing slash, directories before files', () => { + const result = getDirectoryNavigationSuggestions(workDir, '@src/'); + expect(result?.items.map((item) => item.label)).toEqual([ + 'deep/', + 'main.ts', + 'ROADMAP深度研究报告.docx', + 'with space.md', + ]); + }); + + it('hides dot entries unless the fragment starts with a dot', () => { + const listed = getDirectoryNavigationSuggestions(workDir, '@src/'); + expect(listed?.items.map((item) => item.label)).not.toContain('.hidden'); + const dotted = getDirectoryNavigationSuggestions(workDir, '@src/.hi'); + expect(dotted?.items.map((item) => item.label)).toEqual(['.hidden']); + }); + + it('expands ~/ and keeps it as typed in the inserted value', () => { + const result = getDirectoryNavigationSuggestions(workDir, '@~/Down'); + expect(result?.items.map((item) => item.value)).toEqual(['@~/Downloads/']); + expect(result?.items[0]?.description).toBe(join(fakeHome, 'Downloads')); + }); + + it('does not surface hidden-tree junk for scoped queries', () => { + const result = getDirectoryNavigationSuggestions(workDir, '@~/Down'); + const labels = result?.items.map((item) => item.label) ?? []; + expect(labels).not.toContain('Downloader.xpc/'); + expect(labels).toEqual(['Downloads/']); + }); + + it('quotes values for names containing spaces', () => { + const result = getDirectoryNavigationSuggestions(workDir, '@src/with'); + expect(result?.items[0]?.value).toBe('@"src/with space.md"'); + }); + + it('returns null when the directory part does not resolve', () => { + expect(getDirectoryNavigationSuggestions(workDir, '@nope/x')).toBeNull(); + }); + + it('returns null when nothing in the directory matches', () => { + expect(getDirectoryNavigationSuggestions(workDir, '@src/zzz')).toBeNull(); + }); + + it('takes priority over the fallback scanner inside the provider', async () => { + const provider = new FileMentionProvider([], workDir, NO_FD); + const text = 'see @~/Down'; + const result = await provider.getSuggestions([text], 0, text.length, { signal: ctrl() }); + expect(result?.items.map((item) => item.value)).toEqual(['@~/Downloads/']); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/file-mention.test.ts b/apps/kimi-code/test/tui/utils/file-mention.test.ts new file mode 100644 index 000000000..9a5fbb0e4 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/file-mention.test.ts @@ -0,0 +1,98 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { resolveFileMentions } from '@/tui/utils/file-mention'; + +let workDir: string; +let outsideDir: string; + +beforeAll(() => { + workDir = mkdtempSync(join(tmpdir(), 'mention-work-')); + outsideDir = mkdtempSync(join(tmpdir(), 'mention-outside-')); + mkdirSync(join(workDir, 'src')); + writeFileSync(join(workDir, 'src', 'main.ts'), ''); + writeFileSync(join(workDir, '深度研究报告.docx'), ''); + writeFileSync(join(workDir, 'with space.md'), ''); + writeFileSync(join(outsideDir, 'report.docx'), ''); +}); + +afterAll(() => { + rmSync(workDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); +}); + +describe('resolveFileMentions', () => { + it('rewrites a cwd-relative mention to an absolute path and strips @', () => { + const result = resolveFileMentions('look at @src/main.ts please', workDir); + expect(result.text).toBe(`look at ${join(workDir, 'src', 'main.ts')} please`); + expect(result.mentions).toEqual([ + { raw: '@src/main.ts', absolutePath: join(workDir, 'src', 'main.ts') }, + ]); + }); + + it('resolves non-ASCII file names', () => { + const result = resolveFileMentions('读一下 @深度研究报告.docx', workDir); + expect(result.text).toBe(`读一下 ${join(workDir, '深度研究报告.docx')}`); + expect(result.mentions).toHaveLength(1); + }); + + it('resolves quoted mentions and re-quotes paths containing spaces', () => { + const result = resolveFileMentions('see @"with space.md" now', workDir); + expect(result.text).toBe(`see "${join(workDir, 'with space.md')}" now`); + expect(result.mentions[0]?.raw).toBe('@"with space.md"'); + }); + + it('resolves absolute-path mentions outside the work dir', () => { + const target = join(outsideDir, 'report.docx'); + const result = resolveFileMentions(`summarize @${target}`, workDir); + expect(result.text).toBe(`summarize ${target}`); + }); + + it('retries without trailing CJK punctuation and keeps it in the text', () => { + const result = resolveFileMentions('先看 @src/main.ts,再动手。', workDir); + expect(result.text).toBe(`先看 ${join(workDir, 'src', 'main.ts')},再动手。`); + expect(result.mentions[0]?.raw).toBe('@src/main.ts'); + }); + + it('leaves non-existent paths untouched (existence gating)', () => { + const text = 'install @types/node and @anthropic-ai/sdk'; + expect(resolveFileMentions(text, workDir)).toEqual({ text, mentions: [] }); + }); + + it('ignores @ that is not at a token start (emails)', () => { + const text = 'mail me at foo@src please'; + expect(resolveFileMentions(text, workDir)).toEqual({ text, mentions: [] }); + }); + + it('resolves multiple mentions in one message', () => { + const result = resolveFileMentions('diff @src/main.ts and @"with space.md"', workDir); + expect(result.mentions).toHaveLength(2); + expect(result.text).toBe( + `diff ${join(workDir, 'src', 'main.ts')} and "${join(workDir, 'with space.md')}"`, + ); + }); + + it('resolves directory mentions with a trailing slash', () => { + const result = resolveFileMentions('scan @src/ deeply', workDir); + expect(result.text).toBe(`scan ${join(workDir, 'src')} deeply`); + }); + + it('handles mentions at the start of a line in multi-line text', () => { + const result = resolveFileMentions('first line\n@src/main.ts is key', workDir); + expect(result.text).toBe(`first line\n${join(workDir, 'src', 'main.ts')} is key`); + }); + + it('leaves a bare @ and an unterminated quote untouched', () => { + const text = 'a @ b and @"unterminated'; + expect(resolveFileMentions(text, workDir)).toEqual({ text, mentions: [] }); + }); + + it('does not truncate a real extension when the file is missing', () => { + // `src` exists as a directory, but `@src.md` must not fall back to it. + const text = 'open @src.md'; + expect(resolveFileMentions(text, workDir)).toEqual({ text, mentions: [] }); + }); +}); From 109816d2df6c245007a9bc3d80fa81fd9add2cb2 Mon Sep 17 00:00:00 2001 From: UncertaintyDeterminesYou4ndMe <72533078+UncertaintyDeterminesYou4ndMe@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:58:29 +0800 Subject: [PATCH 2/3] chore: add changeset --- .changeset/resolve-at-file-mentions.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/resolve-at-file-mentions.md diff --git a/.changeset/resolve-at-file-mentions.md b/.changeset/resolve-at-file-mentions.md new file mode 100644 index 000000000..6c780b1e5 --- /dev/null +++ b/.changeset/resolve-at-file-mentions.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Resolve `@` file mentions to real paths before sending, so the model no longer treats the `@` as part of the filename. Existence-verified mentions (relative, absolute, `~/`, quoted, or with trailing CJK punctuation) are rewritten to absolute paths on submit; unresolved tokens like `@types/node` pass through untouched. Scoped mentions containing a `/` now navigate that directory shell-style (prefix matches first, hidden entries only for dot fragments) instead of a recursive fuzzy search that surfaced `~/.Trash/**` above `~/Downloads`. From 711873c7756d80f11c3b27443efa304c327a6f7b Mon Sep 17 00:00:00 2001 From: UncertaintyDeterminesYou4ndMe <72533078+UncertaintyDeterminesYou4ndMe@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:58:26 +0800 Subject: [PATCH 3/3] fix(tui): resolve @ mentions in skill and plugin command args Skill and plugin slash commands dispatch through sendSkillActivation / activatePluginCommand with the raw argument string, bypassing the submit-time mention resolution applied to normal prompts. A command like /review @src/main.ts thus handed the skill a literal @src/main.ts. Resolve mentions at these two SDK boundaries as well. --- apps/kimi-code/src/tui/kimi-tui.ts | 10 ++++- .../test/tui/kimi-tui-message-flow.test.ts | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 1448b1aac..0904bc6df 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1248,7 +1248,12 @@ export class KimiTUI { sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { this.beginSessionRequest(); - void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { + // Skill/plugin arguments accept `@` file mentions too (completion is + // offered in slash-command arguments), so resolve them to real paths + // here as well — the SDK receives args as a plain string, so we send + // the resolved text directly. + const resolvedArgs = resolveFileMentions(skillArgs, this.state.appState.workDir).text; + void session.activateSkill(skillName, resolvedArgs).catch((error: unknown) => { const message = formatErrorMessage(error); this.failSessionRequest(`Skill "${skillName}" failed: ${message}`); }); @@ -1261,7 +1266,8 @@ export class KimiTUI { args: string, ): void { this.beginSessionRequest(); - void session.activatePluginCommand(pluginId, commandName, args).catch((error: unknown) => { + const resolvedArgs = resolveFileMentions(args, this.state.appState.workDir).text; + void session.activatePluginCommand(pluginId, commandName, resolvedArgs).catch((error: unknown) => { const message = formatErrorMessage(error); this.failSessionRequest(`Command "${pluginId}:${commandName}" failed: ${message}`); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 550122428..64b641f98 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -90,6 +90,13 @@ interface MessageDriver { handleUserInput(text: string): void; persistInputHistory(text: string): Promise; sendQueuedMessage(session: unknown, item: QueuedMessage): void; + sendSkillActivation(session: unknown, skillName: string, skillArgs: string): void; + activatePluginCommand( + session: unknown, + pluginId: string, + commandName: string, + args: string, + ): void; getCurrentSessionId(): string; } @@ -207,6 +214,7 @@ function makeSession(overrides: Record = {}) { reloadPlugins: vi.fn(async () => ({ added: [], removed: [], errors: [] })), reloadSession: vi.fn(async () => ({})), activateSkill: vi.fn(async () => {}), + activatePluginCommand: vi.fn(async () => {}), getPluginInfo: vi.fn(async (id: string) => ({ id, displayName: id, @@ -3864,6 +3872,43 @@ command = "vim" }); }); + it('resolves @ file mentions in skill activation args', async () => { + const dir = await makeTempHome(); + const filePath = join(dir, 'notes.md'); + await writeFile(filePath, 'x'); + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.sendSkillActivation(session, 'review', `@${filePath} summarize`); + + expect(session.activateSkill).toHaveBeenCalledWith('review', `${filePath} summarize`); + }); + + it('leaves unresolved @ tokens untouched in skill activation args', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.sendSkillActivation(session, 'review', 'check @types/node please'); + + expect(session.activateSkill).toHaveBeenCalledWith('review', 'check @types/node please'); + }); + + it('resolves @ file mentions in plugin command args', async () => { + const dir = await makeTempHome(); + const filePath = join(dir, 'data.json'); + await writeFile(filePath, '{}'); + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.activatePluginCommand(session, 'kimi-webbridge', 'run', `@${filePath}`); + + expect(session.activatePluginCommand).toHaveBeenCalledWith( + 'kimi-webbridge', + 'run', + filePath, + ); + }); + it('removes a plugin record without auto-running any cleanup skill', async () => { const session = makeSession(); const { driver } = await makeDriver(session);