From 11e4f6933bc610be07e47b13c116d2c06e2c533c Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 28 Jul 2025 18:35:39 +0000 Subject: [PATCH] feat: implement Phase 3 of mode-to-agent renaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated all function names from mode to agent terminology - Updated all variable names throughout the codebase - Updated property names in objects and interfaces - Updated event names (TaskModeSwitched → TaskAgentSwitched) - Maintained backward compatibility with aliases for all renamed functions - Preserved "mode" terminology in marketplace-related code and .roomodes files - Fixed test expectations to maintain backward compatibility - Fixed enum duplicate value lint error - All tests passing (3044 passed, 48 skipped) --- packages/types/src/ipc.ts | 11 +- packages/types/src/mode.ts | 37 +- .../presentAssistantMessage.ts | 14 +- src/core/config/CustomAgentsManager.ts | 1033 +++++++++++ src/core/config/CustomModesManager.ts | 1000 +--------- src/core/config/ProviderSettingsManager.ts | 59 +- .../__tests__/ProviderSettingsManager.spec.ts | 2 +- src/core/config/importExport.ts | 4 +- src/core/task/Task.ts | 26 +- src/core/tools/newTaskTool.ts | 32 +- src/core/tools/switchModeTool.ts | 38 +- src/core/tools/validateToolUse.ts | 10 +- src/core/webview/ClineProvider.ts | 2 +- src/core/webview/generateSystemPrompt.ts | 12 +- src/core/webview/webviewMessageHandler.ts | 78 +- src/extension/api.ts | 4 +- src/shared/agents.ts | 450 +++++ src/shared/modes.ts | 385 +--- .../src/components/agents/AgentsView.tsx | 1649 ++++++++++++++++ .../components/agents/DeleteAgentDialog.tsx | 61 + .../__tests__/AgentsView.spec.tsx} | 14 +- .../src/components/chat/AgentSelector.tsx | 304 +++ .../src/components/chat/ModeSelector.tsx | 307 +-- .../chat/__tests__/ModeSelector.spec.tsx | 8 +- .../src/components/modes/DeleteModeDialog.tsx | 59 +- webview-ui/src/components/modes/ModesView.tsx | 1652 +---------------- 26 files changed, 3705 insertions(+), 3546 deletions(-) create mode 100644 src/core/config/CustomAgentsManager.ts create mode 100644 src/shared/agents.ts create mode 100644 webview-ui/src/components/agents/AgentsView.tsx create mode 100644 webview-ui/src/components/agents/DeleteAgentDialog.tsx rename webview-ui/src/components/{modes/__tests__/ModesView.spec.tsx => agents/__tests__/AgentsView.spec.tsx} (95%) create mode 100644 webview-ui/src/components/chat/AgentSelector.tsx diff --git a/packages/types/src/ipc.ts b/packages/types/src/ipc.ts index 28accde9de..25d79c46f4 100644 --- a/packages/types/src/ipc.ts +++ b/packages/types/src/ipc.ts @@ -20,7 +20,7 @@ export enum RooCodeEventName { Message = "message", TaskCreated = "taskCreated", TaskStarted = "taskStarted", - TaskModeSwitched = "taskModeSwitched", + TaskAgentSwitched = "taskAgentSwitched", TaskPaused = "taskPaused", TaskUnpaused = "taskUnpaused", TaskAskResponded = "taskAskResponded", @@ -33,6 +33,9 @@ export enum RooCodeEventName { EvalFail = "evalFail", } +// Backward compatibility alias +export const TaskModeSwitched = RooCodeEventName.TaskAgentSwitched + export const rooCodeEventsSchema = z.object({ [RooCodeEventName.Message]: z.tuple([ z.object({ @@ -43,7 +46,7 @@ export const rooCodeEventsSchema = z.object({ ]), [RooCodeEventName.TaskCreated]: z.tuple([z.string()]), [RooCodeEventName.TaskStarted]: z.tuple([z.string()]), - [RooCodeEventName.TaskModeSwitched]: z.tuple([z.string(), z.string()]), + [RooCodeEventName.TaskAgentSwitched]: z.tuple([z.string(), z.string()]), [RooCodeEventName.TaskPaused]: z.tuple([z.string()]), [RooCodeEventName.TaskUnpaused]: z.tuple([z.string()]), [RooCodeEventName.TaskAskResponded]: z.tuple([z.string()]), @@ -121,8 +124,8 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [ taskId: z.number().optional(), }), z.object({ - eventName: z.literal(RooCodeEventName.TaskModeSwitched), - payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskModeSwitched], + eventName: z.literal(RooCodeEventName.TaskAgentSwitched), + payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskAgentSwitched], taskId: z.number().optional(), }), z.object({ diff --git a/packages/types/src/mode.ts b/packages/types/src/mode.ts index 88dcbb9574..660d7cf62f 100644 --- a/packages/types/src/mode.ts +++ b/packages/types/src/mode.ts @@ -39,7 +39,7 @@ export const groupEntrySchema = z.union([toolGroupsSchema, z.tuple([toolGroupsSc export type GroupEntry = z.infer /** - * ModeConfig + * AgentConfig */ const groupEntryArraySchema = z.array(groupEntrySchema).refine( @@ -61,7 +61,7 @@ const groupEntryArraySchema = z.array(groupEntrySchema).refine( { message: "Duplicate groups are not allowed" }, ) -export const modeConfigSchema = z.object({ +export const agentConfigSchema = z.object({ slug: z.string().regex(/^[a-zA-Z0-9-]+$/, "Slug must contain only letters numbers and dashes"), name: z.string().min(1, "Name is required"), roleDefinition: z.string().min(1, "Role definition is required"), @@ -72,14 +72,18 @@ export const modeConfigSchema = z.object({ source: z.enum(["global", "project"]).optional(), }) -export type ModeConfig = z.infer +export type AgentConfig = z.infer + +// Keep ModeConfig as an alias for backward compatibility (will be removed in later phase) +export const modeConfigSchema = agentConfigSchema +export type ModeConfig = AgentConfig /** - * CustomModesSettings + * CustomAgentsSettings */ -export const customModesSettingsSchema = z.object({ - customModes: z.array(modeConfigSchema).refine( +export const customAgentsSettingsSchema = z.object({ + customModes: z.array(agentConfigSchema).refine( (modes) => { const slugs = new Set() @@ -98,7 +102,11 @@ export const customModesSettingsSchema = z.object({ ), }) -export type CustomModesSettings = z.infer +export type CustomAgentsSettings = z.infer + +// Keep CustomModesSettings as an alias for backward compatibility (will be removed in later phase) +export const customModesSettingsSchema = customAgentsSettingsSchema +export type CustomModesSettings = CustomAgentsSettings /** * PromptComponent @@ -114,12 +122,16 @@ export const promptComponentSchema = z.object({ export type PromptComponent = z.infer /** - * CustomModePrompts + * CustomAgentPrompts */ -export const customModePromptsSchema = z.record(z.string(), promptComponentSchema.optional()) +export const customAgentPromptsSchema = z.record(z.string(), promptComponentSchema.optional()) -export type CustomModePrompts = z.infer +export type CustomAgentPrompts = z.infer + +// Keep CustomModePrompts as an alias for backward compatibility (will be removed in later phase) +export const customModePromptsSchema = customAgentPromptsSchema +export type CustomModePrompts = CustomAgentPrompts /** * CustomSupportPrompts @@ -133,7 +145,7 @@ export type CustomSupportPrompts = z.infer * DEFAULT_MODES */ -export const DEFAULT_MODES: readonly ModeConfig[] = [ +export const DEFAULT_AGENTS: readonly AgentConfig[] = [ { slug: "architect", name: "🏗️ Architect", @@ -193,3 +205,6 @@ export const DEFAULT_MODES: readonly ModeConfig[] = [ "Your role is to coordinate complex workflows by delegating tasks to specialized modes. As an orchestrator, you should:\n\n1. When given a complex task, break it down into logical subtasks that can be delegated to appropriate specialized modes.\n\n2. For each subtask, use the `new_task` tool to delegate. Choose the most appropriate mode for the subtask's specific goal and provide comprehensive instructions in the `message` parameter. These instructions must include:\n * All necessary context from the parent task or previous subtasks required to complete the work.\n * A clearly defined scope, specifying exactly what the subtask should accomplish.\n * An explicit statement that the subtask should *only* perform the work outlined in these instructions and not deviate.\n * An instruction for the subtask to signal completion by using the `attempt_completion` tool, providing a concise yet thorough summary of the outcome in the `result` parameter, keeping in mind that this summary will be the source of truth used to keep track of what was completed on this project.\n * A statement that these specific instructions supersede any conflicting general instructions the subtask's mode might have.\n\n3. Track and manage the progress of all subtasks. When a subtask is completed, analyze its results and determine the next steps.\n\n4. Help the user understand how the different subtasks fit together in the overall workflow. Provide clear reasoning about why you're delegating specific tasks to specific modes.\n\n5. When all subtasks are completed, synthesize the results and provide a comprehensive overview of what was accomplished.\n\n6. Ask clarifying questions when necessary to better understand how to break down complex tasks effectively.\n\n7. Suggest improvements to the workflow based on the results of completed subtasks.\n\nUse subtasks to maintain clarity. If a request significantly shifts focus or requires a different expertise (mode), consider creating a subtask rather than overloading the current one.", }, ] as const + +// Keep DEFAULT_MODES as an alias for backward compatibility (will be removed in later phase) +export const DEFAULT_MODES = DEFAULT_AGENTS diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index ee3fa148b4..6f2ac4812f 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -4,7 +4,7 @@ import { serializeError } from "serialize-error" import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { defaultModeSlug, getModeBySlug } from "../../shared/modes" +import { defaultAgentSlug, getAgentBySlug } from "../../shared/agents" import type { ToolParamName, ToolResponse } from "../../shared/tools" import { fetchInstructionsTool } from "../tools/fetchInstructionsTool" @@ -209,10 +209,10 @@ export async function presentAssistantMessage(cline: Task) { case "update_todo_list": return `[${block.name}]` case "new_task": { - const mode = block.params.mode ?? defaultModeSlug + const agent = block.params.mode ?? defaultAgentSlug const message = block.params.message ?? "(no message)" - const modeName = getModeBySlug(mode, customModes)?.name ?? mode - return `[${block.name} in ${modeName} mode: '${message}']` + // We'll get the custom agents when we actually need them + return `[${block.name} in ${agent} agent: '${message}']` } } } @@ -352,13 +352,13 @@ export async function presentAssistantMessage(cline: Task) { } // Validate tool use before execution. - const { mode, customModes } = (await cline.providerRef.deref()?.getState()) ?? {} + const { mode: agent, customModes: customAgents } = (await cline.providerRef.deref()?.getState()) ?? {} try { validateToolUse( block.name as ToolName, - mode ?? defaultModeSlug, - customModes ?? [], + agent ?? defaultAgentSlug, + customAgents ?? [], { apply_diff: cline.diffEnabled }, block.params, ) diff --git a/src/core/config/CustomAgentsManager.ts b/src/core/config/CustomAgentsManager.ts new file mode 100644 index 0000000000..f7e42a6eeb --- /dev/null +++ b/src/core/config/CustomAgentsManager.ts @@ -0,0 +1,1033 @@ +import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs/promises" +import * as os from "os" + +import * as yaml from "yaml" +import stripBom from "strip-bom" + +import { + type AgentConfig, + type ModeConfig, + type PromptComponent, + customAgentsSettingsSchema, + customModesSettingsSchema, + agentConfigSchema, + modeConfigSchema, +} from "@roo-code/types" + +import { fileExistsAtPath } from "../../utils/fs" +import { getWorkspacePath } from "../../utils/path" +import { getGlobalRooDirectory } from "../../services/roo-config" +import { logger } from "../../utils/logging" +import { GlobalFileNames } from "../../shared/globalFileNames" +import { ensureSettingsDirectoryExists } from "../../utils/globalContext" +import { t } from "../../i18n" + +const ROOMODES_FILENAME = ".roomodes" + +// Type definitions for import/export functionality +interface RuleFile { + relativePath: string + content: string +} + +interface ExportedAgentConfig extends AgentConfig { + rulesFiles?: RuleFile[] +} + +// Keep ExportedModeConfig as an alias for backward compatibility +type ExportedModeConfig = ExportedAgentConfig + +interface ImportData { + customModes: ExportedAgentConfig[] +} + +interface ExportResult { + success: boolean + yaml?: string + error?: string +} + +interface ImportResult { + success: boolean + error?: string +} + +export class CustomAgentsManager { + private static readonly cacheTTL = 10_000 + + private disposables: vscode.Disposable[] = [] + private isWriting = false + private writeQueue: Array<() => Promise> = [] + private cachedAgents: AgentConfig[] | null = null + private cachedAt: number = 0 + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly onUpdate: () => Promise, + ) { + this.watchCustomModesFiles().catch((error) => { + console.error("[CustomModesManager] Failed to setup file watchers:", error) + }) + } + + private async queueWrite(operation: () => Promise): Promise { + this.writeQueue.push(operation) + + if (!this.isWriting) { + await this.processWriteQueue() + } + } + + private async processWriteQueue(): Promise { + if (this.isWriting || this.writeQueue.length === 0) { + return + } + + this.isWriting = true + + try { + while (this.writeQueue.length > 0) { + const operation = this.writeQueue.shift() + + if (operation) { + await operation() + } + } + } finally { + this.isWriting = false + } + } + + private async getWorkspaceRoomodes(): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders + + if (!workspaceFolders || workspaceFolders.length === 0) { + return undefined + } + + const workspaceRoot = getWorkspacePath() + const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME) + const exists = await fileExistsAtPath(roomodesPath) + return exists ? roomodesPath : undefined + } + + /** + * Regex pattern for problematic characters that need to be cleaned from YAML content + * Includes: + * - \u00A0: Non-breaking space + * - \u200B-\u200D: Zero-width spaces and joiners + * - \u2010-\u2015, \u2212: Various dash characters + * - \u2018-\u2019: Smart single quotes + * - \u201C-\u201D: Smart double quotes + */ + private static readonly PROBLEMATIC_CHARS_REGEX = + // eslint-disable-next-line no-misleading-character-class + /[\u00A0\u200B\u200C\u200D\u2010\u2011\u2012\u2013\u2014\u2015\u2212\u2018\u2019\u201C\u201D]/g + + /** + * Clean invisible and problematic characters from YAML content + */ + private cleanInvisibleCharacters(content: string): string { + // Single pass replacement for all problematic characters + return content.replace(CustomAgentsManager.PROBLEMATIC_CHARS_REGEX, (match) => { + switch (match) { + case "\u00A0": // Non-breaking space + return " " + case "\u200B": // Zero-width space + case "\u200C": // Zero-width non-joiner + case "\u200D": // Zero-width joiner + return "" + case "\u2018": // Left single quotation mark + case "\u2019": // Right single quotation mark + return "'" + case "\u201C": // Left double quotation mark + case "\u201D": // Right double quotation mark + return '"' + default: // Dash characters (U+2010 through U+2015, U+2212) + return "-" + } + }) + } + + /** + * Parse YAML content with enhanced error handling and preprocessing + */ + private parseYamlSafely(content: string, filePath: string): any { + // Clean the content + let cleanedContent = stripBom(content) + cleanedContent = this.cleanInvisibleCharacters(cleanedContent) + + try { + const parsed = yaml.parse(cleanedContent) + // Ensure we never return null or undefined + return parsed ?? {} + } catch (yamlError) { + // For .roomodes files, try JSON as fallback + if (filePath.endsWith(ROOMODES_FILENAME)) { + try { + // Try parsing the original content as JSON (not the cleaned content) + return JSON.parse(content) + } catch (jsonError) { + // JSON also failed, show the original YAML error + const errorMsg = yamlError instanceof Error ? yamlError.message : String(yamlError) + console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, errorMsg) + + const lineMatch = errorMsg.match(/at line (\d+)/) + const line = lineMatch ? lineMatch[1] : "unknown" + vscode.window.showErrorMessage(t("common:customModes.errors.yamlParseError", { line })) + + // Return empty object to prevent duplicate error handling + return {} + } + } + + // For non-.roomodes files, just log and return empty object + const errorMsg = yamlError instanceof Error ? yamlError.message : String(yamlError) + console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, errorMsg) + return {} + } + } + + private async loadAgentsFromFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf-8") + const settings = this.parseYamlSafely(content, filePath) + + // Ensure settings has customModes property + if (!settings || typeof settings !== "object" || !settings.customModes) { + return [] + } + + const result = customAgentsSettingsSchema.safeParse(settings) + + if (!result.success) { + console.error(`[CustomModesManager] Schema validation failed for ${filePath}:`, result.error) + + // Show user-friendly error for .roomodes files + if (filePath.endsWith(ROOMODES_FILENAME)) { + const issues = result.error.issues + .map((issue) => `• ${issue.path.join(".")}: ${issue.message}`) + .join("\n") + + vscode.window.showErrorMessage(t("common:customModes.errors.schemaValidationError", { issues })) + } + + return [] + } + + // Determine source based on file path + const isRoomodes = filePath.endsWith(ROOMODES_FILENAME) + const source = isRoomodes ? ("project" as const) : ("global" as const) + + // Add source to each mode + return result.data.customModes.map((mode) => ({ ...mode, source })) + } catch (error) { + // Only log if the error wasn't already handled in parseYamlSafely + if (!(error as any).alreadyHandled) { + const errorMsg = `Failed to load modes from ${filePath}: ${error instanceof Error ? error.message : String(error)}` + console.error(`[CustomModesManager] ${errorMsg}`) + } + return [] + } + } + + private async mergeCustomAgents(projectAgents: AgentConfig[], globalAgents: AgentConfig[]): Promise { + const slugs = new Set() + const merged: AgentConfig[] = [] + + // Add project agent (takes precedence) + for (const agent of projectAgents) { + if (!slugs.has(agent.slug)) { + slugs.add(agent.slug) + merged.push({ ...agent, source: "project" }) + } + } + + // Add non-duplicate global agents + for (const agent of globalAgents) { + if (!slugs.has(agent.slug)) { + slugs.add(agent.slug) + merged.push({ ...agent, source: "global" }) + } + } + + return merged + } + + // Keep mergeCustomModes as an alias for backward compatibility + private async mergeCustomModes(projectModes: ModeConfig[], globalModes: ModeConfig[]): Promise { + return this.mergeCustomAgents(projectModes, globalModes) + } + + public async getCustomModesFilePath(): Promise { + const settingsDir = await ensureSettingsDirectoryExists(this.context) + const filePath = path.join(settingsDir, GlobalFileNames.customModes) + const fileExists = await fileExistsAtPath(filePath) + + if (!fileExists) { + await this.queueWrite(() => fs.writeFile(filePath, yaml.stringify({ customModes: [] }, { lineWidth: 0 }))) + } + + return filePath + } + + private async watchCustomModesFiles(): Promise { + // Skip if test environment is detected + if (process.env.NODE_ENV === "test") { + return + } + + const settingsPath = await this.getCustomModesFilePath() + + // Watch settings file + const settingsWatcher = vscode.workspace.createFileSystemWatcher(settingsPath) + + const handleSettingsChange = async () => { + try { + // Ensure that the settings file exists (especially important for delete events) + await this.getCustomModesFilePath() + const content = await fs.readFile(settingsPath, "utf-8") + + const errorMessage = t("common:customModes.errors.invalidFormat") + + let config: any + + try { + config = this.parseYamlSafely(content, settingsPath) + } catch (error) { + console.error(error) + vscode.window.showErrorMessage(errorMessage) + return + } + + const result = customAgentsSettingsSchema.safeParse(config) + + if (!result.success) { + vscode.window.showErrorMessage(errorMessage) + return + } + + // Get modes from .roomodes if it exists (takes precedence) + const roomodesPath = await this.getWorkspaceRoomodes() + const roomodesModes = roomodesPath ? await this.loadAgentsFromFile(roomodesPath) : [] + + // Merge modes from both sources (.roomodes takes precedence) + const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes) + await this.context.globalState.update("customModes", mergedModes) + this.clearCache() + await this.onUpdate() + } catch (error) { + console.error(`[CustomModesManager] Error handling settings file change:`, error) + } + } + + this.disposables.push(settingsWatcher.onDidChange(handleSettingsChange)) + this.disposables.push(settingsWatcher.onDidCreate(handleSettingsChange)) + this.disposables.push(settingsWatcher.onDidDelete(handleSettingsChange)) + this.disposables.push(settingsWatcher) + + // Watch .roomodes file - watch the path even if it doesn't exist yet + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders && workspaceFolders.length > 0) { + const workspaceRoot = getWorkspacePath() + const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME) + const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPath) + + const handleRoomodesChange = async () => { + try { + const settingsModes = await this.loadAgentsFromFile(settingsPath) + const roomodesModes = await this.loadAgentsFromFile(roomodesPath) + // .roomodes takes precedence + const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes) + await this.context.globalState.update("customModes", mergedModes) + this.clearCache() + await this.onUpdate() + } catch (error) { + console.error(`[CustomModesManager] Error handling .roomodes file change:`, error) + } + } + + this.disposables.push(roomodesWatcher.onDidChange(handleRoomodesChange)) + this.disposables.push(roomodesWatcher.onDidCreate(handleRoomodesChange)) + this.disposables.push( + roomodesWatcher.onDidDelete(async () => { + // When .roomodes is deleted, refresh with only settings modes + try { + const settingsModes = await this.loadAgentsFromFile(settingsPath) + await this.context.globalState.update("customModes", settingsModes) + this.clearCache() + await this.onUpdate() + } catch (error) { + console.error(`[CustomModesManager] Error handling .roomodes file deletion:`, error) + } + }), + ) + this.disposables.push(roomodesWatcher) + } + } + + public async getCustomAgents(): Promise { + // Check if we have a valid cached result. + const now = Date.now() + + if (this.cachedAgents && now - this.cachedAt < CustomAgentsManager.cacheTTL) { + return this.cachedAgents + } + + // Get agents from settings file. + const settingsPath = await this.getCustomModesFilePath() + const settingsAgents = await this.loadAgentsFromFile(settingsPath) + + // Get agents from .roomodes if it exists. + const roomodesPath = await this.getWorkspaceRoomodes() + const roomodesAgents = roomodesPath ? await this.loadAgentsFromFile(roomodesPath) : [] + + // Create maps to store agents by source. + const projectAgents = new Map() + const globalAgents = new Map() + + // Add project agents (they take precedence). + for (const agent of roomodesAgents) { + projectAgents.set(agent.slug, { ...agent, source: "project" as const }) + } + + // Add global agents. + for (const agent of settingsAgents) { + if (!projectAgents.has(agent.slug)) { + globalAgents.set(agent.slug, { ...agent, source: "global" as const }) + } + } + + // Combine agents in the correct order: project agents first, then global agents. + const mergedAgents = [ + ...roomodesAgents.map((agent) => ({ ...agent, source: "project" as const })), + ...settingsAgents + .filter((agent) => !projectAgents.has(agent.slug)) + .map((agent) => ({ ...agent, source: "global" as const })), + ] + + await this.context.globalState.update("customModes", mergedAgents) + + this.cachedAgents = mergedAgents + this.cachedAt = now + + return mergedAgents + } + + // Keep getCustomModes as an alias for backward compatibility + public async getCustomModes(): Promise { + return this.getCustomAgents() + } + + public async updateCustomAgent(slug: string, config: AgentConfig): Promise { + try { + // Validate the agent configuration before saving + const validationResult = agentConfigSchema.safeParse(config) + if (!validationResult.success) { + const errors = validationResult.error.errors.map((e) => e.message).join(", ") + logger.error(`Invalid agent configuration for ${slug}`, { errors: validationResult.error.errors }) + throw new Error(`Invalid agent configuration: ${errors}`) + } + + const isProjectMode = config.source === "project" + let targetPath: string + + if (isProjectMode) { + const workspaceFolders = vscode.workspace.workspaceFolders + + if (!workspaceFolders || workspaceFolders.length === 0) { + logger.error("Failed to update project mode: No workspace folder found", { slug }) + throw new Error(t("common:customModes.errors.noWorkspaceForProject")) + } + + const workspaceRoot = getWorkspacePath() + targetPath = path.join(workspaceRoot, ROOMODES_FILENAME) + const exists = await fileExistsAtPath(targetPath) + + logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, { + slug, + workspace: workspaceRoot, + }) + } else { + targetPath = await this.getCustomModesFilePath() + } + + await this.queueWrite(async () => { + // Ensure source is set correctly based on target file. + const modeWithSource = { + ...config, + source: isProjectMode ? ("project" as const) : ("global" as const), + } + + await this.updateAgentsInFile(targetPath, (modes) => { + const updatedModes = modes.filter((m: AgentConfig) => m.slug !== slug) + updatedModes.push(modeWithSource) + return updatedModes + }) + + this.clearCache() + await this.refreshMergedState() + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to update custom mode", { slug, error: errorMessage }) + vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage })) + } + } + + // Keep updateCustomMode as an alias for backward compatibility + public async updateCustomMode(slug: string, config: ModeConfig): Promise { + return this.updateCustomAgent(slug, config) + } + + private async updateAgentsInFile( + filePath: string, + operation: (agents: AgentConfig[]) => AgentConfig[], + ): Promise { + let content = "{}" + + try { + content = await fs.readFile(filePath, "utf-8") + } catch (error) { + // File might not exist yet. + content = yaml.stringify({ customModes: [] }, { lineWidth: 0 }) + } + + let settings + + try { + settings = this.parseYamlSafely(content, filePath) + } catch (error) { + // Error already logged in parseYamlSafely + settings = { customModes: [] } + } + + // Ensure settings is an object and has customModes property + if (!settings || typeof settings !== "object") { + settings = { customModes: [] } + } + if (!settings.customModes) { + settings.customModes = [] + } + + settings.customModes = operation(settings.customModes) + await fs.writeFile(filePath, yaml.stringify(settings, { lineWidth: 0 }), "utf-8") + } + + private async refreshMergedState(): Promise { + const settingsPath = await this.getCustomModesFilePath() + const roomodesPath = await this.getWorkspaceRoomodes() + + const settingsModes = await this.loadAgentsFromFile(settingsPath) + const roomodesModes = roomodesPath ? await this.loadAgentsFromFile(roomodesPath) : [] + const mergedModes = await this.mergeCustomAgents(roomodesModes, settingsModes) + + await this.context.globalState.update("customModes", mergedModes) + + this.clearCache() + + await this.onUpdate() + } + + public async deleteCustomMode(slug: string, fromMarketplace = false): Promise { + try { + const settingsPath = await this.getCustomModesFilePath() + const roomodesPath = await this.getWorkspaceRoomodes() + + const settingsModes = await this.loadAgentsFromFile(settingsPath) + const roomodesModes = roomodesPath ? await this.loadAgentsFromFile(roomodesPath) : [] + + // Find the mode in either file + const projectMode = roomodesModes.find((m: AgentConfig) => m.slug === slug) + const globalMode = settingsModes.find((m: AgentConfig) => m.slug === slug) + + if (!projectMode && !globalMode) { + throw new Error(t("common:customModes.errors.modeNotFound")) + } + + // Determine which mode to use for rules folder path calculation + const modeToDelete = projectMode || globalMode + + await this.queueWrite(async () => { + // Delete from project first if it exists there + if (projectMode && roomodesPath) { + await this.updateAgentsInFile(roomodesPath, (modes) => + modes.filter((m: AgentConfig) => m.slug !== slug), + ) + } + + // Delete from global settings if it exists there + if (globalMode) { + await this.updateAgentsInFile(settingsPath, (modes) => + modes.filter((m: AgentConfig) => m.slug !== slug), + ) + } + + // Delete associated rules folder + if (modeToDelete) { + await this.deleteRulesFolder(slug, modeToDelete, fromMarketplace) + } + + // Clear cache when modes are deleted + this.clearCache() + await this.refreshMergedState() + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + vscode.window.showErrorMessage(t("common:customModes.errors.deleteFailed", { error: errorMessage })) + } + } + + /** + * Deletes the rules folder for a specific mode + * @param slug - The mode slug + * @param mode - The mode configuration to determine the scope + */ + private async deleteRulesFolder(slug: string, agent: AgentConfig, fromMarketplace = false): Promise { + try { + // Determine the scope based on source (project or global) + const scope = agent.source || "global" + + // Determine the rules folder path + let rulesFolderPath: string + if (scope === "project") { + const workspacePath = getWorkspacePath() + if (workspacePath) { + rulesFolderPath = path.join(workspacePath, ".roo", `rules-${slug}`) + } else { + return // No workspace, can't delete project rules + } + } else { + // Global scope - use OS home directory + const homeDir = os.homedir() + rulesFolderPath = path.join(homeDir, ".roo", `rules-${slug}`) + } + + // Check if the rules folder exists and delete it + const rulesFolderExists = await fileExistsAtPath(rulesFolderPath) + if (rulesFolderExists) { + try { + await fs.rm(rulesFolderPath, { recursive: true, force: true }) + logger.info(`Deleted rules folder for mode ${slug}: ${rulesFolderPath}`) + } catch (error) { + logger.error(`Failed to delete rules folder for mode ${slug}: ${error}`) + // Notify the user about the failure + const messageKey = fromMarketplace + ? "common:marketplace.mode.rulesCleanupFailed" + : "common:customModes.errors.rulesCleanupFailed" + vscode.window.showWarningMessage(t(messageKey, { rulesFolderPath })) + // Continue even if folder deletion fails + } + } + } catch (error) { + logger.error(`Error deleting rules folder for mode ${slug}`, { + error: error instanceof Error ? error.message : String(error), + }) + } + } + + public async resetCustomModes(): Promise { + try { + const filePath = await this.getCustomModesFilePath() + await fs.writeFile(filePath, yaml.stringify({ customModes: [] }, { lineWidth: 0 })) + await this.context.globalState.update("customModes", []) + this.clearCache() + await this.onUpdate() + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + vscode.window.showErrorMessage(t("common:customModes.errors.resetFailed", { error: errorMessage })) + } + } + + /** + * Checks if a mode has associated rules files in the .roo/rules-{slug}/ directory + * @param slug - The mode identifier to check + * @returns True if the mode has rules files with content, false otherwise + */ + public async checkRulesDirectoryHasContent(slug: string): Promise { + try { + // First, find the agent to determine its source + const allAgents = await this.getCustomAgents() + const agent = allAgents.find((a) => a.slug === slug) + + if (!agent) { + // If not in custom modes, check if it's in .roomodes (project-specific) + const workspacePath = getWorkspacePath() + if (!workspacePath) { + return false + } + + const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME) + try { + const roomodesExists = await fileExistsAtPath(roomodesPath) + if (roomodesExists) { + const roomodesContent = await fs.readFile(roomodesPath, "utf-8") + const roomodesData = yaml.parse(roomodesContent) + const roomodesModes = roomodesData?.customModes || [] + + // Check if this specific mode exists in .roomodes + const modeInRoomodes = roomodesModes.find((m: any) => m.slug === slug) + if (!modeInRoomodes) { + return false // Mode not found anywhere + } + } else { + return false // No .roomodes file and not in custom modes + } + } catch (error) { + return false // Cannot read .roomodes and not in custom modes + } + } + + // Determine the correct rules directory based on agent source + let agentRulesDir: string + const isGlobalAgent = agent?.source === "global" + + if (isGlobalAgent) { + // For global agents, check in global .roo directory + const globalRooDir = getGlobalRooDirectory() + agentRulesDir = path.join(globalRooDir, `rules-${slug}`) + } else { + // For project agents, check in workspace .roo directory + const workspacePath = getWorkspacePath() + if (!workspacePath) { + return false + } + agentRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`) + } + + try { + const stats = await fs.stat(agentRulesDir) + if (!stats.isDirectory()) { + return false + } + } catch (error) { + return false + } + + // Check if directory has any content files + try { + const entries = await fs.readdir(agentRulesDir, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.isFile()) { + // Use path.join with agentRulesDir and entry.name for compatibility + const filePath = path.join(agentRulesDir, entry.name) + const content = await fs.readFile(filePath, "utf-8") + if (content.trim()) { + return true // Found at least one file with content + } + } + } + + return false // No files with content found + } catch (error) { + return false + } + } catch (error) { + logger.error("Failed to check rules directory for mode", { + slug, + error: error instanceof Error ? error.message : String(error), + }) + return false + } + } + + /** + * Exports a mode configuration with its associated rules files into a shareable YAML format + * @param slug - The mode identifier to export + * @param customPrompts - Optional custom prompts to merge into the export + * @returns Success status with YAML content or error message + */ + public async exportModeWithRules(slug: string, customPrompts?: PromptComponent): Promise { + try { + // Import modes from shared to check built-in modes + const { modes: builtInModes } = await import("../../shared/modes") + + // Get all current modes + const allModes = await this.getCustomModes() + let mode = allModes.find((m) => m.slug === slug) + + // If mode not found in custom modes, check if it's a built-in mode that has been customized + if (!mode) { + // Only check workspace-based modes if workspace is available + const workspacePath = getWorkspacePath() + if (workspacePath) { + const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME) + try { + const roomodesExists = await fileExistsAtPath(roomodesPath) + if (roomodesExists) { + const roomodesContent = await fs.readFile(roomodesPath, "utf-8") + const roomodesData = yaml.parse(roomodesContent) + const roomodesModes = roomodesData?.customModes || [] + + // Find the mode in .roomodes + mode = roomodesModes.find((m: any) => m.slug === slug) + } + } catch (error) { + // Continue to check built-in modes + } + } + + // If still not found, check if it's a built-in mode + if (!mode) { + const builtInMode = builtInModes.find((m) => m.slug === slug) + if (builtInMode) { + // Use the built-in mode as the base + mode = { ...builtInMode } + } else { + return { success: false, error: "Mode not found" } + } + } + } + + // Determine the base directory based on mode source + const isGlobalMode = mode.source === "global" + let baseDir: string + if (isGlobalMode) { + // For global modes, use the global .roo directory + baseDir = getGlobalRooDirectory() + } else { + // For project modes, use the workspace directory + const workspacePath = getWorkspacePath() + if (!workspacePath) { + return { success: false, error: "No workspace found" } + } + baseDir = workspacePath + } + + // Check for .roo/rules-{slug}/ directory (or rules-{slug}/ for global) + const modeRulesDir = isGlobalMode + ? path.join(baseDir, `rules-${slug}`) + : path.join(baseDir, ".roo", `rules-${slug}`) + + let rulesFiles: RuleFile[] = [] + try { + const stats = await fs.stat(modeRulesDir) + if (stats.isDirectory()) { + // Extract content specific to this mode by looking for the mode-specific rules + const entries = await fs.readdir(modeRulesDir, { withFileTypes: true }) + + for (const entry of entries) { + if (entry.isFile()) { + // Use path.join with modeRulesDir and entry.name for compatibility + const filePath = path.join(modeRulesDir, entry.name) + const content = await fs.readFile(filePath, "utf-8") + if (content.trim()) { + // Calculate relative path based on mode source + const relativePath = isGlobalMode + ? path.relative(baseDir, filePath) + : path.relative(path.join(baseDir, ".roo"), filePath) + rulesFiles.push({ relativePath, content: content.trim() }) + } + } + } + } + } catch (error) { + // Directory doesn't exist, which is fine - mode might not have rules + } + + // Create an export mode with rules files preserved + const exportMode: ExportedModeConfig = { + ...mode, + // Remove source property for export + source: "project" as const, + } + + // Merge custom prompts if provided + if (customPrompts) { + if (customPrompts.roleDefinition) exportMode.roleDefinition = customPrompts.roleDefinition + if (customPrompts.description) exportMode.description = customPrompts.description + if (customPrompts.whenToUse) exportMode.whenToUse = customPrompts.whenToUse + if (customPrompts.customInstructions) exportMode.customInstructions = customPrompts.customInstructions + } + + // Add rules files if any exist + if (rulesFiles.length > 0) { + exportMode.rulesFiles = rulesFiles + } + + // Generate YAML + const exportData = { + customModes: [exportMode], + } + + const yamlContent = yaml.stringify(exportData) + + return { success: true, yaml: yamlContent } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to export mode with rules", { slug, error: errorMessage }) + return { success: false, error: errorMessage } + } + } + + /** + * Helper method to import rules files for a mode + * @param importMode - The mode being imported + * @param rulesFiles - The rules files to import + * @param source - The import source ("global" or "project") + */ + private async importRulesFiles( + importAgent: ExportedAgentConfig, + rulesFiles: RuleFile[], + source: "global" | "project", + ): Promise { + // Determine base directory and rules folder path based on source + let baseDir: string + let rulesFolderPath: string + + if (source === "global") { + baseDir = getGlobalRooDirectory() + rulesFolderPath = path.join(baseDir, `rules-${importAgent.slug}`) + } else { + const workspacePath = getWorkspacePath() + baseDir = path.join(workspacePath, ".roo") + rulesFolderPath = path.join(baseDir, `rules-${importAgent.slug}`) + } + + // Always remove the existing rules folder for this agent if it exists + // This ensures that if the imported agent has no rules, the folder is cleaned up + try { + await fs.rm(rulesFolderPath, { recursive: true, force: true }) + logger.info(`Removed existing ${source} rules folder for agent ${importAgent.slug}`) + } catch (error) { + // It's okay if the folder doesn't exist + logger.debug(`No existing ${source} rules folder to remove for agent ${importAgent.slug}`) + } + + // Only proceed with file creation if there are rules files to import + if (!rulesFiles || !Array.isArray(rulesFiles) || rulesFiles.length === 0) { + return + } + + // Import the new rules files with path validation + for (const ruleFile of rulesFiles) { + if (ruleFile.relativePath && ruleFile.content) { + // Validate the relative path to prevent path traversal attacks + const normalizedRelativePath = path.normalize(ruleFile.relativePath) + + // Ensure the path doesn't contain traversal sequences + if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) { + logger.error(`Invalid file path detected: ${ruleFile.relativePath}`) + continue // Skip this file but continue with others + } + + const targetPath = path.join(baseDir, normalizedRelativePath) + const normalizedTargetPath = path.normalize(targetPath) + const expectedBasePath = path.normalize(baseDir) + + // Ensure the resolved path stays within the base directory + if (!normalizedTargetPath.startsWith(expectedBasePath)) { + logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`) + continue // Skip this file but continue with others + } + + // Ensure directory exists + const targetDir = path.dirname(targetPath) + await fs.mkdir(targetDir, { recursive: true }) + + // Write the file + await fs.writeFile(targetPath, ruleFile.content, "utf-8") + } + } + } + + /** + * Imports modes from YAML content, including their associated rules files + * @param yamlContent - The YAML content containing mode configurations + * @param source - Target level for import: "global" (all projects) or "project" (current workspace only) + * @returns Success status with optional error message + */ + public async importModeWithRules( + yamlContent: string, + source: "global" | "project" = "project", + ): Promise { + try { + // Parse the YAML content with proper type validation + let importData: ImportData + try { + const parsed = yaml.parse(yamlContent) + + // Validate the structure + if (!parsed?.customModes || !Array.isArray(parsed.customModes) || parsed.customModes.length === 0) { + return { success: false, error: "Invalid import format: Expected 'customModes' array in YAML" } + } + + importData = parsed as ImportData + } catch (parseError) { + return { + success: false, + error: `Invalid YAML format: ${parseError instanceof Error ? parseError.message : "Failed to parse YAML"}`, + } + } + + // Check workspace availability early if importing at project level + if (source === "project") { + const workspacePath = getWorkspacePath() + if (!workspacePath) { + return { success: false, error: "No workspace found" } + } + } + + // Process each mode in the import + for (const importMode of importData.customModes) { + const { rulesFiles, ...modeConfig } = importMode + + // Validate the agent configuration + const validationResult = agentConfigSchema.safeParse(modeConfig) + if (!validationResult.success) { + logger.error(`Invalid agent configuration for ${modeConfig.slug}`, { + errors: validationResult.error.errors, + }) + return { + success: false, + error: `Invalid mode configuration for ${modeConfig.slug}: ${validationResult.error.errors.map((e) => e.message).join(", ")}`, + } + } + + // Check for existing mode conflicts + const existingModes = await this.getCustomModes() + const existingMode = existingModes.find((m) => m.slug === importMode.slug) + if (existingMode) { + logger.info(`Overwriting existing mode: ${importMode.slug}`) + } + + // Import the mode configuration with the specified source + await this.updateCustomAgent(importMode.slug, { + ...modeConfig, + source: source, // Use the provided source parameter + }) + + // Import rules files (this also handles cleanup of existing rules folders) + await this.importRulesFiles(importMode, rulesFiles || [], source) + } + + // Refresh the modes after import + await this.refreshMergedState() + + return { success: true } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error("Failed to import mode with rules", { error: errorMessage }) + return { success: false, error: errorMessage } + } + } + + private clearCache(): void { + this.cachedAgents = null + this.cachedAt = 0 + } + + dispose(): void { + for (const disposable of this.disposables) { + disposable.dispose() + } + + this.disposables = [] + } +} + +// Keep CustomModesManager as an alias for backward compatibility +export { CustomAgentsManager as CustomModesManager } diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index c388c1a537..166c08033e 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -1,997 +1,3 @@ -import * as vscode from "vscode" -import * as path from "path" -import * as fs from "fs/promises" -import * as os from "os" - -import * as yaml from "yaml" -import stripBom from "strip-bom" - -import { type ModeConfig, type PromptComponent, customModesSettingsSchema, modeConfigSchema } from "@roo-code/types" - -import { fileExistsAtPath } from "../../utils/fs" -import { getWorkspacePath } from "../../utils/path" -import { getGlobalRooDirectory } from "../../services/roo-config" -import { logger } from "../../utils/logging" -import { GlobalFileNames } from "../../shared/globalFileNames" -import { ensureSettingsDirectoryExists } from "../../utils/globalContext" -import { t } from "../../i18n" - -const ROOMODES_FILENAME = ".roomodes" - -// Type definitions for import/export functionality -interface RuleFile { - relativePath: string - content: string -} - -interface ExportedModeConfig extends ModeConfig { - rulesFiles?: RuleFile[] -} - -interface ImportData { - customModes: ExportedModeConfig[] -} - -interface ExportResult { - success: boolean - yaml?: string - error?: string -} - -interface ImportResult { - success: boolean - error?: string -} - -export class CustomModesManager { - private static readonly cacheTTL = 10_000 - - private disposables: vscode.Disposable[] = [] - private isWriting = false - private writeQueue: Array<() => Promise> = [] - private cachedModes: ModeConfig[] | null = null - private cachedAt: number = 0 - - constructor( - private readonly context: vscode.ExtensionContext, - private readonly onUpdate: () => Promise, - ) { - this.watchCustomModesFiles().catch((error) => { - console.error("[CustomModesManager] Failed to setup file watchers:", error) - }) - } - - private async queueWrite(operation: () => Promise): Promise { - this.writeQueue.push(operation) - - if (!this.isWriting) { - await this.processWriteQueue() - } - } - - private async processWriteQueue(): Promise { - if (this.isWriting || this.writeQueue.length === 0) { - return - } - - this.isWriting = true - - try { - while (this.writeQueue.length > 0) { - const operation = this.writeQueue.shift() - - if (operation) { - await operation() - } - } - } finally { - this.isWriting = false - } - } - - private async getWorkspaceRoomodes(): Promise { - const workspaceFolders = vscode.workspace.workspaceFolders - - if (!workspaceFolders || workspaceFolders.length === 0) { - return undefined - } - - const workspaceRoot = getWorkspacePath() - const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME) - const exists = await fileExistsAtPath(roomodesPath) - return exists ? roomodesPath : undefined - } - - /** - * Regex pattern for problematic characters that need to be cleaned from YAML content - * Includes: - * - \u00A0: Non-breaking space - * - \u200B-\u200D: Zero-width spaces and joiners - * - \u2010-\u2015, \u2212: Various dash characters - * - \u2018-\u2019: Smart single quotes - * - \u201C-\u201D: Smart double quotes - */ - private static readonly PROBLEMATIC_CHARS_REGEX = - // eslint-disable-next-line no-misleading-character-class - /[\u00A0\u200B\u200C\u200D\u2010\u2011\u2012\u2013\u2014\u2015\u2212\u2018\u2019\u201C\u201D]/g - - /** - * Clean invisible and problematic characters from YAML content - */ - private cleanInvisibleCharacters(content: string): string { - // Single pass replacement for all problematic characters - return content.replace(CustomModesManager.PROBLEMATIC_CHARS_REGEX, (match) => { - switch (match) { - case "\u00A0": // Non-breaking space - return " " - case "\u200B": // Zero-width space - case "\u200C": // Zero-width non-joiner - case "\u200D": // Zero-width joiner - return "" - case "\u2018": // Left single quotation mark - case "\u2019": // Right single quotation mark - return "'" - case "\u201C": // Left double quotation mark - case "\u201D": // Right double quotation mark - return '"' - default: // Dash characters (U+2010 through U+2015, U+2212) - return "-" - } - }) - } - - /** - * Parse YAML content with enhanced error handling and preprocessing - */ - private parseYamlSafely(content: string, filePath: string): any { - // Clean the content - let cleanedContent = stripBom(content) - cleanedContent = this.cleanInvisibleCharacters(cleanedContent) - - try { - const parsed = yaml.parse(cleanedContent) - // Ensure we never return null or undefined - return parsed ?? {} - } catch (yamlError) { - // For .roomodes files, try JSON as fallback - if (filePath.endsWith(ROOMODES_FILENAME)) { - try { - // Try parsing the original content as JSON (not the cleaned content) - return JSON.parse(content) - } catch (jsonError) { - // JSON also failed, show the original YAML error - const errorMsg = yamlError instanceof Error ? yamlError.message : String(yamlError) - console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, errorMsg) - - const lineMatch = errorMsg.match(/at line (\d+)/) - const line = lineMatch ? lineMatch[1] : "unknown" - vscode.window.showErrorMessage(t("common:customModes.errors.yamlParseError", { line })) - - // Return empty object to prevent duplicate error handling - return {} - } - } - - // For non-.roomodes files, just log and return empty object - const errorMsg = yamlError instanceof Error ? yamlError.message : String(yamlError) - console.error(`[CustomModesManager] Failed to parse YAML from ${filePath}:`, errorMsg) - return {} - } - } - - private async loadModesFromFile(filePath: string): Promise { - try { - const content = await fs.readFile(filePath, "utf-8") - const settings = this.parseYamlSafely(content, filePath) - - // Ensure settings has customModes property - if (!settings || typeof settings !== "object" || !settings.customModes) { - return [] - } - - const result = customModesSettingsSchema.safeParse(settings) - - if (!result.success) { - console.error(`[CustomModesManager] Schema validation failed for ${filePath}:`, result.error) - - // Show user-friendly error for .roomodes files - if (filePath.endsWith(ROOMODES_FILENAME)) { - const issues = result.error.issues - .map((issue) => `• ${issue.path.join(".")}: ${issue.message}`) - .join("\n") - - vscode.window.showErrorMessage(t("common:customModes.errors.schemaValidationError", { issues })) - } - - return [] - } - - // Determine source based on file path - const isRoomodes = filePath.endsWith(ROOMODES_FILENAME) - const source = isRoomodes ? ("project" as const) : ("global" as const) - - // Add source to each mode - return result.data.customModes.map((mode) => ({ ...mode, source })) - } catch (error) { - // Only log if the error wasn't already handled in parseYamlSafely - if (!(error as any).alreadyHandled) { - const errorMsg = `Failed to load modes from ${filePath}: ${error instanceof Error ? error.message : String(error)}` - console.error(`[CustomModesManager] ${errorMsg}`) - } - return [] - } - } - - private async mergeCustomModes(projectModes: ModeConfig[], globalModes: ModeConfig[]): Promise { - const slugs = new Set() - const merged: ModeConfig[] = [] - - // Add project mode (takes precedence) - for (const mode of projectModes) { - if (!slugs.has(mode.slug)) { - slugs.add(mode.slug) - merged.push({ ...mode, source: "project" }) - } - } - - // Add non-duplicate global modes - for (const mode of globalModes) { - if (!slugs.has(mode.slug)) { - slugs.add(mode.slug) - merged.push({ ...mode, source: "global" }) - } - } - - return merged - } - - public async getCustomModesFilePath(): Promise { - const settingsDir = await ensureSettingsDirectoryExists(this.context) - const filePath = path.join(settingsDir, GlobalFileNames.customModes) - const fileExists = await fileExistsAtPath(filePath) - - if (!fileExists) { - await this.queueWrite(() => fs.writeFile(filePath, yaml.stringify({ customModes: [] }, { lineWidth: 0 }))) - } - - return filePath - } - - private async watchCustomModesFiles(): Promise { - // Skip if test environment is detected - if (process.env.NODE_ENV === "test") { - return - } - - const settingsPath = await this.getCustomModesFilePath() - - // Watch settings file - const settingsWatcher = vscode.workspace.createFileSystemWatcher(settingsPath) - - const handleSettingsChange = async () => { - try { - // Ensure that the settings file exists (especially important for delete events) - await this.getCustomModesFilePath() - const content = await fs.readFile(settingsPath, "utf-8") - - const errorMessage = t("common:customModes.errors.invalidFormat") - - let config: any - - try { - config = this.parseYamlSafely(content, settingsPath) - } catch (error) { - console.error(error) - vscode.window.showErrorMessage(errorMessage) - return - } - - const result = customModesSettingsSchema.safeParse(config) - - if (!result.success) { - vscode.window.showErrorMessage(errorMessage) - return - } - - // Get modes from .roomodes if it exists (takes precedence) - const roomodesPath = await this.getWorkspaceRoomodes() - const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] - - // Merge modes from both sources (.roomodes takes precedence) - const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes) - await this.context.globalState.update("customModes", mergedModes) - this.clearCache() - await this.onUpdate() - } catch (error) { - console.error(`[CustomModesManager] Error handling settings file change:`, error) - } - } - - this.disposables.push(settingsWatcher.onDidChange(handleSettingsChange)) - this.disposables.push(settingsWatcher.onDidCreate(handleSettingsChange)) - this.disposables.push(settingsWatcher.onDidDelete(handleSettingsChange)) - this.disposables.push(settingsWatcher) - - // Watch .roomodes file - watch the path even if it doesn't exist yet - const workspaceFolders = vscode.workspace.workspaceFolders - if (workspaceFolders && workspaceFolders.length > 0) { - const workspaceRoot = getWorkspacePath() - const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME) - const roomodesWatcher = vscode.workspace.createFileSystemWatcher(roomodesPath) - - const handleRoomodesChange = async () => { - try { - const settingsModes = await this.loadModesFromFile(settingsPath) - const roomodesModes = await this.loadModesFromFile(roomodesPath) - // .roomodes takes precedence - const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes) - await this.context.globalState.update("customModes", mergedModes) - this.clearCache() - await this.onUpdate() - } catch (error) { - console.error(`[CustomModesManager] Error handling .roomodes file change:`, error) - } - } - - this.disposables.push(roomodesWatcher.onDidChange(handleRoomodesChange)) - this.disposables.push(roomodesWatcher.onDidCreate(handleRoomodesChange)) - this.disposables.push( - roomodesWatcher.onDidDelete(async () => { - // When .roomodes is deleted, refresh with only settings modes - try { - const settingsModes = await this.loadModesFromFile(settingsPath) - await this.context.globalState.update("customModes", settingsModes) - this.clearCache() - await this.onUpdate() - } catch (error) { - console.error(`[CustomModesManager] Error handling .roomodes file deletion:`, error) - } - }), - ) - this.disposables.push(roomodesWatcher) - } - } - - public async getCustomModes(): Promise { - // Check if we have a valid cached result. - const now = Date.now() - - if (this.cachedModes && now - this.cachedAt < CustomModesManager.cacheTTL) { - return this.cachedModes - } - - // Get modes from settings file. - const settingsPath = await this.getCustomModesFilePath() - const settingsModes = await this.loadModesFromFile(settingsPath) - - // Get modes from .roomodes if it exists. - const roomodesPath = await this.getWorkspaceRoomodes() - const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] - - // Create maps to store modes by source. - const projectModes = new Map() - const globalModes = new Map() - - // Add project modes (they take precedence). - for (const mode of roomodesModes) { - projectModes.set(mode.slug, { ...mode, source: "project" as const }) - } - - // Add global modes. - for (const mode of settingsModes) { - if (!projectModes.has(mode.slug)) { - globalModes.set(mode.slug, { ...mode, source: "global" as const }) - } - } - - // Combine modes in the correct order: project modes first, then global modes. - const mergedModes = [ - ...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })), - ...settingsModes - .filter((mode) => !projectModes.has(mode.slug)) - .map((mode) => ({ ...mode, source: "global" as const })), - ] - - await this.context.globalState.update("customModes", mergedModes) - - this.cachedModes = mergedModes - this.cachedAt = now - - return mergedModes - } - - public async updateCustomMode(slug: string, config: ModeConfig): Promise { - try { - // Validate the mode configuration before saving - const validationResult = modeConfigSchema.safeParse(config) - if (!validationResult.success) { - const errors = validationResult.error.errors.map((e) => e.message).join(", ") - logger.error(`Invalid mode configuration for ${slug}`, { errors: validationResult.error.errors }) - throw new Error(`Invalid mode configuration: ${errors}`) - } - - const isProjectMode = config.source === "project" - let targetPath: string - - if (isProjectMode) { - const workspaceFolders = vscode.workspace.workspaceFolders - - if (!workspaceFolders || workspaceFolders.length === 0) { - logger.error("Failed to update project mode: No workspace folder found", { slug }) - throw new Error(t("common:customModes.errors.noWorkspaceForProject")) - } - - const workspaceRoot = getWorkspacePath() - targetPath = path.join(workspaceRoot, ROOMODES_FILENAME) - const exists = await fileExistsAtPath(targetPath) - - logger.info(`${exists ? "Updating" : "Creating"} project mode in ${ROOMODES_FILENAME}`, { - slug, - workspace: workspaceRoot, - }) - } else { - targetPath = await this.getCustomModesFilePath() - } - - await this.queueWrite(async () => { - // Ensure source is set correctly based on target file. - const modeWithSource = { - ...config, - source: isProjectMode ? ("project" as const) : ("global" as const), - } - - await this.updateModesInFile(targetPath, (modes) => { - const updatedModes = modes.filter((m) => m.slug !== slug) - updatedModes.push(modeWithSource) - return updatedModes - }) - - this.clearCache() - await this.refreshMergedState() - }) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - logger.error("Failed to update custom mode", { slug, error: errorMessage }) - vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage })) - } - } - - private async updateModesInFile(filePath: string, operation: (modes: ModeConfig[]) => ModeConfig[]): Promise { - let content = "{}" - - try { - content = await fs.readFile(filePath, "utf-8") - } catch (error) { - // File might not exist yet. - content = yaml.stringify({ customModes: [] }, { lineWidth: 0 }) - } - - let settings - - try { - settings = this.parseYamlSafely(content, filePath) - } catch (error) { - // Error already logged in parseYamlSafely - settings = { customModes: [] } - } - - // Ensure settings is an object and has customModes property - if (!settings || typeof settings !== "object") { - settings = { customModes: [] } - } - if (!settings.customModes) { - settings.customModes = [] - } - - settings.customModes = operation(settings.customModes) - await fs.writeFile(filePath, yaml.stringify(settings, { lineWidth: 0 }), "utf-8") - } - - private async refreshMergedState(): Promise { - const settingsPath = await this.getCustomModesFilePath() - const roomodesPath = await this.getWorkspaceRoomodes() - - const settingsModes = await this.loadModesFromFile(settingsPath) - const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] - const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes) - - await this.context.globalState.update("customModes", mergedModes) - - this.clearCache() - - await this.onUpdate() - } - - public async deleteCustomMode(slug: string, fromMarketplace = false): Promise { - try { - const settingsPath = await this.getCustomModesFilePath() - const roomodesPath = await this.getWorkspaceRoomodes() - - const settingsModes = await this.loadModesFromFile(settingsPath) - const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : [] - - // Find the mode in either file - const projectMode = roomodesModes.find((m) => m.slug === slug) - const globalMode = settingsModes.find((m) => m.slug === slug) - - if (!projectMode && !globalMode) { - throw new Error(t("common:customModes.errors.modeNotFound")) - } - - // Determine which mode to use for rules folder path calculation - const modeToDelete = projectMode || globalMode - - await this.queueWrite(async () => { - // Delete from project first if it exists there - if (projectMode && roomodesPath) { - await this.updateModesInFile(roomodesPath, (modes) => modes.filter((m) => m.slug !== slug)) - } - - // Delete from global settings if it exists there - if (globalMode) { - await this.updateModesInFile(settingsPath, (modes) => modes.filter((m) => m.slug !== slug)) - } - - // Delete associated rules folder - if (modeToDelete) { - await this.deleteRulesFolder(slug, modeToDelete, fromMarketplace) - } - - // Clear cache when modes are deleted - this.clearCache() - await this.refreshMergedState() - }) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - vscode.window.showErrorMessage(t("common:customModes.errors.deleteFailed", { error: errorMessage })) - } - } - - /** - * Deletes the rules folder for a specific mode - * @param slug - The mode slug - * @param mode - The mode configuration to determine the scope - */ - private async deleteRulesFolder(slug: string, mode: ModeConfig, fromMarketplace = false): Promise { - try { - // Determine the scope based on source (project or global) - const scope = mode.source || "global" - - // Determine the rules folder path - let rulesFolderPath: string - if (scope === "project") { - const workspacePath = getWorkspacePath() - if (workspacePath) { - rulesFolderPath = path.join(workspacePath, ".roo", `rules-${slug}`) - } else { - return // No workspace, can't delete project rules - } - } else { - // Global scope - use OS home directory - const homeDir = os.homedir() - rulesFolderPath = path.join(homeDir, ".roo", `rules-${slug}`) - } - - // Check if the rules folder exists and delete it - const rulesFolderExists = await fileExistsAtPath(rulesFolderPath) - if (rulesFolderExists) { - try { - await fs.rm(rulesFolderPath, { recursive: true, force: true }) - logger.info(`Deleted rules folder for mode ${slug}: ${rulesFolderPath}`) - } catch (error) { - logger.error(`Failed to delete rules folder for mode ${slug}: ${error}`) - // Notify the user about the failure - const messageKey = fromMarketplace - ? "common:marketplace.mode.rulesCleanupFailed" - : "common:customModes.errors.rulesCleanupFailed" - vscode.window.showWarningMessage(t(messageKey, { rulesFolderPath })) - // Continue even if folder deletion fails - } - } - } catch (error) { - logger.error(`Error deleting rules folder for mode ${slug}`, { - error: error instanceof Error ? error.message : String(error), - }) - } - } - - public async resetCustomModes(): Promise { - try { - const filePath = await this.getCustomModesFilePath() - await fs.writeFile(filePath, yaml.stringify({ customModes: [] }, { lineWidth: 0 })) - await this.context.globalState.update("customModes", []) - this.clearCache() - await this.onUpdate() - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - vscode.window.showErrorMessage(t("common:customModes.errors.resetFailed", { error: errorMessage })) - } - } - - /** - * Checks if a mode has associated rules files in the .roo/rules-{slug}/ directory - * @param slug - The mode identifier to check - * @returns True if the mode has rules files with content, false otherwise - */ - public async checkRulesDirectoryHasContent(slug: string): Promise { - try { - // First, find the mode to determine its source - const allModes = await this.getCustomModes() - const mode = allModes.find((m) => m.slug === slug) - - if (!mode) { - // If not in custom modes, check if it's in .roomodes (project-specific) - const workspacePath = getWorkspacePath() - if (!workspacePath) { - return false - } - - const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME) - try { - const roomodesExists = await fileExistsAtPath(roomodesPath) - if (roomodesExists) { - const roomodesContent = await fs.readFile(roomodesPath, "utf-8") - const roomodesData = yaml.parse(roomodesContent) - const roomodesModes = roomodesData?.customModes || [] - - // Check if this specific mode exists in .roomodes - const modeInRoomodes = roomodesModes.find((m: any) => m.slug === slug) - if (!modeInRoomodes) { - return false // Mode not found anywhere - } - } else { - return false // No .roomodes file and not in custom modes - } - } catch (error) { - return false // Cannot read .roomodes and not in custom modes - } - } - - // Determine the correct rules directory based on mode source - let modeRulesDir: string - const isGlobalMode = mode?.source === "global" - - if (isGlobalMode) { - // For global modes, check in global .roo directory - const globalRooDir = getGlobalRooDirectory() - modeRulesDir = path.join(globalRooDir, `rules-${slug}`) - } else { - // For project modes, check in workspace .roo directory - const workspacePath = getWorkspacePath() - if (!workspacePath) { - return false - } - modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`) - } - - try { - const stats = await fs.stat(modeRulesDir) - if (!stats.isDirectory()) { - return false - } - } catch (error) { - return false - } - - // Check if directory has any content files - try { - const entries = await fs.readdir(modeRulesDir, { withFileTypes: true }) - - for (const entry of entries) { - if (entry.isFile()) { - // Use path.join with modeRulesDir and entry.name for compatibility - const filePath = path.join(modeRulesDir, entry.name) - const content = await fs.readFile(filePath, "utf-8") - if (content.trim()) { - return true // Found at least one file with content - } - } - } - - return false // No files with content found - } catch (error) { - return false - } - } catch (error) { - logger.error("Failed to check rules directory for mode", { - slug, - error: error instanceof Error ? error.message : String(error), - }) - return false - } - } - - /** - * Exports a mode configuration with its associated rules files into a shareable YAML format - * @param slug - The mode identifier to export - * @param customPrompts - Optional custom prompts to merge into the export - * @returns Success status with YAML content or error message - */ - public async exportModeWithRules(slug: string, customPrompts?: PromptComponent): Promise { - try { - // Import modes from shared to check built-in modes - const { modes: builtInModes } = await import("../../shared/modes") - - // Get all current modes - const allModes = await this.getCustomModes() - let mode = allModes.find((m) => m.slug === slug) - - // If mode not found in custom modes, check if it's a built-in mode that has been customized - if (!mode) { - // Only check workspace-based modes if workspace is available - const workspacePath = getWorkspacePath() - if (workspacePath) { - const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME) - try { - const roomodesExists = await fileExistsAtPath(roomodesPath) - if (roomodesExists) { - const roomodesContent = await fs.readFile(roomodesPath, "utf-8") - const roomodesData = yaml.parse(roomodesContent) - const roomodesModes = roomodesData?.customModes || [] - - // Find the mode in .roomodes - mode = roomodesModes.find((m: any) => m.slug === slug) - } - } catch (error) { - // Continue to check built-in modes - } - } - - // If still not found, check if it's a built-in mode - if (!mode) { - const builtInMode = builtInModes.find((m) => m.slug === slug) - if (builtInMode) { - // Use the built-in mode as the base - mode = { ...builtInMode } - } else { - return { success: false, error: "Mode not found" } - } - } - } - - // Determine the base directory based on mode source - const isGlobalMode = mode.source === "global" - let baseDir: string - if (isGlobalMode) { - // For global modes, use the global .roo directory - baseDir = getGlobalRooDirectory() - } else { - // For project modes, use the workspace directory - const workspacePath = getWorkspacePath() - if (!workspacePath) { - return { success: false, error: "No workspace found" } - } - baseDir = workspacePath - } - - // Check for .roo/rules-{slug}/ directory (or rules-{slug}/ for global) - const modeRulesDir = isGlobalMode - ? path.join(baseDir, `rules-${slug}`) - : path.join(baseDir, ".roo", `rules-${slug}`) - - let rulesFiles: RuleFile[] = [] - try { - const stats = await fs.stat(modeRulesDir) - if (stats.isDirectory()) { - // Extract content specific to this mode by looking for the mode-specific rules - const entries = await fs.readdir(modeRulesDir, { withFileTypes: true }) - - for (const entry of entries) { - if (entry.isFile()) { - // Use path.join with modeRulesDir and entry.name for compatibility - const filePath = path.join(modeRulesDir, entry.name) - const content = await fs.readFile(filePath, "utf-8") - if (content.trim()) { - // Calculate relative path based on mode source - const relativePath = isGlobalMode - ? path.relative(baseDir, filePath) - : path.relative(path.join(baseDir, ".roo"), filePath) - rulesFiles.push({ relativePath, content: content.trim() }) - } - } - } - } - } catch (error) { - // Directory doesn't exist, which is fine - mode might not have rules - } - - // Create an export mode with rules files preserved - const exportMode: ExportedModeConfig = { - ...mode, - // Remove source property for export - source: "project" as const, - } - - // Merge custom prompts if provided - if (customPrompts) { - if (customPrompts.roleDefinition) exportMode.roleDefinition = customPrompts.roleDefinition - if (customPrompts.description) exportMode.description = customPrompts.description - if (customPrompts.whenToUse) exportMode.whenToUse = customPrompts.whenToUse - if (customPrompts.customInstructions) exportMode.customInstructions = customPrompts.customInstructions - } - - // Add rules files if any exist - if (rulesFiles.length > 0) { - exportMode.rulesFiles = rulesFiles - } - - // Generate YAML - const exportData = { - customModes: [exportMode], - } - - const yamlContent = yaml.stringify(exportData) - - return { success: true, yaml: yamlContent } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - logger.error("Failed to export mode with rules", { slug, error: errorMessage }) - return { success: false, error: errorMessage } - } - } - - /** - * Helper method to import rules files for a mode - * @param importMode - The mode being imported - * @param rulesFiles - The rules files to import - * @param source - The import source ("global" or "project") - */ - private async importRulesFiles( - importMode: ExportedModeConfig, - rulesFiles: RuleFile[], - source: "global" | "project", - ): Promise { - // Determine base directory and rules folder path based on source - let baseDir: string - let rulesFolderPath: string - - if (source === "global") { - baseDir = getGlobalRooDirectory() - rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`) - } else { - const workspacePath = getWorkspacePath() - baseDir = path.join(workspacePath, ".roo") - rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`) - } - - // Always remove the existing rules folder for this mode if it exists - // This ensures that if the imported mode has no rules, the folder is cleaned up - try { - await fs.rm(rulesFolderPath, { recursive: true, force: true }) - logger.info(`Removed existing ${source} rules folder for mode ${importMode.slug}`) - } catch (error) { - // It's okay if the folder doesn't exist - logger.debug(`No existing ${source} rules folder to remove for mode ${importMode.slug}`) - } - - // Only proceed with file creation if there are rules files to import - if (!rulesFiles || !Array.isArray(rulesFiles) || rulesFiles.length === 0) { - return - } - - // Import the new rules files with path validation - for (const ruleFile of rulesFiles) { - if (ruleFile.relativePath && ruleFile.content) { - // Validate the relative path to prevent path traversal attacks - const normalizedRelativePath = path.normalize(ruleFile.relativePath) - - // Ensure the path doesn't contain traversal sequences - if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) { - logger.error(`Invalid file path detected: ${ruleFile.relativePath}`) - continue // Skip this file but continue with others - } - - const targetPath = path.join(baseDir, normalizedRelativePath) - const normalizedTargetPath = path.normalize(targetPath) - const expectedBasePath = path.normalize(baseDir) - - // Ensure the resolved path stays within the base directory - if (!normalizedTargetPath.startsWith(expectedBasePath)) { - logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`) - continue // Skip this file but continue with others - } - - // Ensure directory exists - const targetDir = path.dirname(targetPath) - await fs.mkdir(targetDir, { recursive: true }) - - // Write the file - await fs.writeFile(targetPath, ruleFile.content, "utf-8") - } - } - } - - /** - * Imports modes from YAML content, including their associated rules files - * @param yamlContent - The YAML content containing mode configurations - * @param source - Target level for import: "global" (all projects) or "project" (current workspace only) - * @returns Success status with optional error message - */ - public async importModeWithRules( - yamlContent: string, - source: "global" | "project" = "project", - ): Promise { - try { - // Parse the YAML content with proper type validation - let importData: ImportData - try { - const parsed = yaml.parse(yamlContent) - - // Validate the structure - if (!parsed?.customModes || !Array.isArray(parsed.customModes) || parsed.customModes.length === 0) { - return { success: false, error: "Invalid import format: Expected 'customModes' array in YAML" } - } - - importData = parsed as ImportData - } catch (parseError) { - return { - success: false, - error: `Invalid YAML format: ${parseError instanceof Error ? parseError.message : "Failed to parse YAML"}`, - } - } - - // Check workspace availability early if importing at project level - if (source === "project") { - const workspacePath = getWorkspacePath() - if (!workspacePath) { - return { success: false, error: "No workspace found" } - } - } - - // Process each mode in the import - for (const importMode of importData.customModes) { - const { rulesFiles, ...modeConfig } = importMode - - // Validate the mode configuration - const validationResult = modeConfigSchema.safeParse(modeConfig) - if (!validationResult.success) { - logger.error(`Invalid mode configuration for ${modeConfig.slug}`, { - errors: validationResult.error.errors, - }) - return { - success: false, - error: `Invalid mode configuration for ${modeConfig.slug}: ${validationResult.error.errors.map((e) => e.message).join(", ")}`, - } - } - - // Check for existing mode conflicts - const existingModes = await this.getCustomModes() - const existingMode = existingModes.find((m) => m.slug === importMode.slug) - if (existingMode) { - logger.info(`Overwriting existing mode: ${importMode.slug}`) - } - - // Import the mode configuration with the specified source - await this.updateCustomMode(importMode.slug, { - ...modeConfig, - source: source, // Use the provided source parameter - }) - - // Import rules files (this also handles cleanup of existing rules folders) - await this.importRulesFiles(importMode, rulesFiles || [], source) - } - - // Refresh the modes after import - await this.refreshMergedState() - - return { success: true } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - logger.error("Failed to import mode with rules", { error: errorMessage }) - return { success: false, error: errorMessage } - } - } - - private clearCache(): void { - this.cachedModes = null - this.cachedAt = 0 - } - - dispose(): void { - for (const disposable of this.disposables) { - disposable.dispose() - } - - this.disposables = [] - } -} +// Backward compatibility export +export { CustomAgentsManager as CustomModesManager } from "./CustomAgentsManager" +export * from "./CustomAgentsManager" diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 350c8136f2..3749797b19 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -9,7 +9,7 @@ import { } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { Mode, modes } from "../../shared/modes" +import { Agent, agents } from "../../shared/agents" const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() }) const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and( @@ -21,7 +21,8 @@ type ProviderSettingsWithId = z.infer export const providerProfilesSchema = z.object({ currentApiConfigName: z.string(), apiConfigs: z.record(z.string(), providerSettingsWithIdSchema), - modeApiConfigs: z.record(z.string(), z.string()).optional(), + agentApiConfigs: z.record(z.string(), z.string()).optional(), + modeApiConfigs: z.record(z.string(), z.string()).optional(), // Keep for backward compatibility during migration migrations: z .object({ rateLimitSecondsMigrated: z.boolean().optional(), @@ -39,14 +40,14 @@ export class ProviderSettingsManager { private static readonly SCOPE_PREFIX = "roo_cline_config_" private readonly defaultConfigId = this.generateId() - private readonly defaultModeApiConfigs: Record = Object.fromEntries( - modes.map((mode) => [mode.slug, this.defaultConfigId]), + private readonly defaultAgentApiConfigs: Record = Object.fromEntries( + agents.map((agent) => [agent.slug, this.defaultConfigId]), ) private readonly defaultProviderProfiles: ProviderProfiles = { currentApiConfigName: "default", apiConfigs: { default: { id: this.defaultConfigId } }, - modeApiConfigs: this.defaultModeApiConfigs, + agentApiConfigs: this.defaultAgentApiConfigs, migrations: { rateLimitSecondsMigrated: true, // Mark as migrated on fresh installs diffSettingsMigrated: true, // Mark as migrated on fresh installs @@ -92,15 +93,20 @@ export class ProviderSettingsManager { let isDirty = false - // Migrate existing installs to have per-mode API config map - if (!providerProfiles.modeApiConfigs) { - // Use the currently selected config for all modes initially + // Migrate existing installs to have per-agent API config map + if (!providerProfiles.agentApiConfigs && !providerProfiles.modeApiConfigs) { + // Use the currently selected config for all agents initially const currentName = providerProfiles.currentApiConfigName const seedId = providerProfiles.apiConfigs[currentName]?.id ?? Object.values(providerProfiles.apiConfigs)[0]?.id ?? this.defaultConfigId - providerProfiles.modeApiConfigs = Object.fromEntries(modes.map((m) => [m.slug, seedId])) + providerProfiles.agentApiConfigs = Object.fromEntries(agents.map((a) => [a.slug, seedId])) + isDirty = true + } else if (providerProfiles.modeApiConfigs && !providerProfiles.agentApiConfigs) { + // Migrate from modeApiConfigs to agentApiConfigs + providerProfiles.agentApiConfigs = providerProfiles.modeApiConfigs + delete providerProfiles.modeApiConfigs isDirty = true } @@ -412,39 +418,48 @@ export class ProviderSettingsManager { } /** - * Set the API config for a specific mode. + * Set the API config for a specific agent. */ - public async setModeConfig(mode: Mode, configId: string) { + public async setAgentConfig(agent: Agent, configId: string) { try { return await this.lock(async () => { const providerProfiles = await this.load() - // Ensure the per-mode config map exists - if (!providerProfiles.modeApiConfigs) { - providerProfiles.modeApiConfigs = {} + // Ensure the per-agent config map exists + if (!providerProfiles.agentApiConfigs) { + providerProfiles.agentApiConfigs = {} } - // Assign the chosen config ID to this mode - providerProfiles.modeApiConfigs[mode] = configId + // Assign the chosen config ID to this agent + providerProfiles.agentApiConfigs[agent] = configId await this.store(providerProfiles) }) } catch (error) { - throw new Error(`Failed to set mode config: ${error}`) + throw new Error(`Failed to set agent config: ${error}`) } } /** - * Get the API config ID for a specific mode. + * Get the API config ID for a specific agent. */ - public async getModeConfigId(mode: Mode) { + public async getAgentConfigId(agent: Agent) { try { return await this.lock(async () => { - const { modeApiConfigs } = await this.load() - return modeApiConfigs?.[mode] + const { agentApiConfigs } = await this.load() + return agentApiConfigs?.[agent] }) } catch (error) { - throw new Error(`Failed to get mode config: ${error}`) + throw new Error(`Failed to get agent config: ${error}`) } } + // Backward compatibility aliases + public async setModeConfig(mode: Agent, configId: string) { + return this.setAgentConfig(mode, configId) + } + + public async getModeConfigId(mode: Agent) { + return this.getAgentConfigId(mode) + } + public async export() { try { return await this.lock(async () => { diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index e52c1974b6..56c239984a 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -61,7 +61,7 @@ describe("ProviderSettingsManager", () => { fuzzyMatchThreshold: 1.0, }, }, - modeApiConfigs: {}, + agentApiConfigs: {}, migrations: { rateLimitSecondsMigrated: true, diffSettingsMigrated: true, diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index c3d6f9c215..5eb856d892 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -11,13 +11,13 @@ import { TelemetryService } from "@roo-code/telemetry" import { ProviderSettingsManager, providerProfilesSchema } from "./ProviderSettingsManager" import { ContextProxy } from "./ContextProxy" -import { CustomModesManager } from "./CustomModesManager" +import { CustomAgentsManager, CustomModesManager } from "./CustomModesManager" import { t } from "../../i18n" export type ImportOptions = { providerSettingsManager: ProviderSettingsManager contextProxy: ContextProxy - customModesManager: CustomModesManager + customModesManager: CustomAgentsManager | CustomModesManager } type ExportOptions = { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fe8fd0f68f..c21c89c744 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -41,7 +41,7 @@ import { t } from "../../i18n" import { ClineApiReqCancelReason, ClineApiReqInfo } from "../../shared/ExtensionMessage" import { getApiMetrics } from "../../shared/getApiMetrics" import { ClineAskResponse } from "../../shared/WebviewMessage" -import { defaultModeSlug } from "../../shared/modes" +import { defaultAgentSlug } from "../../shared/modes" import { DiffStrategy } from "../../shared/tools" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { getModelMaxOutputTokens } from "../../shared/api" @@ -99,7 +99,7 @@ const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes export type ClineEvents = { message: [{ action: "created" | "updated"; message: ClineMessage }] taskStarted: [] - taskModeSwitched: [taskId: string, mode: string] + taskAgentSwitched: [taskId: string, agent: string] taskPaused: [] taskUnpaused: [] taskAskResponded: [] @@ -145,7 +145,7 @@ export class Task extends EventEmitter { abandoned = false isInitialized = false isPaused: boolean = false - pausedModeSlug: string = defaultModeSlug + pausedAgentSlug: string = defaultAgentSlug private pauseInterval: NodeJS.Timeout | undefined // API @@ -1193,17 +1193,17 @@ export class Task extends EventEmitter { provider.log(`[subtasks] paused ${this.taskId}.${this.instanceId}`) await this.waitForResume() provider.log(`[subtasks] resumed ${this.taskId}.${this.instanceId}`) - const currentMode = (await provider.getState())?.mode ?? defaultModeSlug + const currentAgent = (await provider.getState())?.mode ?? defaultAgentSlug - if (currentMode !== this.pausedModeSlug) { - // The mode has changed, we need to switch back to the paused mode. - await provider.handleModeSwitch(this.pausedModeSlug) + if (currentAgent !== this.pausedAgentSlug) { + // The agent has changed, we need to switch back to the paused agent. + await provider.handleModeSwitch(this.pausedAgentSlug) - // Delay to allow mode change to take effect before next tool is executed. + // Delay to allow agent change to take effect before next tool is executed. await delay(500) provider.log( - `[subtasks] task ${this.taskId}.${this.instanceId} has switched back to '${this.pausedModeSlug}' from '${currentMode}'`, + `[subtasks] task ${this.taskId}.${this.instanceId} has switched back to '${this.pausedAgentSlug}' from '${currentAgent}'`, ) } } @@ -1626,8 +1626,8 @@ export class Task extends EventEmitter { const { browserViewportSize, mode, - customModes, - customModePrompts, + customModes: customAgents, + customModePrompts: customAgentPrompts, customInstructions, experiments, enableMcpServerCreation, @@ -1653,8 +1653,8 @@ export class Task extends EventEmitter { this.diffStrategy, browserViewportSize, mode, - customModePrompts, - customModes, + customAgentPrompts, + customAgents, customInstructions, this.diffEnabled, experiments, diff --git a/src/core/tools/newTaskTool.ts b/src/core/tools/newTaskTool.ts index 7cc7063b49..00fff80e2d 100644 --- a/src/core/tools/newTaskTool.ts +++ b/src/core/tools/newTaskTool.ts @@ -2,7 +2,7 @@ import delay from "delay" import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { Task } from "../task/Task" -import { defaultModeSlug, getModeBySlug } from "../../shared/modes" +import { defaultAgentSlug, getAgentBySlug } from "../../shared/agents" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" @@ -14,21 +14,21 @@ export async function newTaskTool( pushToolResult: PushToolResult, removeClosingTag: RemoveClosingTag, ) { - const mode: string | undefined = block.params.mode + const agent: string | undefined = block.params.mode const message: string | undefined = block.params.message try { if (block.partial) { const partialMessage = JSON.stringify({ tool: "newTask", - mode: removeClosingTag("mode", mode), + mode: removeClosingTag("mode", agent), content: removeClosingTag("message", message), }) await cline.ask("tool", partialMessage, block.partial).catch(() => {}) return } else { - if (!mode) { + if (!agent) { cline.consecutiveMistakeCount++ cline.recordToolError("new_task") pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "mode")) @@ -47,17 +47,17 @@ export async function newTaskTool( // Un-escape one level: \\@ -> \@ (removes one backslash for hierarchical subtasks) const unescapedMessage = message.replace(/\\\\@/g, "\\@") - // Verify the mode exists - const targetMode = getModeBySlug(mode, (await cline.providerRef.deref()?.getState())?.customModes) + // Verify the agent exists + const targetAgent = getAgentBySlug(agent, (await cline.providerRef.deref()?.getState())?.customModes) - if (!targetMode) { - pushToolResult(formatResponse.toolError(`Invalid mode: ${mode}`)) + if (!targetAgent) { + pushToolResult(formatResponse.toolError(`Invalid agent: ${agent}`)) return } const toolMessage = JSON.stringify({ tool: "newTask", - mode: targetMode.name, + mode: targetAgent.name, content: message, }) @@ -77,13 +77,13 @@ export async function newTaskTool( cline.checkpointSave(true) } - // Preserve the current mode so we can resume with it later. - cline.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug + // Preserve the current agent so we can resume with it later. + cline.pausedAgentSlug = (await provider.getState()).mode ?? defaultAgentSlug - // Switch mode first, then create new task instance. - await provider.handleModeSwitch(mode) + // Switch agent first, then create new task instance. + await provider.handleModeSwitch(agent) - // Delay to allow mode change to take effect before next tool is executed. + // Delay to allow agent change to take effect before next tool is executed. await delay(500) const newCline = await provider.initClineWithTask(unescapedMessage, undefined, cline) @@ -93,7 +93,9 @@ export async function newTaskTool( } cline.emit("taskSpawned", newCline.taskId) - pushToolResult(`Successfully created new task in ${targetMode.name} mode with message: ${unescapedMessage}`) + pushToolResult( + `Successfully created new task in ${targetAgent.name} agent with message: ${unescapedMessage}`, + ) // Set the isPaused flag to true so the parent // task can wait for the sub-task to finish. diff --git a/src/core/tools/switchModeTool.ts b/src/core/tools/switchModeTool.ts index 8ce906b41f..b8573bf3bb 100644 --- a/src/core/tools/switchModeTool.ts +++ b/src/core/tools/switchModeTool.ts @@ -3,7 +3,7 @@ import delay from "delay" 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 { defaultAgentSlug, getAgentBySlug } from "../../shared/agents" export async function switchModeTool( cline: Task, @@ -13,21 +13,21 @@ export async function switchModeTool( pushToolResult: PushToolResult, removeClosingTag: RemoveClosingTag, ) { - const mode_slug: string | undefined = block.params.mode_slug + const agent_slug: string | undefined = block.params.mode_slug const reason: string | undefined = block.params.reason try { if (block.partial) { const partialMessage = JSON.stringify({ tool: "switchMode", - mode: removeClosingTag("mode_slug", mode_slug), + mode: removeClosingTag("mode_slug", agent_slug), reason: removeClosingTag("reason", reason), }) await cline.ask("tool", partialMessage, block.partial).catch(() => {}) return } else { - if (!mode_slug) { + if (!agent_slug) { cline.consecutiveMistakeCount++ cline.recordToolError("switch_mode") pushToolResult(await cline.sayAndCreateMissingParamError("switch_mode", "mode_slug")) @@ -36,41 +36,41 @@ export async function switchModeTool( cline.consecutiveMistakeCount = 0 - // Verify the mode exists - const targetMode = getModeBySlug(mode_slug, (await cline.providerRef.deref()?.getState())?.customModes) + // Verify the agent exists + const targetAgent = getAgentBySlug(agent_slug, (await cline.providerRef.deref()?.getState())?.customModes) - if (!targetMode) { + if (!targetAgent) { cline.recordToolError("switch_mode") - pushToolResult(formatResponse.toolError(`Invalid mode: ${mode_slug}`)) + pushToolResult(formatResponse.toolError(`Invalid agent: ${agent_slug}`)) return } - // Check if already in requested mode - const currentMode = (await cline.providerRef.deref()?.getState())?.mode ?? defaultModeSlug + // Check if already in requested agent + const currentAgent = (await cline.providerRef.deref()?.getState())?.mode ?? defaultAgentSlug - if (currentMode === mode_slug) { + if (currentAgent === agent_slug) { cline.recordToolError("switch_mode") - pushToolResult(`Already in ${targetMode.name} mode.`) + pushToolResult(`Already in ${targetAgent.name} agent.`) return } - const completeMessage = JSON.stringify({ tool: "switchMode", mode: mode_slug, reason }) + const completeMessage = JSON.stringify({ tool: "switchMode", mode: agent_slug, reason }) const didApprove = await askApproval("tool", completeMessage) if (!didApprove) { return } - // Switch the mode using shared handler - await cline.providerRef.deref()?.handleModeSwitch(mode_slug) + // Switch the agent using shared handler + await cline.providerRef.deref()?.handleModeSwitch(agent_slug) pushToolResult( - `Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${ - targetMode.name - } mode${reason ? ` because: ${reason}` : ""}.`, + `Successfully switched from ${getAgentBySlug(currentAgent)?.name ?? currentAgent} agent to ${ + targetAgent.name + } agent${reason ? ` because: ${reason}` : ""}.`, ) - await delay(500) // Delay to allow mode change to take effect before next tool is executed + await delay(500) // Delay to allow agent change to take effect before next tool is executed return } diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index f0ce9e16e6..d133976052 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -1,15 +1,15 @@ import type { ToolName, ModeConfig } from "@roo-code/types" -import { Mode, isToolAllowedForMode } from "../../shared/modes" +import { Agent, isToolAllowedForAgent } from "../../shared/agents" export function validateToolUse( toolName: ToolName, - mode: Mode, - customModes?: ModeConfig[], + agent: Agent, + customAgents?: ModeConfig[], toolRequirements?: Record, toolParams?: Record, ): void { - if (!isToolAllowedForMode(toolName, mode, customModes ?? [], toolRequirements, toolParams)) { - throw new Error(`Tool "${toolName}" is not allowed in ${mode} mode.`) + if (!isToolAllowedForAgent(toolName, agent, customAgents ?? [], toolRequirements, toolParams)) { + throw new Error(`Tool "${toolName}" is not allowed in ${agent} mode.`) } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6bcb85e337..a918d02546 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -806,7 +806,7 @@ export class ClineProvider if (cline) { TelemetryService.instance.captureModeSwitch(cline.taskId, newMode) - cline.emit("taskModeSwitched", cline.taskId, newMode) + cline.emit("taskAgentSwitched", cline.taskId, newMode) } await this.updateGlobalState("mode", newMode) diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index b8e87a1d4a..a4dc3f1f01 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode" import { WebviewMessage } from "../../shared/WebviewMessage" -import { defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes" +import { defaultAgentSlug, getAgentBySlug, getGroupName } from "../../shared/agents" import { buildApiHandler } from "../../api" import { experiments as experimentsModule, EXPERIMENT_IDS } from "../../shared/experiments" @@ -39,7 +39,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web const cwd = provider.cwd - const mode = message.mode ?? defaultModeSlug + const agent = message.mode ?? defaultAgentSlug const customModes = await provider.customModesManager.getCustomModes() const rooIgnoreInstructions = provider.getCurrentCline()?.rooIgnoreController?.getInstructions() @@ -57,12 +57,12 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web } // Check if the current mode includes the browser tool group - const modeConfig = getModeBySlug(mode, customModes) - const modeSupportsBrowser = modeConfig?.groups.some((group) => getGroupName(group) === "browser") ?? false + const agentConfig = getAgentBySlug(agent, customModes) + const agentSupportsBrowser = agentConfig?.groups.some((group) => getGroupName(group) === "browser") ?? false // Only enable browser tools if the model supports it, the mode includes browser tools, // and browser tools are enabled in settings - const canUseBrowserTool = modelSupportsComputerUse && modeSupportsBrowser && (browserToolEnabled ?? true) + const canUseBrowserTool = modelSupportsComputerUse && agentSupportsBrowser && (browserToolEnabled ?? true) const systemPrompt = await SYSTEM_PROMPT( provider.context, @@ -71,7 +71,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web mcpEnabled ? provider.getMcpHub() : undefined, diffStrategy, browserViewportSize ?? "900x600", - mode, + agent, customModePrompts, customModes, customInstructions, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index da73c56920..c740721a04 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -210,9 +210,9 @@ export const webviewMessageHandler = async ( switch (message.type) { case "webviewDidLaunch": - // Load custom modes first - const customModes = await provider.customModesManager.getCustomModes() - await updateGlobalState("customModes", customModes) + // Load custom agents first + const customAgents = await provider.customModesManager.getCustomModes() + await updateGlobalState("customModes", customAgents) provider.postStateToWebview() provider.workspaceTracker?.initializeFilePaths() // Don't await. @@ -772,10 +772,10 @@ export const webviewMessageHandler = async ( break } case "openCustomModesSettings": { - const customModesFilePath = await provider.customModesManager.getCustomModesFilePath() + const customAgentsFilePath = await provider.customModesManager.getCustomModesFilePath() - if (customModesFilePath) { - openFile(customModesFilePath) + if (customAgentsFilePath) { + openFile(customAgentsFilePath) } break @@ -1623,32 +1623,32 @@ export const webviewMessageHandler = async ( break case "updateCustomMode": if (message.modeConfig) { - // Check if this is a new mode or an update to an existing mode - const existingModes = await provider.customModesManager.getCustomModes() - const isNewMode = !existingModes.some((mode) => mode.slug === message.modeConfig?.slug) + // Check if this is a new agent or an update to an existing agent + const existingAgents = await provider.customModesManager.getCustomModes() + const isNewAgent = !existingAgents.some((agent) => agent.slug === message.modeConfig?.slug) await provider.customModesManager.updateCustomMode(message.modeConfig.slug, message.modeConfig) - // Update state after saving the mode - const customModes = await provider.customModesManager.getCustomModes() - await updateGlobalState("customModes", customModes) + // Update state after saving the agent + const customAgents = await provider.customModesManager.getCustomModes() + await updateGlobalState("customModes", customAgents) await updateGlobalState("mode", message.modeConfig.slug) await provider.postStateToWebview() - // Track telemetry for custom mode creation or update + // Track telemetry for custom agent creation or update if (TelemetryService.hasInstance()) { - if (isNewMode) { - // This is a new custom mode + if (isNewAgent) { + // This is a new custom agent TelemetryService.instance.captureCustomModeCreated( message.modeConfig.slug, message.modeConfig.name, ) } else { // Determine which setting was changed by comparing objects - const existingMode = existingModes.find((mode) => mode.slug === message.modeConfig?.slug) - const changedSettings = existingMode + const existingAgent = existingAgents.find((agent) => agent.slug === message.modeConfig?.slug) + const changedSettings = existingAgent ? Object.keys(message.modeConfig).filter( (key) => - JSON.stringify((existingMode as Record)[key]) !== + JSON.stringify((existingAgent as Record)[key]) !== JSON.stringify((message.modeConfig as Record)[key]), ) : [] @@ -1662,16 +1662,16 @@ export const webviewMessageHandler = async ( break case "deleteCustomMode": if (message.slug) { - // Get the mode details to determine source and rules folder path - const customModes = await provider.customModesManager.getCustomModes() - const modeToDelete = customModes.find((mode) => mode.slug === message.slug) + // Get the agent details to determine source and rules folder path + const customAgents = await provider.customModesManager.getCustomModes() + const agentToDelete = customAgents.find((agent) => agent.slug === message.slug) - if (!modeToDelete) { + if (!agentToDelete) { break } // Determine the scope based on source (project or global) - const scope = modeToDelete.source || "global" + const scope = agentToDelete.source || "global" // Determine the rules folder path let rulesFolderPath: string @@ -1701,16 +1701,16 @@ export const webviewMessageHandler = async ( break } - // Delete the mode + // Delete the agent await provider.customModesManager.deleteCustomMode(message.slug) // Delete the rules folder if it exists if (rulesFolderExists) { try { await fs.rm(rulesFolderPath, { recursive: true, force: true }) - provider.log(`Deleted rules folder for mode ${message.slug}: ${rulesFolderPath}`) + provider.log(`Deleted rules folder for agent ${message.slug}: ${rulesFolderPath}`) } catch (error) { - provider.log(`Failed to delete rules folder for mode ${message.slug}: ${error}`) + provider.log(`Failed to delete rules folder for agent ${message.slug}: ${error}`) // Notify the user about the failure vscode.window.showErrorMessage( t("common:errors.delete_rules_folder_failed", { @@ -1718,11 +1718,11 @@ export const webviewMessageHandler = async ( error: error instanceof Error ? error.message : String(error), }), ) - // Continue with mode deletion even if folder deletion fails + // Continue with agent deletion even if folder deletion fails } } - // Switch back to default mode after deletion + // Switch back to default agent after deletion await updateGlobalState("mode", defaultModeSlug) await provider.postStateToWebview() } @@ -1730,11 +1730,11 @@ export const webviewMessageHandler = async ( case "exportMode": if (message.slug) { try { - // Get custom mode prompts to check if built-in mode has been customized - const customModePrompts = getGlobalState("customModePrompts") || {} - const customPrompt = customModePrompts[message.slug] + // Get custom agent prompts to check if built-in agent has been customized + const customAgentPrompts = getGlobalState("customModePrompts") || {} + const customPrompt = customAgentPrompts[message.slug] - // Export the mode with any customizations merged directly + // Export the agent with any customizations merged directly const result = await provider.customModesManager.exportModeWithRules(message.slug, customPrompt) if (result.success && result.yaml) { @@ -1764,7 +1764,7 @@ export const webviewMessageHandler = async ( filters: { "YAML files": ["yaml", "yml"], }, - title: "Save mode export", + title: "Save agent export", }) if (saveUri && result.yaml) { @@ -1803,7 +1803,7 @@ export const webviewMessageHandler = async ( } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - provider.log(`Failed to export mode ${message.slug}: ${errorMessage}`) + provider.log(`Failed to export agent ${message.slug}: ${errorMessage}`) // Send error message to webview provider.postMessageToWebview({ @@ -1842,7 +1842,7 @@ export const webviewMessageHandler = async ( filters: { "YAML files": ["yaml", "yml"], }, - title: "Select mode export file to import", + title: "Select agent export file to import", }) if (fileUri && fileUri[0]) { @@ -1852,7 +1852,7 @@ export const webviewMessageHandler = async ( // Read the file content const yamlContent = await fs.readFile(fileUri[0].fsPath, "utf-8") - // Import the mode with the specified source level + // Import the agent with the specified source level const result = await provider.customModesManager.importModeWithRules( yamlContent, message.source || "project", // Default to project if not specified @@ -1860,8 +1860,8 @@ export const webviewMessageHandler = async ( if (result.success) { // Update state after importing - const customModes = await provider.customModesManager.getCustomModes() - await updateGlobalState("customModes", customModes) + const customAgents = await provider.customModesManager.getCustomModes() + await updateGlobalState("customModes", customAgents) await provider.postStateToWebview() // Send success message to webview @@ -1893,7 +1893,7 @@ export const webviewMessageHandler = async ( } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - provider.log(`Failed to import mode: ${errorMessage}`) + provider.log(`Failed to import agent: ${errorMessage}`) // Send error message to webview provider.postMessageToWebview({ diff --git a/src/extension/api.ts b/src/extension/api.ts index 7027cb963a..48e1a662f6 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -229,7 +229,9 @@ export class API extends EventEmitter implements RooCodeAPI { } }) - cline.on("taskModeSwitched", (taskId, mode) => this.emit(RooCodeEventName.TaskModeSwitched, taskId, mode)) + cline.on("taskAgentSwitched", (taskId, agent) => + this.emit(RooCodeEventName.TaskAgentSwitched, taskId, agent), + ) cline.on("taskAskResponded", () => this.emit(RooCodeEventName.TaskAskResponded, cline.taskId)) diff --git a/src/shared/agents.ts b/src/shared/agents.ts new file mode 100644 index 0000000000..f62b1642d9 --- /dev/null +++ b/src/shared/agents.ts @@ -0,0 +1,450 @@ +import * as vscode from "vscode" + +import { + type GroupOptions, + type GroupEntry, + type AgentConfig, + type ModeConfig, + type CustomAgentPrompts, + type CustomModePrompts, + type ExperimentId, + type ToolGroup, + type PromptComponent, + DEFAULT_AGENTS, + DEFAULT_MODES, +} from "@roo-code/types" + +import { addCustomInstructions } from "../core/prompts/sections/custom-instructions" + +import { EXPERIMENT_IDS } from "./experiments" +import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "./tools" + +export type Agent = string +export type Mode = Agent // Keep Mode as an alias for backward compatibility + +// Helper to extract group name regardless of format +export function getGroupName(group: GroupEntry): ToolGroup { + if (typeof group === "string") { + return group + } + + return group[0] +} + +// Helper to get group options if they exist +function getGroupOptions(group: GroupEntry): GroupOptions | undefined { + return Array.isArray(group) ? group[1] : undefined +} + +// Helper to check if a file path matches a regex pattern +export function doesFileMatchRegex(filePath: string, pattern: string): boolean { + try { + const regex = new RegExp(pattern) + return regex.test(filePath) + } catch (error) { + console.error(`Invalid regex pattern: ${pattern}`, error) + return false + } +} + +// Helper to get all tools for a mode +export function getToolsForMode(groups: readonly GroupEntry[]): string[] { + const tools = new Set() + + // Add tools from each group + groups.forEach((group) => { + const groupName = getGroupName(group) + const groupConfig = TOOL_GROUPS[groupName] + groupConfig.tools.forEach((tool: string) => tools.add(tool)) + }) + + // Always add required tools + ALWAYS_AVAILABLE_TOOLS.forEach((tool) => tools.add(tool)) + + return Array.from(tools) +} + +// Main agents configuration as an ordered array +export const agents = DEFAULT_AGENTS +export const modes = agents // Keep modes as an alias for backward compatibility + +// Export the default agent slug +export const defaultAgentSlug = agents[0].slug +export const defaultModeSlug = defaultAgentSlug // Keep defaultModeSlug as an alias for backward compatibility + +// Helper functions +export function getAgentBySlug(slug: string, customAgents?: AgentConfig[]): AgentConfig | undefined { + // Check custom agents first + const customAgent = customAgents?.find((agent) => agent.slug === slug) + if (customAgent) { + return customAgent + } + // Then check built-in agents + return agents.find((agent) => agent.slug === slug) +} + +// Keep getModeBySlug as an alias for backward compatibility +export function getModeBySlug(slug: string, customModes?: ModeConfig[]): ModeConfig | undefined { + return getAgentBySlug(slug, customModes) +} + +export function getAgentConfig(slug: string, customAgents?: AgentConfig[]): AgentConfig { + const agent = getAgentBySlug(slug, customAgents) + if (!agent) { + throw new Error(`No agent found for slug: ${slug}`) + } + return agent +} + +// Keep getModeConfig as an alias for backward compatibility +export function getModeConfig(slug: string, customModes?: ModeConfig[]): ModeConfig { + return getAgentConfig(slug, customModes) +} + +// Get all available agents, with custom agents overriding built-in agents +export function getAllAgents(customAgents?: AgentConfig[]): AgentConfig[] { + if (!customAgents?.length) { + return [...agents] + } + + // Start with built-in agents + const allAgents = [...agents] + + // Process custom agents + customAgents.forEach((customAgent) => { + const index = allAgents.findIndex((agent) => agent.slug === customAgent.slug) + if (index !== -1) { + // Override existing agent + allAgents[index] = customAgent + } else { + // Add new agent + allAgents.push(customAgent) + } + }) + + return allAgents +} + +// Keep getAllModes as an alias for backward compatibility +export function getAllModes(customModes?: ModeConfig[]): ModeConfig[] { + return getAllAgents(customModes) +} + +// Check if an agent is custom or an override +export function isCustomAgent(slug: string, customAgents?: AgentConfig[]): boolean { + return !!customAgents?.some((agent) => agent.slug === slug) +} + +// Keep isCustomMode as an alias for backward compatibility +export function isCustomMode(slug: string, customModes?: ModeConfig[]): boolean { + return isCustomAgent(slug, customModes) +} + +/** + * Find an agent by its slug, don't fall back to built-in agents + */ +export function findAgentBySlug(slug: string, agents: readonly AgentConfig[] | undefined): AgentConfig | undefined { + return agents?.find((agent) => agent.slug === slug) +} + +// Keep findModeBySlug as an alias for backward compatibility +export function findModeBySlug(slug: string, modes: readonly ModeConfig[] | undefined): ModeConfig | undefined { + return findAgentBySlug(slug, modes) +} + +/** + * Get the agent selection based on the provided agent slug, prompt component, and custom agents. + * If a custom agent is found, it takes precedence over the built-in agents. + * If no custom agent is found, the built-in agent is used with partial merging from promptComponent. + * If neither is found, the default agent is used. + */ +export function getAgentSelection(agent: string, promptComponent?: PromptComponent, customAgents?: AgentConfig[]) { + const customAgent = findAgentBySlug(agent, customAgents) + const builtInAgent = findAgentBySlug(agent, agents) + + // If we have a custom agent, use it entirely + if (customAgent) { + return { + roleDefinition: customAgent.roleDefinition || "", + baseInstructions: customAgent.customInstructions || "", + description: customAgent.description || "", + } + } + + // Otherwise, use built-in agent as base and merge with promptComponent + const baseAgent = builtInAgent || agents[0] // fallback to default agent + + return { + roleDefinition: promptComponent?.roleDefinition || baseAgent.roleDefinition || "", + baseInstructions: promptComponent?.customInstructions || baseAgent.customInstructions || "", + description: baseAgent.description || "", + } +} + +// Keep getModeSelection as an alias for backward compatibility +export function getModeSelection(mode: string, promptComponent?: PromptComponent, customModes?: ModeConfig[]) { + return getAgentSelection(mode, promptComponent, customModes) +} + +// Edit operation parameters that indicate an actual edit operation +const EDIT_OPERATION_PARAMS = ["diff", "content", "operations", "search", "replace", "args", "line"] as const + +// Custom error class for file restrictions +export class FileRestrictionError extends Error { + constructor(mode: string, pattern: string, description: string | undefined, filePath: string, tool?: string) { + const toolInfo = tool ? `Tool '${tool}' in mode '${mode}'` : `This mode (${mode})` + super( + `${toolInfo} can only edit files matching pattern: ${pattern}${description ? ` (${description})` : ""}. Got: ${filePath}`, + ) + this.name = "FileRestrictionError" + } +} + +export function isToolAllowedForAgent( + tool: string, + agentSlug: string, + customAgents: AgentConfig[], + toolRequirements?: Record, + toolParams?: Record, // All tool parameters + experiments?: Record, +): boolean { + // Always allow these tools + if (ALWAYS_AVAILABLE_TOOLS.includes(tool as any)) { + return true + } + if (experiments && Object.values(EXPERIMENT_IDS).includes(tool as ExperimentId)) { + if (!experiments[tool]) { + return false + } + } + + // Check tool requirements if any exist + if (toolRequirements && typeof toolRequirements === "object") { + if (tool in toolRequirements && !toolRequirements[tool]) { + return false + } + } else if (toolRequirements === false) { + // If toolRequirements is a boolean false, all tools are disabled + return false + } + + const agent = getAgentBySlug(agentSlug, customAgents) + if (!agent) { + return false + } + + // Check if tool is in any of the agent's groups and respects any group options + for (const group of agent.groups) { + const groupName = getGroupName(group) + const options = getGroupOptions(group) + + const groupConfig = TOOL_GROUPS[groupName] + + // If the tool isn't in this group's tools, continue to next group + if (!groupConfig.tools.includes(tool)) { + continue + } + + // If there are no options, allow the tool + if (!options) { + return true + } + + // For the edit group, check file regex if specified + if (groupName === "edit" && options.fileRegex) { + const filePath = toolParams?.path + // Check if this is an actual edit operation (not just path-only for streaming) + const isEditOperation = EDIT_OPERATION_PARAMS.some((param) => toolParams?.[param]) + + // Handle single file path validation + if (filePath && isEditOperation && !doesFileMatchRegex(filePath, options.fileRegex)) { + throw new FileRestrictionError(agent.name, options.fileRegex, options.description, filePath, tool) + } + + // Handle XML args parameter (used by MULTI_FILE_APPLY_DIFF experiment) + if (toolParams?.args && typeof toolParams.args === "string") { + // Extract file paths from XML args with improved validation + try { + const filePathMatches = toolParams.args.match(/([^<]+)<\/path>/g) + if (filePathMatches) { + for (const match of filePathMatches) { + // More robust path extraction with validation + const pathMatch = match.match(/([^<]+)<\/path>/) + if (pathMatch && pathMatch[1]) { + const extractedPath = pathMatch[1].trim() + // Validate that the path is not empty and doesn't contain invalid characters + if (extractedPath && !extractedPath.includes("<") && !extractedPath.includes(">")) { + if (!doesFileMatchRegex(extractedPath, options.fileRegex)) { + throw new FileRestrictionError( + agent.name, + options.fileRegex, + options.description, + extractedPath, + tool, + ) + } + } + } + } + } + } catch (error) { + // Re-throw FileRestrictionError as it's an expected validation error + if (error instanceof FileRestrictionError) { + throw error + } + // If XML parsing fails, log the error but don't block the operation + console.warn(`Failed to parse XML args for file restriction validation: ${error}`) + } + } + } + + return true + } + + return false +} + +// Keep isToolAllowedForMode as an alias for backward compatibility +export function isToolAllowedForMode( + tool: string, + modeSlug: string, + customModes: ModeConfig[], + toolRequirements?: Record, + toolParams?: Record, + experiments?: Record, +): boolean { + return isToolAllowedForAgent(tool, modeSlug, customModes, toolRequirements, toolParams, experiments) +} + +// Create the agent-specific default prompts +export const defaultPrompts: Readonly = Object.freeze( + Object.fromEntries( + agents.map((agent) => [ + agent.slug, + { + roleDefinition: agent.roleDefinition, + whenToUse: agent.whenToUse, + customInstructions: agent.customInstructions, + description: agent.description, + }, + ]), + ), +) + +// Helper function to get all agents with their prompt overrides from extension state +export async function getAllAgentsWithPrompts(context: vscode.ExtensionContext): Promise { + const customAgents = (await context.globalState.get("customModes")) || [] + const customAgentPrompts = (await context.globalState.get("customModePrompts")) || {} + + const allAgents = getAllAgents(customAgents) + return allAgents.map((agent) => ({ + ...agent, + roleDefinition: customAgentPrompts[agent.slug]?.roleDefinition ?? agent.roleDefinition, + whenToUse: customAgentPrompts[agent.slug]?.whenToUse ?? agent.whenToUse, + customInstructions: customAgentPrompts[agent.slug]?.customInstructions ?? agent.customInstructions, + // description is not overridable via customAgentPrompts, so we keep the original + })) +} + +// Keep getAllModesWithPrompts as an alias for backward compatibility +export async function getAllModesWithPrompts(context: vscode.ExtensionContext): Promise { + return getAllAgentsWithPrompts(context) +} + +// Helper function to get complete agent details with all overrides +export async function getFullAgentDetails( + agentSlug: string, + customAgents?: AgentConfig[], + customAgentPrompts?: CustomAgentPrompts, + options?: { + cwd?: string + globalCustomInstructions?: string + language?: string + }, +): Promise { + // First get the base agent config from custom agents or built-in agents + const baseAgent = getAgentBySlug(agentSlug, customAgents) || agents.find((a) => a.slug === agentSlug) || agents[0] + + // Check for any prompt component overrides + const promptComponent = customAgentPrompts?.[agentSlug] + + // Get the base custom instructions + const baseCustomInstructions = promptComponent?.customInstructions || baseAgent.customInstructions || "" + const baseWhenToUse = promptComponent?.whenToUse || baseAgent.whenToUse || "" + const baseDescription = promptComponent?.description || baseAgent.description || "" + + // If we have cwd, load and combine all custom instructions + let fullCustomInstructions = baseCustomInstructions + if (options?.cwd) { + fullCustomInstructions = await addCustomInstructions( + baseCustomInstructions, + options.globalCustomInstructions || "", + options.cwd, + agentSlug, + { language: options.language }, + ) + } + + // Return agent with any overrides applied + return { + ...baseAgent, + roleDefinition: promptComponent?.roleDefinition || baseAgent.roleDefinition, + whenToUse: baseWhenToUse, + description: baseDescription, + customInstructions: fullCustomInstructions, + } +} + +// Keep getFullModeDetails as an alias for backward compatibility +export async function getFullModeDetails( + modeSlug: string, + customModes?: ModeConfig[], + customModePrompts?: CustomModePrompts, + options?: { + cwd?: string + globalCustomInstructions?: string + language?: string + }, +): Promise { + return getFullAgentDetails(modeSlug, customModes, customModePrompts, options) +} + +// Helper function to safely get role definition +export function getRoleDefinition(agentSlug: string, customAgents?: AgentConfig[]): string { + const agent = getAgentBySlug(agentSlug, customAgents) + if (!agent) { + console.warn(`No agent found for slug: ${agentSlug}`) + return "" + } + return agent.roleDefinition +} + +// Helper function to safely get description +export function getDescription(agentSlug: string, customAgents?: AgentConfig[]): string { + const agent = getAgentBySlug(agentSlug, customAgents) + if (!agent) { + console.warn(`No agent found for slug: ${agentSlug}`) + return "" + } + return agent.description ?? "" +} + +// Helper function to safely get whenToUse +export function getWhenToUse(agentSlug: string, customAgents?: AgentConfig[]): string { + const agent = getAgentBySlug(agentSlug, customAgents) + if (!agent) { + console.warn(`No agent found for slug: ${agentSlug}`) + return "" + } + return agent.whenToUse ?? "" +} + +// Helper function to safely get custom instructions +export function getCustomInstructions(agentSlug: string, customAgents?: AgentConfig[]): string { + const agent = getAgentBySlug(agentSlug, customAgents) + if (!agent) { + console.warn(`No agent found for slug: ${agentSlug}`) + return "" + } + return agent.customInstructions ?? "" +} diff --git a/src/shared/modes.ts b/src/shared/modes.ts index f68d25c682..3fad558ffb 100644 --- a/src/shared/modes.ts +++ b/src/shared/modes.ts @@ -1,383 +1,2 @@ -import * as vscode from "vscode" - -import { - type GroupOptions, - type GroupEntry, - type ModeConfig, - type CustomModePrompts, - type ExperimentId, - type ToolGroup, - type PromptComponent, - DEFAULT_MODES, -} from "@roo-code/types" - -import { addCustomInstructions } from "../core/prompts/sections/custom-instructions" - -import { EXPERIMENT_IDS } from "./experiments" -import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "./tools" - -export type Mode = string - -// Helper to extract group name regardless of format -export function getGroupName(group: GroupEntry): ToolGroup { - if (typeof group === "string") { - return group - } - - return group[0] -} - -// Helper to get group options if they exist -function getGroupOptions(group: GroupEntry): GroupOptions | undefined { - return Array.isArray(group) ? group[1] : undefined -} - -// Helper to check if a file path matches a regex pattern -export function doesFileMatchRegex(filePath: string, pattern: string): boolean { - try { - const regex = new RegExp(pattern) - return regex.test(filePath) - } catch (error) { - console.error(`Invalid regex pattern: ${pattern}`, error) - return false - } -} - -// Helper to get all tools for a mode -export function getToolsForMode(groups: readonly GroupEntry[]): string[] { - const tools = new Set() - - // Add tools from each group - groups.forEach((group) => { - const groupName = getGroupName(group) - const groupConfig = TOOL_GROUPS[groupName] - groupConfig.tools.forEach((tool: string) => tools.add(tool)) - }) - - // Always add required tools - ALWAYS_AVAILABLE_TOOLS.forEach((tool) => tools.add(tool)) - - return Array.from(tools) -} - -// Main modes configuration as an ordered array -export const modes = DEFAULT_MODES - -// Export the default mode slug -export const defaultModeSlug = modes[0].slug - -// Helper functions -export function getModeBySlug(slug: string, customModes?: ModeConfig[]): ModeConfig | undefined { - // Check custom modes first - const customMode = customModes?.find((mode) => mode.slug === slug) - if (customMode) { - return customMode - } - // Then check built-in modes - return modes.find((mode) => mode.slug === slug) -} - -export function getModeConfig(slug: string, customModes?: ModeConfig[]): ModeConfig { - const mode = getModeBySlug(slug, customModes) - if (!mode) { - throw new Error(`No mode found for slug: ${slug}`) - } - return mode -} - -// Get all available modes, with custom modes overriding built-in modes -export function getAllModes(customModes?: ModeConfig[]): ModeConfig[] { - if (!customModes?.length) { - return [...modes] - } - - // Start with built-in modes - const allModes = [...modes] - - // Process custom modes - customModes.forEach((customMode) => { - const index = allModes.findIndex((mode) => mode.slug === customMode.slug) - if (index !== -1) { - // Override existing mode - allModes[index] = customMode - } else { - // Add new mode - allModes.push(customMode) - } - }) - - return allModes -} - -// Check if a mode is custom or an override -export function isCustomMode(slug: string, customModes?: ModeConfig[]): boolean { - return !!customModes?.some((mode) => mode.slug === slug) -} - -/** - * Find a mode by its slug, don't fall back to built-in modes - */ -export function findModeBySlug(slug: string, modes: readonly ModeConfig[] | undefined): ModeConfig | undefined { - return modes?.find((mode) => mode.slug === slug) -} - -/** - * Get the mode selection based on the provided mode slug, prompt component, and custom modes. - * If a custom mode is found, it takes precedence over the built-in modes. - * If no custom mode is found, the built-in mode is used with partial merging from promptComponent. - * If neither is found, the default mode is used. - */ -export function getModeSelection(mode: string, promptComponent?: PromptComponent, customModes?: ModeConfig[]) { - const customMode = findModeBySlug(mode, customModes) - const builtInMode = findModeBySlug(mode, modes) - - // If we have a custom mode, use it entirely - if (customMode) { - return { - roleDefinition: customMode.roleDefinition || "", - baseInstructions: customMode.customInstructions || "", - description: customMode.description || "", - } - } - - // Otherwise, use built-in mode as base and merge with promptComponent - const baseMode = builtInMode || modes[0] // fallback to default mode - - return { - roleDefinition: promptComponent?.roleDefinition || baseMode.roleDefinition || "", - baseInstructions: promptComponent?.customInstructions || baseMode.customInstructions || "", - description: baseMode.description || "", - } -} - -// Edit operation parameters that indicate an actual edit operation -const EDIT_OPERATION_PARAMS = ["diff", "content", "operations", "search", "replace", "args", "line"] as const - -// Custom error class for file restrictions -export class FileRestrictionError extends Error { - constructor(mode: string, pattern: string, description: string | undefined, filePath: string, tool?: string) { - const toolInfo = tool ? `Tool '${tool}' in mode '${mode}'` : `This mode (${mode})` - super( - `${toolInfo} can only edit files matching pattern: ${pattern}${description ? ` (${description})` : ""}. Got: ${filePath}`, - ) - this.name = "FileRestrictionError" - } -} - -export function isToolAllowedForMode( - tool: string, - modeSlug: string, - customModes: ModeConfig[], - toolRequirements?: Record, - toolParams?: Record, // All tool parameters - experiments?: Record, -): boolean { - // Always allow these tools - if (ALWAYS_AVAILABLE_TOOLS.includes(tool as any)) { - return true - } - if (experiments && Object.values(EXPERIMENT_IDS).includes(tool as ExperimentId)) { - if (!experiments[tool]) { - return false - } - } - - // Check tool requirements if any exist - if (toolRequirements && typeof toolRequirements === "object") { - if (tool in toolRequirements && !toolRequirements[tool]) { - return false - } - } else if (toolRequirements === false) { - // If toolRequirements is a boolean false, all tools are disabled - return false - } - - const mode = getModeBySlug(modeSlug, customModes) - if (!mode) { - return false - } - - // Check if tool is in any of the mode's groups and respects any group options - for (const group of mode.groups) { - const groupName = getGroupName(group) - const options = getGroupOptions(group) - - const groupConfig = TOOL_GROUPS[groupName] - - // If the tool isn't in this group's tools, continue to next group - if (!groupConfig.tools.includes(tool)) { - continue - } - - // If there are no options, allow the tool - if (!options) { - return true - } - - // For the edit group, check file regex if specified - if (groupName === "edit" && options.fileRegex) { - const filePath = toolParams?.path - // Check if this is an actual edit operation (not just path-only for streaming) - const isEditOperation = EDIT_OPERATION_PARAMS.some((param) => toolParams?.[param]) - - // Handle single file path validation - if (filePath && isEditOperation && !doesFileMatchRegex(filePath, options.fileRegex)) { - throw new FileRestrictionError(mode.name, options.fileRegex, options.description, filePath, tool) - } - - // Handle XML args parameter (used by MULTI_FILE_APPLY_DIFF experiment) - if (toolParams?.args && typeof toolParams.args === "string") { - // Extract file paths from XML args with improved validation - try { - const filePathMatches = toolParams.args.match(/([^<]+)<\/path>/g) - if (filePathMatches) { - for (const match of filePathMatches) { - // More robust path extraction with validation - const pathMatch = match.match(/([^<]+)<\/path>/) - if (pathMatch && pathMatch[1]) { - const extractedPath = pathMatch[1].trim() - // Validate that the path is not empty and doesn't contain invalid characters - if (extractedPath && !extractedPath.includes("<") && !extractedPath.includes(">")) { - if (!doesFileMatchRegex(extractedPath, options.fileRegex)) { - throw new FileRestrictionError( - mode.name, - options.fileRegex, - options.description, - extractedPath, - tool, - ) - } - } - } - } - } - } catch (error) { - // Re-throw FileRestrictionError as it's an expected validation error - if (error instanceof FileRestrictionError) { - throw error - } - // If XML parsing fails, log the error but don't block the operation - console.warn(`Failed to parse XML args for file restriction validation: ${error}`) - } - } - } - - return true - } - - return false -} - -// Create the mode-specific default prompts -export const defaultPrompts: Readonly = Object.freeze( - Object.fromEntries( - modes.map((mode) => [ - mode.slug, - { - roleDefinition: mode.roleDefinition, - whenToUse: mode.whenToUse, - customInstructions: mode.customInstructions, - description: mode.description, - }, - ]), - ), -) - -// Helper function to get all modes with their prompt overrides from extension state -export async function getAllModesWithPrompts(context: vscode.ExtensionContext): Promise { - const customModes = (await context.globalState.get("customModes")) || [] - const customModePrompts = (await context.globalState.get("customModePrompts")) || {} - - const allModes = getAllModes(customModes) - return allModes.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 is not overridable via customModePrompts, so we keep the original - })) -} - -// Helper function to get complete mode details with all overrides -export async function getFullModeDetails( - modeSlug: string, - customModes?: ModeConfig[], - customModePrompts?: CustomModePrompts, - options?: { - cwd?: string - globalCustomInstructions?: string - language?: string - }, -): Promise { - // First get the base mode config from custom modes or built-in modes - const baseMode = getModeBySlug(modeSlug, customModes) || modes.find((m) => m.slug === modeSlug) || modes[0] - - // Check for any prompt component overrides - const promptComponent = customModePrompts?.[modeSlug] - - // Get the base custom instructions - const baseCustomInstructions = promptComponent?.customInstructions || baseMode.customInstructions || "" - const baseWhenToUse = promptComponent?.whenToUse || baseMode.whenToUse || "" - const baseDescription = promptComponent?.description || baseMode.description || "" - - // If we have cwd, load and combine all custom instructions - let fullCustomInstructions = baseCustomInstructions - if (options?.cwd) { - fullCustomInstructions = await addCustomInstructions( - baseCustomInstructions, - options.globalCustomInstructions || "", - options.cwd, - modeSlug, - { language: options.language }, - ) - } - - // Return mode with any overrides applied - return { - ...baseMode, - roleDefinition: promptComponent?.roleDefinition || baseMode.roleDefinition, - whenToUse: baseWhenToUse, - description: baseDescription, - customInstructions: fullCustomInstructions, - } -} - -// Helper function to safely get role definition -export function getRoleDefinition(modeSlug: string, customModes?: ModeConfig[]): string { - const mode = getModeBySlug(modeSlug, customModes) - if (!mode) { - console.warn(`No mode found for slug: ${modeSlug}`) - return "" - } - return mode.roleDefinition -} - -// Helper function to safely get description -export function getDescription(modeSlug: string, customModes?: ModeConfig[]): string { - const mode = getModeBySlug(modeSlug, customModes) - if (!mode) { - console.warn(`No mode found for slug: ${modeSlug}`) - return "" - } - return mode.description ?? "" -} - -// Helper function to safely get whenToUse -export function getWhenToUse(modeSlug: string, customModes?: ModeConfig[]): string { - const mode = getModeBySlug(modeSlug, customModes) - if (!mode) { - console.warn(`No mode found for slug: ${modeSlug}`) - return "" - } - return mode.whenToUse ?? "" -} - -// Helper function to safely get custom instructions -export function getCustomInstructions(modeSlug: string, customModes?: ModeConfig[]): string { - const mode = getModeBySlug(modeSlug, customModes) - if (!mode) { - console.warn(`No mode found for slug: ${modeSlug}`) - return "" - } - return mode.customInstructions ?? "" -} +// Backward compatibility export - re-export everything from agents.ts +export * from "./agents" diff --git a/webview-ui/src/components/agents/AgentsView.tsx b/webview-ui/src/components/agents/AgentsView.tsx new file mode 100644 index 0000000000..d102718c05 --- /dev/null +++ b/webview-ui/src/components/agents/AgentsView.tsx @@ -0,0 +1,1649 @@ +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react" +import { + VSCodeCheckbox, + VSCodeRadioGroup, + VSCodeRadio, + VSCodeTextArea, + VSCodeLink, + VSCodeTextField, +} from "@vscode/webview-ui-toolkit/react" +import { Trans } from "react-i18next" +import { ChevronDown, X, Upload, Download } from "lucide-react" + +import { ModeConfig, GroupEntry, PromptComponent, ToolGroup, modeConfigSchema } from "@roo-code/types" + +import { + Mode, + getRoleDefinition, + getWhenToUse, + getDescription, + getCustomInstructions, + getAllModes, + findModeBySlug as findCustomModeBySlug, +} from "@roo/agents" +import { TOOL_GROUPS } from "@roo/tools" + +import { vscode } from "@src/utils/vscode" +import { buildDocLink } from "@src/utils/docLinks" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { Tab, TabContent, TabHeader } from "@src/components/common/Tab" +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Popover, + PopoverContent, + PopoverTrigger, + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandItem, + CommandGroup, + Input, + StandardTooltip, +} from "@src/components/ui" +import { DeleteAgentDialog } from "@src/components/agents/DeleteAgentDialog" + +// Get all available groups that should show in prompts view +const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable) + +type ModeSource = "global" | "project" + +type AgentsViewProps = { + onDone: () => void +} + +// Helper to get group name regardless of format +function getGroupName(group: GroupEntry): ToolGroup { + return Array.isArray(group) ? group[0] : group +} + +const AgentsView = ({ onDone }: AgentsViewProps) => { + const { t } = useAppTranslation() + + const { + customModePrompts, + listApiConfigMeta, + currentApiConfigName, + mode, + customInstructions, + setCustomInstructions, + customModes, + } = useExtensionState() + + // Use a local state to track the visually active mode + // This prevents flickering when switching modes rapidly by: + // 1. Updating the UI immediately when a mode is clicked + // 2. Not syncing with the backend mode state (which would cause flickering) + // 3. Still sending the mode change to the backend for persistence + const [visualMode, setVisualMode] = useState(mode) + + // Memoize modes to preserve array order + const modes = useMemo(() => getAllModes(customModes), [customModes]) + + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [selectedPromptContent, setSelectedPromptContent] = useState("") + const [selectedPromptTitle, setSelectedPromptTitle] = useState("") + const [isToolsEditMode, setIsToolsEditMode] = useState(false) + const [showConfigMenu, setShowConfigMenu] = useState(false) + const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false) + const [isSystemPromptDisclosureOpen, setIsSystemPromptDisclosureOpen] = useState(false) + const [isExporting, setIsExporting] = useState(false) + const [isImporting, setIsImporting] = useState(false) + const [showImportDialog, setShowImportDialog] = useState(false) + const [hasRulesToExport, setHasRulesToExport] = useState>({}) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [modeToDelete, setModeToDelete] = useState<{ + slug: string + name: string + source?: string + rulesFolderPath?: string + } | null>(null) + + // State for mode selection popover and search + const [open, setOpen] = useState(false) + const [searchValue, setSearchValue] = useState("") + const searchInputRef = useRef(null) + + // Local state for mode name input to allow visual emptying + const [localModeName, setLocalModeName] = useState("") + const [currentEditingModeSlug, setCurrentEditingModeSlug] = useState(null) + + // Direct update functions + const updateAgentPrompt = useCallback( + (mode: Mode, promptData: PromptComponent) => { + const existingPrompt = customModePrompts?.[mode] as PromptComponent + const updatedPrompt = { ...existingPrompt, ...promptData } + + // Only include properties that differ from defaults + if (updatedPrompt.roleDefinition === getRoleDefinition(mode)) { + delete updatedPrompt.roleDefinition + } + if (updatedPrompt.description === getDescription(mode)) { + delete updatedPrompt.description + } + if (updatedPrompt.whenToUse === getWhenToUse(mode)) { + delete updatedPrompt.whenToUse + } + + vscode.postMessage({ + type: "updatePrompt", + promptMode: mode, + customPrompt: updatedPrompt, + }) + }, + [customModePrompts], + ) + + const updateCustomMode = useCallback((slug: string, modeConfig: ModeConfig) => { + const source = modeConfig.source || "global" + + vscode.postMessage({ + type: "updateCustomMode", + slug, + modeConfig: { + ...modeConfig, + source, // Ensure source is set + }, + }) + }, []) + + // Helper function to find a mode by slug + const findModeBySlug = useCallback( + (searchSlug: string, modes: readonly ModeConfig[] | undefined): ModeConfig | undefined => { + return findCustomModeBySlug(searchSlug, modes) + }, + [], + ) + + const switchMode = useCallback((slug: string) => { + vscode.postMessage({ + type: "mode", + text: slug, + }) + }, []) + + // Handle mode switching with explicit state initialization + const handleModeSwitch = useCallback( + (modeConfig: ModeConfig) => { + if (modeConfig.slug === visualMode) return // Prevent unnecessary updates + + // Immediately update visual state for instant feedback + setVisualMode(modeConfig.slug) + + // Then send the mode change message to the backend + switchMode(modeConfig.slug) + + // Exit tools edit mode when switching modes + setIsToolsEditMode(false) + }, + [visualMode, switchMode], + ) + + // Handler for popover open state change + const onOpenChange = useCallback((open: boolean) => { + setOpen(open) + // Reset search when closing the popover + if (!open) { + setTimeout(() => setSearchValue(""), 100) + } + }, []) + + // Handler for clearing search input + const onClearSearch = useCallback(() => { + setSearchValue("") + searchInputRef.current?.focus() + }, []) + + // Helper function to get current mode's config + const getCurrentMode = useCallback((): ModeConfig | undefined => { + const findMode = (m: ModeConfig): boolean => m.slug === visualMode + return customModes?.find(findMode) || modes.find(findMode) + }, [visualMode, customModes, modes]) + + // Check if the current mode has rules to export + const checkRulesDirectory = useCallback((slug: string) => { + vscode.postMessage({ + type: "checkRulesDirectory", + slug: slug, + }) + }, []) + + // Check rules directory when mode changes + useEffect(() => { + const currentMode = getCurrentMode() + if (currentMode?.slug && hasRulesToExport[currentMode.slug] === undefined) { + checkRulesDirectory(currentMode.slug) + } + }, [getCurrentMode, checkRulesDirectory, hasRulesToExport]) + + // Reset local name state when mode changes + useEffect(() => { + if (currentEditingModeSlug && currentEditingModeSlug !== visualMode) { + setCurrentEditingModeSlug(null) + setLocalModeName("") + } + }, [visualMode, currentEditingModeSlug]) + + // Helper function to safely access mode properties + const getModeProperty = ( + mode: ModeConfig | undefined, + property: T, + ): ModeConfig[T] | undefined => { + return mode?.[property] + } + + // State for create mode dialog + const [newModeName, setNewModeName] = useState("") + const [newModeSlug, setNewModeSlug] = useState("") + const [newModeDescription, setNewModeDescription] = useState("") + const [newModeRoleDefinition, setNewModeRoleDefinition] = useState("") + const [newModeWhenToUse, setNewModeWhenToUse] = useState("") + const [newModeCustomInstructions, setNewModeCustomInstructions] = useState("") + const [newModeGroups, setNewModeGroups] = useState(availableGroups) + const [newModeSource, setNewModeSource] = useState("global") + + // Field-specific error states + const [nameError, setNameError] = useState("") + const [slugError, setSlugError] = useState("") + const [descriptionError, setDescriptionError] = useState("") + const [roleDefinitionError, setRoleDefinitionError] = useState("") + const [groupsError, setGroupsError] = useState("") + + // Helper to reset form state + const resetFormState = useCallback(() => { + // Reset form fields + setNewModeName("") + setNewModeSlug("") + setNewModeDescription("") + setNewModeGroups(availableGroups) + setNewModeRoleDefinition("") + setNewModeWhenToUse("") + setNewModeCustomInstructions("") + setNewModeSource("global") + // Reset error states + setNameError("") + setSlugError("") + setDescriptionError("") + setRoleDefinitionError("") + setGroupsError("") + }, []) + + // Reset form fields when dialog opens + useEffect(() => { + if (isCreateModeDialogOpen) { + resetFormState() + } + }, [isCreateModeDialogOpen, resetFormState]) + + // Helper function to generate a unique slug from a name + const generateSlug = useCallback((name: string, attempt = 0): string => { + const baseSlug = name + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + return attempt === 0 ? baseSlug : `${baseSlug}-${attempt}` + }, []) + + // Handler for name changes + const handleNameChange = useCallback( + (name: string) => { + setNewModeName(name) + setNewModeSlug(generateSlug(name)) + }, + [generateSlug], + ) + + const handleCreateMode = useCallback(() => { + // Clear previous errors + setNameError("") + setSlugError("") + setDescriptionError("") + setRoleDefinitionError("") + setGroupsError("") + + const source = newModeSource + const newMode: ModeConfig = { + slug: newModeSlug, + name: newModeName, + description: newModeDescription.trim() || undefined, + roleDefinition: newModeRoleDefinition.trim(), + whenToUse: newModeWhenToUse.trim() || undefined, + customInstructions: newModeCustomInstructions.trim() || undefined, + groups: newModeGroups, + source, + } + + // Validate the mode against the schema + const result = modeConfigSchema.safeParse(newMode) + + if (!result.success) { + // Map Zod errors to specific fields + result.error.errors.forEach((error) => { + const field = error.path[0] as string + const message = error.message + + switch (field) { + case "name": + setNameError(message) + break + case "slug": + setSlugError(message) + break + case "description": + setDescriptionError(message) + break + case "roleDefinition": + setRoleDefinitionError(message) + break + case "groups": + setGroupsError(message) + break + } + }) + return + } + + updateCustomMode(newModeSlug, newMode) + switchMode(newModeSlug) + setIsCreateModeDialogOpen(false) + resetFormState() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + newModeName, + newModeSlug, + newModeDescription, + newModeRoleDefinition, + newModeWhenToUse, // Add whenToUse dependency + newModeCustomInstructions, + newModeGroups, + newModeSource, + updateCustomMode, + ]) + + const isNameOrSlugTaken = useCallback( + (name: string, slug: string) => { + return modes.some((m) => m.slug === slug || m.name === name) + }, + [modes], + ) + + const openCreateModeDialog = useCallback(() => { + const baseNamePrefix = "New Custom Mode" + // Find unique name and slug + let attempt = 0 + let name = baseNamePrefix + let slug = generateSlug(name) + while (isNameOrSlugTaken(name, slug)) { + attempt++ + name = `${baseNamePrefix} ${attempt + 1}` + slug = generateSlug(name) + } + setNewModeName(name) + setNewModeSlug(slug) + setIsCreateModeDialogOpen(true) + }, [generateSlug, isNameOrSlugTaken]) + + // Handler for group checkbox changes + const handleGroupChange = useCallback( + (group: ToolGroup, isCustomMode: boolean, customMode: ModeConfig | undefined) => + (e: Event | React.FormEvent) => { + if (!isCustomMode) return // Prevent changes to built-in modes + const target = (e as CustomEvent)?.detail?.target || (e.target as HTMLInputElement) + const checked = target.checked + const oldGroups = customMode?.groups || [] + let newGroups: GroupEntry[] + if (checked) { + newGroups = [...oldGroups, group] + } else { + newGroups = oldGroups.filter((g) => getGroupName(g) !== group) + } + if (customMode) { + const source = customMode.source || "global" + + updateCustomMode(customMode.slug, { + ...customMode, + groups: newGroups, + source, + }) + } + }, + [updateCustomMode], + ) + + // Handle clicks outside the config menu + useEffect(() => { + const handleClickOutside = () => { + if (showConfigMenu) { + setShowConfigMenu(false) + } + } + + document.addEventListener("click", handleClickOutside) + return () => document.removeEventListener("click", handleClickOutside) + }, [showConfigMenu]) + + // Use a ref to store the current modeToDelete value + const modeToDeleteRef = useRef(modeToDelete) + + // Update the ref whenever modeToDelete changes + useEffect(() => { + modeToDeleteRef.current = modeToDelete + }, [modeToDelete]) + + useEffect(() => { + const handler = (event: MessageEvent) => { + const message = event.data + if (message.type === "systemPrompt") { + if (message.text) { + setSelectedPromptContent(message.text) + setSelectedPromptTitle(`System Prompt (${message.mode} mode)`) + setIsDialogOpen(true) + } + } else if (message.type === "exportModeResult") { + setIsExporting(false) + + if (!message.success) { + // Show error message + console.error("Failed to export mode:", message.error) + } + } else if (message.type === "importModeResult") { + setIsImporting(false) + setShowImportDialog(false) + + if (!message.success) { + // Only log error if it's not a cancellation + if (message.error !== "cancelled") { + console.error("Failed to import mode:", message.error) + } + } + } else if (message.type === "checkRulesDirectoryResult") { + setHasRulesToExport((prev) => ({ + ...prev, + [message.slug]: message.hasContent, + })) + } else if (message.type === "deleteCustomModeCheck") { + // Handle the check response + // Use the ref to get the current modeToDelete value + const currentModeToDelete = modeToDeleteRef.current + if (message.slug && currentModeToDelete && currentModeToDelete.slug === message.slug) { + setModeToDelete({ + ...currentModeToDelete, + rulesFolderPath: message.rulesFolderPath, + }) + setShowDeleteConfirm(true) + } + } + } + + window.addEventListener("message", handler) + return () => window.removeEventListener("message", handler) + }, []) // Empty dependency array - only register once + + const handleAgentReset = ( + modeSlug: string, + type: "roleDefinition" | "description" | "whenToUse" | "customInstructions", + ) => { + // Only reset for built-in modes + const existingPrompt = customModePrompts?.[modeSlug] as PromptComponent + const updatedPrompt = { ...existingPrompt } + delete updatedPrompt[type] // Remove the field entirely to ensure it reloads from defaults + + vscode.postMessage({ + type: "updatePrompt", + promptMode: modeSlug, + customPrompt: updatedPrompt, + }) + } + + return ( + + +

{t("prompts:title")}

+ +
+ + +
+
e.stopPropagation()} className="flex justify-between items-center mb-3"> +

