Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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/sdk-suggest-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-sdk": minor
---

Add `suggestFiles(workDir, { query, limit })` to `KimiHarness`: session-less fuzzy file suggestions from the agent-core-v2 workspace fs service, `undefined` on the v1 engine.
5 changes: 5 additions & 0 deletions .changeset/vscode-at-engine-suggest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kimi-code": patch
---

Highlight matched characters in @ file suggestions and allow folders to be inserted as mentions.
5 changes: 5 additions & 0 deletions .changeset/vscode-at-menu-entries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kimi-code": patch
---

Show the image/video picker entry in the @ menu only before a search query is typed, and remove the Browse folders mode.
5 changes: 5 additions & 0 deletions .changeset/vscode-at-scroll-jitter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kimi-code": patch
---

Fix the @ and / suggestion lists jittering when the mouse rests at the scroll edge.
2 changes: 2 additions & 0 deletions apps/vscode/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface ProjectFile {
path: string;
name: string;
isDirectory: boolean;
/** Matched-character offsets into `path`, for mention-style highlighting. */
matchPositions?: number[];
}

export interface FileChange {
Expand Down
29 changes: 23 additions & 6 deletions apps/vscode/src/handlers/file.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ import {
resolveWorkspacePath,
type WorkspacePath,
} from "../utils/workspace-path";
import type { Handler } from "./types";
import type { Handler, HandlerContext } from "./types";

interface GetProjectFilesParams {
query?: string;
directory?: string;
}
interface PickMediaParams { maxCount?: number; includeVideo?: boolean }
interface FilePathParams { filePath: string }
Expand All @@ -34,13 +33,31 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
".ico": "image/x-icon",
};

const FILE_SUGGEST_LIMIT = 20;

const getProjectFiles: Handler<GetProjectFilesParams | undefined, ProjectFile[]> = async (params, ctx) => {
if (!ctx.workDirUri) return [];
return params?.directory !== undefined
? ctx.fileManager.listDirectory(ctx.workDirUri, params.directory)
: ctx.fileManager.searchFiles(ctx.workDirUri, params?.query);
if (!ctx.workDirUri || !ctx.workDir) return [];
const suggested = await suggestFiles(ctx, params?.query ?? "");
if (suggested !== undefined) return suggested;
return ctx.fileManager.searchFiles(ctx.workDirUri, params?.query);
};

async function suggestFiles(ctx: HandlerContext, query: string): Promise<ProjectFile[] | undefined> {
try {
const result = await ctx.harness.suggestFiles(ctx.requireWorkDir(), { query, limit: FILE_SUGGEST_LIMIT });
if (result === undefined) return undefined;
return result.items.map((item) => ({
path: item.path,
name: item.name,
isDirectory: item.kind === "directory",
matchPositions: [...item.matchPositions],
}));
} catch (error) {
ctx.logError("File suggest failed", error);
return [];
}
}

