From 9ebf237f991795467d3b9bb9b6baf59d2b01f991 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 4 Aug 2025 02:25:26 +0000 Subject: [PATCH] feat: add ability to hide default modes - Add hiddenDefaultModes field to global settings schema - Update ExtensionStateContext to manage hidden modes state - Modify getAllModes function to filter out hidden default modes - Add UI controls in ModesView to toggle visibility of default modes - Update ModeSelector to respect hidden modes setting - Add backend message handler for hiddenDefaultModes - Add comprehensive tests for the new functionality - Add translation keys for new UI elements Fixes #6638 --- packages/types/src/global-settings.ts | 1 + src/core/webview/webviewMessageHandler.ts | 6 + src/shared/ExtensionMessage.ts | 1 + src/shared/WebviewMessage.ts | 2 + src/shared/__tests__/getAllModes.spec.ts | 217 ++++++++++++++++++ src/shared/modes.ts | 13 +- .../src/components/chat/ModeSelector.tsx | 6 +- webview-ui/src/components/modes/ModesView.tsx | 48 +++- .../src/context/ExtensionStateContext.tsx | 5 + webview-ui/src/i18n/locales/en/prompts.json | 4 +- 10 files changed, 293 insertions(+), 10 deletions(-) create mode 100644 src/shared/__tests__/getAllModes.spec.ts diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 6de4d7413f..f156e90a5c 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -139,6 +139,7 @@ export const globalSettingsSchema = z.object({ customModes: z.array(modeConfigSchema).optional(), customModePrompts: customModePromptsSchema.optional(), customSupportPrompts: customSupportPromptsSchema.optional(), + hiddenDefaultModes: z.array(z.string()).optional(), enhancementApiConfigId: z.string().optional(), includeTaskHistoryInEnhance: z.boolean().optional(), historyPreviewCollapsed: z.boolean().optional(), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index fdb7e90425..9523c82695 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2568,5 +2568,11 @@ export const webviewMessageHandler = async ( } break } + case "hiddenDefaultModes": + if (message.hiddenModes !== undefined) { + await updateGlobalState("hiddenDefaultModes", message.hiddenModes) + await provider.postStateToWebview() + } + break } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 930edeac73..d27a0569fd 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -262,6 +262,7 @@ export type ExtensionState = Pick< // | "customModes" // Optional in GlobalSettings, required here. | "customModePrompts" | "customSupportPrompts" + | "hiddenDefaultModes" | "enhancementApiConfigId" | "condensingApiConfigId" | "customCondensingPrompt" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index cb8759d851..1bd3a85730 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -210,6 +210,7 @@ export interface WebviewMessage { | "deleteCommand" | "createCommand" | "insertTextIntoTextarea" + | "hiddenDefaultModes" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" @@ -272,6 +273,7 @@ export interface WebviewMessage { codebaseIndexGeminiApiKey?: string codebaseIndexMistralApiKey?: string } + hiddenModes?: string[] } export const checkoutDiffPayloadSchema = z.object({ diff --git a/src/shared/__tests__/getAllModes.spec.ts b/src/shared/__tests__/getAllModes.spec.ts new file mode 100644 index 0000000000..c81b25ccb2 --- /dev/null +++ b/src/shared/__tests__/getAllModes.spec.ts @@ -0,0 +1,217 @@ +// npx vitest run shared/__tests__/getAllModes.spec.ts + +import { describe, it, expect } from "vitest" +import { getAllModes } from "../modes" +import { DEFAULT_MODES, type ModeConfig } from "@roo-code/types" + +describe("getAllModes", () => { + const customModes: ModeConfig[] = [ + { + slug: "custom-mode-1", + name: "Custom Mode 1", + roleDefinition: "Custom role 1", + groups: ["read", "edit"], + }, + { + slug: "custom-mode-2", + name: "Custom Mode 2", + roleDefinition: "Custom role 2", + groups: ["read"], + }, + ] + + describe("without hiddenDefaultModes", () => { + it("returns all default modes and custom modes", () => { + const result = getAllModes(customModes) + + // Should include all default modes + DEFAULT_MODES.forEach((defaultMode) => { + expect(result).toContainEqual( + expect.objectContaining({ + slug: defaultMode.slug, + name: defaultMode.name, + }), + ) + }) + + // Should include all custom modes + customModes.forEach((customMode) => { + expect(result).toContainEqual( + expect.objectContaining({ + slug: customMode.slug, + name: customMode.name, + }), + ) + }) + + // Total should be default modes + custom modes + expect(result).toHaveLength(DEFAULT_MODES.length + customModes.length) + }) + + it("returns only default modes when no custom modes provided", () => { + const result = getAllModes() + + expect(result).toHaveLength(DEFAULT_MODES.length) + DEFAULT_MODES.forEach((defaultMode) => { + expect(result).toContainEqual( + expect.objectContaining({ + slug: defaultMode.slug, + name: defaultMode.name, + }), + ) + }) + }) + }) + + describe("with hiddenDefaultModes", () => { + it("filters out hidden default modes", () => { + const hiddenModes = ["code", "debug"] + const result = getAllModes(customModes, hiddenModes) + + // Should not include hidden modes + hiddenModes.forEach((hiddenSlug) => { + expect(result).not.toContainEqual( + expect.objectContaining({ + slug: hiddenSlug, + }), + ) + }) + + // Should include non-hidden default modes + DEFAULT_MODES.filter((mode) => !hiddenModes.includes(mode.slug)).forEach((defaultMode) => { + expect(result).toContainEqual( + expect.objectContaining({ + slug: defaultMode.slug, + name: defaultMode.name, + }), + ) + }) + + // Should include all custom modes + customModes.forEach((customMode) => { + expect(result).toContainEqual( + expect.objectContaining({ + slug: customMode.slug, + name: customMode.name, + }), + ) + }) + + // Total should be (default modes - hidden modes) + custom modes + expect(result).toHaveLength(DEFAULT_MODES.length - hiddenModes.length + customModes.length) + }) + + it("filters out all default modes when all are hidden", () => { + const allDefaultSlugs = DEFAULT_MODES.map((mode) => mode.slug) + const result = getAllModes(customModes, allDefaultSlugs) + + // Should not include any default modes + DEFAULT_MODES.forEach((defaultMode) => { + expect(result).not.toContainEqual( + expect.objectContaining({ + slug: defaultMode.slug, + }), + ) + }) + + // Should only include custom modes + expect(result).toHaveLength(customModes.length) + customModes.forEach((customMode) => { + expect(result).toContainEqual( + expect.objectContaining({ + slug: customMode.slug, + name: customMode.name, + }), + ) + }) + }) + + it("handles empty hiddenDefaultModes array", () => { + const result = getAllModes(customModes, []) + + // Should include all modes (same as no filter) + expect(result).toHaveLength(DEFAULT_MODES.length + customModes.length) + }) + + it("ignores non-existent mode slugs in hiddenDefaultModes", () => { + const hiddenModes = ["non-existent-mode", "code"] + const result = getAllModes(customModes, hiddenModes) + + // Should only filter out 'code' which exists + expect(result).not.toContainEqual( + expect.objectContaining({ + slug: "code", + }), + ) + + // Should still have all other modes + expect(result).toHaveLength(DEFAULT_MODES.length - 1 + customModes.length) + }) + + it("does not filter custom modes even if their slugs are in hiddenDefaultModes", () => { + const customModesWithDefaultSlug: ModeConfig[] = [ + { + slug: "code", // Same slug as a default mode + name: "Custom Code Mode", + roleDefinition: "Custom code role", + groups: ["read", "edit"], + }, + ] + + const hiddenModes = ["code"] + const result = getAllModes(customModesWithDefaultSlug, hiddenModes) + + // Should not include the default 'code' mode + const defaultCodeMode = DEFAULT_MODES.find((m) => m.slug === "code") + expect(result).not.toContainEqual( + expect.objectContaining({ + slug: "code", + name: defaultCodeMode?.name, + }), + ) + + // Should include the custom 'code' mode + expect(result).toContainEqual( + expect.objectContaining({ + slug: "code", + name: "Custom Code Mode", + }), + ) + }) + }) + + describe("mode ordering", () => { + it("maintains order with default modes first, then custom modes", () => { + const result = getAllModes(customModes) + + // First modes should be default modes + const defaultModeCount = DEFAULT_MODES.length + result.slice(0, defaultModeCount).forEach((mode, index) => { + expect(mode.slug).toBe(DEFAULT_MODES[index].slug) + }) + + // Remaining modes should be custom modes + result.slice(defaultModeCount).forEach((mode, index) => { + expect(mode.slug).toBe(customModes[index].slug) + }) + }) + + it("maintains order when filtering hidden modes", () => { + const hiddenModes = ["code", "ask"] + const result = getAllModes(customModes, hiddenModes) + + // Get expected default modes (non-hidden) + const expectedDefaultModes = DEFAULT_MODES.filter((m) => !hiddenModes.includes(m.slug)) + + // First modes should be non-hidden default modes in original order + result.slice(0, expectedDefaultModes.length).forEach((mode, index) => { + expect(mode.slug).toBe(expectedDefaultModes[index].slug) + }) + + // Remaining modes should be custom modes + result.slice(expectedDefaultModes.length).forEach((mode, index) => { + expect(mode.slug).toBe(customModes[index].slug) + }) + }) + }) +}) diff --git a/src/shared/modes.ts b/src/shared/modes.ts index f68d25c682..3cb79f5b92 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -86,13 +86,18 @@ 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[], hiddenDefaultModes?: string[]): ModeConfig[] { + // Start with built-in modes, filtering out hidden ones + const visibleBuiltInModes = hiddenDefaultModes?.length + ? modes.filter((mode) => !hiddenDefaultModes.includes(mode.slug)) + : [...modes] + if (!customModes?.length) { - return [...modes] + return visibleBuiltInModes } - // Start with built-in modes - const allModes = [...modes] + // Start with visible built-in modes + const allModes = [...visibleBuiltInModes] // Process custom modes customModes.forEach((customMode) => { diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 93dd2f1f4f..a61216199b 100644 --- a/webview-ui/src/components/chat/ModeSelector.tsx +++ b/webview-ui/src/components/chat/ModeSelector.tsx @@ -43,7 +43,7 @@ export const ModeSelector = ({ const [searchValue, setSearchValue] = React.useState("") const searchInputRef = React.useRef(null) const portalContainer = useRooPortal("roo-portal") - const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState() + const { hasOpenedModeSelector, setHasOpenedModeSelector, hiddenDefaultModes } = useExtensionState() const { t } = useAppTranslation() const trackModeSelectorOpened = React.useCallback(() => { @@ -59,12 +59,12 @@ export const ModeSelector = ({ // Get all modes including custom modes and merge custom prompt descriptions const modes = React.useMemo(() => { - const allModes = getAllModes(customModes) + const allModes = getAllModes(customModes, hiddenDefaultModes) return allModes.map((mode) => ({ ...mode, description: customModePrompts?.[mode.slug]?.description ?? mode.description, })) - }, [customModes, customModePrompts]) + }, [customModes, customModePrompts, hiddenDefaultModes]) // Find the selected mode const selectedMode = React.useMemo(() => modes.find((mode) => mode.slug === value), [modes, value]) diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index c2b67bc450..dd005e2b97 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -10,7 +10,7 @@ import { import { Trans } from "react-i18next" import { ChevronDown, X, Upload, Download } from "lucide-react" -import { ModeConfig, GroupEntry, PromptComponent, ToolGroup, modeConfigSchema } from "@roo-code/types" +import { ModeConfig, GroupEntry, PromptComponent, ToolGroup, modeConfigSchema, DEFAULT_MODES } from "@roo-code/types" import { Mode, @@ -75,6 +75,8 @@ const ModesView = ({ onDone }: ModesViewProps) => { customInstructions, setCustomInstructions, customModes, + hiddenDefaultModes, + setHiddenDefaultModes, } = useExtensionState() // Use a local state to track the visually active mode @@ -85,7 +87,7 @@ const ModesView = ({ onDone }: ModesViewProps) => { const [visualMode, setVisualMode] = useState(mode) // Memoize modes to preserve array order - const modes = useMemo(() => getAllModes(customModes), [customModes]) + const modes = useMemo(() => getAllModes(customModes, hiddenDefaultModes), [customModes, hiddenDefaultModes]) const [isDialogOpen, setIsDialogOpen] = useState(false) const [selectedPromptContent, setSelectedPromptContent] = useState("") @@ -701,6 +703,48 @@ const ModesView = ({ onDone }: ModesViewProps) => { + + {/* Default Modes Visibility Settings */} +
+
{t("prompts:modes.defaultModesVisibility")}
+
+ {t("prompts:modes.defaultModesVisibilityDescription")} +
+
+ {DEFAULT_MODES.map((defaultMode) => ( + ) => { + const target = + (e as CustomEvent)?.detail?.target || (e.target as HTMLInputElement) + const checked = target.checked + if (checked) { + // Remove from hidden modes + const newHiddenModes = hiddenDefaultModes.filter( + (slug) => slug !== defaultMode.slug, + ) + setHiddenDefaultModes(newHiddenModes) + vscode.postMessage({ + type: "hiddenDefaultModes", + hiddenModes: newHiddenModes, + }) + } else { + // Add to hidden modes + const newHiddenModes = [...hiddenDefaultModes, defaultMode.slug] + setHiddenDefaultModes(newHiddenModes) + vscode.postMessage({ + type: "hiddenDefaultModes", + hiddenModes: newHiddenModes, + }) + } + }}> + {defaultMode.name} + + ))} +
+
+ {/* API Configuration - Moved Here */}
{t("prompts:apiConfiguration.title")}
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index da7ab63358..5b8db94425 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -43,6 +43,8 @@ export interface ExtensionStateContextType extends ExtensionState { hasOpenedModeSelector: boolean // New property to track if user has opened mode selector setHasOpenedModeSelector: (value: boolean) => void // Setter for the new property alwaysAllowFollowupQuestions: boolean // New property for follow-up questions auto-approve + hiddenDefaultModes: string[] // Array of default mode slugs to hide + setHiddenDefaultModes: (modes: string[]) => void // Setter for hidden default modes setAlwaysAllowFollowupQuestions: (value: boolean) => void // Setter for the new property followupAutoApproveTimeoutMs: number | undefined // Timeout in ms for auto-approving follow-up questions setFollowupAutoApproveTimeoutMs: (value: number) => void // Setter for the timeout @@ -208,6 +210,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode customCondensingPrompt: "", // Default empty string for custom condensing prompt hasOpenedModeSelector: false, // Default to false (not opened yet) autoApprovalEnabled: false, + hiddenDefaultModes: [], // Default to empty array (no modes hidden) customModes: [], maxOpenTabsContext: 20, maxWorkspaceFiles: 200, @@ -408,6 +411,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode profileThresholds: state.profileThresholds ?? {}, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, + hiddenDefaultModes: state.hiddenDefaultModes ?? [], setExperimentEnabled: (id, enabled) => setState((prevState) => ({ ...prevState, experiments: { ...prevState.experiments, [id]: enabled } })), setApiConfiguration, @@ -497,6 +501,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setState((prevState) => ({ ...prevState, historyPreviewCollapsed: value })), setHasOpenedModeSelector: (value) => setState((prevState) => ({ ...prevState, hasOpenedModeSelector: value })), setAutoCondenseContext: (value) => setState((prevState) => ({ ...prevState, autoCondenseContext: value })), + setHiddenDefaultModes: (modes) => setState((prevState) => ({ ...prevState, hiddenDefaultModes: modes })), setAutoCondenseContextPercent: (value) => setState((prevState) => ({ ...prevState, autoCondenseContextPercent: value })), setCondensingApiConfigId: (value) => setState((prevState) => ({ ...prevState, condensingApiConfigId: value })), diff --git a/webview-ui/src/i18n/locales/en/prompts.json b/webview-ui/src/i18n/locales/en/prompts.json index 0ea5e133b8..c0bf25e94b 100644 --- a/webview-ui/src/i18n/locales/en/prompts.json +++ b/webview-ui/src/i18n/locales/en/prompts.json @@ -10,7 +10,9 @@ "editProjectModes": "Edit Project Modes (.roomodes)", "createModeHelpText": "Modes are specialized personas that tailor Roo's behavior. <0>Learn about Using Modes or <1>Customizing Modes.", "selectMode": "Search modes", - "noMatchFound": "No modes found" + "noMatchFound": "No modes found", + "defaultModesVisibility": "Default Modes Visibility", + "defaultModesVisibilityDescription": "Choose which default modes to show in the mode selector" }, "apiConfiguration": { "title": "API Configuration",