Support for custom slash commands (#6263)

This commit is contained in:
Matt Rubens 2025-07-26 23:16:46 -04:00 committed by GitHub
parent 43f649b03f
commit 5d4e3819e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1171 additions and 49 deletions

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest"
import { getCommands, getCommand, getCommandNames } from "../services/command/commands"
import * as path from "path"
describe("Command Integration Tests", () => {
const testWorkspaceDir = path.join(__dirname, "../../")
it("should discover command files in .roo/commands/", async () => {
const commands = await getCommands(testWorkspaceDir)
// Should be able to discover commands (may be empty in test environment)
expect(Array.isArray(commands)).toBe(true)
// If commands exist, verify they have valid properties
commands.forEach((command) => {
expect(command.name).toBeDefined()
expect(typeof command.name).toBe("string")
expect(command.source).toMatch(/^(project|global)$/)
expect(command.content).toBeDefined()
expect(typeof command.content).toBe("string")
})
})
it("should return command names correctly", async () => {
const commandNames = await getCommandNames(testWorkspaceDir)
// Should return an array (may be empty in test environment)
expect(Array.isArray(commandNames)).toBe(true)
// If command names exist, they should be strings
commandNames.forEach((name) => {
expect(typeof name).toBe("string")
expect(name.length).toBeGreaterThan(0)
})
})
it("should load command content if commands exist", async () => {
const commands = await getCommands(testWorkspaceDir)
if (commands.length > 0) {
const firstCommand = commands[0]
const loadedCommand = await getCommand(testWorkspaceDir, firstCommand.name)
expect(loadedCommand).toBeDefined()
expect(loadedCommand?.name).toBe(firstCommand.name)
expect(loadedCommand?.source).toMatch(/^(project|global)$/)
expect(loadedCommand?.content).toBeDefined()
expect(typeof loadedCommand?.content).toBe("string")
}
})
it("should handle non-existent commands gracefully", async () => {
const nonExistentCommand = await getCommand(testWorkspaceDir, "non-existent-command")
expect(nonExistentCommand).toBeUndefined()
})
})

View file

@ -0,0 +1,307 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import { parseMentions } from "../core/mentions"
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
import { getCommand } from "../services/command/commands"
// Mock the dependencies
vi.mock("../services/command/commands")
vi.mock("../services/browser/UrlContentFetcher")
const MockedUrlContentFetcher = vi.mocked(UrlContentFetcher)
const mockGetCommand = vi.mocked(getCommand)
describe("Command Mentions", () => {
let mockUrlContentFetcher: any
beforeEach(() => {
vi.clearAllMocks()
// Create a mock UrlContentFetcher instance
mockUrlContentFetcher = {
launchBrowser: vi.fn(),
urlToMarkdown: vi.fn(),
closeBrowser: vi.fn(),
}
MockedUrlContentFetcher.mockImplementation(() => mockUrlContentFetcher)
})
// Helper function to call parseMentions with required parameters
const callParseMentions = async (text: string) => {
return await parseMentions(
text,
"/test/cwd", // cwd
mockUrlContentFetcher, // urlContentFetcher
undefined, // fileContextTracker
undefined, // rooIgnoreController
true, // showRooIgnoredFiles
true, // includeDiagnosticMessages
50, // maxDiagnosticMessages
undefined, // maxReadFileLine
)
}
describe("parseMentions with command support", () => {
it("should parse command mentions and include content", async () => {
const commandContent = "# Setup Environment\n\nRun the following commands:\n```bash\nnpm install\n```"
mockGetCommand.mockResolvedValue({
name: "setup",
content: commandContent,
source: "project",
filePath: "/project/.roo/commands/setup.md",
})
const input = "/setup Please help me set up the project"
const result = await callParseMentions(input)
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup")
expect(result).toContain('<command name="setup">')
expect(result).toContain(commandContent)
expect(result).toContain("</command>")
expect(result).toContain("Please help me set up the project")
})
it("should handle multiple commands in message", async () => {
mockGetCommand
.mockResolvedValueOnce({
name: "setup",
content: "# Setup instructions",
source: "project",
filePath: "/project/.roo/commands/setup.md",
})
.mockResolvedValueOnce({
name: "deploy",
content: "# Deploy instructions",
source: "project",
filePath: "/project/.roo/commands/deploy.md",
})
// Both commands should be recognized
const input = "/setup the project\nThen /deploy later"
const result = await callParseMentions(input)
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup")
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "deploy")
expect(mockGetCommand).toHaveBeenCalledTimes(2) // Both commands called
expect(result).toContain('<command name="setup">')
expect(result).toContain("# Setup instructions")
expect(result).toContain('<command name="deploy">')
expect(result).toContain("# Deploy instructions")
})
it("should handle non-existent command gracefully", async () => {
mockGetCommand.mockResolvedValue(undefined)
const input = "/nonexistent command"
const result = await callParseMentions(input)
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "nonexistent")
expect(result).toContain('<command name="nonexistent">')
expect(result).toContain("Command 'nonexistent' not found")
expect(result).toContain("</command>")
})
it("should handle command loading errors", async () => {
mockGetCommand.mockRejectedValue(new Error("Failed to load command"))
const input = "/error-command test"
const result = await callParseMentions(input)
expect(result).toContain('<command name="error-command">')
expect(result).toContain("Error loading command")
expect(result).toContain("</command>")
})
it("should handle command names with hyphens and underscores at start", async () => {
mockGetCommand.mockResolvedValue({
name: "setup-dev",
content: "# Dev setup",
source: "project",
filePath: "/project/.roo/commands/setup-dev.md",
})
const input = "/setup-dev for the project"
const result = await callParseMentions(input)
expect(mockGetCommand).toHaveBeenCalledWith("/test/cwd", "setup-dev")
expect(result).toContain('<command name="setup-dev">')
expect(result).toContain("# Dev setup")
})
it("should preserve command content formatting", async () => {
const commandContent = `# Complex Command
## Step 1
Run this command:
\`\`\`bash
npm install
\`\`\`
## Step 2
- Check file1.js
- Update file2.ts
- Test everything
> **Note**: This is important!`
mockGetCommand.mockResolvedValue({
name: "complex",
content: commandContent,
source: "project",
filePath: "/project/.roo/commands/complex.md",
})
const input = "/complex command"
const result = await callParseMentions(input)
expect(result).toContain('<command name="complex">')
expect(result).toContain("# Complex Command")
expect(result).toContain("```bash")
expect(result).toContain("npm install")
expect(result).toContain("- Check file1.js")
expect(result).toContain("> **Note**: This is important!")
expect(result).toContain("</command>")
})
it("should handle empty command content", async () => {
mockGetCommand.mockResolvedValue({
name: "empty",
content: "",
source: "project",
filePath: "/project/.roo/commands/empty.md",
})
const input = "/empty command"
const result = await callParseMentions(input)
expect(result).toContain('<command name="empty">')
expect(result).toContain("</command>")
// Should still include the command tags even with empty content
})
})
describe("command mention regex patterns", () => {
it("should match valid command mention patterns anywhere", () => {
const commandRegex = /\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
const validPatterns = ["/setup", "/build-prod", "/test_suite", "/my-command", "/command123"]
validPatterns.forEach((pattern) => {
const match = pattern.match(commandRegex)
expect(match).toBeTruthy()
expect(match![0]).toBe(pattern)
})
})
it("should match command patterns in middle of text", () => {
const commandRegex = /\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
const validPatterns = ["Please /setup", "Run /build now", "Use /deploy here"]
validPatterns.forEach((pattern) => {
const match = pattern.match(commandRegex)
expect(match).toBeTruthy()
expect(match![0]).toMatch(/^\/[a-zA-Z0-9_\.-]+$/)
})
})
it("should match commands at start of new lines", () => {
const commandRegex = /\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
const multilineText = "First line\n/setup the project\nAnother line\n/deploy when ready"
const matches = multilineText.match(commandRegex)
// Should match both commands now
expect(matches).toBeTruthy()
expect(matches).toHaveLength(2)
expect(matches![0]).toBe("/setup")
expect(matches![1]).toBe("/deploy")
})
it("should match multiple commands in message", () => {
const commandRegex = /(?:^|\s)\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
const validText = "/setup the project\nThen /deploy later"
const matches = validText.match(commandRegex)
expect(matches).toBeTruthy()
expect(matches).toHaveLength(2)
expect(matches![0]).toBe("/setup")
expect(matches![1]).toBe(" /deploy") // Note: includes leading space
})
it("should not match invalid command patterns", () => {
const commandRegex = /\/([a-zA-Z0-9_\.-]+)(?=\s|$)/g
const invalidPatterns = ["/ space", "/with space", "/with/slash", "//double", "/with@symbol"]
invalidPatterns.forEach((pattern) => {
const match = pattern.match(commandRegex)
if (match) {
// If it matches, it should not be the full invalid pattern
expect(match[0]).not.toBe(pattern)
}
})
})
})
describe("command mention text transformation", () => {
it("should transform command mentions at start of message", async () => {
const input = "/setup the project"
const result = await callParseMentions(input)
expect(result).toContain("Command 'setup' (see below for command content)")
})
it("should process multiple commands in message", async () => {
mockGetCommand
.mockResolvedValueOnce({
name: "setup",
content: "# Setup instructions",
source: "project",
filePath: "/project/.roo/commands/setup.md",
})
.mockResolvedValueOnce({
name: "deploy",
content: "# Deploy instructions",
source: "project",
filePath: "/project/.roo/commands/deploy.md",
})
const input = "/setup the project\nThen /deploy later"
const result = await callParseMentions(input)
expect(result).toContain("Command 'setup' (see below for command content)")
expect(result).toContain("Command 'deploy' (see below for command content)")
})
it("should match commands anywhere with proper word boundaries", async () => {
mockGetCommand.mockResolvedValue({
name: "build",
content: "# Build instructions",
source: "project",
filePath: "/project/.roo/commands/build.md",
})
// At the beginning - should match
let input = "/build the project"
let result = await callParseMentions(input)
expect(result).toContain("Command 'build'")
// After space - should match
input = "Please /build and test"
result = await callParseMentions(input)
expect(result).toContain("Command 'build'")
// At the end - should match
input = "Run the /build"
result = await callParseMentions(input)
expect(result).toContain("Command 'build'")
// At start of new line - should match
input = "Some text\n/build the project"
result = await callParseMentions(input)
expect(result).toContain("Command 'build'")
})
})
})

