diff --git a/examples/hidden-submodes.roomodes b/examples/hidden-submodes.roomodes new file mode 100644 index 0000000000..70dfdf6aa1 --- /dev/null +++ b/examples/hidden-submodes.roomodes @@ -0,0 +1,91 @@ +# Example .roomodes file demonstrating hidden submodes feature +# Hidden modes are not shown in the mode selector dropdown but can be accessed +# programmatically by their parent mode using the switch_mode tool + +customModes: + # Parent mode that can delegate to specialized submodes + - slug: complex-task-handler + name: ๐ŸŽฏ Complex Task Handler + roleDefinition: |- + You are a complex task handler that breaks down large tasks into specialized subtasks. + You can delegate specific work to hidden specialized submodes. + whenToUse: Use this mode for complex tasks that require multiple specialized approaches + description: Handles complex multi-step tasks + groups: + - read + - edit + - command + customInstructions: |- + When handling complex tasks: + 1. Analyze the task requirements + 2. Identify specialized subtasks + 3. Use switch_mode to delegate to appropriate hidden submodes: + - data-analyzer: For data analysis tasks + - code-generator: For code generation tasks + - test-writer: For test writing tasks + 4. Coordinate results from submodes + 5. Provide comprehensive solution + + # Hidden submode for data analysis (only accessible from complex-task-handler) + - slug: data-analyzer + name: ๐Ÿ“Š Data Analyzer + roleDefinition: Specialized mode for analyzing data structures and patterns + description: Analyzes data and provides insights + groups: + - read + hidden: true + parent: complex-task-handler + customInstructions: |- + Focus exclusively on data analysis: + - Examine data structures + - Identify patterns + - Generate insights + - Return findings to parent mode + + # Hidden submode for code generation (only accessible from complex-task-handler) + - slug: code-generator + name: โš™๏ธ Code Generator + roleDefinition: Specialized mode for generating optimized code + description: Generates code based on specifications + groups: + - read + - edit + hidden: true + parent: complex-task-handler + customInstructions: |- + Focus exclusively on code generation: + - Generate clean, efficient code + - Follow best practices + - Add appropriate comments + - Return to parent mode when complete + + # Hidden submode for test writing (only accessible from complex-task-handler) + - slug: test-writer + name: ๐Ÿงช Test Writer + roleDefinition: Specialized mode for writing comprehensive tests + description: Writes unit and integration tests + groups: + - read + - edit + hidden: true + parent: complex-task-handler + customInstructions: |- + Focus exclusively on test creation: + - Write comprehensive test cases + - Cover edge cases + - Ensure good test coverage + - Return to parent mode when complete + + # Regular visible mode (shown in dropdown) + - slug: documentation-mode + name: ๐Ÿ“š Documentation + roleDefinition: Mode for creating and updating documentation + description: Creates and maintains documentation + groups: + - read + - edit + customInstructions: |- + Focus on documentation tasks: + - Write clear, comprehensive docs + - Update existing documentation + - Create examples and tutorials \ No newline at end of file diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts index 88dcbb9574..dbb99e7498 100644 --- a/packages/types/src/mode.ts +++ b/packages/types/src/mode.ts @@ -70,6 +70,8 @@ export const modeConfigSchema = z.object({ customInstructions: z.string().optional(), groups: groupEntryArraySchema, source: z.enum(["global", "project"]).optional(), + hidden: z.boolean().optional(), + parent: z.string().optional(), }) export type ModeConfig = z.infer diff --git a/src/core/tools/SwitchModeTool.ts b/src/core/tools/SwitchModeTool.ts index df418cfdfc..fee1df9bf1 100644 --- a/src/core/tools/SwitchModeTool.ts +++ b/src/core/tools/SwitchModeTool.ts @@ -35,8 +35,11 @@ export class SwitchModeTool extends BaseTool<"switch_mode"> { task.consecutiveMistakeCount = 0 - // Verify the mode exists - const targetMode = getModeBySlug(mode_slug, (await task.providerRef.deref()?.getState())?.customModes) + const state = await task.providerRef.deref()?.getState() + const customModes = state?.customModes + + // Verify the mode exists (including hidden modes) + const targetMode = getModeBySlug(mode_slug, customModes) if (!targetMode) { task.recordToolError("switch_mode") @@ -45,7 +48,22 @@ export class SwitchModeTool extends BaseTool<"switch_mode"> { } // Check if already in requested mode - const currentMode = (await task.providerRef.deref()?.getState())?.mode ?? defaultModeSlug + const currentMode = state?.mode ?? defaultModeSlug + const currentModeConfig = getModeBySlug(currentMode, customModes) + + // Check if the target mode is hidden + if (targetMode.hidden) { + // Hidden modes can only be accessed by their parent mode + if (!targetMode.parent || targetMode.parent !== currentMode) { + task.recordToolError("switch_mode") + pushToolResult( + formatResponse.toolError( + `Mode '${mode_slug}' is not accessible from the current mode '${currentMode}'.`, + ), + ) + return + } + } if (currentMode === mode_slug) { task.recordToolError("switch_mode") @@ -64,7 +82,7 @@ export class SwitchModeTool extends BaseTool<"switch_mode"> { await task.providerRef.deref()?.handleModeSwitch(mode_slug) pushToolResult( - `Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${ + `Successfully switched from ${currentModeConfig?.name ?? currentMode} mode to ${ targetMode.name } mode${reason ? ` because: ${reason}` : ""}.`, ) diff --git a/src/shared/__tests__/modes.hidden.spec.ts b/src/shared/__tests__/modes.hidden.spec.ts new file mode 100644 index 0000000000..2120e84301 --- /dev/null +++ b/src/shared/__tests__/modes.hidden.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest" +import { type ModeConfig } from "@roo-code/types" +import { getAllModes, getModeBySlug } from "../modes" + +describe("Hidden Modes", () => { + const customModes: ModeConfig[] = [ + { + slug: "parent-mode", + name: "Parent Mode", + roleDefinition: "Parent mode role", + groups: ["read", "edit"], + }, + { + slug: "hidden-submode", + name: "Hidden Submode", + roleDefinition: "Hidden submode role", + groups: ["read"], + hidden: true, + parent: "parent-mode", + }, + { + slug: "visible-mode", + name: "Visible Mode", + roleDefinition: "Visible mode role", + groups: ["read"], + }, + ] + + describe("getAllModes", () => { + it("should exclude hidden modes by default", () => { + const modes = getAllModes(customModes) + const slugs = modes.map((m) => m.slug) + + expect(slugs).toContain("parent-mode") + expect(slugs).toContain("visible-mode") + expect(slugs).not.toContain("hidden-submode") + }) + + it("should include hidden modes when includeHidden is true", () => { + const modes = getAllModes(customModes, true) + const slugs = modes.map((m) => m.slug) + + expect(slugs).toContain("parent-mode") + expect(slugs).toContain("visible-mode") + expect(slugs).toContain("hidden-submode") + }) + + it("should return all built-in modes when no custom modes provided", () => { + const modes = getAllModes() + expect(modes.length).toBeGreaterThan(0) + expect(modes.every((m) => !m.hidden)).toBe(true) + }) + + it("should override built-in modes with custom modes of same slug", () => { + const customWithOverride: ModeConfig[] = [ + { + slug: "code", + name: "Custom Code Mode", + roleDefinition: "Custom code role", + groups: ["read"], + }, + ] + + const modes = getAllModes(customWithOverride) + const codeMode = modes.find((m) => m.slug === "code") + + expect(codeMode?.name).toBe("Custom Code Mode") + }) + }) + + describe("getModeBySlug", () => { + it("should find hidden modes", () => { + const mode = getModeBySlug("hidden-submode", customModes) + expect(mode).toBeDefined() + expect(mode?.hidden).toBe(true) + expect(mode?.parent).toBe("parent-mode") + }) + + it("should find visible modes", () => { + const mode = getModeBySlug("parent-mode", customModes) + expect(mode).toBeDefined() + expect(mode?.hidden).toBeUndefined() + }) + + it("should return undefined for non-existent mode", () => { + const mode = getModeBySlug("non-existent", customModes) + expect(mode).toBeUndefined() + }) + }) + + describe("Hidden mode parent-child relationships", () => { + it("should correctly identify parent-child relationships", () => { + const hiddenMode = getModeBySlug("hidden-submode", customModes) + const parentMode = getModeBySlug("parent-mode", customModes) + + expect(hiddenMode?.parent).toBe(parentMode?.slug) + }) + + it("should allow multiple hidden modes with same parent", () => { + const modesWithMultipleChildren: ModeConfig[] = [ + ...customModes, + { + slug: "hidden-submode-2", + name: "Hidden Submode 2", + roleDefinition: "Hidden submode 2 role", + groups: ["read"], + hidden: true, + parent: "parent-mode", + }, + ] + + const modes = getAllModes(modesWithMultipleChildren, true) + const hiddenModes = modes.filter((m) => m.hidden && m.parent === "parent-mode") + + expect(hiddenModes.length).toBe(2) + }) + }) +}) diff --git a/src/shared/modes.ts b/src/shared/modes.ts index f68d25c682..80d35b1e2b 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -86,7 +86,7 @@ export function getModeConfig(slug: string, customModes?: ModeConfig[]): ModeCon } // Get all available modes, with custom modes overriding built-in modes -export function getAllModes(customModes?: ModeConfig[]): ModeConfig[] { +export function getAllModes(customModes?: ModeConfig[], includeHidden: boolean = false): ModeConfig[] { if (!customModes?.length) { return [...modes] } @@ -106,6 +106,11 @@ export function getAllModes(customModes?: ModeConfig[]): ModeConfig[] { } }) + // Filter out hidden modes unless explicitly requested + if (!includeHidden) { + return allModes.filter((mode) => !mode.hidden) + } + return allModes } @@ -284,11 +289,14 @@ export const defaultPrompts: Readonly = Object.freeze( ) // Helper function to get all modes with their prompt overrides from extension state -export async function getAllModesWithPrompts(context: vscode.ExtensionContext): Promise { +export async function getAllModesWithPrompts( + context: vscode.ExtensionContext, + includeHidden: boolean = false, +): Promise { const customModes = (await context.globalState.get("customModes")) || [] const customModePrompts = (await context.globalState.get("customModePrompts")) || {} - const allModes = getAllModes(customModes) + const allModes = getAllModes(customModes, includeHidden) return allModes.map((mode) => ({ ...mode, roleDefinition: customModePrompts[mode.slug]?.roleDefinition ?? mode.roleDefinition, diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 0b8c89388c..4309a707b2 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -245,7 +245,7 @@ export const ChatTextArea = forwardRef( } }, [inputValue, setInputValue, t]) - const allModes = useMemo(() => getAllModes(customModes), [customModes]) + const allModes = useMemo(() => getAllModes(customModes, false), [customModes]) // Memoized check for whether the input has content (text or images) const hasInputContent = useMemo(() => { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 9adf603ee4..0c88cc9370 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1289,7 +1289,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const allModes = getAllModes(customModes) + const allModes = getAllModes(customModes, false) const currentModeIndex = allModes.findIndex((m) => m.slug === mode) const nextModeIndex = (currentModeIndex + 1) % allModes.length // Update local state and notify extension to sync mode change @@ -1298,7 +1298,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const allModes = getAllModes(customModes) + const allModes = getAllModes(customModes, false) const currentModeIndex = allModes.findIndex((m) => m.slug === mode) const previousModeIndex = (currentModeIndex - 1 + allModes.length) % allModes.length // Update local state and notify extension to sync mode change diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 3f843344a2..2cd695d42c 100644 --- a/webview-ui/src/components/chat/ModeSelector.tsx +++ b/webview-ui/src/components/chat/ModeSelector.tsx @@ -63,7 +63,8 @@ export const ModeSelector = ({ // Get all modes including custom modes and merge custom prompt descriptions. const modes = React.useMemo(() => { - const allModes = getAllModes(customModes) + // Don't include hidden modes in the selector dropdown + const allModes = getAllModes(customModes, false) return allModes.map((mode) => ({ ...mode, diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index b0091c15b7..bb42f6a22d 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -88,7 +88,8 @@ const ModesView = ({ onDone }: ModesViewProps) => { const [visualMode, setVisualMode] = useState(mode) // Build modes fresh each render so search reflects inline rename updates immediately - const modes = getAllModes(customModes) + // Include hidden modes in the settings view so they can be managed + const modes = getAllModes(customModes, true) const [isDialogOpen, setIsDialogOpen] = useState(false) const [selectedPromptContent, setSelectedPromptContent] = useState("") @@ -537,8 +538,8 @@ const ModesView = ({ onDone }: ModesViewProps) => { if (message.success) { const { slug } = message as ImportModeResult if (slug) { - // Try switching using the freshest mode list available - const all = getAllModes(customModesRef.current) + // Try switching using the freshest mode list available (include hidden modes) + const all = getAllModes(customModesRef.current, true) const importedMode = all.find((m) => m.slug === slug) if (importedMode) { handleModeSwitchRef.current(importedMode)