From 47c8600b21dfc9ce5f9010a471e075af10867cf0 Mon Sep 17 00:00:00 2001 From: Sylwester Liljegren Date: Sun, 10 May 2026 01:35:19 +0200 Subject: [PATCH] feat(prompt-input): add interactive file mention interactions with visual enhancements Implement click-to-open functionality for file mentions, allowing users to directly open files from the prompt input. Add visual distinction between file mentions and built-in mentions (@terminal, @git-changes) through conditional styling with background colors, borders, and hover effects. Enhance mention selection snapping to fully cover partially-selected mentions, and introduce keyboard handlers for atomic backspace deletion and arrow key navigation that skips cursor movement through mention tokens. Persist known paths across the session to enable proper mention rediscovery after undo operations. --- .changeset/mention-chip-style.md | 5 + .../kilo-ui/src/components/message-part.css | 19 +++ .../kilo-ui/src/components/message-part.tsx | 43 ++++- packages/kilo-vscode/src/KiloProvider.ts | 27 ++-- .../tests/unit/file-mention-utils.test.ts | 152 +++++++++++++++++ .../tests/unit/kilo-ui-contract.test.ts | 30 ++++ .../tests/unit/prompt-input-utils.test.ts | 31 ++++ .../tests/unit/use-file-mention.test.ts | 61 +++++++ .../src/components/chat/PromptInput.tsx | 43 ++++- .../src/components/chat/prompt-input-utils.ts | 6 + .../src/hooks/file-mention-utils.ts | 68 ++++++++ .../webview-ui/src/hooks/useFileMention.ts | 153 ++++++++++++++++-- .../webview-ui/src/styles/prompt-input.css | 19 ++- 13 files changed, 632 insertions(+), 25 deletions(-) create mode 100644 .changeset/mention-chip-style.md 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 759c3d96df..7e31a696f3 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 a3ed7bd221..77263cc7b3 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -2950,17 +2950,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 af846de7b7..39ce2d2186 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 27d984c56a..3193f0fea5 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" @@ -365,6 +373,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() @@ -413,6 +422,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() @@ -459,6 +469,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 @@ -566,6 +577,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) @@ -964,7 +987,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} + )} @@ -991,7 +1025,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 {