{t("prompts:modes.title")}

+
+ + + +
+ + + + {showConfigMenu && ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + className="absolute top-full right-0 w-[200px] mt-1 bg-vscode-editor-background border border-vscode-input-border rounded shadow-md z-[1000]"> +
{ + e.preventDefault() // Prevent blur + vscode.postMessage({ + type: "openCustomModesSettings", + }) + setShowConfigMenu(false) + }} + onClick={(e) => e.preventDefault()}> + {t("prompts:modes.editGlobalModes")} +
+
{ + e.preventDefault() // Prevent blur + vscode.postMessage({ + type: "openFile", + text: "./.roomodes", + values: { + create: true, + content: JSON.stringify({ customModes: [] }, null, 2), + }, + }) + setShowConfigMenu(false) + }} + onClick={(e) => e.preventDefault()}> + {t("prompts:modes.editProjectModes")} +
+
+ )} +
+ + + +
+
+ +
+ + + + +
+ +
+ + + + + + +
+ + {searchValue.length > 0 && ( +
+ +
+ )} +
+ + + {searchValue && ( +
+ {t("prompts:modes.noMatchFound")} +
+ )} +
+ + {modes + .filter((modeConfig) => + searchValue + ? modeConfig.name + .toLowerCase() + .includes(searchValue.toLowerCase()) + : true, + ) + .map((modeConfig) => ( + { + handleModeSwitch(modeConfig) + setOpen(false) + }} + data-testid={`mode-option-${modeConfig.slug}`}> +
+ + {modeConfig.name} + + + {modeConfig.slug} + +
+
+ ))} +
+
+
+
+
+
+ {/* API Configuration - Moved Here */} +
+
{t("prompts:apiConfiguration.title")}
+
+ {t("prompts:apiConfiguration.select")} +
+
+ +
+
+
+ + {/* Name section */} +
+ {/* Only show name and delete for custom modes */} + {visualMode && findModeBySlug(visualMode, customModes) && ( +
+
+
{t("prompts:createModeDialog.name.label")}
+
+ { + const customMode = findModeBySlug(visualMode, customModes) + if (customMode) { + setCurrentEditingModeSlug(visualMode) + setLocalModeName(customMode.name) + } + }} + onChange={(e) => { + setLocalModeName(e.target.value) + }} + onBlur={() => { + const customMode = findModeBySlug(visualMode, customModes) + if (customMode && localModeName.trim()) { + // Only update if the name is not empty + updateCustomMode(visualMode, { + ...customMode, + name: localModeName, + source: customMode.source || "global", + }) + } + // Clear the editing state + setCurrentEditingModeSlug(null) + }} + className="w-full" + /> + + + +
+
+
+ )} + + {/* Role Definition section */} +
+
+
{t("prompts:roleDefinition.title")}
+ {!findModeBySlug(visualMode, customModes) && ( + + + + )} +
+
+ {t("prompts:roleDefinition.description")} +
+ { + const customMode = findModeBySlug(visualMode, customModes) + const prompt = customModePrompts?.[visualMode] as PromptComponent + return ( + customMode?.roleDefinition ?? + prompt?.roleDefinition ?? + getRoleDefinition(visualMode) + ) + })()} + onChange={(e) => { + const value = + (e as unknown as CustomEvent)?.detail?.target?.value || + ((e as any).target as HTMLTextAreaElement).value + const customMode = findModeBySlug(visualMode, customModes) + if (customMode) { + // For custom modes, update the JSON file + updateCustomMode(visualMode, { + ...customMode, + roleDefinition: value.trim() || "", + source: customMode.source || "global", + }) + } else { + // For built-in modes, update the prompts + updateAgentPrompt(visualMode, { + roleDefinition: value.trim() || undefined, + }) + } + }} + className="w-full" + rows={5} + data-testid={`${getCurrentMode()?.slug || "code"}-prompt-textarea`} + /> +
+ + {/* Description section */} +
+
+
{t("prompts:description.title")}
+ {!findModeBySlug(visualMode, customModes) && ( + + + + )} +
+
+ {t("prompts:description.description")} +
+ { + const customMode = findModeBySlug(visualMode, customModes) + const prompt = customModePrompts?.[visualMode] as PromptComponent + return customMode?.description ?? prompt?.description ?? getDescription(visualMode) + })()} + onChange={(e) => { + const value = + (e as unknown as CustomEvent)?.detail?.target?.value || + ((e as any).target as HTMLTextAreaElement).value + const customMode = findModeBySlug(visualMode, customModes) + if (customMode) { + // For custom modes, update the JSON file + updateCustomMode(visualMode, { + ...customMode, + description: value.trim() || undefined, + source: customMode.source || "global", + }) + } else { + // For built-in modes, update the prompts + updateAgentPrompt(visualMode, { + description: value.trim() || undefined, + }) + } + }} + className="w-full" + data-testid={`${getCurrentMode()?.slug || "code"}-description-textfield`} + /> +
+ + {/* When to Use section */} +
+
+
{t("prompts:whenToUse.title")}
+ {!findModeBySlug(visualMode, customModes) && ( + + + + )} +
+
+ {t("prompts:whenToUse.description")} +
+ { + const customMode = findModeBySlug(visualMode, customModes) + const prompt = customModePrompts?.[visualMode] as PromptComponent + return customMode?.whenToUse ?? prompt?.whenToUse ?? getWhenToUse(visualMode) + })()} + onChange={(e) => { + const value = + (e as unknown as CustomEvent)?.detail?.target?.value || + ((e as any).target as HTMLTextAreaElement).value + const customMode = findModeBySlug(visualMode, customModes) + if (customMode) { + // For custom modes, update the JSON file + updateCustomMode(visualMode, { + ...customMode, + whenToUse: value.trim() || undefined, + source: customMode.source || "global", + }) + } else { + // For built-in modes, update the prompts + updateAgentPrompt(visualMode, { + whenToUse: value.trim() || undefined, + }) + } + }} + className="w-full" + rows={4} + data-testid={`${getCurrentMode()?.slug || "code"}-when-to-use-textarea`} + /> +
+ + {/* Mode settings */} + <> + {/* Show tools for all modes */} +
+
+
{t("prompts:tools.title")}
+ {findModeBySlug(visualMode, customModes) && ( + + + + )} +
+ {!findModeBySlug(visualMode, customModes) && ( +
+ {t("prompts:tools.builtInModesText")} +
+ )} + {isToolsEditMode && findModeBySlug(visualMode, customModes) ? ( +
+ {availableGroups.map((group) => { + const currentMode = getCurrentMode() + const isCustomMode = findModeBySlug(visualMode, customModes) + const customMode = isCustomMode + const isGroupEnabled = isCustomMode + ? customMode?.groups?.some((g) => getGroupName(g) === group) + : currentMode?.groups?.some((g) => getGroupName(g) === group) + + return ( + + {t(`prompts:tools.toolNames.${group}`)} + {group === "edit" && ( +
+ {t("prompts:tools.allowedFiles")}{" "} + {(() => { + const currentMode = getCurrentMode() + const editGroup = currentMode?.groups?.find( + (g) => + Array.isArray(g) && + g[0] === "edit" && + g[1]?.fileRegex, + ) + if (!Array.isArray(editGroup)) return t("prompts:allFiles") + return ( + editGroup[1].description || + `/${editGroup[1].fileRegex}/` + ) + })()} +
+ )} +
+ ) + })} +
+ ) : ( +
+ {(() => { + const currentMode = getCurrentMode() + const enabledGroups = currentMode?.groups || [] + + // If there are no enabled groups, display translated "None" + if (enabledGroups.length === 0) { + return t("prompts:tools.noTools") + } + + return enabledGroups + .map((group) => { + const groupName = getGroupName(group) + const displayName = t(`prompts:tools.toolNames.${groupName}`) + if (Array.isArray(group) && group[1]?.fileRegex) { + const description = + group[1].description || `/${group[1].fileRegex}/` + return `${displayName} (${description})` + } + return displayName + }) + .join(", ") + })()} +
+ )} +
+ + + {/* Role definition for both built-in and custom modes */} +
+
+
{t("prompts:customInstructions.title")}
+ {!findModeBySlug(visualMode, customModes) && ( + + + + )} +
+
+ {t("prompts:customInstructions.description", { + modeName: getCurrentMode()?.name || "Code", + })} +
+ { + const customMode = findModeBySlug(visualMode, customModes) + const prompt = customModePrompts?.[visualMode] as PromptComponent + return ( + customMode?.customInstructions ?? + prompt?.customInstructions ?? + getCustomInstructions(mode, customModes) + ) + })()} + onChange={(e) => { + const value = + (e as unknown as CustomEvent)?.detail?.target?.value || + ((e as any).target as HTMLTextAreaElement).value + const customMode = findModeBySlug(visualMode, customModes) + if (customMode) { + // For custom modes, update the JSON file + updateCustomMode(visualMode, { + ...customMode, + customInstructions: value.trim() || undefined, + source: customMode.source || "global", + }) + } else { + // For built-in modes, update the prompts + const existingPrompt = customModePrompts?.[visualMode] as PromptComponent + updateAgentPrompt(visualMode, { + ...existingPrompt, + customInstructions: value.trim(), + }) + } + }} + rows={10} + className="w-full" + data-testid={`${getCurrentMode()?.slug || "code"}-custom-instructions-textarea`} + /> +
+ { + const currentMode = getCurrentMode() + if (!currentMode) return + + // Open or create an empty file + vscode.postMessage({ + type: "openFile", + text: `./.roo/rules-${currentMode.slug}/rules.md`, + values: { + create: true, + content: "", + }, + }) + }} + /> + ), + }} + /> +
+
+
+ +
+
+ + + + +
+ + {/* Export/Import Mode Buttons */} +
+ {/* Export button - visible when any mode is selected */} + {getCurrentMode() && ( + + )} + {/* Import button - always visible */} + +
+ + {/* Advanced Features Disclosure */} +
+ + + {isSystemPromptDisclosureOpen && ( +
+ {/* Override System Prompt Section */} +
+

+ Override System Prompt +

+
+ { + const currentMode = getCurrentMode() + if (!currentMode) return + + vscode.postMessage({ + type: "openFile", + text: `./.roo/system-prompt-${currentMode.slug}`, + values: { + create: true, + content: "", + }, + }) + }} + /> + ), + "1": ( + + ), + "2": , + }} + /> +
+
+
+ )} +
+
+ +
+

{t("prompts:globalCustomInstructions.title")}

+ +
+ + + +
+ { + const value = + (e as unknown as CustomEvent)?.detail?.target?.value || + ((e as any).target as HTMLTextAreaElement).value + setCustomInstructions(value || undefined) + vscode.postMessage({ + type: "customInstructions", + text: value.trim() || undefined, + }) + }} + rows={4} + className="w-full" + data-testid="global-custom-instructions-textarea" + /> +
+ + vscode.postMessage({ + type: "openFile", + text: "./.roo/rules/rules.md", + values: { + create: true, + content: "", + }, + }) + } + /> + ), + }} + /> +
+
+
+ + {isCreateModeDialogOpen && ( +
+
+
+ +

{t("prompts:createModeDialog.title")}

+
+
{t("prompts:createModeDialog.name.label")}
+ { + handleNameChange(e.target.value) + }} + className="w-full" + /> + {nameError && ( +
{nameError}
+ )} +
+
+
{t("prompts:createModeDialog.slug.label")}
+ { + setNewModeSlug(e.target.value) + }} + className="w-full" + /> +
+ {t("prompts:createModeDialog.slug.description")} +
+ {slugError && ( +
{slugError}
+ )} +
+
+
{t("prompts:createModeDialog.saveLocation.label")}
+
+ {t("prompts:createModeDialog.saveLocation.description")} +
+ ) => { + const target = ((e as CustomEvent)?.detail?.target || + (e.target as HTMLInputElement)) as HTMLInputElement + setNewModeSource(target.value as ModeSource) + }}> + + {t("prompts:createModeDialog.saveLocation.global.label")} +
+ {t("prompts:createModeDialog.saveLocation.global.description")} +
+
+ + {t("prompts:createModeDialog.saveLocation.project.label")} +
+ {t("prompts:createModeDialog.saveLocation.project.description")} +
+
+
+
+ +
+
+ {t("prompts:createModeDialog.roleDefinition.label")} +
+
+ {t("prompts:createModeDialog.roleDefinition.description")} +
+ { + setNewModeRoleDefinition((e.target as HTMLTextAreaElement).value) + }} + rows={4} + className="w-full" + /> + {roleDefinitionError && ( +
+ {roleDefinitionError} +
+ )} +
+ +
+
{t("prompts:createModeDialog.description.label")}
+
+ {t("prompts:createModeDialog.description.description")} +
+ { + setNewModeDescription((e.target as HTMLInputElement).value) + }} + className="w-full" + /> + {descriptionError && ( +
{descriptionError}
+ )} +
+ +
+
{t("prompts:createModeDialog.whenToUse.label")}
+
+ {t("prompts:createModeDialog.whenToUse.description")} +
+ { + setNewModeWhenToUse((e.target as HTMLTextAreaElement).value) + }} + rows={3} + className="w-full" + /> +
+
+
{t("prompts:createModeDialog.tools.label")}
+
+ {t("prompts:createModeDialog.tools.description")} +
+
+ {availableGroups.map((group) => ( + getGroupName(g) === group)} + onChange={(e: Event | React.FormEvent) => { + const target = + (e as CustomEvent)?.detail?.target || (e.target as HTMLInputElement) + const checked = target.checked + if (checked) { + setNewModeGroups([...newModeGroups, group]) + } else { + setNewModeGroups( + newModeGroups.filter((g) => getGroupName(g) !== group), + ) + } + }}> + {t(`prompts:tools.toolNames.${group}`)} + + ))} +
+ {groupsError && ( +
{groupsError}
+ )} +
+
+
+ {t("prompts:createModeDialog.customInstructions.label")} +
+
+ {t("prompts:createModeDialog.customInstructions.description")} +
+ { + setNewModeCustomInstructions((e.target as HTMLTextAreaElement).value) + }} + rows={4} + className="w-full" + /> +
+
+
+ + +
+
+
+ )} + + {isDialogOpen && ( +
+
+
+ +

+ {selectedPromptTitle || + t("prompts:systemPrompt.title", { + modeName: getCurrentMode()?.name || "Code", + })} +

+
+								{selectedPromptContent}
+							
+
+
+ +
+
+
+ )} + + {/* Import Mode Dialog */} + {showImportDialog && ( +
+
+

{t("prompts:modes.importMode")}

+

+ {t("prompts:importMode.selectLevel")} +

+
+ + +
+
+ + +
+
+
+ )} + + {/* Delete Mode Confirmation Dialog */} + { + if (modeToDelete) { + vscode.postMessage({ + type: "deleteCustomMode", + slug: modeToDelete.slug, + }) + setShowDeleteConfirm(false) + setModeToDelete(null) + } + }} + /> +
+ ) +} + +export default AgentsView diff --git a/webview-ui/src/components/agents/DeleteAgentDialog.tsx b/webview-ui/src/components/agents/DeleteAgentDialog.tsx new file mode 100644 index 0000000000..b2ace1079f --- /dev/null +++ b/webview-ui/src/components/agents/DeleteAgentDialog.tsx @@ -0,0 +1,61 @@ +import React from "react" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@src/components/ui" + +interface DeleteAgentDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + modeToDelete: { + slug: string + name: string + source?: string + rulesFolderPath?: string + } | null + onConfirm: () => void +} + +export const DeleteAgentDialog: React.FC = ({ + open, + onOpenChange, + modeToDelete, + onConfirm, +}) => { + const { t } = useAppTranslation() + + return ( + + + + {t("prompts:deleteMode.title")} + + {modeToDelete && ( + <> + {t("prompts:deleteMode.message", { modeName: modeToDelete.name })} + {modeToDelete.rulesFolderPath && ( +
+ {t("prompts:deleteMode.rulesFolder", { + folderPath: modeToDelete.rulesFolderPath, + })} +
+ )} + + )} +
+
+ + {t("prompts:deleteMode.cancel")} + {t("prompts:deleteMode.confirm")} + +
+
+ ) +} diff --git a/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx b/webview-ui/src/components/agents/__tests__/AgentsView.spec.tsx similarity index 95% rename from webview-ui/src/components/modes/__tests__/ModesView.spec.tsx rename to webview-ui/src/components/agents/__tests__/AgentsView.spec.tsx index e202114bbb..f406d288a0 100644 --- a/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx +++ b/webview-ui/src/components/agents/__tests__/AgentsView.spec.tsx @@ -1,7 +1,7 @@ -// npx vitest src/components/modes/__tests__/ModesView.spec.tsx +// npx vitest src/components/agents/__tests__/AgentsView.spec.tsx import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" -import ModesView from "../ModesView" +import AgentsView from "../AgentsView" import { ExtensionStateContext } from "@src/context/ExtensionStateContext" import { vscode } from "@src/utils/vscode" @@ -32,7 +32,7 @@ const renderPromptsView = (props = {}) => { const mockOnDone = vitest.fn() return render( - + , ) } @@ -128,7 +128,7 @@ describe("PromptsView", () => { const { unmount } = render( - + , ) @@ -154,7 +154,7 @@ describe("PromptsView", () => { render( - + , ) @@ -175,7 +175,7 @@ describe("PromptsView", () => { const { unmount } = render( - + , ) @@ -190,7 +190,7 @@ describe("PromptsView", () => { render( - + , ) diff --git a/webview-ui/src/components/chat/AgentSelector.tsx b/webview-ui/src/components/chat/AgentSelector.tsx new file mode 100644 index 0000000000..b3ab50bf6a --- /dev/null +++ b/webview-ui/src/components/chat/AgentSelector.tsx @@ -0,0 +1,304 @@ +import React from "react" +import { ChevronUp, Check, X } from "lucide-react" +import { cn } from "@/lib/utils" +import { useRooPortal } from "@/components/ui/hooks/useRooPortal" +import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui" +import { IconButton } from "./IconButton" +import { vscode } from "@/utils/vscode" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { Mode, getAllModes } from "@roo/agents" +import { ModeConfig, CustomModePrompts } from "@roo-code/types" +import { telemetryClient } from "@/utils/TelemetryClient" +import { TelemetryEventName } from "@roo-code/types" +import { Fzf } from "fzf" + +// Minimum number of modes required to show search functionality +const SEARCH_THRESHOLD = 6 + +interface AgentSelectorProps { + value: Mode + onChange: (value: Mode) => void + disabled?: boolean + title?: string + triggerClassName?: string + modeShortcutText: string + customModes?: ModeConfig[] + customModePrompts?: CustomModePrompts + disableSearch?: boolean +} + +export const AgentSelector = ({ + value, + onChange, + disabled = false, + title = "", + triggerClassName = "", + modeShortcutText, + customModes, + customModePrompts, + disableSearch = false, +}: AgentSelectorProps) => { + const [open, setOpen] = React.useState(false) + const [searchValue, setSearchValue] = React.useState("") + const searchInputRef = React.useRef(null) + const portalContainer = useRooPortal("roo-portal") + const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState() + const { t } = useAppTranslation() + + const trackModeSelectorOpened = React.useCallback(() => { + // Track telemetry every time the mode selector is opened + telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED) + + // Track first-time usage for UI purposes + if (!hasOpenedModeSelector) { + setHasOpenedModeSelector(true) + vscode.postMessage({ type: "hasOpenedModeSelector", bool: true }) + } + }, [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) => ({ + ...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]) + + // Memoize searchable items for fuzzy search with separate name and description search + const nameSearchItems = React.useMemo(() => { + return modes.map((mode) => ({ + original: mode, + searchStr: [mode.name, mode.slug].filter(Boolean).join(" "), + })) + }, [modes]) + + const descriptionSearchItems = React.useMemo(() => { + return modes.map((mode) => ({ + original: mode, + searchStr: mode.description || "", + })) + }, [modes]) + + // Create memoized Fzf instances for name and description searches + const nameFzfInstance = React.useMemo(() => { + return new Fzf(nameSearchItems, { + selector: (item) => item.searchStr, + }) + }, [nameSearchItems]) + + const descriptionFzfInstance = React.useMemo(() => { + return new Fzf(descriptionSearchItems, { + selector: (item) => item.searchStr, + }) + }, [descriptionSearchItems]) + + // Filter modes based on search value using fuzzy search with priority + const filteredModes = React.useMemo(() => { + if (!searchValue) return modes + + // First search in names/slugs + const nameMatches = nameFzfInstance.find(searchValue) + const nameMatchedModes = new Set(nameMatches.map((result) => result.item.original.slug)) + + // Then search in descriptions + const descriptionMatches = descriptionFzfInstance.find(searchValue) + + // Combine results: name matches first, then description matches + const combinedResults = [ + ...nameMatches.map((result) => result.item.original), + ...descriptionMatches + .filter((result) => !nameMatchedModes.has(result.item.original.slug)) + .map((result) => result.item.original), + ] + + return combinedResults + }, [modes, searchValue, nameFzfInstance, descriptionFzfInstance]) + + const onClearSearch = React.useCallback(() => { + setSearchValue("") + searchInputRef.current?.focus() + }, []) + + const handleSelect = React.useCallback( + (modeSlug: string) => { + onChange(modeSlug as Mode) + setOpen(false) + // Clear search after selection + setSearchValue("") + }, + [onChange], + ) + + const onOpenChange = React.useCallback( + (isOpen: boolean) => { + if (isOpen) trackModeSelectorOpened() + setOpen(isOpen) + // Clear search when closing + if (!isOpen) { + setSearchValue("") + } + }, + [trackModeSelectorOpened], + ) + + // Auto-focus search input when popover opens + React.useEffect(() => { + if (open && searchInputRef.current) { + searchInputRef.current.focus() + } + }, [open]) + + // Determine if search should be shown + const showSearch = !disableSearch && modes.length > SEARCH_THRESHOLD + + // Combine instruction text for tooltip + const instructionText = `${t("chat:modeSelector.description")} ${modeShortcutText}` + + const trigger = ( + + + {selectedMode?.name || ""} + + ) + + return ( + + {title ? {trigger} : trigger} + + +
+ {/* Show search bar only when there are more than SEARCH_THRESHOLD items, otherwise show info blurb */} + {showSearch ? ( +
+ setSearchValue(e.target.value)} + placeholder={t("chat:modeSelector.searchPlaceholder")} + className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0" + data-testid="mode-search-input" + /> + {searchValue.length > 0 && ( +
+ +
+ )} +
+ ) : ( +
+

{instructionText}

+
+ )} + + {/* Mode List */} +
+ {filteredModes.length === 0 && searchValue ? ( +
+ {t("chat:modeSelector.noResults")} +
+ ) : ( +
+ {filteredModes.map((mode) => ( +
handleSelect(mode.slug)} + className={cn( + "px-3 py-1.5 text-sm cursor-pointer flex items-center", + "hover:bg-vscode-list-hoverBackground", + mode.slug === value + ? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground" + : "", + )} + data-testid="mode-selector-item"> +
+
{mode.name}
+ {mode.description && ( +
+ {mode.description} +
+ )} +
+ {mode.slug === value && } +
+ ))} +
+ )} +
+ + {/* Bottom bar with buttons on left and title on right */} +
+
+ { + window.postMessage( + { + type: "action", + action: "marketplaceButtonClicked", + values: { marketplaceTab: "mode" }, + }, + "*", + ) + setOpen(false) + }} + /> + { + vscode.postMessage({ + type: "switchTab", + tab: "modes", + }) + setOpen(false) + }} + /> +
+ + {/* Info icon and title on the right - only show info icon when search bar is visible */} +
+ {showSearch && ( + + + + )} +

+ {t("chat:modeSelector.title")} +

+
+
+
+
+
+ ) +} + +export default AgentSelector diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 93dd2f1f4f..7601fcccb4 100644 --- a/webview-ui/src/components/chat/ModeSelector.tsx +++ b/webview-ui/src/components/chat/ModeSelector.tsx @@ -1,304 +1,3 @@ -import React from "react" -import { ChevronUp, Check, X } from "lucide-react" -import { cn } from "@/lib/utils" -import { useRooPortal } from "@/components/ui/hooks/useRooPortal" -import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui" -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 { ModeConfig, CustomModePrompts } from "@roo-code/types" -import { telemetryClient } from "@/utils/TelemetryClient" -import { TelemetryEventName } from "@roo-code/types" -import { Fzf } from "fzf" - -// Minimum number of modes required to show search functionality -const SEARCH_THRESHOLD = 6 - -interface ModeSelectorProps { - value: Mode - onChange: (value: Mode) => void - disabled?: boolean - title?: string - triggerClassName?: string - modeShortcutText: string - customModes?: ModeConfig[] - customModePrompts?: CustomModePrompts - disableSearch?: boolean -} - -export const ModeSelector = ({ - value, - onChange, - disabled = false, - title = "", - triggerClassName = "", - modeShortcutText, - customModes, - customModePrompts, - disableSearch = false, -}: ModeSelectorProps) => { - const [open, setOpen] = React.useState(false) - const [searchValue, setSearchValue] = React.useState("") - const searchInputRef = React.useRef(null) - const portalContainer = useRooPortal("roo-portal") - const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState() - const { t } = useAppTranslation() - - const trackModeSelectorOpened = React.useCallback(() => { - // Track telemetry every time the mode selector is opened - telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED) - - // Track first-time usage for UI purposes - if (!hasOpenedModeSelector) { - setHasOpenedModeSelector(true) - vscode.postMessage({ type: "hasOpenedModeSelector", bool: true }) - } - }, [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) => ({ - ...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]) - - // Memoize searchable items for fuzzy search with separate name and description search - const nameSearchItems = React.useMemo(() => { - return modes.map((mode) => ({ - original: mode, - searchStr: [mode.name, mode.slug].filter(Boolean).join(" "), - })) - }, [modes]) - - const descriptionSearchItems = React.useMemo(() => { - return modes.map((mode) => ({ - original: mode, - searchStr: mode.description || "", - })) - }, [modes]) - - // Create memoized Fzf instances for name and description searches - const nameFzfInstance = React.useMemo(() => { - return new Fzf(nameSearchItems, { - selector: (item) => item.searchStr, - }) - }, [nameSearchItems]) - - const descriptionFzfInstance = React.useMemo(() => { - return new Fzf(descriptionSearchItems, { - selector: (item) => item.searchStr, - }) - }, [descriptionSearchItems]) - - // Filter modes based on search value using fuzzy search with priority - const filteredModes = React.useMemo(() => { - if (!searchValue) return modes - - // First search in names/slugs - const nameMatches = nameFzfInstance.find(searchValue) - const nameMatchedModes = new Set(nameMatches.map((result) => result.item.original.slug)) - - // Then search in descriptions - const descriptionMatches = descriptionFzfInstance.find(searchValue) - - // Combine results: name matches first, then description matches - const combinedResults = [ - ...nameMatches.map((result) => result.item.original), - ...descriptionMatches - .filter((result) => !nameMatchedModes.has(result.item.original.slug)) - .map((result) => result.item.original), - ] - - return combinedResults - }, [modes, searchValue, nameFzfInstance, descriptionFzfInstance]) - - const onClearSearch = React.useCallback(() => { - setSearchValue("") - searchInputRef.current?.focus() - }, []) - - const handleSelect = React.useCallback( - (modeSlug: string) => { - onChange(modeSlug as Mode) - setOpen(false) - // Clear search after selection - setSearchValue("") - }, - [onChange], - ) - - const onOpenChange = React.useCallback( - (isOpen: boolean) => { - if (isOpen) trackModeSelectorOpened() - setOpen(isOpen) - // Clear search when closing - if (!isOpen) { - setSearchValue("") - } - }, - [trackModeSelectorOpened], - ) - - // Auto-focus search input when popover opens - React.useEffect(() => { - if (open && searchInputRef.current) { - searchInputRef.current.focus() - } - }, [open]) - - // Determine if search should be shown - const showSearch = !disableSearch && modes.length > SEARCH_THRESHOLD - - // Combine instruction text for tooltip - const instructionText = `${t("chat:modeSelector.description")} ${modeShortcutText}` - - const trigger = ( - - - {selectedMode?.name || ""} - - ) - - return ( - - {title ? {trigger} : trigger} - - -
- {/* Show search bar only when there are more than SEARCH_THRESHOLD items, otherwise show info blurb */} - {showSearch ? ( -
- setSearchValue(e.target.value)} - placeholder={t("chat:modeSelector.searchPlaceholder")} - className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0" - data-testid="mode-search-input" - /> - {searchValue.length > 0 && ( -
- -
- )} -
- ) : ( -
-

{instructionText}

-
- )} - - {/* Mode List */} -
- {filteredModes.length === 0 && searchValue ? ( -
- {t("chat:modeSelector.noResults")} -
- ) : ( -
- {filteredModes.map((mode) => ( -
handleSelect(mode.slug)} - className={cn( - "px-3 py-1.5 text-sm cursor-pointer flex items-center", - "hover:bg-vscode-list-hoverBackground", - mode.slug === value - ? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground" - : "", - )} - data-testid="mode-selector-item"> -
-
{mode.name}
- {mode.description && ( -
- {mode.description} -
- )} -
- {mode.slug === value && } -
- ))} -
- )} -
- - {/* Bottom bar with buttons on left and title on right */} -
-
- { - window.postMessage( - { - type: "action", - action: "marketplaceButtonClicked", - values: { marketplaceTab: "mode" }, - }, - "*", - ) - setOpen(false) - }} - /> - { - vscode.postMessage({ - type: "switchTab", - tab: "modes", - }) - setOpen(false) - }} - /> -
- - {/* Info icon and title on the right - only show info icon when search bar is visible */} -
- {showSearch && ( - - - - )} -

