Merge pull request #10099 from sylwester-liljegren/enhanced-file-mention-style

feat(prompt-input): add interactive file mention interactions with vi…
This commit is contained in:
Marius
2026-05-20 16:55:19 +02:00
committed by GitHub
13 changed files with 632 additions and 25 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Render file mentions as styled chips with click-to-open in both the chat input and sent user messages, with atomic backspace removal, arrow key skipping, selection snapping, and mention formatting preserved after reverting a session.
@@ -187,6 +187,25 @@
}
}
/* File mention chips in sent user messages */
[data-slot="user-message-text"] [data-highlight="file"] {
color: var(--vscode-textLink-foreground, var(--syntax-property));
background-color: color-mix(in srgb, var(--vscode-textLink-foreground, var(--syntax-property)) 15%, transparent);
border-radius: 3px;
box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--vscode-textLink-foreground, var(--syntax-property)) 25%, transparent);
}
[data-slot="user-message-text"] [data-highlight="file"][data-clickable] {
cursor: pointer;
transition: background-color 0.15s, box-shadow 0.15s;
}
[data-slot="user-message-text"] [data-highlight="file"][data-clickable]:hover {
background-color: color-mix(in srgb, var(--vscode-textLink-foreground, var(--syntax-property)) 25%, transparent);
box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--vscode-textLink-foreground, var(--syntax-property)) 40%, transparent);
text-decoration: underline;
}
html[data-theme="kilo-vscode"] [data-component="bash-output"] {
border-radius: 0;
background: var(--vscode-terminal-background, var(--vscode-panel-background));
@@ -929,18 +929,34 @@ export function UserMessageDisplay(props: {
type HighlightSegment = { text: string; type?: "file" | "agent" }
/** Match @path mentions: `@` followed by a path-like token (contains `/` or `.`). */
const MENTION_RE = /@([\w./-]+\.[\w]+|[\w.-]+\/[\w./-]+)/g
function detectMentions(text: string): { start: number; end: number; type: "file" }[] {
const result: { start: number; end: number; type: "file" }[] = []
MENTION_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = MENTION_RE.exec(text))) {
result.push({ start: m.index, end: m.index + m[0].length, type: "file" })
}
return result
}
function HighlightedText(props: { text: string; references: FilePart[]; agents: AgentPart[] }) {
const segments = createMemo(() => {
const text = props.text
const allRefs: { start: number; end: number; type: "file" | "agent" }[] = [
const offset: { start: number; end: number; type: "file" | "agent" }[] = [
...props.references
.filter((r) => r.source?.text?.start !== undefined && r.source?.text?.end !== undefined)
.map((r) => ({ start: r.source!.text!.start, end: r.source!.text!.end, type: "file" as const })),
...props.agents
.filter((a) => a.source?.start !== undefined && a.source?.end !== undefined)
.map((a) => ({ start: a.source!.start, end: a.source!.end, type: "agent" as const })),
].sort((a, b) => a.start - b.start)
]
// Fall back to regex detection when no source offsets are available
const allRefs = offset.length > 0 ? offset.sort((a, b) => a.start - b.start) : detectMentions(text)
const result: HighlightSegment[] = []
let lastIndex = 0
@@ -963,7 +979,28 @@ function HighlightedText(props: { text: string; references: FilePart[]; agents:
return result
})
return <For each={segments()}>{(segment) => <span data-highlight={segment.type}>{segment.text}</span>}</For>
const data = useData()
const click = (segment: HighlightSegment, e: MouseEvent) => {
if (segment.type !== "file" || !data.openFile) return
e.preventDefault()
const path = segment.text.replace(/^@/, "")
if (path) data.openFile(path)
}
return (
<For each={segments()}>
{(segment) => (
<span
data-highlight={segment.type}
data-clickable={segment.type === "file" && data.openFile ? "" : undefined}
onClick={[click, segment]}
>
{segment.text}
</span>
)}
</For>
)
}
export function Part(props: MessagePartProps) {
+18 -9
View File
@@ -2987,17 +2987,26 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const uri = isAbsolutePath(filePath)
? vscode.Uri.file(filePath)
: vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory(this.currentSession?.id)), filePath)
vscode.workspace.openTextDocument(uri).then(
(doc) => {
const options: vscode.TextDocumentShowOptions = { preview: true }
if (line !== undefined && line > 0) {
const col = column !== undefined && column > 0 ? column - 1 : 0
const pos = new vscode.Position(line - 1, col)
options.selection = new vscode.Range(pos, pos)
vscode.workspace.fs.stat(uri).then(
(stat) => {
if (stat.type & vscode.FileType.Directory) {
vscode.commands.executeCommand("revealInExplorer", uri)
return
}
vscode.window.showTextDocument(doc, options)
vscode.workspace.openTextDocument(uri).then(
(doc) => {
const options: vscode.TextDocumentShowOptions = { preview: true }
if (line !== undefined && line > 0) {
const col = column !== undefined && column > 0 ? column - 1 : 0
const pos = new vscode.Position(line - 1, col)
options.selection = new vscode.Range(pos, pos)
}
vscode.window.showTextDocument(doc, options)
},
(err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
)
},
(err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
(err) => console.error("[Kilo New] KiloProvider: Path does not exist:", uri.fsPath, err),
)
}
@@ -6,6 +6,9 @@ import {
buildFileAttachments,
buildMentionResults,
filterMentionResults,
getMentionRemovalRange,
isCursorAtMentionEnd,
findMentionRange,
} from "../../webview-ui/src/hooks/file-mention-utils"
describe("AT_PATTERN", () => {
@@ -240,3 +243,152 @@ describe("buildFileAttachments", () => {
expect(result[0]!.url).not.toContain("\\")
})
})
describe("getMentionRemovalRange", () => {
it("returns range for a file path mention ending at position", () => {
const text = "see @foo.ts for details"
const paths = new Set(["foo.ts"])
// position = 11 → text.slice(0, 11) = "see @foo.ts"
const result = getMentionRemovalRange(text, 11, paths)
expect(result).toEqual({ start: 4, end: 12 })
})
it("includes trailing whitespace in the range", () => {
const text = "check @src/bar.ts rest"
const paths = new Set(["src/bar.ts"])
// position = 17 → slice(0,17) = "check @src/bar.ts", slice(17) = " rest"
const result = getMentionRemovalRange(text, 17, paths)
expect(result).toEqual({ start: 6, end: 18 })
})
it("does not include trailing non-space character", () => {
const text = "@foo.tsmore"
const paths = new Set(["foo.ts"])
const result = getMentionRemovalRange(text, 7, paths)
expect(result).toEqual({ start: 0, end: 7 })
})
it("returns null when no mention ends at position", () => {
const text = "no mention here"
const paths = new Set(["foo.ts"])
expect(getMentionRemovalRange(text, 5, paths)).toBeNull()
})
it("matches terminal builtin mention", () => {
const text = "see @terminal output"
const result = getMentionRemovalRange(text, 13, new Set())
expect(result).toEqual({ start: 4, end: 14 })
})
it("matches git-changes builtin mention", () => {
const text = "see @git-changes here"
const result = getMentionRemovalRange(text, 16, new Set())
expect(result).toEqual({ start: 4, end: 17 })
})
it("prefers the longest matching path", () => {
const text = "see @src/a.tsx end"
const paths = new Set(["src/a.ts", "src/a.tsx"])
const result = getMentionRemovalRange(text, 14, paths)
expect(result).toEqual({ start: 4, end: 15 })
})
it("handles mention at end of text with no trailing space", () => {
const text = "check @foo.ts"
const paths = new Set(["foo.ts"])
const result = getMentionRemovalRange(text, 13, paths)
expect(result).toEqual({ start: 6, end: 13 })
})
})
describe("isCursorAtMentionEnd", () => {
it("returns true when cursor is at end of a file mention", () => {
const text = "see @foo.ts rest"
const paths = new Set(["foo.ts"])
expect(isCursorAtMentionEnd(text, 11, paths)).toBe(true)
})
it("returns false when cursor is not at a mention boundary", () => {
const text = "see @foo.ts rest"
const paths = new Set(["foo.ts"])
expect(isCursorAtMentionEnd(text, 8, paths)).toBe(false)
})
it("returns false for empty paths and no builtin match", () => {
expect(isCursorAtMentionEnd("hello", 3, new Set())).toBe(false)
})
it("matches terminal builtin", () => {
expect(isCursorAtMentionEnd("@terminal", 9, new Set())).toBe(true)
})
it("matches git-changes builtin", () => {
expect(isCursorAtMentionEnd("@git-changes", 12, new Set())).toBe(true)
})
it("does not match partial path", () => {
const text = "see @foo.ts rest"
const paths = new Set(["foo.tsx"])
expect(isCursorAtMentionEnd(text, 11, paths)).toBe(false)
})
})
describe("findMentionRange", () => {
it("returns range when cursor is inside a mention", () => {
const text = "see @foo.ts rest"
const paths = new Set(["foo.ts"])
// position 7 is inside "@foo.ts" (indices 4..11)
const result = findMentionRange(text, 7, paths)
expect(result).toEqual({ start: 4, end: 11 })
})
it("returns null when cursor is at the start edge of a mention", () => {
const text = "see @foo.ts rest"
const paths = new Set(["foo.ts"])
expect(findMentionRange(text, 4, paths)).toBeNull()
})
it("returns null when cursor is at the end edge of a mention", () => {
const text = "see @foo.ts rest"
const paths = new Set(["foo.ts"])
expect(findMentionRange(text, 11, paths)).toBeNull()
})
it("returns null when cursor is outside any mention", () => {
const text = "see @foo.ts rest"
const paths = new Set(["foo.ts"])
expect(findMentionRange(text, 2, paths)).toBeNull()
})
it("matches the second occurrence of a duplicated mention", () => {
const text = "@a.ts and @a.ts"
const paths = new Set(["a.ts"])
// First @a.ts is at 0..5, second at 10..15
const result = findMentionRange(text, 12, paths)
expect(result).toEqual({ start: 10, end: 15 })
})
it("handles builtin mentions", () => {
const text = "check @terminal output"
const result = findMentionRange(text, 8, new Set())
expect(result).toEqual({ start: 6, end: 15 })
})
it("prefers the longest matching path to avoid partial matches", () => {
const text = "see @src/a.tsx end"
const paths = new Set(["src/a.ts", "src/a.tsx"])
// position 10 is inside @src/a.tsx (indices 4..14)
const result = findMentionRange(text, 10, paths)
expect(result).toEqual({ start: 4, end: 14 })
})
it("skips overlapping token matches correctly", () => {
const text = "@ab@ab"
const paths = new Set(["ab"])
// First @ab is at 0..3, second at 3..6
// Position 1 is inside the first
expect(findMentionRange(text, 1, paths)).toEqual({ start: 0, end: 3 })
// Position 4 is inside the second
expect(findMentionRange(text, 4, paths)).toEqual({ start: 3, end: 6 })
})
})
@@ -218,6 +218,36 @@ describe("Bash tool syntax highlighting and section labels (source)", () => {
})
})
describe("HighlightedText @mention regex fallback and click handler (source)", () => {
const src = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8")
it("detects @path patterns via regex when source offsets are missing", () => {
// detectMentions is the regex fallback for when the backend doesn't
// populate FilePart.source.text.{start,end}
expect(src).toContain("detectMentions")
expect(src).toMatch(/MENTION_RE/)
})
it("prefers source offsets over regex when both are available", () => {
expect(src).toMatch(/offset\.length\s*>\s*0\s*\?/)
})
it("file mention spans are clickable via data.openFile", () => {
expect(src).toContain("data-clickable")
expect(src).toMatch(/segment\.type\s*===\s*"file".*data\.openFile/)
})
it("click handler strips @ prefix before calling openFile", () => {
expect(src).toMatch(/segment\.text\.replace\(\/\^@\//)
})
it("escapeHtml is imported from shared util, not duplicated", () => {
expect(src).toMatch(/import.*escapeHtml.*from.*util\/escape-html/)
// Must NOT contain a local function definition
expect(src).not.toMatch(/function escapeHtml/)
})
})
describe("BasicTool export contract (runtime)", () => {
it("BasicTool and GenericTool are exported from basic-tool", () => {
const result = check(`
@@ -9,6 +9,7 @@ import {
isPromptBusy,
isSuggesting,
isQuestioning,
isPathMention,
} from "../../webview-ui/src/components/chat/prompt-input-utils"
describe("fileName", () => {
@@ -249,3 +250,33 @@ describe("isQuestioning", () => {
expect(isQuestioning(false, 0)).toBe(false)
})
})
describe("isPathMention", () => {
it("returns true for a file path", () => {
expect(isPathMention("@src/foo.ts")).toBe(true)
})
it("returns true for a simple filename", () => {
expect(isPathMention("@README.md")).toBe(true)
})
it("returns true for a folder path with trailing slash", () => {
expect(isPathMention("@src/components/")).toBe(true)
})
it("returns true for a folder path without trailing slash", () => {
expect(isPathMention("@src/components")).toBe(true)
})
it("returns false for terminal mention", () => {
expect(isPathMention("@terminal")).toBe(false)
})
it("returns false for git-changes mention", () => {
expect(isPathMention("@git-changes")).toBe(false)
})
it("handles text without @ prefix", () => {
expect(isPathMention("src/foo.ts")).toBe(true)
})
})
@@ -86,6 +86,67 @@ describe("useFileMention", () => {
dispose.fn?.()
})
it("seedFromText populates knownPaths so mentions are recognized in pre-filled text", () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
// Before seeding, no paths are known
expect(mention.mentionedPaths().size).toBe(0)
// Seed from text containing @mentions (simulates setChatBoxMessage after revert)
mention.seedFromText("Say hi to @packages/plugin/tsconfig.json !")
expect(mention.mentionedPaths().has("packages/plugin/tsconfig.json")).toBe(true)
dispose.fn?.()
})
it("seedFromText handles multiple @mentions in one string", () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
mention.seedFromText("check @src/a.ts and @src/b.tsx")
expect(mention.mentionedPaths().has("src/a.ts")).toBe(true)
expect(mention.mentionedPaths().has("src/b.tsx")).toBe(true)
dispose.fn?.()
})
it("seedFromText ignores text without @mentions", () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
mention.seedFromText("no mentions here")
expect(mention.mentionedPaths().size).toBe(0)
dispose.fn?.()
})
it("filters visible results synchronously while a new search is pending", async () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
@@ -37,7 +37,15 @@ import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageA
import { convertToMentionPath } from "../../utils/path-mentions"
import { usePromptHistory } from "../../hooks/usePromptHistory"
import { WandSparkles } from "@kilocode/kilo-ui/lucide"
import { fileName, dirName, buildHighlightSegments, atEnd, insertSpacedText, isPromptBusy } from "./prompt-input-utils"
import {
fileName,
dirName,
buildHighlightSegments,
atEnd,
insertSpacedText,
isPromptBusy,
isPathMention,
} from "./prompt-input-utils"
import type { ReviewComment, TextPart } from "../../types/messages"
import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown"
import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../utils/prompt-drafts"
@@ -381,6 +389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const unsubscribe = vscode.onMessage((message) => {
if (message.type === "setChatBoxMessage") {
setText(message.text)
mention.seedFromText(message.text)
if (textareaRef) {
textareaRef.value = message.text
adjustHeight()
@@ -429,6 +438,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (target === draftKey() && !text().trim() && imageAttach.images().length === 0) {
if (failed.text) {
setText(failed.text)
mention.seedFromText(failed.text)
if (textareaRef) {
textareaRef.value = failed.text
adjustHeight()
@@ -475,6 +485,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const result = message as import("../../types/messages").EnhancePromptResultMessage
if (result.requestId === `enhance-${draftKey()}-${enhanceCounter}`) {
setText(result.text)
mention.seedFromText(result.text)
setEnhancing(false)
if (textareaRef) {
textareaRef.value = result.text
@@ -582,6 +593,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
// Atomic mention removal on backspace
if (
mention.handleBackspace(e, textareaRef, setText, () => {
adjustHeight()
syncHighlightScroll()
})
)
return
// Skip cursor over mentions on arrow keys
if (mention.handleArrowKey(e, textareaRef)) return
if (slash.onKeyDown(e, textareaRef, setText, adjustHeight)) {
ghost.setMentionOpen(slash.show())
queueMicrotask(scrollToActiveSlashItem)
@@ -1010,7 +1033,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Index each={buildHighlightSegments(text(), highlightMentions())}>
{(seg) => (
<Show when={seg().highlight} fallback={<span>{seg().text}</span>}>
<span class="prompt-input-file-mention">{seg().text}</span>
<span
class="prompt-input-file-mention"
classList={{ "prompt-input-file-mention--file": isPathMention(seg().text) }}
onClick={(e) => {
if (!isPathMention(seg().text)) return
e.preventDefault()
e.stopPropagation()
vscode.postMessage({ type: "openFile", filePath: seg().text.replace(/^@/, "") })
}}
>
{seg().text}
</span>
</Show>
)}
</Index>
@@ -1037,7 +1071,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onClick={syncGhost}
onFocus={syncGhost}
onBlur={syncGhost}
onSelect={syncGhost}
onSelect={() => {
syncGhost()
if (textareaRef) mention.snapSelection(textareaRef)
}}
onScroll={syncHighlightScroll}
aria-disabled={isDisabled()}
rows={1}
@@ -110,3 +110,9 @@ export function isSuggesting(blocked: boolean, suggestions: number): boolean {
export function isQuestioning(blocked: boolean, questions: number): boolean {
return !blocked && questions > 0
}
/** Whether a mention token refers to a file or folder path (not a special mention like terminal/git-changes). */
export function isPathMention(text: string): boolean {
const path = text.replace(/^@/, "")
return path !== "terminal" && path !== "git-changes"
}
@@ -98,6 +98,74 @@ export function buildTextAfterMentionSelect(before: string, after: string, path:
return replaced + suffix + after
}
/**
* Return the character range [start, end) of a mention ending at `position`,
* including one trailing whitespace character if present. Used by execCommand
* deletion so the change is added to the browser's undo stack.
*/
export function getMentionRemovalRange(
text: string,
position: number,
paths: Set<string>,
): { start: number; end: number } | null {
const before = text.slice(0, position)
const all = [...[...paths].sort((a, b) => b.length - a.length), TERMINAL_MENTION, GIT_CHANGES_MENTION]
for (const path of all) {
const token = `@${path}`
if (before.endsWith(token)) {
const start = position - token.length
const trailing = /^\s/.test(text.slice(position)) ? 1 : 0
return { start, end: position + trailing }
}
}
return null
}
/**
* Check whether the cursor sits immediately after a known mention.
*/
export function isCursorAtMentionEnd(text: string, position: number, paths: Set<string>): boolean {
const before = text.slice(0, position)
const sorted = [...paths].sort((a, b) => b.length - a.length)
for (const path of sorted) {
if (before.endsWith(`@${path}`)) return true
}
for (const builtin of [TERMINAL_MENTION, GIT_CHANGES_MENTION]) {
if (before.endsWith(`@${builtin}`)) return true
}
return false
}
/**
* If the cursor is inside (or at a boundary of) a known @mention token,
* return the token's start and end offsets. Returns null otherwise.
* "Inside" means start < position < end (exclusive boundaries are not
* considered inside, so the cursor can sit right before or right after
* a mention without triggering a skip).
*/
export function findMentionRange(
text: string,
position: number,
paths: Set<string>,
): { start: number; end: number } | null {
const all = [...paths, TERMINAL_MENTION, GIT_CHANGES_MENTION]
// Check longest first to avoid partial matches
all.sort((a, b) => b.length - a.length)
for (const path of all) {
const token = `@${path}`
let idx = text.indexOf(token)
while (idx !== -1) {
const end = idx + token.length
// Cursor is strictly inside the token (not at the edges)
if (position > idx && position < end) {
return { start: idx, end }
}
idx = text.indexOf(token, idx + token.length)
}
}
return null
}
/**
* Build FileAttachment objects from currently mentioned paths in the text.
*/
@@ -4,10 +4,12 @@ import type { FileAttachment, WebviewMessage, ExtensionMessage } from "../types/
import {
AT_PATTERN,
syncMentionedPaths as _syncMentionedPaths,
buildTextAfterMentionSelect,
buildFileAttachments,
buildMentionResults,
filterMentionResults,
isCursorAtMentionEnd,
getMentionRemovalRange,
findMentionRange,
type MentionResult,
} from "./file-mention-utils"
@@ -41,6 +43,28 @@ export interface FileMention {
parseFileAttachments: (text: string) => FileAttachment[]
/** Register paths as active mentions (used by drag-and-drop). Pass cwd to ensure buildFileAttachments resolves correctly. */
addPaths: (paths: string[], cwd: string) => void
/**
* Handle backspace for atomic mention removal. Returns true if the
* event was consumed.
*/
handleBackspace: (
e: KeyboardEvent,
textarea: HTMLTextAreaElement | undefined,
setText: (text: string) => void,
adjust?: () => void,
) => boolean
/**
* Skip the cursor over a mention when pressing ArrowLeft/ArrowRight.
* Returns true if the event was consumed.
*/
handleArrowKey: (e: KeyboardEvent, textarea: HTMLTextAreaElement | undefined) => boolean
/**
* Snap a partial text selection so it fully covers any mention that is
* only partially selected. Call from the textarea's onSelect handler.
*/
snapSelection: (textarea: HTMLTextAreaElement) => void
/** Seed known paths from existing text (e.g. after undo restores a draft). */
seedFromText: (text: string) => void
}
export function useFileMention(
@@ -53,6 +77,9 @@ export function useFileMention(
const [mentionResults, setMentionResults] = createSignal<MentionResult[]>([])
const [mentionIndex, setMentionIndex] = createSignal(0)
let workspaceDir = ""
// Accumulates every path ever mentioned so syncMentionedPaths can
// rediscover them after a native undo restores the text.
const knownPaths = new Set<string>()
let fileSearchTimer: ReturnType<typeof setTimeout> | undefined
let fileSearchCounter = 0
@@ -98,13 +125,13 @@ export function useFileMention(
}
const syncMentionedPaths = (text: string) => {
setMentionedPaths((prev) => _syncMentionedPaths(prev, text))
setMentionedPaths(() => _syncMentionedPaths(knownPaths, text))
}
const selectMention = (
result: MentionResult,
textarea: HTMLTextAreaElement,
setText: (text: string) => void,
_setText: (text: string) => void,
onSelect?: () => void,
) => {
const val = textarea.value
@@ -112,13 +139,26 @@ export function useFileMention(
const before = val.substring(0, cursor)
const after = val.substring(cursor)
const text = buildTextAfterMentionSelect(before, after, result.value)
textarea.value = text
setText(text)
// 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")
knownPaths.add(result.value)
// Replace the @query with the selected @path via execCommand so the
// change lands on the browser's native undo stack. AT_PATTERN is
// guaranteed to match here — the dropdown only opens when it matched.
const match = before.match(AT_PATTERN)!
const prefix = /^\s/.test(match[0]) ? 1 : 0
const atPos = match.index! + prefix
const suffix = /^\s/.test(after) ? "" : " "
suppress = true
try {
textarea.setSelectionRange(atPos, cursor)
document.execCommand("insertText", false, `@${result.value}${suffix}`)
} finally {
suppress = false
}
// Position cursor right after the inserted @mention
const pos = text.length - after.length
textarea.setSelectionRange(pos, pos)
textarea.focus()
if (result.type === "file" || result.type === "folder" || result.type === "opened-file")
@@ -127,8 +167,12 @@ export function useFileMention(
onSelect?.()
}
// When true, onInput skips dropdown logic (used during execCommand changes)
let suppress = false
const onInput = (val: string, cursor: number) => {
syncMentionedPaths(val)
if (suppress) return
const before = val.substring(0, cursor)
const match = before.match(AT_PATTERN)
if (match) {
@@ -184,6 +228,7 @@ export function useFileMention(
const addPaths = (paths: string[], cwd: string) => {
if (cwd) workspaceDir = cwd
for (const p of paths) knownPaths.add(p)
setMentionedPaths((prev) => {
const next = new Set(prev)
for (const p of paths) next.add(p)
@@ -194,6 +239,92 @@ export function useFileMention(
const parseFileAttachments = (text: string): FileAttachment[] =>
buildFileAttachments(text, mentionedPaths(), workspaceDir)
const handleBackspace = (
e: KeyboardEvent,
textarea: HTMLTextAreaElement | undefined,
_setText: (text: string) => void,
_adjust?: () => void,
): boolean => {
if (e.key !== "Backspace" || e.isComposing || !textarea) return false
const val = textarea.value
const cursor = textarea.selectionStart ?? 0
if (textarea.selectionStart !== textarea.selectionEnd) return false
const charBefore = val[cursor - 1]
if (charBefore !== " " && charBefore !== "\n") return false
if (!isCursorAtMentionEnd(val, cursor - 1, mentionedPaths())) return false
// Cursor is on the space right after a mention — remove the entire
// mention + trailing space in one step via execCommand so the change
// lands on the browser's native undo stack.
const range = getMentionRemovalRange(val, cursor - 1, mentionedPaths())
if (!range) return false
e.preventDefault()
suppress = true
try {
textarea.setSelectionRange(range.start, range.end)
document.execCommand("insertText", false, "")
} finally {
suppress = false
}
return true
}
const handleArrowKey = (e: KeyboardEvent, textarea: HTMLTextAreaElement | undefined): boolean => {
if ((e.key !== "ArrowLeft" && e.key !== "ArrowRight") || !textarea) return false
// Don't interfere with selection (Shift) or word/line navigation (Ctrl/Cmd/Alt)
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return false
const cursor = textarea.selectionStart ?? 0
// Only when there's no active selection
if (textarea.selectionStart !== textarea.selectionEnd) return false
// Check where the cursor WOULD land after the native move
const next = e.key === "ArrowRight" ? cursor + 1 : cursor - 1
const range = findMentionRange(textarea.value, next, mentionedPaths())
if (!range) return false
e.preventDefault()
const pos = e.key === "ArrowRight" ? range.end : range.start
textarea.setSelectionRange(pos, pos)
return true
}
let snapping = false
const snapSelection = (textarea: HTMLTextAreaElement): void => {
if (snapping) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
if (start === end) return // cursor, not a selection
const val = textarea.value
const paths = mentionedPaths()
let snapped = start
let snappedEnd = end
const startRange = findMentionRange(val, start, paths)
if (startRange) snapped = startRange.start
const endRange = findMentionRange(val, end, paths)
if (endRange) snappedEnd = endRange.end
if (snapped !== start || snappedEnd !== end) {
snapping = true
textarea.setSelectionRange(snapped, snappedEnd, textarea.selectionDirection)
snapping = false
}
}
const seedFromText = (text: string) => {
const re = /@([\w./-]+\.[\w]+|[\w.-]+\/[\w./-]+)/g
let m: RegExpExecArray | null
while ((m = re.exec(text))) {
knownPaths.add(m[1])
}
syncMentionedPaths(text)
}
return {
mentionedPaths,
mentionResults,
@@ -206,5 +337,9 @@ export function useFileMention(
closeMention,
parseFileAttachments,
addPaths,
handleBackspace,
handleArrowKey,
snapSelection,
seedFromText,
}
}
@@ -362,12 +362,29 @@
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
z-index: 0;
z-index: 2;
color: var(--vscode-input-foreground);
}
.prompt-input-file-mention {
color: var(--vscode-textLink-foreground, #3794ff);
background-color: color-mix(in srgb, var(--vscode-textLink-foreground, #3794ff) 15%, transparent);
border-radius: 3px;
box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--vscode-textLink-foreground, #3794ff) 25%, transparent);
}
.prompt-input-file-mention--file {
pointer-events: auto;
cursor: pointer;
transition:
background-color 0.15s,
box-shadow 0.15s;
}
.prompt-input-file-mention--file:hover {
background-color: color-mix(in srgb, var(--vscode-textLink-foreground, #3794ff) 25%, transparent);
box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--vscode-textLink-foreground, #3794ff) 40%, transparent);
text-decoration: underline;
}
.prompt-input-ghost-text {