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);