diff --git a/src/core/mentions/__tests__/parseMentions-skills.test.ts b/src/core/mentions/__tests__/parseMentions-skills.test.ts new file mode 100644 index 00000000000..7870c679676 --- /dev/null +++ b/src/core/mentions/__tests__/parseMentions-skills.test.ts @@ -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 + 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('') + expect(result.text).toContain("# PDF Processing") + expect(result.text).toContain("Instructions here") + expect(result.text).toContain("") + }) + + it("should handle multiple skills in one message", async () => { + vi.mocked(mockSkillsManager.getSkillContent!).mockImplementation(async (name: string) => { + const skills: Record = { + "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('') + expect(result.text).toContain('') + }) + + 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(" { + 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(" { + 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'") + }) +}) diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index 9ee7cece5d0..02f85c0945b 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -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 { const mentions: Set = new Set() const validCommands: Map = new Map() + const validSkills: Map = 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\n${skillContent}\n` + } catch (error) { + parsedText += `\n\n\nError loading skill '${skillName}': ${error.message}\n` + } + } + } + if (urlMention) { try { await urlContentFetcher.closeBrowser() diff --git a/src/core/mentions/processUserContentMentions.ts b/src/core/mentions/processUserContentMentions.ts index 5ea78f4dc30..3e842aef7b8 100644 --- a/src/core/mentions/processUserContentMentions.ts +++ b/src/core/mentions/processUserContentMentions.ts @@ -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 { // 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) { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index c1550bdd5a0..ae149253394 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2372,6 +2372,10 @@ export class Task extends EventEmitter 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 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) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 35bf08e0486..c7b4f84d6c9 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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) { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 2eec4cb6c88..33f29113264 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -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 @@ -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 diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 4c3e321dea8..c0bc2e68a98 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -156,6 +156,7 @@ export interface WebviewMessage { | "saveCodeIndexSettingsAtomic" | "requestCodeIndexSecretStatus" | "requestCommands" + | "requestSkills" | "openCommandFile" | "deleteCommand" | "createCommand" diff --git a/src/shared/context-mentions.ts b/src/shared/context-mentions.ts index 5a0c4c0bcfa..060960e732e 100644 --- a/src/shared/context-mentions.ts +++ b/src/shared/context-mentions.ts @@ -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 diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 7fbc6587181..b3ae1ddc923 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -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( }, [listApiConfigMeta, currentApiConfigName]) const [gitCommits, setGitCommits] = useState([]) + const [skills, setSkills] = useState([]) const [showDropdown, setShowDropdown] = useState(false) const [fileSearchResults, setFileSearchResults] = useState([]) const [searchLoading, setSearchLoading] = useState(false) @@ -192,6 +201,8 @@ export const ChatTextArea = forwardRef( })) 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( 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( fileSearchResults, allModes, commands, + skills, ) const optionsLength = options.length @@ -464,6 +500,7 @@ export const ChatTextArea = forwardRef( fileSearchResults, allModes, commands, + skills, )[selectedMenuIndex] if ( selectedOption && @@ -565,6 +602,7 @@ export const ChatTextArea = forwardRef( handleHistoryNavigation, resetHistoryNavigation, commands, + skills, enterBehavior, ], ) @@ -603,6 +641,31 @@ export const ChatTextArea = forwardRef( // 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( 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( 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 ` ${skillPart}` + } else { + // Highlight the entire skill (starts at beginning of line) + return `${skillPart}` + } + } + 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( loading={searchLoading} dynamicSearchResults={fileSearchResults} commands={commands} + skills={skills} /> )} diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index cf4b10a981d..4b1c2e9eff5 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -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 = ({ @@ -43,13 +45,22 @@ const ContextMenu: React.FC = ({ modes, dynamicSearchResults = [], commands = [], + skills = [], }) => { const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("") const menuRef = useRef(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 = ({ {option.label} ) + case ContextMenuOptionType.Skill: + return ( +
+
+ {option.label} +
+ {option.description && ( + + {option.description} + + )} +
+ ) case ContextMenuOptionType.Mode: return (
@@ -212,6 +244,8 @@ const ContextMenu: React.FC = ({ const getIconForOption = (option: ContextMenuQueryItem): string => { switch (option.type) { + case ContextMenuOptionType.Skill: + return "lightbulb" case ContextMenuOptionType.Mode: return "symbol-misc" case ContextMenuOptionType.Command: diff --git a/webview-ui/src/utils/__tests__/context-mentions-skills.spec.ts b/webview-ui/src/utils/__tests__/context-mentions-skills.spec.ts new file mode 100644 index 00000000000..3333a02d28b --- /dev/null +++ b/webview-ui/src/utils/__tests__/context-mentions-skills.spec.ts @@ -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) + }) + }) +}) diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index d7aeb0fdd51..46f282bb7df 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -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("@")