const pickMedia: Handler<PickMediaParams, string[]> = async (params) => {
const maxCount = params.maxCount ?? 9;
const includeVideo = params.includeVideo ?? true;
Expand Down
39 changes: 0 additions & 39 deletions apps/vscode/src/managers/file.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { buildCaseInsensitiveGlobLiteral } from "../utils/string";
import {
isWorkspacePathContained,
relativeWorkspacePath,
resolveWorkspacePath,
} from "../utils/workspace-path";

export type BroadcastFn = (event: string, data: unknown, webviewId?: string) => void;
Expand Down Expand Up @@ -43,16 +42,6 @@ const IGNORE_DIRS = new Set([
".turbo",
]);

const IGNORE_EXT = new Set([".lock", ".log", ".map", ".min.js", ".min.css", ".chunk.js", ".chunk.css"]);

function shouldIgnore(name: string): boolean {
if (IGNORE_DIRS.has(name)) {
return true;
}
const ext = path.extname(name).toLowerCase();
return IGNORE_EXT.has(ext);
}

const SEARCH_EXCLUDE = `{${[...IGNORE_DIRS].map((d) => `**/${d}`).join(",")}}`;

interface ViewState {
Expand Down Expand Up @@ -170,34 +159,6 @@ export class FileManager {
return results.filter((result): result is ProjectFile => result !== undefined);
}

async listDirectory(workDirUri: vscode.Uri, directory: string): Promise<ProjectFile[]> {
const requested = resolveWorkspacePath(workDirUri, directory, { allowRoot: true });
if (requested === undefined || !(await isWorkspacePathContained(workDirUri, requested.uri))) return [];
try {
const entries = await vscode.workspace.fs.readDirectory(requested.uri);
const resolvedEntries = await Promise.all(
entries.map(async ([name, type]): Promise<ProjectFile | undefined> => {
if (shouldIgnore(name)) return undefined;
const relativePath = requested.relativePath ? `${requested.relativePath}/${name}` : name;
const entry = resolveWorkspacePath(workDirUri, relativePath);
if (entry === undefined || !(await isWorkspacePathContained(workDirUri, entry.uri))) return undefined;
return {
path: entry.relativePath,
name,
isDirectory: (type & vscode.FileType.Directory) !== 0,
};
}),
);
return resolvedEntries
.filter((entry): entry is ProjectFile => entry !== undefined)
.toSorted((a, b) =>
a.isDirectory === b.isDirectory ? a.name.localeCompare(b.name) : a.isDirectory ? -1 : 1,
);
} catch {
return [];
}
}

dispose(): void {
for (const d of this.disposables) {
d.dispose();
Expand Down
41 changes: 41 additions & 0 deletions apps/vscode/test/webview/mention-match.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { mentionMatchSpans } from '@/lib/mention-match';

describe('mentionMatchSpans', () => {
it('returns the whole text as a plain span when positions are missing or empty', () => {
expect(mentionMatchSpans('app.ts', undefined, 0)).toEqual([{ text: 'app.ts', hit: false }]);
expect(mentionMatchSpans('app.ts', [], 0)).toEqual([{ text: 'app.ts', hit: false }]);
expect(mentionMatchSpans('', [0], 0)).toEqual([{ text: '', hit: false }]);
});

it('splits hit and plain runs by position', () => {
expect(mentionMatchSpans('app.ts', [0, 1, 4], 0)).toEqual([
{ text: 'ap', hit: true },
{ text: 'p.', hit: false },
{ text: 't', hit: true },
{ text: 's', hit: false },
]);
});

it('shifts path-frame positions into the name frame via start', () => {
// path 'src/app.ts' (len 10), name 'app.ts' → start = 6
expect(mentionMatchSpans('app.ts', [6, 7, 10], 6)).toEqual([
{ text: 'ap', hit: true },
{ text: 'p.', hit: false },
{ text: 't', hit: true },
{ text: 's', hit: false },
]);
});

it('drops positions outside the text frame', () => {
expect(mentionMatchSpans('app.ts', [0, 1, 2], 6)).toEqual([{ text: 'app.ts', hit: false }]);
expect(mentionMatchSpans('src', [0, 6, 7], 0)).toEqual([
{ text: 's', hit: true },
{ text: 'rc', hit: false },
]);
});

it('handles adjacent hits as a single run', () => {
expect(mentionMatchSpans('abc', [0, 1, 2], 0)).toEqual([{ text: 'abc', hit: true }]);
});
});
65 changes: 37 additions & 28 deletions apps/vscode/test/webview/useFilePicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ describe('search', () => {
expect(result.current.fileItems[0]).toMatchObject({ name: 'app.ts', path: 'src/app.ts' });
});

it('caps the displayed items at 50', async () => {
getProjectFiles.mockResolvedValue(
Array.from({ length: 60 }, (_, i) => ({ name: `f${i}.ts`, path: `f${i}.ts`, isDirectory: false })),
);
const { result } = renderHook(() => useFilePicker(at('f'), noop, noop, noop), { wrapper: createWrapper() });
await waitFor(() => { expect(result.current.fileItems).toHaveLength(50); });
});

it('debounces rapid query changes into a single request after 100ms', async () => {
const { rerender } = renderHook(({ token }) => useFilePicker(token, noop, noop, noop), {
initialProps: { token: at('') as Token },
Expand All @@ -67,30 +75,25 @@ describe('search', () => {
});
});

describe('search and folder modes', () => {
it('loads the given directory in folder mode', async () => {
const { result } = renderHook(() => useFilePicker(at(''), noop, noop, noop), { wrapper: createWrapper() });
await waitFor(() => { expect(getProjectFiles).toHaveBeenCalledTimes(1); });

act(() => {
result.current.setFilePickerMode('folder');
result.current.setFolderPath('src');
describe('media option', () => {
it('shows the media option only for an empty query', async () => {
const { result, rerender } = renderHook(({ token }) => useFilePicker(token, noop, noop, noop), {
initialProps: { token: at('') as Token },
wrapper: createWrapper(),
});
await waitFor(() => { expect(getProjectFiles).toHaveBeenCalledWith({ directory: 'src' }); });
expect(result.current.showMediaOption).toBe(true);
expect(result.current.fileMenuHeaderCount).toBe(1);

rerender({ token: at('a') });
expect(result.current.showMediaOption).toBe(false);
expect(result.current.fileMenuHeaderCount).toBe(0);
});

it('shows directory contents in folder mode', async () => {
getProjectFiles.mockImplementation((params?: { directory?: string }) =>
Promise.resolve(params?.directory ? [{ name: 'index.ts', path: 'src/index.ts', isDirectory: false }] : []),
);
it('hides the media option when media cannot be added', () => {
useChatStore.setState({ isStreaming: true });
const { result } = renderHook(() => useFilePicker(at(''), noop, noop, noop), { wrapper: createWrapper() });
await waitFor(() => { expect(getProjectFiles).toHaveBeenCalled(); });

act(() => {
result.current.setFilePickerMode('folder');
result.current.setFolderPath('src');
});
await waitFor(() => { expect(result.current.fileItems).toEqual([{ name: 'index.ts', path: 'src/index.ts', isDirectory: false }]); });
expect(result.current.showMediaOption).toBe(false);
expect(result.current.fileMenuHeaderCount).toBe(0);
});
});

Expand Down Expand Up @@ -121,6 +124,15 @@ describe('keyboard navigation', () => {
expect(result.current.selectedIndex).toBe(maxIndex - 1);
});

it('calls onPickMedia when Enter selects the media option', async () => {
const onPickMedia = vi.fn();
const { result } = renderHook(() => useFilePicker(at(''), noop, onPickMedia, noop), { wrapper: createWrapper() });

expect(result.current.selectedIndex).toBe(0);
key(result, 'Enter');
expect(onPickMedia).toHaveBeenCalledTimes(1);
});

it('calls onInsertFile when Enter selects a file', async () => {
getProjectFiles.mockResolvedValue([{ name: 'a.ts', path: 'src/a.ts', isDirectory: false }]);
const onInsertFile = vi.fn();
Expand All @@ -134,19 +146,16 @@ describe('keyboard navigation', () => {
expect(onInsertFile).toHaveBeenCalledWith('src/a.ts');
});

it('enters folder mode when Enter selects a directory', async () => {
getProjectFiles.mockImplementation((params?: { query?: string }) =>
Promise.resolve(params?.query ? [{ name: 'src', path: 'src', isDirectory: true }] : []),
);
const { result } = renderHook(() => useFilePicker(at('sr'), noop, noop, noop), { wrapper: createWrapper() });
it('calls onInsertFile with the directory path when Enter selects a directory', async () => {
getProjectFiles.mockResolvedValue([{ name: 'src', path: 'src', isDirectory: true }]);
const onInsertFile = vi.fn();
const { result } = renderHook(() => useFilePicker(at('sr'), onInsertFile, noop, noop), { wrapper: createWrapper() });
await waitFor(() => { expect(result.current.fileItems).toHaveLength(1); });

act(() => {
result.current.setSelectedIndex(result.current.fileMenuHeaderCount);
});
key(result, 'Enter');
expect(result.current.filePickerMode).toBe('folder');
expect(result.current.folderPath).toBe('src');
await waitFor(() => { expect(getProjectFiles).toHaveBeenCalledWith({ directory: 'src' }); });
expect(onInsertFile).toHaveBeenCalledWith('src');
});
});
Loading
Loading