Merge pull request #12028 from sylwester-liljegren/feat/vscode-file-picker-mention

feat(vscode): add file picker to @ mention dropdown
This commit is contained in:
Marius
2026-07-13 10:34:00 +02:00
committed by GitHub
11 changed files with 606 additions and 68 deletions
+5
View File
@@ -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.
+40 -17
View File
@@ -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"
@@ -110,7 +111,9 @@ import {
handleSkipLegacyMigration,
handleClearLegacyData,
type MigrationContext,
type MigrationSource,
} from "./kilo-provider/handlers/migration"
import type { MigrationSelections } from "./legacy-migration/legacy-types"
// legacy-migration end
import {
handleLogin,
@@ -944,6 +947,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (await this.handleModelSelectorExpandedMessage(message)) return
this.visibleTaskStreams.handle(message)
if (await this.handleMemoryMessage(message)) return
if (this.handleLegacyMigrationMessage(message)) return
switch (message.type) {
case "webviewReady":
console.log("[Kilo New] KiloProvider: ✅ webviewReady received")
@@ -1277,6 +1281,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
@@ -1391,23 +1398,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage({ type: "favoritesLoaded", favorites })
break
}
// legacy-migration start
case "requestMigrationData":
void handleRequestMigrationData(this.migrationCtx, message.source, message.operationId)
break
case "startMigration":
void handleStartMigration(this.migrationCtx, message.source, message.operationId, message.selections)
break
case "skipLegacyMigration":
void handleSkipLegacyMigration(this.migrationCtx)
break
case "clearLegacyData":
void handleClearLegacyData(this.migrationCtx)
break
case "finalizeLegacyMigration":
void handleFinalizeLegacyMigration(this.migrationCtx)
break
// legacy-migration end
case "enhancePrompt": {
const sdkClient = this.client
if (!sdkClient) {
@@ -1466,6 +1456,39 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return false
}
// legacy-migration start
private handleLegacyMigrationMessage(message: { type: string }): boolean {
switch (message.type) {
case "requestMigrationData": {
const msg = message as unknown as { source: MigrationSource; operationId: string }
void handleRequestMigrationData(this.migrationCtx, msg.source, msg.operationId)
break
}
case "startMigration": {
const msg = message as unknown as {
source: MigrationSource
operationId: string
selections: MigrationSelections
}
void handleStartMigration(this.migrationCtx, msg.source, msg.operationId, msg.selections)
break
}
case "skipLegacyMigration":
void handleSkipLegacyMigration(this.migrationCtx)
break
case "clearLegacyData":
void handleClearLegacyData(this.migrationCtx)
break
case "finalizeLegacyMigration":
void handleFinalizeLegacyMigration(this.migrationCtx)
break
default:
return false
}
return true
}
// legacy-migration end
private async toggleFavorite(message: {
action: "add" | "remove"
providerID: string
@@ -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,
})
}
@@ -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", () => {
@@ -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" },
])
})
})
@@ -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])
})
})
@@ -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", () => {
@@ -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(
@@ -78,11 +100,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?.()
})
@@ -119,7 +147,7 @@ describe("useFileMention", () => {
mention.onInput("@zz", 3)
expect(mention.mentionResults()).toEqual([])
expect(mention.mentionResults()).toEqual([FILE_PICKER_RESULT])
dispose.fn?.()
})
@@ -220,7 +248,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?.()
})
@@ -395,4 +423,245 @@ 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", requestId: expect.any(String) }])
expect(execCalled).toBe(false)
dispose.fn?.()
})
it("insertFilePickerResult inserts the path at the stored position", () => {
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, 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
},
)
const requestId = (posted.at(-1) as { requestId: string }).requestId
mention.insertFilePickerResult("/outside/file.ts", requestId)
} 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 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, 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
},
)
const requestId = (posted.at(-1) as { requestId: string }).requestId
mention.insertFilePickerResult("C:\\Users\\file.ts", requestId)
} 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 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 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,
() => {},
)
const requestId = (posted.at(-1) as { requestId: string }).requestId
mention.insertFilePickerResult("", requestId)
expect(input.value).toBe("hello @b")
dispose.fn?.()
})
it("insertFilePickerResult ignores a result whose requestId doesn't match the pending request", () => {
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 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("/outside/file.ts", "stale-request-id")
expect(input.value).toBe("hello @b")
expect(mention.mentionedPaths().has("/outside/file.ts")).toBe(false)
dispose.fn?.()
})
})
@@ -701,6 +701,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setEnhancing(false)
}
}
if (message.type === "filePickerResult") {
mention.insertFilePickerResult(message.path, message.requestId)
}
})
vscode.postMessage({ type: "requestAutoApproveState" })
@@ -1116,40 +1120,51 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<For each={mention.mentionResults()}>
{(item, index) => (
<div
class="file-mention-item"
classList={{ "file-mention-item--active": index() === mention.mentionIndex() }}
onMouseDown={(e) => {
e.preventDefault()
if (textareaRef) mention.selectMention(item, textareaRef, setText, adjustHeight)
}}
onMouseEnter={() => mention.setMentionIndex(index())}
>
{item.type === "terminal" ? (
<>
<Icon name="console" class="file-mention-icon" />
<span class="file-mention-name">{item.label}</span>
<span class="file-mention-dir">{item.description}</span>
</>
) : item.type === "git-changes" ? (
<>
<Icon name="branch" class="file-mention-icon" />
<span class="file-mention-name">{item.label}</span>
<span class="file-mention-dir">{item.description}</span>
</>
) : (
<>
<FileIcon
node={{ path: item.value, type: item.type === "folder" ? "directory" : "file" }}
class="file-mention-icon"
/>
<span class="file-mention-name">
{item.type === "folder" ? `${fileName(item.value)}/` : fileName(item.value)}
</span>
<span class="file-mention-dir">{dirName(item.value)}</span>
</>
)}
</div>
<>
<div
class="file-mention-item"
classList={{ "file-mention-item--active": index() === mention.mentionIndex() }}
onMouseDown={(e) => {
e.preventDefault()
if (textareaRef) mention.selectMention(item, textareaRef, setText, adjustHeight)
}}
onMouseEnter={() => mention.setMentionIndex(index())}
>
{item.type === "terminal" ? (
<>
<Icon name="console" class="file-mention-icon" />
<span class="file-mention-name">{item.label}</span>
<span class="file-mention-dir">{item.description}</span>
</>
) : item.type === "git-changes" ? (
<>
<Icon name="branch" class="file-mention-icon" />
<span class="file-mention-name">{item.label}</span>
<span class="file-mention-dir">{item.description}</span>
</>
) : item.type === "file-picker" ? (
<>
<Icon name="folder" class="file-mention-icon" />
<span class="file-mention-name">{item.label}</span>
<span class="file-mention-dir">{item.description}</span>
</>
) : (
<>
<FileIcon
node={{ path: item.value, type: item.type === "folder" ? "directory" : "file" }}
class="file-mention-icon"
/>
<span class="file-mention-name">
{item.type === "folder" ? `${fileName(item.value)}/` : fileName(item.value)}
</span>
<span class="file-mention-dir">{dirName(item.value)}</span>
</>
)}
</div>
<Show when={item.type === "file-picker" && index() < mention.mentionResults().length - 1}>
<div class="file-mention-separator" />
</Show>
</>
)}
</For>
</Show>
@@ -10,6 +10,7 @@ export type MentionResult =
| { type: "file"; value: string }
| { type: "opened-file"; value: string }
| { type: "folder"; value: string }
| { type: "file-picker"; value: "file-picker"; label: string; description: string }
export const TERMINAL_RESULT: MentionResult = {
type: "terminal",
@@ -25,6 +26,13 @@ export const GIT_CHANGES_RESULT: MentionResult = {
description: "Current session/worktree changes",
}
export const FILE_PICKER_RESULT: MentionResult = {
type: "file-picker",
value: "file-picker",
label: "Browse files...",
description: "Select a file outside the workspace",
}
/**
* Escape special regex characters in a string so it can be used in a RegExp.
*/
@@ -51,7 +59,12 @@ export function buildMentionResults(query: string, items: Array<FileSearchItem |
if (item.type === "opened-file") return { type: "opened-file", value: item.path }
return { type: "file", value: item.path }
})
return [...getTerminalMentionResult(query), ...(git ? getGitChangesMentionResult(query) : []), ...results]
return [
...getTerminalMentionResult(query),
...(git ? getGitChangesMentionResult(query) : []),
FILE_PICKER_RESULT,
...results,
]
}
export function filterMentionResults(query: string, items: MentionResult[]): MentionResult[] {
@@ -60,6 +73,7 @@ export function filterMentionResults(query: string, items: MentionResult[]): Men
return items.filter((item) => {
if (item.type === "terminal") return TERMINAL_MENTION.startsWith(value)
if (item.type === "git-changes") return GIT_CHANGES_MENTION.startsWith(value) || "git".startsWith(value)
if (item.type === "file-picker") return true
return item.value.toLowerCase().includes(value)
})
}
@@ -166,8 +180,54 @@ export function findMentionRange(
return null
}
function isAbsolutePath(path: string): boolean {
return path.startsWith("/") || /^[A-Za-z]:[\\\/]/.test(path) || path.startsWith("\\\\")
}
/**
* Collapse "." and ".." segments in a forward-slash path so a traversal like
* "/workspace/../../etc/passwd" resolves to its real location ("/etc/passwd")
* before any workspace-containment check runs. Preserves a leading drive
* letter (`C:`) and distinguishes a UNC root ("//server") from a plain root
* ("/"). ".." segments that would go above the root are dropped rather than
* kept, matching filesystem semantics for an absolute path.
*/
function normalizeAbsolutePath(input: string): string {
const drive = input.match(/^[A-Za-z]:/)?.[0] ?? ""
const rest = drive ? input.slice(drive.length) : input
const root = rest.startsWith("//") ? "//" : rest.startsWith("/") ? "/" : ""
const segments = rest
.slice(root.length)
.split("/")
.filter((s) => s.length > 0 && s !== ".")
const stack: string[] = []
for (const seg of segments) {
if (seg === "..") {
if (stack.length > 0) stack.pop()
continue
}
stack.push(seg)
}
return `${drive}${root}${stack.join("/")}`
}
/** Whether `abs` is the workspace root or lives under it (both already normalized). */
function isInsideWorkspace(abs: string, dir: string): boolean {
return abs === dir || abs.startsWith(`${dir}/`)
}
/**
* Build FileAttachment objects from currently mentioned paths in the text.
*
* Paths outside the workspace (e.g. picked via the file picker, or seeded from
* raw draft text via a "../.." traversal) are deliberately excluded: attaching a
* file reads its content on the backend through a path that bypasses the
* permission system, including any prior "deny" decision for that file. Such
* paths remain visible and clickable as a styled mention in the UI, but are not
* auto-attached — if the model needs their contents it must call the Read tool,
* which enforces the normal external-directory permission checks. Every
* resolved path (relative or absolute) is normalized before the containment
* check so a "../" sequence can't slip past a literal string-prefix match.
*/
export function buildFileAttachments(
text: string,
@@ -175,10 +235,12 @@ export function buildFileAttachments(
workspaceDir: string,
): FileAttachment[] {
const result: FileAttachment[] = []
const dir = workspaceDir.replaceAll("\\", "/")
const dir = normalizeAbsolutePath(workspaceDir.replaceAll("\\", "/")).replace(/\/+$/, "")
for (const path of mentionedPaths) {
if (text.includes(`@${path}`)) {
const abs = path.startsWith("/") ? path : `${dir}/${path}`
const raw = isAbsolutePath(path) ? path.replaceAll("\\", "/") : `${dir}/${path}`
const abs = normalizeAbsolutePath(raw)
if (!isInsideWorkspace(abs, dir)) continue
const url = new URL("file://")
url.pathname = abs.startsWith("/") ? abs : `/${abs}`
result.push({ mime: "text/plain", url: url.href })
@@ -10,6 +10,7 @@ import {
isCursorAtMentionEnd,
getMentionRemovalRange,
findMentionRange,
FILE_PICKER_RESULT,
type MentionResult,
} from "./file-mention-utils"
@@ -65,6 +66,8 @@ export interface FileMention {
snapSelection: (textarea: HTMLTextAreaElement) => void
/** Seed known paths from existing text (e.g. after undo restores a draft). */
seedFromText: (text: string) => void
/** Insert a file-picker result at the stored cursor position. Ignored unless requestId matches the pending request. */
insertFilePickerResult: (path: string, requestId: string) => void
}
export function useFileMention(
@@ -83,6 +86,15 @@ export function useFileMention(
let fileSearchTimer: ReturnType<typeof setTimeout> | undefined
let fileSearchCounter = 0
let filePickerCounter = 0
let pickerState: {
requestId: string
textarea: HTMLTextAreaElement
atStart: number
atEnd: number
setText: (text: string) => void
onSelect?: () => void
} | null = null
let pendingArrowSnap: { timer: ReturnType<typeof setTimeout>; prevValue: string; prevPosition: number } | undefined
const showMention = () => mentionQuery() !== null
@@ -141,6 +153,18 @@ export function useFileMention(
const before = val.substring(0, cursor)
const after = val.substring(cursor)
if (result.type === "file-picker") {
const match = before.match(AT_PATTERN)!
const prefix = /^\s/.test(match[0]) ? 1 : 0
const atPos = match.index! + prefix
filePickerCounter++
const requestId = `file-picker-${filePickerCounter}`
pickerState = { requestId, textarea, atStart: atPos, atEnd: cursor, setText: _setText, onSelect }
closeMention()
vscode.postMessage({ type: "requestFilePicker", requestId })
return
}
// Add to knownPaths BEFORE execCommand so syncMentionedPaths (triggered
// by the input event) can discover the new path.
if (result.type === "file" || result.type === "folder" || result.type === "opened-file")
@@ -357,7 +381,10 @@ export function useFileMention(
}
const seedFromText = (text: string) => {
const re = /@([\w./-]+\.[\w]+|[\w.-]+\/[\w./-]+)/g
// The optional drive-letter prefix is scoped to a single letter directly after
// @ (e.g. "C:") so a colon elsewhere in the match (as in "@https://example.com")
// doesn't get mistaken for a Windows path.
const re = /@((?:[A-Za-z]:)?(?:[\w./-]+\.[\w]+|[\w.-]+\/[\w./-]+))/g
let m: RegExpExecArray | null
while ((m = re.exec(text))) {
knownPaths.add(m[1])
@@ -365,6 +392,44 @@ export function useFileMention(
syncMentionedPaths(text)
}
const insertFilePickerResult = (path: string, requestId: string) => {
const state = pickerState
if (!state || state.requestId !== requestId) return
if (!path) {
pickerState = null
return
}
const norm = path.replaceAll("\\", "/")
pickerState = null
const textarea = state.textarea
if (!textarea.isConnected) return
const after = textarea.value.substring(state.atEnd)
const suffix = /^\s/.test(after) ? "" : " "
// Insert as a styled @mention so it renders like any other file reference and
// is clickable to preview (openFile is a plain editor action on the user's own
// disk, unrelated to the AI permission system). The actual security boundary
// lives in buildFileAttachments: paths outside the workspace are never turned
// into an auto-read FileAttachment, regardless of how they were mentioned, so
// a prior "deny" decision can't be bypassed by picking/attaching this way. If
// the model wants the file's contents it must call the Read tool, which
// enforces the normal external-directory permission checks.
// Restore focus before execCommand: after the native dialog closes the textarea
// is no longer the active element, so execCommand would otherwise silently no-op.
textarea.focus()
suppress = true
try {
textarea.setSelectionRange(state.atStart, state.atEnd)
document.execCommand("insertText", false, `@${norm}${suffix}`)
} finally {
suppress = false
}
knownPaths.add(norm)
setMentionedPaths((prev) => new Set([...prev, norm]))
syncMentionedPaths(textarea.value)
state.setText(textarea.value)
state.onSelect?.()
}
return {
mentionedPaths,
mentionResults,
@@ -381,5 +446,6 @@ export function useFileMention(
handleArrowKey,
snapSelection,
seedFromText,
insertFilePickerResult,
}
}
@@ -64,6 +64,13 @@
text-align: center;
}
.file-mention-separator {
height: 1px;
margin: 4px 10px;
background: var(--vscode-editorWidget-border, var(--vscode-input-border));
opacity: 0.5;
}
/* ============================================
Slash Command Dropdown
============================================ */
@@ -437,6 +437,12 @@ export interface FileSearchResultMessage {
requestId: string
}
export interface FilePickerResultMessage {
type: "filePickerResult"
path: string
requestId: string
}
export interface TerminalContextResultMessage {
type: "terminalContextResult"
requestId: string
@@ -1117,6 +1123,7 @@ export type ExtensionMessage =
| SpeechToTextResultMessage
| SpeechToTextErrorMessage
| FileSearchResultMessage
| FilePickerResultMessage
| TerminalContextResultMessage
| TerminalContextErrorMessage
| GitChangesContextResultMessage
@@ -401,6 +401,11 @@ export interface RequestFileSearchMessage {
sessionID?: string
}
export interface RequestFilePickerMessage {
type: "requestFilePicker"
requestId: string
}
export interface RequestTerminalContextMessage {
type: "requestTerminalContext"
requestId: string
@@ -1250,6 +1255,7 @@ export type WebviewMessage =
| SpeechToTextStopMessage
| SpeechToTextCancelMessage
| RequestFileSearchMessage
| RequestFilePickerMessage
| RequestTerminalContextMessage
| RequestGitChangesContextMessage
| ChatCompletionAcceptedMessage