Bladeren bron

feat: add optional mode field to slash command front matter (#10344)

* feat: add optional mode field to slash command front matter

- Add mode field to Command interface
- Update command parsing to extract mode from frontmatter
- Modify RunSlashCommandTool to automatically switch mode when specified
- Add comprehensive tests for mode field parsing and switching
- Update existing tests to include mode field

* Make it work for manual slash commands too

---------

Co-authored-by: Roo Code <[email protected]>
Co-authored-by: Matt Rubens <[email protected]>
roomote[bot] 1 dag geleden
bovenliggende
commit
13370a2ad1

+ 1 - 0
.roo/commands/release.md

@@ -1,6 +1,7 @@
 ---
 description: "Create a new release of the Roo Code extension"
 argument-hint: patch | minor | major
+mode: code
 ---
 
 1. Identify the SHA corresponding to the most recent release using GitHub CLI: `gh release view --json tagName,targetCommitish,publishedAt`

+ 3 - 1
src/__tests__/command-mentions.spec.ts

@@ -27,7 +27,7 @@ describe("Command Mentions", () => {
 
 	// Helper function to call parseMentions with required parameters
 	const callParseMentions = async (text: string) => {
-		return await parseMentions(
+		const result = await parseMentions(
 			text,
 			"/test/cwd", // cwd
 			mockUrlContentFetcher, // urlContentFetcher
@@ -38,6 +38,8 @@ describe("Command Mentions", () => {
 			50, // maxDiagnosticMessages
 			undefined, // maxReadFileLine
 		)
+		// Return just the text for backward compatibility with existing tests
+		return result.text
 	}
 
 	describe("parseMentions with command support", () => {

+ 16 - 16
src/core/mentions/__tests__/index.spec.ts

@@ -40,7 +40,7 @@ describe("parseMentions - URL error handling", () => {
 
 		expect(consoleErrorSpy).toHaveBeenCalledWith("Error fetching URL https://example.com:", timeoutError)
 		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
-		expect(result).toContain("Error fetching content: Navigation timeout of 30000 ms exceeded")
+		expect(result.text).toContain("Error fetching content: Navigation timeout of 30000 ms exceeded")
 	})
 
 	it("should handle DNS resolution errors", async () => {
@@ -50,7 +50,7 @@ describe("parseMentions - URL error handling", () => {
 		const result = await parseMentions("Check @https://nonexistent.example", "/test", mockUrlContentFetcher)
 
 		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
-		expect(result).toContain("Error fetching content: net::ERR_NAME_NOT_RESOLVED")
+		expect(result.text).toContain("Error fetching content: net::ERR_NAME_NOT_RESOLVED")
 	})
 
 	it("should handle network disconnection errors", async () => {
@@ -60,7 +60,7 @@ describe("parseMentions - URL error handling", () => {
 		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
 
 		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
-		expect(result).toContain("Error fetching content: net::ERR_INTERNET_DISCONNECTED")
+		expect(result.text).toContain("Error fetching content: net::ERR_INTERNET_DISCONNECTED")
 	})
 
 	it("should handle 403 Forbidden errors", async () => {
@@ -70,7 +70,7 @@ describe("parseMentions - URL error handling", () => {
 		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
 
 		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
-		expect(result).toContain("Error fetching content: 403 Forbidden")
+		expect(result.text).toContain("Error fetching content: 403 Forbidden")
 	})
 
 	it("should handle 404 Not Found errors", async () => {
@@ -80,7 +80,7 @@ describe("parseMentions - URL error handling", () => {
 		const result = await parseMentions("Check @https://example.com/missing", "/test", mockUrlContentFetcher)
 
 		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
-		expect(result).toContain("Error fetching content: 404 Not Found")
+		expect(result.text).toContain("Error fetching content: 404 Not Found")
 	})
 
 	it("should handle generic errors with fallback message", async () => {
@@ -90,7 +90,7 @@ describe("parseMentions - URL error handling", () => {
 		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
 
 		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
-		expect(result).toContain("Error fetching content: Some unexpected error")
+		expect(result.text).toContain("Error fetching content: Some unexpected error")
 	})
 
 	it("should handle non-Error objects thrown", async () => {
@@ -100,7 +100,7 @@ describe("parseMentions - URL error handling", () => {
 		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
 
 		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.url_fetch_error_with_url")
-		expect(result).toContain("Error fetching content:")
+		expect(result.text).toContain("Error fetching content:")
 	})
 
 	it("should handle browser launch errors correctly", async () => {
@@ -112,7 +112,7 @@ describe("parseMentions - URL error handling", () => {
 		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
 			"Error fetching content for https://example.com: Failed to launch browser",
 		)
-		expect(result).toContain("Error fetching content: Failed to launch browser")
+		expect(result.text).toContain("Error fetching content: Failed to launch browser")
 		// Should not attempt to fetch URL if browser launch failed
 		expect(mockUrlContentFetcher.urlToMarkdown).not.toHaveBeenCalled()
 	})
@@ -126,7 +126,7 @@ describe("parseMentions - URL error handling", () => {
 		expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
 			"Error fetching content for https://example.com: String error",
 		)
-		expect(result).toContain("Error fetching content: String error")
+		expect(result.text).toContain("Error fetching content: String error")
 	})
 
 	it("should successfully fetch URL content when no errors occur", async () => {
@@ -135,9 +135,9 @@ describe("parseMentions - URL error handling", () => {
 		const result = await parseMentions("Check @https://example.com", "/test", mockUrlContentFetcher)
 
 		expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
-		expect(result).toContain('<url_content url="https://example.com">')
-		expect(result).toContain("# Example Content\n\nThis is the content.")
-		expect(result).toContain("</url_content>")
+		expect(result.text).toContain('<url_content url="https://example.com">')
+		expect(result.text).toContain("# Example Content\n\nThis is the content.")
+		expect(result.text).toContain("</url_content>")
 	})
 
 	it("should handle multiple URLs with mixed success and failure", async () => {
@@ -151,9 +151,9 @@ describe("parseMentions - URL error handling", () => {
 			mockUrlContentFetcher,
 		)
 
-		expect(result).toContain('<url_content url="https://example1.com">')
-		expect(result).toContain("# First Site")
-		expect(result).toContain('<url_content url="https://example2.com">')
-		expect(result).toContain("Error fetching content: timeout")
+		expect(result.text).toContain('<url_content url="https://example1.com">')
+		expect(result.text).toContain("# First Site")
+		expect(result.text).toContain('<url_content url="https://example2.com">')
+		expect(result.text).toContain("Error fetching content: timeout")
 	})
 })

+ 20 - 11
src/core/mentions/__tests__/processUserContentMentions.spec.ts

@@ -22,8 +22,11 @@ describe("processUserContentMentions", () => {
 		mockFileContextTracker = {} as FileContextTracker
 		mockRooIgnoreController = {}
 
-		// Default mock implementation
-		vi.mocked(parseMentions).mockImplementation(async (text) => `parsed: ${text}`)
+		// Default mock implementation - returns ParseMentionsResult object
+		vi.mocked(parseMentions).mockImplementation(async (text) => ({
+			text: `parsed: ${text}`,
+			mode: undefined,
+		}))
 	})
 
 	describe("maxReadFileLine parameter", () => {
@@ -134,10 +137,11 @@ describe("processUserContentMentions", () => {
 			})
 
 			expect(parseMentions).toHaveBeenCalled()
-			expect(result[0]).toEqual({
+			expect(result.content[0]).toEqual({
 				type: "text",
 				text: "parsed: <task>Do something</task>",
 			})
+			expect(result.mode).toBeUndefined()
 		})
 
 		it("should process text blocks with <feedback> tags", async () => {
@@ -156,10 +160,11 @@ describe("processUserContentMentions", () => {
 			})
 
 			expect(parseMentions).toHaveBeenCalled()
-			expect(result[0]).toEqual({
+			expect(result.content[0]).toEqual({
 				type: "text",
 				text: "parsed: <feedback>Fix this issue</feedback>",
 			})
+			expect(result.mode).toBeUndefined()
 		})
 
 		it("should not process text blocks without task or feedback tags", async () => {
@@ -178,7 +183,8 @@ describe("processUserContentMentions", () => {
 			})
 
 			expect(parseMentions).not.toHaveBeenCalled()
-			expect(result[0]).toEqual(userContent[0])
+			expect(result.content[0]).toEqual(userContent[0])
+			expect(result.mode).toBeUndefined()
 		})
 
 		it("should process tool_result blocks with string content", async () => {
@@ -198,11 +204,12 @@ describe("processUserContentMentions", () => {
 			})
 
 			expect(parseMentions).toHaveBeenCalled()
-			expect(result[0]).toEqual({
+			expect(result.content[0]).toEqual({
 				type: "tool_result",
 				tool_use_id: "123",
 				content: "parsed: <feedback>Tool feedback</feedback>",
 			})
+			expect(result.mode).toBeUndefined()
 		})
 
 		it("should process tool_result blocks with array content", async () => {
@@ -231,7 +238,7 @@ describe("processUserContentMentions", () => {
 			})
 
 			expect(parseMentions).toHaveBeenCalledTimes(1)
-			expect(result[0]).toEqual({
+			expect(result.content[0]).toEqual({
 				type: "tool_result",
 				tool_use_id: "123",
 				content: [
@@ -245,6 +252,7 @@ describe("processUserContentMentions", () => {
 					},
 				],
 			})
+			expect(result.mode).toBeUndefined()
 		})
 
 		it("should handle mixed content types", async () => {
@@ -277,17 +285,18 @@ describe("processUserContentMentions", () => {
 			})
 
 			expect(parseMentions).toHaveBeenCalledTimes(2)
-			expect(result).toHaveLength(3)
-			expect(result[0]).toEqual({
+			expect(result.content).toHaveLength(3)
+			expect(result.content[0]).toEqual({
 				type: "text",
 				text: "parsed: <task>First task</task>",
 			})
-			expect(result[1]).toEqual(userContent[1]) // Image block unchanged
-			expect(result[2]).toEqual({
+			expect(result.content[1]).toEqual(userContent[1]) // Image block unchanged
+			expect(result.content[2]).toEqual({
 				type: "tool_result",
 				tool_use_id: "456",
 				content: "parsed: <feedback>Feedback</feedback>",
 			})
+			expect(result.mode).toBeUndefined()
 		})
 	})
 

+ 14 - 3
src/core/mentions/index.ts

@@ -71,6 +71,11 @@ export async function openMention(cwd: string, mention?: string): Promise<void>
 	}
 }
 
+export interface ParseMentionsResult {
+	text: string
+	mode?: string // Mode from the first slash command that has one
+}
+
 export async function parseMentions(
 	text: string,
 	cwd: string,
@@ -81,9 +86,10 @@ export async function parseMentions(
 	includeDiagnosticMessages: boolean = true,
 	maxDiagnosticMessages: number = 50,
 	maxReadFileLine?: number,
-): Promise<string> {
+): Promise<ParseMentionsResult> {
 	const mentions: Set<string> = new Set()
 	const validCommands: Map<string, Command> = new Map()
+	let commandMode: string | undefined // Track mode from the first slash command that has one
 
 	// First pass: check which command mentions exist and cache the results
 	const commandMatches = Array.from(text.matchAll(commandRegexGlobal))
@@ -101,10 +107,14 @@ export async function parseMentions(
 		}),
 	)
 
-	// Store valid commands for later use
+	// Store valid commands for later use and capture the first mode found
 	for (const { commandName, command } of commandExistenceChecks) {
 		if (command) {
 			validCommands.set(commandName, command)
+			// Capture the mode from the first command that has one
+			if (!commandMode && command.mode) {
+				commandMode = command.mode
+			}
 		}
 	}
 
@@ -257,7 +267,7 @@ export async function parseMentions(
 		}
 	}
 
-	return parsedText
+	return { text: parsedText, mode: commandMode }
 }
 
 async function getFileOrFolderContent(
@@ -410,3 +420,4 @@ export async function getLatestTerminalOutput(): Promise<string> {
 
 // Export processUserContentMentions from its own file
 export { processUserContentMentions } from "./processUserContentMentions"
+export type { ProcessUserContentMentionsResult } from "./processUserContentMentions"

+ 60 - 35
src/core/mentions/processUserContentMentions.ts

@@ -1,8 +1,13 @@
 import { Anthropic } from "@anthropic-ai/sdk"
-import { parseMentions } from "./index"
+import { parseMentions, ParseMentionsResult } from "./index"
 import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
 import { FileContextTracker } from "../context-tracking/FileContextTracker"
 
+export interface ProcessUserContentMentionsResult {
+	content: Anthropic.Messages.ContentBlockParam[]
+	mode?: string // Mode from the first slash command that has one
+}
+
 /**
  * Process mentions in user content, specifically within task and feedback tags
  */
@@ -26,7 +31,10 @@ export async function processUserContentMentions({
 	includeDiagnosticMessages?: boolean
 	maxDiagnosticMessages?: number
 	maxReadFileLine?: number
-}) {
+}): Promise<ProcessUserContentMentionsResult> {
+	// Track the first mode found from slash commands
+	let commandMode: string | undefined
+
 	// Process userContent array, which contains various block types:
 	// TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam.
 	// We need to apply parseMentions() to:
@@ -37,7 +45,7 @@ export async function processUserContentMentions({
 	// (see askFollowupQuestion), we place all user generated content in
 	// these tags so they can effectively be used as markers for when we
 	// should parse mentions).
-	return Promise.all(
+	const content = await Promise.all(
 		userContent.map(async (block) => {
 			const shouldProcessMentions = (text: string) =>
 				text.includes("<task>") ||
@@ -47,10 +55,33 @@ export async function processUserContentMentions({
 
 			if (block.type === "text") {
 				if (shouldProcessMentions(block.text)) {
+					const result = await parseMentions(
+						block.text,
+						cwd,
+						urlContentFetcher,
+						fileContextTracker,
+						rooIgnoreController,
+						showRooIgnoredFiles,
+						includeDiagnosticMessages,
+						maxDiagnosticMessages,
+						maxReadFileLine,
+					)
+					// Capture the first mode found
+					if (!commandMode && result.mode) {
+						commandMode = result.mode
+					}
 					return {
 						...block,
-						text: await parseMentions(
-							block.text,
+						text: result.text,
+					}
+				}
+
+				return block
+			} else if (block.type === "tool_result") {
+				if (typeof block.content === "string") {
+					if (shouldProcessMentions(block.content)) {
+						const result = await parseMentions(
+							block.content,
 							cwd,
 							urlContentFetcher,
 							fileContextTracker,
@@ -59,27 +90,14 @@ export async function processUserContentMentions({
 							includeDiagnosticMessages,
 							maxDiagnosticMessages,
 							maxReadFileLine,
-						),
-					}
-				}
-
-				return block
-			} else if (block.type === "tool_result") {
-				if (typeof block.content === "string") {
-					if (shouldProcessMentions(block.content)) {
+						)
+						// Capture the first mode found
+						if (!commandMode && result.mode) {
+							commandMode = result.mode
+						}
 						return {
 							...block,
-							content: await parseMentions(
-								block.content,
-								cwd,
-								urlContentFetcher,
-								fileContextTracker,
-								rooIgnoreController,
-								showRooIgnoredFiles,
-								includeDiagnosticMessages,
-								maxDiagnosticMessages,
-								maxReadFileLine,
-							),
+							content: result.text,
 						}
 					}
 
@@ -88,19 +106,24 @@ export async function processUserContentMentions({
 					const parsedContent = await Promise.all(
 						block.content.map(async (contentBlock) => {
 							if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
+								const result = await parseMentions(
+									contentBlock.text,
+									cwd,
+									urlContentFetcher,
+									fileContextTracker,
+									rooIgnoreController,
+									showRooIgnoredFiles,
+									includeDiagnosticMessages,
+									maxDiagnosticMessages,
+									maxReadFileLine,
+								)
+								// Capture the first mode found
+								if (!commandMode && result.mode) {
+									commandMode = result.mode
+								}
 								return {
 									...contentBlock,
-									text: await parseMentions(
-										contentBlock.text,
-										cwd,
-										urlContentFetcher,
-										fileContextTracker,
-										rooIgnoreController,
-										showRooIgnoredFiles,
-										includeDiagnosticMessages,
-										maxDiagnosticMessages,
-										maxReadFileLine,
-									),
+									text: result.text,
 								}
 							}
 
@@ -117,4 +140,6 @@ export async function processUserContentMentions({
 			return block
 		}),
 	)
+
+	return { content, mode: commandMode }
 }

+ 13 - 1
src/core/task/Task.ts

@@ -2357,7 +2357,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 				maxReadFileLine = -1,
 			} = (await this.providerRef.deref()?.getState()) ?? {}
 
-			const parsedUserContent = await processUserContentMentions({
+			const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({
 				userContent: currentUserContent,
 				cwd: this.cwd,
 				urlContentFetcher: this.urlContentFetcher,
@@ -2369,6 +2369,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 				maxReadFileLine,
 			})
 
+			// Switch mode if specified in a slash command's frontmatter
+			if (slashCommandMode) {
+				const provider = this.providerRef.deref()
+				if (provider) {
+					const state = await provider.getState()
+					const targetMode = getModeBySlug(slashCommandMode, state?.customModes)
+					if (targetMode) {
+						await provider.handleModeSwitch(slashCommandMode)
+					}
+				}
+			}
+
 			const environmentDetails = await getEnvironmentDetails(this, currentIncludeFileDetails)
 
 			// Remove any existing environment_details blocks before adding fresh ones.

+ 2 - 2
src/core/task/__tests__/Task.spec.ts

@@ -132,7 +132,7 @@ vi.mock("vscode", () => {
 
 vi.mock("../../mentions", () => ({
 	parseMentions: vi.fn().mockImplementation((text) => {
-		return Promise.resolve(`processed: ${text}`)
+		return Promise.resolve({ text: `processed: ${text}`, mode: undefined })
 	}),
 	openMention: vi.fn(),
 	getLatestTerminalOutput: vi.fn(),
@@ -913,7 +913,7 @@ describe("Cline", () => {
 						} as Anthropic.ToolResultBlockParam,
 					]
 
-					const processedContent = await processUserContentMentions({
+					const { content: processedContent } = await processUserContentMentions({
 						userContent,
 						cwd: cline.cwd,
 						urlContentFetcher: cline.urlContentFetcher,

+ 15 - 0
src/core/tools/RunSlashCommandTool.ts

@@ -4,6 +4,7 @@ import { getCommand, getCommandNames } from "../../services/command/commands"
 import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
 import { BaseTool, ToolCallbacks } from "./BaseTool"
 import type { ToolUse } from "../../shared/tools"
+import { getModeBySlug } from "../../shared/modes"
 
 interface RunSlashCommandParams {
 	command: string
@@ -74,6 +75,7 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> {
 				args: args,
 				source: command.source,
 				description: command.description,
+				mode: command.mode,
 			})
 
 			const didApprove = await askApproval("tool", toolMessage)
@@ -82,6 +84,15 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> {
 				return
 			}
 
+			// Switch mode if specified in the command frontmatter
+			if (command.mode) {
+				const provider = task.providerRef.deref()
+				const targetMode = getModeBySlug(command.mode, (await provider?.getState())?.customModes)
+				if (targetMode) {
+					await provider?.handleModeSwitch(command.mode)
+				}
+			}
+
 			// Build the result message
 			let result = `Command: /${commandName}`
 
@@ -93,6 +104,10 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> {
 				result += `\nArgument hint: ${command.argumentHint}`
 			}
 
+			if (command.mode) {
+				result += `\nMode: ${command.mode}`
+			}
+
 			if (args) {
 				result += `\nProvided arguments: ${args}`
 			}

+ 129 - 0
src/core/tools/__tests__/runSlashCommandTool.spec.ts

@@ -307,4 +307,133 @@ Deploy application to production`,
 
 		expect(mockTask.consecutiveMistakeCount).toBe(0)
 	})
+
+	it("should switch mode when mode is specified in command", async () => {
+		const mockHandleModeSwitch = vi.fn()
+		const block: ToolUse<"run_slash_command"> = {
+			type: "tool_use" as const,
+			name: "run_slash_command" as const,
+			params: {
+				command: "debug-app",
+			},
+			partial: false,
+		}
+
+		const mockCommand = {
+			name: "debug-app",
+			content: "Start debugging the application",
+			source: "project" as const,
+			filePath: ".roo/commands/debug-app.md",
+			description: "Debug the application",
+			mode: "debug",
+		}
+
+		mockTask.providerRef.deref = vi.fn().mockReturnValue({
+			getState: vi.fn().mockResolvedValue({
+				experiments: {
+					runSlashCommand: true,
+				},
+				customModes: undefined,
+			}),
+			handleModeSwitch: mockHandleModeSwitch,
+		})
+
+		vi.mocked(getCommand).mockResolvedValue(mockCommand)
+
+		await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks)
+
+		expect(mockHandleModeSwitch).toHaveBeenCalledWith("debug")
+		expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(
+			`Command: /debug-app
+Description: Debug the application
+Mode: debug
+Source: project
+
+--- Command Content ---
+
+Start debugging the application`,
+		)
+	})
+
+	it("should not switch mode when mode is not specified in command", async () => {
+		const mockHandleModeSwitch = vi.fn()
+		const block: ToolUse<"run_slash_command"> = {
+			type: "tool_use" as const,
+			name: "run_slash_command" as const,
+			params: {
+				command: "test",
+			},
+			partial: false,
+		}
+
+		const mockCommand = {
+			name: "test",
+			content: "Run tests",
+			source: "project" as const,
+			filePath: ".roo/commands/test.md",
+			description: "Run project tests",
+		}
+
+		mockTask.providerRef.deref = vi.fn().mockReturnValue({
+			getState: vi.fn().mockResolvedValue({
+				experiments: {
+					runSlashCommand: true,
+				},
+				customModes: undefined,
+			}),
+			handleModeSwitch: mockHandleModeSwitch,
+		})
+
+		vi.mocked(getCommand).mockResolvedValue(mockCommand)
+
+		await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks)
+
+		expect(mockHandleModeSwitch).not.toHaveBeenCalled()
+	})
+
+	it("should include mode in askApproval message when mode is specified", async () => {
+		const block: ToolUse<"run_slash_command"> = {
+			type: "tool_use" as const,
+			name: "run_slash_command" as const,
+			params: {
+				command: "debug-app",
+			},
+			partial: false,
+		}
+
+		const mockCommand = {
+			name: "debug-app",
+			content: "Start debugging",
+			source: "project" as const,
+			filePath: ".roo/commands/debug-app.md",
+			description: "Debug the application",
+			mode: "debug",
+		}
+
+		mockTask.providerRef.deref = vi.fn().mockReturnValue({
+			getState: vi.fn().mockResolvedValue({
+				experiments: {
+					runSlashCommand: true,
+				},
+				customModes: undefined,
+			}),
+			handleModeSwitch: vi.fn(),
+		})
+
+		vi.mocked(getCommand).mockResolvedValue(mockCommand)
+
+		await runSlashCommandTool.handle(mockTask as Task, block, mockCallbacks)
+
+		expect(mockCallbacks.askApproval).toHaveBeenCalledWith(
+			"tool",
+			JSON.stringify({
+				tool: "runSlashCommand",
+				command: "debug-app",
+				args: undefined,
+				source: "project",
+				description: "Debug the application",
+				mode: "debug",
+			}),
+		)
+	})
 })

+ 78 - 0
src/services/command/__tests__/frontmatter-commands.spec.ts

@@ -49,6 +49,7 @@ npm run build
 				filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"),
 				description: "Sets up the development environment",
 				argumentHint: undefined,
+				mode: undefined,
 			})
 		})
 
@@ -73,6 +74,7 @@ npm run build
 				filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"),
 				description: undefined,
 				argumentHint: undefined,
+				mode: undefined,
 			})
 		})
 
@@ -116,6 +118,7 @@ Command content here.`
 				filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"),
 				description: undefined,
 				argumentHint: undefined,
+				mode: undefined,
 			})
 		})
 
@@ -151,6 +154,7 @@ Global setup instructions.`
 				filePath: path.join("/test/cwd", ".roo", "commands", "setup.md"),
 				description: "Project-specific setup",
 				argumentHint: undefined,
+				mode: undefined,
 			})
 		})
 
@@ -178,6 +182,7 @@ Global setup instructions.`
 				filePath: expect.stringContaining(path.join(".roo", "commands", "setup.md")),
 				description: "Global setup command",
 				argumentHint: undefined,
+				mode: undefined,
 			})
 		})
 	})
@@ -205,6 +210,7 @@ Create a new release.`
 				filePath: path.join("/test/cwd", ".roo", "commands", "release.md"),
 				description: "Create a new release of the Roo Code extension",
 				argumentHint: "patch | minor | major",
+				mode: undefined,
 			})
 		})
 
@@ -231,6 +237,7 @@ Deploy the application.`
 				filePath: path.join("/test/cwd", ".roo", "commands", "deploy.md"),
 				description: "Deploy application to environment",
 				argumentHint: "staging | production",
+				mode: undefined,
 			})
 		})
 
@@ -287,6 +294,77 @@ Test content.`
 
 			expect(result?.argumentHint).toBeUndefined()
 		})
+
+		it("should load command with mode from frontmatter", async () => {
+			const commandContent = `---
+description: Debug the application
+mode: debug
+---
+
+# Debug Command
+
+Start debugging.`
+
+			mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
+			mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
+
+			const result = await getCommand("/test/cwd", "debug-app")
+
+			expect(result).toEqual({
+				name: "debug-app",
+				content: "# Debug Command\n\nStart debugging.",
+				source: "project",
+				filePath: path.join("/test/cwd", ".roo", "commands", "debug-app.md"),
+				description: "Debug the application",
+				argumentHint: undefined,
+				mode: "debug",
+			})
+		})
+
+		it("should handle empty mode in frontmatter", async () => {
+			const commandContent = `---
+description: Test command
+mode: ""
+---
+
+# Test Command
+
+Test content.`
+
+			mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
+			mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
+
+			const result = await getCommand("/test/cwd", "test")
+
+			expect(result?.mode).toBeUndefined()
+		})
+
+		it("should handle command with description, argument-hint, and mode", async () => {
+			const commandContent = `---
+description: Deploy to environment
+argument-hint: staging | production
+mode: code
+---
+
+# Deploy Command
+
+Deploy the application.`
+
+			mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true })
+			mockFs.readFile = vi.fn().mockResolvedValue(commandContent)
+
+			const result = await getCommand("/test/cwd", "deploy")
+
+			expect(result).toEqual({
+				name: "deploy",
+				content: "# Deploy Command\n\nDeploy the application.",
+				source: "project",
+				filePath: path.join("/test/cwd", ".roo", "commands", "deploy.md"),
+				description: "Deploy to environment",
+				argumentHint: "staging | production",
+				mode: "code",
+			})
+		})
 	})
 
 	describe("getCommands with frontmatter", () => {

+ 12 - 0
src/services/command/commands.ts

@@ -17,6 +17,7 @@ export interface Command {
 	filePath: string
 	description?: string
 	argumentHint?: string
+	mode?: string
 }
 
 /**
@@ -215,6 +216,7 @@ async function tryLoadCommand(
 		let parsed
 		let description: string | undefined
 		let argumentHint: string | undefined
+		let mode: string | undefined
 		let commandContent: string
 
 		try {
@@ -228,11 +230,13 @@ async function tryLoadCommand(
 				typeof parsed.data["argument-hint"] === "string" && parsed.data["argument-hint"].trim()
 					? parsed.data["argument-hint"].trim()
 					: undefined
+			mode = typeof parsed.data.mode === "string" && parsed.data.mode.trim() ? parsed.data.mode.trim() : undefined
 			commandContent = parsed.content.trim()
 		} catch {
 			// If frontmatter parsing fails, treat the entire content as command content
 			description = undefined
 			argumentHint = undefined
+			mode = undefined
 			commandContent = content.trim()
 		}
 
@@ -243,6 +247,7 @@ async function tryLoadCommand(
 			filePath: resolvedPath,
 			description,
 			argumentHint,
+			mode,
 		}
 	} catch {
 		// Directory doesn't exist or can't be read
@@ -296,6 +301,7 @@ async function scanCommandDirectory(
 				let parsed
 				let description: string | undefined
 				let argumentHint: string | undefined
+				let mode: string | undefined
 				let commandContent: string
 
 				try {
@@ -309,11 +315,16 @@ async function scanCommandDirectory(
 						typeof parsed.data["argument-hint"] === "string" && parsed.data["argument-hint"].trim()
 							? parsed.data["argument-hint"].trim()
 							: undefined
+					mode =
+						typeof parsed.data.mode === "string" && parsed.data.mode.trim()
+							? parsed.data.mode.trim()
+							: undefined
 					commandContent = parsed.content.trim()
 				} catch {
 					// If frontmatter parsing fails, treat the entire content as command content
 					description = undefined
 					argumentHint = undefined
+					mode = undefined
 					commandContent = content.trim()
 				}
 
@@ -326,6 +337,7 @@ async function scanCommandDirectory(
 						filePath: resolvedPath,
 						description,
 						argumentHint,
+						mode,
 					})
 				}
 			} catch (error) {