Просмотр исходного кода

Add support for project-specific .roomodes

Matt Rubens 10 месяцев назад
Родитель
Сommit
7c6fe52c80

+ 198 - 45
src/core/config/CustomModesManager.ts

@@ -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
+		// 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 })
+		}
+
+		// Add global modes
+		for (const mode of settingsModes) {
+			if (!projectModes.has(mode.slug)) {
+				globalModes.set(mode.slug, { ...mode, source: "global" as const })
 			}
-			return modes ?? []
-		} catch (error) {
-			// Return empty array if there's an error reading the file
 		}
 
-		return modes ?? []
+		// 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()
 
-			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
-
-				const newContent = JSON.stringify(settings, null, 2)
+			if (isProjectMode && !targetPath) {
+				throw new Error("No workspace folder found for project-specific mode")
+			}
 
-				// Write to file
-				await fs.writeFile(settingsPath, newContent)
+			await this.queueWrite(async () => {
+				// Ensure source is set correctly based on target file
+				const modeWithSource = {
+					...config,
+					source: isProjectMode ? ("project" as const) : ("global" as const),
+				}
 
-				// Update global state
-				await this.context.globalState.update("customModes", updatedModes)
+				await this.updateModesInFile(targetPath!, (modes) => {
+					const updatedModes = modes.filter((m) => m.slug !== slug)
+					updatedModes.push(modeWithSource)
+					return 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()

+ 315 - 151
src/core/config/__tests__/CustomModesManager.test.ts

@@ -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
-
-	beforeEach(() => {
-		// Reset mocks
-		jest.clearAllMocks()
+	let mockWorkspaceFolders: { uri: { fsPath: string } }[]
 
-		// Mock storage path
-		mockStoragePath = "/test/storage/path"
+	const mockStoragePath = "/mock/settings"
+	const mockSettingsPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
+	const mockRoomodes = "/mock/workspace/.roomodes"
 
-		// Mock context
+	beforeEach(() => {
+		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"] },
+			]
+
+			const roomodesModes = [
+				{ slug: "mode2", name: "Mode 2 Override", roleDefinition: "Role 2 Override", groups: ["read"] },
+				{ slug: "mode3", name: "Mode 3", roleDefinition: "Role 3", groups: ["read"] },
+			]
+
+			;(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")
+			})
 
-			await manager.updateCustomMode(validMode.slug, validMode)
+			const modes = await manager.getCustomModes()
 
-			// Verify file was written with the new mode
-			expect(fs.writeFile).toHaveBeenCalledWith(
-				expect.stringContaining("cline_custom_modes.json"),
-				expect.stringContaining(validMode.name),
+			// 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()
+				},
 			)
 
-			// Verify global state was updated
+			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([validMode]),
+				expect.arrayContaining([
+					expect.objectContaining({
+						slug: "mode1",
+						name: "Roomodes Mode 1", // .roomodes version should take precedence
+						source: "project",
+					}),
+				]),
 			)
 
-			// 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([])
-		})
+		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",
+			}
 
-		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
+			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()
+				},
+			)
 
-			// Mock fs.writeFile to throw error
-			;(fs.writeFile as jest.Mock).mockRejectedValueOnce(new Error("Write error"))
+			// Start both updates simultaneously
+			await Promise.all([manager.updateCustomMode("mode1", mode1), manager.updateCustomMode("mode2", mode2)])
 
-			const mockShowError = jest.fn()
-			;(vscode.window.showErrorMessage as jest.Mock) = mockShowError
+			// 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")
 
-			await manager.updateCustomMode(validMode.slug, validMode)
+			// Verify global state was updated
+			expect(mockContext.globalState.update).toHaveBeenCalledWith(
+				"customModes",
+				expect.arrayContaining([
+					expect.objectContaining({
+						slug: "mode1",
+						name: "Mode 1",
+						source: "global",
+					}),
+					expect.objectContaining({
+						slug: "mode2",
+						name: "Mode 2",
+						source: "global",
+					}),
+				]),
+			)
 
-			// Should show error message
-			expect(mockShowError).toHaveBeenCalledWith(expect.stringContaining("Write error"))
+			// Should trigger onUpdate
+			expect(mockOnUpdate).toHaveBeenCalled()
 		})
 	})
 
 	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
-
-		beforeEach(() => {
-			// Mock fs.readFile to return empty config
-			;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
-		})
+	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",
+			}
 
-		test("adds new custom mode", async () => {
-			await manager.updateCustomMode(validMode.slug, 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()
+				},
+			)
 
-			expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.stringContaining(validMode.name))
-			expect(mockOnUpdate).toHaveBeenCalled()
-		})
+			// 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()
+			})
 
