From c88b6a8c68847f4881404d54284c501c121fcd2c Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sat, 22 Nov 2025 08:55:36 +0000 Subject: [PATCH] feat: support compound slug names for submodes - Update ModeConfig schema to allow slash in slugs (parent/child format) - Add hidden and parent fields to ModeConfig for submode support - Update getModeBySlug to handle compound slug matching - Add getVisibleModes and getSubmodes helper functions - Add comprehensive tests for compound slug functionality Part of #9446 to enable hidden/submodes feature --- packages/types/src/mode.ts | 6 +- .../__tests__/modes-compound-slugs.spec.ts | 158 ++++++++++++++++++ src/shared/modes.ts | 35 +++- 3 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 src/shared/__tests__/modes-compound-slugs.spec.ts diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts index 88dcbb9574..db7ff0598f 100644 --- a/packages/types/src/mode.ts +++ b/packages/types/src/mode.ts @@ -62,7 +62,9 @@ const groupEntryArraySchema = z.array(groupEntrySchema).refine( ) export const modeConfigSchema = z.object({ - slug: z.string().regex(/^[a-zA-Z0-9-]+$/, "Slug must contain only letters numbers and dashes"), + slug: z + .string() + .regex(/^[a-zA-Z0-9-]+(\/[a-zA-Z0-9-]+)?$/, "Slug must be in format 'slug' or 'parent-slug/child-slug'"), name: z.string().min(1, "Name is required"), roleDefinition: z.string().min(1, "Role definition is required"), whenToUse: z.string().optional(), @@ -70,6 +72,8 @@ export const modeConfigSchema = z.object({ customInstructions: z.string().optional(), groups: groupEntryArraySchema, source: z.enum(["global", "project"]).optional(), + hidden: z.boolean().optional(), // Hide mode from dropdown selector + parent: z.string().optional(), // Parent mode slug for submodes }) export type ModeConfig = z.infer diff --git a/src/shared/__tests__/modes-compound-slugs.spec.ts b/src/shared/__tests__/modes-compound-slugs.spec.ts new file mode 100644 index 0000000000..7319b7b695 --- /dev/null +++ b/src/shared/__tests__/modes-compound-slugs.spec.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from "vitest" +import { type ModeConfig } from "@roo-code/types" +import { getModeBySlug, getVisibleModes, getSubmodes } from "../modes" + +describe("Compound Mode Slugs", () => { + const mockCustomModes: ModeConfig[] = [ + { + slug: "parent-mode", + name: "Parent Mode", + roleDefinition: "Parent mode definition", + groups: ["read"], + }, + { + slug: "parent-mode/submode1", + name: "Submode 1", + roleDefinition: "Submode 1 definition", + groups: ["read"], + hidden: true, + parent: "parent-mode", + }, + { + slug: "parent-mode/submode2", + name: "Submode 2", + roleDefinition: "Submode 2 definition", + groups: ["edit"], + hidden: true, + parent: "parent-mode", + }, + { + slug: "regular-mode", + name: "Regular Mode", + roleDefinition: "Regular mode definition", + groups: ["read"], + }, + ] + + describe("getModeBySlug", () => { + it("should find mode with simple slug", () => { + const mode = getModeBySlug("parent-mode", mockCustomModes) + expect(mode).toBeDefined() + expect(mode?.slug).toBe("parent-mode") + }) + + it("should find mode with compound slug (parent/child format)", () => { + const mode = getModeBySlug("parent-mode/submode1", mockCustomModes) + expect(mode).toBeDefined() + expect(mode?.slug).toBe("parent-mode/submode1") + expect(mode?.parent).toBe("parent-mode") + }) + + it("should return undefined for non-existent compound slug", () => { + const mode = getModeBySlug("parent-mode/non-existent", mockCustomModes) + expect(mode).toBeUndefined() + }) + + it("should prioritize exact match over compound slug parsing", () => { + const mode = getModeBySlug("regular-mode", mockCustomModes) + expect(mode).toBeDefined() + expect(mode?.slug).toBe("regular-mode") + }) + }) + + describe("getVisibleModes", () => { + it("should exclude hidden submodes from visible modes", () => { + const visibleModes = getVisibleModes(mockCustomModes) + + // Should include built-in modes plus custom non-hidden modes + // Built-in modes: architect, code, ask, debug, orchestrator (5) + // Custom non-hidden: parent-mode, regular-mode (2) + // Total: 7 + expect(visibleModes.length).toBeGreaterThan(0) + expect(visibleModes.map((m) => m.slug)).toContain("parent-mode") + expect(visibleModes.map((m) => m.slug)).toContain("regular-mode") + expect(visibleModes.map((m) => m.slug)).not.toContain("parent-mode/submode1") + expect(visibleModes.map((m) => m.slug)).not.toContain("parent-mode/submode2") + }) + + it("should include all modes when none are hidden", () => { + const modesWithoutHidden: ModeConfig[] = [ + { + slug: "mode1", + name: "Mode 1", + roleDefinition: "Definition 1", + groups: ["read"], + }, + { + slug: "mode2", + name: "Mode 2", + roleDefinition: "Definition 2", + groups: ["edit"], + }, + ] + + const visibleModes = getVisibleModes(modesWithoutHidden) + // Should include built-in modes plus custom modes (5 + 2 = 7) + expect(visibleModes.length).toBeGreaterThan(0) + expect(visibleModes.map((m) => m.slug)).toContain("mode1") + expect(visibleModes.map((m) => m.slug)).toContain("mode2") + }) + }) + + describe("getSubmodes", () => { + it("should return all submodes for a parent", () => { + const submodes = getSubmodes("parent-mode", mockCustomModes) + + expect(submodes).toHaveLength(2) + expect(submodes.map((m) => m.slug)).toContain("parent-mode/submode1") + expect(submodes.map((m) => m.slug)).toContain("parent-mode/submode2") + }) + + it("should return empty array for mode with no submodes", () => { + const submodes = getSubmodes("regular-mode", mockCustomModes) + expect(submodes).toHaveLength(0) + }) + + it("should handle non-existent parent mode", () => { + const submodes = getSubmodes("non-existent", mockCustomModes) + expect(submodes).toHaveLength(0) + }) + }) + + describe("Slug validation", () => { + it("should accept valid simple slugs", () => { + const validSlugs = ["code", "architect", "debug-mode", "test-123"] + + validSlugs.forEach((slug) => { + const regex = /^[a-zA-Z0-9-]+(\/[a-zA-Z0-9-]+)?$/ + expect(regex.test(slug)).toBe(true) + }) + }) + + it("should accept valid compound slugs", () => { + const validCompoundSlugs = ["parent/child", "architect/design", "debug-mode/trace", "test-123/subtest-456"] + + validCompoundSlugs.forEach((slug) => { + const regex = /^[a-zA-Z0-9-]+(\/[a-zA-Z0-9-]+)?$/ + expect(regex.test(slug)).toBe(true) + }) + }) + + it("should reject invalid slugs", () => { + const invalidSlugs = [ + "parent/child/grandchild", // Too many levels + "/child", // Starting with slash + "parent/", // Ending with slash + "parent//child", // Double slash + "parent child", // Space + "parent@child", // Invalid character + "", // Empty + ] + + invalidSlugs.forEach((slug) => { + const regex = /^[a-zA-Z0-9-]+(\/[a-zA-Z0-9-]+)?$/ + expect(regex.test(slug)).toBe(false) + }) + }) + }) +}) diff --git a/src/shared/modes.ts b/src/shared/modes.ts index f68d25c682..a0b4120ae5 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -68,13 +68,26 @@ export const defaultModeSlug = modes[0].slug // Helper functions export function getModeBySlug(slug: string, customModes?: ModeConfig[]): ModeConfig | undefined { - // Check custom modes first + // First try exact match in custom modes const customMode = customModes?.find((mode) => mode.slug === slug) if (customMode) { return customMode } - // Then check built-in modes - return modes.find((mode) => mode.slug === slug) + + // Then try exact match in built-in modes + const builtInMode = modes.find((mode) => mode.slug === slug) + if (builtInMode) { + return builtInMode + } + + // If no exact match and slug contains '/', try to find as compound slug (parent/child) + if (slug.includes("/")) { + // For compound slugs, search in both custom and built-in modes + const allModes = [...(customModes || []), ...modes] + return allModes.find((mode) => mode.slug === slug) + } + + return undefined } export function getModeConfig(slug: string, customModes?: ModeConfig[]): ModeConfig { @@ -109,6 +122,22 @@ export function getAllModes(customModes?: ModeConfig[]): ModeConfig[] { return allModes } +// Get visible modes for dropdown selector (excludes hidden submodes) +export function getVisibleModes(customModes?: ModeConfig[]): ModeConfig[] { + const allModes = getAllModes(customModes) + // Filter out hidden modes (submodes) + return allModes.filter((mode) => !mode.hidden) +} + +// Get submodes for a parent mode +export function getSubmodes(parentSlug: string, customModes?: ModeConfig[]): ModeConfig[] { + const allModes = getAllModes(customModes) + // Return modes that have this parent or compound slugs starting with parent/ + return allModes.filter( + (mode) => mode.parent === parentSlug || (mode.slug.includes("/") && mode.slug.startsWith(`${parentSlug}/`)), + ) +} + // Check if a mode is custom or an override export function isCustomMode(slug: string, customModes?: ModeConfig[]): boolean { return !!customModes?.some((mode) => mode.slug === slug)