- {t("chat:modeSelector.title")} -

-
-
-
-
-
- ) -} - -export default ModeSelector +// Backward compatibility export +export { AgentSelector as ModeSelector, default } from "./AgentSelector" +export * from "./AgentSelector" diff --git a/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx index a829168893..a600e8a115 100644 --- a/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx @@ -1,8 +1,8 @@ import React from "react" import { render, screen, fireEvent } from "@/utils/test-utils" import { describe, test, expect, vi } from "vitest" -import ModeSelector from "../ModeSelector" -import { Mode } from "@roo/modes" +import { AgentSelector as ModeSelector } from "../AgentSelector" +import { Mode } from "@roo/agents" import { ModeConfig } from "@roo-code/types" // Mock the dependencies @@ -38,8 +38,8 @@ vi.mock("@/utils/TelemetryClient", () => ({ // Create a variable to control what getAllModes returns let mockModes: ModeConfig[] = [] -vi.mock("@roo/modes", async () => { - const actual = await vi.importActual("@roo/modes") +vi.mock("@roo/agents", async () => { + const actual = await vi.importActual("@roo/agents") return { ...actual, getAllModes: () => mockModes, diff --git a/webview-ui/src/components/modes/DeleteModeDialog.tsx b/webview-ui/src/components/modes/DeleteModeDialog.tsx index d801b3149b..1e9a3e1595 100644 --- a/webview-ui/src/components/modes/DeleteModeDialog.tsx +++ b/webview-ui/src/components/modes/DeleteModeDialog.tsx @@ -1,56 +1,3 @@ -import React from "react" -import { useAppTranslation } from "@src/i18n/TranslationContext" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@src/components/ui" - -interface DeleteModeDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - modeToDelete: { - slug: string - name: string - source?: string - rulesFolderPath?: string - } | null - onConfirm: () => void -} - -export const DeleteModeDialog: React.FC = ({ open, onOpenChange, modeToDelete, onConfirm }) => { - const { t } = useAppTranslation() - - return ( - - - - {t("prompts:deleteMode.title")} - - {modeToDelete && ( - <> - {t("prompts:deleteMode.message", { modeName: modeToDelete.name })} - {modeToDelete.rulesFolderPath && ( -
- {t("prompts:deleteMode.rulesFolder", { - folderPath: modeToDelete.rulesFolderPath, - })} -
- )} - - )} -
-
- - {t("prompts:deleteMode.cancel")} - {t("prompts:deleteMode.confirm")} - -
-
- ) -} +// Backward compatibility export +export { DeleteAgentDialog as DeleteModeDialog } from "../agents/DeleteAgentDialog" +export * from "../agents/DeleteAgentDialog" diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index 170d03b0e4..b6eceb0d1f 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -1,1649 +1,3 @@ -import React, { useState, useEffect, useMemo, useCallback, useRef } from "react" -import { - VSCodeCheckbox, - VSCodeRadioGroup, - VSCodeRadio, - VSCodeTextArea, - VSCodeLink, - VSCodeTextField, -} from "@vscode/webview-ui-toolkit/react" -import { Trans } from "react-i18next" -import { ChevronDown, X, Upload, Download } from "lucide-react" - -import { ModeConfig, GroupEntry, PromptComponent, ToolGroup, modeConfigSchema } from "@roo-code/types" - -import { - Mode, - getRoleDefinition, - getWhenToUse, - getDescription, - getCustomInstructions, - getAllModes, - findModeBySlug as findCustomModeBySlug, -} from "@roo/modes" -import { TOOL_GROUPS } from "@roo/tools" - -import { vscode } from "@src/utils/vscode" -import { buildDocLink } from "@src/utils/docLinks" -import { useAppTranslation } from "@src/i18n/TranslationContext" -import { useExtensionState } from "@src/context/ExtensionStateContext" -import { Tab, TabContent, TabHeader } from "@src/components/common/Tab" -import { - Button, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Popover, - PopoverContent, - PopoverTrigger, - Command, - CommandInput, - CommandList, - CommandEmpty, - CommandItem, - CommandGroup, - Input, - StandardTooltip, -} from "@src/components/ui" -import { DeleteModeDialog } from "@src/components/modes/DeleteModeDialog" - -// Get all available groups that should show in prompts view -const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable) - -type ModeSource = "global" | "project" - -type ModesViewProps = { - onDone: () => void -} - -// Helper to get group name regardless of format -function getGroupName(group: GroupEntry): ToolGroup { - return Array.isArray(group) ? group[0] : group -} - -const ModesView = ({ onDone }: ModesViewProps) => { - const { t } = useAppTranslation() - - const { - customModePrompts, - listApiConfigMeta, - currentApiConfigName, - mode, - customInstructions, - setCustomInstructions, - customModes, - } = useExtensionState() - - // Use a local state to track the visually active mode - // This prevents flickering when switching modes rapidly by: - // 1. Updating the UI immediately when a mode is clicked - // 2. Not syncing with the backend mode state (which would cause flickering) - // 3. Still sending the mode change to the backend for persistence - const [visualMode, setVisualMode] = useState(mode) - - // Memoize modes to preserve array order - const modes = useMemo(() => getAllModes(customModes), [customModes]) - - const [isDialogOpen, setIsDialogOpen] = useState(false) - const [selectedPromptContent, setSelectedPromptContent] = useState("") - const [selectedPromptTitle, setSelectedPromptTitle] = useState("") - const [isToolsEditMode, setIsToolsEditMode] = useState(false) - const [showConfigMenu, setShowConfigMenu] = useState(false) - const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false) - const [isSystemPromptDisclosureOpen, setIsSystemPromptDisclosureOpen] = useState(false) - const [isExporting, setIsExporting] = useState(false) - const [isImporting, setIsImporting] = useState(false) - const [showImportDialog, setShowImportDialog] = useState(false) - const [hasRulesToExport, setHasRulesToExport] = useState>({}) - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) - const [modeToDelete, setModeToDelete] = useState<{ - slug: string - name: string - source?: string - rulesFolderPath?: string - } | null>(null) - - // State for mode selection popover and search - const [open, setOpen] = useState(false) - const [searchValue, setSearchValue] = useState("") - const searchInputRef = useRef(null) - - // Local state for mode name input to allow visual emptying - const [localModeName, setLocalModeName] = useState("") - const [currentEditingModeSlug, setCurrentEditingModeSlug] = useState(null) - - // Direct update functions - const updateAgentPrompt = useCallback( - (mode: Mode, promptData: PromptComponent) => { - const existingPrompt = customModePrompts?.[mode] as PromptComponent - const updatedPrompt = { ...existingPrompt, ...promptData } - - // Only include properties that differ from defaults - if (updatedPrompt.roleDefinition === getRoleDefinition(mode)) { - delete updatedPrompt.roleDefinition - } - if (updatedPrompt.description === getDescription(mode)) { - delete updatedPrompt.description - } - if (updatedPrompt.whenToUse === getWhenToUse(mode)) { - delete updatedPrompt.whenToUse - } - - vscode.postMessage({ - type: "updatePrompt", - promptMode: mode, - customPrompt: updatedPrompt, - }) - }, - [customModePrompts], - ) - - const updateCustomMode = useCallback((slug: string, modeConfig: ModeConfig) => { - const source = modeConfig.source || "global" - - vscode.postMessage({ - type: "updateCustomMode", - slug, - modeConfig: { - ...modeConfig, - source, // Ensure source is set - }, - }) - }, []) - - // Helper function to find a mode by slug - const findModeBySlug = useCallback( - (searchSlug: string, modes: readonly ModeConfig[] | undefined): ModeConfig | undefined => { - return findCustomModeBySlug(searchSlug, modes) - }, - [], - ) - - const switchMode = useCallback((slug: string) => { - vscode.postMessage({ - type: "mode", - text: slug, - }) - }, []) - - // Handle mode switching with explicit state initialization - const handleModeSwitch = useCallback( - (modeConfig: ModeConfig) => { - if (modeConfig.slug === visualMode) return // Prevent unnecessary updates - - // Immediately update visual state for instant feedback - setVisualMode(modeConfig.slug) - - // Then send the mode change message to the backend - switchMode(modeConfig.slug) - - // Exit tools edit mode when switching modes - setIsToolsEditMode(false) - }, - [visualMode, switchMode], - ) - - // Handler for popover open state change - const onOpenChange = useCallback((open: boolean) => { - setOpen(open) - // Reset search when closing the popover - if (!open) { - setTimeout(() => setSearchValue(""), 100) - } - }, []) - - // Handler for clearing search input - const onClearSearch = useCallback(() => { - setSearchValue("") - searchInputRef.current?.focus() - }, []) - - // Helper function to get current mode's config - const getCurrentMode = useCallback((): ModeConfig | undefined => { - const findMode = (m: ModeConfig): boolean => m.slug === visualMode - return customModes?.find(findMode) || modes.find(findMode) - }, [visualMode, customModes, modes]) - - // Check if the current mode has rules to export - const checkRulesDirectory = useCallback((slug: string) => { - vscode.postMessage({ - type: "checkRulesDirectory", - slug: slug, - }) - }, []) - - // Check rules directory when mode changes - useEffect(() => { - const currentMode = getCurrentMode() - if (currentMode?.slug && hasRulesToExport[currentMode.slug] === undefined) { - checkRulesDirectory(currentMode.slug) - } - }, [getCurrentMode, checkRulesDirectory, hasRulesToExport]) - - // Reset local name state when mode changes - useEffect(() => { - if (currentEditingModeSlug && currentEditingModeSlug !== visualMode) { - setCurrentEditingModeSlug(null) - setLocalModeName("") - } - }, [visualMode, currentEditingModeSlug]) - - // Helper function to safely access mode properties - const getModeProperty = ( - mode: ModeConfig | undefined, - property: T, - ): ModeConfig[T] | undefined => { - return mode?.[property] - } - - // State for create mode dialog - const [newModeName, setNewModeName] = useState("") - const [newModeSlug, setNewModeSlug] = useState("") - const [newModeDescription, setNewModeDescription] = useState("") - const [newModeRoleDefinition, setNewModeRoleDefinition] = useState("") - const [newModeWhenToUse, setNewModeWhenToUse] = useState("") - const [newModeCustomInstructions, setNewModeCustomInstructions] = useState("") - const [newModeGroups, setNewModeGroups] = useState(availableGroups) - const [newModeSource, setNewModeSource] = useState("global") - - // Field-specific error states - const [nameError, setNameError] = useState("") - const [slugError, setSlugError] = useState("") - const [descriptionError, setDescriptionError] = useState("") - const [roleDefinitionError, setRoleDefinitionError] = useState("") - const [groupsError, setGroupsError] = useState("") - - // Helper to reset form state - const resetFormState = useCallback(() => { - // Reset form fields - setNewModeName("") - setNewModeSlug("") - setNewModeDescription("") - setNewModeGroups(availableGroups) - setNewModeRoleDefinition("") - setNewModeWhenToUse("") - setNewModeCustomInstructions("") - setNewModeSource("global") - // Reset error states - setNameError("") - setSlugError("") - setDescriptionError("") - setRoleDefinitionError("") - setGroupsError("") - }, []) - - // Reset form fields when dialog opens - useEffect(() => { - if (isCreateModeDialogOpen) { - resetFormState() - } - }, [isCreateModeDialogOpen, resetFormState]) - - // Helper function to generate a unique slug from a name - const generateSlug = useCallback((name: string, attempt = 0): string => { - const baseSlug = name - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/^-+|-+$/g, "") - return attempt === 0 ? baseSlug : `${baseSlug}-${attempt}` - }, []) - - // Handler for name changes - const handleNameChange = useCallback( - (name: string) => { - setNewModeName(name) - setNewModeSlug(generateSlug(name)) - }, - [generateSlug], - ) - - const handleCreateMode = useCallback(() => { - // Clear previous errors - setNameError("") - setSlugError("") - setDescriptionError("") - setRoleDefinitionError("") - setGroupsError("") - - const source = newModeSource - const newMode: ModeConfig = { - slug: newModeSlug, - name: newModeName, - description: newModeDescription.trim() || undefined, - roleDefinition: newModeRoleDefinition.trim(), - whenToUse: newModeWhenToUse.trim() || undefined, - customInstructions: newModeCustomInstructions.trim() || undefined, - groups: newModeGroups, - source, - } - - // Validate the mode against the schema - const result = modeConfigSchema.safeParse(newMode) - - if (!result.success) { - // Map Zod errors to specific fields - result.error.errors.forEach((error) => { - const field = error.path[0] as string - const message = error.message - - switch (field) { - case "name": - setNameError(message) - break - case "slug": - setSlugError(message) - break - case "description": - setDescriptionError(message) - break - case "roleDefinition": - setRoleDefinitionError(message) - break - case "groups": - setGroupsError(message) - break - } - }) - return - } - - updateCustomMode(newModeSlug, newMode) - switchMode(newModeSlug) - setIsCreateModeDialogOpen(false) - resetFormState() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - newModeName, - newModeSlug, - newModeDescription, - newModeRoleDefinition, - newModeWhenToUse, // Add whenToUse dependency - newModeCustomInstructions, - newModeGroups, - newModeSource, - updateCustomMode, - ]) - - const isNameOrSlugTaken = useCallback( - (name: string, slug: string) => { - return modes.some((m) => m.slug === slug || m.name === name) - }, - [modes], - ) - - const openCreateModeDialog = useCallback(() => { - const baseNamePrefix = "New Custom Mode" - // Find unique name and slug - let attempt = 0 - let name = baseNamePrefix - let slug = generateSlug(name) - while (isNameOrSlugTaken(name, slug)) { - attempt++ - name = `${baseNamePrefix} ${attempt + 1}` - slug = generateSlug(name) - } - setNewModeName(name) - setNewModeSlug(slug) - setIsCreateModeDialogOpen(true) - }, [generateSlug, isNameOrSlugTaken]) - - // Handler for group checkbox changes - const handleGroupChange = useCallback( - (group: ToolGroup, isCustomMode: boolean, customMode: ModeConfig | undefined) => - (e: Event | React.FormEvent) => { - if (!isCustomMode) return // Prevent changes to built-in modes - const target = (e as CustomEvent)?.detail?.target || (e.target as HTMLInputElement) - const checked = target.checked - const oldGroups = customMode?.groups || [] - let newGroups: GroupEntry[] - if (checked) { - newGroups = [...oldGroups, group] - } else { - newGroups = oldGroups.filter((g) => getGroupName(g) !== group) - } - if (customMode) { - const source = customMode.source || "global" - - updateCustomMode(customMode.slug, { - ...customMode, - groups: newGroups, - source, - }) - } - }, - [updateCustomMode], - ) - - // Handle clicks outside the config menu - useEffect(() => { - const handleClickOutside = () => { - if (showConfigMenu) { - setShowConfigMenu(false) - } - } - - document.addEventListener("click", handleClickOutside) - return () => document.removeEventListener("click", handleClickOutside) - }, [showConfigMenu]) - - // Use a ref to store the current modeToDelete value - const modeToDeleteRef = useRef(modeToDelete) - - // Update the ref whenever modeToDelete changes - useEffect(() => { - modeToDeleteRef.current = modeToDelete - }, [modeToDelete]) - - useEffect(() => { - const handler = (event: MessageEvent) => { - const message = event.data - if (message.type === "systemPrompt") { - if (message.text) { - setSelectedPromptContent(message.text) - setSelectedPromptTitle(`System Prompt (${message.mode} mode)`) - setIsDialogOpen(true) - } - } else if (message.type === "exportModeResult") { - setIsExporting(false) - - if (!message.success) { - // Show error message - console.error("Failed to export mode:", message.error) - } - } else if (message.type === "importModeResult") { - setIsImporting(false) - setShowImportDialog(false) - - if (!message.success) { - // Only log error if it's not a cancellation - if (message.error !== "cancelled") { - console.error("Failed to import mode:", message.error) - } - } - } else if (message.type === "checkRulesDirectoryResult") { - setHasRulesToExport((prev) => ({ - ...prev, - [message.slug]: message.hasContent, - })) - } else if (message.type === "deleteCustomModeCheck") { - // Handle the check response - // Use the ref to get the current modeToDelete value - const currentModeToDelete = modeToDeleteRef.current - if (message.slug && currentModeToDelete && currentModeToDelete.slug === message.slug) { - setModeToDelete({ - ...currentModeToDelete, - rulesFolderPath: message.rulesFolderPath, - }) - setShowDeleteConfirm(true) - } - } - } - - window.addEventListener("message", handler) - return () => window.removeEventListener("message", handler) - }, []) // Empty dependency array - only register once - - const handleAgentReset = ( - modeSlug: string, - type: "roleDefinition" | "description" | "whenToUse" | "customInstructions", - ) => { - // Only reset for built-in modes - const existingPrompt = customModePrompts?.[modeSlug] as PromptComponent - const updatedPrompt = { ...existingPrompt } - delete updatedPrompt[type] // Remove the field entirely to ensure it reloads from defaults - - vscode.postMessage({ - type: "updatePrompt", - promptMode: modeSlug, - customPrompt: updatedPrompt, - }) - } - - return ( - - -

