feat: add hidden submodes functionality for complex Roo modes

- Add hidden and parent fields to ModeConfig type
- Update getAllModes to support filtering hidden modes
- Hide submodes from mode selector dropdowns
- Add parent mode validation in switch_mode tool
- Include hidden modes in settings view for management
- Add comprehensive tests for hidden mode functionality
- Create example .roomodes file demonstrating usage

Closes #9446
This commit is contained in:
Roo Code 2025-11-20 21:28:12 +00:00
parent 1589cc1849
commit 4fc71f790b
9 changed files with 253 additions and 14 deletions

View file

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

View file

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

View file

@ -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}` : ""}.`,
)

View file

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

View file

@ -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<CustomModePrompts> = Object.freeze(
)
// Helper function to get all modes with their prompt overrides from extension state
export async function getAllModesWithPrompts(context: vscode.ExtensionContext): Promise<ModeConfig[]> {
export async function getAllModesWithPrompts(
context: vscode.ExtensionContext,
includeHidden: boolean = false,
): Promise<ModeConfig[]> {
const customModes = (await context.globalState.get<ModeConfig[]>("customModes")) || []
const customModePrompts = (await context.globalState.get<CustomModePrompts>("customModePrompts")) || {}
const allModes = getAllModes(customModes)
const allModes = getAllModes(customModes, includeHidden)
return allModes.map((mode) => ({
...mode,
roleDefinition: customModePrompts[mode.slug]?.roleDefinition ?? mode.roleDefinition,

View file

@ -245,7 +245,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
}, [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(() => {

View file

@ -1289,7 +1289,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// Function to handle mode switching
const switchToNextMode = useCallback(() => {
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<ChatViewRef, ChatViewPro
// Function to handle switching to previous mode
const switchToPreviousMode = useCallback(() => {
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

View file

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

View file

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