mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: add $skill-name autocomplete functionality
- Add Skill type to ContextMenuOptionType enum - Implement $ trigger detection anywhere after whitespace - Add insertSkill function for $skill-name insertion with metadata - Add skill-related message types (ExtensionMessage and WebviewMessage) - Update ChatTextArea to handle skill autocomplete (request/receive/display) - Update handleMentionSelect to use insertSkill for skill selection - Update ContextMenu component to render skill suggestions - Add requestSkills handler in ClineProvider to provide mode-filtered skills - Add send-time skill resolution to include SKILL.md content in prompts - Update highlight layer to visually highlight valid $skill-name tokens - Add comprehensive tests for skill autocomplete functionality - Skills are filtered by current mode and resolved at message send time - Skill content is attached as structured metadata for extension processing
This commit is contained in:
parent
d23824dba5
commit
899586ca2e
12 changed files with 646 additions and 6 deletions
187
src/core/mentions/__tests__/parseMentions-skills.test.ts
Normal file
187
src/core/mentions/__tests__/parseMentions-skills.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { parseMentions } from "../index"
|
||||
import type { SkillsManager } from "../../../services/skills/SkillsManager"
|
||||
|
||||
describe("parseMentions - Skill Resolution", () => {
|
||||
let mockSkillsManager: Partial<SkillsManager>
|
||||
let mockUrlContentFetcher: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockUrlContentFetcher = {
|
||||
launchBrowser: vi.fn(),
|
||||
urlToMarkdown: vi.fn(),
|
||||
closeBrowser: vi.fn(),
|
||||
}
|
||||
|
||||
mockSkillsManager = {
|
||||
getSkillContent: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
it("should replace $skill-name tokens with placeholders", async () => {
|
||||
vi.mocked(mockSkillsManager.getSkillContent!).mockResolvedValue({
|
||||
name: "pdf-processing",
|
||||
description: "Extract text from PDFs",
|
||||
path: "/path/to/skill/SKILL.md",
|
||||
source: "global",
|
||||
instructions: "# PDF Processing\n\nInstructions here",
|
||||
})
|
||||
|
||||
const result = await parseMentions(
|
||||
"Please help with $pdf-processing task",
|
||||
"/workspace",
|
||||
mockUrlContentFetcher,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
50,
|
||||
undefined,
|
||||
mockSkillsManager as SkillsManager,
|
||||
"code",
|
||||
)
|
||||
|
||||
expect(result.text).toContain("Skill '$pdf-processing' (see below for skill content)")
|
||||
expect(result.text).toContain('<skill name="pdf-processing">')
|
||||
expect(result.text).toContain("# PDF Processing")
|
||||
expect(result.text).toContain("Instructions here")
|
||||
expect(result.text).toContain("</skill>")
|
||||
})
|
||||
|
||||
it("should handle multiple skills in one message", async () => {
|
||||
vi.mocked(mockSkillsManager.getSkillContent!).mockImplementation(async (name: string) => {
|
||||
const skills: Record<string, any> = {
|
||||
"pdf-processing": {
|
||||
name: "pdf-processing",
|
||||
description: "Extract text from PDFs",
|
||||
path: "/path/to/pdf/SKILL.md",
|
||||
source: "global",
|
||||
instructions: "# PDF Processing",
|
||||
},
|
||||
"code-review": {
|
||||
name: "code-review",
|
||||
description: "Review code",
|
||||
path: "/path/to/review/SKILL.md",
|
||||
source: "global",
|
||||
instructions: "# Code Review",
|
||||
},
|
||||
}
|
||||
return skills[name] || null
|
||||
})
|
||||
|
||||
const result = await parseMentions(
|
||||
"Use $pdf-processing and $code-review",
|
||||
"/workspace",
|
||||
mockUrlContentFetcher,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
50,
|
||||
undefined,
|
||||
mockSkillsManager as SkillsManager,
|
||||
"code",
|
||||
)
|
||||
|
||||
expect(result.text).toContain("Skill '$pdf-processing'")
|
||||
expect(result.text).toContain("Skill '$code-review'")
|
||||
expect(result.text).toContain('<skill name="pdf-processing">')
|
||||
expect(result.text).toContain('<skill name="code-review">')
|
||||
})
|
||||
|
||||
it("should handle invalid skill names gracefully", async () => {
|
||||
vi.mocked(mockSkillsManager.getSkillContent!).mockResolvedValue(null)
|
||||
|
||||
const result = await parseMentions(
|
||||
"Use $nonexistent skill",
|
||||
"/workspace",
|
||||
mockUrlContentFetcher,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
50,
|
||||
undefined,
|
||||
mockSkillsManager as SkillsManager,
|
||||
"code",
|
||||
)
|
||||
|
||||
// Should not replace invalid skills
|
||||
expect(result.text).toBe("Use $nonexistent skill")
|
||||
expect(result.text).not.toContain("<skill")
|
||||
})
|
||||
|
||||
it("should work without skillsManager", async () => {
|
||||
const result = await parseMentions(
|
||||
"Use $pdf-processing",
|
||||
"/workspace",
|
||||
mockUrlContentFetcher,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
50,
|
||||
undefined,
|
||||
undefined,
|
||||
"code",
|
||||
)
|
||||
|
||||
// Should not process skills without manager
|
||||
expect(result.text).toBe("Use $pdf-processing")
|
||||
expect(result.text).not.toContain("<skill")
|
||||
})
|
||||
|
||||
it("should handle skills with @ mentions and / commands", async () => {
|
||||
vi.mocked(mockSkillsManager.getSkillContent!).mockResolvedValue({
|
||||
name: "pdf-processing",
|
||||
description: "Extract text from PDFs",
|
||||
path: "/path/to/skill/SKILL.md",
|
||||
source: "global",
|
||||
instructions: "# PDF Processing",
|
||||
})
|
||||
|
||||
const result = await parseMentions(
|
||||
"Use $pdf-processing on @/test.pdf",
|
||||
"/workspace",
|
||||
mockUrlContentFetcher,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
50,
|
||||
undefined,
|
||||
mockSkillsManager as SkillsManager,
|
||||
"code",
|
||||
)
|
||||
|
||||
// Both should be processed
|
||||
expect(result.text).toContain("Skill '$pdf-processing'")
|
||||
expect(result.text).toContain("'test.pdf' (see below for file content)")
|
||||
})
|
||||
|
||||
it("should handle skill names with hyphens and underscores", async () => {
|
||||
vi.mocked(mockSkillsManager.getSkillContent!).mockResolvedValue({
|
||||
name: "my-special_skill",
|
||||
description: "A test skill",
|
||||
path: "/path/to/skill/SKILL.md",
|
||||
source: "global",
|
||||
instructions: "# My Skill",
|
||||
})
|
||||
|
||||
const result = await parseMentions(
|
||||
"Use $my-special_skill",
|
||||
"/workspace",
|
||||
mockUrlContentFetcher,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
50,
|
||||
undefined,
|
||||
mockSkillsManager as SkillsManager,
|
||||
"code",
|
||||
)
|
||||
|
||||
expect(result.text).toContain("Skill '$my-special_skill'")
|
||||
})
|
||||
})
|
||||
|
|
@ -4,7 +4,7 @@ import * as path from "path"
|
|||
import * as vscode from "vscode"
|
||||
import { isBinaryFile } from "isbinaryfile"
|
||||
|
||||
import { mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"
|
||||
import { mentionRegexGlobal, commandRegexGlobal, skillRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"
|
||||
|
||||
import { getCommitInfo, getWorkingState } from "../../utils/git"
|
||||
|
||||
|
|
@ -18,6 +18,7 @@ import { FileContextTracker } from "../context-tracking/FileContextTracker"
|
|||
|
||||
import { RooIgnoreController } from "../ignore/RooIgnoreController"
|
||||
import { getCommand, type Command } from "../../services/command/commands"
|
||||
import { SkillsManager } from "../../services/skills/SkillsManager"
|
||||
|
||||
import { t } from "../../i18n"
|
||||
|
||||
|
|
@ -86,9 +87,12 @@ export async function parseMentions(
|
|||
includeDiagnosticMessages: boolean = true,
|
||||
maxDiagnosticMessages: number = 50,
|
||||
maxReadFileLine?: number,
|
||||
skillsManager?: SkillsManager,
|
||||
currentMode?: string,
|
||||
): Promise<ParseMentionsResult> {
|
||||
const mentions: Set<string> = new Set()
|
||||
const validCommands: Map<string, Command> = new Map()
|
||||
const validSkills: Map<string, { name: string; path: string }> = new Map()
|
||||
let commandMode: string | undefined // Track mode from the first slash command that has one
|
||||
|
||||
// First pass: check which command mentions exist and cache the results
|
||||
|
|
@ -118,6 +122,31 @@ export async function parseMentions(
|
|||
}
|
||||
}
|
||||
|
||||
// Check which skill mentions exist and cache the results
|
||||
const skillMatches = Array.from(text.matchAll(skillRegexGlobal))
|
||||
const uniqueSkillNames = new Set(skillMatches.map(([, skillName]) => skillName))
|
||||
|
||||
if (skillsManager && currentMode) {
|
||||
const skillExistenceChecks = await Promise.all(
|
||||
Array.from(uniqueSkillNames).map(async (skillName) => {
|
||||
try {
|
||||
const skillContent = await skillsManager.getSkillContent(skillName, currentMode)
|
||||
return { skillName, skillContent }
|
||||
} catch (error) {
|
||||
// If there's an error checking skill existence, treat it as non-existent
|
||||
return { skillName, skillContent: undefined }
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Store valid skills for later use
|
||||
for (const { skillName, skillContent } of skillExistenceChecks) {
|
||||
if (skillContent) {
|
||||
validSkills.set(skillName, { name: skillContent.name, path: skillContent.path })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only replace text for commands that actually exist
|
||||
let parsedText = text
|
||||
for (const [match, commandName] of commandMatches) {
|
||||
|
|
@ -126,6 +155,13 @@ export async function parseMentions(
|
|||
}
|
||||
}
|
||||
|
||||
// Replace skill mentions with placeholders
|
||||
for (const [match, skillName] of skillMatches) {
|
||||
if (validSkills.has(skillName)) {
|
||||
parsedText = parsedText.replace(match, `Skill '$${skillName}' (see below for skill content)`)
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: handle regular mentions
|
||||
parsedText = parsedText.replace(mentionRegexGlobal, (match, mention) => {
|
||||
mentions.add(mention)
|
||||
|
|
@ -259,6 +295,19 @@ export async function parseMentions(
|
|||
}
|
||||
}
|
||||
|
||||
// Process valid skill mentions using cached results
|
||||
if (skillsManager) {
|
||||
for (const [skillName, skillInfo] of validSkills) {
|
||||
try {
|
||||
// Read the full SKILL.md content
|
||||
const skillContent = await fs.readFile(skillInfo.path, "utf-8")
|
||||
parsedText += `\n\n<skill name="${skillName}">\n${skillContent}\n</skill>`
|
||||
} catch (error) {
|
||||
parsedText += `\n\n<skill name="${skillName}">\nError loading skill '${skillName}': ${error.message}\n</skill>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (urlMention) {
|
||||
try {
|
||||
await urlContentFetcher.closeBrowser()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
|
|||
import { parseMentions, ParseMentionsResult } from "./index"
|
||||
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
|
||||
import { FileContextTracker } from "../context-tracking/FileContextTracker"
|
||||
import { SkillsManager } from "../../services/skills/SkillsManager"
|
||||
|
||||
export interface ProcessUserContentMentionsResult {
|
||||
content: Anthropic.Messages.ContentBlockParam[]
|
||||
|
|
@ -21,6 +22,8 @@ export async function processUserContentMentions({
|
|||
includeDiagnosticMessages = true,
|
||||
maxDiagnosticMessages = 50,
|
||||
maxReadFileLine,
|
||||
skillsManager,
|
||||
currentMode,
|
||||
}: {
|
||||
userContent: Anthropic.Messages.ContentBlockParam[]
|
||||
cwd: string
|
||||
|
|
@ -31,6 +34,8 @@ export async function processUserContentMentions({
|
|||
includeDiagnosticMessages?: boolean
|
||||
maxDiagnosticMessages?: number
|
||||
maxReadFileLine?: number
|
||||
skillsManager?: SkillsManager
|
||||
currentMode?: string
|
||||
}): Promise<ProcessUserContentMentionsResult> {
|
||||
// Track the first mode found from slash commands
|
||||
let commandMode: string | undefined
|
||||
|
|
@ -65,6 +70,8 @@ export async function processUserContentMentions({
|
|||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
maxReadFileLine,
|
||||
skillsManager,
|
||||
currentMode,
|
||||
)
|
||||
// Capture the first mode found
|
||||
if (!commandMode && result.mode) {
|
||||
|
|
@ -90,6 +97,8 @@ export async function processUserContentMentions({
|
|||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
maxReadFileLine,
|
||||
skillsManager,
|
||||
currentMode,
|
||||
)
|
||||
// Capture the first mode found
|
||||
if (!commandMode && result.mode) {
|
||||
|
|
@ -116,6 +125,8 @@ export async function processUserContentMentions({
|
|||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
maxReadFileLine,
|
||||
skillsManager,
|
||||
currentMode,
|
||||
)
|
||||
// Capture the first mode found
|
||||
if (!commandMode && result.mode) {
|
||||
|
|
|
|||
|
|
@ -2372,6 +2372,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
maxReadFileLine = -1,
|
||||
} = (await this.providerRef.deref()?.getState()) ?? {}
|
||||
|
||||
const provider = this.providerRef.deref()
|
||||
const skillsManager = provider?.getSkillsManager()
|
||||
const currentMode = await this.getTaskMode()
|
||||
|
||||
const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({
|
||||
userContent: currentUserContent,
|
||||
cwd: this.cwd,
|
||||
|
|
@ -2382,11 +2386,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
includeDiagnosticMessages,
|
||||
maxDiagnosticMessages,
|
||||
maxReadFileLine,
|
||||
skillsManager,
|
||||
currentMode,
|
||||
})
|
||||
|
||||
// Switch mode if specified in a slash command's frontmatter
|
||||
if (slashCommandMode) {
|
||||
const provider = this.providerRef.deref()
|
||||
if (provider) {
|
||||
const state = await provider.getState()
|
||||
const targetMode = getModeBySlug(slashCommandMode, state?.customModes)
|
||||
|
|
|
|||
|
|
@ -2870,6 +2870,43 @@ export const webviewMessageHandler = async (
|
|||
}
|
||||
break
|
||||
}
|
||||
case "requestSkills": {
|
||||
try {
|
||||
const skillsManager = provider.getSkillsManager()
|
||||
const currentMode = getGlobalState("mode") || "code"
|
||||
|
||||
if (!skillsManager) {
|
||||
// No skills manager available, send empty array
|
||||
await provider.postMessageToWebview({
|
||||
type: "skills",
|
||||
skills: [],
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Get skills filtered by current mode
|
||||
const skills = skillsManager.getSkillsForMode(currentMode)
|
||||
|
||||
// Convert to the format expected by the frontend
|
||||
const skillList = skills.map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
}))
|
||||
|
||||
await provider.postMessageToWebview({
|
||||
type: "skills",
|
||||
skills: skillList,
|
||||
})
|
||||
} catch (error) {
|
||||
provider.log(`Error fetching skills: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
|
||||
// Send empty array on error
|
||||
await provider.postMessageToWebview({
|
||||
type: "skills",
|
||||
skills: [],
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case "openCommandFile": {
|
||||
try {
|
||||
if (message.text) {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ export interface Command {
|
|||
argumentHint?: string
|
||||
}
|
||||
|
||||
// Skill interface for frontend/backend communication
|
||||
export interface Skill {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// Type for marketplace installed metadata
|
||||
export interface MarketplaceInstalledMetadata {
|
||||
project: Record<string, { type: string }>
|
||||
|
|
@ -132,6 +138,7 @@ export interface ExtensionMessage {
|
|||
| "browserSessionNavigate"
|
||||
| "claudeCodeRateLimits"
|
||||
| "customToolsResult"
|
||||
| "skills"
|
||||
text?: string
|
||||
payload?: any // Add a generic payload for now, can refine later
|
||||
// Checkpoint warning message
|
||||
|
|
@ -211,6 +218,7 @@ export interface ExtensionMessage {
|
|||
hasCheckpoint?: boolean
|
||||
context?: string
|
||||
commands?: Command[]
|
||||
skills?: Skill[]
|
||||
queuedMessages?: QueuedMessage[]
|
||||
list?: string[] // For dismissedUpsells
|
||||
organizationId?: string | null // For organizationSwitchResult
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ export interface WebviewMessage {
|
|||
| "saveCodeIndexSettingsAtomic"
|
||||
| "requestCodeIndexSecretStatus"
|
||||
| "requestCommands"
|
||||
| "requestSkills"
|
||||
| "openCommandFile"
|
||||
| "deleteCommand"
|
||||
| "createCommand"
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")
|
|||
// Regex to match command mentions like /command-name anywhere in text
|
||||
export const commandRegexGlobal = /(?:^|\s)\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
|
||||
|
||||
// Regex to match skill mentions like $skill-name anywhere after whitespace or at start
|
||||
// Matches: $skill-name, $skillname, etc. (alphanumeric and hyphens)
|
||||
export const skillRegexGlobal = /(?:^|(?<=\s))\$([a-zA-Z0-9_-]+)(?=\s|$)/g
|
||||
|
||||
export interface MentionSuggestion {
|
||||
type: "file" | "folder" | "git" | "problems"
|
||||
label: string
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import { useEvent } from "react-use"
|
|||
import DynamicTextArea from "react-textarea-autosize"
|
||||
import { VolumeX, Image, WandSparkles, SendHorizontal, MessageSquareX } from "lucide-react"
|
||||
|
||||
import { mentionRegex, mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "@roo/context-mentions"
|
||||
import {
|
||||
mentionRegex,
|
||||
mentionRegexGlobal,
|
||||
commandRegexGlobal,
|
||||
skillRegexGlobal,
|
||||
unescapeSpaces,
|
||||
} from "@roo/context-mentions"
|
||||
import { WebviewMessage } from "@roo/WebviewMessage"
|
||||
import { Mode, getAllModes } from "@roo/modes"
|
||||
import { ExtensionMessage } from "@roo/ExtensionMessage"
|
||||
|
|
@ -15,9 +21,11 @@ import {
|
|||
ContextMenuOptionType,
|
||||
getContextMenuOptions,
|
||||
insertMention,
|
||||
insertSkill,
|
||||
removeMention,
|
||||
shouldShowContextMenu,
|
||||
SearchResult,
|
||||
Skill,
|
||||
} from "@src/utils/context-mentions"
|
||||
import { cn } from "@src/lib/utils"
|
||||
import { convertToMentionPath } from "@src/utils/path-mentions"
|
||||
|
|
@ -107,6 +115,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
}, [listApiConfigMeta, currentApiConfigName])
|
||||
|
||||
const [gitCommits, setGitCommits] = useState<any[]>([])
|
||||
const [skills, setSkills] = useState<Skill[]>([])
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
|
|
@ -192,6 +201,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
}))
|
||||
|
||||
setGitCommits(commits)
|
||||
} else if (message.type === "skills") {
|
||||
setSkills(message.skills || [])
|
||||
} else if (message.type === "fileSearchResults") {
|
||||
setSearchLoading(false)
|
||||
if (message.requestId === searchRequestId) {
|
||||
|
|
@ -345,6 +356,30 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
return
|
||||
}
|
||||
|
||||
if (type === ContextMenuOptionType.Skill && value) {
|
||||
// Handle skill selection using insertSkill
|
||||
setShowContextMenu(false)
|
||||
setSelectedType(null)
|
||||
|
||||
if (textAreaRef.current) {
|
||||
const { newValue, skillIndex } = insertSkill(textAreaRef.current.value, cursorPosition, value)
|
||||
|
||||
setInputValue(newValue)
|
||||
const newCursorPosition = newValue.indexOf(" ", skillIndex + value.length + 1) + 1
|
||||
setCursorPosition(newCursorPosition)
|
||||
setIntendedCursorPosition(newCursorPosition)
|
||||
|
||||
// Scroll to cursor.
|
||||
setTimeout(() => {
|
||||
if (textAreaRef.current) {
|
||||
textAreaRef.current.blur()
|
||||
textAreaRef.current.focus()
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
type === ContextMenuOptionType.File ||
|
||||
type === ContextMenuOptionType.Folder ||
|
||||
|
|
@ -426,6 +461,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
fileSearchResults,
|
||||
allModes,
|
||||
commands,
|
||||
skills,
|
||||
)
|
||||
const optionsLength = options.length
|
||||
|
||||
|
|
@ -464,6 +500,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
fileSearchResults,
|
||||
allModes,
|
||||
commands,
|
||||
skills,
|
||||
)[selectedMenuIndex]
|
||||
if (
|
||||
selectedOption &&
|
||||
|
|
@ -565,6 +602,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
handleHistoryNavigation,
|
||||
resetHistoryNavigation,
|
||||
commands,
|
||||
skills,
|
||||
enterBehavior,
|
||||
],
|
||||
)
|
||||
|
|
@ -603,6 +641,31 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
// Request commands fresh each time slash menu is shown
|
||||
vscode.postMessage({ type: "requestCommands" })
|
||||
} else {
|
||||
// Check for $ skill autocomplete
|
||||
const beforeCursor = newValue.slice(0, newCursorPosition)
|
||||
const lastDollarIndex = beforeCursor.lastIndexOf("$")
|
||||
|
||||
if (lastDollarIndex !== -1) {
|
||||
// Check if $ is at start or preceded by whitespace
|
||||
const charBeforeDollar = lastDollarIndex > 0 ? beforeCursor[lastDollarIndex - 1] : null
|
||||
const isDollarAfterWhitespace = !charBeforeDollar || /\s/.test(charBeforeDollar)
|
||||
|
||||
if (isDollarAfterWhitespace) {
|
||||
const textAfterDollar = beforeCursor.slice(lastDollarIndex)
|
||||
const hasSpace = /\s/.test(textAfterDollar.slice(1))
|
||||
|
||||
if (!hasSpace) {
|
||||
// We're in skill autocomplete mode
|
||||
const query = textAfterDollar
|
||||
setSearchQuery(query)
|
||||
setSelectedMenuIndex(1) // Skip section header
|
||||
// Request skills from extension
|
||||
vscode.postMessage({ type: "requestSkills" })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Existing @ mention handling.
|
||||
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
|
||||
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
|
||||
|
|
@ -754,6 +817,11 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
return commands?.some((cmd) => cmd.name === commandName) || false
|
||||
}
|
||||
|
||||
// Helper function to check if a skill is valid
|
||||
const isValidSkill = (skillName: string): boolean => {
|
||||
return skills?.some((skill) => skill.name === skillName) || false
|
||||
}
|
||||
|
||||
// Process the text to highlight mentions and valid commands
|
||||
let processedText = text
|
||||
.replace(/\n$/, "\n\n")
|
||||
|
|
@ -779,11 +847,30 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
return match // Return unhighlighted if command is not valid
|
||||
})
|
||||
|
||||
// Custom replacement for skills - only highlight valid ones
|
||||
processedText = processedText.replace(skillRegexGlobal, (match, skillName) => {
|
||||
// Only highlight if the skill exists in the valid skills list
|
||||
if (isValidSkill(skillName)) {
|
||||
// Check if the match starts with a space
|
||||
const startsWithSpace = match.startsWith(" ")
|
||||
const skillPart = `$${skillName}`
|
||||
|
||||
if (startsWithSpace) {
|
||||
// Keep the space but only highlight the skill part
|
||||
return ` <mark class="mention-context-textarea-highlight">${skillPart}</mark>`
|
||||
} else {
|
||||
// Highlight the entire skill (starts at beginning of line)
|
||||
return `<mark class="mention-context-textarea-highlight">${skillPart}</mark>`
|
||||
}
|
||||
}
|
||||
return match // Return unhighlighted if skill is not valid
|
||||
})
|
||||
|
||||
highlightLayerRef.current.innerHTML = processedText
|
||||
|
||||
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
|
||||
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
|
||||
}, [commands])
|
||||
}, [commands, skills])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateHighlights()
|
||||
|
|
@ -997,6 +1084,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
loading={searchLoading}
|
||||
dynamicSearchResults={fileSearchResults}
|
||||
commands={commands}
|
||||
skills={skills}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
ContextMenuQueryItem,
|
||||
getContextMenuOptions,
|
||||
SearchResult,
|
||||
Skill,
|
||||
} from "@src/utils/context-mentions"
|
||||
import { removeLeadingNonAlphanumeric } from "@src/utils/removeLeadingNonAlphanumeric"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
|
|
@ -30,6 +31,7 @@ interface ContextMenuProps {
|
|||
loading?: boolean
|
||||
dynamicSearchResults?: SearchResult[]
|
||||
commands?: Command[]
|
||||
skills?: Skill[]
|
||||
}
|
||||
|
||||
const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
|
|
@ -43,13 +45,22 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||
modes,
|
||||
dynamicSearchResults = [],
|
||||
commands = [],
|
||||
skills = [],
|
||||
}) => {
|
||||
const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("")
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
return getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands)
|
||||
}, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands])
|
||||
return getContextMenuOptions(
|
||||
searchQuery,
|
||||
selectedType,
|
||||
queryItems,
|
||||
dynamicSearchResults,
|
||||
modes,
|
||||
commands,
|
||||
skills,
|
||||
)
|
||||
}, [searchQuery, selectedType, queryItems, dynamicSearchResults, modes, commands, skills])
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
|
|
@ -86,6 +97,27 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||
{option.label}
|
||||
</span>
|
||||
)
|
||||
case ContextMenuOptionType.Skill:
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
|
||||
<div style={{ lineHeight: "1.2" }}>
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
{option.description && (
|
||||
<span
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
fontSize: "0.9em",
|
||||
lineHeight: "1.2",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case ContextMenuOptionType.Mode:
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
|
||||
|
|
@ -212,6 +244,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||
|
||||
const getIconForOption = (option: ContextMenuQueryItem): string => {
|
||||
switch (option.type) {
|
||||
case ContextMenuOptionType.Skill:
|
||||
return "lightbulb"
|
||||
case ContextMenuOptionType.Mode:
|
||||
return "symbol-misc"
|
||||
case ContextMenuOptionType.Command:
|
||||
|
|
|
|||
120
webview-ui/src/utils/__tests__/context-mentions-skills.spec.ts
Normal file
120
webview-ui/src/utils/__tests__/context-mentions-skills.spec.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
shouldShowContextMenu,
|
||||
getContextMenuOptions,
|
||||
insertSkill,
|
||||
ContextMenuOptionType,
|
||||
Skill,
|
||||
} from "../context-mentions"
|
||||
|
||||
describe("Skill Autocomplete", () => {
|
||||
describe("shouldShowContextMenu", () => {
|
||||
it("should show menu when typing $ at start", () => {
|
||||
expect(shouldShowContextMenu("$", 1)).toBe(true)
|
||||
})
|
||||
|
||||
it("should show menu when typing $ after whitespace", () => {
|
||||
expect(shouldShowContextMenu("hello $", 7)).toBe(true)
|
||||
expect(shouldShowContextMenu("hello world $", 13)).toBe(true)
|
||||
})
|
||||
|
||||
it("should show menu when typing $ after newline", () => {
|
||||
expect(shouldShowContextMenu("hello\n$", 7)).toBe(true)
|
||||
})
|
||||
|
||||
it("should show menu while typing skill name", () => {
|
||||
expect(shouldShowContextMenu("$pdf", 4)).toBe(true)
|
||||
expect(shouldShowContextMenu("$pdf-proc", 9)).toBe(true)
|
||||
})
|
||||
|
||||
it("should not show menu when $ is in middle of word", () => {
|
||||
expect(shouldShowContextMenu("hello$world", 6)).toBe(false)
|
||||
})
|
||||
|
||||
it("should not show menu after space following $skill-name", () => {
|
||||
expect(shouldShowContextMenu("$skill ", 7)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getContextMenuOptions for skills", () => {
|
||||
const mockSkills: Skill[] = [
|
||||
{ name: "pdf-processing", description: "Extract text and tables from PDF files" },
|
||||
{ name: "code-review", description: "Review code for best practices" },
|
||||
{ name: "refactoring", description: "Refactor code for better maintainability" },
|
||||
]
|
||||
|
||||
it("should return all skills when query is just $", () => {
|
||||
const options = getContextMenuOptions("$", null, [], [], undefined, undefined, mockSkills)
|
||||
|
||||
expect(options).toHaveLength(4) // Section header + 3 skills
|
||||
expect(options[0]).toEqual({
|
||||
type: ContextMenuOptionType.SectionHeader,
|
||||
label: "Skills",
|
||||
})
|
||||
expect(options[1].type).toBe(ContextMenuOptionType.Skill)
|
||||
expect(options[1].value).toBe("pdf-processing")
|
||||
expect(options[1].label).toBe("$pdf-processing")
|
||||
})
|
||||
|
||||
it("should filter skills by name", () => {
|
||||
const options = getContextMenuOptions("$pdf", null, [], [], undefined, undefined, mockSkills)
|
||||
|
||||
expect(options).toHaveLength(2) // Section header + 1 skill
|
||||
expect(options[1].value).toBe("pdf-processing")
|
||||
})
|
||||
|
||||
it("should filter skills using fuzzy matching", () => {
|
||||
const options = getContextMenuOptions("$code", null, [], [], undefined, undefined, mockSkills)
|
||||
|
||||
expect(options.some((opt) => opt.value === "code-review")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return NoResults when no skills match", () => {
|
||||
const options = getContextMenuOptions("$nonexistent", null, [], [], undefined, undefined, mockSkills)
|
||||
|
||||
expect(options).toEqual([{ type: ContextMenuOptionType.NoResults }])
|
||||
})
|
||||
|
||||
it("should return NoResults when no skills are available", () => {
|
||||
const options = getContextMenuOptions("$", null, [], [], undefined, undefined, [])
|
||||
|
||||
expect(options).toEqual([{ type: ContextMenuOptionType.NoResults }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("insertSkill", () => {
|
||||
it("should insert skill at cursor position", () => {
|
||||
const result = insertSkill("hello world", 5, "pdf-processing")
|
||||
|
||||
expect(result.newValue).toBe("hello$pdf-processing world")
|
||||
expect(result.skillIndex).toBe(5)
|
||||
})
|
||||
|
||||
it("should replace $ when present before cursor", () => {
|
||||
const result = insertSkill("hello $", 7, "pdf-processing")
|
||||
|
||||
expect(result.newValue).toBe("hello $pdf-processing ")
|
||||
expect(result.skillIndex).toBe(6)
|
||||
})
|
||||
|
||||
it("should replace partial skill name when present", () => {
|
||||
const result = insertSkill("hello $pdf", 10, "pdf-processing")
|
||||
|
||||
expect(result.newValue).toBe("hello $pdf-processing ")
|
||||
expect(result.skillIndex).toBe(6)
|
||||
})
|
||||
|
||||
it("should add trailing space after skill", () => {
|
||||
const result = insertSkill("$", 1, "code-review")
|
||||
|
||||
expect(result.newValue).toBe("$code-review ")
|
||||
})
|
||||
|
||||
it("should work in middle of text", () => {
|
||||
const result = insertSkill("start $ end", 7, "refactoring")
|
||||
|
||||
expect(result.newValue).toBe("start $refactoring end")
|
||||
expect(result.skillIndex).toBe(6)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -76,6 +76,34 @@ export function insertMention(
|
|||
return { newValue, mentionIndex }
|
||||
}
|
||||
|
||||
export function insertSkill(text: string, position: number, value: string): { newValue: string; skillIndex: number } {
|
||||
const beforeCursor = text.slice(0, position)
|
||||
const afterCursor = text.slice(position)
|
||||
|
||||
// Find the position of the last '$' symbol before the cursor
|
||||
const lastDollarIndex = beforeCursor.lastIndexOf("$")
|
||||
|
||||
let newValue: string
|
||||
let skillIndex: number
|
||||
|
||||
if (lastDollarIndex !== -1) {
|
||||
// If there's a '$' symbol, replace everything after it with the new skill
|
||||
const beforeSkill = text.slice(0, lastDollarIndex)
|
||||
// Only replace if afterCursor is all alphanumerical
|
||||
const afterCursorContent = /^[a-zA-Z0-9\s]*$/.test(afterCursor)
|
||||
? afterCursor.replace(/^[^\s]*/, "")
|
||||
: afterCursor
|
||||
newValue = beforeSkill + "$" + value + " " + afterCursorContent
|
||||
skillIndex = lastDollarIndex
|
||||
} else {
|
||||
// If there's no '$' symbol, insert the skill at the cursor position
|
||||
newValue = beforeCursor + "$" + value + " " + afterCursor
|
||||
skillIndex = position
|
||||
}
|
||||
|
||||
return { newValue, skillIndex }
|
||||
}
|
||||
|
||||
export function removeMention(text: string, position: number): { newText: string; newPosition: number } {
|
||||
const beforeCursor = text.slice(0, position)
|
||||
const afterCursor = text.slice(position)
|
||||
|
|
@ -109,6 +137,12 @@ export enum ContextMenuOptionType {
|
|||
Mode = "mode", // Add mode type
|
||||
Command = "command", // Add command type
|
||||
SectionHeader = "sectionHeader", // Add section header type
|
||||
Skill = "skill", // Add skill type
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ContextMenuQueryItem {
|
||||
|
|
@ -129,7 +163,52 @@ export function getContextMenuOptions(
|
|||
dynamicSearchResults: SearchResult[] = [],
|
||||
modes?: ModeConfig[],
|
||||
commands?: Command[],
|
||||
skills?: Skill[],
|
||||
): ContextMenuQueryItem[] {
|
||||
// Handle dollar sign for skills
|
||||
if (query.startsWith("$")) {
|
||||
const skillQuery = query.slice(1)
|
||||
const results: ContextMenuQueryItem[] = []
|
||||
|
||||
if (skills?.length) {
|
||||
// Create searchable strings array for fzf
|
||||
const searchableSkills = skills.map((skill) => ({
|
||||
original: skill,
|
||||
searchStr: skill.name,
|
||||
}))
|
||||
|
||||
// Initialize fzf instance for fuzzy search
|
||||
const fzf = new Fzf(searchableSkills, {
|
||||
selector: (item) => item.searchStr,
|
||||
})
|
||||
|
||||
// Get fuzzy matching skills
|
||||
const matchingSkills = skillQuery
|
||||
? fzf.find(skillQuery).map((result) => ({
|
||||
type: ContextMenuOptionType.Skill,
|
||||
value: result.item.original.name,
|
||||
label: `$${result.item.original.name}`,
|
||||
description: result.item.original.description,
|
||||
}))
|
||||
: skills.map((skill) => ({
|
||||
type: ContextMenuOptionType.Skill,
|
||||
value: skill.name,
|
||||
label: `$${skill.name}`,
|
||||
description: skill.description,
|
||||
}))
|
||||
|
||||
if (matchingSkills.length > 0) {
|
||||
results.push({
|
||||
type: ContextMenuOptionType.SectionHeader,
|
||||
label: "Skills",
|
||||
})
|
||||
results.push(...matchingSkills)
|
||||
}
|
||||
}
|
||||
|
||||
return results.length > 0 ? results : [{ type: ContextMenuOptionType.NoResults }]
|
||||
}
|
||||
|
||||
// Handle slash commands for modes and commands
|
||||
// Only process as slash command if the query itself starts with "/" (meaning we're typing a slash command)
|
||||
if (query.startsWith("/")) {
|
||||
|
|
@ -373,6 +452,23 @@ export function shouldShowContextMenu(text: string, position: number): boolean {
|
|||
return true
|
||||
}
|
||||
|
||||
// Check for $ skill context anywhere after whitespace/newline or at start
|
||||
const dollarIndex = beforeCursor.lastIndexOf("$")
|
||||
if (dollarIndex !== -1) {
|
||||
// Check if $ is at start or preceded by whitespace/newline
|
||||
const charBeforeDollar = dollarIndex > 0 ? beforeCursor[dollarIndex - 1] : null
|
||||
const isDollarAfterWhitespace = !charBeforeDollar || /\s/.test(charBeforeDollar)
|
||||
|
||||
if (isDollarAfterWhitespace) {
|
||||
const textAfterDollar = beforeCursor.slice(dollarIndex + 1)
|
||||
// No space after $ means we're still typing the skill name
|
||||
const hasSpace = /\s/.test(textAfterDollar)
|
||||
if (!hasSpace) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for @ mention context
|
||||
const atIndex = beforeCursor.lastIndexOf("@")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue