mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
342ee70fb4
commit
11e4f6933b
26 changed files with 3705 additions and 3546 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
1033
src/core/config/CustomAgentsManager.ts
Normal file
1033
src/core/config/CustomAgentsManager.ts
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ describe("ProviderSettingsManager", () => {
|
|||
fuzzyMatchThreshold: 1.0,
|
||||
},
|
||||
},
|
||||
modeApiConfigs: {},
|
||||
agentApiConfigs: {},
|
||||
migrations: {
|
||||
rateLimitSecondsMigrated: true,
|
||||
diffSettingsMigrated: true,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
450
src/shared/agents.ts
Normal 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 ?? ""
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
1649
webview-ui/src/components/agents/AgentsView.tsx
Normal file
1649
webview-ui/src/components/agents/AgentsView.tsx
Normal file
File diff suppressed because it is too large
Load diff
61
webview-ui/src/components/agents/DeleteAgentDialog.tsx
Normal file
61
webview-ui/src/components/agents/DeleteAgentDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
||||
304
webview-ui/src/components/chat/AgentSelector.tsx
Normal file
304
webview-ui/src/components/chat/AgentSelector.tsx
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue