Просмотр исходного кода

feat: Add auto-approval for mode switching

Implements automatic approval for mode switching operations when enabled, following
existing auto-approval patterns in the codebase.

Implementation:
- Added `alwaysAllowModeSwitch` to state management
- Updated `isAutoApproved` function in ChatView to handle mode switch requests
- Added mode switch option to AutoApproveMenu with appropriate handler
- Integrated with existing auto-approval flow

Tests:
- Added three test cases in ChatView.auto-approve.test.tsx:
  1. Verifies mode switch auto-approval when enabled
  2. Verifies no auto-approval when mode switch setting is disabled
  3. Verifies no auto-approval when global auto-approval is disabled

The implementation follows existing patterns for other auto-approve features
(read, write, browser, etc.) to maintain consistency in the codebase.
MFPires 11 месяцев назад
Родитель
Сommit
b3be00c050

+ 11 - 1
src/core/webview/ClineProvider.ts

@@ -79,6 +79,8 @@ type GlobalStateKey =
 	| "alwaysAllowWrite"
 	| "alwaysAllowExecute"
 	| "alwaysAllowBrowser"
+	| "alwaysAllowMcp"
+	| "alwaysAllowModeSwitch"
 	| "taskHistory"
 	| "openAiBaseUrl"
 	| "openAiModelId"
@@ -99,7 +101,6 @@ type GlobalStateKey =
 	| "soundEnabled"
 	| "soundVolume"
 	| "diffEnabled"
-	| "alwaysAllowMcp"
 	| "browserViewportSize"
 	| "screenshotQuality"
 	| "fuzzyMatchThreshold"
@@ -620,6 +621,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 						await this.updateGlobalState("alwaysAllowMcp", message.bool)
 						await this.postStateToWebview()
 						break
+					case "alwaysAllowModeSwitch":
+						await this.updateGlobalState("alwaysAllowModeSwitch", message.bool)
+						await this.postStateToWebview()
+						break
 					case "askResponse":
 						this.cline?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
 						break
@@ -1848,6 +1853,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			alwaysAllowExecute,
 			alwaysAllowBrowser,
 			alwaysAllowMcp,
+			alwaysAllowModeSwitch,
 			soundEnabled,
 			diffEnabled,
 			taskHistory,
@@ -1882,6 +1888,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			alwaysAllowExecute: alwaysAllowExecute ?? false,
 			alwaysAllowBrowser: alwaysAllowBrowser ?? false,
 			alwaysAllowMcp: alwaysAllowMcp ?? false,
+			alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
 			uriScheme: vscode.env.uriScheme,
 			clineMessages: this.cline?.clineMessages || [],
 			taskHistory: (taskHistory || [])
@@ -2009,6 +2016,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			alwaysAllowExecute,
 			alwaysAllowBrowser,
 			alwaysAllowMcp,
+			alwaysAllowModeSwitch,
 			taskHistory,
 			allowedCommands,
 			soundEnabled,
@@ -2078,6 +2086,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			this.getGlobalState("alwaysAllowExecute") as Promise<boolean | undefined>,
 			this.getGlobalState("alwaysAllowBrowser") as Promise<boolean | undefined>,
 			this.getGlobalState("alwaysAllowMcp") as Promise<boolean | undefined>,
+			this.getGlobalState("alwaysAllowModeSwitch") as Promise<boolean | undefined>,
 			this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
 			this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
 			this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
@@ -2166,6 +2175,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
 			alwaysAllowExecute: alwaysAllowExecute ?? false,
 			alwaysAllowBrowser: alwaysAllowBrowser ?? false,
 			alwaysAllowMcp: alwaysAllowMcp ?? false,
+			alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
 			taskHistory,
 			allowedCommands,
 			soundEnabled: soundEnabled ?? false,

+ 1 - 0
src/shared/ExtensionMessage.ts

