Просмотр исходного кода

Support for custom slash commands (#6263)

Matt Rubens 5 месяцев назад
Родитель
Сommit
5d4e3819e4
33 измененных файлов с 1173 добавлено и 51 удалено
  1. 56 0
      src/__tests__/command-integration.spec.ts
  2. 307 0
      src/__tests__/command-mentions.spec.ts
  3. 97 0
      src/__tests__/commands.spec.ts
  4. 26 2
      src/core/mentions/index.ts
  5. 25 0
      src/core/webview/webviewMessageHandler.ts
  6. 150 0
      src/services/command/commands.ts
  7. 8 0
      src/shared/ExtensionMessage.ts
  8. 1 0
      src/shared/WebviewMessage.ts
  9. 3 0
      src/shared/context-mentions.ts
  10. 271 0
      webview-ui/src/__tests__/command-autocomplete.spec.ts
  11. 34 1
      webview-ui/src/components/chat/ChatTextArea.tsx
  12. 37 2
      webview-ui/src/components/chat/ContextMenu.tsx
  13. 8 1
      webview-ui/src/context/ExtensionStateContext.tsx
  14. 3 0
      webview-ui/src/i18n/locales/ca/chat.json
  15. 3 0
      webview-ui/src/i18n/locales/de/chat.json
  16. 3 0
      webview-ui/src/i18n/locales/en/chat.json
  17. 3 0
      webview-ui/src/i18n/locales/es/chat.json
  18. 3 0
      webview-ui/src/i18n/locales/fr/chat.json
  19. 3 0
      webview-ui/src/i18n/locales/hi/chat.json
  20. 3 0
      webview-ui/src/i18n/locales/id/chat.json
  21. 3 0
      webview-ui/src/i18n/locales/it/chat.json
  22. 3 0
      webview-ui/src/i18n/locales/ja/chat.json
  23. 3 0
      webview-ui/src/i18n/locales/ko/chat.json
  24. 3 0
      webview-ui/src/i18n/locales/nl/chat.json
  25. 3 0
      webview-ui/src/i18n/locales/pl/chat.json
  26. 3 0
      webview-ui/src/i18n/locales/pt-BR/chat.json
  27. 3 0
      webview-ui/src/i18n/locales/ru/chat.json
  28. 3 0
      webview-ui/src/i18n/locales/tr/chat.json
  29. 3 0
      webview-ui/src/i18n/locales/vi/chat.json
  30. 3 0
      webview-ui/src/i18n/locales/zh-CN/chat.json
  31. 3 0
      webview-ui/src/i18n/locales/zh-TW/chat.json
  32. 25 16
      webview-ui/src/utils/__tests__/context-mentions.spec.ts
  33. 71 29
      webview-ui/src/utils/context-mentions.ts

+ 56 - 0
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()
+	})
+})

+ 307 - 0
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('<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'")
+		})
+	})
+})

+ 97 - 0
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()
+		})
+	})
+})

+ 26 - 2
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<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()

+ 25 - 0
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
+		}
 	}
 }

+ 150 - 0
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<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")
+}

+ 8 - 0
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<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<

+ 1 - 0
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"

+ 3 - 0
src/shared/context-mentions.ts

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

+ 271 - 0
webview-ui/src/__tests__/command-autocomplete.spec.ts

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

+ 34 - 1
webview-ui/src/components/chat/ChatTextArea.tsx

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

+ 37 - 2
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<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:

+ 8 - 1
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<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,

+ 3 - 0
webview-ui/src/i18n/locales/ca/chat.json

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

+ 3 - 0
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..."
 	}

+ 3 - 0
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"
 	}
 }

+ 3 - 0
webview-ui/src/i18n/locales/es/chat.json

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

+ 3 - 0
webview-ui/src/i18n/locales/fr/chat.json

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

+ 3 - 0
webview-ui/src/i18n/locales/hi/chat.json

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

+ 3 - 0
webview-ui/src/i18n/locales/id/chat.json

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

+ 3 - 0
webview-ui/src/i18n/locales/it/chat.json

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

+ 3 - 0
webview-ui/src/i18n/locales/ja/chat.json

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

+ 3 - 0
webview-ui/src/i18n/locales/ko/chat.json

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

+ 3 - 0
webview-ui/src/i18n/locales/nl/chat.json

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

+ 3 - 0
webview-ui/src/i18n/locales/pl/chat.json

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

+ 3 - 0
webview-ui/src/i18n/locales/pt-BR/chat.json

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

+ 3 - 0
webview-ui/src/i18n/locales/ru/chat.json

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

+ 3 - 0
webview-ui/src/i18n/locales/tr/chat.json

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

+ 3 - 0
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}}"
 	}
 }

+ 3 - 0
webview-ui/src/i18n/locales/zh-CN/chat.json

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

+ 3 - 0
webview-ui/src/i18n/locales/zh-TW/chat.json

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

+ 25 - 16
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)

+ 71 - 29
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 = {