diff --git a/implementation-plan.md b/implementation-plan.md new file mode 100644 index 0000000000..e158da3ae6 --- /dev/null +++ b/implementation-plan.md @@ -0,0 +1,112 @@ +# Implementation Plan: Add Optional Mode Parameter for Slash Commands + +## Overview + +Add support for an optional `mode` parameter in slash command markdown files that will automatically trigger a mode switch when the slash command is executed. + +## Current Architecture Understanding + +### 1. Command System + +- Commands are stored as markdown files in `.roo/commands/` directory +- Commands support frontmatter with `description` and `argument-hint` fields +- Commands are loaded by `src/services/command/commands.ts` +- Command interface is defined in `src/services/command/commands.ts` + +### 2. Slash Command Flow + +- User types `/command` in the chat +- `ChatTextArea` component shows autocomplete menu with available commands +- When selected, the command text is inserted into the input +- Commands are processed when the message is sent + +### 3. Mode Switching + +- Modes can be switched via the mode selector dropdown +- Mode switching sends a `mode` message to the backend via `vscode.postMessage` +- The `setMode` function updates the current mode state + +## Implementation Steps + +### Step 1: Update Command Interface + +**File:** `src/services/command/commands.ts` + +- Add optional `mode?: string` field to the `Command` interface +- Update the frontmatter parsing to extract the `mode` field + +### Step 2: Update Command Loading + +**File:** `src/services/command/commands.ts` + +- Modify `scanCommandDirectory` and `tryLoadCommand` functions +- Parse the `mode` field from frontmatter (similar to `description` and `argument-hint`) + +### Step 3: Update Frontend Command Handling + +**File:** `webview-ui/src/components/chat/ChatTextArea.tsx` + +- Modify the `handleMentionSelect` function for `ContextMenuOptionType.Command` +- Check if the selected command has a `mode` property +- If it does, trigger mode switch before inserting the command + +### Step 4: Pass Mode Information to Frontend + +**File:** `src/core/webview/webviewMessageHandler.ts` + +- Update the command list sent to frontend to include the `mode` field +- Ensure the `Command` type in `src/shared/ExtensionMessage.ts` includes the mode field + +### Step 5: Update Context Menu + +**File:** `webview-ui/src/utils/context-mentions.ts` + +- Ensure the command's mode is passed through when creating menu options +- Update the `ContextMenuQueryItem` type if needed + +## Example Usage + +A command markdown file with mode specification: + +```markdown +--- +description: Deploy the application to production +argument-hint: +mode: architect +--- + +# Deploy Command + +This command helps you deploy the application... +``` + +When this command is selected: + +1. The mode automatically switches to "architect" +2. The command `/deploy` is inserted into the input +3. The user can continue typing arguments + +## Testing Requirements + +1. **Unit Tests:** + + - Test command loading with mode parameter + - Test command loading without mode parameter (backward compatibility) + - Test mode switching when command is selected + +2. **Integration Tests:** + - Test full flow from command selection to mode switch + - Test that commands without mode don't trigger mode switch + - Test that invalid mode values are handled gracefully + +## Backward Compatibility + +- Commands without the `mode` field should work as before +- Existing command files don't need to be updated +- The feature is entirely optional + +## Benefits + +1. **Improved UX:** Users don't need to manually switch modes for mode-specific commands +2. **Workflow Optimization:** Commands can be pre-configured for the most appropriate mode +3. **Discoverability:** Users learn which modes are best for which commands diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e2c6d6a475..74c7bed10b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2422,6 +2422,7 @@ export const webviewMessageHandler = async ( filePath: command.filePath, description: command.description, argumentHint: command.argumentHint, + mode: command.mode, })) await provider.postMessageToWebview({ @@ -2581,6 +2582,8 @@ export const webviewMessageHandler = async ( source: command.source, filePath: command.filePath, description: command.description, + argumentHint: command.argumentHint, + mode: command.mode, })) await provider.postMessageToWebview({ type: "commands", diff --git a/src/services/command/__tests__/command-mode.spec.ts b/src/services/command/__tests__/command-mode.spec.ts new file mode 100644 index 0000000000..6a2a2ded41 --- /dev/null +++ b/src/services/command/__tests__/command-mode.spec.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import fs from "fs/promises" +import * as path from "path" +import { getCommand, getCommands } from "../commands" + +// Mock fs and path modules +vi.mock("fs/promises") +vi.mock("../roo-config", () => ({ + getGlobalRooDirectory: vi.fn(() => "/mock/global/.roo"), + getProjectRooDirectoryForCwd: vi.fn(() => "/mock/project/.roo"), +})) + +const mockFs = vi.mocked(fs) + +describe("Command mode parameter", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("getCommand with mode parameter", () => { + it("should parse mode from frontmatter", async () => { + const commandContent = `--- +description: Deploy the application +argument-hint: +mode: architect +--- + +# Deploy Command + +This command helps you deploy the application.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readFile = vi.fn().mockResolvedValue(commandContent) + + const command = await getCommand("/test/cwd", "deploy") + + expect(command).toBeDefined() + expect(command?.name).toBe("deploy") + expect(command?.description).toBe("Deploy the application") + expect(command?.argumentHint).toBe("") + expect(command?.mode).toBe("architect") + }) + + it("should handle commands without mode parameter", async () => { + const commandContent = `--- +description: Test command +argument-hint: +--- + +# Test Command + +This is a test command without mode.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readFile = vi.fn().mockResolvedValue(commandContent) + + const command = await getCommand("/test/cwd", "test") + + expect(command).toBeDefined() + expect(command?.name).toBe("test") + expect(command?.description).toBe("Test command") + expect(command?.mode).toBeUndefined() + }) + + it("should handle commands with empty mode parameter", async () => { + const commandContent = `--- +description: Test command +mode: "" +--- + +# Test Command + +This is a test command with empty mode.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readFile = vi.fn().mockResolvedValue(commandContent) + + const command = await getCommand("/test/cwd", "test") + + expect(command).toBeDefined() + expect(command?.mode).toBeUndefined() + }) + + it("should trim whitespace from mode values", async () => { + const commandContent = `--- +description: Test command +mode: " code " +--- + +# Test Command + +This is a test command with whitespace in mode.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readFile = vi.fn().mockResolvedValue(commandContent) + + const command = await getCommand("/test/cwd", "test") + + expect(command?.mode).toBe("code") + }) + + it("should handle non-string mode values", async () => { + const commandContent = `--- +description: Test command +mode: 123 +--- + +# Test Command + +This is a test command with non-string mode.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readFile = vi.fn().mockResolvedValue(commandContent) + + const command = await getCommand("/test/cwd", "test") + + expect(command?.mode).toBeUndefined() + }) + }) + + describe("getCommands with mode parameter", () => { + it("should include mode parameter in command list", async () => { + const deployContent = `--- +description: Deploy the application +mode: architect +--- + +# Deploy Command + +Deploy instructions.` + + const testContent = `--- +description: Test command +mode: debug +--- + +# Test Command + +Test instructions.` + + const simpleContent = `--- +description: Simple command +--- + +# Simple Command + +Simple instructions without mode.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readdir = vi.fn().mockResolvedValue([ + { name: "deploy.md", isFile: () => true }, + { name: "test.md", isFile: () => true }, + { name: "simple.md", isFile: () => true }, + ]) + mockFs.readFile = vi + .fn() + .mockResolvedValueOnce(deployContent) + .mockResolvedValueOnce(testContent) + .mockResolvedValueOnce(simpleContent) + + const commands = await getCommands("/test/cwd") + + expect(commands).toHaveLength(3) + + const deployCmd = commands.find((c) => c.name === "deploy") + expect(deployCmd?.mode).toBe("architect") + + const testCmd = commands.find((c) => c.name === "test") + expect(testCmd?.mode).toBe("debug") + + const simpleCmd = commands.find((c) => c.name === "simple") + expect(simpleCmd?.mode).toBeUndefined() + }) + + it("should handle invalid mode values gracefully", async () => { + const commandContent = `--- +description: Test command +mode: [1, 2, 3] +--- + +# Test Command + +Test content.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + mockFs.readdir = vi.fn().mockResolvedValue([{ name: "test.md", isFile: () => true }]) + mockFs.readFile = vi.fn().mockResolvedValue(commandContent) + + const commands = await getCommands("/test/cwd") + + expect(commands).toHaveLength(1) + // Mode should be undefined since it's not a string + expect(commands[0].mode).toBeUndefined() + }) + }) + + describe("Project commands override global commands with mode", () => { + it("should use project command mode over global command", async () => { + const projectDeployContent = `--- +description: Project deploy +mode: architect +--- + +# Project Deploy + +Project-specific deploy.` + + const globalDeployContent = `--- +description: Global deploy +mode: code +--- + +# Global Deploy + +Global deploy.` + + mockFs.stat = vi.fn().mockResolvedValue({ isDirectory: () => true }) + + // Mock readdir for both global and project directories + mockFs.readdir = vi.fn().mockImplementation((dirPath) => { + if (dirPath.includes("global")) { + return Promise.resolve([{ name: "deploy.md", isFile: () => true }]) + } else { + return Promise.resolve([{ name: "deploy.md", isFile: () => true }]) + } + }) + + // Mock readFile for both global and project files + mockFs.readFile = vi.fn().mockImplementation((filePath) => { + if (filePath.includes("global")) { + return Promise.resolve(globalDeployContent) + } else { + return Promise.resolve(projectDeployContent) + } + }) + + const commands = await getCommands("/test/cwd") + + // Should only have one deploy command (project overrides global) + const deployCommands = commands.filter((c) => c.name === "deploy") + expect(deployCommands).toHaveLength(1) + expect(deployCommands[0].mode).toBe("architect") + expect(deployCommands[0].source).toBe("project") + }) + }) +}) diff --git a/src/services/command/commands.ts b/src/services/command/commands.ts index 452269511c..9d348984cb 100644 --- a/src/services/command/commands.ts +++ b/src/services/command/commands.ts @@ -10,6 +10,7 @@ export interface Command { filePath: string description?: string argumentHint?: string + mode?: string } /** @@ -72,6 +73,7 @@ async function tryLoadCommand( let parsed let description: string | undefined let argumentHint: string | undefined + let mode: string | undefined let commandContent: string try { @@ -85,11 +87,16 @@ 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 (frontmatterError) { // If frontmatter parsing fails, treat the entire content as command content description = undefined argumentHint = undefined + mode = undefined commandContent = content.trim() } @@ -100,6 +107,7 @@ async function tryLoadCommand( filePath, description, argumentHint, + mode, } } catch (error) { // File doesn't exist or can't be read @@ -146,6 +154,7 @@ async function scanCommandDirectory( let parsed let description: string | undefined let argumentHint: string | undefined + let mode: string | undefined let commandContent: string try { @@ -159,11 +168,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 (frontmatterError) { // If frontmatter parsing fails, treat the entire content as command content description = undefined argumentHint = undefined + mode = undefined commandContent = content.trim() } @@ -176,6 +190,7 @@ async function scanCommandDirectory( filePath, description, argumentHint, + mode, }) } } catch (error) { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 3ddd69945c..a1e0c498d5 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -26,6 +26,7 @@ export interface Command { filePath?: string description?: string argumentHint?: string + mode?: string } // Type for marketplace installed metadata diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 5135eca2f2..7228e8dc8d 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -308,6 +308,14 @@ const ChatTextArea = forwardRef( setInputValue("") setShowContextMenu(false) + // Check if the command has a mode specified + const selectedCommand = commands?.find((cmd) => cmd.name === value) + if (selectedCommand?.mode) { + // Switch to the specified mode + setMode(selectedCommand.mode) + vscode.postMessage({ type: "mode", text: selectedCommand.mode }) + } + // Insert the command mention into the textarea const commandMention = `/${value}` setInputValue(commandMention + " ") diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 217373c21b..3cbcc3904c 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -164,6 +164,7 @@ export function getContextMenuOptions( slashCommand: `/${command.name}`, description: command.description, argumentHint: command.argumentHint, + mode: command.mode, })) if (matchingCommands.length > 0) {