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/vscode-file-picker-mention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Add a "Browse files..." option to the @ mention dropdown in the VS Code extension prompt input. Selecting it opens a native file picker and mentions the chosen file, so you can point Kilo Code at files outside the current workspace. Files outside the workspace are not auto-attached; Kilo Code reads them on request through the normal Read tool, respecting your file access permissions.
4 changes: 4 additions & 0 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree"
import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files"
import { renameSession } from "./kilo-provider/rename-session"
import { handleFileSearch } from "./kilo-provider/file-search"
import { handleFilePicker } from "./kilo-provider/file-picker"
import { watchFontSizeConfig } from "./kilo-provider/font-size"
import { getTerminalContents } from "./services/terminal/context"
import { disposeGitChangesTarget } from "./kilo-provider/git-changes-target"
Expand Down Expand Up @@ -1271,6 +1272,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
post: (msg) => this.postMessage(msg),
})
break
case "requestFilePicker":
await handleFilePicker({ requestId: message.requestId, post: (msg) => this.postMessage(msg) })
break
case "requestTerminalContext":
void this.handleTerminalContext(message.requestId)
break
Expand Down
20 changes: 20 additions & 0 deletions packages/kilo-vscode/src/kilo-provider/file-picker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as vscode from "vscode"

type Input = {
requestId: string
post: (message: unknown) => void
}

export async function handleFilePicker(input: Input): Promise<void> {
const uri = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
openLabel: "Select file",
})
input.post({
type: "filePickerResult",
path: uri && uri[0] ? uri[0].fsPath : "",
requestId: input.requestId,
})
}
76 changes: 67 additions & 9 deletions packages/kilo-vscode/tests/unit/file-mention-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
getMentionRemovalRange,
isCursorAtMentionEnd,
findMentionRange,
FILE_PICKER_RESULT,
TERMINAL_RESULT,
GIT_CHANGES_RESULT,
} from "../../webview-ui/src/hooks/file-mention-utils"

describe("AT_PATTERN", () => {
Expand Down Expand Up @@ -53,32 +56,42 @@ describe("buildMentionResults", () => {

it("includes terminal for matching prefix", () => {
const result = buildMentionResults("term", ["src/terminal.ts"])
expect(result.map((item) => item.type)).toEqual(["terminal", "file"])
expect(result.map((item) => item.type)).toEqual(["terminal", "file-picker", "file"])
})

it("includes git changes for matching prefix", () => {
const result = buildMentionResults("git", ["src/git.ts"])
expect(result.map((item) => item.type)).toEqual(["git-changes", "file"])
expect(result.map((item) => item.type)).toEqual(["git-changes", "file-picker", "file"])
})

it("omits special mentions for unrelated query", () => {
const result = buildMentionResults("src", ["src/index.ts"])
expect(result.map((item) => item.type)).toEqual(["file"])
expect(result.map((item) => item.type)).toEqual(["file-picker", "file"])
})

it("omits git changes when git is unavailable", () => {
const result = buildMentionResults("git", ["src/git.ts"], false)
expect(result.map((item) => item.type)).toEqual(["file"])
expect(result.map((item) => item.type)).toEqual(["file-picker", "file"])
})

it("includes folder results", () => {
const result = buildMentionResults("src", [{ path: "src", type: "folder" }])
expect(result).toEqual([{ type: "folder", value: "src" }])
expect(result).toEqual([FILE_PICKER_RESULT, { type: "folder", value: "src" }])
})

it("preserves opened file result type", () => {
const result = buildMentionResults("src", [{ path: "src/index.ts", type: "opened-file" }])
expect(result).toEqual([{ type: "opened-file", value: "src/index.ts" }])
expect(result).toEqual([FILE_PICKER_RESULT, { type: "opened-file", value: "src/index.ts" }])
})

it("always includes file picker result, placed after terminal/git-changes and before file results", () => {
const result = buildMentionResults("", ["src/index.ts"])
expect(result).toEqual([
TERMINAL_RESULT,
GIT_CHANGES_RESULT,
FILE_PICKER_RESULT,
{ type: "file", value: "src/index.ts" },
])
})
})

Expand All @@ -87,8 +100,14 @@ describe("filterMentionResults", () => {
const result = filterMentionResults("gi", [
{ type: "file", value: "README.md" },
{ type: "file", value: "src/git.ts" },
FILE_PICKER_RESULT,
])
expect(result).toEqual([{ type: "file", value: "src/git.ts" }])
expect(result).toEqual([{ type: "file", value: "src/git.ts" }, FILE_PICKER_RESULT])
})

it("always preserves file picker result regardless of query", () => {
const result = filterMentionResults("zz", [FILE_PICKER_RESULT])
expect(result).toEqual([FILE_PICKER_RESULT])
})
})

Expand Down Expand Up @@ -230,11 +249,50 @@ describe("buildFileAttachments", () => {
expect(result[0]!.url).toContain("foo.ts")
})

it("handles absolute paths directly", () => {
it("attaches an absolute path that lives inside the workspace", () => {
const paths = new Set(["/workspace/src/file.ts"])
const result = buildFileAttachments("@/workspace/src/file.ts", paths, "/workspace")
expect(result).toHaveLength(1)
expect(result[0]!.url).toContain("/workspace/src/file.ts")
})

it("does not attach an absolute Unix path outside the workspace", () => {
const paths = new Set(["/abs/path/file.ts"])
const result = buildFileAttachments("@/abs/path/file.ts", paths, "/workspace")
expect(result).toEqual([])
})

it("does not attach an absolute Windows path outside the workspace", () => {
const paths = new Set(["C:/Users/file.ts"])
const result = buildFileAttachments("@C:/Users/file.ts", paths, "/workspace")
expect(result).toEqual([])
})

it("does not attach a UNC path outside the workspace", () => {
const paths = new Set(["\\\\server\\share\\file.ts"])
const result = buildFileAttachments("@\\\\server\\share\\file.ts", paths, "/workspace")
expect(result).toEqual([])
})

it("does not attach an absolute path that escapes the workspace via ../ segments", () => {
const paths = new Set(["/workspace/../../etc/passwd"])
const result = buildFileAttachments("@/workspace/../../etc/passwd", paths, "/workspace")
expect(result).toEqual([])
})

it("does not attach a relative-looking mention that escapes the workspace via ../ segments", () => {
// Simulates a path seeded from raw text (e.g. seedFromText) rather than the
// file picker or file search, which never produce a leading "../".
const paths = new Set(["../../etc/passwd"])
const result = buildFileAttachments("@../../etc/passwd", paths, "/workspace")
expect(result).toEqual([])
})

it("attaches a relative mention with ../ segments that still resolves inside the workspace", () => {
const paths = new Set(["sub/../foo.ts"])
const result = buildFileAttachments("@sub/../foo.ts", paths, "/workspace")
expect(result).toHaveLength(1)
expect(result[0]!.url).toContain("/abs/path/file.ts")
expect(result[0]!.url).toContain("/workspace/foo.ts")
})

it("normalizes Windows backslashes in workspaceDir", () => {
Expand Down
Loading
Loading