diff --git a/.changeset/sdk-suggest-files.md b/.changeset/sdk-suggest-files.md new file mode 100644 index 00000000000..ec6d260b27e --- /dev/null +++ b/.changeset/sdk-suggest-files.md @@ -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. diff --git a/.changeset/vscode-at-engine-suggest.md b/.changeset/vscode-at-engine-suggest.md new file mode 100644 index 00000000000..f2ccb968093 --- /dev/null +++ b/.changeset/vscode-at-engine-suggest.md @@ -0,0 +1,5 @@ +--- +"kimi-code": patch +--- + +Highlight matched characters in @ file suggestions and allow folders to be inserted as mentions. diff --git a/.changeset/vscode-at-menu-entries.md b/.changeset/vscode-at-menu-entries.md new file mode 100644 index 00000000000..24b57657ec5 --- /dev/null +++ b/.changeset/vscode-at-menu-entries.md @@ -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. diff --git a/.changeset/vscode-at-scroll-jitter.md b/.changeset/vscode-at-scroll-jitter.md new file mode 100644 index 00000000000..fecde932570 --- /dev/null +++ b/.changeset/vscode-at-scroll-jitter.md @@ -0,0 +1,5 @@ +--- +"kimi-code": patch +--- + +Fix the @ and / suggestion lists jittering when the mouse rests at the scroll edge. diff --git a/apps/vscode/shared/types.ts b/apps/vscode/shared/types.ts index a16351cfde6..79454849019 100644 --- a/apps/vscode/shared/types.ts +++ b/apps/vscode/shared/types.ts @@ -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 { diff --git a/apps/vscode/src/handlers/file.handler.ts b/apps/vscode/src/handlers/file.handler.ts index abc49cabf20..1ae238aaefc 100644 --- a/apps/vscode/src/handlers/file.handler.ts +++ b/apps/vscode/src/handlers/file.handler.ts @@ -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 } @@ -34,13 +33,31 @@ const IMAGE_MIME_TYPES: Record = { ".ico": "image/x-icon", }; +const FILE_SUGGEST_LIMIT = 20; + const getProjectFiles: Handler = 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 { + 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 = async (params) => { const maxCount = params.maxCount ?? 9; const includeVideo = params.includeVideo ?? true; diff --git a/apps/vscode/src/managers/file.manager.ts b/apps/vscode/src/managers/file.manager.ts index 2e1b1f6c171..1427bcdfc2c 100644 --- a/apps/vscode/src/managers/file.manager.ts +++ b/apps/vscode/src/managers/file.manager.ts @@ -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; @@ -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 { @@ -170,34 +159,6 @@ export class FileManager { return results.filter((result): result is ProjectFile => result !== undefined); } - async listDirectory(workDirUri: vscode.Uri, directory: string): Promise { - 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 => { - 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(); diff --git a/apps/vscode/test/webview/mention-insert.test.ts b/apps/vscode/test/webview/mention-insert.test.ts new file mode 100644 index 00000000000..bb366be7c2d --- /dev/null +++ b/apps/vscode/test/webview/mention-insert.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { computeMentionInsert } from '@/components/inputarea/utils'; + +describe('computeMentionInsert', () => { + it('replaces the active token with the mention and moves the cursor past it', () => { + const result = computeMentionInsert({ + text: 'check @ap', + cursorPos: 9, + filePath: 'src/app.ts', + activeToken: { start: 6 }, + isAppend: false, + }); + expect(result.newText).toBe('check @src/app.ts '); + expect(result.newCursorPos).toBe(result.newText.length); + }); + + it('appends the mention when there is no active token', () => { + const result = computeMentionInsert({ + text: 'hi ', + cursorPos: 3, + filePath: 'a.ts', + activeToken: null, + isAppend: true, + }); + expect(result.newText).toBe('hi @a.ts '); + expect(result.newCursorPos).toBe(result.newText.length); + }); + + it('quotes a path containing spaces so whitespace cannot split the mention', () => { + const result = computeMentionInsert({ + text: '@my', + cursorPos: 3, + filePath: 'My Folder/app.ts', + activeToken: { start: 0 }, + isAppend: false, + }); + expect(result.newText).toBe('@"My Folder/app.ts" '); + expect(result.newCursorPos).toBe(result.newText.length); + }); + + it('quotes a path containing spaces in append mode', () => { + const result = computeMentionInsert({ + text: '', + cursorPos: 0, + filePath: 'My Folder', + activeToken: null, + isAppend: true, + }); + expect(result.newText).toBe('@"My Folder" '); + }); +}); diff --git a/apps/vscode/test/webview/mention-match.test.ts b/apps/vscode/test/webview/mention-match.test.ts new file mode 100644 index 00000000000..27252c94309 --- /dev/null +++ b/apps/vscode/test/webview/mention-match.test.ts @@ -0,0 +1,59 @@ +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('extends a hit across the complete surrogate pair', () => { + // 'a😀b': 😀 occupies UTF-16 units 1 and 2; a match reported at offset 1 + // must not split the pair between two spans. + expect(mentionMatchSpans('a\u{1F600}b', [1], 0)).toEqual([ + { text: 'a', hit: false }, + { text: '\u{1F600}', hit: true }, + { text: 'b', hit: false }, + ]); + }); + + it('keeps an unmatched surrogate pair in one plain run', () => { + expect(mentionMatchSpans('\u{1F600}ab', [2], 0)).toEqual([ + { text: '\u{1F600}', hit: false }, + { text: 'a', hit: true }, + { text: 'b', hit: false }, + ]); + }); + + it('handles adjacent hits as a single run', () => { + expect(mentionMatchSpans('abc', [0, 1, 2], 0)).toEqual([{ text: 'abc', hit: true }]); + }); +}); diff --git a/apps/vscode/test/webview/useFilePicker.test.tsx b/apps/vscode/test/webview/useFilePicker.test.tsx index 6fd46a3ab89..51557add905 100644 --- a/apps/vscode/test/webview/useFilePicker.test.tsx +++ b/apps/vscode/test/webview/useFilePicker.test.tsx @@ -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 }, @@ -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); }); }); @@ -121,6 +124,72 @@ describe('keyboard navigation', () => { expect(result.current.selectedIndex).toBe(maxIndex - 1); }); + it('lets Enter fall through when there is no selectable entry', async () => { + getProjectFiles.mockResolvedValue([]); + const { result } = renderHook(() => useFilePicker(at('zzz'), noop, noop, noop), { wrapper: createWrapper() }); + await waitFor(() => { expect(result.current.isLoading).toBe(false); }); + + let handled = true; + act(() => { + handled = result.current.handleFileMenuKey({ key: 'Enter', preventDefault: vi.fn() } as unknown as React.KeyboardEvent); + }); + expect(handled).toBe(false); + }); + + it('clamps the selection when fresh results shrink the list', async () => { + getProjectFiles.mockResolvedValue([ + { name: 'a.ts', path: 'a.ts', isDirectory: false }, + { name: 'b.ts', path: 'b.ts', isDirectory: false }, + ]); + const { result, rerender } = renderHook(({ token }) => useFilePicker(token, noop, noop, noop), { + initialProps: { token: at('a') as Token }, + wrapper: createWrapper(), + }); + await waitFor(() => { expect(result.current.fileItems).toHaveLength(2); }); + + getProjectFiles.mockResolvedValue([{ name: 'app.ts', path: 'app.ts', isDirectory: false }]); + rerender({ token: at('ap') }); + act(() => { result.current.setSelectedIndex(1); }); + await waitFor(() => { expect(result.current.fileItems).toHaveLength(1); }); + expect(result.current.selectedIndex).toBe(0); + }); + + 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('ignores confirmation while results are stale for the current query', async () => { + getProjectFiles.mockResolvedValue([{ name: 'a.ts', path: 'src/a.ts', isDirectory: false }]); + const onInsertFile = vi.fn(); + const { result, rerender } = renderHook(({ token }) => useFilePicker(token, onInsertFile, noop, noop), { + initialProps: { token: at('a') as Token }, + wrapper: createWrapper(), + }); + await waitFor(() => { expect(result.current.fileItems).toHaveLength(1); }); + + let resolveNext: (value: unknown) => void = noop; + getProjectFiles.mockImplementation(() => new Promise((resolve) => { resolveNext = resolve; })); + rerender({ token: at('ap') }); + expect(result.current.isStale).toBe(true); + + key(result, 'Enter'); + act(() => { result.current.handleSelectItem(result.current.fileItems[0]!); }); + expect(onInsertFile).not.toHaveBeenCalled(); + + await waitFor(() => { expect(getProjectFiles).toHaveBeenCalledWith({ query: 'ap' }); }); + act(() => { resolveNext([{ name: 'app.ts', path: 'src/app.ts', isDirectory: false }]); }); + await waitFor(() => { expect(result.current.fileItems[0]?.name).toBe('app.ts'); }); + expect(result.current.isStale).toBe(false); + + key(result, 'Enter'); + expect(onInsertFile).toHaveBeenCalledWith('src/app.ts'); + }); + it('calls onInsertFile when Enter selects a file', async () => { getProjectFiles.mockResolvedValue([{ name: 'a.ts', path: 'src/a.ts', isDirectory: false }]); const onInsertFile = vi.fn(); @@ -134,19 +203,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'); }); }); diff --git a/apps/vscode/test/workspace-paths.test.ts b/apps/vscode/test/workspace-paths.test.ts index 1e64f433817..191d35934d1 100644 --- a/apps/vscode/test/workspace-paths.test.ts +++ b/apps/vscode/test/workspace-paths.test.ts @@ -6,7 +6,7 @@ * VS Code host APIs are the only stubbed boundary. * Run: pnpm --filter kimi-code exec vitest run --config vitest.config.ts test/workspace-paths.test.ts */ -import { mkdtemp, mkdir, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -153,12 +153,6 @@ let extraRoots: string[]; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "kimi-vscode-workspace-paths-")); vscodeHost.workspaceFolders.splice(0, vscodeHost.workspaceFolders.length, { uri: vscodeHost.Uri.file(root) }); - vscodeHost.readDirectory.mockImplementation(async (uri: { fsPath: string }) => - (await readdir(uri.fsPath, { withFileTypes: true })).map((entry) => [ - entry.name, - entry.isDirectory() ? 2 : entry.isSymbolicLink() ? 64 : 1, - ]), - ); vscodeHost.stat.mockImplementation((uri: { fsPath: string }) => stat(uri.fsPath)); vscodeHost.readFile.mockImplementation((uri: { fsPath: string }) => readFile(uri.fsPath)); vscodeHost.findFiles.mockResolvedValue([]); @@ -177,58 +171,6 @@ afterEach(async () => { }); describe("Webview workspace paths (selected-directory containment)", () => { - it("returns no entries when directory traversal is requested", async () => { - const workDir = join(root, "project"); - await mkdir(workDir); - - const files = await getProjectFiles(workDir, { directory: "../" }); - - expect(files).toEqual([]); - expect(vscodeHost.readDirectory).not.toHaveBeenCalled(); - }); - - it("returns no entries when an absolute directory is requested", async () => { - const workDir = join(root, "project"); - await mkdir(workDir); - - const files = await getProjectFiles(workDir, { directory: join(root, "outside") }); - - expect(files).toEqual([]); - expect(vscodeHost.readDirectory).not.toHaveBeenCalled(); - }); - - it("returns no entries when a Windows absolute directory is requested", async () => { - const workDir = join(root, "project"); - await mkdir(workDir); - - const files = await getProjectFiles(workDir, { directory: "C:\\outside" }); - - expect(files).toEqual([]); - expect(vscodeHost.readDirectory).not.toHaveBeenCalled(); - }); - - it("returns no entries when a Windows drive-relative directory is requested", async () => { - const workDir = join(root, "project"); - await mkdir(workDir); - - const files = await getProjectFiles(workDir, { directory: "C:outside" }); - - expect(files).toEqual([]); - expect(vscodeHost.readDirectory).not.toHaveBeenCalled(); - }); - - it("omits a symlink when its target is outside the selected working directory", async () => { - const workDir = join(root, "project"); - const outside = join(root, "outside"); - await Promise.all([mkdir(workDir), mkdir(outside)]); - await writeFile(join(outside, "secret.txt"), "secret"); - await symlink(outside, join(workDir, "outside-link")); - - const files = await getProjectFiles(workDir, { directory: "." }); - - expect(files).toEqual([]); - }); - it("searches within the selected subdirectory using work-directory-relative results", async () => { const workDir = join(root, "project", "subproject"); const inside = join(workDir, "src", "inside.ts"); @@ -244,26 +186,40 @@ describe("Webview workspace paths (selected-directory containment)", () => { expect(files).toEqual([{ path: "src/inside.ts", name: "inside.ts", isDirectory: false }]); }); - it("normalizes native Windows separators during directory navigation", async () => { + it("maps engine suggestions without touching the local file search", async () => { const workDir = join(root, "project"); - await mkdir(join(workDir, "src", "nested"), { recursive: true }); - await writeFile(join(workDir, "src", "nested", "app.ts"), "app"); + await mkdir(workDir); + const ctx = createContext(vscodeHost.Uri.file(workDir)); + const suggestFiles = vi.mocked(ctx.harness.suggestFiles); + suggestFiles.mockResolvedValue({ + items: [ + { path: "src/app.ts", name: "app.ts", kind: "file", matchPositions: [4, 5, 6] }, + { path: "src", name: "src", kind: "directory", matchPositions: [] }, + ], + truncated: false, + }); - const files = await getProjectFiles(workDir, { directory: "src\\nested" }); + const files = await fileHandlers[Methods.GetProjectFiles]!({ query: "app" }, ctx); - expect(files).toEqual([{ path: "src/nested/app.ts", name: "app.ts", isDirectory: false }]); + expect(suggestFiles).toHaveBeenCalledWith(workDir, { query: "app", limit: 20 }); + expect(vscodeHost.findFiles).not.toHaveBeenCalled(); + expect(files).toEqual([ + { path: "src/app.ts", name: "app.ts", isDirectory: false, matchPositions: [4, 5, 6] }, + { path: "src", name: "src", isDirectory: true, matchPositions: [] }, + ]); }); - it("preserves the remote workspace URI while listing a directory", async () => { - const remoteRoot = vscodeHost.Uri.remote("ssh-remote+example", "/workspace/project"); - vscodeHost.readDirectory.mockResolvedValue([["remote.ts", 1]]); - const ctx = createContext(remoteRoot); + it("degrades to an empty list when engine suggestions fail", async () => { + const workDir = join(root, "project"); + await mkdir(workDir); + const ctx = createContext(vscodeHost.Uri.file(workDir)); + vi.mocked(ctx.harness.suggestFiles).mockRejectedValue(new Error("engine down")); - const files = await fileHandlers[Methods.GetProjectFiles]!({ directory: "." }, ctx); + const files = await fileHandlers[Methods.GetProjectFiles]!({ query: "app" }, ctx); - const requestedUri = vscodeHost.readDirectory.mock.calls[0]?.[0]; - expect(requestedUri).toMatchObject({ scheme: "vscode-remote", authority: "ssh-remote+example" }); - expect(files).toEqual([{ path: "remote.ts", name: "remote.ts", isDirectory: false }]); + expect(files).toEqual([]); + expect(ctx.logError).toHaveBeenCalled(); + expect(vscodeHost.findFiles).not.toHaveBeenCalled(); }); it("refuses to open a symlink whose target lies outside the selected working directory", async () => { @@ -461,7 +417,7 @@ describe("native workspace path comparison (Windows drive and UNC semantics)", ( }); }); -async function getProjectFiles(workDir: string, params: { query?: string; directory?: string }) { +async function getProjectFiles(workDir: string, params: { query?: string }) { return fileHandlers[Methods.GetProjectFiles]!(params, createContext(vscodeHost.Uri.file(workDir))); } @@ -476,7 +432,9 @@ function createContext(workDirUri: InstanceType): Handler requireWorkDir: () => workDirUri.fsPath, requireWorkDirUri: () => workDirUri as vscode.Uri, fileManager, - } as HandlerContext; + harness: { suggestFiles: vi.fn(async () => undefined) }, + logError: vi.fn(), + } as unknown as HandlerContext; } function createBridge(): BridgeHandler { diff --git a/apps/vscode/webview-ui/src/components/FilePickerMenu.tsx b/apps/vscode/webview-ui/src/components/FilePickerMenu.tsx index 649f8884fa5..ea4d8ee5291 100644 --- a/apps/vscode/webview-ui/src/components/FilePickerMenu.tsx +++ b/apps/vscode/webview-ui/src/components/FilePickerMenu.tsx @@ -1,168 +1,127 @@ -import { useEffect, useRef } from "react"; -import { IconFolder, IconFile, IconArrowLeft, IconFolderOpen, IconPhoto } from "@tabler/icons-react"; +import { Fragment, useEffect, useRef } from "react"; +import { IconFolder, IconFile, IconPhoto } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; - -export type FilePickerMode = "search" | "folder"; +import { mentionMatchSpans, type MentionMatchSpan } from "@/lib/mention-match"; export interface FileItem { name: string; path: string; isDirectory: boolean; - highlightedName?: React.ReactNode; + matchPositions?: number[]; } interface FilePickerMenuProps { - mode: FilePickerMode; items: FileItem[]; - currentPath: string; selectedIndex: number; isLoading?: boolean; + isStale?: boolean; showMediaOption?: boolean; onSelectMedia?: () => void; - onSwitchToFolder: () => void; - onSwitchToSearch: () => void; onSelectItem: (item: FileItem) => void; - onNavigateUp: () => void; - onNavigateInto: (item: FileItem) => void; onHover: (index: number) => void; } -function truncateMiddle(str: string, maxLen: number): string { - if (str.length <= maxLen) return str; - const ellipsis = "…"; - const charsToShow = maxLen - ellipsis.length; - const frontChars = Math.ceil(charsToShow / 2); - const backChars = Math.floor(charsToShow / 2); - return str.slice(0, frontChars) + ellipsis + str.slice(-backChars); +function parentDir(path: string): string { + const trimmed = path.endsWith("/") ? path.slice(0, -1) : path; + const idx = trimmed.lastIndexOf("/"); + return idx === -1 ? "" : trimmed.slice(0, idx); +} + +function nameSpans(item: FileItem): MentionMatchSpan[] { + const path = item.path.endsWith("/") ? item.path.slice(0, -1) : item.path; + return mentionMatchSpans(item.name, item.matchPositions, Math.max(0, path.length - item.name.length)); +} + +function dirSpans(item: FileItem): MentionMatchSpan[] { + return mentionMatchSpans(parentDir(item.path), item.matchPositions, 0); } export function FilePickerMenu({ - mode, items, - currentPath, selectedIndex, isLoading, + isStale = false, showMediaOption = true, onSelectMedia, - onSwitchToFolder, - onSwitchToSearch, onSelectItem, - onNavigateUp, - onNavigateInto, onHover, }: FilePickerMenuProps) { const selectedRef = useRef(null); + const hoverSelectionRef = useRef(null); useEffect(() => { + if (hoverSelectionRef.current === selectedIndex) { + hoverSelectionRef.current = null; + return; + } + hoverSelectionRef.current = null; selectedRef.current?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); - const preventFocus = (e: React.MouseEvent) => e.preventDefault(); - - // Calculate header count based on mode and options - const getHeaderCount = () => { - if (mode === "search") { - // Select media (if shown) + Browse folders - return showMediaOption ? 2 : 1; - } else { - // Back to search + optional parent nav - return currentPath ? 2 : 1; - } + const handleHover = (index: number) => { + if (isStale) return; + hoverSelectionRef.current = index; + onHover(index); }; - const headerCount = getHeaderCount(); + const headerCount = showMediaOption ? 1 : 0; return (
- {mode === "search" ? ( - <> - {showMediaOption && onSelectMedia && ( - - )} - - - ) : ( - <> - - {currentPath && ( - - )} - + {showMediaOption && onSelectMedia && ( + )} -
+
{isLoading ? (
Loading…
) : items.length === 0 ? ( -
{mode === "search" ? "No files found" : "Empty folder"}
+
No files found
) : ( items.map((item, idx) => { const itemIndex = idx + headerCount; + const dir = parentDir(item.path); return ( ); }) diff --git a/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx b/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx index 3a7d0028f3e..c00ae2be58f 100644 --- a/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx +++ b/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx @@ -44,11 +44,22 @@ function highlightMatch(text: string, query: string): React.ReactNode { export function SlashCommandMenu({ commands, query, selectedIndex, onSelect, onHover }: SlashCommandMenuProps) { const selectedRef = useRef(null); + const hoverSelectionRef = useRef(null); useEffect(() => { + if (hoverSelectionRef.current === selectedIndex) { + hoverSelectionRef.current = null; + return; + } + hoverSelectionRef.current = null; selectedRef.current?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); + const handleHover = (index: number) => { + hoverSelectionRef.current = index; + onHover(index); + }; + if (commands.length === 0) { return
No commands found
; } @@ -61,7 +72,7 @@ export function SlashCommandMenu({ commands, query, selectedIndex, onSelect, onH key={cmd.name} ref={idx === selectedIndex ? selectedRef : null} onClick={() => onSelect(cmd.name)} - onMouseEnter={() => onHover(idx)} + onMouseMove={() => handleHover(idx)} className={cn("w-full px-2 py-1.5 text-left flex items-center justify-between gap-3", idx === selectedIndex ? "bg-accent" : "hover:bg-accent/50")} > {highlightMatch(`/${cmd.name}`, query)} diff --git a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx index 4e5f104d072..0d50b7226a6 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx +++ b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx @@ -213,15 +213,13 @@ export function InputArea({ onAuthAction }: InputAreaProps) { const { showFileMenu, - filePickerMode, - folderPath, fileItems, selectedIndex: fileSelectedIndex, isLoading: isFileLoading, + isStale: isFileStale, showMediaOption, setSelectedIndex: setFileSelectedIndex, - setFilePickerMode, - setFolderPath, + handleSelectItem: handleSelectFileItem, handleFileMenuKey, resetFilePicker, } = useFilePicker( @@ -338,35 +336,15 @@ export function InputArea({ onAuthAction }: InputAreaProps) { {showFileMenu && (
{ void handlePickMedia(); }} - onSwitchToFolder={() => { - setFilePickerMode("folder"); - setFolderPath(""); - setFileSelectedIndex(0); - }} - onSwitchToSearch={() => { - setFilePickerMode("search"); - setFolderPath(""); - setFileSelectedIndex(0); - }} - onSelectItem={(item) => { applyMention(item.path); }} - onNavigateUp={() => { - setFolderPath(folderPath.split("/").slice(0, -1).join("/")); - setFileSelectedIndex(0); - }} - onNavigateInto={(item) => { - setFilePickerMode("folder"); - setFolderPath(item.path); - setFileSelectedIndex(0); - }} + onSelectItem={handleSelectFileItem} onHover={setFileSelectedIndex} />
diff --git a/apps/vscode/webview-ui/src/components/inputarea/hooks/useFilePicker.tsx b/apps/vscode/webview-ui/src/components/inputarea/hooks/useFilePicker.tsx index 3370511404c..137efdfa9c8 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/hooks/useFilePicker.tsx +++ b/apps/vscode/webview-ui/src/components/inputarea/hooks/useFilePicker.tsx @@ -6,12 +6,11 @@ import { useChatStore } from "@/stores"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { MEDIA_CONFIG } from "@/services/config"; -export type FilePickerMode = "search" | "folder"; - export interface FileItem { name: string; path: string; isDirectory: boolean; + matchPositions?: number[]; } interface ActiveToken { @@ -24,16 +23,14 @@ const NO_FILES: ProjectFile[] = []; interface UseFilePickerResult { showFileMenu: boolean; - filePickerMode: FilePickerMode; - folderPath: string; fileItems: FileItem[]; selectedIndex: number; isLoading: boolean; + isStale: boolean; showMediaOption: boolean; fileMenuHeaderCount: number; setSelectedIndex: (index: number) => void; - setFilePickerMode: (mode: FilePickerMode) => void; - setFolderPath: (path: string) => void; + handleSelectItem: (item: FileItem) => void; handleFileMenuKey: (e: React.KeyboardEvent) => boolean; resetFilePicker: () => void; } @@ -43,8 +40,6 @@ export function useFilePicker(activeToken: ActiveToken | null, onInsertFile: (pa const canAddMedia = !isStreaming && draftMedia.length < MEDIA_CONFIG.maxCount; const [selectedIndex, setSelectedIndex] = useState(0); - const [filePickerMode, setFilePickerMode] = useState("search"); - const [folderPath, setFolderPath] = useState(""); const showFileMenu = activeToken?.trigger === "@"; const query = activeToken?.query || ""; @@ -53,103 +48,62 @@ export function useFilePicker(activeToken: ActiveToken | null, onInsertFile: (pa const searchQuery = useQuery({ queryKey: ["projectFiles", "search", debouncedQuery], queryFn: () => bridge.getProjectFiles({ query: debouncedQuery || undefined }), - enabled: showFileMenu && filePickerMode === "search", + enabled: showFileMenu, placeholderData: keepPreviousData, }); const searchResults = searchQuery.data ?? NO_FILES; - const isSearchLoading = searchQuery.isLoading; - - const folderQuery = useQuery({ - queryKey: ["projectFiles", "folder", folderPath || "."], - queryFn: () => bridge.getProjectFiles({ directory: folderPath || "." }), - enabled: showFileMenu && filePickerMode === "folder", - }); - const folderItems = folderQuery.data ?? NO_FILES; - const isFolderLoading = folderQuery.isLoading; - - useEffect(() => { - if (!showFileMenu) { - setFilePickerMode("search"); - setFolderPath(""); - } - }, [showFileMenu]); + const isLoading = searchQuery.isLoading; + const isStale = debouncedQuery !== query || searchQuery.isPlaceholderData; useEffect(() => { setSelectedIndex(0); - }, [query, filePickerMode, folderPath]); + }, [query]); const fileItems = useMemo((): FileItem[] => { - if (filePickerMode === "folder") { - return folderItems.map((f) => ({ - name: f.name, - path: f.path, - isDirectory: f.isDirectory, - })); - } return searchResults.slice(0, 50).map((f) => ({ name: f.name, path: f.path, isDirectory: f.isDirectory, + matchPositions: f.matchPositions, })); - }, [filePickerMode, folderItems, searchResults]); + }, [searchResults]); + + const showMediaOption = canAddMedia && query === ""; + const fileMenuHeaderCount = showMediaOption ? 1 : 0; - const isLoading = filePickerMode === "search" ? isSearchLoading : isFolderLoading; - const showMediaOption = filePickerMode === "search" && canAddMedia; - const fileMenuHeaderCount = filePickerMode === "search" ? (showMediaOption ? 2 : 1) : folderPath ? 2 : 1; + useEffect(() => { + setSelectedIndex((i) => Math.min(i, Math.max(0, fileMenuHeaderCount + fileItems.length - 1))); + }, [fileMenuHeaderCount, fileItems.length]); const resetFilePicker = useCallback(() => { setSelectedIndex(0); - setFilePickerMode("search"); - setFolderPath(""); }, []); const handleFileMenuConfirm = useCallback(() => { - if (filePickerMode === "search") { - if (showMediaOption && selectedIndex === 0) { - onPickMedia(); - return; - } - - const browseIndex = showMediaOption ? 1 : 0; - if (selectedIndex === browseIndex) { - setFilePickerMode("folder"); - setFolderPath(""); - setSelectedIndex(0); - return; - } - } - - if (filePickerMode === "folder" && selectedIndex === 0) { - setFilePickerMode("search"); - setFolderPath(""); - setSelectedIndex(0); - return; - } - - if (filePickerMode === "folder" && selectedIndex === 1 && folderPath) { - setFolderPath(folderPath.split("/").slice(0, -1).join("/")); - setSelectedIndex(0); + if (showMediaOption && selectedIndex === 0) { + onPickMedia(); return; } - const itemIndex = selectedIndex - fileMenuHeaderCount; - const item = fileItems[itemIndex]; + if (isStale) return; + const item = fileItems[selectedIndex - fileMenuHeaderCount]; if (!item) return; + onInsertFile(item.path); + }, [selectedIndex, showMediaOption, isStale, fileMenuHeaderCount, fileItems, onPickMedia, onInsertFile]); - if (filePickerMode === "search" && item.isDirectory) { - setFilePickerMode("folder"); - setFolderPath(item.path); - setSelectedIndex(0); - } else { + const handleSelectItem = useCallback( + (item: FileItem) => { + if (isStale) return; onInsertFile(item.path); - } - }, [filePickerMode, selectedIndex, showMediaOption, folderPath, fileMenuHeaderCount, fileItems, onPickMedia, onInsertFile]); + }, + [isStale, onInsertFile], + ); const handleFileMenuKey = useCallback( (e: React.KeyboardEvent): boolean => { if (!showFileMenu) return false; - const maxIdx = fileMenuHeaderCount + fileItems.length - 1; + const maxIdx = Math.max(0, fileMenuHeaderCount + fileItems.length - 1); switch (e.key) { case "ArrowDown": @@ -160,60 +114,33 @@ export function useFilePicker(activeToken: ActiveToken | null, onInsertFile: (pa e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); return true; - case "ArrowLeft": - if (filePickerMode !== "folder") return false; - e.preventDefault(); - if (folderPath) { - setFolderPath(folderPath.split("/").slice(0, -1).join("/")); - } else { - setFilePickerMode("search"); - } - setSelectedIndex(0); - return true; - case "ArrowRight": { - if (filePickerMode !== "folder") return false; - e.preventDefault(); - const itemForRight = fileItems[selectedIndex - fileMenuHeaderCount]; - if (itemForRight?.isDirectory) { - setFolderPath(itemForRight.path); - setSelectedIndex(0); - } - return true; - } case "Tab": case "Enter": + if (fileMenuHeaderCount + fileItems.length === 0) return false; e.preventDefault(); handleFileMenuConfirm(); return true; case "Escape": e.preventDefault(); - if (filePickerMode === "folder") { - setFilePickerMode("search"); - setFolderPath(""); - setSelectedIndex(0); - } else { - onCancel(); - } + onCancel(); return true; default: return false; } }, - [showFileMenu, fileMenuHeaderCount, fileItems, filePickerMode, folderPath, selectedIndex, handleFileMenuConfirm, onCancel], + [showFileMenu, fileMenuHeaderCount, fileItems, handleFileMenuConfirm, onCancel], ); return { showFileMenu, - filePickerMode, - folderPath, fileItems, selectedIndex, isLoading, + isStale, showMediaOption, fileMenuHeaderCount, setSelectedIndex, - setFilePickerMode, - setFolderPath, + handleSelectItem, handleFileMenuKey, resetFilePicker, }; diff --git a/apps/vscode/webview-ui/src/components/inputarea/utils.ts b/apps/vscode/webview-ui/src/components/inputarea/utils.ts index 33e7cf834ad..a0a1689e6cf 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/utils.ts +++ b/apps/vscode/webview-ui/src/components/inputarea/utils.ts @@ -13,16 +13,19 @@ interface InsertMentionResult { export function computeMentionInsert(params: InsertMentionParams): InsertMentionResult { const { text, cursorPos, filePath, activeToken, isAppend } = params; + // Quote paths containing spaces, as the CLI/TUI mention completers do, so + // whitespace cannot split the mention. + const target = filePath.includes(" ") ? `"${filePath}"` : filePath; if (isAppend || !activeToken) { - const newText = text + `@${filePath} `; + const newText = text + `@${target} `; return { newText, newCursorPos: newText.length }; } const before = text.slice(0, activeToken.start); const after = text.slice(cursorPos); - const newText = `${before}@${filePath} ${after}`; - const newCursorPos = activeToken.start + 1 + filePath.length + 1; + const newText = `${before}@${target} ${after}`; + const newCursorPos = activeToken.start + 1 + target.length + 1; return { newText, newCursorPos }; } diff --git a/apps/vscode/webview-ui/src/lib/mention-match.ts b/apps/vscode/webview-ui/src/lib/mention-match.ts new file mode 100644 index 00000000000..6db718c00d9 --- /dev/null +++ b/apps/vscode/webview-ui/src/lib/mention-match.ts @@ -0,0 +1,47 @@ +const SURROGATE_PAIR_AT_START = /^[\uD800-\uDBFF][\uDC00-\uDFFF]/; +const SURROGATE_PAIR_EXACT = /^[\uD800-\uDBFF][\uDC00-\uDFFF]$/; + +export interface MentionMatchSpan { + text: string; + hit: boolean; +} + +/** + * Split `text` into hit/plain runs from match positions. `positions` index + * into the full path; `start` shifts them into this text's frame. + */ +export function mentionMatchSpans( + text: string, + positions: readonly number[] | undefined, + start: number, +): MentionMatchSpan[] { + if (positions === undefined || positions.length === 0 || text.length === 0) { + return [{ text, hit: false }]; + } + const hits = new Set(); + for (const pos of positions) { + const i = pos - start; + if (i >= 0 && i < text.length) hits.add(i); + } + if (hits.size === 0) return [{ text, hit: false }]; + for (const i of Array.from(hits)) { + if (SURROGATE_PAIR_AT_START.test(text.slice(i, i + 2))) { + hits.add(i + 1); + } else if (SURROGATE_PAIR_EXACT.test(text.slice(i - 1, i + 1))) { + hits.add(i - 1); + } + } + const spans: MentionMatchSpan[] = []; + let runStart = 0; + let runHit = hits.has(0); + for (let i = 1; i < text.length; i++) { + const hit = hits.has(i); + if (hit !== runHit) { + spans.push({ text: text.slice(runStart, i), hit: runHit }); + runStart = i; + runHit = hit; + } + } + spans.push({ text: text.slice(runStart), hit: runHit }); + return spans; +} diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 4ddf52e2fec..c4e766f958e 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -42,6 +42,8 @@ import type { SessionSummary, SessionSummaryPage, SkillSummary, + SuggestFilesInput, + SuggestFilesResult, TelemetryClient, TelemetryContextPatch, TelemetryProperties, @@ -337,6 +339,15 @@ export class KimiHarness { return this.rpc.listWorkspaceSkills(workDir); } + /** + * File suggestions for @ mention-style completion under `workDir`, no + * session required. `undefined` on the v1 engine, which has no equivalent + * capability; callers fall back to their own file search there. + */ + async suggestFiles(workDir: string, input: SuggestFilesInput): Promise { + return this.rpc.suggestFiles(workDir, input); + } + /** * App-global plugin command list, no session required. Empty on the v1 * engine, which only exposes plugin commands through a live session. diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 36ad66a82b7..e4bbd2358a0 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -70,6 +70,8 @@ import type { SessionSummaryPage, SkillSummary, PluginCommandDef, + SuggestFilesInput, + SuggestFilesResult, Unsubscribe, UploadFileOptions, WorkspaceTrustInfo, @@ -856,6 +858,17 @@ export abstract class SDKRpcClientBase { return []; } + /** + * Workspace-root file suggestions, no session required. The v1 engine has + * no equivalent capability, so the base reports `undefined`; the v2 client + * overrides with the workspace handler's fs service. + */ + async suggestFiles(workDir: string, input: SuggestFilesInput): Promise { + void workDir; + void input; + return undefined; + } + async listBackgroundTasks( input: SessionIdRpcInput & { activeOnly?: boolean; limit?: number }, ): Promise { diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 9da7370515a..25e019227c9 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -16,6 +16,9 @@ * - `listWorkspaceSkills` → not covered by the klient facade, so it goes * through the `engineAccessor` escape hatch (the workspace handler's * `IWorkspaceSkillCatalog`) instead. + * - `suggestFiles` → same escape hatch (the workspace handler's + * `IWorkspaceFsService`); the v1 client inherits the base's `undefined` + * (capability absent). * - `getConfig` / `setConfig` / `removeProvider` / `getConfigDiagnostics` → * `klient.global.config.*`, with the v1 `KimiConfig` shape restored by the * pure mapping layer in `src/v2/config-mapper.ts`. @@ -150,6 +153,7 @@ import { import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug'; import { McpConnectionManager } from '@moonshot-ai/agent-core-v2/mcpCore/connection-manager'; import { loadMcpServers } from '@moonshot-ai/agent-core-v2/app/mcpConfig/configLoader'; +import { fsSuggestRequestSchema } from '@moonshot-ai/agent-core-v2/workspace/workspaceFs/fs'; import { IAppendLogStore } from '@moonshot-ai/agent-core-v2/persistence/interface/appendLogStore'; import type { McpServerConfig as WorkspaceMcpServerConfig } from '@moonshot-ai/agent-core-v2/mcpCore/config-schema'; import { @@ -316,6 +320,8 @@ import type { SessionTodoItem, SessionUsage, SkillSummary, + SuggestFilesInput, + SuggestFilesResult, TelemetryClient, UploadFileOptions, WorkspaceTrustInfo, @@ -621,6 +627,42 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return catalog.catalog.listSkills().map(summarizeSkill); } + /** + * Through the workspace handler's `IWorkspaceFsService` — the same engine + * suggest the kap-server `fs:suggest` routes serve (fuzzy scoring, + * directories included, gitignore respected), so in-process hosts match + * the web client's @ mention results. + */ + override async suggestFiles(workDir: string, input: SuggestFilesInput): Promise { + const parsed = fsSuggestRequestSchema.safeParse({ + query: input.query, + limit: input.limit ?? 50, + follow_gitignore: true, + show_hidden: false, + }); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const where = issue !== undefined && issue.path.length > 0 ? `${String(issue.path[0])}: ` : ''; + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + `suggestFiles ${where}${issue?.message ?? 'invalid input'}`, + ); + } + const handler = await this.engineAccessor + .get(IWorkspaceInstanceManager) + .getOrCreate({ root: normalizeRequiredWorkDir('suggestFiles', workDir) }); + const result = await handler.program.fs.suggest(parsed.data); + return { + items: result.items.map((item) => ({ + path: item.path, + name: item.name, + kind: item.kind, + matchPositions: item.match_positions, + })), + truncated: result.truncated, + }; + } + /** * klient has no workspace-trust facade; composed directly from the engine * via {@link engineAccessor} — the same `handlerFor({ root })` path diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index b3e44897910..5cb25841b17 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -113,6 +113,29 @@ export interface WorkspaceTrustInfo { readonly gatedMcpServers: readonly WorkspaceTrustMcpServerInfo[]; } +/** + * File-suggestion query against a workspace root, no session required. Only + * meaningful on the agent-core-v2 engine; the v1 engine has no equivalent + * and reports `undefined`. + */ +export interface SuggestFilesInput { + readonly query: string; + readonly limit?: number; +} + +export interface SuggestFilesItem { + readonly path: string; + readonly name: string; + readonly kind: 'file' | 'directory' | 'symlink'; + /** Matched-character offsets into `path`, for mention-style highlighting. */ + readonly matchPositions: readonly number[]; +} + +export interface SuggestFilesResult { + readonly items: readonly SuggestFilesItem[]; + readonly truncated: boolean; +} + /** Metadata of one upload in the engine's daemon file store. */ export type { FileMeta } from '@moonshot-ai/agent-core-v2/app/file/fileService'; diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 5693ea3a5ad..f2b94cbafce 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -800,6 +800,45 @@ key = "${titleOAuthRef.key}" } }); + it('serves suggestFiles through the workspace handler fs service', async () => { + const { harness } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await mkdir(join(workDir, 'src'), { recursive: true }); + await writeFile(join(workDir, 'src', 'app.ts'), 'app'); + await writeFile(join(workDir, 'src', 'index.ts'), 'index'); + await writeFile(join(workDir, 'README.md'), 'readme'); + try { + const matched = await harness.suggestFiles(workDir, { query: 'app', limit: 20 }); + expect(matched?.items).toContainEqual( + expect.objectContaining({ kind: 'file', path: 'src/app.ts', name: 'app.ts' }), + ); + const appItem = matched?.items.find((item) => item.name === 'app.ts'); + expect(appItem?.matchPositions.length).toBeGreaterThan(0); + + const topLevel = await harness.suggestFiles(workDir, { query: '', limit: 20 }); + expect(topLevel?.items).toContainEqual(expect.objectContaining({ kind: 'directory', name: 'src' })); + expect(topLevel?.items).toContainEqual(expect.objectContaining({ kind: 'file', name: 'README.md' })); + } finally { + await harness.close(); + } + }); + + it('rejects an out-of-range suggestFiles limit before touching the engine', async () => { + const { harness } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + try { + for (const limit of [0, -1, 201, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + await expect(harness.suggestFiles(workDir, { query: 'a', limit })).rejects.toMatchObject({ + code: ErrorCodes.REQUEST_INVALID, + }); + } + } finally { + await harness.close(); + } + }); + it('honors skillDirs (explicit dirs) over default user / project discovery', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-')); tempDirs.push(homeDir);