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 {