From 7c6fe52c80322032fcbf4633b4ee7bde981ba585 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Thu, 13 Feb 2025 11:03:01 -0500 Subject: [PATCH 1/2] Add support for project-specific .roomodes --- src/core/config/CustomModesManager.ts | 243 +++++++-- .../__tests__/CustomModesManager.test.ts | 466 ++++++++++++------ src/core/prompts/sections/modes.ts | 14 +- src/shared/WebviewMessage.ts | 1 + src/shared/modes.ts | 1 + .../src/components/prompts/PromptsView.tsx | 172 ++++++- 6 files changed, 685 insertions(+), 212 deletions(-) diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index ad3a2b8b100..331e3fb9097 100644 --- a/src/core/config/CustomModesManager.ts +++ b/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, ) { - this.watchCustomModesFile() + this.watchCustomModesFiles() } private async queueWrite(operation: () => Promise): Promise { @@ -43,6 +45,73 @@ export class CustomModesManager { } } + private async getWorkspaceRoomodes(): Promise { + 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 { + 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 { + const slugs = new Set() + 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 { 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 { + private async watchCustomModesFiles(): Promise { 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 { - const modes = await this.context.globalState.get("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() + const globalModes = new Map() + + // 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 { 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 { + 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 { + 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 { 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 { try { const filePath = await this.getCustomModesFilePath() diff --git a/src/core/config/__tests__/CustomModesManager.test.ts b/src/core/config/__tests__/CustomModesManager.test.ts index 384f0a21714..bbc070b0ba7 100644 --- a/src/core/config/__tests__/CustomModesManager.test.ts +++ b/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") }) }) }) diff --git a/src/core/prompts/sections/modes.ts b/src/core/prompts/sections/modes.ts index ef09c035c99..8babe2d5a2e 100644 --- a/src/core/prompts/sections/modes.ts +++ b/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 diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index fd26f887c86..aadbe0167cd 100644 --- a/src/shared/WebviewMessage.ts +++ b/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({ diff --git a/src/shared/modes.ts b/src/shared/modes.ts index e06c7872f70..1bba7e87c8e 100644 --- a/src/shared/modes.ts +++ b/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 diff --git a/webview-ui/src/components/prompts/PromptsView.tsx b/webview-ui/src/components/prompts/PromptsView.tsx index 8b46e488aea..08b986705ca 100644 --- a/webview-ui/src/components/prompts/PromptsView.tsx +++ b/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("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(availableGroups) + const [newModeSource, setNewModeSource] = useState("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) => {
e.stopPropagation()} style={{ display: "flex", justifyContent: "space-between", @@ -445,16 +482,81 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { - { - vscode.postMessage({ - type: "openCustomModesSettings", - }) - }}> - - +
+ { + e.preventDefault() + e.stopPropagation() + setShowConfigMenu((prev) => !prev) + }} + onBlur={() => { + // Add slight delay to allow menu item clicks to register + setTimeout(() => setShowConfigMenu(false), 200) + }}> + + + {showConfigMenu && ( +
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, + }}> +
{ + e.preventDefault() // Prevent blur + vscode.postMessage({ + type: "openCustomModesSettings", + }) + setShowConfigMenu(false) + }} + onClick={(e) => e.preventDefault()}> + Edit Global Modes +
+
{ + 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) +
+
+ )} +
@@ -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. +
+
Save Location
+
+ Choose where to save this mode. Project-specific modes take precedence over global + modes. +
+ ) => { + const target = ((e as CustomEvent)?.detail?.target || + (e.target as HTMLInputElement)) as HTMLInputElement + setNewModeSource(target.value as ModeSource) + }}> + + Global +
+ Available in all workspaces +
+
+ + Project-specific (.roomodes) +
+ Only available in this workspace, takes precedence over global +
+
+
+
+
Role Definition
Date: Thu, 13 Feb 2025 23:32:47 -0500 Subject: [PATCH 2/2] Convert PromptsView to tailwind --- .clinerules | 5 + .../src/components/prompts/PromptsView.tsx | 239 ++++-------------- webview-ui/src/index.css | 1 + 3 files changed, 55 insertions(+), 190 deletions(-) diff --git a/.clinerules b/.clinerules index 4f726299d61..9bd9ff02ee8 100644 --- a/.clinerules +++ b/.clinerules @@ -16,6 +16,11 @@ - Logs can be found in `logs\app.log` - Logfile is overwritten on each run to keep it to a manageable volume. +4. Styling Guidelines: + - Use Tailwind CSS classes instead of inline style objects for new markup + - VSCode CSS variables must be added to webview-ui/src/index.css before using them in Tailwind classes + - Example: `
` instead of style objects + # Adding a New Setting diff --git a/webview-ui/src/components/prompts/PromptsView.tsx b/webview-ui/src/components/prompts/PromptsView.tsx index 08b986705ca..5956ff6265b 100644 --- a/webview-ui/src/components/prompts/PromptsView.tsx +++ b/webview-ui/src/components/prompts/PromptsView.tsx @@ -343,31 +343,16 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { } return ( -
-
-

Prompts

+
+
+

Prompts

Done
-
-
-
-
Preferred Language
+
+
+
+
Preferred Language
-

+

Select the language that Cline should use for communication.

-
Custom Instructions for All Modes
-
+
Custom Instructions for All Modes
+
These instructions apply to all modes. They provide a base set of behaviors that can be enhanced by mode-specific instructions below.
@@ -435,23 +406,13 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { }} rows={4} resize="vertical" - style={{ width: "100%" }} + className="w-full" data-testid="global-custom-instructions-textarea" /> -
+
Instructions can also be loaded from{" "} vscode.postMessage({ type: "openFile", @@ -468,25 +429,18 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
-
-
e.stopPropagation()} - style={{ - display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginBottom: "12px", - }}> -

Mode-Specific Prompts

-
+
+
e.stopPropagation()} className="flex justify-between items-center mb-3"> +

