diff --git a/README.md b/README.md index 2f3a6069d9..627a8bfbf2 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,97 @@ Roo Code adapts to your needs with specialized [modes](https://docs.roocode.com/ - **Debug Mode:** For systematic problem diagnosis - **[Custom Modes](https://docs.roocode.com/advanced-usage/custom-modes):** Create unlimited specialized personas for security auditing, performance optimization, documentation, or any other task +## Overriding Default Modes + +You can override Roo Code's built-in modes (for example: `code`, `debug`, `ask`, `architect`, `orchestrator`) by creating a custom mode that uses the same slug as the built-in mode. When a custom mode uses the same slug it takes precedence according to the following order: + +- Project-specific override (in the workspace `.roomodes`) — highest precedence +- Global override (in `custom_modes.yaml`) — next +- Built-in mode — fallback + +Overriding Modes Globally +To customize a default mode across all your projects: + +1. Open the Prompts tab in the Roo Code UI. +2. Open the Global Prompts / Global Modes settings (click the ⋯ / settings menu) and choose "Edit Global Modes" to edit `custom_modes.yaml`. +3. Add a custom mode entry that uses the same `slug` as the built-in you want to override. + +Corrected YAML example + +customModes: + +- slug: code # Matches the default 'code' mode slug + name: "💻 Code (Global Override)" + roleDefinition: "You are a software engineer with global-specific constraints." + whenToUse: "This globally overridden code mode is for JS/TS tasks." + customInstructions: "Focus on project-specific JS/TS development." + groups: + - read + - [ edit, { fileRegex: "\\.(js|ts)$", description: "JS/TS files only" } ] + +JSON example (note: escape backslashes appropriately when embedding inside other strings) + +{ +"customModes": [{ +"slug": "code", +"name": "💻 Code (Global Override)", +"roleDefinition": "You are a software engineer with global-specific constraints", +"whenToUse": "This globally overridden code mode is for JS/TS tasks.", +"customInstructions": "Focus on project-specific JS/TS development", +"groups": [ +"read", +["edit", { "fileRegex": "\\\\.(js|ts)$", "description": "JS/TS files only" }] +] +}] +} + +Project-Specific Mode Override +To override a default mode for just one project: + +1. Open the Prompts tab. +2. Open the Project Prompts / Project Modes settings and choose "Edit Project Modes" to edit the `.roomodes` file in the workspace root. +3. Add a `customModes` entry with the same `slug` as the built-in mode. + +YAML example (project override): + +customModes: + +- slug: code + name: "💻 Code (Project-Specific)" + roleDefinition: "You are a software engineer with project-specific constraints for this project." + whenToUse: "This project-specific code mode is for Python tasks within this project." + customInstructions: "Adhere to PEP8 and use type hints." + groups: + - read + - [ edit, { fileRegex: "\\.py$", description: "Python files only" } ] + - command + +Project-specific overrides take precedence over global overrides. + +## Restore built-in (delete override) + +Enabling/disabling an override is different from restoring the built-in mode. + +- Enable/Disable: Toggling enabled/disabled updates the custom override entry (preserves your customizations). +- Restore built-in: Deletes the custom override entry (removes your customizations) so the built-in mode is used again. + +How to restore the original built-in mode: + +1. Open Prompts → Global Modes (Edit Global Modes). +2. Find the global custom mode that uses the same slug as a built-in — a "Restore built-in" action is available for overrides. +3. Click "Restore built-in" and confirm. The extension will delete the custom mode entry from `custom_modes.yaml` and (optionally) remove the associated rules folder. After this, the built-in mode will be used. + +Exact file path examples + +- Global custom modes file: `{user_home}/.roo/custom_modes.yaml` (the extension writes global overrides here) +- Project overrides file: `{workspace_root}/.roomodes` (or `.roo/.roomodes` depending on workspace layout) + +## Rules folder and backups + +Custom mode rule files are stored in a rules folder (for example: `~/.roo/rules-` for global or `.roo/rules-` for project-scoped rules). When you delete a custom mode (restore built-in), the extension will attempt to remove the associated rules folder. If you want to keep your custom rules, back up the rules folder before restoring/deleting the override. + +Tip: When overriding default modes, test carefully. Consider backing up configurations and rules before major changes. + ### Smart Tools Roo Code comes with powerful [tools](https://docs.roocode.com/basic-usage/how-tools-work) that can: diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts index 88dcbb9574..e89a04903e 100644 --- a/packages/types/src/mode.ts +++ b/packages/types/src/mode.ts @@ -69,7 +69,8 @@ export const modeConfigSchema = z.object({ description: z.string().optional(), customInstructions: z.string().optional(), groups: groupEntryArraySchema, - source: z.enum(["global", "project"]).optional(), + source: z.enum(["builtin", "global", "project"]).optional(), + disabled: z.boolean().optional(), }) export type ModeConfig = z.infer diff --git a/src/api/providers/featherless.ts b/src/api/providers/featherless.ts index 56d7177de7..2a985e2a87 100644 --- a/src/api/providers/featherless.ts +++ b/src/api/providers/featherless.ts @@ -1,4 +1,9 @@ -import { DEEP_SEEK_DEFAULT_TEMPERATURE, type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types" +import { + DEEP_SEEK_DEFAULT_TEMPERATURE, + type FeatherlessModelId, + featherlessDefaultModelId, + featherlessModels, +} from "@roo-code/types" import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index a9a2e6a6b5..6f50f17d6d 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -488,6 +488,8 @@ export class CustomModesManager { } settings.customModes = operation(settings.customModes) + // Log write operations so extension host logs show when custom modes are persisted. + console.info(`[CustomModesManager] Writing custom modes to: ${filePath}`) await fs.writeFile(filePath, yaml.stringify(settings, { lineWidth: 0 }), "utf-8") } @@ -543,6 +545,10 @@ export class CustomModesManager { // Clear cache when modes are deleted this.clearCache() + + // Add a small delay to ensure file operations are complete before refreshing UI + await new Promise((resolve) => setTimeout(resolve, 100)) + await this.refreshMergedState() }) } catch (error) { @@ -1002,6 +1008,136 @@ export class CustomModesManager { this.cachedAt = 0 } + /** + * Set the disabled state of a mode + */ + public async setModeDisabled(slug: string, disabled: boolean): Promise { + try { + const modes = await this.getCustomModes() + const mode = modes.find((m) => m.slug === slug) + + if (!mode) { + throw new Error(`Mode not found: ${slug}`) + } + + // Determine which file to update based on source + let targetPath: string + if (mode.source === "project") { + const roomodesPath = await this.getWorkspaceRoomodes() + if (!roomodesPath) { + throw new Error("No .roomodes file found in workspace") + } + targetPath = roomodesPath + } else { + targetPath = await this.getCustomModesFilePath() + } + + await this.queueWrite(async () => { + await this.updateModesInFile(targetPath, (modes) => { + return modes.map((m) => (m.slug === slug ? { ...m, disabled } : m)) + }) + }) + + await this.refreshMergedState() + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to update mode disabled state", { slug, disabled, error: errorMessage }) + throw error + } + } + + /** + * Set disabled state for multiple modes + */ + public async setMultipleModesDisabled(updates: Array<{ slug: string; disabled: boolean }>): Promise { + try { + const modes = await this.getCustomModes() + const updatesByFile = new Map>() + // Collect built-in modes that need to be copied into the global custom modes file + const addsForSettings: ModeConfig[] = [] + + // Group updates by source/file + for (const update of updates) { + const mode = modes.find((m) => m.slug === update.slug) + if (!mode) { + // If mode isn't present in custom modes, it might be a built-in mode. + // Try loading built-in definitions and create a custom copy in the global file. + try { + const { modes: builtInModes } = await import("../../shared/modes") + const builtIn = builtInModes.find((b: any) => b.slug === update.slug) + if (builtIn) { + // Create a global-scoped custom mode based on the built-in definition + const modeToAdd: ModeConfig = { + ...builtIn, + disabled: update.disabled, + source: "global", + } + addsForSettings.push(modeToAdd) + // Add a corresponding update entry for the settings file + const settingsPath = await this.getCustomModesFilePath() + if (!updatesByFile.has(settingsPath)) { + updatesByFile.set(settingsPath, []) + } + updatesByFile.get(settingsPath)!.push(update) + continue + } + console.warn(`Mode not found: ${update.slug}`) + } catch (e) { + console.warn(`Mode not found and failed to load built-ins: ${update.slug}`) + } + continue + } + + let targetPath: string + if (mode.source === "project") { + const roomodesPath = await this.getWorkspaceRoomodes() + if (!roomodesPath) { + console.warn(`No .roomodes file found for project mode: ${update.slug}`) + continue + } + targetPath = roomodesPath + } else { + targetPath = await this.getCustomModesFilePath() + } + + if (!updatesByFile.has(targetPath)) { + updatesByFile.set(targetPath, []) + } + updatesByFile.get(targetPath)!.push(update) + } + + // Apply updates to each file + for (const [filePath, fileUpdates] of updatesByFile) { + await this.queueWrite(async () => { + await this.updateModesInFile(filePath, (modes) => { + return modes.map((m) => { + const update = fileUpdates.find((u) => u.slug === m.slug) + return update ? { ...m, disabled: update.disabled } : m + }) + }) + }) + } + + // If we found built-in modes that need to be copied to settings, add them now + if (addsForSettings.length > 0) { + const settingsPath = await this.getCustomModesFilePath() + await this.queueWrite(async () => { + await this.updateModesInFile(settingsPath, (modes) => { + // Remove any existing entries with the same slugs, then append new ones + const filtered = modes.filter((m) => !addsForSettings.some((a) => a.slug === m.slug)) + return [...filtered, ...addsForSettings] + }) + }) + } + + await this.refreshMergedState() + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to update multiple modes disabled state", { updates, error: errorMessage }) + throw error + } + } + dispose(): void { for (const disposable of this.disposables) { disposable.dispose() diff --git a/src/core/prompts/sections/modes.ts b/src/core/prompts/sections/modes.ts index 9b863840c0..9c05b5d736 100644 --- a/src/core/prompts/sections/modes.ts +++ b/src/core/prompts/sections/modes.ts @@ -13,12 +13,15 @@ export async function getModesSection(context: vscode.ExtensionContext): Promise // Get all modes with their overrides from extension state const allModes = await getAllModesWithPrompts(context) + // Filter out disabled modes to reduce system prompt size + const enabledModes = allModes.filter((mode) => !mode.disabled) + let modesContent = `==== MODES - These are the currently available modes: -${allModes +${enabledModes .map((mode: ModeConfig) => { let description: string if (mode.whenToUse && mode.whenToUse.trim() !== "") { diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index 1c883c6fe5..9e1a9fca84 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -15,6 +15,32 @@ vi.mock("vscode", () => ({ vi.mock("../../../shared/modes", () => ({ getModeBySlug: vi.fn(), defaultModeSlug: "ask", + modes: [ + { + slug: "code", + name: "Code Mode", + roleDefinition: "Test role definition", + groups: ["command", "read", "edit"], + }, + { slug: "ask", name: "Ask Mode", roleDefinition: "Test role definition", groups: ["command", "read", "edit"] }, + ], +})) + +// Mock ModeManager - must be hoisted before newTaskTool import +const mockValidateModeSwitch = vi.fn().mockResolvedValue({ isValid: true }) +const mockGetEnabledModes = vi.fn().mockResolvedValue([ + { slug: "code", name: "Code Mode" }, + { slug: "ask", name: "Ask Mode" }, +]) + +vi.mock("../../services/ModeManager", () => ({ + ModeManager: vi.fn().mockImplementation((context, customModesManager) => { + console.log("Mock ModeManager constructor called with:", { context, customModesManager }) + return { + validateModeSwitch: mockValidateModeSwitch, + getEnabledModes: mockGetEnabledModes, + } + }), })) vi.mock("../../prompts/responses", () => ({ @@ -79,6 +105,13 @@ const mockCline = { getState: vi.fn(() => ({ customModes: [], mode: "ask" })), handleModeSwitch: vi.fn(), createTask: mockCreateTask, + context: { + globalState: { get: vi.fn(), update: vi.fn() }, + workspaceState: { get: vi.fn(), update: vi.fn() }, + }, + customModesManager: { + getCustomModes: vi.fn().mockResolvedValue([]), + }, })), }, } diff --git a/src/core/tools/newTaskTool.ts b/src/core/tools/newTaskTool.ts index 2a3ab293b6..af0b574d89 100644 --- a/src/core/tools/newTaskTool.ts +++ b/src/core/tools/newTaskTool.ts @@ -6,6 +6,7 @@ import { RooCodeEventName, TodoItem } from "@roo-code/types" import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { Task } from "../task/Task" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" +import { ModeManager } from "../../services/ModeManager" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" import { parseMarkdownChecklist } from "./updateTodoListTool" @@ -95,6 +96,26 @@ export async function newTaskTool( return } + // Validate mode availability (not disabled) + if (!provider.context || !provider.customModesManager) { + pushToolResult(formatResponse.toolError("Unable to access mode configuration.")) + return + } + + const modeManager = new ModeManager(provider.context, provider.customModesManager) + const validationResult = await modeManager.validateModeSwitch(mode) + + if (!validationResult.isValid) { + cline.recordToolError("new_task") + // Provide helpful error message with available modes + const enabledModes = await modeManager.getEnabledModes() + const availableModesList = enabledModes.map((m) => `- ${m.slug}: ${m.name}`).join("\n") + + const errorMessage = `${validationResult.errorMessage}\n\nAvailable enabled modes:\n${availableModesList}` + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + const toolMessage = JSON.stringify({ tool: "newTask", mode: targetMode.name, diff --git a/src/core/tools/switchModeTool.ts b/src/core/tools/switchModeTool.ts index 8ce906b41f..eb21321d60 100644 --- a/src/core/tools/switchModeTool.ts +++ b/src/core/tools/switchModeTool.ts @@ -4,6 +4,7 @@ import { Task } from "../task/Task" import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" +import { ModeManager } from "../../services/ModeManager" export async function switchModeTool( cline: Task, @@ -37,7 +38,8 @@ export async function switchModeTool( cline.consecutiveMistakeCount = 0 // Verify the mode exists - const targetMode = getModeBySlug(mode_slug, (await cline.providerRef.deref()?.getState())?.customModes) + const provider = cline.providerRef.deref() + const targetMode = getModeBySlug(mode_slug, (await provider?.getState())?.customModes) if (!targetMode) { cline.recordToolError("switch_mode") @@ -45,6 +47,28 @@ export async function switchModeTool( return } + // Check if mode is disabled using ModeManager + if (!provider?.context || !provider?.customModesManager) { + cline.recordToolError("switch_mode") + pushToolResult(formatResponse.toolError("Unable to access mode configuration.")) + return + } + + const modeManager = new ModeManager(provider.context, provider.customModesManager) + const validationResult = await modeManager.validateModeSwitch(mode_slug) + + if (!validationResult.isValid) { + cline.recordToolError("switch_mode") + + // Provide helpful error message with available modes + const enabledModes = await modeManager.getEnabledModes() + const availableModesList = enabledModes.map((mode) => `- ${mode.slug}: ${mode.name}`).join("\n") + + const errorMessage = `${validationResult.errorMessage}\n\nAvailable enabled modes:\n${availableModesList}` + pushToolResult(formatResponse.toolError(errorMessage)) + return + } + // Check if already in requested mode const currentMode = (await cline.providerRef.deref()?.getState())?.mode ?? defaultModeSlug diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 8e42707a95..32756739b3 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2642,6 +2642,83 @@ export const webviewMessageHandler = async ( } break } + case "enableDisableModesClicked": { + // Handle enable/disable modes button click + try { + const { ModeManager } = await import("../../services/ModeManager") + const modeManager = new ModeManager(provider.context, provider.customModesManager) + const allModes = await modeManager.getAllModesWithSource() + + // Send modes with source information to webview for the dialog + await provider.postMessageToWebview({ + type: "showModeEnableDisableDialog", + modes: allModes, + }) + } catch (error) { + provider.log(`Error opening enable/disable modes dialog: ${error}`) + vscode.window.showErrorMessage("Failed to open mode management dialog") + } + break + } + case "updateModeDisabledStates": { + // Handle bulk mode enable/disable updates + if (message.updates && typeof message.updates === "object") { + try { + // Convert updates object into array for batch processing + const updatesArray: Array<{ slug: string; disabled: boolean }> = [] + for (const [slug, disabled] of Object.entries(message.updates)) { + if (typeof disabled === "boolean") { + updatesArray.push({ slug, disabled }) + } + } + + if (updatesArray.length > 0) { + // Use CustomModesManager batch API which groups updates by file and + // performs each file write in a single queued operation. + await provider.customModesManager.setMultipleModesDisabled(updatesArray) + } + + // Refresh state and notify webview + const customModes = await provider.customModesManager.getCustomModes() + await updateGlobalState("customModes", customModes) + await provider.postStateToWebview() + + await provider.postMessageToWebview({ type: "modeDisabledStatesUpdated", success: true }) + } catch (error) { + provider.log(`Error updating mode disabled states: ${error}`) + + await provider.postMessageToWebview({ + type: "modeDisabledStatesUpdated", + success: false, + error: error instanceof Error ? error.message : String(error), + }) + + vscode.window.showErrorMessage(t("common:errors.update_modes_failed")) + } + } + break + } + case "getModesBySource": { + // Handle request for modes categorized by source + try { + const { ModeManager } = await import("../../services/ModeManager") + const modeManager = new ModeManager(provider.context, provider.customModesManager) + const modesBySource = await modeManager.getModesBySource() + + await provider.postMessageToWebview({ + type: "modesBySource", + modesBySource, + }) + } catch (error) { + provider.log(`Error getting modes by source: ${error}`) + await provider.postMessageToWebview({ + type: "modesBySource", + modesBySource: { builtin: [], global: [], project: [] }, + error: error instanceof Error ? error.message : String(error), + }) + } + break + } case "showMdmAuthRequiredNotification": { // Show notification that organization requires authentication vscode.window.showWarningMessage(t("common:mdm.info.organization_requires_auth")) diff --git a/src/integrations/misc/__tests__/switch-mode-validation.spec.ts b/src/integrations/misc/__tests__/switch-mode-validation.spec.ts new file mode 100644 index 0000000000..a7990d6dab --- /dev/null +++ b/src/integrations/misc/__tests__/switch-mode-validation.spec.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from "vitest" +import { ModeManager } from "../../../services/ModeManager" + +// Mock vscode module +const mockShowErrorMessage = vi.fn() +vi.mock("vscode", () => ({ + window: { + showErrorMessage: mockShowErrorMessage, + }, +})) + +// Mock extension context +const mockContext = { + globalState: { + get: vi.fn(), + update: vi.fn(), + }, + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, +} as any + +// Mock custom modes manager +const mockCustomModesManager = { + getCustomModes: vi.fn().mockReturnValue([]), + updateCustomMode: vi.fn(), +} as any + +describe("Mode Switch Validation", () => { + let modeManager: ModeManager + + beforeEach(() => { + vi.clearAllMocks() + modeManager = new ModeManager(mockContext, mockCustomModesManager) + }) + + describe("validateModeSwitch", () => { + it("should validate enabled built-in mode", async () => { + // Mock that architect mode is enabled (default) + mockCustomModesManager.getCustomModes.mockResolvedValue([]) + + const result = await modeManager.validateModeSwitch("architect") + + expect(result.isValid).toBe(true) + expect(result.errorMessage).toBeUndefined() + }) + + it("should reject disabled built-in mode", async () => { + // Mock that code mode is disabled via custom override + mockCustomModesManager.getCustomModes.mockResolvedValue([ + { + slug: "code", + name: "Code", + roleDefinition: "You are a coding assistant", + groups: ["read", "edit", "command"], + source: "global", + disabled: true, + }, + ]) + + const result = await modeManager.validateModeSwitch("code") + + expect(result.isValid).toBe(false) + expect(result.errorMessage).toBe("Mode 'code' is currently disabled.") + }) + + it("should reject non-existent mode", async () => { + // Mock no disabled modes + mockCustomModesManager.getCustomModes.mockResolvedValue([]) + + const result = await modeManager.validateModeSwitch("invalid-mode") + + expect(result.isValid).toBe(false) + expect(result.errorMessage).toBe("Mode 'invalid-mode' not found.") + }) + + it("should handle empty mode slug", async () => { + const result = await modeManager.validateModeSwitch("") + + expect(result.isValid).toBe(false) + expect(result.errorMessage).toBe("Mode '' not found.") + }) + + it("should validate custom enabled mode", async () => { + // Mock custom mode + const customMode = { + slug: "custom-test", + name: "Custom Test", + roleDefinition: "A test custom mode", + groups: [], + source: "global", + disabled: false, + } + mockCustomModesManager.getCustomModes.mockResolvedValue([customMode]) + + const result = await modeManager.validateModeSwitch("custom-test") + + expect(result.isValid).toBe(true) + expect(result.errorMessage).toBeUndefined() + }) + + it("should reject disabled custom mode", async () => { + // Mock custom mode + const customMode = { + slug: "custom-disabled", + name: "Custom Disabled", + roleDefinition: "A disabled custom mode", + groups: [], + source: "global", + disabled: true, + } + mockCustomModesManager.getCustomModes.mockResolvedValue([customMode]) + + const result = await modeManager.validateModeSwitch("custom-disabled") + + expect(result.isValid).toBe(false) + expect(result.errorMessage).toBe("Mode 'custom-disabled' is currently disabled.") + }) + }) + + describe("error message formatting", () => { + it("should format disabled mode messages consistently", async () => { + mockCustomModesManager.getCustomModes.mockResolvedValue([ + { + slug: "debug", + name: "Debug", + roleDefinition: "You are a debugging assistant", + groups: ["read", "edit", "command"], + source: "global", + disabled: true, + }, + ]) + + const result = await modeManager.validateModeSwitch("debug") + + expect(result.isValid).toBe(false) + expect(result.errorMessage).toBe("Mode 'debug' is currently disabled.") + }) + + it("should format non-existent mode messages consistently", async () => { + mockCustomModesManager.getCustomModes.mockResolvedValue([]) + + const result = await modeManager.validateModeSwitch("unknown") + + expect(result.isValid).toBe(false) + expect(result.errorMessage).toBe("Mode 'unknown' not found.") + }) + }) + + describe("mode existence checks", () => { + it("should recognize all built-in modes", async () => { + const builtInModes = ["architect", "code", "ask", "debug", "orchestrator"] + mockCustomModesManager.getCustomModes.mockResolvedValue([]) + + for (const mode of builtInModes) { + const result = await modeManager.validateModeSwitch(mode) + expect(result.isValid).toBe(true) + } + }) + + it("should handle case sensitivity", async () => { + mockCustomModesManager.getCustomModes.mockResolvedValue([]) + + const result = await modeManager.validateModeSwitch("ARCHITECT") + + expect(result.isValid).toBe(false) + expect(result.errorMessage).toBe("Mode 'ARCHITECT' not found.") + }) + }) +}) diff --git a/src/services/ModeManager.ts b/src/services/ModeManager.ts new file mode 100644 index 0000000000..e031c48e91 --- /dev/null +++ b/src/services/ModeManager.ts @@ -0,0 +1,223 @@ +import * as vscode from "vscode" +import type { ModeConfig, CustomModePrompts } from "@roo-code/types" + +import { modes as builtinModes, defaultModeSlug, getAllModes } from "../shared/modes" +import { CustomModesManager } from "../core/config/CustomModesManager" + +export type ModeSource = "builtin" | "global" | "project" + +export interface ModeState { + mode: ModeConfig + isDisabled: boolean +} + +// Internal type used when returning modes to callers (webview/handlers). +// Extends the canonical ModeConfig with source information and an optional +// UI-only `overridesBuiltin` flag used by the webview. +export type ModeWithSource = ModeConfig & { + source: ModeSource + overridesBuiltin?: boolean + overriddenBy?: ModeSource +} + +/** + * ModeManager handles mode operations including enable/disable functionality, + * source identification, and mode filtering + */ +export class ModeManager { + private customModesManager: CustomModesManager + private context: vscode.ExtensionContext + + constructor(context: vscode.ExtensionContext, customModesManager: CustomModesManager) { + this.context = context + this.customModesManager = customModesManager + } + + /** + * Get all modes with their sources (builtin, global, project) + */ + async getAllModesWithSource(): Promise { + const customModes = await this.customModesManager.getCustomModes() + const allModes: ModeWithSource[] = [] + + // Add built-in modes first + for (const mode of builtinModes) { + // Check if this built-in mode is overridden by custom modes + const customOverride = customModes.find((m) => m.slug === mode.slug) + if (!customOverride) { + allModes.push({ ...mode, source: "builtin" }) + } else { + // Add overridden built-in mode with override information + allModes.push({ + ...mode, + source: "builtin", + disabled: customOverride.disabled, + overriddenBy: customOverride.source as ModeSource, + }) + } + } + + // Add custom modes (they override built-in modes and have source information) + for (const mode of customModes) { + // UI-only flag: indicate when a global custom mode overrides a built-in one. + // The webview uses this to show a "Restore built-in" action. + const overridesBuiltin = !!builtinModes.find((b) => b.slug === mode.slug) + allModes.push({ + ...mode, + source: (mode.source as ModeSource) || "global", + overridesBuiltin, + }) + } + + return allModes + } + + /** + * Get all enabled modes (excluding disabled ones) + */ + async getEnabledModes(): Promise { + const allModes = await this.getAllModesWithSource() + return allModes.filter((mode) => !mode.disabled) + } + + /** + * Get all disabled modes + */ + async getDisabledModes(): Promise { + const allModes = await this.getAllModesWithSource() + return allModes.filter((mode) => mode.disabled === true) + } + + /** + * Check if a mode is disabled + */ + async isModeDisabled(slug: string): Promise { + const allModes = await this.getAllModesWithSource() + const mode = allModes.find((m) => m.slug === slug) + return mode?.disabled === true + } + + /** + * Enable or disable a mode + */ + async setModeDisabled(slug: string, disabled: boolean): Promise { + const allModes = await this.getAllModesWithSource() + const mode = allModes.find((m) => m.slug === slug) + + if (!mode) { + throw new Error(`Mode with slug '${slug}' not found`) + } + + // For built-in modes, we need to create an override + if (mode.source === "builtin") { + // Create a global override for the built-in mode + const builtinMode = builtinModes.find((m) => m.slug === slug) + if (builtinMode) { + const override: ModeConfig = { + ...builtinMode, + source: "global", + disabled: disabled, + } + await this.customModesManager.updateCustomMode(slug, override) + } + } else { + // Update existing custom mode + const updatedMode = { ...mode, disabled } + await this.customModesManager.updateCustomMode(slug, updatedMode) + } + } + + /** + * Get mode by slug with source information + */ + async getModeBySlug(slug: string): Promise { + const allModes = await this.getAllModesWithSource() + return allModes.find((m) => m.slug === slug) || null + } + + /** + * Get available mode slugs (enabled modes only) + */ + async getAvailableModeSlugs(): Promise { + const enabledModes = await this.getEnabledModes() + return enabledModes.map((m) => m.slug) + } + + /** + * Batch update mode disabled states + */ + async batchUpdateModeStates(updates: Record): Promise { + const allModes = await this.getAllModesWithSource() + + for (const [slug, disabled] of Object.entries(updates)) { + const mode = allModes.find((m) => m.slug === slug) + if (mode) { + await this.setModeDisabled(slug, disabled) + } + } + } + + /** + * Get modes categorized by source + */ + async getModesBySource(): Promise<{ + builtin: ModeWithSource[] + global: ModeWithSource[] + project: ModeWithSource[] + }> { + const allModes = await this.getAllModesWithSource() + + return { + builtin: allModes.filter((m) => m.source === "builtin"), + global: allModes.filter((m) => m.source === "global"), + project: allModes.filter((m) => m.source === "project"), + } + } + + /** + * Get enabled modes for system prompt generation (excludes disabled modes) + */ + async getModesForSystemPrompt(customModePrompts?: CustomModePrompts): Promise { + const enabledModes = await this.getEnabledModes() + + // Apply custom prompt overrides + return enabledModes.map((mode) => ({ + ...mode, + roleDefinition: customModePrompts?.[mode.slug]?.roleDefinition ?? mode.roleDefinition, + whenToUse: customModePrompts?.[mode.slug]?.whenToUse ?? mode.whenToUse, + customInstructions: customModePrompts?.[mode.slug]?.customInstructions ?? mode.customInstructions, + description: customModePrompts?.[mode.slug]?.description ?? mode.description, + })) + } + + /** + * Check if current mode is disabled and suggest enabled alternatives + */ + async validateModeSwitch(targetSlug: string): Promise<{ + isValid: boolean + errorMessage?: string + availableModes?: string[] + }> { + const mode = await this.getModeBySlug(targetSlug) + + if (!mode) { + const availableModes = await this.getAvailableModeSlugs() + return { + isValid: false, + errorMessage: `Mode '${targetSlug}' not found.`, + availableModes, + } + } + + if (mode.disabled) { + const availableModes = await this.getAvailableModeSlugs() + return { + isValid: false, + errorMessage: `Mode '${targetSlug}' is currently disabled.`, + availableModes, + } + } + + return { isValid: true } + } +} diff --git a/src/services/__tests__/ModeManager.spec.ts b/src/services/__tests__/ModeManager.spec.ts new file mode 100644 index 0000000000..2b4f8b2a97 --- /dev/null +++ b/src/services/__tests__/ModeManager.spec.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from "vitest" +import type { ExtensionContext } from "vscode" +import type { ModeConfig } from "@roo-code/types" +import { ModeManager } from "../ModeManager" +import type { CustomModesManager } from "../../core/config/CustomModesManager" + +// Mock VSCode ExtensionContext +const mockContext = { + globalState: { + get: vi.fn(), + update: vi.fn(), + }, + workspaceState: { + get: vi.fn(), + update: vi.fn(), + }, +} as unknown as ExtensionContext + +// Mock CustomModesManager +const mockCustomModesManager = { + getCustomModes: vi.fn(), + setModeDisabled: vi.fn(), + getModeDisabledState: vi.fn(), + updateCustomMode: vi.fn(), +} as unknown as CustomModesManager + +const sampleCustomModes: ModeConfig[] = [ + { + slug: "custom-mode", + name: "Custom Mode", + roleDefinition: "You are a custom assistant", + groups: ["read"], + source: "global", + disabled: false, + }, + { + slug: "project-mode", + name: "Project Mode", + roleDefinition: "You are a project-specific assistant", + groups: ["edit"], + source: "project", + disabled: false, + }, +] + +describe("ModeManager", () => { + let modeManager: ModeManager + + beforeEach(() => { + vi.clearAllMocks() + modeManager = new ModeManager(mockContext, mockCustomModesManager) + + // Setup default mock returns + ;(mockCustomModesManager.getCustomModes as Mock).mockResolvedValue(sampleCustomModes) + }) + + describe("getAllModesWithSource", () => { + it("should return all modes with source information", async () => { + const modes = await modeManager.getAllModesWithSource() + + expect(modes).toHaveLength(7) // 5 built-in + 2 custom + expect(modes.map((m) => m.slug)).toEqual( + expect.arrayContaining([ + "architect", + "code", + "ask", + "debug", + "orchestrator", + "custom-mode", + "project-mode", + ]), + ) + const architectMode = modes.find((m) => m.slug === "architect") + expect(architectMode).toMatchObject({ + slug: "architect", + source: "builtin", + }) + }) + + it("should include custom modes from CustomModesManager", async () => { + const modes = await modeManager.getAllModesWithSource() + + const customMode = modes.find((m) => m.slug === "custom-mode") + const projectMode = modes.find((m) => m.slug === "project-mode") + + expect(customMode).toMatchObject({ + slug: "custom-mode", + source: "global", + }) + expect(projectMode).toMatchObject({ + slug: "project-mode", + source: "project", + }) + }) + }) + + describe("getEnabledModes", () => { + it("should return only enabled modes", async () => { + const enabledModes = await modeManager.getEnabledModes() + + expect(enabledModes).toHaveLength(7) // All modes enabled by default + expect(enabledModes.map((m) => m.slug)).toEqual( + expect.arrayContaining([ + "architect", + "code", + "ask", + "debug", + "orchestrator", + "custom-mode", + "project-mode", + ]), + ) + }) + + it("should exclude disabled modes", async () => { + // Mock one mode as disabled + ;(mockCustomModesManager.getCustomModes as Mock).mockResolvedValue([ + { + slug: "code", + name: "Code", + roleDefinition: "You are a coding assistant", + groups: ["read", "edit", "command"], + source: "global", + disabled: true, + }, + ...sampleCustomModes, + ]) + + const enabledModes = await modeManager.getEnabledModes() + + expect(enabledModes).toHaveLength(6) // 6 enabled modes + expect(enabledModes.map((m) => m.slug)).not.toContain("code") + }) + }) + + describe("getModesBySource", () => { + it("should group modes by source correctly", async () => { + const modesBySource = await modeManager.getModesBySource() + + expect(modesBySource).toHaveProperty("builtin") + expect(modesBySource).toHaveProperty("global") + expect(modesBySource).toHaveProperty("project") + + expect(modesBySource.builtin).toHaveLength(5) // 5 built-in modes + expect(modesBySource.global).toHaveLength(1) // custom-mode + expect(modesBySource.project).toHaveLength(1) // project-mode + }) + }) + + describe("setModeDisabled", () => { + it("should call CustomModesManager.updateCustomMode for built-in modes", async () => { + await modeManager.setModeDisabled("architect", true) + + expect(mockCustomModesManager.updateCustomMode).toHaveBeenCalledWith( + "architect", + expect.objectContaining({ + slug: "architect", + disabled: true, + source: "global", + }), + ) + }) + }) + + describe("validateModeSwitch", () => { + it("should return valid for enabled modes", async () => { + const result = await modeManager.validateModeSwitch("architect") + + expect(result).toEqual({ + isValid: true, + }) + }) + + it("should return invalid for disabled modes", async () => { + // Setup: code mode is disabled via custom override + ;(mockCustomModesManager.getCustomModes as Mock).mockResolvedValue([ + { + slug: "code", + name: "Code", + roleDefinition: "You are a coding assistant", + groups: ["read", "edit", "command"], + source: "global", + disabled: true, + }, + ...sampleCustomModes, + ]) + + const result = await modeManager.validateModeSwitch("code") + + expect(result).toEqual({ + isValid: false, + errorMessage: "Mode 'code' is currently disabled.", + availableModes: expect.any(Array), + }) + }) + + it("should return invalid for non-existent modes", async () => { + const result = await modeManager.validateModeSwitch("non-existent") + + expect(result).toEqual({ + isValid: false, + errorMessage: "Mode 'non-existent' not found.", + availableModes: expect.any(Array), + }) + }) + }) +}) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index dccc1f2af0..9fd622067e 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -120,6 +120,9 @@ export interface ExtensionMessage { | "showEditMessageDialog" | "commands" | "insertTextIntoTextarea" + | "showModeEnableDisableDialog" + | "modeDisabledStatesUpdated" + | "modesBySource" text?: string payload?: any // Add a generic payload for now, can refine later action?: @@ -194,6 +197,8 @@ export interface ExtensionMessage { messageTs?: number context?: string commands?: Command[] + modes?: ModeConfig[] // For showModeEnableDisableDialog + modesBySource?: { builtin: ModeConfig[]; global: ModeConfig[]; project: ModeConfig[] } // For modesBySource response } export type ExtensionState = Pick< diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index d8b873e40a..18abcdbd6c 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -211,6 +211,9 @@ export interface WebviewMessage { | "deleteCommand" | "createCommand" | "insertTextIntoTextarea" + | "enableDisableModesClicked" + | "updateModeDisabledStates" + | "getModesBySource" | "showMdmAuthRequiredNotification" text?: string editedMessageContent?: string @@ -255,6 +258,7 @@ export interface WebviewMessage { visibility?: ShareVisibility // For share visibility hasContent?: boolean // For checkRulesDirectoryResult checkOnly?: boolean // For deleteCustomMode check + updates?: Record // For mode enable/disable bulk updates codeIndexSettings?: { // Global state settings codebaseIndexEnabled: boolean diff --git a/src/shared/modes.ts b/src/shared/modes.ts index f68d25c682..3e73029e6b 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -109,6 +109,12 @@ export function getAllModes(customModes?: ModeConfig[]): ModeConfig[] { return allModes } +// Get all enabled modes (filtered to exclude disabled modes) +export function getEnabledModes(customModes?: ModeConfig[]): ModeConfig[] { + const allModes = getAllModes(customModes) + return allModes.filter((mode) => !mode.disabled) +} + // Check if a mode is custom or an override export function isCustomMode(slug: string, customModes?: ModeConfig[]): boolean { return !!customModes?.some((mode) => mode.slug === slug) @@ -288,8 +294,8 @@ export async function getAllModesWithPrompts(context: vscode.ExtensionContext): const customModes = (await context.globalState.get("customModes")) || [] const customModePrompts = (await context.globalState.get("customModePrompts")) || {} - const allModes = getAllModes(customModes) - return allModes.map((mode) => ({ + const enabledModes = getEnabledModes(customModes) + return enabledModes.map((mode) => ({ ...mode, roleDefinition: customModePrompts[mode.slug]?.roleDefinition ?? mode.roleDefinition, whenToUse: customModePrompts[mode.slug]?.whenToUse ?? mode.whenToUse, diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index f24e4556a1..179bc445a1 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -19,6 +19,7 @@ import McpView from "./components/mcp/McpView" import { MarketplaceView } from "./components/marketplace/MarketplaceView" import ModesView from "./components/modes/ModesView" import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog" +import { ModeEnableDisableDialog } from "./components/modes/ModeEnableDisableDialog" import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog" import ErrorBoundary from "./components/ErrorBoundary" import { AccountView } from "./components/account/AccountView" @@ -100,6 +101,10 @@ const App = () => { images: [], }) + // State for Mode Enable/Disable dialog + const [modeDialogOpen, setModeDialogOpen] = useState(false) + const [modeDialogModes, setModeDialogModes] = useState([]) + const settingsRef = useRef(null) const chatViewRef = useRef(null) @@ -158,6 +163,22 @@ const App = () => { setHumanRelayDialogState({ isOpen: true, requestId, promptText }) } + if (message.type === "showModeEnableDisableDialog" && message.modes) { + setModeDialogModes(message.modes as any[]) + setModeDialogOpen(true) + } + + // Internal request from child components to open the delete flow in Modes settings + if ((message as any).type === "openDeleteModeInSettingsRequest" && (message as any).slug) { + // Switch to modes tab so ModesView is mounted and can receive the forwarded message + switchTab("modes") + // Forward the request to ModesView to trigger its delete flow + window.postMessage( + { type: "openDeleteModeInSettings", slug: (message as any).slug, name: (message as any).name }, + "*", + ) + } + if (message.type === "showDeleteMessageDialog" && message.messageTs) { setDeleteMessageDialogState({ isOpen: true, messageTs: message.messageTs }) } @@ -295,6 +316,32 @@ const App = () => { setEditMessageDialogState((prev) => ({ ...prev, isOpen: false })) }} /> + setModeDialogOpen(open)} + modes={modeDialogModes} + onSave={(updatedModes: any[]) => { + // Only send updates for modes whose disabled state actually changed. + const updates: Record = {} + const previousModesBySlug: Record = {} + for (const m of modeDialogModes) { + previousModesBySlug[m.slug] = m + } + for (const m of updatedModes) { + const prev = previousModesBySlug[m.slug] + const prevDisabled = !!(prev && prev.disabled) + const newDisabled = !!m.disabled + if (prevDisabled !== newDisabled) { + updates[m.slug] = newDisabled + } + } + if (Object.keys(updates).length > 0) { + vscode.postMessage({ type: "updateModeDisabledStates", updates }) + } + // Optimistically update local dialog modes + setModeDialogModes(updatedModes) + }} + /> ) } diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 93dd2f1f4f..828611c7d6 100644 --- a/webview-ui/src/components/chat/ModeSelector.tsx +++ b/webview-ui/src/components/chat/ModeSelector.tsx @@ -7,7 +7,7 @@ import { IconButton } from "./IconButton" import { vscode } from "@/utils/vscode" import { useExtensionState } from "@/context/ExtensionStateContext" import { useAppTranslation } from "@/i18n/TranslationContext" -import { Mode, getAllModes } from "@roo/modes" +import { Mode, getAllModes, getEnabledModes } from "@roo/modes" import { ModeConfig, CustomModePrompts } from "@roo-code/types" import { telemetryClient } from "@/utils/TelemetryClient" import { TelemetryEventName } from "@roo-code/types" @@ -58,31 +58,40 @@ export const ModeSelector = ({ }, [hasOpenedModeSelector, setHasOpenedModeSelector]) // Get all modes including custom modes and merge custom prompt descriptions - const modes = React.useMemo(() => { - const allModes = getAllModes(customModes) - return allModes.map((mode) => ({ + const allModes = React.useMemo(() => { + const modes = getAllModes(customModes) + return modes.map((mode) => ({ + ...mode, + description: customModePrompts?.[mode.slug]?.description ?? mode.description, + })) + }, [customModes, customModePrompts]) + + // Get only enabled modes for the dropdown + const enabledModes = React.useMemo(() => { + const modes = getEnabledModes(customModes) + return modes.map((mode) => ({ ...mode, description: customModePrompts?.[mode.slug]?.description ?? mode.description, })) }, [customModes, customModePrompts]) - // Find the selected mode - const selectedMode = React.useMemo(() => modes.find((mode) => mode.slug === value), [modes, value]) + // Find the selected mode (search in all modes, not just enabled) + const selectedMode = React.useMemo(() => allModes.find((mode) => mode.slug === value), [allModes, value]) // Memoize searchable items for fuzzy search with separate name and description search const nameSearchItems = React.useMemo(() => { - return modes.map((mode) => ({ + return enabledModes.map((mode) => ({ original: mode, searchStr: [mode.name, mode.slug].filter(Boolean).join(" "), })) - }, [modes]) + }, [enabledModes]) const descriptionSearchItems = React.useMemo(() => { - return modes.map((mode) => ({ + return enabledModes.map((mode) => ({ original: mode, searchStr: mode.description || "", })) - }, [modes]) + }, [enabledModes]) // Create memoized Fzf instances for name and description searches const nameFzfInstance = React.useMemo(() => { @@ -99,7 +108,7 @@ export const ModeSelector = ({ // Filter modes based on search value using fuzzy search with priority const filteredModes = React.useMemo(() => { - if (!searchValue) return modes + if (!searchValue) return enabledModes // First search in names/slugs const nameMatches = nameFzfInstance.find(searchValue) @@ -117,7 +126,7 @@ export const ModeSelector = ({ ] return combinedResults - }, [modes, searchValue, nameFzfInstance, descriptionFzfInstance]) + }, [enabledModes, searchValue, nameFzfInstance, descriptionFzfInstance]) const onClearSearch = React.useCallback(() => { setSearchValue("") @@ -154,7 +163,7 @@ export const ModeSelector = ({ }, [open]) // Determine if search should be shown - const showSearch = !disableSearch && modes.length > SEARCH_THRESHOLD + const showSearch = !disableSearch && enabledModes.length > SEARCH_THRESHOLD // Combine instruction text for tooltip const instructionText = `${t("chat:modeSelector.description")} ${modeShortcutText}` @@ -255,6 +264,14 @@ export const ModeSelector = ({ {/* Bottom bar with buttons on left and title on right */}
+ { + vscode.postMessage({ type: "enableDisableModesClicked" }) + setOpen(false) + }} + /> { return { ...actual, getAllModes: () => mockModes, + getEnabledModes: () => mockModes.filter((mode) => !mode.disabled), } }) diff --git a/webview-ui/src/components/modes/ModeEnableDisableDialog.tsx b/webview-ui/src/components/modes/ModeEnableDisableDialog.tsx new file mode 100644 index 0000000000..88c1ca5b6a --- /dev/null +++ b/webview-ui/src/components/modes/ModeEnableDisableDialog.tsx @@ -0,0 +1,455 @@ +import React, { useState, useEffect } from "react" +import { Eye, EyeOff, HelpCircle } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Button, + Checkbox, + Badge, + Separator, + StandardTooltip, +} from "@src/components/ui" +import { cn } from "@/lib/utils" +import type { ModeConfig } from "@roo-code/types" +import { useAppTranslation } from "@src/i18n/TranslationContext" + +const SOURCE_INFO = { + builtin: { + label: "Built-in Modes", + description: "Core modes provided by Roo Code", + icon: "🏠", + color: "builtin", + }, + global: { + label: "Global Modes", + description: "Custom modes available across all workspaces", + icon: "🌐", + color: "global", + }, + project: { + label: "Project Modes", + description: "Custom modes specific to this workspace", + icon: "📁", + color: "project", + }, +} as const + +export type ModeSource = "builtin" | "global" | "project" + +export interface ModeWithSource extends ModeConfig { + source: ModeSource + disabled?: boolean + overriddenBy?: ModeSource +} + +interface ModeEnableDisableDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + modes: ModeWithSource[] + onSave: (updatedModes: ModeWithSource[]) => void +} + +interface GroupedModes { + builtin: ModeWithSource[] + global: ModeWithSource[] + project: ModeWithSource[] +} + +// const SOURCE_INFO = { +// builtin: { +// label: "Built-in Modes", +// description: "Core modes provided by Roo Code", +// icon: "🏠", +// color: "bg-blue-100 text-blue-800 border-blue-200", +// }, +// global: { +// label: "Global Modes", +// description: "Custom modes available across all workspaces", +// icon: "🌐", +// color: "bg-green-100 text-green-800 border-green-200", +// }, +// project: { +// label: "Project Modes", +// description: "Modes specific to this workspace", +// icon: "📁", +// color: "bg-purple-100 text-purple-800 border-purple-200", +// }, +// } as const + +export const ModeEnableDisableDialog: React.FC = ({ + open, + onOpenChange, + modes, + onSave, +}) => { + const [localModes, setLocalModes] = useState(modes) + const [hasChanges, setHasChanges] = useState(false) + const [confirmOpen, setConfirmOpen] = useState(false) + const [pendingAction, setPendingAction] = useState< + | { type: "mode"; payload: string } + | { type: "source"; payload: { source: ModeSource; enable: boolean } } + | { type: "all"; payload: { enable: boolean } } + | null + >(null) + + useAppTranslation() + + // Delete handled in the mode settings window; no delete UI in this dialog. + + // Update local state when props change + useEffect(() => { + setLocalModes(modes) + setHasChanges(false) + }, [modes]) + + // Group modes by source + const groupedModes: GroupedModes = React.useMemo(() => { + return localModes.reduce( + (acc, mode) => { + acc[mode.source].push(mode) + return acc + }, + { builtin: [], global: [], project: [] } as GroupedModes, + ) + }, [localModes]) + + // Calculate statistics + const stats = React.useMemo(() => { + const total = localModes.length + const enabled = localModes.filter((m) => !m.disabled).length + const disabled = total - enabled + + return { total, enabled, disabled } + }, [localModes]) + + // Toggle a single mode's disabled state + const doToggleModeImmediate = (slug: string) => { + setLocalModes((prev) => { + const updated = prev.map((mode) => (mode.slug === slug ? { ...mode, disabled: !mode.disabled } : mode)) + return updated + }) + setHasChanges(true) + } + + const attemptToggleMode = (slug: string) => { + const mode = localModes.find((m) => m.slug === slug) + if (!mode) return + // If disabling a builtin that is currently enabled, show confirmation + if (mode.source === "builtin" && !mode.disabled) { + setPendingAction({ type: "mode", payload: slug }) + setConfirmOpen(true) + return + } + doToggleModeImmediate(slug) + } + + // Toggle all modes in a source group (with confirmation for builtin disables) + const doToggleSourceGroupImmediate = (source: ModeSource, enable: boolean) => { + setLocalModes((prev) => { + const updated = prev.map((mode) => (mode.source === source ? { ...mode, disabled: !enable } : mode)) + return updated + }) + setHasChanges(true) + } + + const attemptToggleSourceGroup = (source: ModeSource, enable: boolean) => { + if (source === "builtin" && enable === false) { + // find builtin slugs that would be disabled + const toDisable = localModes.filter((m) => m.source === "builtin" && !m.disabled).map((m) => m.slug) + if (toDisable.length > 0) { + setPendingAction({ type: "source", payload: { source, enable } }) + setConfirmOpen(true) + return + } + } + doToggleSourceGroupImmediate(source, enable) + } + + // Enable/disable all modes (with confirmation for builtin disables) + const doToggleAllModesImmediate = (enable: boolean) => { + setLocalModes((prev) => { + const updated = prev.map((mode) => ({ ...mode, disabled: !enable })) + return updated + }) + setHasChanges(true) + } + + const attemptToggleAllModes = (enable: boolean) => { + if (enable === false) { + const toDisable = localModes.filter((m) => m.source === "builtin" && !m.disabled).map((m) => m.slug) + if (toDisable.length > 0) { + setPendingAction({ type: "all", payload: { enable } }) + setConfirmOpen(true) + return + } + } + doToggleAllModesImmediate(enable) + } + + // Handle save + const handleSave = () => { + onSave(localModes) + setHasChanges(false) + onOpenChange(false) + } + + // Handle cancel + const handleCancel = () => { + setLocalModes(modes) // Reset to original state + setHasChanges(false) + onOpenChange(false) + } + + // Mode item component + const ModeItem: React.FC<{ mode: ModeWithSource }> = ({ mode }) => ( +
+
+ attemptToggleMode(mode.slug)} + className="flex-shrink-0" + /> +
+
+ {mode.name} + + {SOURCE_INFO[mode.source].icon} {mode.source} + + {mode.overriddenBy && ( + + Overridden by {mode.overriddenBy} + + )} +
+ {mode.description && ( +

+ {mode.description} +

+ )} +
slug: {mode.slug}
+
+
+
+ {mode.disabled ? : } + {/* Show delete for global custom modes (they override built-in or are user-created) */} + {(mode.source === "global" || mode.source === "project") && ( +
+ + + {(mode as any).overridesBuiltin && ( + + )} +
+ )} +
+
+ ) + + // No delete handlers here — deletion lives in the dedicated mode settings UI. + + // Source group component + const SourceGroup: React.FC<{ source: ModeSource; modes: ModeWithSource[] }> = ({ source, modes }) => { + const sourceInfo = SOURCE_INFO[source] + const enabled = modes.filter((m) => !m.disabled).length + const total = modes.length + const allEnabled = enabled === total + const noneEnabled = enabled === 0 + + return ( +
+
+
+

+ {sourceInfo.icon} {sourceInfo.label} +

+ + + + + {enabled}/{total} enabled + +
+
+ + {source !== "builtin" && ( + + )} +
+
+ {modes.length === 0 ? ( +
Nothing to see here.
+ ) : ( +
+ {modes.map((mode) => ( + + ))} +
+ )} +
+ ) + } + + return ( + + + + + + Enable/Disable Modes + + + Control which modes are available for use. Disabled modes will not appear in the mode selector + and will be excluded from system prompts to reduce token usage. + + + + {/* Statistics and bulk actions */} +
+
+ + {stats.total} total modes + + + {stats.enabled} enabled + + + {stats.disabled} disabled + +
+
+ + {/* Per design, omit a global "Disable All" here */} +
+
+ + {/* Scrollable content */} +
+ + {groupedModes.global.length > 0 && ( + <> + + + + )} + {groupedModes.project.length > 0 && ( + <> + + + + )} +
+ + {/* Confirmation dialog shown when disabling built-in modes */} + {confirmOpen && ( +
+
+

Disabling built-in mode(s)

+

+ Disabling a built-in mode will copy it to your custom_model.yaml so you can modify or + delete the custom copy. You can restore the original built-in mode later by deleting the + custom mode in Mode Settings. +

+
+ + +
+
+
+ )} + + +
{hasChanges && "You have unsaved changes"}
+
+ + +
+
+ + {/* Delete handled in settings; confirmation dialog removed from this popup */} +
+
+ ) +} diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index 0283fbbec4..1ded35c045 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -471,6 +471,13 @@ const ModesView = ({ onDone }: ModesViewProps) => { ...prev, [message.slug]: message.hasContent, })) + } else if (message.type === "openDeleteModeInSettings") { + // Received forwarded request to open the delete flow for a specific mode. + if (message.slug && message.name) { + setModeToDelete({ slug: message.slug, name: message.name, source: "global" }) + // Ask the extension to check for rules folder and return path via message + vscode.postMessage({ type: "deleteCustomMode", slug: message.slug, checkOnly: true }) + } } else if (message.type === "deleteCustomModeCheck") { // Handle the check response // Use the ref to get the current modeToDelete value @@ -523,23 +530,7 @@ const ModesView = ({ onDone }: ModesViewProps) => {
- - - + {/* Single Edit modes configuration button (removed duplicate) */} {showConfigMenu && (
e.stopPropagation()} @@ -577,6 +568,36 @@ const ModesView = ({ onDone }: ModesViewProps) => {
)}
+ + + + {/* Enable/Disable Modes button - placed between Edit config and Marketplace */} + + +