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:
Roo Code 2026-01-04 20:46:25 +00:00
parent d23824dba5
commit 899586ca2e
12 changed files with 646 additions and 6 deletions

View 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'")
})
})

View file

@ -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()

View file

@ -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) {

View file

@ -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)

View file

@ -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) {

View file

@ -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

View file

@ -156,6 +156,7 @@ export interface WebviewMessage {
| "saveCodeIndexSettingsAtomic"
| "requestCodeIndexSecretStatus"
| "requestCommands"
| "requestSkills"
| "openCommandFile"
| "deleteCommand"
| "createCommand"

View file

@ -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

View file

@ -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>
)}

View file

@ -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:

View 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)
})
})
})

View file

@ -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("@")