@@ -91,6 +91,7 @@ export interface ExtensionState {
 	alwaysAllowBrowser?: boolean
 	alwaysAllowMcp?: boolean
 	alwaysApproveResubmit?: boolean
+	alwaysAllowModeSwitch?: boolean
 	requestDelaySeconds: number
 	uriScheme?: string
 	allowedCommands?: string[]

+ 1 - 0
src/shared/WebviewMessage.ts

@@ -41,6 +41,7 @@ export interface WebviewMessage {
 		| "refreshOpenAiModels"
 		| "alwaysAllowBrowser"
 		| "alwaysAllowMcp"
+		| "alwaysAllowModeSwitch"
 		| "playSound"
 		| "soundEnabled"
 		| "soundVolume"

+ 16 - 0
webview-ui/src/components/chat/AutoApproveMenu.tsx

@@ -28,6 +28,8 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
 		setAlwaysAllowBrowser,
 		alwaysAllowMcp,
 		setAlwaysAllowMcp,
+		alwaysAllowModeSwitch,
+		setAlwaysAllowModeSwitch,
 		alwaysApproveResubmit,
 		setAlwaysApproveResubmit,
 		autoApprovalEnabled,
@@ -71,6 +73,13 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
 			enabled: alwaysAllowMcp ?? false,
 			description: "Allows use of configured MCP servers which may modify filesystem or interact with APIs.",
 		},
+		{
+			id: "switchModes",
+			label: "Switch between modes",
+			shortName: "Modes",
+			enabled: alwaysAllowModeSwitch ?? false,
+			description: "Allows automatic switching between different AI modes without requiring approval.",
+		},
 		{
 			id: "retryRequests",
 			label: "Retry failed requests",
@@ -120,6 +129,12 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
 		vscode.postMessage({ type: "alwaysAllowMcp", bool: newValue })
 	}, [alwaysAllowMcp, setAlwaysAllowMcp])
 
+	const handleModeSwitchChange = useCallback(() => {
+		const newValue = !(alwaysAllowModeSwitch ?? false)
+		setAlwaysAllowModeSwitch(newValue)
+		vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: newValue })
+	}, [alwaysAllowModeSwitch, setAlwaysAllowModeSwitch])
+
 	const handleRetryChange = useCallback(() => {
 		const newValue = !(alwaysApproveResubmit ?? false)
 		setAlwaysApproveResubmit(newValue)
@@ -133,6 +148,7 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
 		executeCommands: handleExecuteChange,
 		useBrowser: handleBrowserChange,
 		useMcp: handleMcpChange,
+		switchModes: handleModeSwitchChange,
 		retryRequests: handleRetryChange,
 	}
 

+ 6 - 1
webview-ui/src/components/chat/ChatView.tsx

@@ -55,6 +55,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 		mode,
 		setMode,
 		autoApprovalEnabled,
+		alwaysAllowModeSwitch,
 	} = useExtensionState()
 
 	//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
@@ -565,7 +566,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 				(alwaysAllowReadOnly && message.ask === "tool" && isReadOnlyToolAction(message)) ||
 				(alwaysAllowWrite && message.ask === "tool" && isWriteToolAction(message)) ||
 				(alwaysAllowExecute && message.ask === "command" && isAllowedCommand(message)) ||
-				(alwaysAllowMcp && message.ask === "use_mcp_server" && isMcpToolAlwaysAllowed(message))
+				(alwaysAllowMcp && message.ask === "use_mcp_server" && isMcpToolAlwaysAllowed(message)) ||
+				(alwaysAllowModeSwitch &&
+					message.ask === "tool" &&
+					JSON.parse(message.text || "{}")?.tool === "switchMode")
 			)
 		},
 		[
@@ -579,6 +583,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
 			isAllowedCommand,
 			alwaysAllowMcp,
 			isMcpToolAlwaysAllowed,
+			alwaysAllowModeSwitch,
 		],
 	)
 

+ 164 - 0
webview-ui/src/components/chat/__tests__/ChatView.auto-approve.test.tsx

@@ -313,4 +313,168 @@ describe("ChatView - Auto Approval Tests", () => {
 			})
 		})
 	})
