diff --git a/.changeset/mention-chip-style.md b/.changeset/mention-chip-style.md
new file mode 100644
index 0000000000..9621f918cb
--- /dev/null
+++ b/.changeset/mention-chip-style.md
@@ -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.
diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css
index 5494f7c0c7..414110ec40 100644
--- a/packages/kilo-ui/src/components/message-part.css
+++ b/packages/kilo-ui/src/components/message-part.css
@@ -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));
diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx
index aa9bbbe086..fff3a7b978 100644
--- a/packages/kilo-ui/src/components/message-part.tsx
+++ b/packages/kilo-ui/src/components/message-part.tsx
@@ -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 {(segment) => {segment.text}}
+ 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 (
+
+ {(segment) => (
+
+ {segment.text}
+
+ )}
+
+ )
}
export function Part(props: MessagePartProps) {
diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts
index 1010dde9fc..b4fe6a4ecb 100644
--- a/packages/kilo-vscode/src/KiloProvider.ts
+++ b/packages/kilo-vscode/src/KiloProvider.ts
@@ -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),
)
}
diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts
index 2846dd030f..6d5083bd66 100644
--- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts
+++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts
@@ -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 })
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts
index 1397f19101..784aa019e4 100644
--- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts
+++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts
@@ -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(`
diff --git a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts
index 916e715b20..9ccd644809 100644
--- a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts
+++ b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts
@@ -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)
+ })
+})
diff --git a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts
index 60c444ee9a..5e2fefc569 100644
--- a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts
+++ b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts
@@ -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>()
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
index 0314b3521e..897a10c9f7 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx
@@ -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 = (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 = (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 = (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 = (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 = (props) => {
{(seg) => (
{seg().text}}>
- {seg().text}
+ {
+ if (!isPathMention(seg().text)) return
+ e.preventDefault()
+ e.stopPropagation()
+ vscode.postMessage({ type: "openFile", filePath: seg().text.replace(/^@/, "") })
+ }}
+ >
+ {seg().text}
+
)}
@@ -1037,7 +1071,10 @@ export const PromptInput: Component = (props) => {
onClick={syncGhost}
onFocus={syncGhost}
onBlur={syncGhost}
- onSelect={syncGhost}
+ onSelect={() => {
+ syncGhost()
+ if (textareaRef) mention.snapSelection(textareaRef)
+ }}
onScroll={syncHighlightScroll}
aria-disabled={isDisabled()}
rows={1}
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts
index 65dbd032eb..22fb8db90a 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/prompt-input-utils.ts
@@ -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"
+}
diff --git a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts
index 43d704e55a..6de5511815 100644
--- a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts
+++ b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts
@@ -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,
+): { 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): 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,
+): { 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.
*/
diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts
index 479f7a158b..342c603cd7 100644
--- a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts
+++ b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts
@@ -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([])
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()
let fileSearchTimer: ReturnType | 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,
}
}
diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css
index 2a158d5e47..333249377a 100644
--- a/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css
+++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css
@@ -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 {