View file

@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest"
import {
getCommands,
getCommand,
getCommandNames,
getCommandNameFromFile,
isMarkdownFile,
} from "../services/command/commands"
describe("Command Utilities", () => {
const testCwd = "/test/project"
describe("getCommandNameFromFile", () => {
it("should strip .md extension only", () => {
expect(getCommandNameFromFile("my-command.md")).toBe("my-command")
expect(getCommandNameFromFile("test.txt")).toBe("test.txt")
expect(getCommandNameFromFile("no-extension")).toBe("no-extension")
expect(getCommandNameFromFile("multiple.dots.file.md")).toBe("multiple.dots.file")
expect(getCommandNameFromFile("api.config.md")).toBe("api.config")
expect(getCommandNameFromFile("deploy_prod.md")).toBe("deploy_prod")
})
})
describe("isMarkdownFile", () => {
it("should identify markdown files correctly", () => {
// Markdown files
expect(isMarkdownFile("command.md")).toBe(true)
expect(isMarkdownFile("my-command.md")).toBe(true)
expect(isMarkdownFile("README.MD")).toBe(true)
expect(isMarkdownFile("test.Md")).toBe(true)
// Non-markdown files
expect(isMarkdownFile("command.txt")).toBe(false)
expect(isMarkdownFile("script.sh")).toBe(false)
expect(isMarkdownFile("config.json")).toBe(false)
expect(isMarkdownFile("no-extension")).toBe(false)
expect(isMarkdownFile("file.md.bak")).toBe(false)
})
})
describe("getCommands", () => {
it("should return empty array when no command directories exist", async () => {
// This will fail to find directories but should return empty array gracefully
const commands = await getCommands(testCwd)
expect(Array.isArray(commands)).toBe(true)
})
})
describe("getCommandNames", () => {
it("should return empty array when no commands exist", async () => {
const names = await getCommandNames(testCwd)
expect(Array.isArray(names)).toBe(true)
})
})
describe("getCommand", () => {
it("should return undefined for non-existent command", async () => {
const result = await getCommand(testCwd, "non-existent")
expect(result).toBeUndefined()
})
})
describe("command name extraction edge cases", () => {
it("should handle various filename formats", () => {
// Files without extensions
expect(getCommandNameFromFile("command")).toBe("command")
expect(getCommandNameFromFile("my-command")).toBe("my-command")
// Files with multiple dots - only strip .md extension
expect(getCommandNameFromFile("my.complex.command.md")).toBe("my.complex.command")
expect(getCommandNameFromFile("v1.2.3.txt")).toBe("v1.2.3.txt")
// Edge cases
expect(getCommandNameFromFile(".")).toBe(".")
expect(getCommandNameFromFile("..")).toBe("..")
expect(getCommandNameFromFile(".hidden.md")).toBe(".hidden")
})
})
describe("command loading behavior", () => {
it("should handle multiple calls to getCommands", async () => {
const commands1 = await getCommands(testCwd)
const commands2 = await getCommands(testCwd)
expect(Array.isArray(commands1)).toBe(true)
expect(Array.isArray(commands2)).toBe(true)
})
})
describe("error handling", () => {
it("should handle invalid command names gracefully", async () => {
// These should not throw errors
expect(await getCommand(testCwd, "")).toBeUndefined()
expect(await getCommand(testCwd, " ")).toBeUndefined()
expect(await getCommand(testCwd, "non/existent/path")).toBeUndefined()
})
})
})

