feat: implement Phase 3 of mode-to-agent renaming

- 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)
This commit is contained in:
Roo Code 2025-07-28 18:35:39 +00:00
parent 342ee70fb4
commit 11e4f6933b
26 changed files with 3705 additions and 3546 deletions

View file

@ -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({

View file

@ -39,7 +39,7 @@ export const groupEntrySchema = z.union([toolGroupsSchema, z.tuple([toolGroupsSc
export type GroupEntry = z.infer<typeof groupEntrySchema>
/**
* 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<typeof modeConfigSchema>
export type AgentConfig = z.infer<typeof agentConfigSchema>
// 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<typeof customModesSettingsSchema>
export type CustomAgentsSettings = z.infer<typeof customAgentsSettingsSchema>
// 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<typeof promptComponentSchema>
/**
* 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<typeof customModePromptsSchema>
export type CustomAgentPrompts = z.infer<typeof customAgentPromptsSchema>
// 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<typeof customSupportPromptsSchema>
* 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

View file

@ -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,
)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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<typeof providerSettingsWithIdSchema>
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<string, string> = Object.fromEntries(
modes.map((mode) => [mode.slug, this.defaultConfigId]),
private readonly defaultAgentApiConfigs: Record<string, string> = 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 () => {

View file

@ -61,7 +61,7 @@ describe("ProviderSettingsManager", () => {
fuzzyMatchThreshold: 1.0,
},
},
modeApiConfigs: {},
agentApiConfigs: {},
migrations: {
rateLimitSecondsMigrated: true,
diffSettingsMigrated: true,

View file

@ -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 = {

View file

@ -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<ClineEvents> {
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<ClineEvents> {
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<ClineEvents> {
const {
browserViewportSize,
mode,
customModes,
customModePrompts,
customModes: customAgents,
customModePrompts: customAgentPrompts,
customInstructions,
experiments,
enableMcpServerCreation,
@ -1653,8 +1653,8 @@ export class Task extends EventEmitter<ClineEvents> {
this.diffStrategy,
browserViewportSize,
mode,
customModePrompts,
customModes,
customAgentPrompts,
customAgents,
customInstructions,
this.diffEnabled,
experiments,

View file

@ -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.

View file

@ -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
}

View file

@ -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<string, boolean>,
toolParams?: Record<string, unknown>,
): 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.`)
}
}

View file

@ -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)

View file

@ -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,

View file

@ -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<string, unknown>)[key]) !==
JSON.stringify((existingAgent as Record<string, unknown>)[key]) !==
JSON.stringify((message.modeConfig as Record<string, unknown>)[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({

View file

@ -229,7 +229,9 @@ export class API extends EventEmitter<RooCodeEvents> 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))

450
src/shared/agents.ts Normal file
View file

@ -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<string>()
// 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<string, boolean>,
toolParams?: Record<string, any>, // All tool parameters
experiments?: Record<string, boolean>,
): 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>([^<]+)<\/path>/g)
if (filePathMatches) {
for (const match of filePathMatches) {
// More robust path extraction with validation
const pathMatch = match.match(/<path>([^<]+)<\/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<string, boolean>,
toolParams?: Record<string, any>,
experiments?: Record<string, boolean>,
): boolean {
return isToolAllowedForAgent(tool, modeSlug, customModes, toolRequirements, toolParams, experiments)
}
// Create the agent-specific default prompts
export const defaultPrompts: Readonly<CustomAgentPrompts> = 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<AgentConfig[]> {
const customAgents = (await context.globalState.get<AgentConfig[]>("customModes")) || []
const customAgentPrompts = (await context.globalState.get<CustomAgentPrompts>("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<ModeConfig[]> {
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<AgentConfig> {
// 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<ModeConfig> {
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 ?? ""
}

View file

@ -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<string>()
// 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<string, boolean>,
toolParams?: Record<string, any>, // All tool parameters
experiments?: Record<string, boolean>,
): 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>([^<]+)<\/path>/g)
if (filePathMatches) {
for (const match of filePathMatches) {
// More robust path extraction with validation
const pathMatch = match.match(/<path>([^<]+)<\/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<CustomModePrompts> = 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<ModeConfig[]> {
const customModes = (await context.globalState.get<ModeConfig[]>("customModes")) || []
const customModePrompts = (await context.globalState.get<CustomModePrompts>("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<ModeConfig> {
// 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"

File diff suppressed because it is too large Load diff

View file

@ -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<DeleteAgentDialogProps> = ({
open,
onOpenChange,
modeToDelete,
onConfirm,
}) => {
const { t } = useAppTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("prompts:deleteMode.title")}</AlertDialogTitle>
<AlertDialogDescription>
{modeToDelete && (
<>
{t("prompts:deleteMode.message", { modeName: modeToDelete.name })}
{modeToDelete.rulesFolderPath && (
<div className="mt-2">
{t("prompts:deleteMode.rulesFolder", {
folderPath: modeToDelete.rulesFolderPath,
})}
</div>
)}
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("prompts:deleteMode.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>{t("prompts:deleteMode.confirm")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -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(
<ExtensionStateContext.Provider value={{ ...mockExtensionState, ...props } as any}>
<ModesView onDone={mockOnDone} />
<AgentsView onDone={mockOnDone} />
</ExtensionStateContext.Provider>,
)
}
@ -128,7 +128,7 @@ describe("PromptsView", () => {
const { unmount } = render(
<ExtensionStateContext.Provider
value={{ ...mockExtensionState, mode: "code", customModes: [customMode] } as any}>
<ModesView onDone={vitest.fn()} />
<AgentsView onDone={vitest.fn()} />
</ExtensionStateContext.Provider>,
)
@ -154,7 +154,7 @@ describe("PromptsView", () => {
render(
<ExtensionStateContext.Provider
value={{ ...mockExtensionState, mode: "custom-mode", customModes: [customMode] } as any}>
<ModesView onDone={vitest.fn()} />
<AgentsView onDone={vitest.fn()} />
</ExtensionStateContext.Provider>,
)
@ -175,7 +175,7 @@ describe("PromptsView", () => {
const { unmount } = render(
<ExtensionStateContext.Provider
value={{ ...mockExtensionState, mode: "code", customModes: [customMode] } as any}>
<ModesView onDone={vitest.fn()} />
<AgentsView onDone={vitest.fn()} />
</ExtensionStateContext.Provider>,
)
@ -190,7 +190,7 @@ describe("PromptsView", () => {
render(
<ExtensionStateContext.Provider
value={{ ...mockExtensionState, mode: "custom-mode", customModes: [customMode] } as any}>
<ModesView onDone={vitest.fn()} />
<AgentsView onDone={vitest.fn()} />
</ExtensionStateContext.Provider>,
)

View file

@ -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<HTMLInputElement>(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 = (
<PopoverTrigger
disabled={disabled}
data-testid="mode-selector-trigger"
className={cn(
"inline-flex items-center gap-1.5 relative whitespace-nowrap px-1.5 py-1 text-xs",
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
disabled
? "opacity-50 cursor-not-allowed"
: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
triggerClassName,
!disabled && !hasOpenedModeSelector
? "bg-primary opacity-90 hover:bg-primary-hover text-vscode-button-foreground"
: null,
)}>
<ChevronUp className="pointer-events-none opacity-80 flex-shrink-0 size-3" />
<span className="truncate">{selectedMode?.name || ""}</span>
</PopoverTrigger>
)
return (
<Popover open={open} onOpenChange={onOpenChange} data-testid="mode-selector-root">
{title ? <StandardTooltip content={title}>{trigger}</StandardTooltip> : trigger}
<PopoverContent
align="start"
sideOffset={4}
container={portalContainer}
className="p-0 overflow-hidden min-w-80 max-w-9/10">
<div className="flex flex-col w-full">
{/* Show search bar only when there are more than SEARCH_THRESHOLD items, otherwise show info blurb */}
{showSearch ? (
<div className="relative p-2 border-b border-vscode-dropdown-border">
<input
aria-label="Search modes"
ref={searchInputRef}
value={searchValue}
onChange={(e) => 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 && (
<div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
<X
className="text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
onClick={onClearSearch}
/>
</div>
)}
</div>
) : (
<div className="p-3 border-b border-vscode-dropdown-border">
<p className="m-0 text-xs text-vscode-descriptionForeground">{instructionText}</p>
</div>
)}
{/* Mode List */}
<div className="max-h-[300px] overflow-y-auto">
{filteredModes.length === 0 && searchValue ? (
<div className="py-2 px-3 text-sm text-vscode-foreground/70">
{t("chat:modeSelector.noResults")}
</div>
) : (
<div className="py-1">
{filteredModes.map((mode) => (
<div
key={mode.slug}
onClick={() => 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">
<div className="flex-1 min-w-0">
<div className="font-bold truncate">{mode.name}</div>
{mode.description && (
<div className="text-xs text-vscode-descriptionForeground truncate">
{mode.description}
</div>
)}
</div>
{mode.slug === value && <Check className="ml-auto size-4 p-0.5" />}
</div>
))}
</div>
)}
</div>
{/* Bottom bar with buttons on left and title on right */}
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
<div className="flex flex-row gap-1">
<IconButton
iconClass="codicon-extensions"
title={t("chat:modeSelector.marketplace")}
onClick={() => {
window.postMessage(
{
type: "action",
action: "marketplaceButtonClicked",
values: { marketplaceTab: "mode" },
},
"*",
)
setOpen(false)
}}
/>
<IconButton
iconClass="codicon-settings-gear"
title={t("chat:modeSelector.settings")}
onClick={() => {
vscode.postMessage({
type: "switchTab",
tab: "modes",
})
setOpen(false)
}}
/>
</div>
{/* Info icon and title on the right - only show info icon when search bar is visible */}
<div className="flex items-center gap-1 pr-1">
{showSearch && (
<StandardTooltip content={instructionText}>
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
</StandardTooltip>
)}
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
{t("chat:modeSelector.title")}
</h4>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export default AgentSelector

View file

@ -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<HTMLInputElement>(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 = (
<PopoverTrigger
disabled={disabled}
data-testid="mode-selector-trigger"
className={cn(
"inline-flex items-center gap-1.5 relative whitespace-nowrap px-1.5 py-1 text-xs",
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
disabled
? "opacity-50 cursor-not-allowed"
: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
triggerClassName,
!disabled && !hasOpenedModeSelector
? "bg-primary opacity-90 hover:bg-primary-hover text-vscode-button-foreground"
: null,
)}>
<ChevronUp className="pointer-events-none opacity-80 flex-shrink-0 size-3" />
<span className="truncate">{selectedMode?.name || ""}</span>
</PopoverTrigger>
)
return (
<Popover open={open} onOpenChange={onOpenChange} data-testid="mode-selector-root">
{title ? <StandardTooltip content={title}>{trigger}</StandardTooltip> : trigger}
<PopoverContent
align="start"
sideOffset={4}
container={portalContainer}
className="p-0 overflow-hidden min-w-80 max-w-9/10">
<div className="flex flex-col w-full">
{/* Show search bar only when there are more than SEARCH_THRESHOLD items, otherwise show info blurb */}
{showSearch ? (
<div className="relative p-2 border-b border-vscode-dropdown-border">
<input
aria-label="Search modes"
ref={searchInputRef}
value={searchValue}
onChange={(e) => 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 && (
<div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
<X
className="text-vscode-input-foreground opacity-50 hover:opacity-100 size-4 p-0.5 cursor-pointer"
onClick={onClearSearch}
/>
</div>
)}
</div>
) : (
<div className="p-3 border-b border-vscode-dropdown-border">
<p className="m-0 text-xs text-vscode-descriptionForeground">{instructionText}</p>
</div>
)}
{/* Mode List */}
<div className="max-h-[300px] overflow-y-auto">
{filteredModes.length === 0 && searchValue ? (
<div className="py-2 px-3 text-sm text-vscode-foreground/70">
{t("chat:modeSelector.noResults")}
</div>
) : (
<div className="py-1">
{filteredModes.map((mode) => (
<div
key={mode.slug}
onClick={() => 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">
<div className="flex-1 min-w-0">
<div className="font-bold truncate">{mode.name}</div>
{mode.description && (
<div className="text-xs text-vscode-descriptionForeground truncate">
{mode.description}
</div>
)}
</div>
{mode.slug === value && <Check className="ml-auto size-4 p-0.5" />}
</div>
))}
</div>
)}
</div>
{/* Bottom bar with buttons on left and title on right */}
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
<div className="flex flex-row gap-1">
<IconButton
iconClass="codicon-extensions"
title={t("chat:modeSelector.marketplace")}
onClick={() => {
window.postMessage(
{
type: "action",
action: "marketplaceButtonClicked",
values: { marketplaceTab: "mode" },
},
"*",
)
setOpen(false)
}}
/>
<IconButton
iconClass="codicon-settings-gear"
title={t("chat:modeSelector.settings")}
onClick={() => {
vscode.postMessage({
type: "switchTab",
tab: "modes",
})
setOpen(false)
}}
/>
</div>
{/* Info icon and title on the right - only show info icon when search bar is visible */}
<div className="flex items-center gap-1 pr-1">
{showSearch && (
<StandardTooltip content={instructionText}>
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
</StandardTooltip>
)}
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
{t("chat:modeSelector.title")}
</h4>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export default ModeSelector
// Backward compatibility export
export { AgentSelector as ModeSelector, default } from "./AgentSelector"
export * from "./AgentSelector"

View file

@ -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<typeof import("@roo/modes")>("@roo/modes")
vi.mock("@roo/agents", async () => {
const actual = await vi.importActual<typeof import("@roo/agents")>("@roo/agents")
return {
...actual,
getAllModes: () => mockModes,

View file

@ -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<DeleteModeDialogProps> = ({ open, onOpenChange, modeToDelete, onConfirm }) => {
const { t } = useAppTranslation()
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("prompts:deleteMode.title")}</AlertDialogTitle>
<AlertDialogDescription>
{modeToDelete && (
<>
{t("prompts:deleteMode.message", { modeName: modeToDelete.name })}
{modeToDelete.rulesFolderPath && (
<div className="mt-2">
{t("prompts:deleteMode.rulesFolder", {
folderPath: modeToDelete.rulesFolderPath,
})}
</div>
)}
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("prompts:deleteMode.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>{t("prompts:deleteMode.confirm")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// Backward compatibility export
export { DeleteAgentDialog as DeleteModeDialog } from "../agents/DeleteAgentDialog"
export * from "../agents/DeleteAgentDialog"

File diff suppressed because it is too large Load diff