Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/resolve-at-file-mentions.md
Original file line number Diff line number Diff line change
@@ -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`.
109 changes: 106 additions & 3 deletions apps/kimi-code/src/tui/components/editor/file-mention-provider.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 `<dir>/<fragment>`, 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
Expand Down
35 changes: 29 additions & 6 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve mentions for skill and plugin command args

This resolver only runs for normal prompts and steering; dynamic skill/plugin slash commands are dispatched through sendSkillActivation / activatePluginCommand with raw intent.args (apps/kimi-code/src/tui/commands/dispatch.ts:205 and :219). In a command such as /review @src/main.ts or a plugin command with an @ file argument, the editor still offers file-mention completion in slash-command arguments, but the activated skill/plugin receives the literal @src/main.ts, so the new submit-time guarantee is skipped for that workflow. Apply the same resolution to activation args before sending them to the SDK.

Useful? React with 👍 / 👎.

const extraction = extractMediaAttachments(mentionResolution.text, this.imageStore);
if (!this.validateMediaCapabilities(extraction)) return;
const session = this.session;
if (session === undefined) {
Expand All @@ -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);
}
Expand Down Expand Up @@ -1240,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}`);
});
Expand All @@ -1253,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}`);
});
Expand All @@ -1272,15 +1286,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;
}
Expand All @@ -1295,7 +1317,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}`);
});
Expand Down
Loading