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