Mode-Specific Prompts

+
-
+
{ e.preventDefault() e.stopPropagation() @@ -502,25 +456,9 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
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, - }}> + className="absolute top-full right-0 w-[200px] mt-1 bg-vscode-editor-background border border-vscode-input-border rounded shadow-md z-[1000]">
{ e.preventDefault() // Prevent blur vscode.postMessage({ @@ -532,13 +470,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { Edit Global Modes
{ e.preventDefault() // Prevent blur vscode.postMessage({ @@ -560,24 +492,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
-
+
Hit the + to create a new custom mode, or just ask Roo in chat to create one for you!
-
+
{modes.map((modeConfig) => { const isActive = mode === modeConfig.slug return ( @@ -586,18 +505,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { data-testid={`${modeConfig.slug}-tab`} data-active={isActive ? "true" : "false"} onClick={() => handleModeSwitch(modeConfig)} - style={{ - padding: "4px 8px", - border: "none", - background: isActive ? "var(--vscode-button-background)" : "none", - color: isActive - ? "var(--vscode-button-foreground)" - : "var(--vscode-foreground)", - cursor: "pointer", - opacity: isActive ? 1 : 0.8, - borderRadius: "3px", - fontWeight: "bold", - }}> + className={`px-2 py-1 border-none rounded cursor-pointer font-bold ${ + isActive + ? "bg-vscode-button-background text-vscode-button-foreground opacity-100" + : "bg-transparent text-vscode-foreground opacity-80" + }`}> {modeConfig.name} ) @@ -608,10 +520,10 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
{/* Only show name and delete for custom modes */} {mode && findModeBySlug(mode, customModes) && ( -
-
-
Name
-
+
+
+
Name
+
) => { @@ -627,7 +539,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { }) } }} - style={{ width: "100%" }} + className="w-full" /> {
)}
-
-
Role Definition
+
+
Role Definition
{!findModeBySlug(mode, customModes) && ( { )}
-
+
Define Roo's expertise and personality for this mode. This description shapes how Roo presents itself and approaches tasks.
@@ -722,34 +623,23 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { text: value, }) }} - style={{ width: "100%" }}> + className="w-full"> {(listApiConfigMeta || []).map((config) => ( {config.name} ))} -
+
Select which API configuration to use for this mode
{/* Show tools for all modes */} -
-
-
Available Tools
+
+
+
Available Tools
{findModeBySlug(mode, customModes) && ( { )}
{!findModeBySlug(mode, customModes) && ( -
+
Tools for built-in modes cannot be modified
)} {isToolsEditMode && findModeBySlug(mode, customModes) ? ( -
+
{availableGroups.map((group) => { const currentMode = getCurrentMode() const isCustomMode = findModeBySlug(mode, customModes) @@ -793,12 +673,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { disabled={!isCustomMode}> {GROUP_DISPLAY_NAMES[group]} {group === "edit" && ( -
+
Allowed files:{" "} {(() => { const currentMode = getCurrentMode() @@ -821,13 +696,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { })}
) : ( -
+
{(() => { const currentMode = getCurrentMode() const enabledGroups = currentMode?.groups || [] @@ -1225,12 +1094,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
Save Location
-
+
Choose where to save this mode. Project-specific modes take precedence over global modes.
@@ -1254,12 +1118,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => { Project-specific (.roomodes) -
+
Only available in this workspace, takes precedence over global
diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index d4ccf2f233b..7c83662c5f4 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -83,6 +83,7 @@ --color-vscode-notifications-foreground: var(--vscode-notifications-foreground); --color-vscode-notifications-background: var(--vscode-notifications-background); --color-vscode-notifications-border: var(--vscode-notifications-border); + --color-vscode-descriptionForeground: var(--vscode-descriptionForeground); } @layer base {