Skip to content
Merged
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 file picker option to the @ mention dropdown in the VS Code extension prompt input, allowing users to attach files from outside the current workspace.
13 changes: 13 additions & 0 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
post: (msg) => this.postMessage(msg),
})
break
case "requestFilePicker": {
const uri = await vscode.window.showOpenDialog({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
openLabel: "Select file",
})
this.postMessage({
type: "filePickerResult",
path: uri && uri[0] ? uri[0].fsPath : "",
})
break
}
case "requestTerminalContext":
void this.handleTerminalContext(message.requestId)
break
Expand Down
33 changes: 26 additions & 7 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,7 @@ import {
getMentionRemovalRange,
isCursorAtMentionEnd,
findMentionRange,
FILE_PICKER_RESULT,
} from "../../webview-ui/src/hooks/file-mention-utils"

describe("AT_PATTERN", () => {
Expand Down Expand Up @@ -53,32 +54,37 @@ 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", () => {
const result = buildMentionResults("", [])
expect(result[result.length - 1]).toEqual(FILE_PICKER_RESULT)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SUGGESTION: Test doesn't actually validate picker placement

This test calls buildMentionResults("", []) (empty items) and asserts result[result.length - 1] equals FILE_PICKER_RESULT. Since items is empty, FILE_PICKER_RESULT is trivially both the first and last element of the result — this would pass identically whether the implementation splices it before or after ...results, so it doesn't verify the "picker appears before regular file results" placement the test name implies. The sibling tests above (e.g. lines 75-83) already correctly verify ordering using non-empty items; this one could be dropped as redundant, or rewritten to assert an index/position using a non-empty items array.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

})
})

Expand All @@ -87,8 +93,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 @@ -242,6 +254,13 @@ describe("buildFileAttachments", () => {
const result = buildFileAttachments("@foo.ts", paths, "C:\\Users\\workspace")
expect(result[0]!.url).not.toContain("\\")
})

it("handles Windows absolute paths directly", () => {
const paths = new Set(["C:/Users/file.ts"])
const result = buildFileAttachments("@C:/Users/file.ts", paths, "/workspace")
expect(result).toHaveLength(1)
expect(result[0]!.url).toContain("C:/Users/file.ts")
})
})

describe("getMentionRemovalRange", () => {
Expand Down
236 changes: 232 additions & 4 deletions packages/kilo-vscode/tests/unit/use-file-mention.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
import { describe, expect, it } from "bun:test"
import { createRoot } from "solid-js"
import { useFileMention } from "../../webview-ui/src/hooks/useFileMention"
import { FILE_PICKER_RESULT } from "../../webview-ui/src/hooks/file-mention-utils"
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"

declare global {
// eslint-disable-next-line no-var
var document: { execCommand: (commandId: string, showUI?: boolean, value?: string) => boolean }
}

const hadDoc = "document" in globalThis
const originalDoc = hadDoc ? globalThis.document : undefined

function mockDocument() {
globalThis.document = { execCommand: () => true }
}

function restoreDocument() {
if (hadDoc && originalDoc) {
globalThis.document = originalDoc
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (globalThis as any).document
}
}

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

function textarea(value: string, cursor: number, dir: "ltr" | "rtl") {
Expand Down Expand Up @@ -68,11 +90,17 @@ describe("useFileMention", () => {
})
}

expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" }])
expect(mention.mentionResults()).toEqual([
FILE_PICKER_RESULT,
{ type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" },
])

mention.onInput("@ex", 3)

expect(mention.mentionResults()).toEqual([{ type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" }])
expect(mention.mentionResults()).toEqual([
FILE_PICKER_RESULT,
{ type: "opened-file", value: "packages/kilo-vscode/src/extension.ts" },
])

dispose.fn?.()
})
Expand Down Expand Up @@ -109,7 +137,7 @@ describe("useFileMention", () => {

mention.onInput("@zz", 3)

expect(mention.mentionResults()).toEqual([])
expect(mention.mentionResults()).toEqual([FILE_PICKER_RESULT])

dispose.fn?.()
})
Expand Down Expand Up @@ -210,7 +238,7 @@ describe("useFileMention", () => {

mention.onInput("@gi", 3)

expect(mention.mentionResults()).toEqual([{ type: "file", value: "src/git.ts" }])
expect(mention.mentionResults()).toEqual([FILE_PICKER_RESULT, { type: "file", value: "src/git.ts" }])

dispose.fn?.()
})
Expand Down Expand Up @@ -272,4 +300,204 @@ describe("useFileMention", () => {

dispose.fn?.()
})

it("selecting file picker sends requestFilePicker and stores state", async () => {
const posted: WebviewMessage[] = []
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: () => () => {},
}

const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})

const state = { value: "hello @b", cursor: 8 }
const input = {
value: state.value,
get selectionStart() {
return state.cursor
},
get selectionEnd() {
return state.cursor
},
isConnected: true,
setSelectionRange: (start: number, end: number) => {
state.cursor = end
},
focus: () => {},
} as unknown as HTMLTextAreaElement

let execCalled = false
mockDocument()
globalThis.document.execCommand = () => {
execCalled = true
return true
}

try {
mention.selectMention(
{ type: "file-picker", value: "file-picker", label: "Browse", description: "" },
input,
() => {},
)
} finally {
restoreDocument()
}

expect(posted).toEqual([{ type: "requestFilePicker" }])
expect(execCalled).toBe(false)

dispose.fn?.()
})

it("insertFilePickerResult inserts the path at the stored position", () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}

const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})