View file

@ -4,7 +4,7 @@ import * as path from "path"
import * as vscode from "vscode"
import { isBinaryFile } from "isbinaryfile"
import { mentionRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"
import { mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "../../shared/context-mentions"
import { getCommitInfo, getWorkingState } from "../../utils/git"
import { getWorkspacePath } from "../../utils/path"
@ -18,6 +18,7 @@ import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
import { getCommand } from "../../services/command/commands"
import { t } from "../../i18n"
@ -85,7 +86,16 @@ export async function parseMentions(
maxReadFileLine?: number,
): Promise<string> {
const mentions: Set<string> = new Set()
let parsedText = text.replace(mentionRegexGlobal, (match, mention) => {
const commandMentions: Set<string> = new Set()
// First pass: extract command mentions (starting with /)
let parsedText = text.replace(commandRegexGlobal, (match, commandName) => {
commandMentions.add(commandName)
return `Command '${commandName}' (see below for command content)`
})
// Second pass: handle regular mentions
parsedText = parsedText.replace(mentionRegexGlobal, (match, mention) => {
mentions.add(mention)
if (mention.startsWith("http")) {
return `'${mention}' (see below for site content)`
@ -203,6 +213,20 @@ export async function parseMentions(
}
}
// Process command mentions
for (const commandName of commandMentions) {
try {
const command = await getCommand(cwd, commandName)
if (command) {
parsedText += `\n\n<command name="${commandName}">\n${command.content}\n</command>`
} else {
parsedText += `\n\n<command name="${commandName}">\nCommand '${commandName}' not found. Available commands can be found in .roo/commands/ or ~/.roo/commands/\n</command>`
}
} catch (error) {
parsedText += `\n\n<command name="${commandName}">\nError loading command '${commandName}': ${error.message}\n</command>`
}
}
if (urlMention) {
try {
await urlContentFetcher.closeBrowser()

View file

@ -2356,5 +2356,30 @@ export const webviewMessageHandler = async (
}
break
}
case "requestCommands": {
try {
const { getCommands } = await import("../../services/command/commands")
const commands = await getCommands(provider.cwd || "")
// Convert to the format expected by the frontend
const commandList = commands.map((command) => ({
name: command.name,
source: command.source,
}))
await provider.postMessageToWebview({
type: "commands",
commands: commandList,
})
} catch (error) {
provider.log(`Error fetching commands: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
// Send empty array on error
await provider.postMessageToWebview({
type: "commands",
commands: [],
})
}
break
}
}
}

View file

@ -0,0 +1,150 @@
import fs from "fs/promises"
import * as path from "path"
import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config"
export interface Command {
name: string
content: string
source: "global" | "project"
filePath: string
}
/**
* Get all available commands from both global and project directories
*/
export async function getCommands(cwd: string): Promise<Command[]> {
const commands = new Map<string, Command>()
// Scan global commands first
const globalDir = path.join(getGlobalRooDirectory(), "commands")
await scanCommandDirectory(globalDir, "global", commands)
// Scan project commands (these override global ones)
const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "commands")
await scanCommandDirectory(projectDir, "project", commands)
return Array.from(commands.values())
}
/**
* Get a specific command by name (optimized to avoid scanning all commands)
*/
export async function getCommand(cwd: string, name: string): Promise<Command | undefined> {
// Try to find the command directly without scanning all commands
const projectDir = path.join(getProjectRooDirectoryForCwd(cwd), "commands")
const globalDir = path.join(getGlobalRooDirectory(), "commands")
// Check project directory first (project commands override global ones)
const projectCommand = await tryLoadCommand(projectDir, name, "project")
if (projectCommand) {
return projectCommand
}
// Check global directory if not found in project
const globalCommand = await tryLoadCommand(globalDir, name, "global")
return globalCommand
}
/**
* Try to load a specific command from a directory
*/
async function tryLoadCommand(
dirPath: string,
name: string,
source: "global" | "project",
): Promise<Command | undefined> {
try {
const stats = await fs.stat(dirPath)
if (!stats.isDirectory()) {
return undefined
}
// Try to find the command file directly
const commandFileName = `${name}.md`
const filePath = path.join(dirPath, commandFileName)
try {
const content = await fs.readFile(filePath, "utf-8")
return {
name,
content: content.trim(),
source,
filePath,
}
} catch (error) {
// File doesn't exist or can't be read
return undefined
}
} catch (error) {
// Directory doesn't exist or can't be read
return undefined
}
}
/**
* Get command names for autocomplete
*/
export async function getCommandNames(cwd: string): Promise<string[]> {
const commands = await getCommands(cwd)
return commands.map((cmd) => cmd.name)
}
/**
* Scan a specific command directory
*/
async function scanCommandDirectory(
dirPath: string,
source: "global" | "project",
commands: Map<string, Command>,
): Promise<void> {
try {
const stats = await fs.stat(dirPath)
if (!stats.isDirectory()) {
return
}
const entries = await fs.readdir(dirPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile() && isMarkdownFile(entry.name)) {
const filePath = path.join(dirPath, entry.name)
const commandName = getCommandNameFromFile(entry.name)
try {
const content = await fs.readFile(filePath, "utf-8")
// Project commands override global ones
if (source === "project" || !commands.has(commandName)) {
commands.set(commandName, {
name: commandName,
content: content.trim(),
source,
filePath,
})
}
} catch (error) {
console.warn(`Failed to read command file ${filePath}:`, error)
}
}
}
} catch (error) {
// Directory doesn't exist or can't be read - this is fine
}
}
/**
* Extract command name from filename (strip .md extension only)
*/
export function getCommandNameFromFile(filename: string): string {
if (filename.toLowerCase().endsWith(".md")) {
return filename.slice(0, -3)
}
return filename
}
/**
* Check if a file is a markdown file
*/
export function isMarkdownFile(filename: string): boolean {
return filename.toLowerCase().endsWith(".md")
}

View file

@ -19,6 +19,12 @@ import { Mode } from "./modes"
import { RouterModels } from "./api"
import type { MarketplaceItem } from "@roo-code/types"
// Command interface for frontend/backend communication
export interface Command {
name: string
source: "global" | "project"
}
// Type for marketplace installed metadata
export interface MarketplaceInstalledMetadata {
project: Record<string, { type: string }>
@ -109,6 +115,7 @@ export interface ExtensionMessage {
| "codeIndexSecretStatus"
| "showDeleteMessageDialog"
| "showEditMessageDialog"
| "commands"
text?: string
payload?: any // Add a generic payload for now, can refine later
action?:
@ -180,6 +187,7 @@ export interface ExtensionMessage {
settings?: any
messageTs?: number
context?: string
commands?: Command[]
}
export type ExtensionState = Pick<

View file

@ -201,6 +201,7 @@ export interface WebviewMessage {
| "checkRulesDirectoryResult"
| "saveCodeIndexSettingsAtomic"
| "requestCodeIndexSecretStatus"
| "requestCommands"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"

View file

@ -57,6 +57,9 @@ export const mentionRegex =
/(?<!\\)@((?:\/|\w+:\/\/)(?:[^\s\\]|\\ )+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
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
export interface MentionSuggestion {
type: "file" | "folder" | "git" | "problems"
label: string

View file

@ -0,0 +1,271 @@
import { describe, it, expect } from "vitest"
import { getContextMenuOptions, ContextMenuOptionType } from "../utils/context-mentions"
import type { Command } from "@roo/ExtensionMessage"
describe("Command Autocomplete", () => {
const mockCommands: Command[] = [
{ name: "setup", source: "project" },
{ name: "build", source: "project" },
{ name: "deploy", source: "global" },
{ name: "test-suite", source: "project" },
{ name: "cleanup_old", source: "global" },
]
const mockQueryItems = [
{ type: ContextMenuOptionType.File, value: "/src/app.ts" },
{ type: ContextMenuOptionType.Problems, value: "problems" },
]
// Mock translation function
const mockT = (key: string, options?: { name?: string }) => {
if (key === "chat:command.triggerDescription") {
return `Trigger the ${options?.name || "command"} command`
}
return key
}
describe("slash command command suggestions", () => {
it('should return all commands when query is just "/"', () => {
const options = getContextMenuOptions("/", "/", mockT, null, mockQueryItems, [], [], mockCommands)
expect(options).toHaveLength(5)
expect(options.every((option) => option.type === ContextMenuOptionType.Command)).toBe(true)
const commandNames = options.map((option) => option.value)
expect(commandNames).toContain("setup")
expect(commandNames).toContain("build")
expect(commandNames).toContain("deploy")
expect(commandNames).toContain("test-suite")
expect(commandNames).toContain("cleanup_old")
})
it("should filter commands based on fuzzy search", () => {
const options = getContextMenuOptions("/set", "/set", mockT, null, mockQueryItems, [], [], mockCommands)
// Should match 'setup' (fuzzy search behavior may vary)
expect(options.length).toBeGreaterThan(0)
const commandNames = options.map((option) => option.value)
expect(commandNames).toContain("setup")
// Note: fuzzy search may not match 'test-suite' for 'set' query
})
it("should return commands with correct format", () => {
const options = getContextMenuOptions("/setup", "/setup", mockT, null, mockQueryItems, [], [], mockCommands)
const setupOption = options.find((option) => option.value === "setup")
expect(setupOption).toBeDefined()
expect(setupOption!.type).toBe(ContextMenuOptionType.Command)
expect(setupOption!.label).toBe("setup")
expect(setupOption!.description).toBe("Trigger the setup command")
expect(setupOption!.icon).toBe("$(play)")
})
it("should handle empty command list", () => {
const options = getContextMenuOptions("/setup", "/setup", mockT, null, mockQueryItems, [], [], [])
// Should return NoResults when no commands match
expect(options).toHaveLength(1)
expect(options[0].type).toBe(ContextMenuOptionType.NoResults)
})
it("should handle no matching commands", () => {
const options = getContextMenuOptions(
"/nonexistent",
"/nonexistent",
mockT,
null,
mockQueryItems,
[],
[],
mockCommands,
)
// Should return NoResults when no commands match
expect(options).toHaveLength(1)
expect(options[0].type).toBe(ContextMenuOptionType.NoResults)
})
it("should not return command suggestions for non-slash queries", () => {
const options = getContextMenuOptions("setup", "setup", mockT, null, mockQueryItems, [], [], mockCommands)
// Should not contain command options for non-slash queries
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
expect(commandOptions).toHaveLength(0)
})
it("should handle commands with special characters in names", () => {
const specialCommands: Command[] = [
{ name: "setup-dev", source: "project" },
{ name: "test_suite", source: "project" },
{ name: "deploy.prod", source: "global" },
]
const options = getContextMenuOptions(
"/setup",
"/setup",
mockT,
null,
mockQueryItems,
[],
[],
specialCommands,
)
const setupDevOption = options.find((option) => option.value === "setup-dev")
expect(setupDevOption).toBeDefined()
expect(setupDevOption!.label).toBe("setup-dev")
})
it("should handle case-insensitive fuzzy matching", () => {
const options = getContextMenuOptions("/setup", "/setup", mockT, null, mockQueryItems, [], [], mockCommands)
const commandNames = options.map((option) => option.value)
expect(commandNames).toContain("setup")
})
it("should prioritize exact matches in fuzzy search", () => {
const commandsWithSimilarNames: Command[] = [
{ name: "test", source: "project" },
{ name: "test-suite", source: "project" },
{ name: "integration-test", source: "project" },
]
const options = getContextMenuOptions(
"/test",
"/test",
mockT,
null,
mockQueryItems,
[],
[],
commandsWithSimilarNames,
)
// 'test' should be first due to exact match
expect(options[0].value).toBe("test")
})
it("should handle partial matches correctly", () => {
const options = getContextMenuOptions("/te", "/te", mockT, null, mockQueryItems, [], [], mockCommands)
// Should match 'test-suite'
const commandNames = options.map((option) => option.value)
expect(commandNames).toContain("test-suite")
})
})
describe("command integration with modes", () => {
const mockModes = [
{
name: "Code",
slug: "code",
description: "Write and edit code",
roleDefinition: "You are a code assistant",
groups: ["read", "edit"],
},
{
name: "Debug",
slug: "debug",
description: "Debug applications",
roleDefinition: "You are a debug assistant",
groups: ["read", "edit"],
},
] as any[]
it("should return both modes and commands for slash commands", () => {
const options = getContextMenuOptions("/", "/", mockT, null, mockQueryItems, [], mockModes, mockCommands)
const modeOptions = options.filter((option) => option.type === ContextMenuOptionType.Mode)
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
expect(modeOptions.length).toBe(2)
expect(commandOptions.length).toBe(5)
})
it("should filter both modes and commands based on query", () => {
const options = getContextMenuOptions(
"/co",
"/co",
mockT,
null,
mockQueryItems,
[],
mockModes,
mockCommands,
)
// Should match 'code' mode and possibly some commands (fuzzy search may match)
const modeOptions = options.filter((option) => option.type === ContextMenuOptionType.Mode)
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
expect(modeOptions.length).toBe(1)
expect(modeOptions[0].value).toBe("code")
// Fuzzy search might match some commands, so we just check it's a reasonable number
expect(commandOptions.length).toBeGreaterThanOrEqual(0)
})
})
describe("command source indication", () => {
it("should not expose source information in autocomplete", () => {
const options = getContextMenuOptions("/setup", "/setup", mockT, null, mockQueryItems, [], [], mockCommands)
const setupOption = options.find((option) => option.value === "setup")
expect(setupOption).toBeDefined()
// Source should not be exposed in the UI
expect(setupOption!.description).not.toContain("project")
expect(setupOption!.description).not.toContain("global")
expect(setupOption!.description).toBe("Trigger the setup command")
})
})
describe("edge cases", () => {
it("should handle undefined commands gracefully", () => {
const options = getContextMenuOptions("/setup", "/setup", mockT, null, mockQueryItems, [], [], undefined)
expect(options).toHaveLength(1)
expect(options[0].type).toBe(ContextMenuOptionType.NoResults)
})
it("should handle empty query with commands", () => {
const options = getContextMenuOptions("", "", mockT, null, mockQueryItems, [], [], mockCommands)
// Should not return command options for empty query
const commandOptions = options.filter((option) => option.type === ContextMenuOptionType.Command)
expect(commandOptions).toHaveLength(0)
})
it("should handle very long command names", () => {
const longNameCommands: Command[] = [
{ name: "very-long-command-name-that-exceeds-normal-length", source: "project" },
]
const options = getContextMenuOptions(
"/very",
"/very",
mockT,
null,
mockQueryItems,
[],
[],
longNameCommands,
)
expect(options.length).toBe(1)
expect(options[0].value).toBe("very-long-command-name-that-exceeds-normal-length")
})
it("should handle commands with numeric names", () => {
const numericCommands: Command[] = [
{ name: "command1", source: "project" },
{ name: "v2-setup", source: "project" },
{ name: "123test", source: "project" },
]
const options = getContextMenuOptions("/v", "/v", mockT, null, mockQueryItems, [], [], numericCommands)
const commandNames = options.map((option) => option.value)
expect(commandNames).toContain("v2-setup")
})
})
})

View file

@ -86,6 +86,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
togglePinnedApiConfig,
taskHistory,
clineMessages,
commands,
} = useExtensionState()
// Find the ID and display text for the currently selected API configuration
@ -273,6 +274,27 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return
}
if (type === ContextMenuOptionType.Command && value) {
// Handle command selection.
setSelectedMenuIndex(-1)
setInputValue("")
setShowContextMenu(false)
// Insert the command mention into the textarea
const commandMention = `/${value}`
setInputValue(commandMention + " ")
setCursorPosition(commandMention.length + 1)
setIntendedCursorPosition(commandMention.length + 1)
// Focus the textarea
setTimeout(() => {
if (textAreaRef.current) {
textAreaRef.current.focus()
}
}, 0)
return
}
if (
type === ContextMenuOptionType.File ||
type === ContextMenuOptionType.Folder ||
@ -302,6 +324,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
insertValue = "terminal"
} else if (type === ContextMenuOptionType.Git) {
insertValue = value || ""
} else if (type === ContextMenuOptionType.Command) {
insertValue = value ? `/${value}` : ""
}
const { newValue, mentionIndex } = insertMention(
@ -344,10 +368,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const options = getContextMenuOptions(
searchQuery,
inputValue,
t,
selectedType,
queryItems,
fileSearchResults,
allModes,
commands,
)
const optionsLength = options.length
@ -381,10 +407,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const selectedOption = getContextMenuOptions(
searchQuery,
inputValue,
t,
selectedType,
queryItems,
fileSearchResults,
allModes,
commands,
)[selectedMenuIndex]
if (
selectedOption &&
@ -475,6 +503,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
fileSearchResults,
handleHistoryNavigation,
resetHistoryNavigation,
commands,
t,
],
)
@ -504,10 +534,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
if (showMenu) {
if (newValue.startsWith("/")) {
// Handle slash command.
// Handle slash command - request fresh commands
const query = newValue
setSearchQuery(query)
setSelectedMenuIndex(0)
// Request commands fresh each time slash menu is shown
vscode.postMessage({ type: "requestCommands" })
} else {
// Existing @ mention handling.
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
@ -1245,6 +1277,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
modes={allModes}
loading={searchLoading}
dynamicSearchResults={fileSearchResults}
commands={commands}
/>
</div>
)}

View file

@ -1,7 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
import { getIconForFilePath, getIconUrlByName, getIconForDirectoryPath } from "vscode-material-icons"
import { useTranslation } from "react-i18next"
import type { ModeConfig } from "@roo-code/types"
import type { Command } from "@roo/ExtensionMessage"
import {
ContextMenuOptionType,
@ -23,6 +25,7 @@ interface ContextMenuProps {
modes?: ModeConfig[]
loading?: boolean
dynamicSearchResults?: SearchResult[]
commands?: Command[]
}
const ContextMenu: React.FC<ContextMenuProps> = ({
@ -36,13 +39,24 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
queryItems,
modes,
dynamicSearchResults = [],
commands = [],
}) => {
const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("")
const menuRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation()
const filteredOptions = useMemo(() => {
return getContextMenuOptions(searchQuery, inputValue, selectedType, queryItems, dynamicSearchResults, modes)
}, [searchQuery, inputValue, selectedType, queryItems, dynamicSearchResults, modes])
return getContextMenuOptions(
searchQuery,
inputValue,
t,
selectedType,
queryItems,
dynamicSearchResults,
modes,
commands,
)
}, [searchQuery, inputValue, t, selectedType, queryItems, dynamicSearchResults, modes, commands])
useEffect(() => {
if (menuRef.current) {
@ -87,6 +101,25 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
)}
</div>
)
case ContextMenuOptionType.Command:
return (
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
<span style={{ lineHeight: "1.2" }}>{option.label}</span>
{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.Problems:
return <span>Problems</span>
case ContextMenuOptionType.Terminal:
@ -163,6 +196,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
switch (option.type) {
case ContextMenuOptionType.Mode:
return "symbol-misc"
case ContextMenuOptionType.Command:
return "play"
case ContextMenuOptionType.OpenedFile:
return "window"
case ContextMenuOptionType.File:

View file

@ -10,7 +10,7 @@ import {
ORGANIZATION_ALLOW_ALL,
} from "@roo-code/types"
import { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata } from "@roo/ExtensionMessage"
import { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata, Command } from "@roo/ExtensionMessage"
import { findLastIndex } from "@roo/array"
import { McpServer } from "@roo/mcp"
import { checkExistKey } from "@roo/checkExistApiConfig"
@ -33,6 +33,7 @@ export interface ExtensionStateContextType extends ExtensionState {
currentCheckpoint?: string
filePaths: string[]
openedTabs: Array<{ label: string; isActive: boolean; path?: string }>
commands: Command[]
organizationAllowList: OrganizationAllowList
cloudIsAuthenticated: boolean
sharingEnabled: boolean
@ -242,6 +243,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [theme, setTheme] = useState<any>(undefined)
const [filePaths, setFilePaths] = useState<string[]>([])
const [openedTabs, setOpenedTabs] = useState<Array<{ label: string; isActive: boolean; path?: string }>>([])
const [commands, setCommands] = useState<Command[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
const [currentCheckpoint, setCurrentCheckpoint] = useState<string>()
const [extensionRouterModels, setExtensionRouterModels] = useState<RouterModels | undefined>(undefined)
@ -308,6 +310,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setOpenedTabs(tabs)
break
}
case "commands": {
setCommands(message.commands ?? [])
break
}
case "messageUpdated": {
const clineMessage = message.clineMessage!
setState((prevState) => {
@ -372,6 +378,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
currentCheckpoint,
filePaths,
openedTabs,
commands,
soundVolume: state.soundVolume,
ttsSpeed: state.ttsSpeed,
fuzzyMatchThreshold: state.fuzzyMatchThreshold,

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "Edita el teu missatge..."
},
"command": {
"triggerDescription": "Activa la comanda {{name}}"
}
}

View file

@ -344,6 +344,9 @@
"description": "Führe Remote-Agenten in der Cloud aus, greife von überall auf deine Aufgaben zu, arbeite mit anderen zusammen und vieles mehr.",
"joinWaitlist": "Tritt der Warteliste bei, um frühen Zugang zu erhalten."
},
"command": {
"triggerDescription": "Starte den {{name}} Befehl"
},
"editMessage": {
"placeholder": "Bearbeite deine Nachricht..."
}

View file

@ -346,5 +346,8 @@
"title": "Roo Code Cloud is coming soon!",
"description": "Run Roomote agents in the cloud, access your tasks from anywhere, collaborate with others, and more.",
"joinWaitlist": "Join the waitlist to get early access."
},
"command": {
"triggerDescription": "Trigger the {{name}} command"
}
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "Edita tu mensaje..."
},
"command": {
"triggerDescription": "Activar el comando {{name}}"
}
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "Modifiez votre message..."
},
"command": {
"triggerDescription": "Déclencher la commande {{name}}"
}
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "अपना संदेश संपादित करें..."
},
"command": {
"triggerDescription": "{{name}} कमांड को ट्रिगर करें"
}
}

View file

@ -352,5 +352,8 @@
},
"editMessage": {
"placeholder": "Edit pesan Anda..."
},
"command": {
"triggerDescription": "Jalankan perintah {{name}}"
}
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "Modifica il tuo messaggio..."
},
"command": {
"triggerDescription": "Attiva il comando {{name}}"
}
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "メッセージを編集..."
},
"command": {
"triggerDescription": "{{name}}コマンドをトリガー"
}
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "메시지 편집..."
},
"command": {
"triggerDescription": "{{name}} 명령 트리거"
}
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "Bewerk je bericht..."
},
"command": {
"triggerDescription": "Activeer de {{name}} opdracht"
}
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "Edytuj swoją wiadomość..."
},
"command": {
"triggerDescription": "Uruchom polecenie {{name}}"
}
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "Edite sua mensagem..."
},
"command": {
"triggerDescription": "Acionar o comando {{name}}"
}
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "Редактировать сообщение..."
},
"command": {
"triggerDescription": "Запустить команду {{name}}"
}
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "Mesajını düzenle..."
},
"command": {
"triggerDescription": "{{name}} komutunu tetikle"
}
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "Chỉnh sửa tin nhắn của bạn..."
},
"command": {
"triggerDescription": "Kích hoạt lệnh {{name}}"
}
}

View file

@ -344,6 +344,9 @@
"description": "在云端运行远程代理,随时随地访问任务,与他人协作等更多功能。",
"joinWaitlist": "加入等待列表获取早期访问权限。"
},
"command": {
"triggerDescription": "触发 {{name}} 命令"
},
"editMessage": {
"placeholder": "编辑消息..."
}

View file

@ -346,5 +346,8 @@
},
"editMessage": {
"placeholder": "編輯訊息..."
},
"command": {
"triggerDescription": "觸發 {{name}} 命令"
}
}

View file

@ -194,8 +194,16 @@ describe("getContextMenuOptions", () => {
{ path: "/Users/test/project/assets/", type: "folder", label: "assets/" },
]
// Mock translation function
const mockT = (key: string, options?: { name?: string }) => {
if (key === "chat:command.triggerDescription" && options?.name) {
return `Trigger command: ${options.name}`
}
return key
}
it("should return all option types for empty query", () => {
const result = getContextMenuOptions("", "", null, [])
const result = getContextMenuOptions("", "", mockT, null, [])
expect(result).toHaveLength(6)
expect(result.map((item) => item.type)).toEqual([
ContextMenuOptionType.Problems,
@ -208,7 +216,7 @@ describe("getContextMenuOptions", () => {
})
it("should filter by selected type when query is empty", () => {
const result = getContextMenuOptions("", "", ContextMenuOptionType.File, mockQueryItems)
const result = getContextMenuOptions("", "", mockT, ContextMenuOptionType.File, mockQueryItems)
expect(result).toHaveLength(2)
expect(result.map((item) => item.type)).toContain(ContextMenuOptionType.File)
expect(result.map((item) => item.type)).toContain(ContextMenuOptionType.OpenedFile)
@ -217,19 +225,19 @@ describe("getContextMenuOptions", () => {
})
it("should match git commands", () => {
const result = getContextMenuOptions("git", "git", null, mockQueryItems)
const result = getContextMenuOptions("git", "git", mockT, null, mockQueryItems)
expect(result[0].type).toBe(ContextMenuOptionType.Git)
expect(result[0].label).toBe("Git Commits")
})
it("should match git commit hashes", () => {
const result = getContextMenuOptions("abc1234", "abc1234", null, mockQueryItems)
const result = getContextMenuOptions("abc1234", "abc1234", mockT, null, mockQueryItems)
expect(result[0].type).toBe(ContextMenuOptionType.Git)
expect(result[0].value).toBe("abc1234")
})
it("should return NoResults when no matches found", () => {
const result = getContextMenuOptions("nonexistent", "nonexistent", null, mockQueryItems)
const result = getContextMenuOptions("nonexistent", "nonexistent", mockT, null, mockQueryItems)
expect(result).toHaveLength(1)
expect(result[0].type).toBe(ContextMenuOptionType.NoResults)
})
@ -250,7 +258,7 @@ describe("getContextMenuOptions", () => {
},
]
const result = getContextMenuOptions("test", "test", null, testItems, mockDynamicSearchResults)
const result = getContextMenuOptions("test", "test", mockT, null, testItems, mockDynamicSearchResults)
// Check if opened files and dynamic search results are included
expect(result.some((item) => item.type === ContextMenuOptionType.OpenedFile)).toBe(true)
@ -259,7 +267,7 @@ describe("getContextMenuOptions", () => {
it("should maintain correct result ordering according to implementation", () => {
// Add multiple item types to test ordering
const result = getContextMenuOptions("t", "t", null, mockQueryItems, mockDynamicSearchResults)
const result = getContextMenuOptions("t", "t", mockT, null, mockQueryItems, mockDynamicSearchResults)
// Find the different result types
const fileResults = result.filter(
@ -290,7 +298,7 @@ describe("getContextMenuOptions", () => {
})
it("should include opened files when dynamic search results exist", () => {
const result = getContextMenuOptions("open", "open", null, mockQueryItems, mockDynamicSearchResults)
const result = getContextMenuOptions("open", "open", mockT, null, mockQueryItems, mockDynamicSearchResults)
// Verify opened files are included
expect(result.some((item) => item.type === ContextMenuOptionType.OpenedFile)).toBe(true)
@ -299,7 +307,7 @@ describe("getContextMenuOptions", () => {
})
it("should include git results when dynamic search results exist", () => {
const result = getContextMenuOptions("commit", "commit", null, mockQueryItems, mockDynamicSearchResults)
const result = getContextMenuOptions("commit", "commit", mockT, null, mockQueryItems, mockDynamicSearchResults)
// Verify git results are included
expect(result.some((item) => item.type === ContextMenuOptionType.Git)).toBe(true)
@ -320,7 +328,7 @@ describe("getContextMenuOptions", () => {
},
]
const result = getContextMenuOptions("test", "test", null, mockQueryItems, duplicateSearchResults)
const result = getContextMenuOptions("test", "test", mockT, null, mockQueryItems, duplicateSearchResults)
// Count occurrences of src/test.ts in results
const duplicateCount = result.filter(
@ -340,6 +348,7 @@ describe("getContextMenuOptions", () => {
const result = getContextMenuOptions(
"nonexistentquery123456",
"nonexistentquery123456",
mockT,
null,
mockQueryItems,
[], // Empty dynamic search results
@ -387,7 +396,7 @@ describe("getContextMenuOptions", () => {
]
// Get results for "test" query
const result = getContextMenuOptions(testQuery, testQuery, null, testItems, testSearchResults)
const result = getContextMenuOptions(testQuery, testQuery, mockT, null, testItems, testSearchResults)
// Verify we have results
expect(result.length).toBeGreaterThan(0)
@ -433,7 +442,7 @@ describe("getContextMenuOptions", () => {
},
]
const result = getContextMenuOptions("/co", "/co", null, [], [], mockModes)
const result = getContextMenuOptions("/co", "/co", mockT, null, [], [], mockModes)
// Verify mode results are returned
expect(result[0].type).toBe(ContextMenuOptionType.Mode)
@ -443,7 +452,7 @@ describe("getContextMenuOptions", () => {
it("should not process slash commands when query starts with slash but inputValue doesn't", () => {
// Use a completely non-matching query to ensure we get NoResults
// and provide empty query items to avoid any matches
const result = getContextMenuOptions("/nonexistentquery", "Hello /code", null, [], [])
const result = getContextMenuOptions("/nonexistentquery", "Hello /code", mockT, null, [], [])
// Should not process as a mode command
expect(result[0].type).not.toBe(ContextMenuOptionType.Mode)
@ -453,7 +462,7 @@ describe("getContextMenuOptions", () => {
// --- Tests for Escaped Spaces (Focus on how paths are presented) ---
it("should return search results with correct labels/descriptions (no escaping needed here)", () => {
const options = getContextMenuOptions("@search", "search", null, mockQueryItems, mockSearchResults)
const options = getContextMenuOptions("@search", "search", mockT, null, mockQueryItems, mockSearchResults)
const fileResult = options.find((o) => o.label === "search result spaces.ts")
expect(fileResult).toBeDefined()
// Value should be the normalized path, description might be the same or label
@ -466,7 +475,7 @@ describe("getContextMenuOptions", () => {
})
it("should return query items (like opened files) with correct labels/descriptions", () => {
const options = getContextMenuOptions("open", "@open", null, mockQueryItems, [])
const options = getContextMenuOptions("open", "@open", mockT, null, mockQueryItems, [])
const openedFile = options.find((o) => o.label === "open file.ts")
expect(openedFile).toBeDefined()
expect(openedFile?.value).toBe("src/open file.ts")
@ -483,7 +492,7 @@ describe("getContextMenuOptions", () => {
]
// The formatting happens in getContextMenuOptions when converting search results to menu items
const formattedItems = getContextMenuOptions("spaces", "@spaces", null, [], searchResults)
const formattedItems = getContextMenuOptions("spaces", "@spaces", mockT, null, [], searchResults)
// Verify we get some results back that aren't "No Results"
expect(formattedItems.length).toBeGreaterThan(0)

View file

@ -1,6 +1,7 @@
import { Fzf } from "fzf"
import type { ModeConfig } from "@roo-code/types"
import type { Command } from "@roo/ExtensionMessage"
import { mentionRegex } from "@roo/context-mentions"
@ -105,6 +106,7 @@ export enum ContextMenuOptionType {
Git = "git",
NoResults = "noResults",
Mode = "mode", // Add mode type
Command = "command", // Add command type
}
export interface ContextMenuQueryItem {
@ -118,43 +120,83 @@ export interface ContextMenuQueryItem {
export function getContextMenuOptions(
query: string,
inputValue: string,
t: (key: string, options?: { name?: string }) => string,
selectedType: ContextMenuOptionType | null = null,
queryItems: ContextMenuQueryItem[],
dynamicSearchResults: SearchResult[] = [],
modes?: ModeConfig[],
commands?: Command[],
): ContextMenuQueryItem[] {
// Handle slash commands for modes
// Handle slash commands for modes and commands
if (query.startsWith("/") && inputValue.startsWith("/")) {
const modeQuery = query.slice(1)
if (!modes?.length) return [{ type: ContextMenuOptionType.NoResults }]
const slashQuery = query.slice(1)
const results: ContextMenuQueryItem[] = []
// Create searchable strings array for fzf
const searchableItems = modes.map((mode) => ({
original: mode,
searchStr: mode.name,
}))
// Add mode suggestions
if (modes?.length) {
// Create searchable strings array for fzf
const searchableItems = modes.map((mode) => ({
original: mode,
searchStr: mode.name,
}))
// Initialize fzf instance for fuzzy search
const fzf = new Fzf(searchableItems, {
selector: (item) => item.searchStr,
})
// Initialize fzf instance for fuzzy search
const fzf = new Fzf(searchableItems, {
selector: (item) => item.searchStr,
})
// Get fuzzy matching items
const matchingModes = modeQuery
? fzf.find(modeQuery).map((result) => ({
type: ContextMenuOptionType.Mode,
value: result.item.original.slug,
label: result.item.original.name,
description: getModeDescription(result.item.original),
}))
: modes.map((mode) => ({
type: ContextMenuOptionType.Mode,
value: mode.slug,
label: mode.name,
description: getModeDescription(mode),
}))
// Get fuzzy matching items
const matchingModes = slashQuery
? fzf.find(slashQuery).map((result) => ({
type: ContextMenuOptionType.Mode,
value: result.item.original.slug,
label: result.item.original.name,
description: getModeDescription(result.item.original),
}))
: modes.map((mode) => ({
type: ContextMenuOptionType.Mode,
value: mode.slug,
label: mode.name,
description: getModeDescription(mode),
}))
return matchingModes.length > 0 ? matchingModes : [{ type: ContextMenuOptionType.NoResults }]
results.push(...matchingModes)
}
// Add command suggestions
if (commands?.length) {
// Create searchable strings array for fzf
const searchableCommands = commands.map((command) => ({
original: command,
searchStr: command.name,
}))
// Initialize fzf instance for fuzzy search
const fzf = new Fzf(searchableCommands, {
selector: (item) => item.searchStr,
})
// Get fuzzy matching commands
const matchingCommands = slashQuery
? fzf.find(slashQuery).map((result) => ({
type: ContextMenuOptionType.Command,
value: result.item.original.name,
label: result.item.original.name,
description: t("chat:command.triggerDescription", { name: result.item.original.name }),
icon: "$(play)",
}))
: commands.map((command) => ({
type: ContextMenuOptionType.Command,
value: command.name,
label: command.name,
description: t("chat:command.triggerDescription", { name: command.name }),
icon: "$(play)",
}))
results.push(...matchingCommands)
}
return results.length > 0 ? results : [{ type: ContextMenuOptionType.NoResults }]
}
const workingChanges: ContextMenuQueryItem = {