From bcce6f45225d2ef399cc5b2f6f9042b192dd02b4 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sat, 22 Nov 2025 08:19:49 +0000 Subject: [PATCH] feat: implement submode LLM customization with compound slugs - Add hidden and parent fields to ModeConfig type - Implement compound slug naming convention (parent/child) - Update ProviderSettingsManager to handle compound slugs with fallback - Add helper functions for submode management - Filter hidden modes from mode selector while showing in config panel - Add comprehensive tests for submode functionality This allows complex modes to delegate to focused submodes with individual LLM model & API provider customization, addressing issue #9446 --- packages/types/src/mode.ts | 4 +- src/core/config/ProviderSettingsManager.ts | 21 ++- src/core/webview/ClineProvider.ts | 30 +++- src/shared/__tests__/submodes.spec.ts | 174 +++++++++++++++++++++ src/shared/modes.ts | 78 ++++++++- 5 files changed, 296 insertions(+), 11 deletions(-) create mode 100644 src/shared/__tests__/submodes.spec.ts diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts index 88dcbb9574..da25b47ba7 100644 --- a/packages/types/src/mode.ts +++ b/packages/types/src/mode.ts @@ -62,7 +62,7 @@ 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-/]+$/, "Slug must contain only letters, numbers, dashes, and forward slashes"), name: z.string().min(1, "Name is required"), roleDefinition: z.string().min(1, "Role definition is required"), whenToUse: z.string().optional(), @@ -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/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index ea3e18f3ef..b97ad24257 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -14,7 +14,7 @@ import { } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { Mode, modes } from "../../shared/modes" +import { Mode, modes, parseCompoundSlug, getCompoundSlug } from "../../shared/modes" import { buildApiHandler } from "../../api" // Type-safe model migrations mapping @@ -488,6 +488,7 @@ export class ProviderSettingsManager { /** * Set the API config for a specific mode. + * Supports compound slugs for submodes (e.g., "parent/child") */ public async setModeConfig(mode: Mode, configId: string) { try { @@ -498,6 +499,7 @@ export class ProviderSettingsManager { providerProfiles.modeApiConfigs = {} } // Assign the chosen config ID to this mode + // Mode can be a compound slug like "parent/child" for submodes providerProfiles.modeApiConfigs[mode] = configId await this.store(providerProfiles) }) @@ -508,12 +510,27 @@ export class ProviderSettingsManager { /** * Get the API config ID for a specific mode. + * Supports compound slugs for submodes (e.g., "parent/child") + * Falls back to parent mode config if submode has no specific config */ public async getModeConfigId(mode: Mode) { try { return await this.lock(async () => { const { modeApiConfigs } = await this.load() - return modeApiConfigs?.[mode] + if (!modeApiConfigs) return undefined + + // First try to get config for the exact mode (could be compound slug) + const directConfig = modeApiConfigs[mode] + if (directConfig) return directConfig + + // If mode contains a slash, it's a compound slug for a submode + // Try to fall back to parent mode config + const { parent } = parseCompoundSlug(mode) + if (parent) { + return modeApiConfigs[parent] + } + + return undefined }) } catch (error) { throw new Error(`Failed to get mode config: ${error}`) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 240844c08d..30385cb377 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -52,7 +52,15 @@ import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" import type { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage" -import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes" +import { + Mode, + defaultModeSlug, + getModeBySlug, + getCompoundSlug, + parseCompoundSlug, + getModeByCompoundSlug, + getAllModesForConfiguration, +} from "../../shared/modes" import { experimentDefault } from "../../shared/experiments" import { formatLanguage } from "../../shared/language" import { WebviewMessage } from "../../shared/WebviewMessage" @@ -1268,8 +1276,13 @@ export class ClineProvider this.emit(RooCodeEventName.ModeChanged, newMode) + // For submodes, use compound slug for configuration + const customModes = await this.customModesManager.getCustomModes() + const modeConfig = getModeBySlug(newMode, customModes) + const configKey = modeConfig?.parent ? getCompoundSlug(modeConfig) : newMode + // Load the saved API config for the new mode if it exists. - const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) + const savedConfigId = await this.providerSettingsManager.getModeConfigId(configKey) const listApiConfig = await this.providerSettingsManager.listConfig() // Update listApiConfigMeta first to ensure UI has latest data. @@ -1290,7 +1303,7 @@ export class ClineProvider const config = listApiConfig.find((c) => c.name === currentApiConfigName) if (config?.id) { - await this.providerSettingsManager.setModeConfig(newMode, config.id) + await this.providerSettingsManager.setModeConfig(configKey, config.id) } } } @@ -1433,8 +1446,13 @@ export class ClineProvider const { mode } = await this.getState() + // For submodes, use compound slug for configuration + const customModes = await this.customModesManager.getCustomModes() + const modeConfig = getModeBySlug(mode, customModes) + const configKey = modeConfig?.parent ? getCompoundSlug(modeConfig) : mode + if (id) { - await this.providerSettingsManager.setModeConfig(mode, id) + await this.providerSettingsManager.setModeConfig(configKey, id) } // Change the provider for the current task. this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) @@ -2807,7 +2825,9 @@ export class ClineProvider public async getModes(): Promise<{ slug: string; name: string }[]> { try { const customModes = await this.customModesManager.getCustomModes() - return [...DEFAULT_MODES, ...customModes].map(({ slug, name }) => ({ slug, name })) + // Get all modes for configuration (includes submodes with compound slugs) + const allModes = getAllModesForConfiguration(customModes) + return allModes.map(({ slug, name }) => ({ slug, name })) } catch (error) { return DEFAULT_MODES.map(({ slug, name }) => ({ slug, name })) } diff --git a/src/shared/__tests__/submodes.spec.ts b/src/shared/__tests__/submodes.spec.ts new file mode 100644 index 0000000000..c128dfb57c --- /dev/null +++ b/src/shared/__tests__/submodes.spec.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from "vitest" +import type { ModeConfig } from "@roo-code/types" + +import { + getSubmodes, + isSubmode, + getCompoundSlug, + parseCompoundSlug, + getModeByCompoundSlug, + getAllModes, + getVisibleModes, + getAllModesForConfiguration, +} from "../modes" + +describe("Submode functionality", () => { + const parentMode: ModeConfig = { + slug: "architect", + name: "Architect", + roleDefinition: "Parent mode", + groups: ["read"], + } + + const submode1: ModeConfig = { + slug: "planner", + name: "Planner", + roleDefinition: "Planning submode", + groups: ["read"], + parent: "architect", + hidden: true, + } + + const submode2: ModeConfig = { + slug: "designer", + name: "Designer", + roleDefinition: "Design submode", + groups: ["read", "edit"], + parent: "architect", + hidden: true, + } + + const visibleSubmode: ModeConfig = { + slug: "reviewer", + name: "Reviewer", + roleDefinition: "Review submode", + groups: ["read"], + parent: "architect", + hidden: false, + } + + const customModes = [parentMode, submode1, submode2, visibleSubmode] + + describe("getSubmodes", () => { + it("should return all submodes for a parent", () => { + const submodes = getSubmodes("architect", customModes) + expect(submodes).toHaveLength(3) + expect(submodes).toContainEqual(submode1) + expect(submodes).toContainEqual(submode2) + expect(submodes).toContainEqual(visibleSubmode) + }) + + it("should return empty array for mode with no submodes", () => { + const submodes = getSubmodes("code", customModes) + expect(submodes).toHaveLength(0) + }) + }) + + describe("isSubmode", () => { + it("should return true for submodes", () => { + expect(isSubmode(submode1)).toBe(true) + expect(isSubmode(submode2)).toBe(true) + }) + + it("should return false for parent modes", () => { + expect(isSubmode(parentMode)).toBe(false) + }) + }) + + describe("getCompoundSlug", () => { + it("should return compound slug for submodes", () => { + expect(getCompoundSlug(submode1)).toBe("architect/planner") + expect(getCompoundSlug(submode2)).toBe("architect/designer") + }) + + it("should return simple slug for parent modes", () => { + expect(getCompoundSlug(parentMode)).toBe("architect") + }) + }) + + describe("parseCompoundSlug", () => { + it("should parse compound slugs correctly", () => { + expect(parseCompoundSlug("architect/planner")).toEqual({ + parent: "architect", + child: "planner", + }) + }) + + it("should parse simple slugs correctly", () => { + expect(parseCompoundSlug("architect")).toEqual({ + child: "architect", + }) + }) + }) + + describe("getModeByCompoundSlug", () => { + it("should find submode by compound slug", () => { + const mode = getModeByCompoundSlug("architect/planner", customModes) + expect(mode).toEqual(submode1) + }) + + it("should find parent mode by simple slug", () => { + const mode = getModeByCompoundSlug("architect", customModes) + expect(mode).toEqual(parentMode) + }) + + it("should return undefined for non-existent compound slug", () => { + const mode = getModeByCompoundSlug("architect/nonexistent", customModes) + expect(mode).toBeUndefined() + }) + }) + + describe("getAllModes", () => { + it("should exclude hidden modes by default", () => { + const modes = getAllModes(customModes) + expect(modes).toContain(parentMode) + expect(modes).toContain(visibleSubmode) + expect(modes).not.toContain(submode1) + expect(modes).not.toContain(submode2) + }) + + it("should include hidden modes when requested", () => { + const modes = getAllModes(customModes, true) + expect(modes).toContain(parentMode) + expect(modes).toContain(visibleSubmode) + expect(modes).toContain(submode1) + expect(modes).toContain(submode2) + }) + }) + + describe("getVisibleModes", () => { + it("should only return visible modes", () => { + const modes = getVisibleModes(customModes) + expect(modes).toContain(parentMode) + expect(modes).toContain(visibleSubmode) + expect(modes).not.toContain(submode1) + expect(modes).not.toContain(submode2) + }) + }) + + describe("getAllModesForConfiguration", () => { + it("should include all modes with compound slugs for submodes", () => { + const modes = getAllModesForConfiguration(customModes) + + // Should include all modes (including hidden) + // We have 4 custom modes + 5 built-in modes = 9, but architect appears in both + // so we expect 8 total (architect from custom overrides the built-in) + expect(modes).toHaveLength(8) + + // Find the transformed submodes + const transformedPlanner = modes.find((m) => m.slug === "architect/planner") + const transformedDesigner = modes.find((m) => m.slug === "architect/designer") + const transformedReviewer = modes.find((m) => m.slug === "architect/reviewer") + + // Check compound slugs are created + expect(transformedPlanner).toBeDefined() + expect(transformedDesigner).toBeDefined() + expect(transformedReviewer).toBeDefined() + + // Check names show hierarchy + expect(transformedPlanner?.name).toBe("architect › Planner") + expect(transformedDesigner?.name).toBe("architect › Designer") + expect(transformedReviewer?.name).toBe("architect › Reviewer") + }) + }) +}) diff --git a/src/shared/modes.ts b/src/shared/modes.ts index f68d25c682..63c0ce8770 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -77,6 +77,48 @@ export function getModeBySlug(slug: string, customModes?: ModeConfig[]): ModeCon return modes.find((mode) => mode.slug === slug) } +// Get all submodes for a parent mode +export function getSubmodes(parentSlug: string, customModes?: ModeConfig[]): ModeConfig[] { + const allModes = getAllModes(customModes, true) // Include hidden modes + return allModes.filter((mode) => mode.parent === parentSlug) +} + +// Check if a mode is a submode +export function isSubmode(mode: ModeConfig): boolean { + return !!mode.parent +} + +// Get the compound slug for a submode (parent/child format) +export function getCompoundSlug(mode: ModeConfig): string { + if (mode.parent) { + return `${mode.parent}/${mode.slug}` + } + return mode.slug +} + +// Parse a compound slug to get parent and child parts +export function parseCompoundSlug(compoundSlug: string): { parent?: string; child: string } { + const parts = compoundSlug.split("/") + if (parts.length === 2) { + return { parent: parts[0], child: parts[1] } + } + return { child: compoundSlug } +} + +// Get a mode by compound slug +export function getModeByCompoundSlug(compoundSlug: string, customModes?: ModeConfig[]): ModeConfig | undefined { + const { parent, child } = parseCompoundSlug(compoundSlug) + + if (parent) { + // Look for a submode with matching parent and slug + const allModes = getAllModes(customModes, true) // Include hidden modes + return allModes.find((mode) => mode.parent === parent && mode.slug === child) + } + + // If no parent, just look for the mode by slug + return getModeBySlug(child, customModes) +} + export function getModeConfig(slug: string, customModes?: ModeConfig[]): ModeConfig { const mode = getModeBySlug(slug, customModes) if (!mode) { @@ -86,7 +128,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,9 +148,36 @@ export function getAllModes(customModes?: ModeConfig[]): ModeConfig[] { } }) + // Filter out hidden modes unless explicitly requested + if (!includeHidden) { + return allModes.filter((mode) => !mode.hidden) + } + return allModes } +// Get all visible modes (excludes hidden submodes) +export function getVisibleModes(customModes?: ModeConfig[]): ModeConfig[] { + return getAllModes(customModes, false) +} + +// Get all modes including submodes for configuration UI +export function getAllModesForConfiguration(customModes?: ModeConfig[]): ModeConfig[] { + const allModes = getAllModes(customModes, true) + + // Transform submodes to use compound slugs for display + return allModes.map((mode) => { + if (mode.parent) { + return { + ...mode, + slug: getCompoundSlug(mode), + name: `${mode.parent} › ${mode.name}`, // Use › to show hierarchy + } + } + return mode + }) +} + // Check if a mode is custom or an override export function isCustomMode(slug: string, customModes?: ModeConfig[]): boolean { return !!customModes?.some((mode) => mode.slug === slug) @@ -284,11 +353,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,