mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Merge pull request #983 from RooVetGit/roomodes
Add support for project-specific .roomodes
This commit is contained in:
commit
998beeed52
8 changed files with 715 additions and 377 deletions
|
|
@ -16,6 +16,11 @@
|
|||
- Logs can be found in `logs\app.log`
|
||||
- Logfile is overwritten on each run to keep it to a manageable volume.
|
||||
|
||||
4. Styling Guidelines:
|
||||
- Use Tailwind CSS classes instead of inline style objects for new markup
|
||||
- VSCode CSS variables must be added to webview-ui/src/index.css before using them in Tailwind classes
|
||||
- Example: `<div className="text-md text-vscode-descriptionForeground mb-2" />` instead of style objects
|
||||
|
||||
|
||||
# Adding a New Setting
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { ModeConfig } from "../../shared/modes"
|
|||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
|
||||
const ROOMODES_FILENAME = ".roomodes"
|
||||
|
||||
export class CustomModesManager {
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private isWriting = false
|
||||
|
|
@ -15,7 +17,7 @@ export class CustomModesManager {
|
|||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly onUpdate: () => Promise<void>,
|
||||
) {
|
||||
this.watchCustomModesFile()
|
||||
this.watchCustomModesFiles()
|
||||
}
|
||||
|
||||
private async queueWrite(operation: () => Promise<void>): Promise<void> {
|
||||
|
|
@ -43,6 +45,73 @@ export class CustomModesManager {
|
|||
}
|
||||
}
|
||||
|
||||
private async getWorkspaceRoomodes(): Promise<string | undefined> {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders
|
||||
if (!workspaceFolders || workspaceFolders.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const workspaceRoot = workspaceFolders[0].uri.fsPath
|
||||
const roomodesPath = path.join(workspaceRoot, ROOMODES_FILENAME)
|
||||
const exists = await fileExistsAtPath(roomodesPath)
|
||||
return exists ? roomodesPath : undefined
|
||||
}
|
||||
|
||||
private async loadModesFromFile(filePath: string): Promise<ModeConfig[]> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
const settings = JSON.parse(content)
|
||||
const result = CustomModesSettingsSchema.safeParse(settings)
|
||||
if (!result.success) {
|
||||
const errorMsg = `Schema validation failed for ${filePath}`
|
||||
console.error(`[CustomModesManager] ${errorMsg}:`, result.error)
|
||||
return []
|
||||
}
|
||||
|
||||
// Determine source based on file path
|
||||
const isRoomodes = filePath.endsWith(ROOMODES_FILENAME)
|
||||
const source = isRoomodes ? ("project" as const) : ("global" as const)
|
||||
|
||||
// Add source to each mode
|
||||
return result.data.customModes.map((mode) => ({
|
||||
...mode,
|
||||
source,
|
||||
}))
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to load modes from ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
console.error(`[CustomModesManager] ${errorMsg}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async mergeCustomModes(projectModes: ModeConfig[], globalModes: ModeConfig[]): Promise<ModeConfig[]> {
|
||||
const slugs = new Set<string>()
|
||||
const merged: ModeConfig[] = []
|
||||
|
||||
// Add project mode (takes precedence)
|
||||
for (const mode of projectModes) {
|
||||
if (!slugs.has(mode.slug)) {
|
||||
slugs.add(mode.slug)
|
||||
merged.push({
|
||||
...mode,
|
||||
source: "project",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add non-duplicate global modes
|
||||
for (const mode of globalModes) {
|
||||
if (!slugs.has(mode.slug)) {
|
||||
slugs.add(mode.slug)
|
||||
merged.push({
|
||||
...mode,
|
||||
source: "global",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
async getCustomModesFilePath(): Promise<string> {
|
||||
const settingsDir = await this.ensureSettingsDirectoryExists()
|
||||
const filePath = path.join(settingsDir, "cline_custom_modes.json")
|
||||
|
|
@ -55,14 +124,17 @@ export class CustomModesManager {
|
|||
return filePath
|
||||
}
|
||||
|
||||
private async watchCustomModesFile(): Promise<void> {
|
||||
private async watchCustomModesFiles(): Promise<void> {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
|
||||
// Watch settings file
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidSaveTextDocument(async (document) => {
|
||||
if (arePathsEqual(document.uri.fsPath, settingsPath)) {
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const errorMessage =
|
||||
"Invalid custom modes format. Please ensure your settings follow the correct JSON format."
|
||||
|
||||
let config: any
|
||||
try {
|
||||
config = JSON.parse(content)
|
||||
|
|
@ -71,86 +143,170 @@ export class CustomModesManager {
|
|||
vscode.window.showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const result = CustomModesSettingsSchema.safeParse(config)
|
||||
if (!result.success) {
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
await this.context.globalState.update("customModes", result.data.customModes)
|
||||
|
||||
// Get modes from .roomodes if it exists (takes precedence)
|
||||
const roomodesPath = await this.getWorkspaceRoomodes()
|
||||
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
|
||||
|
||||
// Merge modes from both sources (.roomodes takes precedence)
|
||||
const mergedModes = await this.mergeCustomModes(roomodesModes, result.data.customModes)
|
||||
await this.context.globalState.update("customModes", mergedModes)
|
||||
await this.onUpdate()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Watch .roomodes file if it exists
|
||||
const roomodesPath = await this.getWorkspaceRoomodes()
|
||||
if (roomodesPath) {
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidSaveTextDocument(async (document) => {
|
||||
if (arePathsEqual(document.uri.fsPath, roomodesPath)) {
|
||||
const settingsModes = await this.loadModesFromFile(settingsPath)
|
||||
const roomodesModes = await this.loadModesFromFile(roomodesPath)
|
||||
// .roomodes takes precedence
|
||||
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
|
||||
await this.context.globalState.update("customModes", mergedModes)
|
||||
await this.onUpdate()
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async getCustomModes(): Promise<ModeConfig[]> {
|
||||
const modes = await this.context.globalState.get<ModeConfig[]>("customModes")
|
||||
// Get modes from settings file
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
const settingsModes = await this.loadModesFromFile(settingsPath)
|
||||
|
||||
// Always read from file to ensure we have the latest
|
||||
try {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
// Get modes from .roomodes if it exists
|
||||
const roomodesPath = await this.getWorkspaceRoomodes()
|
||||
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
|
||||
|
||||
const settings = JSON.parse(content)
|
||||
const result = CustomModesSettingsSchema.safeParse(settings)
|
||||
if (result.success) {
|
||||
await this.context.globalState.update("customModes", result.data.customModes)
|
||||
return result.data.customModes
|
||||
}
|
||||
return modes ?? []
|
||||
} catch (error) {
|
||||
// Return empty array if there's an error reading the file
|
||||
// Create maps to store modes by source
|
||||
const projectModes = new Map<string, ModeConfig>()
|
||||
const globalModes = new Map<string, ModeConfig>()
|
||||
|
||||
// Add project modes (they take precedence)
|
||||
for (const mode of roomodesModes) {
|
||||
projectModes.set(mode.slug, { ...mode, source: "project" as const })
|
||||
}
|
||||
|
||||
return modes ?? []
|
||||
// Add global modes
|
||||
for (const mode of settingsModes) {
|
||||
if (!projectModes.has(mode.slug)) {
|
||||
globalModes.set(mode.slug, { ...mode, source: "global" as const })
|
||||
}
|
||||
}
|
||||
|
||||
// Combine modes in the correct order: project modes first, then global modes
|
||||
const mergedModes = [
|
||||
...roomodesModes.map((mode) => ({ ...mode, source: "project" as const })),
|
||||
...settingsModes
|
||||
.filter((mode) => !projectModes.has(mode.slug))
|
||||
.map((mode) => ({ ...mode, source: "global" as const })),
|
||||
]
|
||||
|
||||
await this.context.globalState.update("customModes", mergedModes)
|
||||
return mergedModes
|
||||
}
|
||||
|
||||
async updateCustomMode(slug: string, config: ModeConfig): Promise<void> {
|
||||
try {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
const isProjectMode = config.source === "project"
|
||||
const targetPath = isProjectMode ? await this.getWorkspaceRoomodes() : await this.getCustomModesFilePath()
|
||||
|
||||
if (isProjectMode && !targetPath) {
|
||||
throw new Error("No workspace folder found for project-specific mode")
|
||||
}
|
||||
|
||||
await this.queueWrite(async () => {
|
||||
// Read and update file
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const settings = JSON.parse(content)
|
||||
const currentModes = settings.customModes || []
|
||||
const updatedModes = currentModes.filter((m: ModeConfig) => m.slug !== slug)
|
||||
updatedModes.push(config)
|
||||
settings.customModes = updatedModes
|
||||
// Ensure source is set correctly based on target file
|
||||
const modeWithSource = {
|
||||
...config,
|
||||
source: isProjectMode ? ("project" as const) : ("global" as const),
|
||||
}
|
||||
|
||||
const newContent = JSON.stringify(settings, null, 2)
|
||||
await this.updateModesInFile(targetPath!, (modes) => {
|
||||
const updatedModes = modes.filter((m) => m.slug !== slug)
|
||||
updatedModes.push(modeWithSource)
|
||||
return updatedModes
|
||||
})
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(settingsPath, newContent)
|
||||
|
||||
// Update global state
|
||||
await this.context.globalState.update("customModes", updatedModes)
|
||||
|
||||
// Notify about the update
|
||||
await this.onUpdate()
|
||||
await this.refreshMergedState()
|
||||
})
|
||||
|
||||
// Success, no need for message
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to update custom mode: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
private async updateModesInFile(filePath: string, operation: (modes: ModeConfig[]) => ModeConfig[]): Promise<void> {
|
||||
let content = "{}"
|
||||
try {
|
||||
content = await fs.readFile(filePath, "utf-8")
|
||||
} catch (error) {
|
||||
// File might not exist yet
|
||||
content = JSON.stringify({ customModes: [] })
|
||||
}
|
||||
|
||||
let settings
|
||||
try {
|
||||
settings = JSON.parse(content)
|
||||
} catch (error) {
|
||||
console.error(`[CustomModesManager] Failed to parse JSON from ${filePath}:`, error)
|
||||
settings = { customModes: [] }
|
||||
}
|
||||
settings.customModes = operation(settings.customModes || [])
|
||||
await fs.writeFile(filePath, JSON.stringify(settings, null, 2), "utf-8")
|
||||
}
|
||||
|
||||
private async refreshMergedState(): Promise<void> {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
const roomodesPath = await this.getWorkspaceRoomodes()
|
||||
|
||||
const settingsModes = await this.loadModesFromFile(settingsPath)
|
||||
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
|
||||
const mergedModes = await this.mergeCustomModes(roomodesModes, settingsModes)
|
||||
|
||||
await this.context.globalState.update("customModes", mergedModes)
|
||||
await this.onUpdate()
|
||||
}
|
||||
|
||||
async deleteCustomMode(slug: string): Promise<void> {
|
||||
try {
|
||||
const settingsPath = await this.getCustomModesFilePath()
|
||||
const roomodesPath = await this.getWorkspaceRoomodes()
|
||||
|
||||
const settingsModes = await this.loadModesFromFile(settingsPath)
|
||||
const roomodesModes = roomodesPath ? await this.loadModesFromFile(roomodesPath) : []
|
||||
|
||||
// Find the mode in either file
|
||||
const projectMode = roomodesModes.find((m) => m.slug === slug)
|
||||
const globalMode = settingsModes.find((m) => m.slug === slug)
|
||||
|
||||
if (!projectMode && !globalMode) {
|
||||
throw new Error("Write error: Mode not found")
|
||||
}
|
||||
|
||||
await this.queueWrite(async () => {
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const settings = JSON.parse(content)
|
||||
// Delete from project first if it exists there
|
||||
if (projectMode && roomodesPath) {
|
||||
await this.updateModesInFile(roomodesPath, (modes) => modes.filter((m) => m.slug !== slug))
|
||||
}
|
||||
|
||||
settings.customModes = (settings.customModes || []).filter((m: ModeConfig) => m.slug !== slug)
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2))
|
||||
// Delete from global settings if it exists there
|
||||
if (globalMode) {
|
||||
await this.updateModesInFile(settingsPath, (modes) => modes.filter((m) => m.slug !== slug))
|
||||
}
|
||||
|
||||
await this.context.globalState.update("customModes", settings.customModes)
|
||||
await this.onUpdate()
|
||||
await this.refreshMergedState()
|
||||
})
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
|
|
@ -165,9 +321,6 @@ export class CustomModesManager {
|
|||
return settingsDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the custom modes file and reset to default state
|
||||
*/
|
||||
async resetCustomModes(): Promise<void> {
|
||||
try {
|
||||
const filePath = await this.getCustomModesFilePath()
|
||||
|
|
|
|||
|
|
@ -1,134 +1,307 @@
|
|||
import { ModeConfig } from "../../../shared/modes"
|
||||
import { CustomModesManager } from "../CustomModesManager"
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { CustomModesManager } from "../CustomModesManager"
|
||||
import { ModeConfig } from "../../../shared/modes"
|
||||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("vscode")
|
||||
jest.mock("fs/promises")
|
||||
jest.mock("../../../utils/fs", () => ({
|
||||
fileExistsAtPath: jest.fn().mockResolvedValue(false),
|
||||
}))
|
||||
jest.mock("../../../utils/fs")
|
||||
|
||||
describe("CustomModesManager", () => {
|
||||
let manager: CustomModesManager
|
||||
let mockContext: vscode.ExtensionContext
|
||||
let mockOnUpdate: jest.Mock
|
||||
let mockStoragePath: string
|
||||
let mockWorkspaceFolders: { uri: { fsPath: string } }[]
|
||||
|
||||
const mockStoragePath = "/mock/settings"
|
||||
const mockSettingsPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
|
||||
const mockRoomodes = "/mock/workspace/.roomodes"
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks()
|
||||
|
||||
// Mock storage path
|
||||
mockStoragePath = "/test/storage/path"
|
||||
|
||||
// Mock context
|
||||
mockOnUpdate = jest.fn()
|
||||
mockContext = {
|
||||
globalStorageUri: { fsPath: mockStoragePath },
|
||||
globalState: {
|
||||
get: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
get: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
globalStorageUri: {
|
||||
fsPath: mockStoragePath,
|
||||
},
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
// Mock onUpdate callback
|
||||
mockOnUpdate = jest.fn().mockResolvedValue(undefined)
|
||||
|
||||
// Mock fs.mkdir to do nothing
|
||||
mockWorkspaceFolders = [{ uri: { fsPath: "/mock/workspace" } }]
|
||||
;(vscode.workspace as any).workspaceFolders = mockWorkspaceFolders
|
||||
;(vscode.workspace.onDidSaveTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() })
|
||||
;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
|
||||
return path === mockSettingsPath || path === mockRoomodes
|
||||
})
|
||||
;(fs.mkdir as jest.Mock).mockResolvedValue(undefined)
|
||||
;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
|
||||
if (path === mockSettingsPath) {
|
||||
return JSON.stringify({ customModes: [] })
|
||||
}
|
||||
throw new Error("File not found")
|
||||
})
|
||||
|
||||
// Create manager instance
|
||||
manager = new CustomModesManager(mockContext, mockOnUpdate)
|
||||
})
|
||||
|
||||
describe("Mode Configuration Validation", () => {
|
||||
test("validates valid custom mode configuration", async () => {
|
||||
const validMode = {
|
||||
slug: "test-mode",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: ["read"] as const,
|
||||
} satisfies ModeConfig
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// Mock file read/write operations
|
||||
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
|
||||
;(fs.writeFile as jest.Mock).mockResolvedValue(undefined)
|
||||
describe("getCustomModes", () => {
|
||||
it("should merge modes with .roomodes taking precedence", async () => {
|
||||
const settingsModes = [
|
||||
{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] },
|
||||
{ slug: "mode2", name: "Mode 2", roleDefinition: "Role 2", groups: ["read"] },
|
||||
]
|
||||
|
||||
await manager.updateCustomMode(validMode.slug, validMode)
|
||||
const roomodesModes = [
|
||||
{ slug: "mode2", name: "Mode 2 Override", roleDefinition: "Role 2 Override", groups: ["read"] },
|
||||
{ slug: "mode3", name: "Mode 3", roleDefinition: "Role 3", groups: ["read"] },
|
||||
]
|
||||
|
||||
// Verify file was written with the new mode
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining("cline_custom_modes.json"),
|
||||
expect.stringContaining(validMode.name),
|
||||
;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
|
||||
if (path === mockSettingsPath) {
|
||||
return JSON.stringify({ customModes: settingsModes })
|
||||
}
|
||||
if (path === mockRoomodes) {
|
||||
return JSON.stringify({ customModes: roomodesModes })
|
||||
}
|
||||
throw new Error("File not found")
|
||||
})
|
||||
|
||||
const modes = await manager.getCustomModes()
|
||||
|
||||
// Should contain 3 modes (mode1 from settings, mode2 and mode3 from roomodes)
|
||||
expect(modes).toHaveLength(3)
|
||||
expect(modes.map((m) => m.slug)).toEqual(["mode2", "mode3", "mode1"])
|
||||
|
||||
// mode2 should come from .roomodes since it takes precedence
|
||||
const mode2 = modes.find((m) => m.slug === "mode2")
|
||||
expect(mode2?.name).toBe("Mode 2 Override")
|
||||
expect(mode2?.roleDefinition).toBe("Role 2 Override")
|
||||
})
|
||||
|
||||
it("should handle missing .roomodes file", async () => {
|
||||
const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }]
|
||||
|
||||
;(fileExistsAtPath as jest.Mock).mockImplementation(async (path: string) => {
|
||||
return path === mockSettingsPath
|
||||
})
|
||||
;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
|
||||
if (path === mockSettingsPath) {
|
||||
return JSON.stringify({ customModes: settingsModes })
|
||||
}
|
||||
throw new Error("File not found")
|
||||
})
|
||||
|
||||
const modes = await manager.getCustomModes()
|
||||
|
||||
expect(modes).toHaveLength(1)
|
||||
expect(modes[0].slug).toBe("mode1")
|
||||
})
|
||||
|
||||
it("should handle invalid JSON in .roomodes", async () => {
|
||||
const settingsModes = [{ slug: "mode1", name: "Mode 1", roleDefinition: "Role 1", groups: ["read"] }]
|
||||
|
||||
;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
|
||||
if (path === mockSettingsPath) {
|
||||
return JSON.stringify({ customModes: settingsModes })
|
||||
}
|
||||
if (path === mockRoomodes) {
|
||||
return "invalid json"
|
||||
}
|
||||
throw new Error("File not found")
|
||||
})
|
||||
|
||||
const modes = await manager.getCustomModes()
|
||||
|
||||
// Should fall back to settings modes when .roomodes is invalid
|
||||
expect(modes).toHaveLength(1)
|
||||
expect(modes[0].slug).toBe("mode1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateCustomMode", () => {
|
||||
it("should update mode in settings file while preserving .roomodes precedence", async () => {
|
||||
const newMode: ModeConfig = {
|
||||
slug: "mode1",
|
||||
name: "Updated Mode 1",
|
||||
roleDefinition: "Updated Role 1",
|
||||
groups: ["read"],
|
||||
source: "global",
|
||||
}
|
||||
|
||||
const roomodesModes = [
|
||||
{
|
||||
slug: "mode1",
|
||||
name: "Roomodes Mode 1",
|
||||
roleDefinition: "Role 1",
|
||||
groups: ["read"],
|
||||
source: "project",
|
||||
},
|
||||
]
|
||||
|
||||
const existingModes = [
|
||||
{ slug: "mode2", name: "Mode 2", roleDefinition: "Role 2", groups: ["read"], source: "global" },
|
||||
]
|
||||
|
||||
let settingsContent = { customModes: existingModes }
|
||||
let roomodesContent = { customModes: roomodesModes }
|
||||
|
||||
;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
|
||||
if (path === mockRoomodes) {
|
||||
return JSON.stringify(roomodesContent)
|
||||
}
|
||||
if (path === mockSettingsPath) {
|
||||
return JSON.stringify(settingsContent)
|
||||
}
|
||||
throw new Error("File not found")
|
||||
})
|
||||
;(fs.writeFile as jest.Mock).mockImplementation(
|
||||
async (path: string, content: string, encoding?: string) => {
|
||||
if (path === mockSettingsPath) {
|
||||
settingsContent = JSON.parse(content)
|
||||
}
|
||||
if (path === mockRoomodes) {
|
||||
roomodesContent = JSON.parse(content)
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
)
|
||||
|
||||
await manager.updateCustomMode("mode1", newMode)
|
||||
|
||||
// Should write to settings file
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(mockSettingsPath, expect.any(String), "utf-8")
|
||||
|
||||
// Verify the content of the write
|
||||
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
|
||||
const content = JSON.parse(writeCall[1])
|
||||
expect(content.customModes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
slug: "mode1",
|
||||
name: "Updated Mode 1",
|
||||
roleDefinition: "Updated Role 1",
|
||||
source: "global",
|
||||
}),
|
||||
)
|
||||
|
||||
// Should update global state with merged modes where .roomodes takes precedence
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith(
|
||||
"customModes",
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
slug: "mode1",
|
||||
name: "Roomodes Mode 1", // .roomodes version should take precedence
|
||||
source: "project",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
// Should trigger onUpdate
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("queues write operations", async () => {
|
||||
const mode1: ModeConfig = {
|
||||
slug: "mode1",
|
||||
name: "Mode 1",
|
||||
roleDefinition: "Role 1",
|
||||
groups: ["read"],
|
||||
source: "global",
|
||||
}
|
||||
const mode2: ModeConfig = {
|
||||
slug: "mode2",
|
||||
name: "Mode 2",
|
||||
roleDefinition: "Role 2",
|
||||
groups: ["read"],
|
||||
source: "global",
|
||||
}
|
||||
|
||||
let settingsContent = { customModes: [] }
|
||||
;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
|
||||
if (path === mockSettingsPath) {
|
||||
return JSON.stringify(settingsContent)
|
||||
}
|
||||
throw new Error("File not found")
|
||||
})
|
||||
;(fs.writeFile as jest.Mock).mockImplementation(
|
||||
async (path: string, content: string, encoding?: string) => {
|
||||
if (path === mockSettingsPath) {
|
||||
settingsContent = JSON.parse(content)
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
)
|
||||
|
||||
// Start both updates simultaneously
|
||||
await Promise.all([manager.updateCustomMode("mode1", mode1), manager.updateCustomMode("mode2", mode2)])
|
||||
|
||||
// Verify final state in settings file
|
||||
expect(settingsContent.customModes).toHaveLength(2)
|
||||
expect(settingsContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 1")
|
||||
expect(settingsContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 2")
|
||||
|
||||
// Verify global state was updated
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith(
|
||||
"customModes",
|
||||
expect.arrayContaining([validMode]),
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
slug: "mode1",
|
||||
name: "Mode 1",
|
||||
source: "global",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
slug: "mode2",
|
||||
name: "Mode 2",
|
||||
source: "global",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
// Verify onUpdate was called
|
||||
// Should trigger onUpdate
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("handles file read errors gracefully", async () => {
|
||||
// Mock fs.readFile to throw error
|
||||
;(fs.readFile as jest.Mock).mockRejectedValueOnce(new Error("Test error"))
|
||||
|
||||
const modes = await manager.getCustomModes()
|
||||
|
||||
// Should return empty array on error
|
||||
expect(modes).toEqual([])
|
||||
})
|
||||
|
||||
test("handles file write errors gracefully", async () => {
|
||||
const validMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: ["read"] as const,
|
||||
} satisfies ModeConfig
|
||||
|
||||
// Mock fs.writeFile to throw error
|
||||
;(fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error("Write error"))
|
||||
|
||||
const mockShowError = jest.fn()
|
||||
;(vscode.window.showErrorMessage as jest.Mock) = mockShowError
|
||||
|
||||
await manager.updateCustomMode(validMode.slug, validMode)
|
||||
|
||||
// Should show error message
|
||||
expect(mockShowError).toHaveBeenCalledWith(expect.stringContaining("Write error"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("File Operations", () => {
|
||||
test("creates settings directory if it doesn't exist", async () => {
|
||||
it("creates settings directory if it doesn't exist", async () => {
|
||||
const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
|
||||
await manager.getCustomModesFilePath()
|
||||
|
||||
expect(fs.mkdir).toHaveBeenCalledWith(path.dirname(configPath), { recursive: true })
|
||||
})
|
||||
|
||||
test("creates default config if file doesn't exist", async () => {
|
||||
it("creates default config if file doesn't exist", async () => {
|
||||
const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
|
||||
|
||||
// Mock fileExists to return false first time, then true
|
||||
let firstCall = true
|
||||
;(fileExistsAtPath as jest.Mock).mockImplementation(async () => {
|
||||
if (firstCall) {
|
||||
firstCall = false
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
await manager.getCustomModesFilePath()
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(configPath, JSON.stringify({ customModes: [] }, null, 2))
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
configPath,
|
||||
expect.stringMatching(/^\{\s+"customModes":\s+\[\s*\]\s*\}$/),
|
||||
)
|
||||
})
|
||||
|
||||
test("watches file for changes", async () => {
|
||||
// Mock file path resolution
|
||||
it("watches file for changes", async () => {
|
||||
const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
|
||||
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
|
||||
|
||||
// Create manager and wait for initialization
|
||||
const manager = new CustomModesManager(mockContext, mockOnUpdate)
|
||||
await manager.getCustomModesFilePath() // This ensures watchCustomModesFile has completed
|
||||
|
||||
// Get the registered callback
|
||||
const registerCall = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock.calls[0]
|
||||
expect(registerCall).toBeDefined()
|
||||
|
|
@ -144,102 +317,93 @@ describe("CustomModesManager", () => {
|
|||
expect(fs.readFile).toHaveBeenCalledWith(configPath, "utf-8")
|
||||
expect(mockContext.globalState.update).toHaveBeenCalled()
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
|
||||
// Verify file content was processed
|
||||
expect(fs.readFile).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Mode Operations", () => {
|
||||
const validMode = {
|
||||
slug: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test role definition",
|
||||
groups: ["read"] as const,
|
||||
} satisfies ModeConfig
|
||||
describe("deleteCustomMode", () => {
|
||||
it("deletes mode from settings file", async () => {
|
||||
const existingMode = {
|
||||
slug: "mode-to-delete",
|
||||
name: "Mode To Delete",
|
||||
roleDefinition: "Test role",
|
||||
groups: ["read"],
|
||||
source: "global",
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock fs.readFile to return empty config
|
||||
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
|
||||
})
|
||||
|
||||
test("adds new custom mode", async () => {
|
||||
await manager.updateCustomMode(validMode.slug, validMode)
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(validMode.name))
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("updates existing custom mode", async () => {
|
||||
// Mock existing mode
|
||||
;(fs.readFile as jest.Mock).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
customModes: [validMode],
|
||||
}),
|
||||
let settingsContent = { customModes: [existingMode] }
|
||||
;(fs.readFile as jest.Mock).mockImplementation(async (path: string) => {
|
||||
if (path === mockSettingsPath) {
|
||||
return JSON.stringify(settingsContent)
|
||||
}
|
||||
throw new Error("File not found")
|
||||
})
|
||||
;(fs.writeFile as jest.Mock).mockImplementation(
|
||||
async (path: string, content: string, encoding?: string) => {
|
||||
if (path === mockSettingsPath && encoding === "utf-8") {
|
||||
settingsContent = JSON.parse(content)
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
)
|
||||
|
||||
const updatedMode = {
|
||||
...validMode,
|
||||
name: "Updated Name",
|
||||
}
|
||||
|
||||
await manager.updateCustomMode(validMode.slug, updatedMode)
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.stringContaining("Updated Name"))
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("deletes custom mode", async () => {
|
||||
// Mock existing mode
|
||||
;(fs.readFile as jest.Mock).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
customModes: [validMode],
|
||||
}),
|
||||
)
|
||||
|
||||
await manager.deleteCustomMode(validMode.slug)
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.not.stringContaining(validMode.name))
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("queues write operations", async () => {
|
||||
const mode1 = {
|
||||
...validMode,
|
||||
name: "Mode 1",
|
||||
}
|
||||
const mode2 = {
|
||||
...validMode,
|
||||
slug: "mode-2",
|
||||
name: "Mode 2",
|
||||
}
|
||||
|
||||
// Mock initial empty state and track writes
|
||||
let currentModes: ModeConfig[] = []
|
||||
;(fs.readFile as jest.Mock).mockImplementation(() => JSON.stringify({ customModes: currentModes }))
|
||||
;(fs.writeFile as jest.Mock).mockImplementation(async (path, content) => {
|
||||
const data = JSON.parse(content)
|
||||
currentModes = data.customModes
|
||||
// Mock the global state update to actually update the settingsContent
|
||||
;(mockContext.globalState.update as jest.Mock).mockImplementation((key: string, value: any) => {
|
||||
if (key === "customModes") {
|
||||
settingsContent.customModes = value
|
||||
}
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
// Start both updates simultaneously
|
||||
await Promise.all([
|
||||
manager.updateCustomMode(mode1.slug, mode1),
|
||||
manager.updateCustomMode(mode2.slug, mode2),
|
||||
])
|
||||
await manager.deleteCustomMode("mode-to-delete")
|
||||
|
||||
// Verify final state
|
||||
expect(currentModes).toHaveLength(2)
|
||||
expect(currentModes.map((m) => m.name)).toContain("Mode 1")
|
||||
expect(currentModes.map((m) => m.name)).toContain("Mode 2")
|
||||
// Verify mode was removed from settings file
|
||||
expect(settingsContent.customModes).toHaveLength(0)
|
||||
|
||||
// Verify write was called with both modes
|
||||
const lastWriteCall = (fs.writeFile as jest.Mock).mock.calls.pop()
|
||||
const finalContent = JSON.parse(lastWriteCall[1])
|
||||
expect(finalContent.customModes).toHaveLength(2)
|
||||
expect(finalContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 1")
|
||||
expect(finalContent.customModes.map((m: ModeConfig) => m.name)).toContain("Mode 2")
|
||||
// Verify global state was updated
|
||||
expect(mockContext.globalState.update).toHaveBeenCalledWith("customModes", [])
|
||||
|
||||
// Should trigger onUpdate
|
||||
expect(mockOnUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
const mockShowError = jest.fn()
|
||||
;(vscode.window.showErrorMessage as jest.Mock) = mockShowError
|
||||
;(fs.writeFile as jest.Mock).mockRejectedValue(new Error("Write error"))
|
||||
|
||||
await manager.deleteCustomMode("non-existent-mode")
|
||||
|
||||
expect(mockShowError).toHaveBeenCalledWith(expect.stringContaining("Write error"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateModesInFile", () => {
|
||||
it("handles corrupted JSON content gracefully", async () => {
|
||||
const corruptedJson = "{ invalid json content"
|
||||
;(fs.readFile as jest.Mock).mockResolvedValue(corruptedJson)
|
||||
|
||||
const newMode: ModeConfig = {
|
||||
slug: "test-mode",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test Role",
|
||||
groups: ["read"],
|
||||
source: "global",
|
||||
}
|
||||
|
||||
await manager.updateCustomMode("test-mode", newMode)
|
||||
|
||||
// Verify that a valid JSON structure was written
|
||||
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
|
||||
const writtenContent = JSON.parse(writeCall[1])
|
||||
expect(writtenContent).toEqual({
|
||||
customModes: [
|
||||
expect.objectContaining({
|
||||
slug: "test-mode",
|
||||
name: "Test Mode",
|
||||
roleDefinition: "Test Role",
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,7 +16,13 @@ MODES
|
|||
${modes.map((mode: ModeConfig) => ` * "${mode.name}" mode - ${mode.roleDefinition.split(".")[0]}`).join("\n")}
|
||||
Custom modes will be referred to by their configured name property.
|
||||
|
||||
- Custom modes can be configured by editing the custom modes file at '${customModesPath}'. The file gets created automatically on startup and should always exist. Make sure to read the latest contents before writing to it to avoid overwriting existing modes.
|
||||
- Custom modes can be configured in two ways:
|
||||
1. Globally via '${customModesPath}' (created automatically on startup)
|
||||
2. Per-workspace via '.roomodes' in the workspace root directory
|
||||
|
||||
When modes with the same slug exist in both files, the workspace-specific .roomodes version takes precedence. This allows projects to override global modes or define project-specific modes.
|
||||
|
||||
If asked to create a project mode, create it in .roomodes in the workspace root. If asked to create a global mode, use the global custom modes file.
|
||||
|
||||
- The following fields are required and must not be empty:
|
||||
* slug: A valid slug (lowercase letters, numbers, and hyphens). Must be unique, and shorter is better.
|
||||
|
|
@ -26,15 +32,15 @@ ${modes.map((mode: ModeConfig) => ` * "${mode.name}" mode - ${mode.roleDefiniti
|
|||
|
||||
- The customInstructions field is optional.
|
||||
|
||||
- For multi-line text, include newline characters in the string like "This is the first line.\nThis is the next line.\n\nThis is a double line break."
|
||||
- For multi-line text, include newline characters in the string like "This is the first line.\\nThis is the next line.\\n\\nThis is a double line break."
|
||||
|
||||
The file should follow this structure:
|
||||
Both files should follow this structure:
|
||||
{
|
||||
"customModes": [
|
||||
{
|
||||
"slug": "designer", // Required: unique slug with lowercase letters, numbers, and hyphens
|
||||
"name": "Designer", // Required: mode display name
|
||||
"roleDefinition": "You are Roo, a UI/UX expert specializing in design systems and frontend development. Your expertise includes:\n- Creating and maintaining design systems\n- Implementing responsive and accessible web interfaces\n- Working with CSS, HTML, and modern frontend frameworks\n- Ensuring consistent user experiences across platforms", // Required: non-empty
|
||||
"roleDefinition": "You are Roo, a UI/UX expert specializing in design systems and frontend development. Your expertise includes:\\n- Creating and maintaining design systems\\n- Implementing responsive and accessible web interfaces\\n- Working with CSS, HTML, and modern frontend frameworks\\n- Ensuring consistent user experiences across platforms", // Required: non-empty
|
||||
"groups": [ // Required: array of tool groups (can be empty)
|
||||
"read", // Read files group (read_file, search_files, list_files, list_code_definition_names)
|
||||
"edit", // Edit files group (write_to_file, apply_diff) - allows editing any file
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export interface WebviewMessage {
|
|||
modeConfig?: ModeConfig
|
||||
timeout?: number
|
||||
payload?: WebViewMessagePayload
|
||||
source?: "global" | "project"
|
||||
}
|
||||
|
||||
export const checkoutDiffPayloadSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export type ModeConfig = {
|
|||
roleDefinition: string
|
||||
customInstructions?: string
|
||||
groups: readonly GroupEntry[] // Now supports both simple strings and tuples with options
|
||||
source?: "global" | "project" // Where this mode was loaded from
|
||||
}
|
||||
|
||||
// Mode-specific prompts only
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import {
|
|||
VSCodeOption,
|
||||
VSCodeTextField,
|
||||
VSCodeCheckbox,
|
||||
VSCodeRadioGroup,
|
||||
VSCodeRadio,
|
||||
} from "@vscode/webview-ui-toolkit/react"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import {
|
||||
|
|
@ -30,6 +32,8 @@ import { vscode } from "../../utils/vscode"
|
|||
// Get all available groups that should show in prompts view
|
||||
const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable)
|
||||
|
||||
type ModeSource = "global" | "project"
|
||||
|
||||
type PromptsViewProps = {
|
||||
onDone: () => void
|
||||
}
|
||||
|
|
@ -64,6 +68,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
const [selectedPromptContent, setSelectedPromptContent] = useState("")
|
||||
const [selectedPromptTitle, setSelectedPromptTitle] = useState("")
|
||||
const [isToolsEditMode, setIsToolsEditMode] = useState(false)
|
||||
const [showConfigMenu, setShowConfigMenu] = useState(false)
|
||||
const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false)
|
||||
const [activeSupportTab, setActiveSupportTab] = useState<SupportPromptType>("ENHANCE")
|
||||
|
||||
|
|
@ -88,10 +93,14 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
)
|
||||
|
||||
const updateCustomMode = useCallback((slug: string, modeConfig: ModeConfig) => {
|
||||
const source = modeConfig.source || "global"
|
||||
vscode.postMessage({
|
||||
type: "updateCustomMode",
|
||||
slug,
|
||||
modeConfig,
|
||||
modeConfig: {
|
||||
...modeConfig,
|
||||
source, // Ensure source is set
|
||||
},
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
|
@ -146,6 +155,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
const [newModeRoleDefinition, setNewModeRoleDefinition] = useState("")
|
||||
const [newModeCustomInstructions, setNewModeCustomInstructions] = useState("")
|
||||
const [newModeGroups, setNewModeGroups] = useState<GroupEntry[]>(availableGroups)
|
||||
const [newModeSource, setNewModeSource] = useState<ModeSource>("global")
|
||||
|
||||
// Reset form fields when dialog opens
|
||||
useEffect(() => {
|
||||
|
|
@ -153,6 +163,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
setNewModeGroups(availableGroups)
|
||||
setNewModeRoleDefinition("")
|
||||
setNewModeCustomInstructions("")
|
||||
setNewModeSource("global")
|
||||
}
|
||||
}, [isCreateModeDialogOpen])
|
||||
|
||||
|
|
@ -177,12 +188,14 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
const handleCreateMode = useCallback(() => {
|
||||
if (!newModeName.trim() || !newModeSlug.trim()) return
|
||||
|
||||
const source = newModeSource
|
||||
const newMode: ModeConfig = {
|
||||
slug: newModeSlug,
|
||||
name: newModeName,
|
||||
roleDefinition: newModeRoleDefinition.trim() || "",
|
||||
customInstructions: newModeCustomInstructions.trim() || undefined,
|
||||
groups: newModeGroups,
|
||||
source,
|
||||
}
|
||||
updateCustomMode(newModeSlug, newMode)
|
||||
switchMode(newModeSlug)
|
||||
|
|
@ -192,8 +205,17 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
setNewModeRoleDefinition("")
|
||||
setNewModeCustomInstructions("")
|
||||
setNewModeGroups(availableGroups)
|
||||
setNewModeSource("global")
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [newModeName, newModeSlug, newModeRoleDefinition, newModeCustomInstructions, newModeGroups, updateCustomMode])
|
||||
}, [
|
||||
newModeName,
|
||||
newModeSlug,
|
||||
newModeRoleDefinition,
|
||||
newModeCustomInstructions,
|
||||
newModeGroups,
|
||||
newModeSource,
|
||||
updateCustomMode,
|
||||
])
|
||||
|
||||
const isNameOrSlugTaken = useCallback(
|
||||
(name: string, slug: string) => {
|
||||
|
|
@ -233,15 +255,29 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
newGroups = oldGroups.filter((g) => getGroupName(g) !== group)
|
||||
}
|
||||
if (customMode) {
|
||||
const source = customMode.source || "global"
|
||||
updateCustomMode(customMode.slug, {
|
||||
...customMode,
|
||||
groups: newGroups,
|
||||
source,
|
||||
})
|
||||
}
|
||||
},
|
||||
[updateCustomMode],
|
||||
)
|
||||
|
||||
// Handle clicks outside the config menu
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (showConfigMenu) {
|
||||
setShowConfigMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClickOutside)
|
||||
return () => document.removeEventListener("click", handleClickOutside)
|
||||
}, [showConfigMenu])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
|
|
@ -307,31 +343,16 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "10px 17px 10px 20px",
|
||||
}}>
|
||||
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Prompts</h3>
|
||||
<div className="fixed inset-0 flex flex-col">
|
||||
<div className="flex justify-between items-center px-5 py-2.5">
|
||||
<h3 className="text-vscode-foreground m-0">Prompts</h3>
|
||||
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "0 20px" }}>
|
||||
<div style={{ paddingBottom: "20px", borderBottom: "1px solid var(--vscode-input-border)" }}>
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Preferred Language</div>
|
||||
<div className="flex-1 overflow-auto px-5">
|
||||
<div className="pb-5 border-b border-vscode-input-border">
|
||||
<div className="mb-5">
|
||||
<div className="font-bold mb-1">Preferred Language</div>
|
||||
<select
|
||||
value={preferredLanguage}
|
||||
onChange={(e) => {
|
||||
|
|
@ -341,15 +362,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
text: e.target.value,
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "4px 8px",
|
||||
backgroundColor: "var(--vscode-input-background)",
|
||||
color: "var(--vscode-input-foreground)",
|
||||
border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: "2px",
|
||||
height: "28px",
|
||||
}}>
|
||||
className="w-full px-2 py-1 h-7 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded">
|
||||
<option value="English">English</option>
|
||||
<option value="Arabic">Arabic - العربية</option>
|
||||
<option value="Brazilian Portuguese">Portuguese - Português (Brasil)</option>
|
||||
|
|
@ -369,19 +382,13 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
<option value="Traditional Chinese">Traditional Chinese - 繁體中文</option>
|
||||
<option value="Turkish">Turkish - Türkçe</option>
|
||||
</select>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<p className="text-xs mt-1.5 text-vscode-descriptionForeground">
|
||||
Select the language that Cline should use for communication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Custom Instructions for All Modes</div>
|
||||
<div
|
||||
style={{ fontSize: "13px", color: "var(--vscode-descriptionForeground)", marginBottom: "8px" }}>
|
||||
<div className="font-bold mb-1">Custom Instructions for All Modes</div>
|
||||
<div className="text-sm text-vscode-descriptionForeground mb-2">
|
||||
These instructions apply to all modes. They provide a base set of behaviors that can be enhanced
|
||||
by mode-specific instructions below.
|
||||
</div>
|
||||
|
|
@ -399,23 +406,13 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
}}
|
||||
rows={4}
|
||||
resize="vertical"
|
||||
style={{ width: "100%" }}
|
||||
className="w-full"
|
||||
data-testid="global-custom-instructions-textarea"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
marginTop: "5px",
|
||||
marginBottom: "40px",
|
||||
}}>
|
||||
<div className="text-xs text-vscode-descriptionForeground mt-1.5 mb-10">
|
||||
Instructions can also be loaded from{" "}
|
||||
<span
|
||||
style={{
|
||||
color: "var(--vscode-textLink-foreground)",
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
className="text-vscode-textLink-foreground cursor-pointer underline"
|
||||
onClick={() =>
|
||||
vscode.postMessage({
|
||||
type: "openFile",
|
||||
|
|
@ -432,50 +429,74 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "20px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "12px",
|
||||
}}>
|
||||
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Mode-Specific Prompts</h3>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<div className="mt-5">
|
||||
<div onClick={(e) => e.stopPropagation()} className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-vscode-foreground m-0">Mode-Specific Prompts</h3>
|
||||
<div className="flex gap-2">
|
||||
<VSCodeButton appearance="icon" onClick={openCreateModeDialog} title="Create new mode">
|
||||
<span className="codicon codicon-add"></span>
|
||||
</VSCodeButton>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
title="Edit modes configuration"
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openCustomModesSettings",
|
||||
})
|
||||
}}>
|
||||
<span className="codicon codicon-json"></span>
|
||||
</VSCodeButton>
|
||||
<div className="relative inline-block">
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
title="Edit modes configuration"
|
||||
className="flex"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setShowConfigMenu((prev) => !prev)
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Add slight delay to allow menu item clicks to register
|
||||
setTimeout(() => setShowConfigMenu(false), 200)
|
||||
}}>
|
||||
<span className="codicon codicon-json"></span>
|
||||
</VSCodeButton>
|
||||
{showConfigMenu && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="absolute top-full right-0 w-[200px] mt-1 bg-vscode-editor-background border border-vscode-input-border rounded shadow-md z-[1000]">
|
||||
<div
|
||||
className="p-2 cursor-pointer text-vscode-foreground text-sm"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault() // Prevent blur
|
||||
vscode.postMessage({
|
||||
type: "openCustomModesSettings",
|
||||
})
|
||||
setShowConfigMenu(false)
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}>
|
||||
Edit Global Modes
|
||||
</div>
|
||||
<div
|
||||
className="p-2 cursor-pointer text-vscode-foreground text-sm border-t border-vscode-input-border"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault() // Prevent blur
|
||||
vscode.postMessage({
|
||||
type: "openFile",
|
||||
text: "./.roomodes",
|
||||
values: {
|
||||
create: true,
|
||||
content: JSON.stringify({ customModes: [] }, null, 2),
|
||||
},
|
||||
})
|
||||
setShowConfigMenu(false)
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}>
|
||||
Edit Project Modes (.roomodes)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
marginBottom: "12px",
|
||||
}}>
|
||||
<div className="text-sm text-vscode-descriptionForeground mb-3">
|
||||
Hit the + to create a new custom mode, or just ask Roo in chat to create one for you!
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
marginBottom: "12px",
|
||||
flexWrap: "wrap",
|
||||
padding: "4px 0",
|
||||
}}>
|
||||
<div className="flex gap-2 items-center mb-3 flex-wrap py-1">
|
||||
{modes.map((modeConfig) => {
|
||||
const isActive = mode === modeConfig.slug
|
||||
return (
|
||||
|
|
@ -484,18 +505,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
data-testid={`${modeConfig.slug}-tab`}
|
||||
data-active={isActive ? "true" : "false"}
|
||||
onClick={() => handleModeSwitch(modeConfig)}
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
border: "none",
|
||||
background: isActive ? "var(--vscode-button-background)" : "none",
|
||||
color: isActive
|
||||
? "var(--vscode-button-foreground)"
|
||||
: "var(--vscode-foreground)",
|
||||
cursor: "pointer",
|
||||
opacity: isActive ? 1 : 0.8,
|
||||
borderRadius: "3px",
|
||||
fontWeight: "bold",
|
||||
}}>
|
||||
className={`px-2 py-1 border-none rounded cursor-pointer font-bold ${
|
||||
isActive
|
||||
? "bg-vscode-button-background text-vscode-button-foreground opacity-100"
|
||||
: "bg-transparent text-vscode-foreground opacity-80"
|
||||
}`}>
|
||||
{modeConfig.name}
|
||||
</button>
|
||||
)
|
||||
|
|
@ -506,10 +520,10 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
<div style={{ marginBottom: "20px" }}>
|
||||
{/* Only show name and delete for custom modes */}
|
||||
{mode && findModeBySlug(mode, customModes) && (
|
||||
<div style={{ display: "flex", gap: "12px", marginBottom: "16px" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Name</div>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="font-bold mb-1">Name</div>
|
||||
<div className="flex gap-2">
|
||||
<VSCodeTextField
|
||||
value={getModeProperty(findModeBySlug(mode, customModes), "name") ?? ""}
|
||||
onChange={(e: Event | React.FormEvent<HTMLElement>) => {
|
||||
|
|
@ -521,10 +535,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
updateCustomMode(mode, {
|
||||
...customMode,
|
||||
name: target.value,
|
||||
source: customMode.source || "global",
|
||||
})
|
||||
}
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
className="w-full"
|
||||
/>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
|
|
@ -542,14 +557,8 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
</div>
|
||||
)}
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "4px",
|
||||
}}>
|
||||
<div style={{ fontWeight: "bold" }}>Role Definition</div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<div className="font-bold">Role Definition</div>
|
||||
{!findModeBySlug(mode, customModes) && (
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
|
|
@ -565,12 +574,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
</VSCodeButton>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
marginBottom: "8px",
|
||||
}}>
|
||||
<div className="text-sm text-vscode-descriptionForeground mb-2">
|
||||
Define Roo's expertise and personality for this mode. This description shapes how Roo
|
||||
presents itself and approaches tasks.
|
||||
</div>
|
||||
|
|
@ -590,6 +594,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
updateCustomMode(mode, {
|
||||
...customMode,
|
||||
roleDefinition: value.trim() || "",
|
||||
source: customMode.source || "global",
|
||||
})
|
||||
} else {
|
||||
// For built-in modes, update the prompts
|
||||
|
|
@ -618,34 +623,23 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
text: value,
|
||||
})
|
||||
}}
|
||||
style={{ width: "100%" }}>
|
||||
className="w-full">
|
||||
{(listApiConfigMeta || []).map((config) => (
|
||||
<VSCodeOption key={config.id} value={config.name}>
|
||||
{config.name}
|
||||
</VSCodeOption>
|
||||
))}
|
||||
</VSCodeDropdown>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
<div className="text-xs mt-1.5 text-vscode-descriptionForeground">
|
||||
Select which API configuration to use for this mode
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show tools for all modes */}
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "4px",
|
||||
}}>
|
||||
<div style={{ fontWeight: "bold" }}>Available Tools</div>
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<div className="font-bold">Available Tools</div>
|
||||
{findModeBySlug(mode, customModes) && (
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
|
|
@ -657,22 +651,12 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
)}
|
||||
</div>
|
||||
{!findModeBySlug(mode, customModes) && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
marginBottom: "8px",
|
||||
}}>
|
||||
<div className="text-sm text-vscode-descriptionForeground mb-2">
|
||||
Tools for built-in modes cannot be modified
|
||||
</div>
|
||||
)}
|
||||
{isToolsEditMode && findModeBySlug(mode, customModes) ? (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||
gap: "8px",
|
||||
}}>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
|
||||
{availableGroups.map((group) => {
|
||||
const currentMode = getCurrentMode()
|
||||
const isCustomMode = findModeBySlug(mode, customModes)
|
||||
|
|
@ -689,12 +673,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
disabled={!isCustomMode}>
|
||||
{GROUP_DISPLAY_NAMES[group]}
|
||||
{group === "edit" && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
marginTop: "2px",
|
||||
}}>
|
||||
<div className="text-xs text-vscode-descriptionForeground mt-0.5">
|
||||
Allowed files:{" "}
|
||||
{(() => {
|
||||
const currentMode = getCurrentMode()
|
||||
|
|
@ -717,13 +696,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--vscode-foreground)",
|
||||
marginBottom: "8px",
|
||||
lineHeight: "1.4",
|
||||
}}>
|
||||
<div className="text-sm text-vscode-foreground mb-2 leading-relaxed">
|
||||
{(() => {
|
||||
const currentMode = getCurrentMode()
|
||||
const enabledGroups = currentMode?.groups || []
|
||||
|
|
@ -798,6 +771,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
updateCustomMode(mode, {
|
||||
...customMode,
|
||||
customInstructions: value.trim() || undefined,
|
||||
source: customMode.source || "global",
|
||||
})
|
||||
} else {
|
||||
// For built-in modes, update the prompts
|
||||
|
|
@ -1118,6 +1092,39 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
|
|||
letters, numbers, and hyphens.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Save Location</div>
|
||||
<div className="text-sm text-vscode-descriptionForeground mb-2">
|
||||
Choose where to save this mode. Project-specific modes take precedence over global
|
||||
modes.
|
||||
</div>
|
||||
<VSCodeRadioGroup
|
||||
value={newModeSource}
|
||||
onChange={(e: Event | React.FormEvent<HTMLElement>) => {
|
||||
const target = ((e as CustomEvent)?.detail?.target ||
|
||||
(e.target as HTMLInputElement)) as HTMLInputElement
|
||||
setNewModeSource(target.value as ModeSource)
|
||||
}}>
|
||||
<VSCodeRadio value="global">
|
||||
Global
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
marginTop: "2px",
|
||||
}}>
|
||||
Available in all workspaces
|
||||
</div>
|
||||
</VSCodeRadio>
|
||||
<VSCodeRadio value="project">
|
||||
Project-specific (.roomodes)
|
||||
<div className="text-xs text-vscode-descriptionForeground mt-0.5">
|
||||
Only available in this workspace, takes precedence over global
|
||||
</div>
|
||||
</VSCodeRadio>
|
||||
</VSCodeRadioGroup>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Role Definition</div>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@
|
|||
--color-vscode-notifications-foreground: var(--vscode-notifications-foreground);
|
||||
--color-vscode-notifications-background: var(--vscode-notifications-background);
|
||||
--color-vscode-notifications-border: var(--vscode-notifications-border);
|
||||
--color-vscode-descriptionForeground: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue