diff --git a/src/__tests__/command-integration.spec.ts b/src/__tests__/command-integration.spec.ts new file mode 100644 index 00000000000..e884325b68a --- /dev/null +++ b/src/__tests__/command-integration.spec.ts @@ -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() + }) +}) diff --git a/src/__tests__/command-mentions.spec.ts b/src/__tests__/command-mentions.spec.ts new file mode 100644 index 00000000000..d4de0bbba7c --- /dev/null +++ b/src/__tests__/command-mentions.spec.ts @@ -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('') + expect(result).toContain(commandContent) + expect(result).toContain("") + 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('') + expect(result).toContain("# Setup instructions") + expect(result).toContain('') + 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('') + expect(result).toContain("Command 'nonexistent' not found") + expect(result).toContain("") + }) + + 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('') + expect(result).toContain("Error loading command") + expect(result).toContain("") + }) + + 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('') + 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('') + 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("") + }) + + 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('') + expect(result).toContain("") + // 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'") + }) + }) +}) diff --git a/src/__tests__/commands.spec.ts b/src/__tests__/commands.spec.ts new file mode 100644 index 00000000000..94010500623 --- /dev/null +++ b/src/__tests__/commands.spec.ts @@ -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() + }) + }) +}) diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index d0d305d0965..494fbae4fc1 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, 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 { const mentions: Set = new Set() - let parsedText = text.replace(mentionRegexGlobal, (match, mention) => { + const commandMentions: Set = 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\n${command.content}\n` + } else { + parsedText += `\n\n\nCommand '${commandName}' not found. Available commands can be found in .roo/commands/ or ~/.roo/commands/\n` + } + } catch (error) { + parsedText += `\n\n\nError loading command '${commandName}': ${error.message}\n` + } + } + if (urlMention) { try { await urlContentFetcher.closeBrowser() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 82c780adf47..da73c569201 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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 + } } } diff --git a/src/services/command/commands.ts b/src/services/command/commands.ts new file mode 100644 index 00000000000..a78b2187f82 --- /dev/null +++ b/src/services/command/commands.ts @@ -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 { + const commands = new Map() + + // 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 { + // 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 { + 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 { + 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, +): Promise { + 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") +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index bdd32c4e36b..816069f91f9 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -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 @@ -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< diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 795e2765222..1304e4c7d51 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -201,6 +201,7 @@ export interface WebviewMessage { | "checkRulesDirectoryResult" | "saveCodeIndexSettingsAtomic" | "requestCodeIndexSecretStatus" + | "requestCommands" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" diff --git a/src/shared/context-mentions.ts b/src/shared/context-mentions.ts index 2edb99de6ad..d7e59a77ddb 100644 --- a/src/shared/context-mentions.ts +++ b/src/shared/context-mentions.ts @@ -57,6 +57,9 @@ export const mentionRegex = /(? { + 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") + }) + }) +}) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 6c541353eb2..e179013203a 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -86,6 +86,7 @@ const ChatTextArea = forwardRef( togglePinnedApiConfig, taskHistory, clineMessages, + commands, } = useExtensionState() // Find the ID and display text for the currently selected API configuration @@ -273,6 +274,27 @@ const ChatTextArea = forwardRef( 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( 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( const options = getContextMenuOptions( searchQuery, inputValue, + t, selectedType, queryItems, fileSearchResults, allModes, + commands, ) const optionsLength = options.length @@ -381,10 +407,12 @@ const ChatTextArea = forwardRef( const selectedOption = getContextMenuOptions( searchQuery, inputValue, + t, selectedType, queryItems, fileSearchResults, allModes, + commands, )[selectedMenuIndex] if ( selectedOption && @@ -475,6 +503,8 @@ const ChatTextArea = forwardRef( fileSearchResults, handleHistoryNavigation, resetHistoryNavigation, + commands, + t, ], ) @@ -504,10 +534,12 @@ const ChatTextArea = forwardRef( 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( modes={allModes} loading={searchLoading} dynamicSearchResults={fileSearchResults} + commands={commands} /> )} diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index 1672c35ee3d..372dea088c7 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -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 = ({ @@ -36,13 +39,24 @@ const ContextMenu: React.FC = ({ queryItems, modes, dynamicSearchResults = [], + commands = [], }) => { const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("") const menuRef = useRef(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 = ({ )} ) + case ContextMenuOptionType.Command: + return ( +
+ {option.label} + {option.description && ( + + {option.description} + + )} +
+ ) case ContextMenuOptionType.Problems: return Problems case ContextMenuOptionType.Terminal: @@ -163,6 +196,8 @@ const ContextMenu: React.FC = ({ switch (option.type) { case ContextMenuOptionType.Mode: return "symbol-misc" + case ContextMenuOptionType.Command: + return "play" case ContextMenuOptionType.OpenedFile: return "window" case ContextMenuOptionType.File: diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ff1ce31c53c..cb51fe9c9a3 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -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(undefined) const [filePaths, setFilePaths] = useState([]) const [openedTabs, setOpenedTabs] = useState>([]) + const [commands, setCommands] = useState([]) const [mcpServers, setMcpServers] = useState([]) const [currentCheckpoint, setCurrentCheckpoint] = useState() const [extensionRouterModels, setExtensionRouterModels] = useState(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, diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 8865da01c24..ace10191cfc 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "Edita el teu missatge..." + }, + "command": { + "triggerDescription": "Activa la comanda {{name}}" } } diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index c28a41aabd8..692f3c3afb1 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -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..." } diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index b5e9c1da16b..441dad589d3 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index da2973027c0..a379f4cfe07 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "Edita tu mensaje..." + }, + "command": { + "triggerDescription": "Activar el comando {{name}}" } } diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 1153dea6c01..09ac5da79b1 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "Modifiez votre message..." + }, + "command": { + "triggerDescription": "Déclencher la commande {{name}}" } } diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 114384da18c..99699335c0d 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "अपना संदेश संपादित करें..." + }, + "command": { + "triggerDescription": "{{name}} कमांड को ट्रिगर करें" } } diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index e272ba3fa20..e40ca9c7f70 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -352,5 +352,8 @@ }, "editMessage": { "placeholder": "Edit pesan Anda..." + }, + "command": { + "triggerDescription": "Jalankan perintah {{name}}" } } diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 4a0b58180bf..97329b4c750 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "Modifica il tuo messaggio..." + }, + "command": { + "triggerDescription": "Attiva il comando {{name}}" } } diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 1db33759d57..c6de5224fcf 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "メッセージを編集..." + }, + "command": { + "triggerDescription": "{{name}}コマンドをトリガー" } } diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 9af867fe7a2..132e8a19ea4 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "메시지 편집..." + }, + "command": { + "triggerDescription": "{{name}} 명령 트리거" } } diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 0c95429b553..62cbad798d5 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "Bewerk je bericht..." + }, + "command": { + "triggerDescription": "Activeer de {{name}} opdracht" } } diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index c2cb70a05d3..371e81e77ae 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "Edytuj swoją wiadomość..." + }, + "command": { + "triggerDescription": "Uruchom polecenie {{name}}" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 64a6b4a067a..61d28acaffe 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "Edite sua mensagem..." + }, + "command": { + "triggerDescription": "Acionar o comando {{name}}" } } diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index a860c9e94e1..4cfbb368057 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "Редактировать сообщение..." + }, + "command": { + "triggerDescription": "Запустить команду {{name}}" } } diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 6ddca0a6256..1a5d1438b5e 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "Mesajını düzenle..." + }, + "command": { + "triggerDescription": "{{name}} komutunu tetikle" } } diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 61b11998ade..4195845279d 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -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}}" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index a19d7af5f01..c3787cce0ee 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -344,6 +344,9 @@ "description": "在云端运行远程代理,随时随地访问任务,与他人协作等更多功能。", "joinWaitlist": "加入等待列表获取早期访问权限。" }, + "command": { + "triggerDescription": "触发 {{name}} 命令" + }, "editMessage": { "placeholder": "编辑消息..." } diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 45053a4493e..27c53f8ed67 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -346,5 +346,8 @@ }, "editMessage": { "placeholder": "編輯訊息..." + }, + "command": { + "triggerDescription": "觸發 {{name}} 命令" } } diff --git a/webview-ui/src/utils/__tests__/context-mentions.spec.ts b/webview-ui/src/utils/__tests__/context-mentions.spec.ts index 50fb1b1c504..f0266cc07f6 100644 --- a/webview-ui/src/utils/__tests__/context-mentions.spec.ts +++ b/webview-ui/src/utils/__tests__/context-mentions.spec.ts @@ -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) diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 889dca9dbea..50ab3cf12b3 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -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 }] - - // 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, - }) + const slashQuery = query.slice(1) + const results: ContextMenuQueryItem[] = [] + + // 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, + }) - // 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), + })) + + 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 matchingModes.length > 0 ? matchingModes : [{ type: ContextMenuOptionType.NoResults }] + return results.length > 0 ? results : [{ type: ContextMenuOptionType.NoResults }] } const workingChanges: ContextMenuQueryItem = {