2
0
Эх сурвалжийг харах

Merge pull request #983 from RooVetGit/roomodes

Add support for project-specific .roomodes
Matt Rubens 10 сар өмнө
parent
commit
998beeed52

+ 5 - 0
.clinerules

@@ -16,6 +16,11 @@
    - Logs can be found in `logs\app.log`
    - Logs can be found in `logs\app.log`
      - Logfile is overwritten on each run to keep it to a manageable volume.
      - 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
 # Adding a New Setting
 
 

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

@@ -6,6 +6,8 @@ import { ModeConfig } from "../../shared/modes"
 import { fileExistsAtPath } from "../../utils/fs"
 import { fileExistsAtPath } from "../../utils/fs"
 import { arePathsEqual } from "../../utils/path"
 import { arePathsEqual } from "../../utils/path"
 
 
+const ROOMODES_FILENAME = ".roomodes"
+
 export class CustomModesManager {
 export class CustomModesManager {
 	private disposables: vscode.Disposable[] = []
 	private disposables: vscode.Disposable[] = []
 	private isWriting = false
 	private isWriting = false
@@ -15,7 +17,7 @@ export class CustomModesManager {
 		private readonly context: vscode.ExtensionContext,
 		private readonly context: vscode.ExtensionContext,
 		private readonly onUpdate: () => Promise<void>,
 		private readonly onUpdate: () => Promise<void>,
 	) {
 	) {
-		this.watchCustomModesFile()
+		this.watchCustomModesFiles()
 	}
 	}
 
 
 	private async queueWrite(operation: () => Promise<void>): Promise<void> {
 	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> {
 	async getCustomModesFilePath(): Promise<string> {
 		const settingsDir = await this.ensureSettingsDirectoryExists()
 		const settingsDir = await this.ensureSettingsDirectoryExists()
 		const filePath = path.join(settingsDir, "cline_custom_modes.json")
 		const filePath = path.join(settingsDir, "cline_custom_modes.json")
@@ -55,14 +124,17 @@ export class CustomModesManager {
 		return filePath
 		return filePath
 	}
 	}
 
 
-	private async watchCustomModesFile(): Promise<void> {
+	private async watchCustomModesFiles(): Promise<void> {
 		const settingsPath = await this.getCustomModesFilePath()
 		const settingsPath = await this.getCustomModesFilePath()
+
+		// Watch settings file
 		this.disposables.push(
 		this.disposables.push(
 			vscode.workspace.onDidSaveTextDocument(async (document) => {
 			vscode.workspace.onDidSaveTextDocument(async (document) => {
 				if (arePathsEqual(document.uri.fsPath, settingsPath)) {
 				if (arePathsEqual(document.uri.fsPath, settingsPath)) {
 					const content = await fs.readFile(settingsPath, "utf-8")
 					const content = await fs.readFile(settingsPath, "utf-8")
 					const errorMessage =
 					const errorMessage =
 						"Invalid custom modes format. Please ensure your settings follow the correct JSON format."
 						"Invalid custom modes format. Please ensure your settings follow the correct JSON format."
+
 					let config: any
 					let config: any
 					try {
 					try {
 						config = JSON.parse(content)
 						config = JSON.parse(content)
@@ -71,86 +143,170 @@ export class CustomModesManager {
 						vscode.window.showErrorMessage(errorMessage)
 						vscode.window.showErrorMessage(errorMessage)
 						return
 						return
 					}
 					}
+
 					const result = CustomModesSettingsSchema.safeParse(config)
 					const result = CustomModesSettingsSchema.safeParse(config)
 					if (!result.success) {
 					if (!result.success) {
 						vscode.window.showErrorMessage(errorMessage)
 						vscode.window.showErrorMessage(errorMessage)
 						return
 						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()
 					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[]> {
 	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> {
 	async updateCustomMode(slug: string, config: ModeConfig): Promise<void> {
 		try {
 		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) {
 		} catch (error) {
 			vscode.window.showErrorMessage(
 			vscode.window.showErrorMessage(
 				`Failed to update custom mode: ${error instanceof Error ? error.message : String(error)}`,
 				`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> {
 	async deleteCustomMode(slug: string): Promise<void> {
 		try {
 		try {
 			const settingsPath = await this.getCustomModesFilePath()
 			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 () => {
 			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) {
 		} catch (error) {
 			vscode.window.showErrorMessage(
 			vscode.window.showErrorMessage(
@@ -165,9 +321,6 @@ export class CustomModesManager {
 		return settingsDir
 		return settingsDir
 	}
 	}
 
 
-	/**
-	 * Delete the custom modes file and reset to default state
-	 */
 	async resetCustomModes(): Promise<void> {
 	async resetCustomModes(): Promise<void> {
 		try {
 		try {
 			const filePath = await this.getCustomModesFilePath()
 			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 vscode from "vscode"
-import * as fs from "fs/promises"
 import * as path from "path"
 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("vscode")
 jest.mock("fs/promises")
 jest.mock("fs/promises")
-jest.mock("../../../utils/fs", () => ({
-	fileExistsAtPath: jest.fn().mockResolvedValue(false),
-}))
+jest.mock("../../../utils/fs")
 
 
 describe("CustomModesManager", () => {
 describe("CustomModesManager", () => {
 	let manager: CustomModesManager
 	let manager: CustomModesManager
 	let mockContext: vscode.ExtensionContext
 	let mockContext: vscode.ExtensionContext
 	let mockOnUpdate: jest.Mock
 	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 = {
 		mockContext = {
-			globalStorageUri: { fsPath: mockStoragePath },
 			globalState: {
 			globalState: {
-				get: jest.fn().mockResolvedValue([]),
-				update: jest.fn().mockResolvedValue(undefined),
+				get: jest.fn(),
+				update: jest.fn(),
+			},
+			globalStorageUri: {
+				fsPath: mockStoragePath,
 			},
 			},
 		} as unknown as vscode.ExtensionContext
 		} 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.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)
 		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(
 			expect(mockContext.globalState.update).toHaveBeenCalledWith(
 				"customModes",
 				"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()
 			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", () => {
 	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")
 			const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
 			await manager.getCustomModesFilePath()
 			await manager.getCustomModesFilePath()
 
 
 			expect(fs.mkdir).toHaveBeenCalledWith(path.dirname(configPath), { recursive: true })
 			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")
 			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()
 			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")
 			const configPath = path.join(mockStoragePath, "settings", "cline_custom_modes.json")
 			;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({ customModes: [] }))
 			;(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
 			// Get the registered callback
 			const registerCall = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock.calls[0]
 			const registerCall = (vscode.workspace.onDidSaveTextDocument as jest.Mock).mock.calls[0]
 			expect(registerCall).toBeDefined()
 			expect(registerCall).toBeDefined()
@@ -144,102 +317,93 @@ describe("CustomModesManager", () => {
 			expect(fs.readFile).toHaveBeenCalledWith(configPath, "utf-8")
 			expect(fs.readFile).toHaveBeenCalledWith(configPath, "utf-8")
 			expect(mockContext.globalState.update).toHaveBeenCalled()
 			expect(mockContext.globalState.update).toHaveBeenCalled()
 			expect(mockOnUpdate).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()
 			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")}
 ${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 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:
 - 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.
   * 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.
 - 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": [
  "customModes": [
    {
    {
      "slug": "designer", // Required: unique slug with lowercase letters, numbers, and hyphens
      "slug": "designer", // Required: unique slug with lowercase letters, numbers, and hyphens
      "name": "Designer", // Required: mode display name
      "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)
      "groups": [ // Required: array of tool groups (can be empty)
        "read",    // Read files group (read_file, search_files, list_files, list_code_definition_names)
        "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
        "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
 	modeConfig?: ModeConfig
 	timeout?: number
 	timeout?: number
 	payload?: WebViewMessagePayload
 	payload?: WebViewMessagePayload
+	source?: "global" | "project"
 }
 }
 
 
 export const checkoutDiffPayloadSchema = z.object({
 export const checkoutDiffPayloadSchema = z.object({

+ 1 - 0
src/shared/modes.ts

@@ -19,6 +19,7 @@ export type ModeConfig = {
 	roleDefinition: string
 	roleDefinition: string
 	customInstructions?: string
 	customInstructions?: string
 	groups: readonly GroupEntry[] // Now supports both simple strings and tuples with options
 	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
 // Mode-specific prompts only

+ 169 - 162
webview-ui/src/components/prompts/PromptsView.tsx

@@ -6,6 +6,8 @@ import {
 	VSCodeOption,
 	VSCodeOption,
 	VSCodeTextField,
 	VSCodeTextField,
 	VSCodeCheckbox,
 	VSCodeCheckbox,
+	VSCodeRadioGroup,
+	VSCodeRadio,
 } from "@vscode/webview-ui-toolkit/react"
 } from "@vscode/webview-ui-toolkit/react"
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import { useExtensionState } from "../../context/ExtensionStateContext"
 import {
 import {
@@ -30,6 +32,8 @@ import { vscode } from "../../utils/vscode"
 // Get all available groups that should show in prompts view
 // Get all available groups that should show in prompts view
 const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable)
 const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable)
 
 
+type ModeSource = "global" | "project"
+
 type PromptsViewProps = {
 type PromptsViewProps = {
 	onDone: () => void
 	onDone: () => void
 }
 }
@@ -64,6 +68,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	const [selectedPromptContent, setSelectedPromptContent] = useState("")
 	const [selectedPromptContent, setSelectedPromptContent] = useState("")
 	const [selectedPromptTitle, setSelectedPromptTitle] = useState("")
 	const [selectedPromptTitle, setSelectedPromptTitle] = useState("")
 	const [isToolsEditMode, setIsToolsEditMode] = useState(false)
 	const [isToolsEditMode, setIsToolsEditMode] = useState(false)
+	const [showConfigMenu, setShowConfigMenu] = useState(false)
 	const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false)
 	const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false)
 	const [activeSupportTab, setActiveSupportTab] = useState<SupportPromptType>("ENHANCE")
 	const [activeSupportTab, setActiveSupportTab] = useState<SupportPromptType>("ENHANCE")
 
 
@@ -88,10 +93,14 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	)
 	)
 
 
 	const updateCustomMode = useCallback((slug: string, modeConfig: ModeConfig) => {
 	const updateCustomMode = useCallback((slug: string, modeConfig: ModeConfig) => {
+		const source = modeConfig.source || "global"
 		vscode.postMessage({
 		vscode.postMessage({
 			type: "updateCustomMode",
 			type: "updateCustomMode",
 			slug,
 			slug,
-			modeConfig,
+			modeConfig: {
+				...modeConfig,
+				source, // Ensure source is set
+			},
 		})
 		})
 	}, [])
 	}, [])
 
 
@@ -146,6 +155,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	const [newModeRoleDefinition, setNewModeRoleDefinition] = useState("")
 	const [newModeRoleDefinition, setNewModeRoleDefinition] = useState("")
 	const [newModeCustomInstructions, setNewModeCustomInstructions] = useState("")
 	const [newModeCustomInstructions, setNewModeCustomInstructions] = useState("")
 	const [newModeGroups, setNewModeGroups] = useState<GroupEntry[]>(availableGroups)
 	const [newModeGroups, setNewModeGroups] = useState<GroupEntry[]>(availableGroups)
+	const [newModeSource, setNewModeSource] = useState<ModeSource>("global")
 
 
 	// Reset form fields when dialog opens
 	// Reset form fields when dialog opens
 	useEffect(() => {
 	useEffect(() => {
@@ -153,6 +163,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 			setNewModeGroups(availableGroups)
 			setNewModeGroups(availableGroups)
 			setNewModeRoleDefinition("")
 			setNewModeRoleDefinition("")
 			setNewModeCustomInstructions("")
 			setNewModeCustomInstructions("")
+			setNewModeSource("global")
 		}
 		}
 	}, [isCreateModeDialogOpen])
 	}, [isCreateModeDialogOpen])
 
 
@@ -177,12 +188,14 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	const handleCreateMode = useCallback(() => {
 	const handleCreateMode = useCallback(() => {
 		if (!newModeName.trim() || !newModeSlug.trim()) return
 		if (!newModeName.trim() || !newModeSlug.trim()) return
 
 
+		const source = newModeSource
 		const newMode: ModeConfig = {
 		const newMode: ModeConfig = {
 			slug: newModeSlug,
 			slug: newModeSlug,
 			name: newModeName,
 			name: newModeName,
 			roleDefinition: newModeRoleDefinition.trim() || "",
 			roleDefinition: newModeRoleDefinition.trim() || "",
 			customInstructions: newModeCustomInstructions.trim() || undefined,
 			customInstructions: newModeCustomInstructions.trim() || undefined,
 			groups: newModeGroups,
 			groups: newModeGroups,
+			source,
 		}
 		}
 		updateCustomMode(newModeSlug, newMode)
 		updateCustomMode(newModeSlug, newMode)
 		switchMode(newModeSlug)
 		switchMode(newModeSlug)
@@ -192,8 +205,17 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 		setNewModeRoleDefinition("")
 		setNewModeRoleDefinition("")
 		setNewModeCustomInstructions("")
 		setNewModeCustomInstructions("")
 		setNewModeGroups(availableGroups)
 		setNewModeGroups(availableGroups)
+		setNewModeSource("global")
 		// eslint-disable-next-line react-hooks/exhaustive-deps
 		// eslint-disable-next-line react-hooks/exhaustive-deps
-	}, [newModeName, newModeSlug, newModeRoleDefinition, newModeCustomInstructions, newModeGroups, updateCustomMode])
+	}, [
+		newModeName,
+		newModeSlug,
+		newModeRoleDefinition,
+		newModeCustomInstructions,
+		newModeGroups,
+		newModeSource,
+		updateCustomMode,
+	])
 
 
 	const isNameOrSlugTaken = useCallback(
 	const isNameOrSlugTaken = useCallback(
 		(name: string, slug: string) => {
 		(name: string, slug: string) => {
@@ -233,15 +255,29 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 					newGroups = oldGroups.filter((g) => getGroupName(g) !== group)
 					newGroups = oldGroups.filter((g) => getGroupName(g) !== group)
 				}
 				}
 				if (customMode) {
 				if (customMode) {
+					const source = customMode.source || "global"
 					updateCustomMode(customMode.slug, {
 					updateCustomMode(customMode.slug, {
 						...customMode,
 						...customMode,
 						groups: newGroups,
 						groups: newGroups,
+						source,
 					})
 					})
 				}
 				}
 			},
 			},
 		[updateCustomMode],
 		[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(() => {
 	useEffect(() => {
 		const handler = (event: MessageEvent) => {
 		const handler = (event: MessageEvent) => {
 			const message = event.data
 			const message = event.data
@@ -307,31 +343,16 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 	}
 	}
 
 
 	return (
 	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>
 				<VSCodeButton onClick={onDone}>Done</VSCodeButton>
 			</div>
 			</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
 						<select
 							value={preferredLanguage}
 							value={preferredLanguage}
 							onChange={(e) => {
 							onChange={(e) => {
@@ -341,15 +362,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									text: e.target.value,
 									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="English">English</option>
 							<option value="Arabic">Arabic - العربية</option>
 							<option value="Arabic">Arabic - العربية</option>
 							<option value="Brazilian Portuguese">Portuguese - Português (Brasil)</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="Traditional Chinese">Traditional Chinese - 繁體中文</option>
 							<option value="Turkish">Turkish - Türkçe</option>
 							<option value="Turkish">Turkish - Türkçe</option>
 						</select>
 						</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.
 							Select the language that Cline should use for communication.
 						</p>
 						</p>
 					</div>
 					</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
 						These instructions apply to all modes. They provide a base set of behaviors that can be enhanced
 						by mode-specific instructions below.
 						by mode-specific instructions below.
 					</div>
 					</div>
@@ -399,23 +406,13 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 						}}
 						}}
 						rows={4}
 						rows={4}
 						resize="vertical"
 						resize="vertical"
-						style={{ width: "100%" }}
+						className="w-full"
 						data-testid="global-custom-instructions-textarea"
 						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{" "}
 						Instructions can also be loaded from{" "}
 						<span
 						<span
-							style={{
-								color: "var(--vscode-textLink-foreground)",
-								cursor: "pointer",
-								textDecoration: "underline",
-							}}
+							className="text-vscode-textLink-foreground cursor-pointer underline"
 							onClick={() =>
 							onClick={() =>
 								vscode.postMessage({
 								vscode.postMessage({
 									type: "openFile",
 									type: "openFile",
@@ -432,50 +429,74 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 					</div>
 					</div>
 				</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">
 							<VSCodeButton appearance="icon" onClick={openCreateModeDialog} title="Create new mode">
 								<span className="codicon codicon-add"></span>
 								<span className="codicon codicon-add"></span>
 							</VSCodeButton>
 							</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>
 					</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!
 						Hit the + to create a new custom mode, or just ask Roo in chat to create one for you!
 					</div>
 					</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) => {
 						{modes.map((modeConfig) => {
 							const isActive = mode === modeConfig.slug
 							const isActive = mode === modeConfig.slug
 							return (
 							return (
@@ -484,18 +505,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									data-testid={`${modeConfig.slug}-tab`}
 									data-testid={`${modeConfig.slug}-tab`}
 									data-active={isActive ? "true" : "false"}
 									data-active={isActive ? "true" : "false"}
 									onClick={() => handleModeSwitch(modeConfig)}
 									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}
 									{modeConfig.name}
 								</button>
 								</button>
 							)
 							)
@@ -506,10 +520,10 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 				<div style={{ marginBottom: "20px" }}>
 				<div style={{ marginBottom: "20px" }}>
 					{/* Only show name and delete for custom modes */}
 					{/* Only show name and delete for custom modes */}
 					{mode && findModeBySlug(mode, customModes) && (
 					{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
 									<VSCodeTextField
 										value={getModeProperty(findModeBySlug(mode, customModes), "name") ?? ""}
 										value={getModeProperty(findModeBySlug(mode, customModes), "name") ?? ""}
 										onChange={(e: Event | React.FormEvent<HTMLElement>) => {
 										onChange={(e: Event | React.FormEvent<HTMLElement>) => {
@@ -521,10 +535,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 												updateCustomMode(mode, {
 												updateCustomMode(mode, {
 													...customMode,
 													...customMode,
 													name: target.value,
 													name: target.value,
+													source: customMode.source || "global",
 												})
 												})
 											}
 											}
 										}}
 										}}
-										style={{ width: "100%" }}
+										className="w-full"
 									/>
 									/>
 									<VSCodeButton
 									<VSCodeButton
 										appearance="icon"
 										appearance="icon"
@@ -542,14 +557,8 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 						</div>
 						</div>
 					)}
 					)}
 					<div style={{ marginBottom: "16px" }}>
 					<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) && (
 							{!findModeBySlug(mode, customModes) && (
 								<VSCodeButton
 								<VSCodeButton
 									appearance="icon"
 									appearance="icon"
@@ -565,12 +574,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 								</VSCodeButton>
 								</VSCodeButton>
 							)}
 							)}
 						</div>
 						</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
 							Define Roo's expertise and personality for this mode. This description shapes how Roo
 							presents itself and approaches tasks.
 							presents itself and approaches tasks.
 						</div>
 						</div>
@@ -590,6 +594,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									updateCustomMode(mode, {
 									updateCustomMode(mode, {
 										...customMode,
 										...customMode,
 										roleDefinition: value.trim() || "",
 										roleDefinition: value.trim() || "",
+										source: customMode.source || "global",
 									})
 									})
 								} else {
 								} else {
 									// For built-in modes, update the prompts
 									// For built-in modes, update the prompts
@@ -618,34 +623,23 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 											text: value,
 											text: value,
 										})
 										})
 									}}
 									}}
-									style={{ width: "100%" }}>
+									className="w-full">
 									{(listApiConfigMeta || []).map((config) => (
 									{(listApiConfigMeta || []).map((config) => (
 										<VSCodeOption key={config.id} value={config.name}>
 										<VSCodeOption key={config.id} value={config.name}>
 											{config.name}
 											{config.name}
 										</VSCodeOption>
 										</VSCodeOption>
 									))}
 									))}
 								</VSCodeDropdown>
 								</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
 									Select which API configuration to use for this mode
 								</div>
 								</div>
 							</div>
 							</div>
 						</div>
 						</div>
 
 
 						{/* Show tools for all modes */}
 						{/* 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) && (
 								{findModeBySlug(mode, customModes) && (
 									<VSCodeButton
 									<VSCodeButton
 										appearance="icon"
 										appearance="icon"
@@ -657,22 +651,12 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 								)}
 								)}
 							</div>
 							</div>
 							{!findModeBySlug(mode, customModes) && (
 							{!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
 									Tools for built-in modes cannot be modified
 								</div>
 								</div>
 							)}
 							)}
 							{isToolsEditMode && findModeBySlug(mode, customModes) ? (
 							{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) => {
 									{availableGroups.map((group) => {
 										const currentMode = getCurrentMode()
 										const currentMode = getCurrentMode()
 										const isCustomMode = findModeBySlug(mode, customModes)
 										const isCustomMode = findModeBySlug(mode, customModes)
@@ -689,12 +673,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 												disabled={!isCustomMode}>
 												disabled={!isCustomMode}>
 												{GROUP_DISPLAY_NAMES[group]}
 												{GROUP_DISPLAY_NAMES[group]}
 												{group === "edit" && (
 												{group === "edit" && (
-													<div
-														style={{
-															fontSize: "12px",
-															color: "var(--vscode-descriptionForeground)",
-															marginTop: "2px",
-														}}>
+													<div className="text-xs text-vscode-descriptionForeground mt-0.5">
 														Allowed files:{" "}
 														Allowed files:{" "}
 														{(() => {
 														{(() => {
 															const currentMode = getCurrentMode()
 															const currentMode = getCurrentMode()
@@ -717,13 +696,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									})}
 									})}
 								</div>
 								</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 currentMode = getCurrentMode()
 										const enabledGroups = currentMode?.groups || []
 										const enabledGroups = currentMode?.groups || []
@@ -798,6 +771,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									updateCustomMode(mode, {
 									updateCustomMode(mode, {
 										...customMode,
 										...customMode,
 										customInstructions: value.trim() || undefined,
 										customInstructions: value.trim() || undefined,
+										source: customMode.source || "global",
 									})
 									})
 								} else {
 								} else {
 									// For built-in modes, update the prompts
 									// For built-in modes, update the prompts
@@ -1118,6 +1092,39 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
 									letters, numbers, and hyphens.
 									letters, numbers, and hyphens.
 								</div>
 								</div>
 							</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={{ marginBottom: "16px" }}>
 								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Role Definition</div>
 								<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Role Definition</div>
 								<div
 								<div

+ 1 - 0
webview-ui/src/index.css

@@ -83,6 +83,7 @@
 	--color-vscode-notifications-foreground: var(--vscode-notifications-foreground);
 	--color-vscode-notifications-foreground: var(--vscode-notifications-foreground);
 	--color-vscode-notifications-background: var(--vscode-notifications-background);
 	--color-vscode-notifications-background: var(--vscode-notifications-background);
 	--color-vscode-notifications-border: var(--vscode-notifications-border);
 	--color-vscode-notifications-border: var(--vscode-notifications-border);
+	--color-vscode-descriptionForeground: var(--vscode-descriptionForeground);
 }
 }
 
 
 @layer base {
 @layer base {