diff --git a/core/config/markdown/loadCodebaseRules.ts b/core/config/markdown/loadCodebaseRules.ts index a239f56f1c..e0d76bfec4 100644 --- a/core/config/markdown/loadCodebaseRules.ts +++ b/core/config/markdown/loadCodebaseRules.ts @@ -7,6 +7,43 @@ import { walkDirs } from "../../indexing/walkDir"; import { RULES_MARKDOWN_FILENAME } from "../../llm/rules/constants"; import { getUriPathBasename } from "../../util/uri"; +export class CodebaseRulesCache { + private static instance: CodebaseRulesCache | null = null; + private constructor() {} + + public static getInstance(): CodebaseRulesCache { + if (CodebaseRulesCache.instance === null) { + CodebaseRulesCache.instance = new CodebaseRulesCache(); + } + return CodebaseRulesCache.instance; + } + rules: RuleWithSource[] = []; + errors: ConfigValidationError[] = []; + async refresh(ide: IDE) { + const { rules, errors } = await loadCodebaseRules(ide); + this.rules = rules; + this.errors = errors; + } + async update(ide: IDE, uri: string) { + const content = await ide.readFile(uri); + const rule = markdownToRule(content, { uriType: "file", filePath: uri }); + const ruleWithSource: RuleWithSource = { + ...rule, + source: "colocated-markdown", + ruleFile: uri, + }; + const matchIdx = this.rules.findIndex((r) => r.ruleFile === uri); + if (matchIdx === -1) { + this.rules.push(ruleWithSource); + } else { + this.rules[matchIdx] = ruleWithSource; + } + } + remove(uri: string) { + this.rules = this.rules.filter((r) => r.ruleFile !== uri); + } +} + /** * Loads rules from rules.md files colocated in the codebase */ @@ -33,7 +70,11 @@ export async function loadCodebaseRules(ide: IDE): Promise<{ const content = await ide.readFile(filePath); const rule = markdownToRule(content, { uriType: "file", filePath }); - rules.push({ ...rule, source: "rules-block", ruleFile: filePath }); + rules.push({ + ...rule, + source: "colocated-markdown", + ruleFile: filePath, + }); } catch (e) { errors.push({ fatal: false, diff --git a/core/config/markdown/loadCodebaseRules.vitest.ts b/core/config/markdown/loadCodebaseRules.vitest.ts index 9a77f692b4..f9c3972c87 100644 --- a/core/config/markdown/loadCodebaseRules.vitest.ts +++ b/core/config/markdown/loadCodebaseRules.vitest.ts @@ -45,27 +45,27 @@ describe("loadCodebaseRules", () => { "src/rules.md": { name: "General Rules", rule: "Follow coding standards", - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/rules.md", }, "src/redux/rules.md": { name: "Redux Rules", rule: "Use Redux Toolkit", globs: "**/*.{ts,tsx}", - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/redux/rules.md", }, "src/components/rules.md": { name: "Component Rules", rule: "Use functional components", globs: ["**/*.tsx", "**/*.jsx"], - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/components/rules.md", }, ".continue/rules.md": { name: "Global Rules", rule: "Follow project guidelines", - source: "rules-block", + source: "colocated-markdown", ruleFile: ".continue/rules.md", }, }; diff --git a/core/config/markdown/ruleCollocationApplication.vitest.ts b/core/config/markdown/ruleCollocationApplication.vitest.ts index 35210870df..120e2d6a17 100644 --- a/core/config/markdown/ruleCollocationApplication.vitest.ts +++ b/core/config/markdown/ruleCollocationApplication.vitest.ts @@ -16,7 +16,7 @@ describe("Rule Colocation Application", () => { { name: "Root Rule", rule: "Follow project standards", - source: "rules-block", + source: "colocated-markdown", ruleFile: ".continue/rules.md", }, @@ -24,7 +24,7 @@ describe("Rule Colocation Application", () => { { name: "React Components Rule", rule: "Use functional components with hooks", - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/components/rules.md", // No explicit globs - should implicitly only apply to files in that directory }, @@ -34,7 +34,7 @@ describe("Rule Colocation Application", () => { name: "Redux Rule", rule: "Use Redux Toolkit for state management", globs: "src/redux/**/*.{ts,tsx}", - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/redux/rules.md", }, @@ -43,7 +43,7 @@ describe("Rule Colocation Application", () => { name: "TypeScript Components Rule", rule: "Use TypeScript with React components", globs: "**/*.tsx", // Only apply to .tsx files - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/components/rules.md", }, @@ -52,7 +52,7 @@ describe("Rule Colocation Application", () => { name: "API Utils Rule", rule: "Follow API utility conventions", globs: "**/*.ts", // Only TypeScript files in this directory - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/utils/api/rules.md", }, ]; @@ -183,7 +183,7 @@ describe("Rule Colocation Application", () => { const impliedComponentRule: RuleWithSource = { name: "Implied Components Rule", rule: "Use React component best practices", - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/components/rules.md", // No explicit globs - should infer from directory }; @@ -233,7 +233,7 @@ describe("Rule Colocation Application", () => { name: "TypeScript Component Rule", rule: "Use TypeScript with React components", globs: "**/*.tsx", // Only apply to .tsx files - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/components/rules.md", }; @@ -280,7 +280,7 @@ describe("Rule Colocation Application", () => { name: "API Utils Rule", rule: "Follow API utility conventions", globs: "**/*.ts", // Only TypeScript files in this directory - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/utils/api/rules.md", }; @@ -323,7 +323,7 @@ describe("Rule Colocation Application", () => { return { name: `Inferred Rule for ${directory}`, rule: `Follow standards for ${directory}`, - source: "rules-block", + source: "colocated-markdown", ruleFile: ruleFilePath, // In a fixed implementation, these globs would be automatically inferred // globs: expectedGlob, diff --git a/core/config/profile/doLoadConfig.ts b/core/config/profile/doLoadConfig.ts index 1a5d27ab95..858085bf93 100644 --- a/core/config/profile/doLoadConfig.ts +++ b/core/config/profile/doLoadConfig.ts @@ -37,7 +37,7 @@ import { Telemetry } from "../../util/posthog"; import { TTS } from "../../util/tts"; import { getWorkspaceContinueRuleDotFiles } from "../getWorkspaceContinueRuleDotFiles"; import { loadContinueConfigFromJson } from "../load"; -import { loadCodebaseRules } from "../markdown/loadCodebaseRules"; +import { CodebaseRulesCache } from "../markdown/loadCodebaseRules"; import { loadMarkdownRules } from "../markdown/loadMarkdownRules"; import { migrateJsonSharedConfig } from "../migrateSharedConfig"; import { rectifySelectedModelsFromGlobalContext } from "../selectedModels"; @@ -165,10 +165,9 @@ export default async function doLoadConfig(options: { } // Add rules from colocated rules.md files in the codebase - const { rules: codebaseRules, errors: codebaseRulesErrors } = - await loadCodebaseRules(ide); - newConfig.rules.unshift(...codebaseRules); - errors.push(...codebaseRulesErrors); + const codebaseRulesCache = CodebaseRulesCache.getInstance(); + newConfig.rules.unshift(...codebaseRulesCache.rules); + errors.push(...codebaseRulesCache.errors); // Rectify model selections for each role newConfig = rectifySelectedModelsFromGlobalContext(newConfig, profileId); diff --git a/core/core.ts b/core/core.ts index c1dff0e487..9a34da3bb0 100644 --- a/core/core.ts +++ b/core/core.ts @@ -52,6 +52,7 @@ import { BLOCK_TYPES, ConfigYaml } from "@continuedev/config-yaml"; import { getDiffFn, GitDiffCache } from "./autocomplete/snippets/gitDiffCache"; import { stringifyMcpPrompt } from "./commands/slash/mcpSlashCommand"; import { isLocalDefinitionFile } from "./config/loadLocalAssistants"; +import { CodebaseRulesCache } from "./config/markdown/loadCodebaseRules"; import { setupLocalConfig, setupProviderConfig, @@ -69,11 +70,11 @@ import { RULES_MARKDOWN_FILENAME } from "./llm/rules/constants"; import { llmStreamChat } from "./llm/streamChat"; import { BeforeAfterDiff } from "./nextEdit/context/diffFormatting"; import { processSmallEdit } from "./nextEdit/context/processSmallEdit"; +import { NextEditProvider } from "./nextEdit/NextEditProvider"; import type { FromCoreProtocol, ToCoreProtocol } from "./protocol"; import { OnboardingModes } from "./protocol/core"; import type { IMessenger, Message } from "./protocol/messenger"; import { getUriPathBasename } from "./util/uri"; -import { NextEditProvider } from "./nextEdit/NextEditProvider"; const hasRulesFiles = (uris: string[]): boolean => { for (const uri of uris) { @@ -235,6 +236,15 @@ export class Core { (..._) => Promise.resolve([]), ); + const codebaseRulesCache = CodebaseRulesCache.getInstance(); + void codebaseRulesCache + .refresh(ide) + .catch((e) => console.error("Failed to initialize colocated rules cache")) + .then(() => { + void this.configHandler.reloadConfig( + "Initial codebase rules post-walkdir/load reload", + ); + }); this.nextEditProvider = NextEditProvider.initialize( this.configHandler, ide, @@ -342,10 +352,12 @@ export class Core { }); on("config/reload", async (msg) => { + // User force reloading will retrigger colocated rules + const codebaseRulesCache = CodebaseRulesCache.getInstance(); + await codebaseRulesCache.refresh(this.ide); void this.configHandler.reloadConfig( "Force reloaded (config/reload message)", ); - return await this.configHandler.getSerializedConfig(); }); on("config/ideSettingsUpdate", async (msg) => { @@ -682,9 +694,12 @@ export class Core { void refreshIfNotIgnored(data.uris); if (hasRulesFiles(data.uris)) { + const rulesCache = CodebaseRulesCache.getInstance(); + await Promise.all( + data.uris.map((uri) => rulesCache.update(this.ide, uri)), + ); await this.configHandler.reloadConfig("Rules file created"); } - // If it's a local assistant being created, we want to reload all assistants so it shows up in the list let localAssistantCreated = false; for (const uri of data.uris) { @@ -704,7 +719,9 @@ export class Core { void refreshIfNotIgnored(data.uris); if (hasRulesFiles(data.uris)) { - await this.configHandler.reloadConfig("Rules file deleted"); + const rulesCache = CodebaseRulesCache.getInstance(); + data.uris.forEach((uri) => rulesCache.remove(uri)); + await this.configHandler.reloadConfig("Codebase rule file deleted"); } } }); @@ -1020,17 +1037,24 @@ export class Core { uri.endsWith(".continuerc.json") || uri.endsWith(".prompt") || uri.endsWith(SYSTEM_PROMPT_DOT_FILE) || - (uri.includes(".continue") && uri.endsWith(".yaml")) || - uri.endsWith(RULES_MARKDOWN_FILENAME) || - BLOCK_TYPES.some( - (blockType) => - uri.includes(`.continue/${blockType}`) || - uri.includes(`.continue\\${blockType}`), + (uri.includes(".continue") && + (uri.endsWith(".yaml") || uri.endsWith("yml"))) || + BLOCK_TYPES.some((blockType) => + uri.includes(`.continue/${blockType}`), ) ) { await this.configHandler.reloadConfig( "Config-related file updated: continuerc, prompt, local block, etc", ); + } else if (uri.endsWith(RULES_MARKDOWN_FILENAME)) { + try { + const codebaseRulesCache = CodebaseRulesCache.getInstance(); + void codebaseRulesCache.update(this.ide, uri).then(() => { + void this.configHandler.reloadConfig("Codebase rule update"); + }); + } catch (e) { + console.error("Failed to update codebase rule", e); + } } else if ( uri.endsWith(".continueignore") || uri.endsWith(".gitignore") diff --git a/core/index.d.ts b/core/index.d.ts index a32e5532f9..46f172b060 100644 --- a/core/index.d.ts +++ b/core/index.d.ts @@ -1663,6 +1663,7 @@ export type RuleSource = | "model-options-plan" | "model-options-agent" | "rules-block" + | "colocated-markdown" | "json-systemMessage" | ".continuerules"; diff --git a/core/llm/rules/ruleColocation.vitest.ts b/core/llm/rules/ruleColocation.vitest.ts index 4b5530a8ee..6bee635b0f 100644 --- a/core/llm/rules/ruleColocation.vitest.ts +++ b/core/llm/rules/ruleColocation.vitest.ts @@ -9,7 +9,7 @@ describe("Rule colocation - glob pattern matching", () => { generalRule: { name: "General Rule", rule: "Follow coding standards", - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/rules.md", }, @@ -18,7 +18,7 @@ describe("Rule colocation - glob pattern matching", () => { name: "Redux Rule", rule: "Use Redux Toolkit", globs: "src/redux/**/*.{ts,tsx}", - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/redux/rules.md", }, @@ -27,7 +27,7 @@ describe("Rule colocation - glob pattern matching", () => { name: "Component Rule", rule: "Use functional components", globs: ["src/components/**/*.tsx", "src/components/**/*.jsx"], - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/components/rules.md", }, @@ -37,7 +37,7 @@ describe("Rule colocation - glob pattern matching", () => { rule: "Follow these guidelines always", alwaysApply: true, globs: "src/specific/**/*.ts", // Should be ignored since alwaysApply is true - source: "rules-block", + source: "colocated-markdown", ruleFile: ".continue/rules.md", }, @@ -47,7 +47,7 @@ describe("Rule colocation - glob pattern matching", () => { rule: "This rule should only apply to matching files", alwaysApply: false, // No globs, so should never apply - source: "rules-block", + source: "colocated-markdown", ruleFile: ".continue/rules.md", }, @@ -57,7 +57,7 @@ describe("Rule colocation - glob pattern matching", () => { rule: "Apply only to matching files", alwaysApply: false, globs: "src/utils/**/*.ts", - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/utils/rules.md", }, }; @@ -127,7 +127,7 @@ describe("Rule colocation - glob pattern matching", () => { rule: "Follow nested module standards", // Fix: Specify the exact path prefix to restrict to this directory structure globs: "src/features/auth/utils/**/*.ts", - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/features/auth/utils/rules.md", }; @@ -157,7 +157,7 @@ describe("Rule colocation - glob pattern matching", () => { // Note: Negative globs may not be supported by the current implementation // Testing with standard pattern instead globs: "src/**/[!.]*.ts", - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/rules.md", }; @@ -174,7 +174,7 @@ describe("Rule colocation - glob pattern matching", () => { // Use a pattern that doesn't match test files globs: ["src/**/*.ts", "!src/**/*.test.ts", "!src/**/*.spec.ts"], alwaysApply: false, - source: "rules-block", + source: "colocated-markdown", ruleFile: "src/rules.md", }; diff --git a/core/protocol/core.ts b/core/protocol/core.ts index 03f4d6b2a1..44f0d0a5df 100644 --- a/core/protocol/core.ts +++ b/core/protocol/core.ts @@ -91,7 +91,7 @@ export type ToCoreFromIdeOrWebviewProtocol = { }, ]; "config/deleteModel": [{ title: string }, void]; - "config/reload": [undefined, ConfigResult]; + "config/reload": [undefined, void]; "config/refreshProfiles": [ ( | undefined diff --git a/gui/src/components/mainInput/belowMainInput/RulesPeek.tsx b/gui/src/components/mainInput/belowMainInput/RulesPeek.tsx index c9c60acc75..c887e9d9e3 100644 --- a/gui/src/components/mainInput/belowMainInput/RulesPeek.tsx +++ b/gui/src/components/mainInput/belowMainInput/RulesPeek.tsx @@ -1,5 +1,6 @@ import { DocumentTextIcon, GlobeAltIcon } from "@heroicons/react/24/outline"; -import { RuleSource, RuleWithSource } from "core"; +import { RuleWithSource } from "core"; +import { getLastNPathParts } from "core/util/uri"; import { ComponentType, useMemo, useState } from "react"; import ToggleDiv from "../../ToggleDiv"; @@ -13,8 +14,8 @@ interface RulesPeekItemProps { } // Convert technical source to user-friendly text -const getSourceLabel = (source: RuleSource): string => { - switch (source) { +const getSourceLabel = (rule: RuleWithSource): string => { + switch (rule.source) { case "default-chat": return "Default Chat"; case "default-agent": @@ -27,12 +28,18 @@ const getSourceLabel = (source: RuleSource): string => { return "Model Agent Options"; case "rules-block": return "Rules Block"; + case "colocated-markdown": + if (rule.ruleFile) { + return getLastNPathParts(rule.ruleFile, 2); + } else { + return "rules.md"; + } case "json-systemMessage": return "System Message"; case ".continuerules": return "Project Rules"; default: - return source; + return rule.source; } }; @@ -95,7 +102,7 @@ export function RulesPeekItem({ rule }: RulesPeekItemProps) { )}
- Source: {getSourceLabel(rule.source)} + Source: {getSourceLabel(rule)}
);