mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db42bd2cff | |||
| 744030be16 | |||
| 8e1f6dfa41 |
@@ -149,6 +149,7 @@ import { HelpPanelContent } from "./HelpPanelContent"
|
||||
import { HighlightedInput } from "./HighlightedInput"
|
||||
import { HistoryPanelContent } from "./HistoryPanelContent"
|
||||
import { providerModels } from "./ModelPicker"
|
||||
import { RulesPanelContent } from "./RulesPanelContent"
|
||||
import { SettingsPanelContent } from "./SettingsPanelContent"
|
||||
import { SkillsPanelContent } from "./SkillsPanelContent"
|
||||
import { SlashCommandMenu } from "./SlashCommandMenu"
|
||||
@@ -413,6 +414,7 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
| { type: "settings"; initialMode?: "model-picker" | "featured-models"; initialModelKey?: "actModelId" | "planModelId" }
|
||||
| { type: "history" }
|
||||
| { type: "help" }
|
||||
| { type: "rules" }
|
||||
| { type: "skills" }
|
||||
| null
|
||||
>(null)
|
||||
@@ -1158,6 +1160,14 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "rules") {
|
||||
setActivePanel({ type: "rules" })
|
||||
setTextInput("")
|
||||
setCursorPos(0)
|
||||
setSelectedSlashIndex(0)
|
||||
setSlashMenuDismissed(true)
|
||||
return
|
||||
}
|
||||
if (cmd.name === "skills") {
|
||||
setActivePanel({ type: "skills" })
|
||||
setTextInput("")
|
||||
@@ -1555,6 +1565,11 @@ export const ChatView: React.FC<ChatViewProps> = ({
|
||||
{/* Help panel */}
|
||||
{activePanel?.type === "help" && <HelpPanelContent onClose={() => setActivePanel(null)} />}
|
||||
|
||||
{/* Rules panel */}
|
||||
{activePanel?.type === "rules" && ctrl && (
|
||||
<RulesPanelContent controller={ctrl} onClose={() => setActivePanel(null)} />
|
||||
)}
|
||||
|
||||
{/* Skills panel */}
|
||||
{activePanel?.type === "skills" && ctrl && (
|
||||
<SkillsPanelContent
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Text } from "ink"
|
||||
import { render } from "ink-testing-library"
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("../context/TaskContext", () => ({
|
||||
useTaskState: () => ({
|
||||
clineMessages: [],
|
||||
mode: "act",
|
||||
}),
|
||||
useTaskContext: () => ({
|
||||
controller: null,
|
||||
clearState: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("../hooks/useStateSubscriber", () => ({
|
||||
useIsSpinnerActive: () => ({ isActive: false, startTime: null }),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({
|
||||
getAvailableSlashCommands: vi.fn().mockResolvedValue({ commands: [] }),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/controller/task/showTaskWithId", () => ({
|
||||
showTaskWithId: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/storage/StateManager", () => ({
|
||||
StateManager: {
|
||||
get: () => ({
|
||||
getGlobalSettingsKey: vi.fn((key: string) => {
|
||||
if (key === "mode") return "act"
|
||||
if (key === "yoloModeToggled") return false
|
||||
if (key === "actModeApiModelId") return "claude-sonnet-4-20250514"
|
||||
return null
|
||||
}),
|
||||
getGlobalStateKey: vi.fn().mockReturnValue([]),
|
||||
getApiConfiguration: vi.fn().mockReturnValue({}),
|
||||
setGlobalState: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/services/telemetry", () => ({
|
||||
telemetryService: {
|
||||
captureHostEvent: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@shared/services/Session", () => ({
|
||||
Session: {
|
||||
get: () => ({
|
||||
getStats: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
execSync: vi.fn().mockReturnValue(""),
|
||||
exec: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("./ActionButtons", () => ({
|
||||
ActionButtons: () => React.createElement(Text, null, "ActionButtons"),
|
||||
getButtonConfig: vi.fn(() => ({ enableButtons: false })),
|
||||
}))
|
||||
|
||||
vi.mock("./AsciiMotionCli", () => ({
|
||||
AsciiMotionCli: () => React.createElement(Text, null, "AsciiMotion"),
|
||||
StaticRobotFrame: () => React.createElement(Text, null, "StaticRobot"),
|
||||
}))
|
||||
|
||||
vi.mock("./ChatMessage", () => ({
|
||||
ChatMessage: ({ message }: { message?: { ts?: number } }) => React.createElement(Text, null, `Message: ${message?.ts}`),
|
||||
}))
|
||||
|
||||
vi.mock("./FileMentionMenu", () => ({
|
||||
FileMentionMenu: () => React.createElement(Text, null, "FileMentionMenu"),
|
||||
}))
|
||||
|
||||
vi.mock("./HighlightedInput", () => ({
|
||||
HighlightedInput: ({ text }: { text?: string }) => React.createElement(Text, null, `Input: ${text ?? ""}`),
|
||||
}))
|
||||
|
||||
vi.mock("./HistoryPanelContent", () => ({
|
||||
HistoryPanelContent: () => React.createElement(Text, null, "HistoryPanel"),
|
||||
}))
|
||||
|
||||
vi.mock("./RulesPanelContent", () => ({
|
||||
RulesPanelContent: () => React.createElement(Text, null, "RulesPanel"),
|
||||
}))
|
||||
|
||||
vi.mock("./SettingsPanelContent", () => ({
|
||||
SettingsPanelContent: () => React.createElement(Text, null, "SettingsPanel"),
|
||||
}))
|
||||
|
||||
vi.mock("./SkillsPanelContent", () => ({
|
||||
SkillsPanelContent: () => React.createElement(Text, null, "SkillsPanel"),
|
||||
}))
|
||||
|
||||
vi.mock("./SlashCommandMenu", () => ({
|
||||
SlashCommandMenu: () => React.createElement(Text, null, "SlashMenu"),
|
||||
}))
|
||||
|
||||
vi.mock("./ThinkingIndicator", () => ({
|
||||
ThinkingIndicator: () => React.createElement(Text, null, "ThinkingIndicator"),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/file-search", () => ({
|
||||
checkAndWarnRipgrepMissing: vi.fn(() => false),
|
||||
extractMentionQuery: vi.fn(() => ({ inMentionMode: false, query: "", atIndex: -1 })),
|
||||
getRipgrepInstallInstructions: vi.fn(() => "brew install ripgrep"),
|
||||
insertMention: vi.fn((text: string) => text),
|
||||
searchWorkspaceFiles: vi.fn(async () => []),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/input", () => ({
|
||||
isMouseEscapeSequence: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/parser", () => ({
|
||||
jsonParseSafe: vi.fn((_text: string, defaultValue: unknown) => defaultValue),
|
||||
parseImagesFromInput: vi.fn((text: string) => ({ prompt: text, imagePaths: [] })),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/tools", () => ({
|
||||
isFileEditTool: vi.fn(() => false),
|
||||
parseToolFromMessage: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/display", () => ({
|
||||
setTerminalTitle: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../utils/cursor", () => ({
|
||||
moveCursorUp: vi.fn((_text: string, pos: number) => pos),
|
||||
moveCursorDown: vi.fn((_text: string, pos: number) => pos),
|
||||
}))
|
||||
|
||||
vi.mock("@shared/combineCommandSequences", () => ({
|
||||
combineCommandSequences: vi.fn((messages: unknown[]) => messages),
|
||||
}))
|
||||
|
||||
vi.mock("@shared/getApiMetrics", () => ({
|
||||
getApiMetrics: vi.fn(() => ({
|
||||
totalTokensIn: 0,
|
||||
totalTokensOut: 0,
|
||||
totalCost: 0,
|
||||
})),
|
||||
getLastApiReqTotalTokens: vi.fn(() => 0),
|
||||
}))
|
||||
|
||||
import { ChatView } from "./ChatView"
|
||||
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const ControllerHarness = ({ dropAfterMs }: { dropAfterMs: number }) => {
|
||||
const [controller, setController] = React.useState<any>({})
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => setController(undefined), dropAfterMs)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [dropAfterMs])
|
||||
|
||||
return <ChatView controller={controller} />
|
||||
}
|
||||
|
||||
describe("Rules command controller guards", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("does not open the rules panel after the controller disappears", async () => {
|
||||
const { stdin, lastFrame } = render(<ControllerHarness dropAfterMs={120} />)
|
||||
await delay(240)
|
||||
|
||||
stdin.write("/rules")
|
||||
await delay()
|
||||
|
||||
stdin.write("\r")
|
||||
await delay()
|
||||
|
||||
const frame = lastFrame() || ""
|
||||
expect(frame).not.toContain("RulesPanel")
|
||||
expect(frame).toContain("Input:")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render } from "ink-testing-library"
|
||||
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX
|
||||
import React from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mockRefreshRules = vi.fn()
|
||||
vi.mock("@/core/controller/file/refreshRules", () => ({
|
||||
refreshRules: (...args: unknown[]) => mockRefreshRules(...args),
|
||||
}))
|
||||
|
||||
vi.mock("@/core/controller/file/toggleClineRule", () => ({
|
||||
toggleClineRule: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@shared/proto/cline/file", () => ({
|
||||
RuleScope: { GLOBAL: 0, LOCAL: 1 },
|
||||
}))
|
||||
|
||||
vi.mock("../context/StdinContext", () => ({
|
||||
useStdinContext: () => ({ isRawModeSupported: true }),
|
||||
}))
|
||||
|
||||
import { RulesPanelContent } from "./RulesPanelContent"
|
||||
|
||||
const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe("RulesPanelContent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("renders the basename for Windows rule paths", async () => {
|
||||
mockRefreshRules.mockResolvedValue({
|
||||
globalClineRulesToggles: {
|
||||
toggles: {
|
||||
"C:\\workspace\\.clinerules\\rules\\architecture.md": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { lastFrame } = render(<RulesPanelContent controller={{} as any} onClose={vi.fn()} />)
|
||||
await delay()
|
||||
|
||||
const frame = lastFrame() || ""
|
||||
expect(frame).toContain("architecture.md")
|
||||
expect(frame).not.toContain("C:\\workspace")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Rules panel content for inline display in ChatView
|
||||
* Shows all rule types (Cline, Cursor, Windsurf, Agents) with toggle functionality
|
||||
*/
|
||||
|
||||
import { RuleScope } from "@shared/proto/cline/file"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import React, { useCallback, useEffect, useState } from "react"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { refreshRules } from "@/core/controller/file/refreshRules"
|
||||
import { toggleAgentsRule } from "@/core/controller/file/toggleAgentsRule"
|
||||
import { toggleClineRule } from "@/core/controller/file/toggleClineRule"
|
||||
import { toggleCursorRule } from "@/core/controller/file/toggleCursorRule"
|
||||
import { toggleWindsurfRule } from "@/core/controller/file/toggleWindsurfRule"
|
||||
import { useStdinContext } from "../context/StdinContext"
|
||||
import { isMouseEscapeSequence } from "../utils/input"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
interface RuleEntry {
|
||||
name: string
|
||||
path: string
|
||||
enabled: boolean
|
||||
isGlobal: boolean
|
||||
ruleType: "cline" | "cursor" | "windsurf" | "agents"
|
||||
}
|
||||
|
||||
interface RulesPanelContentProps {
|
||||
controller: Controller
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 10
|
||||
const PATH_SEPARATOR_REGEX = /[\\/]/
|
||||
|
||||
function buildRuleEntries(toggles: Record<string, boolean>, isGlobal: boolean, ruleType: RuleEntry["ruleType"]): RuleEntry[] {
|
||||
return Object.entries(toggles).map(([path, enabled]) => ({
|
||||
name: path.split(PATH_SEPARATOR_REGEX).at(-1) || path,
|
||||
path,
|
||||
enabled,
|
||||
isGlobal,
|
||||
ruleType,
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeRefreshedRules(data: Awaited<ReturnType<typeof refreshRules>>): RuleEntry[] {
|
||||
return [
|
||||
...buildRuleEntries(data.globalClineRulesToggles?.toggles ?? {}, true, "cline"),
|
||||
...buildRuleEntries(data.localClineRulesToggles?.toggles ?? {}, false, "cline"),
|
||||
...buildRuleEntries(data.localCursorRulesToggles?.toggles ?? {}, false, "cursor"),
|
||||
...buildRuleEntries(data.localWindsurfRulesToggles?.toggles ?? {}, false, "windsurf"),
|
||||
...buildRuleEntries(data.localAgentsRulesToggles?.toggles ?? {}, false, "agents"),
|
||||
]
|
||||
}
|
||||
|
||||
export const RulesPanelContent: React.FC<RulesPanelContentProps> = ({ controller, onClose }) => {
|
||||
const { isRawModeSupported } = useStdinContext()
|
||||
const [entries, setEntries] = useState<RuleEntry[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
try {
|
||||
const data = await refreshRules(controller, {})
|
||||
setEntries(normalizeRefreshedRules(data))
|
||||
setLoadError(null)
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
// Load rules on mount
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
await loadRules()
|
||||
setIsLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [loadRules])
|
||||
|
||||
// Subscribe to external state changes so that toggles made in VSCode are
|
||||
// reflected here immediately without requiring any user action.
|
||||
useEffect(() => {
|
||||
return controller.subscribeToExternalStateChange(() => {
|
||||
loadRules()
|
||||
})
|
||||
}, [controller, loadRules])
|
||||
|
||||
// Handle toggle — calls the appropriate backend function then re-fetches
|
||||
const handleToggle = useCallback(async () => {
|
||||
const entry = entries[selectedIndex]
|
||||
if (!entry || isToggling) return
|
||||
|
||||
setIsToggling(true)
|
||||
try {
|
||||
const newEnabled = !entry.enabled
|
||||
|
||||
switch (entry.ruleType) {
|
||||
case "cline": {
|
||||
const scope = entry.isGlobal ? RuleScope.GLOBAL : RuleScope.LOCAL
|
||||
await toggleClineRule(controller, {
|
||||
metadata: undefined,
|
||||
rulePath: entry.path,
|
||||
enabled: newEnabled,
|
||||
scope,
|
||||
})
|
||||
break
|
||||
}
|
||||
case "cursor":
|
||||
await toggleCursorRule(controller, { metadata: undefined, rulePath: entry.path, enabled: newEnabled })
|
||||
break
|
||||
case "windsurf":
|
||||
await toggleWindsurfRule(controller, { metadata: undefined, rulePath: entry.path, enabled: newEnabled })
|
||||
break
|
||||
case "agents":
|
||||
await toggleAgentsRule(controller, { metadata: undefined, rulePath: entry.path, enabled: newEnabled })
|
||||
break
|
||||
}
|
||||
|
||||
await loadRules()
|
||||
} finally {
|
||||
setIsToggling(false)
|
||||
}
|
||||
}, [controller, entries, selectedIndex, isToggling, loadRules])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (isMouseEscapeSequence(input)) return
|
||||
if (key.escape) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
// Navigation
|
||||
if (key.upArrow || input === "k") {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : entries.length - 1))
|
||||
return
|
||||
}
|
||||
if (key.downArrow || input === "j") {
|
||||
setSelectedIndex((i) => (i < entries.length - 1 ? i + 1 : 0))
|
||||
return
|
||||
}
|
||||
|
||||
// Toggle
|
||||
if (input === " " || key.return) {
|
||||
handleToggle()
|
||||
return
|
||||
}
|
||||
},
|
||||
{ isActive: isRawModeSupported },
|
||||
)
|
||||
|
||||
// Scrolling window
|
||||
const halfVisible = Math.floor(MAX_VISIBLE / 2)
|
||||
const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, entries.length - MAX_VISIBLE))
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Panel label="Rules">
|
||||
<Text color="gray">Loading rules...</Text>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Panel label="Rules">
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{loadError ? (
|
||||
<Text color="red">Error loading rules: {loadError}</Text>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Text color="gray">No rules found.</Text>
|
||||
<Text>
|
||||
Create a <Text color="white">.clinerules</Text> file or directory in your project root to add
|
||||
rules.
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Box>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
// Determine section headers
|
||||
const getSectionLabel = (entry: RuleEntry): string => {
|
||||
if (entry.ruleType === "cline" && entry.isGlobal) return "Global Cline Rules"
|
||||
if (entry.ruleType === "cline" && !entry.isGlobal) return "Workspace Cline Rules"
|
||||
if (entry.ruleType === "cursor") return "Cursor Rules"
|
||||
if (entry.ruleType === "windsurf") return "Windsurf Rules"
|
||||
if (entry.ruleType === "agents") return "Agents Rules"
|
||||
return ""
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel label="Rules">
|
||||
<Box flexDirection="column">
|
||||
{entries.slice(startIndex, startIndex + MAX_VISIBLE).map((entry, idx) => {
|
||||
const actualIndex = startIndex + idx
|
||||
const prevEntry = entries[actualIndex - 1]
|
||||
const showHeader = actualIndex === 0 || (prevEntry && getSectionLabel(prevEntry) !== getSectionLabel(entry))
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${entry.ruleType}-${entry.path}`}>
|
||||
{showHeader && (
|
||||
<Box marginTop={actualIndex > 0 ? 1 : 0}>
|
||||
<Text bold color="gray">
|
||||
{getSectionLabel(entry)}:
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<RuleRow
|
||||
entry={entry}
|
||||
isSelected={actualIndex === selectedIndex}
|
||||
isToggling={actualIndex === selectedIndex && isToggling}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">↑/↓ Navigate • Space/Enter Toggle • Esc Close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const RuleRow: React.FC<{ entry: RuleEntry; isSelected: boolean; isToggling: boolean }> = ({ entry, isSelected, isToggling }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isSelected ? "cyan" : undefined}>
|
||||
{isSelected ? "❯ " : " "}
|
||||
{isToggling ? (
|
||||
<Text color="yellow">◌</Text>
|
||||
) : (
|
||||
<Text color={entry.enabled ? "green" : "red"}>{entry.enabled ? "●" : "○"}</Text>
|
||||
)}
|
||||
<Text> </Text>
|
||||
<Text bold={isSelected} color="white">
|
||||
{entry.name}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,9 @@ export async function toggleAgentsRule(controller: Controller, request: ToggleAg
|
||||
toggles[rulePath] = enabled
|
||||
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", toggles)
|
||||
|
||||
// Flush immediately so cross-process file watchers detect the change right away.
|
||||
await controller.stateManager.flushPendingState()
|
||||
|
||||
// Get the current state to return in the response
|
||||
const agentsToggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ export async function toggleClineRule(controller: Controller, request: ToggleCli
|
||||
telemetryService.captureClineRuleToggled(controller.task.ulid, ruleFileName, enabled, isGlobal)
|
||||
}
|
||||
|
||||
// Flush immediately so cross-process file watchers detect the change right away,
|
||||
// rather than waiting for the 500ms debounce timer.
|
||||
await controller.stateManager.flushPendingState()
|
||||
|
||||
// Get the current state to return in the response
|
||||
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
|
||||
const localToggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
|
||||
|
||||
@@ -25,6 +25,9 @@ export async function toggleCursorRule(controller: Controller, request: ToggleCu
|
||||
toggles[rulePath] = enabled
|
||||
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
|
||||
|
||||
// Flush immediately so cross-process file watchers detect the change right away.
|
||||
await controller.stateManager.flushPendingState()
|
||||
|
||||
// Get the current state to return in the response
|
||||
const cursorToggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ export async function toggleWindsurfRule(controller: Controller, request: Toggle
|
||||
toggles[rulePath] = enabled
|
||||
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", toggles)
|
||||
|
||||
// Flush immediately so cross-process file watchers detect the change right away.
|
||||
await controller.stateManager.flushPendingState()
|
||||
|
||||
// Return the toggles directly
|
||||
return ClineRulesToggles.create({ toggles: toggles })
|
||||
}
|
||||
|
||||
@@ -75,6 +75,9 @@ export class Controller {
|
||||
ocaAuthService: OcaAuthService
|
||||
readonly stateManager: StateManager
|
||||
|
||||
// Listeners notified when external state changes are detected (used by CLI Ink components)
|
||||
private externalStateListeners: Set<() => void> = new Set()
|
||||
|
||||
// NEW: Add workspace manager (optional initially)
|
||||
private workspaceManager?: WorkspaceRootManager
|
||||
private backgroundCommandRunning = false
|
||||
@@ -129,7 +132,21 @@ export class Controller {
|
||||
Logger.error("[Controller] Storage persistence failed (will retry):", error)
|
||||
},
|
||||
onSyncExternalChange: async () => {
|
||||
await this.postStateToWebview()
|
||||
// Notify CLI Ink components first — these are lightweight sync callbacks
|
||||
// that must fire even if postStateToWebview fails (e.g. in CLI where
|
||||
// getStateToPostToWebview may throw due to missing browser/OAuth modules).
|
||||
for (const listener of this.externalStateListeners) {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
Logger.error("[Controller] External state listener error:", error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.postStateToWebview()
|
||||
} catch (error) {
|
||||
Logger.error("[Controller] Failed to post state to webview on external change:", error)
|
||||
}
|
||||
},
|
||||
})
|
||||
this.authService = AuthService.getInstance(this)
|
||||
@@ -159,6 +176,19 @@ export class Controller {
|
||||
Logger.log("[Controller] ClineProvider instantiated")
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to external state changes (e.g. another process toggled a rule).
|
||||
* Used by CLI Ink components to re-fetch and re-render when state is updated externally.
|
||||
*
|
||||
* @returns Unsubscribe function — call it in useEffect cleanup to avoid memory leaks.
|
||||
*/
|
||||
subscribeToExternalStateChange(listener: () => void): () => void {
|
||||
this.externalStateListeners.add(listener)
|
||||
return () => {
|
||||
this.externalStateListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc.
|
||||
- https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import type { ApiConfiguration, ModelInfo } from "@shared/api"
|
||||
import {
|
||||
ApiHandlerSettingsKeys,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
isSettingsKey,
|
||||
type LocalState,
|
||||
type LocalStateKey,
|
||||
LocalStateKeys,
|
||||
type RemoteConfigFields,
|
||||
type SecretKey,
|
||||
SecretKeys,
|
||||
@@ -111,6 +114,15 @@ export class StateManager {
|
||||
private persistenceTimeout: NodeJS.Timeout | null = null
|
||||
private readonly PERSISTENCE_DELAY_MS = 500
|
||||
private taskHistoryWatcher: FSWatcher | null = null
|
||||
private workspaceStateWatcher: FSWatcher | null = null
|
||||
private globalStateWatcher: FSWatcher | null = null
|
||||
|
||||
// Global state keys that should be synced across processes (rule toggles)
|
||||
private static readonly WATCHED_GLOBAL_KEYS = [
|
||||
"globalClineRulesToggles",
|
||||
"globalWorkflowToggles",
|
||||
"remoteRulesToggles",
|
||||
] as const
|
||||
|
||||
// Callback for persistence errors
|
||||
onPersistenceError?: (event: PersistenceErrorEvent) => void
|
||||
@@ -149,6 +161,11 @@ export class StateManager {
|
||||
// Start watcher for taskHistory.json so external edits update cache (no persist loop)
|
||||
await StateManager.instance.setupTaskHistoryWatcher()
|
||||
|
||||
// Start watchers for workspaceState.json and globalState.json to sync rule toggles
|
||||
// across processes (CLI ↔ VSCode) without requiring a restart.
|
||||
await StateManager.instance.setupWorkspaceStateWatcher()
|
||||
await StateManager.instance.setupGlobalStateWatcher()
|
||||
|
||||
StateManager.instance.isInitialized = true
|
||||
|
||||
await AgentConfigLoader.getInstance().ready()
|
||||
@@ -571,6 +588,120 @@ export class StateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize chokidar watcher for workspaceState.json.
|
||||
* When another process (e.g. VSCode) writes rule toggles, this updates
|
||||
* the in-memory cache and fires onSyncExternalChange so the UI reflects
|
||||
* the external change immediately.
|
||||
*
|
||||
* Self-writes are safe: the cache is updated in-memory before the debounced
|
||||
* disk flush, so the watcher comparison finds no difference → no spurious event.
|
||||
*/
|
||||
private async setupWorkspaceStateWatcher(): Promise<void> {
|
||||
try {
|
||||
const wsFile = path.join(this.storage.workspaceStoragePath, "workspaceState.json")
|
||||
|
||||
if (this.workspaceStateWatcher) {
|
||||
await this.workspaceStateWatcher.close()
|
||||
this.workspaceStateWatcher = null
|
||||
}
|
||||
|
||||
this.workspaceStateWatcher = chokidar.watch(wsFile, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
atomic: true,
|
||||
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
|
||||
})
|
||||
|
||||
const syncWorkspaceStateFromDisk = async () => {
|
||||
try {
|
||||
if (!this.isInitialized) return
|
||||
let onDisk: Record<string, any> = {}
|
||||
try {
|
||||
onDisk = JSON.parse(readFileSync(wsFile, "utf-8"))
|
||||
} catch {
|
||||
return // File doesn't exist or invalid JSON — skip
|
||||
}
|
||||
let changed = false
|
||||
for (const key of LocalStateKeys) {
|
||||
const diskValue = onDisk[key] ?? {}
|
||||
if (JSON.stringify(diskValue) !== JSON.stringify(this.workspaceStateCache[key])) {
|
||||
this.workspaceStateCache[key] = diskValue
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
await this.onSyncExternalChange?.()
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error("[StateManager] Failed to reload workspace state on change:", err)
|
||||
}
|
||||
}
|
||||
|
||||
this.workspaceStateWatcher
|
||||
.on("add", () => syncWorkspaceStateFromDisk())
|
||||
.on("change", () => syncWorkspaceStateFromDisk())
|
||||
.on("error", (error) => Logger.error("[StateManager] WorkspaceState watcher error:", error))
|
||||
} catch (err) {
|
||||
Logger.error("[StateManager] Failed to set up workspaceState watcher:", err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize chokidar watcher for globalState.json.
|
||||
* Watches rule-toggle keys so that a CLI toggle of a global rule is
|
||||
* reflected in the VSCode webview without a restart.
|
||||
*/
|
||||
private async setupGlobalStateWatcher(): Promise<void> {
|
||||
try {
|
||||
const globalFile = path.join(this.storage.dataDir, "globalState.json")
|
||||
|
||||
if (this.globalStateWatcher) {
|
||||
await this.globalStateWatcher.close()
|
||||
this.globalStateWatcher = null
|
||||
}
|
||||
|
||||
this.globalStateWatcher = chokidar.watch(globalFile, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
atomic: true,
|
||||
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
|
||||
})
|
||||
|
||||
const syncGlobalStateFromDisk = async () => {
|
||||
try {
|
||||
if (!this.isInitialized) return
|
||||
let onDisk: Record<string, any> = {}
|
||||
try {
|
||||
onDisk = JSON.parse(readFileSync(globalFile, "utf-8"))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
let changed = false
|
||||
for (const key of StateManager.WATCHED_GLOBAL_KEYS) {
|
||||
const diskValue = onDisk[key] ?? {}
|
||||
if (JSON.stringify(diskValue) !== JSON.stringify((this.globalStateCache as any)[key])) {
|
||||
;(this.globalStateCache as any)[key] = diskValue
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
await this.onSyncExternalChange?.()
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error("[StateManager] Failed to reload global state on change:", err)
|
||||
}
|
||||
}
|
||||
|
||||
this.globalStateWatcher
|
||||
.on("add", () => syncGlobalStateFromDisk())
|
||||
.on("change", () => syncGlobalStateFromDisk())
|
||||
.on("error", (error) => Logger.error("[StateManager] GlobalState watcher error:", error))
|
||||
} catch (err) {
|
||||
Logger.error("[StateManager] Failed to set up globalState watcher:", err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for getting API configuration
|
||||
* Ensures cache is initialized if not already done
|
||||
@@ -706,11 +837,19 @@ export class StateManager {
|
||||
clearTimeout(this.persistenceTimeout)
|
||||
this.persistenceTimeout = null
|
||||
}
|
||||
// Close file watcher if active
|
||||
// Close file watchers if active
|
||||
if (this.taskHistoryWatcher) {
|
||||
this.taskHistoryWatcher.close()
|
||||
this.taskHistoryWatcher = null
|
||||
}
|
||||
if (this.workspaceStateWatcher) {
|
||||
this.workspaceStateWatcher.close()
|
||||
this.workspaceStateWatcher = null
|
||||
}
|
||||
if (this.globalStateWatcher) {
|
||||
this.globalStateWatcher.close()
|
||||
this.globalStateWatcher = null
|
||||
}
|
||||
|
||||
this.pendingGlobalState.clear()
|
||||
this.pendingSecrets.clear()
|
||||
|
||||
@@ -91,6 +91,12 @@ export const CLI_ONLY_COMMANDS: SlashCommand[] = [
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
},
|
||||
{
|
||||
name: "rules",
|
||||
description: "View and toggle rules",
|
||||
section: "default",
|
||||
cliCompatible: true,
|
||||
},
|
||||
{
|
||||
name: "skills",
|
||||
description: "View and manage installed skills",
|
||||
|
||||
Reference in New Issue
Block a user