diff --git a/src/__tests__/migrateSettings.test.ts b/src/__tests__/migrateSettings.test.ts index 538ff7d590..35640c885f 100644 --- a/src/__tests__/migrateSettings.test.ts +++ b/src/__tests__/migrateSettings.test.ts @@ -48,6 +48,18 @@ describe("Settings Migration", () => { // Mock extension context mockContext = { globalStorageUri: { fsPath: mockStoragePath }, + globalState: { + get: jest.fn((key: string) => { + if (key === "clineCustomModes") { + return "some old custom modes content" + } + if (key === "customInstructions") { + return undefined + } + return undefined + }), + update: jest.fn().mockResolvedValue(undefined), + }, } as unknown as vscode.ExtensionContext // Set global outputChannel for all tests diff --git a/src/core/prompts/__tests__/custom-system-prompt.test.ts b/src/core/prompts/__tests__/custom-system-prompt.test.ts index e7d1ae08d7..7d11f992da 100644 --- a/src/core/prompts/__tests__/custom-system-prompt.test.ts +++ b/src/core/prompts/__tests__/custom-system-prompt.test.ts @@ -16,6 +16,7 @@ const mockedFs = fs as jest.Mocked // Mock the fileExistsAtPath function jest.mock("../../../utils/fs", () => ({ + ...(jest.requireActual("../../../utils/fs") as object), // Spread all original exports fileExistsAtPath: jest.fn().mockResolvedValue(true), createDirectoriesForFile: jest.fn().mockResolvedValue([]), })) diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index f9f4b7dea0..29c4a04d90 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -5,22 +5,7 @@ import { Dirent } from "fs" import { isLanguage } from "@roo-code/types" import { LANGUAGES } from "../../../shared/language" - -/** - * Safely read a file and return its trimmed content - */ -async function safeReadFile(filePath: string): Promise { - try { - const content = await fs.readFile(filePath, "utf-8") - return content.trim() - } catch (err) { - const errorCode = (err as NodeJS.ErrnoException).code - if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) { - throw err - } - return "" - } -} +import { safeReadFile } from "../../../utils/fs" /** * Check if a directory exists diff --git a/src/core/prompts/sections/custom-system-prompt.ts b/src/core/prompts/sections/custom-system-prompt.ts index f401000bb5..0d7de5a768 100644 --- a/src/core/prompts/sections/custom-system-prompt.ts +++ b/src/core/prompts/sections/custom-system-prompt.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import { Mode } from "../../../shared/modes" -import { fileExistsAtPath } from "../../../utils/fs" +import { fileExistsAtPath, safeReadFile } from "../../../utils/fs" export type PromptVariables = { workspace?: string @@ -25,23 +25,6 @@ function interpolatePromptContent(content: string, variables: PromptVariables): return interpolatedContent } -/** - * Safely reads a file, returning an empty string if the file doesn't exist - */ -async function safeReadFile(filePath: string): Promise { - try { - const content = await fs.readFile(filePath, "utf-8") - // When reading with "utf-8" encoding, content should be a string - return content.trim() - } catch (err) { - const errorCode = (err as NodeJS.ErrnoException).code - if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) { - throw err - } - return "" - } -} - /** * Get the path to a system prompt file for a specific mode */ diff --git a/src/core/task/__tests__/Task.test.ts b/src/core/task/__tests__/Task.test.ts index 8ed57ffcb3..78f500327b 100644 --- a/src/core/task/__tests__/Task.test.ts +++ b/src/core/task/__tests__/Task.test.ts @@ -140,6 +140,7 @@ jest.mock("../../../utils/storage", () => ({ })) jest.mock("../../../utils/fs", () => ({ + ...(jest.requireActual("../../../utils/fs") as object), // Spread all original exports fileExistsAtPath: jest.fn().mockImplementation((filePath) => { return filePath.includes("ui_messages.json") || filePath.includes("api_conversation_history.json") }), diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 015e3ec184..ff0cdd5b14 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -37,6 +37,7 @@ import { Package } from "../../shared/package" import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" +import { GlobalContentIds } from "../../shared/globalContentIds" import { ExtensionMessage } from "../../shared/ExtensionMessage" import { Mode, defaultModeSlug } from "../../shared/modes" import { experimentDefault, experiments, EXPERIMENT_IDS } from "../../shared/experiments" @@ -65,6 +66,7 @@ import { webviewMessageHandler } from "./webviewMessageHandler" import { WebviewMessage } from "../../shared/WebviewMessage" import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels" import { ProfileValidator } from "../../shared/ProfileValidator" +import { ContentManager } from "../../services/content/ContentManager" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -101,6 +103,7 @@ export class ClineProvider return this._workspaceTracker } protected mcpHub?: McpHub // Change from private to protected + public contentManager: ContentManager // Declare private member public isViewLaunched = false public settingsImportedAt?: number @@ -147,6 +150,14 @@ export class ClineProvider .catch((error) => { this.log(`Failed to initialize MCP Hub: ${error}`) }) + + // Instantiate ContentManager + this.contentManager = new ContentManager({ + log: this.log.bind(this), + postMessageToWebview: this.postMessageToWebview.bind(this), + postStateToWebview: this.postStateToWebview.bind(this), + globalStorageUriFsPath: this.contextProxy.globalStorageUri.fsPath, + }) } // Adds a new Cline instance to clineStack, marking the start of a new task. @@ -988,10 +999,23 @@ export class ClineProvider await this.initClineWithHistoryItem({ ...historyItem, rootTask, parentTask }) } - async updateCustomInstructions(instructions?: string) { - // User may be clearing the field. - await this.updateGlobalState("customInstructions", instructions || undefined) - await this.postStateToWebview() + // Settings Directory + + async ensureSettingsDirectoryExists(): Promise { + const { getSettingsDirectoryPath } = await import("../../utils/storage") + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + return getSettingsDirectoryPath(globalStoragePath) + } + + private async getContentPath(contentId: string): Promise { + const settingsDir = await this.ensureSettingsDirectoryExists() + switch (contentId) { + case GlobalContentIds.customInstructions: + return path.join(settingsDir, GlobalFileNames.customInstructions) + default: + this.log(`Unknown contentId: ${contentId}`) + return undefined + } } // MCP @@ -1019,12 +1043,6 @@ export class ClineProvider return mcpServersDir } - async ensureSettingsDirectoryExists(): Promise { - const { getSettingsDirectoryPath } = await import("../../utils/storage") - const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - return getSettingsDirectoryPath(globalStoragePath) - } - // OpenRouter async handleOpenRouterCallback(code: string) { @@ -1474,7 +1492,7 @@ export class ClineProvider return { apiConfiguration: providerSettings, lastShownAnnouncementId: stateValues.lastShownAnnouncementId, - customInstructions: stateValues.customInstructions, + customInstructions: await this.contentManager.readContent(GlobalContentIds.customInstructions), apiModelId: stateValues.apiModelId, alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 659d60f31a..60139e679b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -14,6 +14,7 @@ import { RouterName, toRouterName, ModelRecord } from "../../shared/api" import { supportPrompt } from "../../shared/support-prompt" import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" +import { GlobalContentIds } from "../../shared/globalContentIds" import { checkExistKey } from "../../shared/checkExistApiConfig" import { experimentDefault } from "../../shared/experiments" import { Terminal } from "../../integrations/terminal/Terminal" @@ -130,7 +131,23 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We await provider.initClineWithTask(message.text, message.images) break case "customInstructions": - await provider.updateCustomInstructions(message.text) + await provider.contentManager.updateContent(GlobalContentIds.customInstructions, message.text) + break + case "openContent": + if (message.contentId) { + // Check for contentId instead of filePath + await provider.contentManager.openContent(message.contentId) + } else { + console.error("openContent message missing contentId") + } + break + case "refreshContent": + if (message.contentId) { + await provider.contentManager.refreshContent(message.contentId) + } else { + // Handle error or log if contentId is missing + console.error("refreshContent message missing contentId") + } break case "alwaysAllowReadOnly": await updateGlobalState("alwaysAllowReadOnly", message.bool ?? undefined) diff --git a/src/services/content/ContentManager.ts b/src/services/content/ContentManager.ts new file mode 100644 index 0000000000..631fb4f021 --- /dev/null +++ b/src/services/content/ContentManager.ts @@ -0,0 +1,102 @@ +import * as vscode from "vscode" +import * as path from "path" +import fs from "fs/promises" + +import { GlobalContentIds } from "../../shared/globalContentIds" +import { GlobalFileNames } from "../../shared/globalFileNames" +import { openFile } from "../../integrations/misc/open-file" +import { safeReadFile } from "../../utils/fs" +import { getSettingsDirectoryPath } from "../../utils/storage" // Need to confirm this import path +import { ExtensionMessage } from "../../shared/ExtensionMessage" // Assuming this type is needed for postMessageToWebview + +// Define an interface for the dependencies ContentManager needs from ClineProvider +interface ContentManagerDependencies { + log: (message: string) => void + postMessageToWebview: (message: ExtensionMessage) => Promise + postStateToWebview: () => Promise + globalStorageUriFsPath: string // To replace contextProxy.globalStorageUri.fsPath +} + +export class ContentManager { + private readonly log: (message: string) => void + private readonly postMessageToWebview: (message: ExtensionMessage) => Promise + private readonly postStateToWebview: () => Promise + private readonly globalStorageUriFsPath: string + + constructor(dependencies: ContentManagerDependencies) { + this.log = dependencies.log + this.postMessageToWebview = dependencies.postMessageToWebview + this.postStateToWebview = dependencies.postStateToWebview + this.globalStorageUriFsPath = dependencies.globalStorageUriFsPath + } + + private async ensureSettingsDirectoryExists(): Promise { + const globalStoragePath = this.globalStorageUriFsPath + return getSettingsDirectoryPath(globalStoragePath) + } + + private async getContentPath(contentId: string): Promise { + const settingsDir = await this.ensureSettingsDirectoryExists() + switch (contentId) { + case GlobalContentIds.customInstructions: + return path.join(settingsDir, GlobalFileNames.customInstructions) + default: + this.log(`Unknown contentId: ${contentId}`) + return undefined + } + } + + async openContent(contentId: string): Promise { + const filePath = await this.getContentPath(contentId) + + if (!filePath) { + this.log(`File path could not be determined for contentId: ${contentId}`) + return + } + + await openFile(filePath, { create: true }) + await vscode.commands.executeCommand("workbench.action.files.revert") + } + + async refreshContent(contentId: string): Promise { + const content = await this.readContent(contentId) + await this.updateContent(contentId, content) + this.postMessageToWebview({ + type: "contentRefreshed", + contentId: contentId, + success: true, // Assuming success for now + }) + } + + async updateContent(contentId: string, content?: string) { + const filePath = await this.getContentPath(contentId) + + if (!filePath) { + this.log(`File path could not be determined for contentId: ${contentId}`) + return + } + + if (content && content.trim()) { + await fs.writeFile(filePath, content.trim(), "utf-8") + this.log(`Updated content file: ${filePath}`) + } else { + try { + await fs.unlink(filePath) + this.log(`Deleted content file: ${filePath}`) + } catch (error: any) { + if (error.code !== "ENOENT") { + this.log(`Error deleting content file: ${error.message}`) + throw error + } + } + } + // Update the webview state + await this.postStateToWebview() + } + + async readContent(contentId: string): Promise { + const filePath = await this.getContentPath(contentId) + + return filePath ? ((await safeReadFile(filePath)) ?? "") : "" + } +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 07d300ed01..92bb610ca8 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -73,6 +73,7 @@ export interface ExtensionMessage { | "indexingStatusUpdate" | "indexCleared" | "codebaseIndexConfig" + | "contentRefreshed" text?: string action?: | "chatButtonClicked" @@ -105,6 +106,7 @@ export interface ExtensionMessage { customMode?: ModeConfig slug?: string success?: boolean + contentId?: string values?: Record requestId?: string promptText?: string diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 05136c18b3..66ec21d0ec 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -51,6 +51,7 @@ export interface WebviewMessage { | "openImage" | "openFile" | "openMention" + | "openContent" | "cancelTask" | "updateVSCodeSetting" | "getVSCodeSetting" @@ -118,6 +119,7 @@ export interface WebviewMessage { | "deleteCustomMode" | "setopenAiCustomModelInfo" | "openCustomModesSettings" + | "refreshContent" | "checkpointDiff" | "checkpointRestore" | "deleteMcpServer" @@ -170,6 +172,7 @@ export interface WebviewMessage { modeConfig?: ModeConfig timeout?: number payload?: WebViewMessagePayload + contentId?: string source?: "global" | "project" requestId?: string ids?: string[] diff --git a/src/shared/globalContentIds.ts b/src/shared/globalContentIds.ts new file mode 100644 index 0000000000..c7c43ead5f --- /dev/null +++ b/src/shared/globalContentIds.ts @@ -0,0 +1,3 @@ +export const GlobalContentIds = { + customInstructions: "customInstructions" as const, +} diff --git a/src/shared/globalFileNames.ts b/src/shared/globalFileNames.ts index 98b48485f0..f1192ae022 100644 --- a/src/shared/globalFileNames.ts +++ b/src/shared/globalFileNames.ts @@ -3,5 +3,6 @@ export const GlobalFileNames = { uiMessages: "ui_messages.json", mcpSettings: "mcp_settings.json", customModes: "custom_modes.yaml", + customInstructions: "custom_instructions.md", taskMetadata: "task_metadata.json", } diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 9f7af84e4a..6bb0ed7fb6 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -45,3 +45,19 @@ export async function fileExistsAtPath(filePath: string): Promise { return false } } + +/** + * Safely read a file and return its trimmed content + */ +export async function safeReadFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, "utf-8") + return content ? content.trim() : "" + } catch (err) { + const errorCode = (err as NodeJS.ErrnoException).code + if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) { + throw err + } + return "" + } +} diff --git a/src/utils/migrateSettings.ts b/src/utils/migrateSettings.ts index 406e5bd051..45529b65f8 100644 --- a/src/utils/migrateSettings.ts +++ b/src/utils/migrateSettings.ts @@ -34,6 +34,27 @@ export async function migrateSettings( // Process each file migration try { + // Migrate custom instructions from GlobalState to file + const customInstructionsKey = "customInstructions" + const customInstructionsContent = context.globalState.get(customInstructionsKey) + const customInstructionsFilePath = path.join(settingsDir, GlobalFileNames.customInstructions) + + if (customInstructionsContent && !(await fileExistsAtPath(customInstructionsFilePath))) { + try { + await fs.writeFile(customInstructionsFilePath, customInstructionsContent, "utf-8") + await context.globalState.update(customInstructionsKey, undefined) // Delete from GlobalState + outputChannel.appendLine("Migrated custom instructions from GlobalState to file.") + } catch (migrationError) { + outputChannel.appendLine( + `Error migrating custom instructions: ${migrationError}. Data might still be in GlobalState.`, + ) + } + } else { + outputChannel.appendLine( + `Skipping custom instructions migration: ${customInstructionsContent ? "file already exists" : "no data in GlobalState"}`, + ) + } + for (const migration of fileMigrations) { const oldPath = path.join(settingsDir, migration.oldName) const newPath = path.join(settingsDir, migration.newName) diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index 18f4bdf3d2..85b95d59a8 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -44,6 +44,8 @@ import { CommandGroup, Input, } from "@src/components/ui" +import { GlobalContentIds } from "../../../../src/shared/globalContentIds" +import { useRefreshableContent } from "../../hooks/useRefreshableContent" // Get all available groups that should show in prompts view const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable) @@ -79,6 +81,10 @@ const ModesView = ({ onDone }: ModesViewProps) => { // 3. Still sending the mode change to the backend for persistence const [visualMode, setVisualMode] = useState(mode) + const { isRefreshing, showRefreshSuccess, refreshContent } = useRefreshableContent( + GlobalContentIds.customInstructions, + ) + // Memoize modes to preserve array order const modes = useMemo(() => getAllModes(customModes), [customModes]) @@ -1036,9 +1042,38 @@ const ModesView = ({ onDone }: ModesViewProps) => { -
-

{t("prompts:globalCustomInstructions.title")}

- +
+
+

{t("prompts:globalCustomInstructions.title")}

+
+ + +
+
+ {showRefreshSuccess && ( +
+ {t("prompts:globalCustomInstructions.refreshSuccess")} +
+ )}
{ ((e as any).target as HTMLTextAreaElement).value setCustomInstructions(value || undefined) vscode.postMessage({ - type: "customInstructions", + type: GlobalContentIds.customInstructions, text: value.trim() || undefined, }) }} diff --git a/webview-ui/src/hooks/useRefreshableContent.ts b/webview-ui/src/hooks/useRefreshableContent.ts new file mode 100644 index 0000000000..e48063fe6b --- /dev/null +++ b/webview-ui/src/hooks/useRefreshableContent.ts @@ -0,0 +1,45 @@ +import { useState, useEffect, useCallback } from "react" +import { vscode } from "../utils/vscode" +import type { ExtensionMessage } from "../../../src/shared/ExtensionMessage" + +interface UseRefreshableContentResult { + isRefreshing: boolean + showRefreshSuccess: boolean + refreshContent: () => void +} + +export function useRefreshableContent(contentId: string): UseRefreshableContentResult { + const [isRefreshing, setIsRefreshing] = useState(false) + const [showRefreshSuccess, setShowRefreshSuccess] = useState(false) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "contentRefreshed" && message.contentId === contentId) { + setIsRefreshing(false) + if (message.success) { + setShowRefreshSuccess(true) + const timer = setTimeout(() => { + setShowRefreshSuccess(false) + }, 3000) + return () => clearTimeout(timer) + } + } + } + + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) + } + }, [contentId]) + + const refreshContent = useCallback(() => { + setIsRefreshing(true) + vscode.postMessage({ + type: "refreshContent", + contentId, + }) + }, [contentId]) + + return { isRefreshing, showRefreshSuccess, refreshContent } +} diff --git a/webview-ui/src/i18n/locales/en/prompts.json b/webview-ui/src/i18n/locales/en/prompts.json index f03d48ca92..531bdfec62 100644 --- a/webview-ui/src/i18n/locales/en/prompts.json +++ b/webview-ui/src/i18n/locales/en/prompts.json @@ -48,6 +48,9 @@ "globalCustomInstructions": { "title": "Custom Instructions for All Modes", "description": "These instructions apply to all modes. They provide a base set of behaviors that can be enhanced by mode-specific instructions below. <0>Learn more", + "openFile": "Open custom instructions file", + "refreshFile": "Refresh custom instructions from file", + "refreshSuccess": "Custom instructions refreshed!", "loadFromFile": "Instructions can also be loaded from the .roo/rules/ folder in your workspace (.roorules and .clinerules are deprecated and will stop working soon)." }, "systemPrompt": {