+
+	it("auto-approves mode switch when enabled", async () => {
+		render(
+			<ExtensionStateContextProvider>
+				<ChatView
+					isHidden={false}
+					showAnnouncement={false}
+					hideAnnouncement={() => {}}
+					showHistoryView={() => {}}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		// First hydrate state with initial task
+		mockPostMessage({
+			alwaysAllowModeSwitch: true,
+			autoApprovalEnabled: true,
+			clineMessages: [
+				{
+					type: "say",
+					say: "task",
+					ts: Date.now() - 2000,
+					text: "Initial task",
+				},
+			],
+		})
+
+		// Then send the mode switch ask message
+		mockPostMessage({
+			alwaysAllowModeSwitch: true,
+			autoApprovalEnabled: true,
+			clineMessages: [
+				{
+					type: "say",
+					say: "task",
+					ts: Date.now() - 2000,
+					text: "Initial task",
+				},
+				{
+					type: "ask",
+					ask: "tool",
+					ts: Date.now(),
+					text: JSON.stringify({ tool: "switchMode" }),
+					partial: false,
+				},
+			],
+		})
+
+		// Wait for the auto-approval message
+		await waitFor(() => {
+			expect(vscode.postMessage).toHaveBeenCalledWith({
+				type: "askResponse",
+				askResponse: "yesButtonClicked",
+			})
+		})
+	})
+
+	it("does not auto-approve mode switch when disabled", async () => {
+		render(
+			<ExtensionStateContextProvider>
+				<ChatView
+					isHidden={false}
+					showAnnouncement={false}
+					hideAnnouncement={() => {}}
+					showHistoryView={() => {}}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		// First hydrate state with initial task
+		mockPostMessage({
+			alwaysAllowModeSwitch: false,
+			autoApprovalEnabled: true,
+			clineMessages: [
+				{
+					type: "say",
+					say: "task",
+					ts: Date.now() - 2000,
+					text: "Initial task",
+				},
+			],
+		})
+
+		// Then send the mode switch ask message
+		mockPostMessage({
+			alwaysAllowModeSwitch: false,
+			autoApprovalEnabled: true,
+			clineMessages: [
+				{
+					type: "say",
+					say: "task",
+					ts: Date.now() - 2000,
+					text: "Initial task",
+				},
+				{
+					type: "ask",
+					ask: "tool",
+					ts: Date.now(),
+					text: JSON.stringify({ tool: "switchMode" }),
+					partial: false,
+				},
+			],
+		})
+
+		// Verify no auto-approval message was sent
+		expect(vscode.postMessage).not.toHaveBeenCalledWith({
+			type: "askResponse",
+			askResponse: "yesButtonClicked",
+		})
+	})
+
+	it("does not auto-approve mode switch when auto-approval is disabled", async () => {
+		render(
+			<ExtensionStateContextProvider>
+				<ChatView
+					isHidden={false}
+					showAnnouncement={false}
+					hideAnnouncement={() => {}}
+					showHistoryView={() => {}}
+				/>
+			</ExtensionStateContextProvider>,
+		)
+
+		// First hydrate state with initial task
+		mockPostMessage({
+			alwaysAllowModeSwitch: true,
+			autoApprovalEnabled: false,
+			clineMessages: [
+				{
+					type: "say",
+					say: "task",
+					ts: Date.now() - 2000,
+					text: "Initial task",
+				},
+			],
+		})
+
+		// Then send the mode switch ask message
+		mockPostMessage({
+			alwaysAllowModeSwitch: true,
+			autoApprovalEnabled: false,
+			clineMessages: [
+				{
+					type: "say",
+					say: "task",
+					ts: Date.now() - 2000,
+					text: "Initial task",
+				},
+				{
+					type: "ask",
+					ask: "tool",
+					ts: Date.now(),
+					text: JSON.stringify({ tool: "switchMode" }),
+					partial: false,
+				},
+			],
+		})
+
+		// Verify no auto-approval message was sent
+		expect(vscode.postMessage).not.toHaveBeenCalledWith({
+			type: "askResponse",
+			askResponse: "yesButtonClicked",
+		})
+	})
 })

+ 2 - 0
webview-ui/src/context/ExtensionStateContext.tsx

@@ -33,6 +33,7 @@ export interface ExtensionStateContextType extends ExtensionState {
 	setAlwaysAllowExecute: (value: boolean) => void
 	setAlwaysAllowBrowser: (value: boolean) => void
 	setAlwaysAllowMcp: (value: boolean) => void
+	setAlwaysAllowModeSwitch: (value: boolean) => void
 	setShowAnnouncement: (value: boolean) => void
 	setAllowedCommands: (value: string[]) => void
 	setSoundEnabled: (value: boolean) => void
@@ -253,6 +254,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
 		setAlwaysAllowExecute: (value) => setState((prevState) => ({ ...prevState, alwaysAllowExecute: value })),
 		setAlwaysAllowBrowser: (value) => setState((prevState) => ({ ...prevState, alwaysAllowBrowser: value })),
 		setAlwaysAllowMcp: (value) => setState((prevState) => ({ ...prevState, alwaysAllowMcp: value })),
+		setAlwaysAllowModeSwitch: (value) => setState((prevState) => ({ ...prevState, alwaysAllowModeSwitch: value })),
 		setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })),
 		setAllowedCommands: (value) => setState((prevState) => ({ ...prevState, allowedCommands: value })),
 		setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),