diff --git a/core/config/markdown/loadMarkdownRules.ts b/core/config/markdown/loadMarkdownRules.ts index 42bfcd21d8c..46fdc0e7f78 100644 --- a/core/config/markdown/loadMarkdownRules.ts +++ b/core/config/markdown/loadMarkdownRules.ts @@ -64,13 +64,18 @@ export async function loadMarkdownRules(ide: IDE): Promise<{ // Process each markdown file for (const file of mdFiles) { try { - const { relativePathOrBasename } = findUriInDirs( + const { relativePathOrBasename, foundInDir } = findUriInDirs( file.path, await ide.getWorkspaceDirs(), ); + + // For global rules, use the full path + // For workspace rules, use the relative path + const fileUri = foundInDir ? relativePathOrBasename : file.path; + const rule = markdownToRule(file.content, { uriType: "file", - fileUri: relativePathOrBasename, + fileUri: fileUri, }); rules.push({ ...rule, source: "rules-block", ruleFile: file.path }); } catch (e) { diff --git a/core/config/workspace/workspaceBlocks.ts b/core/config/workspace/workspaceBlocks.ts index 25a606bf932..ed0be2c8c30 100644 --- a/core/config/workspace/workspaceBlocks.ts +++ b/core/config/workspace/workspaceBlocks.ts @@ -6,6 +6,7 @@ import { } from "@continuedev/config-yaml"; import * as YAML from "yaml"; import { IDE } from "../.."; +import { getContinueGlobalPath } from "../../util/paths"; import { joinPathsToUri } from "../../util/uri"; const BLOCK_TYPE_CONFIG: Record< @@ -101,8 +102,13 @@ export async function findAvailableFilename( blockType: BlockType, fileExists: (uri: string) => Promise, extension?: string, + isGlobal?: boolean, ): Promise { - const baseFilename = `new-${BLOCK_TYPE_CONFIG[blockType]?.filename}`; + // Differentiate filename based on whether its a global rule or a workspace rule + const baseFilename = + blockType === "rules" && isGlobal + ? "global-rule" + : `new-${BLOCK_TYPE_CONFIG[blockType]?.filename}`; const fileExtension = extension ?? getFileExtension(blockType); let counter = 0; let fileUri: string; @@ -143,3 +149,28 @@ export async function createNewWorkspaceBlockFile( await ide.writeFile(fileUri, fileContent); await ide.openFile(fileUri); } + +export async function createNewGlobalRuleFile(ide: IDE): Promise { + try { + const globalDir = getContinueGlobalPath(); + + // Create the rules subdirectory within the global directory + const rulesDir = joinPathsToUri(globalDir, "rules"); + + const fileUri = await findAvailableFilename( + rulesDir, + "rules", + ide.fileExists.bind(ide), + undefined, + true, // isGlobal = true for global rules + ); + + const fileContent = getFileContent("rules"); + + await ide.writeFile(fileUri, fileContent); + + await ide.openFile(fileUri); + } catch (error) { + throw error; + } +} diff --git a/core/core.ts b/core/core.ts index 6249107bd85..3ff4232274c 100644 --- a/core/core.ts +++ b/core/core.ts @@ -60,7 +60,10 @@ import { setupProviderConfig, setupQuickstartConfig, } from "./config/onboarding"; -import { createNewWorkspaceBlockFile } from "./config/workspace/workspaceBlocks"; +import { + createNewGlobalRuleFile, + createNewWorkspaceBlockFile, +} from "./config/workspace/workspaceBlocks"; import { MCPManagerSingleton } from "./context/mcp/MCPManagerSingleton"; import { performAuth, removeMCPAuth } from "./context/mcp/MCPOauth"; import { setMdmLicenseKey } from "./control-plane/mdm/mdm"; @@ -419,6 +422,17 @@ export class Core { ); }); + on("config/addGlobalRule", async (msg) => { + try { + await createNewGlobalRuleFile(this.ide); + await this.configHandler.reloadConfig( + "Global rule created (config/addGlobalRule message)", + ); + } catch (error) { + throw error; + } + }); + on("config/openProfile", async (msg) => { await this.configHandler.openConfigProfile( msg.data.profileId, diff --git a/core/protocol/core.ts b/core/protocol/core.ts index eb7be1bf68e..fe6693bbd40 100644 --- a/core/protocol/core.ts +++ b/core/protocol/core.ts @@ -94,6 +94,7 @@ export type ToCoreFromIdeOrWebviewProtocol = { void, ]; "config/addLocalWorkspaceBlock": [{ blockType: BlockType }, void]; + "config/addGlobalRule": [undefined, void]; "config/newPromptFile": [undefined, void]; "config/newAssistantFile": [undefined, void]; "config/ideSettingsUpdate": [IdeSettings, void]; diff --git a/core/protocol/passThrough.ts b/core/protocol/passThrough.ts index f02727a37ed..d9c7565cecf 100644 --- a/core/protocol/passThrough.ts +++ b/core/protocol/passThrough.ts @@ -22,6 +22,7 @@ export const WEBVIEW_TO_CORE_PASS_THROUGH: (keyof ToCoreFromWebviewProtocol)[] = "config/newAssistantFile", "config/ideSettingsUpdate", "config/addLocalWorkspaceBlock", + "config/addGlobalRule", "config/getSerializedProfileInfo", "config/deleteModel", "config/refreshProfiles", diff --git a/extensions/vscode/src/extension/VsCodeExtension.ts b/extensions/vscode/src/extension/VsCodeExtension.ts index 23ced0cc501..226e85d51f4 100644 --- a/extensions/vscode/src/extension/VsCodeExtension.ts +++ b/extensions/vscode/src/extension/VsCodeExtension.ts @@ -1,4 +1,5 @@ import fs from "fs"; +import path from "path"; import { IContextProvider } from "core"; import { ConfigHandler } from "core/config/ConfigHandler"; @@ -10,6 +11,7 @@ import { getConfigJsonPath, getConfigTsPath, getConfigYamlPath, + getContinueGlobalPath, } from "core/util/paths"; import { v4 as uuidv4 } from "uuid"; import * as vscode from "vscode"; @@ -463,6 +465,18 @@ export class VsCodeExtension { void this.configHandler.reloadConfig("config.ts updated - fs file watch"); }); + // watch global rules directory for changes + const globalRulesDir = path.join(getContinueGlobalPath(), "rules"); + if (fs.existsSync(globalRulesDir)) { + fs.watch(globalRulesDir, { recursive: true }, (eventType, filename) => { + if (filename && filename.endsWith(".md")) { + void this.configHandler.reloadConfig( + "Global rules directory updated - fs file watch", + ); + } + }); + } + vscode.workspace.onDidChangeTextDocument(async (event) => { if (event.contentChanges.length > 0) { selectionManager.documentChanged(); diff --git a/gui/src/components/DropdownButton.tsx b/gui/src/components/DropdownButton.tsx new file mode 100644 index 00000000000..5d2a0eaaea0 --- /dev/null +++ b/gui/src/components/DropdownButton.tsx @@ -0,0 +1,69 @@ +import { ChevronDownIcon, PlusIcon } from "@heroicons/react/24/outline"; +import { ToolTip } from "./gui/Tooltip"; +import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "./ui"; + +interface DropdownOption { + value: string; + label: string; +} + +interface DropdownButtonProps { + title: string; + options: DropdownOption[]; + onOptionClick: (value: string) => void; + addButtonTooltip?: string; + className?: string; + variant?: "default" | "sm"; +} + +export function DropdownButton({ + title, + options, + onOptionClick, + addButtonTooltip, + className = "", + variant = "default", +}: DropdownButtonProps) { + const isSmall = variant === "sm"; + const titleSize = isSmall ? "text-sm font-semibold" : "text-xl font-semibold"; + const marginBottom = isSmall ? "mb-2" : "mb-4"; + + return ( +
+

{title}

+ {}}> +
+ + + + + + + + {options.map((option) => ( + + `relative flex cursor-default select-none items-center gap-3 py-2 pl-4 pr-4 ${ + active + ? "bg-list-active text-list-active-foreground" + : "text-foreground" + }` + } + onClick={() => onOptionClick(option.value)} + > + {option.label} + + ))} + +
+
+
+ ); +} diff --git a/gui/src/pages/config/sections/RulesSection.tsx b/gui/src/pages/config/sections/RulesSection.tsx index 8d17fad6223..75bf7787165 100644 --- a/gui/src/pages/config/sections/RulesSection.tsx +++ b/gui/src/pages/config/sections/RulesSection.tsx @@ -19,7 +19,8 @@ import { DEFAULT_SYSTEM_MESSAGES_URL, } from "core/llm/defaultSystemMessages"; import { getRuleDisplayName } from "core/llm/rules/rules-utils"; -import { useContext, useMemo } from "react"; +import { useContext, useMemo, useState } from "react"; +import { DropdownButton } from "../../../components/DropdownButton"; import HeaderButtonWithToolTip from "../../../components/gui/HeaderButtonWithToolTip"; import Switch from "../../../components/gui/Switch"; import { useMainEditor } from "../../../components/mainInput/TipTapEditor"; @@ -182,8 +183,8 @@ const RuleCard: React.FC = ({ rule }) => { ); } - const smallFont = useFontSize(-2); - const tinyFont = useFontSize(-3); + const smallFont = fontSize(-2); + const tinyFont = fontSize(-3); return (
store.config.config); const mode = useAppSelector((store) => store.session.mode); const ideMessenger = useContext(IdeMessengerContext); const isLocal = selectedProfile?.profileType === "local"; + const [globalRulesMode, setGlobalRulesMode] = useState("workspace"); - const handleAddRule = () => { + const handleAddRule = (mode?: string) => { + const currentMode = mode || globalRulesMode; if (isLocal) { - void ideMessenger.request("config/addLocalWorkspaceBlock", { - blockType: "rules", - }); + if (currentMode === "global") { + void ideMessenger.request("config/addGlobalRule", undefined); + } else { + void ideMessenger.request("config/addLocalWorkspaceBlock", { + blockType: "rules", + }); + } } else { void ideMessenger.request("controlPlane/openUrl", { path: "?type=rules", @@ -427,6 +440,11 @@ function RulesSubSection() { } }; + const handleOptionClick = (value: string) => { + setGlobalRulesMode(value); + handleAddRule(value); + }; + const sortedRules: RuleWithSource[] = useMemo(() => { const rules = [...config.rules.map((rule) => ({ ...rule }))]; @@ -469,12 +487,22 @@ function RulesSubSection() { return (
- + {isLocal ? ( + + ) : ( + handleAddRule()} + addButtonTooltip="Add rules" + /> + )} {sortedRules.length > 0 ? (