Repository: cline/cline
Pull request #3291: add ui component for external rules files
Author: login: 0xToshii
Created: 2025-05-04T18:31:58Z | Merged: 2025-05-05T14:49:36Z

### Description

Added UI component for the cursor rules and windsurf rules in the cline rules menu. These are placed in the workspace section and below the Cline rules, and each have an SVG representing the source of the rules.

Based on discussion migration is not good ux, instead treat external rules as normal rules, with new rules being placed in .clinerules.

reference: #3164

### Test Procedure

Test with a directory that has any combination of .clinerules, .cursor/rules, .cursorrules, .windsurfrules and check that all the actions that work with Cline rules also work the same with cursor and windsurf rules.

### Type of Change

<!-- Put an 'x' in all boxes that apply -->

-   [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
-   [x] ✨ New feature (non-breaking change which adds functionality)
-   [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
-   [ ] ♻️ Refactor Changes
-   [ ] 💅 Cosmetic Changes
-   [ ] 📚 Documentation update
-   [ ] 🏃 Workflow Changes

### Pre-flight Checklist

-   [x] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
-   [x] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
-   [x] I have created a changeset using `npm run changeset` (required for user-facing changes)
-   [x] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)

### Screenshots

<img width="296" alt="all rules" src="https://github.com/user-attachments/assets/731efc51-7447-46d0-9351-eceaf8e4ee0e" />

### Additional Notes

<!-- ELLIPSIS_HIDDEN -->

----

> [!IMPORTANT]
> Add UI components and backend logic to manage cursor and windsurf rules alongside existing Cline rules.
> 
>   - **UI Components**:
>     - Added `ClineRulesToggleModal.tsx` to manage cursor and windsurf rules alongside Cline rules.
>     - Updated `RuleRow.tsx` to display icons for cursor and windsurf rules.
>     - Modified `RulesToggleList.tsx` to handle new rule types.
>   - **Backend Logic**:
>     - Updated `deleteRuleFile.ts` to refresh external rules toggles after deletion.
>     - Modified `Controller` class in `index.ts` to handle `toggleCursorRule` and `toggleWindsurfRule` messages.
>     - Updated `ExtensionState` in `ExtensionMessage.ts` to include `localCursorRulesToggles` and `localWindsurfRulesToggles`.
>   - **State Management**:
>     - Updated `ExtensionStateContext.tsx` to manage state for new rule types.
>     - Added handling for `toggleCursorRule` and `toggleWindsurfRule` in `WebviewMessage.ts`.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis" src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=cline%2Fcline&utm_source=github&utm_medium=referral)<sup> for e8f46f4b1b7dfeb690e01f85fee49d0af760c16d. You can [customize](https://app.ellipsis.dev/cline/settings/summaries) this summary. It will automatically update as commits are pushed.</sup>

<!-- ELLIPSIS_HIDDEN -->


Diff:
diff --git a/.changeset/two-jobs-notice.md b/.changeset/two-jobs-notice.md
new file mode 100644
index 00000000000..2399ffbc78f
--- /dev/null
+++ b/.changeset/two-jobs-notice.md
@@ -0,0 +1,5 @@
+---
+"claude-dev": minor
+---
+
+add ui for windsurf and cursor rules
diff --git a/src/core/controller/file/deleteRuleFile.ts b/src/core/controller/file/deleteRuleFile.ts
index cafef0520db..ed060a83a3c 100644
--- a/src/core/controller/file/deleteRuleFile.ts
+++ b/src/core/controller/file/deleteRuleFile.ts
@@ -5,6 +5,7 @@ import {
 	deleteRuleFile as deleteRuleFileImpl,
 	refreshClineRulesToggles,
 } from "@core/context/instructions/user-instructions/cline-rules"
+import { refreshExternalRulesToggles } from "@core/context/instructions/user-instructions/external-rules"
 import * as vscode from "vscode"
 import * as path from "path"
 import { cwd } from "@core/task"
@@ -32,6 +33,7 @@ export const deleteRuleFile: FileMethodHandler = async (controller: Controller,
 	}
 
 	await refreshClineRulesToggles(controller.context, cwd)
+	await refreshExternalRulesToggles(controller.context, cwd)
 	await controller.postStateToWebview()
 
 	const fileName = path.basename(request.rulePath)
diff --git a/src/core/controller/index.ts b/src/core/controller/index.ts
index 05920491ec6..ea04f92d258 100644
--- a/src/core/controller/index.ts
+++ b/src/core/controller/index.ts
@@ -48,7 +48,8 @@ import {
 } from "../storage/state"
 import { Task, cwd } from "../task"
 import { ClineRulesToggles } from "@shared/cline-rules"
-import { createRuleFile, refreshClineRulesToggles } from "../context/instructions/user-instructions/cline-rules"
+import { refreshClineRulesToggles } from "@core/context/instructions/user-instructions/cline-rules"
+import { refreshExternalRulesToggles } from "@core/context/instructions/user-instructions/external-rules"
 
 /*
 https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -375,6 +376,7 @@ export class Controller {
 				break
 			case "refreshClineRules":
 				await refreshClineRulesToggles(this.context, cwd)
+				await refreshExternalRulesToggles(this.context, cwd)
 				await this.postStateToWebview()
 				break
 			case "openInBrowser":
@@ -516,6 +518,32 @@ export class Controller {
 				}
 				break
 			}
+			case "toggleWindsurfRule": {
+				const { rulePath, enabled } = message
+				if (rulePath && typeof enabled === "boolean") {
+					const toggles =
+						((await getWorkspaceState(this.context, "localWindsurfRulesToggles")) as ClineRulesToggles) || {}
+					toggles[rulePath] = enabled
+					await updateWorkspaceState(this.context, "localWindsurfRulesToggles", toggles)
+					await this.postStateToWebview()
+				} else {
+					console.error("toggleWindsurfRule: Missing or invalid parameters")
+				}
+				break
+			}
+			case "toggleCursorRule": {
+				const { rulePath, enabled } = message
+				if (rulePath && typeof enabled === "boolean") {
+					const toggles =
+						((await getWorkspaceState(this.context, "localCursorRulesToggles")) as ClineRulesToggles) || {}
+					toggles[rulePath] = enabled
+					await updateWorkspaceState(this.context, "localCursorRulesToggles", toggles)
+					await this.postStateToWebview()
+				} else {
+					console.error("toggleCursorRule: Missing or invalid parameters")
+				}
+				break
+			}
 			case "requestTotalTasksSize": {
 				this.refreshTotalTasksSize()
 				break
@@ -1755,6 +1783,12 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
 		const localClineRulesToggles =
 			((await getWorkspaceState(this.context, "localClineRulesToggles")) as ClineRulesToggles) || {}
 
+		const localWindsurfRulesToggles =
+			((await getWorkspaceState(this.context, "localWindsurfRulesToggles")) as ClineRulesToggles) || {}
+
+		const localCursorRulesToggles =
+			((await getWorkspaceState(this.context, "localCursorRulesToggles")) as ClineRulesToggles) || {}
+
 		return {
 			version: this.context.extension?.packageJSON?.version ?? "",
 			apiConfiguration,
@@ -1779,6 +1813,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
 			vscMachineId: vscode.env.machineId,
 			globalClineRulesToggles: globalClineRulesToggles || {},
 			localClineRulesToggles: localClineRulesToggles || {},
+			localWindsurfRulesToggles: localWindsurfRulesToggles || {},
+			localCursorRulesToggles: localCursorRulesToggles || {},
 			shellIntegrationTimeout,
 		}
 	}
diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts
index 489d740e120..201eda70cbf 100644
--- a/src/shared/ExtensionMessage.ts
+++ b/src/shared/ExtensionMessage.ts
@@ -142,6 +142,8 @@ export interface ExtensionState {
 	vscMachineId: string
 	globalClineRulesToggles: ClineRulesToggles
 	localClineRulesToggles: ClineRulesToggles
+	localCursorRulesToggles: ClineRulesToggles
+	localWindsurfRulesToggles: ClineRulesToggles
 }
 
 export interface ClineMessage {
diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts
index b1fb4e7c975..4c60a329b4d 100644
--- a/src/shared/WebviewMessage.ts
+++ b/src/shared/WebviewMessage.ts
@@ -67,6 +67,8 @@ export interface WebviewMessage {
 		| "toggleFavoriteModel"
 		| "grpc_request"
 		| "toggleClineRule"
+		| "toggleCursorRule"
+		| "toggleWindsurfRule"
 		| "deleteClineRule"
 		| "copyToClipboard"
 		| "updateTerminalConnectionTimeout"
diff --git a/webview-ui/src/components/cline-rules/ClineRulesToggleModal.tsx b/webview-ui/src/components/cline-rules/ClineRulesToggleModal.tsx
index 6f899276f8e..1760d583d45 100644
--- a/webview-ui/src/components/cline-rules/ClineRulesToggleModal.tsx
+++ b/webview-ui/src/components/cline-rules/ClineRulesToggleModal.tsx
@@ -8,7 +8,12 @@ import RulesToggleList from "./RulesToggleList"
 import Tooltip from "@/components/common/Tooltip"
 
 const ClineRulesToggleModal: React.FC = () => {
-	const { globalClineRulesToggles = {}, localClineRulesToggles = {} } = useExtensionState()
+	const {
+		globalClineRulesToggles = {},
+		localClineRulesToggles = {},
+		localCursorRulesToggles = {},
+		localWindsurfRulesToggles = {},
+	} = useExtensionState()
 	const [isVisible, setIsVisible] = useState(false)
 	const buttonRef = useRef<HTMLDivElement>(null)
 	const modalRef = useRef<HTMLDivElement>(null)
@@ -32,6 +37,14 @@ const ClineRulesToggleModal: React.FC = () => {
 		.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
 		.sort(([a], [b]) => a.localeCompare(b))
 
+	const cursorRules = Object.entries(localCursorRulesToggles || {})
+		.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
+		.sort(([a], [b]) => a.localeCompare(b))
+
+	const windsurfRules = Object.entries(localWindsurfRulesToggles || {})
+		.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
+		.sort(([a], [b]) => a.localeCompare(b))
+
 	// Handle toggle rule
 	const toggleRule = (isGlobal: boolean, rulePath: string, enabled: boolean) => {
 		vscode.postMessage({
@@ -42,6 +55,22 @@ const ClineRulesToggleModal: React.FC = () => {
 		})
 	}
 
+	const toggleCursorRule = (rulePath: string, enabled: boolean) => {
+		vscode.postMessage({
+			type: "toggleCursorRule",
+			rulePath,
+			enabled,
+		})
+	}
+
+	const toggleWindsurfRule = (rulePath: string, enabled: boolean) => {
+		vscode.postMessage({
+			type: "toggleWindsurfRule",
+			rulePath,
+			enabled,
+		})
+	}
+
 	// Close modal when clicking outside
 	useClickAway(modalRef, () => {
 		setIsVisible(false)
@@ -117,6 +146,9 @@ const ClineRulesToggleModal: React.FC = () => {
 							toggleRule={(rulePath, enabled) => toggleRule(true, rulePath, enabled)}
 							listGap="small"
 							isGlobal={true}
+							ruleType={"cline"}
+							showNewRule={true}
+							showNoRules={true}
 						/>
 					</div>
 
@@ -128,6 +160,27 @@ const ClineRulesToggleModal: React.FC = () => {
 							toggleRule={(rulePath, enabled) => toggleRule(false, rulePath, enabled)}
 							listGap="small"
 							isGlobal={false}
+							ruleType={"cline"}
+							showNewRule={false}
+							showNoRules={false}
+						/>
+						<RulesToggleList
+							rules={cursorRules}
+							toggleRule={toggleCursorRule}
+							listGap="small"
+							isGlobal={false}
+							ruleType={"cursor"}
+							showNewRule={false}
+							showNoRules={false}
+						/>
+						<RulesToggleList
+							rules={windsurfRules}
+							toggleRule={toggleWindsurfRule}
+							listGap="small"
+							isGlobal={false}
+							ruleType={"windsurf"}
+							showNewRule={true}
+							showNoRules={localRules.length === 0 && cursorRules.length === 0 && windsurfRules.length === 0}
 						/>
 					</div>
 				</div>
diff --git a/webview-ui/src/components/cline-rules/RuleRow.tsx b/webview-ui/src/components/cline-rules/RuleRow.tsx
index 9834613505c..5f019a777e5 100644
--- a/webview-ui/src/components/cline-rules/RuleRow.tsx
+++ b/webview-ui/src/components/cline-rules/RuleRow.tsx
@@ -6,11 +6,53 @@ const RuleRow: React.FC<{
 	rulePath: string
 	enabled: boolean
 	isGlobal: boolean
+	ruleType: string
 	toggleRule: (rulePath: string, enabled: boolean) => void
-}> = ({ rulePath, enabled, isGlobal, toggleRule }) => {
+}> = ({ rulePath, enabled, isGlobal, toggleRule, ruleType }) => {
 	// Get the filename from the path for display
 	const displayName = rulePath.split("/").pop() || rulePath
 
+	const getRuleTypeIcon = () => {
+		switch (ruleType) {
+			case "cursor":
+				return (
+					<svg
+						xmlns="http://www.w3.org/2000/svg"
+						width="16"
+						height="16"
+						viewBox="0 0 24 24"
+						style={{ verticalAlign: "middle" }}>
+						<g fill="none" stroke="currentColor" strokeWidth="1.2">
+							<path d="M12 4L5 8l7 4 7-4-7-4z" fill="rgba(255,255,255,0.2)" />
+							<path d="M5 8v8l7 4v-8L5 8z" fill="rgba(255,255,255,0.1)" />
+							<path d="M19 8v8l-7 4v-8l7-4z" fill="rgba(255,255,255,0.15)" />
+							<line x1="5" y1="8" x2="12" y2="12" />
+							<line x1="12" y1="12" x2="19" y2="8" />
+							<line x1="12" y1="12" x2="12" y2="20" />
+						</g>
+					</svg>
+				)
+			case "windsurf":
+				return (
+					<svg
+						xmlns="http://www.w3.org/2000/svg"
+						width="16"
+						height="16"
+						viewBox="0 0 24 24"
+						style={{ verticalAlign: "middle" }}>
+						<g fill="currentColor" stroke="currentColor" strokeWidth="1">
+							<path d="M6 18L16 5L14 18H6z" fill="currentColor" />
+							<line x1="14" y1="18" x2="16" y2="5" strokeWidth="1.5" />
+							<path d="M4 19h12c0.5 0 1-0.3 1-1s-0.3-1-1-1H4c-0.5 0-1 0.3-1 1s0.3 1 1 1z" fill="currentColor" />
+							<line x1="14" y1="13" x2="16" y2="9" strokeWidth="1" />
+						</g>
+					</svg>
+				)
+			default:
+				return null
+		}
+	}
+
 	const handleEditClick = () => {
 		FileServiceClient.openFile({ value: rulePath }).catch((err) => console.error("Failed to open file:", err))
 	}
@@ -31,6 +73,7 @@ const RuleRow: React.FC<{
 					enabled ? "opacity-100" : "opacity-60"
 				}`}>
 				<span className="flex-1 overflow-hidden break-all whitespace-normal flex items-center mr-1" title={rulePath}>
+					{getRuleTypeIcon() && <span className="mr-1.5">{getRuleTypeIcon()}</span>}
 					{displayName}
 				</span>
 
diff --git a/webview-ui/src/components/cline-rules/RulesToggleList.tsx b/webview-ui/src/components/cline-rules/RulesToggleList.tsx
index cebe198d5c0..0e9cb2e988d 100644
--- a/webview-ui/src/components/cline-rules/RulesToggleList.tsx
+++ b/webview-ui/src/components/cline-rules/RulesToggleList.tsx
@@ -6,11 +6,17 @@ const RulesToggleList = ({
 	toggleRule,
 	listGap = "medium",
 	isGlobal,
+	ruleType,
+	showNewRule,
+	showNoRules,
 }: {
 	rules: [string, boolean][]
 	toggleRule: (rulePath: string, enabled: boolean) => void
 	listGap?: "small" | "medium" | "large"
 	isGlobal: boolean
+	ruleType: string
+	showNewRule: boolean
+	showNoRules: boolean
 }) => {
 	const gapClasses = {
 		small: "gap-0",
@@ -31,16 +37,19 @@ const RulesToggleList = ({
 							enabled={enabled}
 							isGlobal={isGlobal}
 							toggleRule={toggleRule}
+							ruleType={ruleType}
 						/>
 					))}
-					<NewRuleRow isGlobal={isGlobal} />
+					{showNewRule && <NewRuleRow isGlobal={isGlobal} />}
 				</>
 			) : (
 				<>
-					<div className="flex flex-col items-center gap-3 my-3 text-[var(--vscode-descriptionForeground)]">
-						No rules found
-					</div>
-					<NewRuleRow isGlobal={isGlobal} />
+					{showNoRules && (
+						<div className="flex flex-col items-center gap-3 my-3 text-[var(--vscode-descriptionForeground)]">
+							No rules found
+						</div>
+					)}
+					{showNewRule && <NewRuleRow isGlobal={isGlobal} />}
 				</>
 			)}
 		</div>
diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx
index d2b13a875cb..731f99f56fa 100644
--- a/webview-ui/src/context/ExtensionStateContext.tsx
+++ b/webview-ui/src/context/ExtensionStateContext.tsx
@@ -70,6 +70,8 @@ export const ExtensionStateContextProvider: React.FC<{
 		planActSeparateModelsSetting: true,
 		globalClineRulesToggles: {},
 		localClineRulesToggles: {},
+		localCursorRulesToggles: {},
+		localWindsurfRulesToggles: {},
 		shellIntegrationTimeout: 4000, // default timeout for shell integration
 	})
 	const [didHydrateState, setDidHydrateState] = useState(false)
@@ -219,6 +221,8 @@ export const ExtensionStateContextProvider: React.FC<{
 		mcpTab,
 		globalClineRulesToggles: state.globalClineRulesToggles || {},
 		localClineRulesToggles: state.localClineRulesToggles || {},
+		localCursorRulesToggles: state.localCursorRulesToggles || {},
+		localWindsurfRulesToggles: state.localWindsurfRulesToggles || {},
 		setApiConfiguration: (value) =>
 			setState((prevState) => ({
 				...prevState,


Reviews:
user: login: ellipsis-dev[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T18:34:32Z
user: login: pashpashpash
body: this is fire
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T19:15:52Z
user: login: celestial-vault
body: Looks good!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T14:49:31Z

Review comments:
diff_hunk: @@ -31,6 +73,7 @@ const RuleRow: React.FC<{
 					enabled ? "opacity-100" : "opacity-60"
 				}`}>
 				<span className="flex-1 overflow-hidden break-all whitespace-normal flex items-center mr-1" title={rulePath}>
+					{getRuleTypeIcon() && <span className="mr-1.5">{getRuleTypeIcon()}</span>}
path: webview-ui/src/components/cline-rules/RuleRow.tsx
user: login: ellipsis-dev[bot]
body: Avoid calling `getRuleTypeIcon()` twice. Store its result in a variable to prevent duplicate computation.

created_at: 2025-05-04T18:34:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -516,6 +518,32 @@ export class Controller {
 				}
 				break
 			}
+			case "toggleWindsurfRule": {
path: src/core/controller/index.ts
user: login: ellipsis-dev[bot]
body: The 'toggleWindsurfRule' case duplicates logic similar to other rule toggling. Consider abstracting the common `updateWorkspaceState` logic into a helper to reduce repetition.

created_at: 2025-05-04T18:34:32Z
author_association: CONTRIBUTOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-05-04T18:32:02Z
body: ###  🦋  Changeset detected

Latest commit: e8f46f4b1b7dfeb690e01f85fee49d0af760c16d

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name       | Type  |
| ---------- | ----- |
| claude-dev | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/cline/cline/new/to/external-rules-ui?filename=.changeset/shaggy-pears-chew.md&value=---%0A%22claude-dev%22%3A%20patch%0A---%0A%0Aadd%20ui%20component%20for%20external%20rules%20files%0A)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-04T18:35:01Z
body: <!-- COVERAGE_REPORT -->
## Coverage Report

### Extension Coverage

Base branch: 45%

PR branch: 46%

✅ Coverage increased or remained the same

### Webview Coverage

Base branch: 11%

PR branch: 11%

⚠️ **Warning: Coverage decreased by 0.04%**

Consider adding tests to cover your changes.

### Overall Assessment

⚠️ **Test coverage has decreased in this PR**

Please consider adding tests to maintain or improve coverage.



<sub>Last updated: 2025-05-04T18:35:01.213118</sub>
author_association: CONTRIBUTOR