{t("prompts:title")}

- -
- - -
-
e.stopPropagation()} className="flex justify-between items-center mb-3"> -

{t("prompts:modes.title")}

-
- - - -
- - - - {showConfigMenu && ( -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - className="absolute top-full right-0 w-[200px] mt-1 bg-vscode-editor-background border border-vscode-input-border rounded shadow-md z-[1000]"> -
{ - e.preventDefault() // Prevent blur - vscode.postMessage({ - type: "openCustomModesSettings", - }) - setShowConfigMenu(false) - }} - onClick={(e) => e.preventDefault()}> - {t("prompts:modes.editGlobalModes")} -
-
{ - e.preventDefault() // Prevent blur - vscode.postMessage({ - type: "openFile", - text: "./.roomodes", - values: { - create: true, - content: JSON.stringify({ customModes: [] }, null, 2), - }, - }) - setShowConfigMenu(false) - }} - onClick={(e) => e.preventDefault()}> - {t("prompts:modes.editProjectModes")} -
-
- )} -
- - - -
-
- -
- - - - -
- -
- - - - - - -
- - {searchValue.length > 0 && ( -
- -
- )} -
- - - {searchValue && ( -
- {t("prompts:modes.noMatchFound")} -
- )} -
- - {modes - .filter((modeConfig) => - searchValue - ? modeConfig.name - .toLowerCase() - .includes(searchValue.toLowerCase()) - : true, - ) - .map((modeConfig) => ( - { - handleModeSwitch(modeConfig) - setOpen(false) - }} - data-testid={`mode-option-${modeConfig.slug}`}> -
- - {modeConfig.name} - - - {modeConfig.slug} - -
-
- ))} -
-
-
-
-
-
- {/* API Configuration - Moved Here */} -
-
{t("prompts:apiConfiguration.title")}
-
- {t("prompts:apiConfiguration.select")} -
-
- -
-
-
- - {/* Name section */} -
- {/* Only show name and delete for custom modes */} - {visualMode && findModeBySlug(visualMode, customModes) && ( -
-
-
{t("prompts:createModeDialog.name.label")}
-
- { - const customMode = findModeBySlug(visualMode, customModes) - if (customMode) { - setCurrentEditingModeSlug(visualMode) - setLocalModeName(customMode.name) - } - }} - onChange={(e) => { - setLocalModeName(e.target.value) - }} - onBlur={() => { - const customMode = findModeBySlug(visualMode, customModes) - if (customMode && localModeName.trim()) { - // Only update if the name is not empty - updateCustomMode(visualMode, { - ...customMode, - name: localModeName, - source: customMode.source || "global", - }) - } - // Clear the editing state - setCurrentEditingModeSlug(null) - }} - className="w-full" - /> - - - -
-
-
- )} - - {/* Role Definition section */} -
-
-
{t("prompts:roleDefinition.title")}
- {!findModeBySlug(visualMode, customModes) && ( - - - - )} -
-
- {t("prompts:roleDefinition.description")} -
- { - const customMode = findModeBySlug(visualMode, customModes) - const prompt = customModePrompts?.[visualMode] as PromptComponent - return ( - customMode?.roleDefinition ?? - prompt?.roleDefinition ?? - getRoleDefinition(visualMode) - ) - })()} - onChange={(e) => { - const value = - (e as unknown as CustomEvent)?.detail?.target?.value || - ((e as any).target as HTMLTextAreaElement).value - const customMode = findModeBySlug(visualMode, customModes) - if (customMode) { - // For custom modes, update the JSON file - updateCustomMode(visualMode, { - ...customMode, - roleDefinition: value.trim() || "", - source: customMode.source || "global", - }) - } else { - // For built-in modes, update the prompts - updateAgentPrompt(visualMode, { - roleDefinition: value.trim() || undefined, - }) - } - }} - className="w-full" - rows={5} - data-testid={`${getCurrentMode()?.slug || "code"}-prompt-textarea`} - /> -
- - {/* Description section */} -
-
-
{t("prompts:description.title")}
- {!findModeBySlug(visualMode, customModes) && ( - - - - )} -
-
- {t("prompts:description.description")} -
- { - const customMode = findModeBySlug(visualMode, customModes) - const prompt = customModePrompts?.[visualMode] as PromptComponent - return customMode?.description ?? prompt?.description ?? getDescription(visualMode) - })()} - onChange={(e) => { - const value = - (e as unknown as CustomEvent)?.detail?.target?.value || - ((e as any).target as HTMLTextAreaElement).value - const customMode = findModeBySlug(visualMode, customModes) - if (customMode) { - // For custom modes, update the JSON file - updateCustomMode(visualMode, { - ...customMode, - description: value.trim() || undefined, - source: customMode.source || "global", - }) - } else { - // For built-in modes, update the prompts - updateAgentPrompt(visualMode, { - description: value.trim() || undefined, - }) - } - }} - className="w-full" - data-testid={`${getCurrentMode()?.slug || "code"}-description-textfield`} - /> -
- - {/* When to Use section */} -
-
-
{t("prompts:whenToUse.title")}
- {!findModeBySlug(visualMode, customModes) && ( - - - - )} -
-
- {t("prompts:whenToUse.description")} -
- { - const customMode = findModeBySlug(visualMode, customModes) - const prompt = customModePrompts?.[visualMode] as PromptComponent - return customMode?.whenToUse ?? prompt?.whenToUse ?? getWhenToUse(visualMode) - })()} - onChange={(e) => { - const value = - (e as unknown as CustomEvent)?.detail?.target?.value || - ((e as any).target as HTMLTextAreaElement).value - const customMode = findModeBySlug(visualMode, customModes) - if (customMode) { - // For custom modes, update the JSON file - updateCustomMode(visualMode, { - ...customMode, - whenToUse: value.trim() || undefined, - source: customMode.source || "global", - }) - } else { - // For built-in modes, update the prompts - updateAgentPrompt(visualMode, { - whenToUse: value.trim() || undefined, - }) - } - }} - className="w-full" - rows={4} - data-testid={`${getCurrentMode()?.slug || "code"}-when-to-use-textarea`} - /> -
- - {/* Mode settings */} - <> - {/* Show tools for all modes */} -
-
-
{t("prompts:tools.title")}
- {findModeBySlug(visualMode, customModes) && ( - - - - )} -
- {!findModeBySlug(visualMode, customModes) && ( -
- {t("prompts:tools.builtInModesText")} -
- )} - {isToolsEditMode && findModeBySlug(visualMode, customModes) ? ( -
- {availableGroups.map((group) => { - const currentMode = getCurrentMode() - const isCustomMode = findModeBySlug(visualMode, customModes) - const customMode = isCustomMode - const isGroupEnabled = isCustomMode - ? customMode?.groups?.some((g) => getGroupName(g) === group) - : currentMode?.groups?.some((g) => getGroupName(g) === group) - - return ( - - {t(`prompts:tools.toolNames.${group}`)} - {group === "edit" && ( -
- {t("prompts:tools.allowedFiles")}{" "} - {(() => { - const currentMode = getCurrentMode() - const editGroup = currentMode?.groups?.find( - (g) => - Array.isArray(g) && - g[0] === "edit" && - g[1]?.fileRegex, - ) - if (!Array.isArray(editGroup)) return t("prompts:allFiles") - return ( - editGroup[1].description || - `/${editGroup[1].fileRegex}/` - ) - })()} -
- )} -
- ) - })} -
- ) : ( -
- {(() => { - const currentMode = getCurrentMode() - const enabledGroups = currentMode?.groups || [] - - // If there are no enabled groups, display translated "None" - if (enabledGroups.length === 0) { - return t("prompts:tools.noTools") - } - - return enabledGroups - .map((group) => { - const groupName = getGroupName(group) - const displayName = t(`prompts:tools.toolNames.${groupName}`) - if (Array.isArray(group) && group[1]?.fileRegex) { - const description = - group[1].description || `/${group[1].fileRegex}/` - return `${displayName} (${description})` - } - return displayName - }) - .join(", ") - })()} -
- )} -
- - - {/* Role definition for both built-in and custom modes */} -
-
-
{t("prompts:customInstructions.title")}
- {!findModeBySlug(visualMode, customModes) && ( - - - - )} -
-
- {t("prompts:customInstructions.description", { - modeName: getCurrentMode()?.name || "Code", - })} -
- { - const customMode = findModeBySlug(visualMode, customModes) - const prompt = customModePrompts?.[visualMode] as PromptComponent - return ( - customMode?.customInstructions ?? - prompt?.customInstructions ?? - getCustomInstructions(mode, customModes) - ) - })()} - onChange={(e) => { - const value = - (e as unknown as CustomEvent)?.detail?.target?.value || - ((e as any).target as HTMLTextAreaElement).value - const customMode = findModeBySlug(visualMode, customModes) - if (customMode) { - // For custom modes, update the JSON file - updateCustomMode(visualMode, { - ...customMode, - customInstructions: value.trim() || undefined, - source: customMode.source || "global", - }) - } else { - // For built-in modes, update the prompts - const existingPrompt = customModePrompts?.[visualMode] as PromptComponent - updateAgentPrompt(visualMode, { - ...existingPrompt, - customInstructions: value.trim(), - }) - } - }} - rows={10} - className="w-full" - data-testid={`${getCurrentMode()?.slug || "code"}-custom-instructions-textarea`} - /> -
- { - const currentMode = getCurrentMode() - if (!currentMode) return - - // Open or create an empty file - vscode.postMessage({ - type: "openFile", - text: `./.roo/rules-${currentMode.slug}/rules.md`, - values: { - create: true, - content: "", - }, - }) - }} - /> - ), - }} - /> -
-
-
- -
-
- - - - -
- - {/* Export/Import Mode Buttons */} -
- {/* Export button - visible when any mode is selected */} - {getCurrentMode() && ( - - )} - {/* Import button - always visible */} - -
- - {/* Advanced Features Disclosure */} -
- - - {isSystemPromptDisclosureOpen && ( -
- {/* Override System Prompt Section */} -
-

- Override System Prompt -

-
- { - const currentMode = getCurrentMode() - if (!currentMode) return - - vscode.postMessage({ - type: "openFile", - text: `./.roo/system-prompt-${currentMode.slug}`, - values: { - create: true, - content: "", - }, - }) - }} - /> - ), - "1": ( - - ), - "2": , - }} - /> -
-
-
- )} -
-
- -
-

{t("prompts:globalCustomInstructions.title")}

- -
- - - -
- { - const value = - (e as unknown as CustomEvent)?.detail?.target?.value || - ((e as any).target as HTMLTextAreaElement).value - setCustomInstructions(value || undefined) - vscode.postMessage({ - type: "customInstructions", - text: value.trim() || undefined, - }) - }} - rows={4} - className="w-full" - data-testid="global-custom-instructions-textarea" - /> -
- - vscode.postMessage({ - type: "openFile", - text: "./.roo/rules/rules.md", - values: { - create: true, - content: "", - }, - }) - } - /> - ), - }} - /> -
-
-
- - {isCreateModeDialogOpen && ( -
-
-
- -

{t("prompts:createModeDialog.title")}

-
-
{t("prompts:createModeDialog.name.label")}
- { - handleNameChange(e.target.value) - }} - className="w-full" - /> - {nameError && ( -
{nameError}
- )} -
-
-
{t("prompts:createModeDialog.slug.label")}
- { - setNewModeSlug(e.target.value) - }} - className="w-full" - /> -
- {t("prompts:createModeDialog.slug.description")} -
- {slugError && ( -
{slugError}
- )} -
-
-
{t("prompts:createModeDialog.saveLocation.label")}
-
- {t("prompts:createModeDialog.saveLocation.description")} -
- ) => { - const target = ((e as CustomEvent)?.detail?.target || - (e.target as HTMLInputElement)) as HTMLInputElement - setNewModeSource(target.value as ModeSource) - }}> - - {t("prompts:createModeDialog.saveLocation.global.label")} -
- {t("prompts:createModeDialog.saveLocation.global.description")} -
-
- - {t("prompts:createModeDialog.saveLocation.project.label")} -
- {t("prompts:createModeDialog.saveLocation.project.description")} -
-
-
-
- -
-
- {t("prompts:createModeDialog.roleDefinition.label")} -
-
- {t("prompts:createModeDialog.roleDefinition.description")} -
- { - setNewModeRoleDefinition((e.target as HTMLTextAreaElement).value) - }} - rows={4} - className="w-full" - /> - {roleDefinitionError && ( -
- {roleDefinitionError} -
- )} -
- -
-
{t("prompts:createModeDialog.description.label")}
-
- {t("prompts:createModeDialog.description.description")} -
- { - setNewModeDescription((e.target as HTMLInputElement).value) - }} - className="w-full" - /> - {descriptionError && ( -
{descriptionError}
- )} -
- -
-
{t("prompts:createModeDialog.whenToUse.label")}
-
- {t("prompts:createModeDialog.whenToUse.description")} -
- { - setNewModeWhenToUse((e.target as HTMLTextAreaElement).value) - }} - rows={3} - className="w-full" - /> -
-
-
{t("prompts:createModeDialog.tools.label")}
-
- {t("prompts:createModeDialog.tools.description")} -
-
- {availableGroups.map((group) => ( - getGroupName(g) === group)} - onChange={(e: Event | React.FormEvent) => { - const target = - (e as CustomEvent)?.detail?.target || (e.target as HTMLInputElement) - const checked = target.checked - if (checked) { - setNewModeGroups([...newModeGroups, group]) - } else { - setNewModeGroups( - newModeGroups.filter((g) => getGroupName(g) !== group), - ) - } - }}> - {t(`prompts:tools.toolNames.${group}`)} - - ))} -
- {groupsError && ( -
{groupsError}
- )} -
-
-
- {t("prompts:createModeDialog.customInstructions.label")} -
-
- {t("prompts:createModeDialog.customInstructions.description")} -
- { - setNewModeCustomInstructions((e.target as HTMLTextAreaElement).value) - }} - rows={4} - className="w-full" - /> -
-
-
- - -
-
-
- )} - - {isDialogOpen && ( -
-
-
- -

- {selectedPromptTitle || - t("prompts:systemPrompt.title", { - modeName: getCurrentMode()?.name || "Code", - })} -

-
-								{selectedPromptContent}
-							
-
-
- -
-
-
- )} - - {/* Import Mode Dialog */} - {showImportDialog && ( -
-
-

{t("prompts:modes.importMode")}

-

- {t("prompts:importMode.selectLevel")} -

-
- - -
-
- - -
-
-
- )} - - {/* Delete Mode Confirmation Dialog */} - { - if (modeToDelete) { - vscode.postMessage({ - type: "deleteCustomMode", - slug: modeToDelete.slug, - }) - setShowDeleteConfirm(false) - setModeToDelete(null) - } - }} - /> -
- ) -} - -export default ModesView +// Backward compatibility export +export { default } from "../agents/AgentsView" +export * from "../agents/AgentsView"