const state = { value: "hello @b", cursor: 8, textSet: "" }
const input = {
get value() {
return state.value
},
get selectionStart() {
return state.cursor
},
get selectionEnd() {
return state.cursor
},
isConnected: true,
setSelectionRange: (start: number, end: number) => {
state.value = state.value.slice(0, start) + state.value.slice(end)
state.cursor = start
},
focus: () => {},
} as unknown as HTMLTextAreaElement

mockDocument()
globalThis.document.execCommand = (_cmd: string, _show: boolean, val: string) => {
state.value = state.value.slice(0, state.cursor) + val + state.value.slice(state.cursor)
state.cursor = state.cursor + val.length
return true
}

try {
mention.selectMention(
{ type: "file-picker", value: "file-picker", label: "Browse", description: "" },
input,
(text: string) => {
state.textSet = text
},
)
mention.insertFilePickerResult("/outside/file.ts")
} finally {
restoreDocument()
}

expect(state.value).toBe("hello @/outside/file.ts ")
expect(mention.mentionedPaths().has("/outside/file.ts")).toBe(true)
expect(state.textSet).toBe("hello @/outside/file.ts ")

dispose.fn?.()
})

it("insertFilePickerResult normalizes Windows backslashes to forward slashes", () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}

const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})

const state = { value: "hello @b", cursor: 8, textSet: "" }
const input = {
get value() {
return state.value
},
get selectionStart() {
return state.cursor
},
get selectionEnd() {
return state.cursor
},
isConnected: true,
setSelectionRange: (start: number, end: number) => {
state.value = state.value.slice(0, start) + state.value.slice(end)
state.cursor = start
},
focus: () => {},
} as unknown as HTMLTextAreaElement

mockDocument()
globalThis.document.execCommand = (_cmd: string, _show: boolean, val: string) => {
state.value = state.value.slice(0, state.cursor) + val + state.value.slice(state.cursor)
state.cursor = state.cursor + val.length
return true
}

try {
mention.selectMention(
{ type: "file-picker", value: "file-picker", label: "Browse", description: "" },
input,
(text: string) => {
state.textSet = text
},
)
mention.insertFilePickerResult("C:\\Users\\file.ts")
} finally {
restoreDocument()
}

expect(state.value).toBe("hello @C:/Users/file.ts ")
expect(mention.mentionedPaths().has("C:/Users/file.ts")).toBe(true)

dispose.fn?.()
})

it("insertFilePickerResult with empty path cleans up state", () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}

const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})

const input = {
value: "hello @b",
selectionStart: 8,
selectionEnd: 8,
isConnected: true,
setSelectionRange: () => {},
focus: () => {},
} as unknown as HTMLTextAreaElement

mention.selectMention(
{ type: "file-picker", value: "file-picker", label: "Browse", description: "" },
input,
() => {},
)
mention.insertFilePickerResult("")

expect(input.value).toBe("hello @b")

dispose.fn?.()
})
})
Loading