-		test("updates existing custom mode", async () => {
-			// Mock existing mode
-			;(fs.readFile as jest.Mock).mockResolvedValue(
-				JSON.stringify({
-					customModes: [validMode],
-				}),
-			)
+			await manager.deleteCustomMode("mode-to-delete")
 
-			const updatedMode = {
-				...validMode,
-				name: "Updated Name",
-			}
+			// Verify mode was removed from settings file
+			expect(settingsContent.customModes).toHaveLength(0)
 
-			await manager.updateCustomMode(validMode.slug, updatedMode)
+			// Verify global state was updated
+			expect(mockContext.globalState.update).toHaveBeenCalledWith("customModes", [])
 
-			expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.stringContaining("Updated Name"))
+			// Should trigger onUpdate
 			expect(mockOnUpdate).toHaveBeenCalled()
 		})
 
-		test("deletes custom mode", async () => {
-			// Mock existing mode
-			;(fs.readFile as jest.Mock).mockResolvedValue(
-				JSON.stringify({
-					customModes: [validMode],
-				}),
-			)
+		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(validMode.slug)
+			await manager.deleteCustomMode("non-existent-mode")
 
-			expect(fs.writeFile).toHaveBeenCalledWith(expect.any(String), expect.not.stringContaining(validMode.name))
-			expect(mockOnUpdate).toHaveBeenCalled()
+			expect(mockShowError).toHaveBeenCalledWith(expect.stringContaining("Write error"))
 		})
+	})
 
-		test("queues write operations", async () => {
-			const mode1 = {
-				...validMode,
-				name: "Mode 1",
-			}
-			const mode2 = {
-				...validMode,
-				slug: "mode-2",
-				name: "Mode 2",
+	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",
 			}
 
-			// 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
-				return Promise.resolve()
+			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",
+					}),
+				],
 			})
-
-			// Start both updates simultaneously
-			await Promise.all([
-				manager.updateCustomMode(mode1.slug, mode1),
-				manager.updateCustomMode(mode2.slug, mode2),
-			])
-
-			// 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 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")
 		})
 	})
 })

+ 10 - 4
src/core/prompts/sections/modes.ts

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

+ 1 - 0
src/shared/WebviewMessage.ts

@@ -115,6 +115,7 @@ export interface WebviewMessage {
 	modeConfig?: ModeConfig
 	timeout?: number
 	payload?: WebViewMessagePayload
+	source?: "global" | "project"
 }
 
 export const checkoutDiffPayloadSchema = z.object({

+ 1 - 0
src/shared/modes.ts

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

+ 160 - 12
webview-ui/src/components/prompts/PromptsView.tsx

@@ -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
@@ -434,6 +470,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 
 				<div style={{ marginTop: "20px" }}>
 					<div
+						onClick={(e) => e.stopPropagation()}
 						style={{
 							display: "flex",
 							justifyContent: "space-between",
@@ -445,16 +482,81 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 							<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 style={{ position: "relative", display: "inline-block" }}>
+								<VSCodeButton
+									appearance="icon"
+									title="Edit modes configuration"
+									style={{ display: "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()}
+										style={{
+											position: "absolute",
+											top: "100%",
+											right: 0,
+											width: "200px",
+											marginTop: "4px",
+											backgroundColor: "var(--vscode-editor-background)",
+											border: "1px solid var(--vscode-input-border)",
+											borderRadius: "3px",
+											boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
+											zIndex: 1000,
+										}}>
+										<div
+											style={{
+												padding: "8px",
+												cursor: "pointer",
+												color: "var(--vscode-foreground)",
+												fontSize: "13px",
+											}}
+											onMouseDown={(e) => {
+												e.preventDefault() // Prevent blur
+												vscode.postMessage({
+													type: "openCustomModesSettings",
+												})
+												setShowConfigMenu(false)
+											}}
+											onClick={(e) => e.preventDefault()}>
+											Edit Global Modes
+										</div>
+										<div
+											style={{
+												padding: "8px",
+												cursor: "pointer",
+												color: "var(--vscode-foreground)",
+												fontSize: "13px",
+												borderTop: "1px solid var(--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>
 
@@ -521,6 +623,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 												updateCustomMode(mode, {
 													...customMode,
 													name: target.value,
+													source: customMode.source || "global",
 												})
 											}
 										}}
@@ -590,6 +693,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									updateCustomMode(mode, {
 										...customMode,
 										roleDefinition: value.trim() || "",
+										source: customMode.source || "global",
 									})
 								} else {
 									// For built-in modes, update the prompts
@@ -798,6 +902,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 +1223,49 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									letters, numbers, and hyphens.
 								</div>
 							</div>
+							<div style={{ marginBottom: "16px" }}>
+								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Save Location</div>
+								<div
+									style={{
+										fontSize: "13px",
+										color: "var(--vscode-descriptionForeground)",
+										marginBottom: "8px",
+									}}>
+									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
+											style={{
+												fontSize: "12px",
+												color: "var(--vscode-descriptionForeground)",
+												marginTop: "2px",
+											}}>
+											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