Customize for Roo

This commit is contained in:
Matt Rubens
2025-01-15 22:28:43 -05:00
parent 4b4905ec9e
commit b65c8d0ec6
9 changed files with 842 additions and 133 deletions

View File

@@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Add auto-approve menu (thanks Cline!)

View File

@@ -93,6 +93,7 @@ export interface ExtensionState {
mode: Mode mode: Mode
modeApiConfigs?: Record<Mode, string> modeApiConfigs?: Record<Mode, string>
enhancementApiConfigId?: string enhancementApiConfigId?: string
autoApprovalEnabled?: boolean
} }
export interface ClineMessage { export interface ClineMessage {

View File

@@ -1,11 +1,12 @@
import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { useCallback, useState } from "react" import { useCallback, useState } from "react"
import styled from "styled-components" import { useExtensionState } from "../../context/ExtensionStateContext"
interface AutoApproveAction { interface AutoApproveAction {
id: string id: string
label: string label: string
enabled: boolean enabled: boolean
shortName: string
description: string description: string
} }
@@ -13,58 +14,97 @@ interface AutoApproveMenuProps {
style?: React.CSSProperties style?: React.CSSProperties
} }
const DEFAULT_MAX_REQUESTS = 50
const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
const [isExpanded, setIsExpanded] = useState(false) const [isExpanded, setIsExpanded] = useState(false)
const [actions, setActions] = useState<AutoApproveAction[]>([ const {
alwaysAllowReadOnly,
setAlwaysAllowReadOnly,
alwaysAllowWrite,
setAlwaysAllowWrite,
alwaysAllowExecute,
setAlwaysAllowExecute,
alwaysAllowBrowser,
setAlwaysAllowBrowser,
alwaysAllowMcp,
setAlwaysAllowMcp,
alwaysApproveResubmit,
setAlwaysApproveResubmit,
autoApprovalEnabled,
setAutoApprovalEnabled,
} = useExtensionState()
const actions: AutoApproveAction[] = [
{ {
id: "readFiles", id: "readFiles",
label: "Read files and directories", label: "Read files and directories",
enabled: false, shortName: "Read",
enabled: alwaysAllowReadOnly ?? false,
description: "Allows access to read any file on your computer.", description: "Allows access to read any file on your computer.",
}, },
{ {
id: "editFiles", id: "editFiles",
label: "Edit files", label: "Edit files",
enabled: false, shortName: "Edit",
enabled: alwaysAllowWrite ?? false,
description: "Allows modification of any files on your computer.", description: "Allows modification of any files on your computer.",
}, },
{ {
id: "executeCommands", id: "executeCommands",
label: "Execute safe commands", label: "Execute safe commands",
enabled: false, shortName: "Commands",
enabled: alwaysAllowExecute ?? false,
description: description:
"Allows automatic execution of safe terminal commands. The model will determine if a command is potentially destructive and ask for explicit approval.", "Allows execution of approved terminal commands. You can configure this in the settings panel.",
}, },
{ {
id: "useBrowser", id: "useBrowser",
label: "Use the browser", label: "Use the browser",
enabled: false, shortName: "Browser",
enabled: alwaysAllowBrowser ?? false,
description: "Allows ability to launch and interact with any website in a headless browser.", description: "Allows ability to launch and interact with any website in a headless browser.",
}, },
{ {
id: "useMcp", id: "useMcp",
label: "Use MCP servers", label: "Use MCP servers",
enabled: false, shortName: "MCP",
enabled: alwaysAllowMcp ?? false,
description: "Allows use of configured MCP servers which may modify filesystem or interact with APIs.", description: "Allows use of configured MCP servers which may modify filesystem or interact with APIs.",
}, },
]) {
const [maxRequests, setMaxRequests] = useState(DEFAULT_MAX_REQUESTS) id: "retryRequests",
const [enableNotifications, setEnableNotifications] = useState(false) label: "Retry failed requests",
shortName: "Retries",
enabled: alwaysApproveResubmit ?? false,
description: "Automatically retry failed API requests when the provider returns an error response.",
},
]
const toggleExpanded = useCallback(() => { const toggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev) setIsExpanded((prev) => !prev)
}, []) }, [])
const toggleAction = useCallback((actionId: string) => { const enabledActionsList = actions
setActions((prev) => .filter((action) => action.enabled)
prev.map((action) => (action.id === actionId ? { ...action, enabled: !action.enabled } : action)), .map((action) => action.shortName)
) .join(", ")
}, [])
const enabledActions = actions.filter((action) => action.enabled) // Individual checkbox handlers - each one only updates its own state
const enabledActionsList = enabledActions.map((action) => action.label).join(", ") const handleReadOnlyChange = useCallback(() => setAlwaysAllowReadOnly(!(alwaysAllowReadOnly ?? false)), [alwaysAllowReadOnly, setAlwaysAllowReadOnly])
const handleWriteChange = useCallback(() => setAlwaysAllowWrite(!(alwaysAllowWrite ?? false)), [alwaysAllowWrite, setAlwaysAllowWrite])
const handleExecuteChange = useCallback(() => setAlwaysAllowExecute(!(alwaysAllowExecute ?? false)), [alwaysAllowExecute, setAlwaysAllowExecute])
const handleBrowserChange = useCallback(() => setAlwaysAllowBrowser(!(alwaysAllowBrowser ?? false)), [alwaysAllowBrowser, setAlwaysAllowBrowser])
const handleMcpChange = useCallback(() => setAlwaysAllowMcp(!(alwaysAllowMcp ?? false)), [alwaysAllowMcp, setAlwaysAllowMcp])
const handleRetryChange = useCallback(() => setAlwaysApproveResubmit(!(alwaysApproveResubmit ?? false)), [alwaysApproveResubmit, setAlwaysApproveResubmit])
// Map action IDs to their specific handlers
const actionHandlers: Record<AutoApproveAction['id'], () => void> = {
readFiles: handleReadOnlyChange,
editFiles: handleWriteChange,
executeCommands: handleExecuteChange,
useBrowser: handleBrowserChange,
useMcp: handleMcpChange,
retryRequests: handleRetryChange,
}
return ( return (
<div <div
@@ -86,39 +126,41 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
cursor: "pointer", cursor: "pointer",
}} }}
onClick={toggleExpanded}> onClick={toggleExpanded}>
<div onClick={(e) => e.stopPropagation()}>
<VSCodeCheckbox <VSCodeCheckbox
checked={enabledActions.length > 0} checked={autoApprovalEnabled ?? false}
onChange={(e) => { onChange={() => setAutoApprovalEnabled(!(autoApprovalEnabled ?? false))}
const checked = (e.target as HTMLInputElement).checked
setActions((prev) =>
prev.map((action) => ({
...action,
enabled: checked,
})),
)
e.stopPropagation()
}}
onClick={(e) => e.stopPropagation()}
/> />
<CollapsibleSection> </div>
<span style={{ color: "var(--vscode-foreground)" }}>Auto-approve:</span> <div style={{
<span display: 'flex',
style={{ alignItems: 'center',
whiteSpace: "nowrap", gap: '4px',
flex: 1,
minWidth: 0
}}>
<span style={{
color: "var(--vscode-foreground)",
flexShrink: 0
}}>Auto-approve:</span>
<span style={{
color: "var(--vscode-descriptionForeground)",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
minWidth: 0
}}> }}>
{enabledActions.length === 0 ? "None" : enabledActionsList} {enabledActionsList || "None"}
</span> </span>
<span <span
className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`} className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}
style={{ style={{
// fontSize: "14px",
flexShrink: 0, flexShrink: 0,
marginLeft: isExpanded ? "2px" : "-2px", marginLeft: isExpanded ? "2px" : "-2px",
}} }}
/> />
</CollapsibleSection> </div>
</div> </div>
{isExpanded && ( {isExpanded && (
<div style={{ padding: "0" }}> <div style={{ padding: "0" }}>
@@ -129,13 +171,17 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
fontSize: "12px", fontSize: "12px",
}}> }}>
Auto-approve allows Cline to perform actions without asking for permission. Only enable for Auto-approve allows Cline to perform actions without asking for permission. Only enable for
actions you fully trust, and consider setting a low request limit as a safeguard. actions you fully trust.
</div> </div>
{actions.map((action) => ( {actions.map((action) => (
<div key={action.id} style={{ margin: "6px 0" }}> <div key={action.id} style={{ margin: "6px 0" }}>
<VSCodeCheckbox checked={action.enabled} onChange={() => toggleAction(action.id)}> <div onClick={(e) => e.stopPropagation()}>
<VSCodeCheckbox
checked={action.enabled}
onChange={actionHandlers[action.id]}>
{action.label} {action.label}
</VSCodeCheckbox> </VSCodeCheckbox>
</div>
<div <div
style={{ style={{
marginLeft: "28px", marginLeft: "28px",
@@ -146,76 +192,10 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
</div> </div>
</div> </div>
))} ))}
<div
style={{
height: "0.5px",
background: "var(--vscode-titleBar-inactiveForeground)",
margin: "15px 0",
opacity: 0.2,
}}
/>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginTop: "10px",
marginBottom: "8px",
color: "var(--vscode-foreground)",
}}>
<span style={{ flexShrink: 1, minWidth: 0 }}>Max Requests:</span>
<VSCodeTextField
value={maxRequests.toString()}
onChange={(e) => {
const value = parseInt((e.target as HTMLInputElement).value)
if (!isNaN(value) && value > 0) {
setMaxRequests(value)
}
}}
style={{ flex: 1 }}
/>
</div>
<div
style={{
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
marginBottom: "10px",
}}>
Cline will make this many API requests before asking for approval to proceed with the task.
</div>
<div style={{ margin: "6px 0" }}>
<VSCodeCheckbox
checked={enableNotifications}
onChange={() => setEnableNotifications((prev) => !prev)}>
Enable Notifications
</VSCodeCheckbox>
<div
style={{
marginLeft: "28px",
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
}}>
Receive system notifications when Cline requires approval to proceed or when a task is
completed.
</div>
</div>
</div> </div>
)} )}
</div> </div>
) )
} }
const CollapsibleSection = styled.div`
display: flex;
align-items: center;
gap: 4px;
color: var(--vscode-descriptionForeground);
flex: 1;
min-width: 0;
&:hover {
color: var(--vscode-foreground);
}
`
export default AutoApproveMenu export default AutoApproveMenu

View File

@@ -527,7 +527,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
flexDirection: "column", flexDirection: "column",
gap: "8px", gap: "8px",
backgroundColor: "var(--vscode-input-background)", backgroundColor: "var(--vscode-input-background)",
minHeight: "100px",
margin: "10px 15px", margin: "10px 15px",
padding: "8px" padding: "8px"
}} }}
@@ -652,7 +651,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
onHeightChange?.(height) onHeightChange?.(height)
}} }}
placeholder={placeholderText} placeholder={placeholderText}
minRows={4} minRows={2}
maxRows={20} maxRows={20}
autoFocus={true} autoFocus={true}
style={{ style={{

View File

@@ -39,7 +39,7 @@ interface ChatViewProps {
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => { const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const { version, clineMessages: messages, taskHistory, apiConfiguration, mcpServers, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, allowedCommands, writeDelayMs, mode, setMode } = useExtensionState() const { version, clineMessages: messages, taskHistory, apiConfiguration, mcpServers, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, allowedCommands, writeDelayMs, mode, setMode, autoApprovalEnabled } = useExtensionState()
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined //const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort) const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
@@ -529,7 +529,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const isAutoApproved = useCallback( const isAutoApproved = useCallback(
(message: ClineMessage | undefined) => { (message: ClineMessage | undefined) => {
if (!message || message.type !== "ask") return false if (!autoApprovalEnabled || !message || message.type !== "ask") return false
return ( return (
(alwaysAllowBrowser && message.ask === "browser_action_launch") || (alwaysAllowBrowser && message.ask === "browser_action_launch") ||
@@ -539,17 +539,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
(alwaysAllowMcp && message.ask === "use_mcp_server" && isMcpToolAlwaysAllowed(message)) (alwaysAllowMcp && message.ask === "use_mcp_server" && isMcpToolAlwaysAllowed(message))
) )
}, },
[ [autoApprovalEnabled, alwaysAllowBrowser, alwaysAllowReadOnly, isReadOnlyToolAction, alwaysAllowWrite, isWriteToolAction, alwaysAllowExecute, isAllowedCommand, alwaysAllowMcp, isMcpToolAlwaysAllowed]
alwaysAllowBrowser,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowMcp,
isReadOnlyToolAction,
isWriteToolAction,
isAllowedCommand,
isMcpToolAlwaysAllowed
]
) )
useEffect(() => { useEffect(() => {

View File

@@ -0,0 +1,198 @@
import { render, fireEvent, screen } from "@testing-library/react"
import { useExtensionState } from "../../../context/ExtensionStateContext"
import AutoApproveMenu from "../AutoApproveMenu"
import { codeMode, defaultPrompts } from "../../../../../src/shared/modes"
// Mock the ExtensionStateContext hook
jest.mock("../../../context/ExtensionStateContext")
const mockUseExtensionState = useExtensionState as jest.MockedFunction<typeof useExtensionState>
describe("AutoApproveMenu", () => {
const defaultMockState = {
// Required state properties
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
soundEnabled: false,
soundVolume: 0.5,
diffEnabled: false,
fuzzyMatchThreshold: 1.0,
preferredLanguage: "English",
writeDelayMs: 1000,
browserViewportSize: "900x600",
screenshotQuality: 75,
terminalOutputLineLimit: 500,
mcpEnabled: true,
requestDelaySeconds: 5,
currentApiConfigName: "default",
listApiConfigMeta: [],
mode: codeMode,
customPrompts: defaultPrompts,
enhancementApiConfigId: "",
didHydrateState: true,
showWelcome: false,
theme: {},
glamaModels: {},
openRouterModels: {},
openAiModels: [],
mcpServers: [],
filePaths: [],
// Auto-approve specific properties
alwaysAllowReadOnly: false,
alwaysAllowWrite: false,
alwaysAllowExecute: false,
alwaysAllowBrowser: false,
alwaysAllowMcp: false,
alwaysApproveResubmit: false,
autoApprovalEnabled: false,
// Required setter functions
setApiConfiguration: jest.fn(),
setCustomInstructions: jest.fn(),
setAlwaysAllowReadOnly: jest.fn(),
setAlwaysAllowWrite: jest.fn(),
setAlwaysAllowExecute: jest.fn(),
setAlwaysAllowBrowser: jest.fn(),
setAlwaysAllowMcp: jest.fn(),
setShowAnnouncement: jest.fn(),
setAllowedCommands: jest.fn(),
setSoundEnabled: jest.fn(),
setSoundVolume: jest.fn(),
setDiffEnabled: jest.fn(),
setBrowserViewportSize: jest.fn(),
setFuzzyMatchThreshold: jest.fn(),
setPreferredLanguage: jest.fn(),
setWriteDelayMs: jest.fn(),
setScreenshotQuality: jest.fn(),
setTerminalOutputLineLimit: jest.fn(),
setMcpEnabled: jest.fn(),
setAlwaysApproveResubmit: jest.fn(),
setRequestDelaySeconds: jest.fn(),
setCurrentApiConfigName: jest.fn(),
setListApiConfigMeta: jest.fn(),
onUpdateApiConfig: jest.fn(),
setMode: jest.fn(),
setCustomPrompts: jest.fn(),
setEnhancementApiConfigId: jest.fn(),
setAutoApprovalEnabled: jest.fn(),
}
beforeEach(() => {
mockUseExtensionState.mockReturnValue(defaultMockState)
})
afterEach(() => {
jest.clearAllMocks()
})
it("renders with initial collapsed state", () => {
render(<AutoApproveMenu />)
// Check for main checkbox and label
expect(screen.getByText("Auto-approve:")).toBeInTheDocument()
expect(screen.getByText("None")).toBeInTheDocument()
// Verify the menu is collapsed (actions not visible)
expect(screen.queryByText("Read files and directories")).not.toBeInTheDocument()
})
it("expands menu when clicked", () => {
render(<AutoApproveMenu />)
// Click to expand
fireEvent.click(screen.getByText("Auto-approve:"))
// Verify menu items are visible
expect(screen.getByText("Read files and directories")).toBeInTheDocument()
expect(screen.getByText("Edit files")).toBeInTheDocument()
expect(screen.getByText("Execute safe commands")).toBeInTheDocument()
expect(screen.getByText("Use the browser")).toBeInTheDocument()
expect(screen.getByText("Use MCP servers")).toBeInTheDocument()
expect(screen.getByText("Retry failed requests")).toBeInTheDocument()
})
it("toggles main auto-approval checkbox", () => {
render(<AutoApproveMenu />)
const mainCheckbox = screen.getByRole("checkbox")
fireEvent.click(mainCheckbox)
expect(defaultMockState.setAutoApprovalEnabled).toHaveBeenCalledWith(true)
})
it("toggles individual permissions", () => {
render(<AutoApproveMenu />)
// Expand menu
fireEvent.click(screen.getByText("Auto-approve:"))
// Click read files checkbox
fireEvent.click(screen.getByText("Read files and directories"))
expect(defaultMockState.setAlwaysAllowReadOnly).toHaveBeenCalledWith(true)
// Click edit files checkbox
fireEvent.click(screen.getByText("Edit files"))
expect(defaultMockState.setAlwaysAllowWrite).toHaveBeenCalledWith(true)
// Click execute commands checkbox
fireEvent.click(screen.getByText("Execute safe commands"))
expect(defaultMockState.setAlwaysAllowExecute).toHaveBeenCalledWith(true)
})
it("displays enabled actions in summary", () => {
mockUseExtensionState.mockReturnValue({
...defaultMockState,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
autoApprovalEnabled: true,
})
render(<AutoApproveMenu />)
// Check that enabled actions are shown in summary
expect(screen.getByText("Read, Edit")).toBeInTheDocument()
})
it("preserves checkbox states", () => {
// Mock state with some permissions enabled
const mockState = {
...defaultMockState,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
}
// Update mock to return our state
mockUseExtensionState.mockReturnValue(mockState)
render(<AutoApproveMenu />)
// Expand menu
fireEvent.click(screen.getByText("Auto-approve:"))
// Verify read and edit checkboxes are checked
expect(screen.getByLabelText("Read files and directories")).toBeInTheDocument()
expect(screen.getByLabelText("Edit files")).toBeInTheDocument()
// Verify the setters haven't been called yet
expect(mockState.setAlwaysAllowReadOnly).not.toHaveBeenCalled()
expect(mockState.setAlwaysAllowWrite).not.toHaveBeenCalled()
// Collapse menu
fireEvent.click(screen.getByText("Auto-approve:"))
// Expand again
fireEvent.click(screen.getByText("Auto-approve:"))
// Verify checkboxes are still present
expect(screen.getByLabelText("Read files and directories")).toBeInTheDocument()
expect(screen.getByLabelText("Edit files")).toBeInTheDocument()
// Verify the setters still haven't been called
expect(mockState.setAlwaysAllowReadOnly).not.toHaveBeenCalled()
expect(mockState.setAlwaysAllowWrite).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,313 @@
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import ChatView from '../ChatView'
import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
import { vscode } from '../../../utils/vscode'
// Mock vscode API
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn(),
},
}))
// Mock all problematic dependencies
jest.mock('rehype-highlight', () => ({
__esModule: true,
default: () => () => {},
}))
jest.mock('hast-util-to-text', () => ({
__esModule: true,
default: () => '',
}))
// Mock components that use ESM dependencies
jest.mock('../BrowserSessionRow', () => ({
__esModule: true,
default: function MockBrowserSessionRow({ messages }: { messages: any[] }) {
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
}
}))
jest.mock('../ChatRow', () => ({
__esModule: true,
default: function MockChatRow({ message }: { message: any }) {
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
}
}))
jest.mock('../TaskHeader', () => ({
__esModule: true,
default: function MockTaskHeader({ task }: { task: any }) {
return <div data-testid="task-header">{JSON.stringify(task)}</div>
}
}))
jest.mock('../AutoApproveMenu', () => ({
__esModule: true,
default: () => null,
}))
jest.mock('../../common/CodeBlock', () => ({
__esModule: true,
default: () => null,
CODE_BLOCK_BG_COLOR: 'rgb(30, 30, 30)',
}))
jest.mock('../../common/CodeAccordian', () => ({
__esModule: true,
default: () => null,
}))
jest.mock('../ContextMenu', () => ({
__esModule: true,
default: () => null,
}))
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: any) => {
window.postMessage({
type: 'state',
state: {
version: '1.0.0',
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
autoApprovalEnabled: true,
...state
}
}, '*')
}
describe('ChatView - Auto Approval Tests', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('auto-approves read operations when enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the read tool ask message
mockPostMessage({
alwaysAllowReadOnly: 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: 'readFile', path: 'test.txt' }),
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('does not auto-approve when autoApprovalEnabled is false', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
autoApprovalEnabled: false,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the read tool ask message
mockPostMessage({
alwaysAllowReadOnly: 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: 'readFile', path: 'test.txt' }),
partial: false
}
]
})
// Verify no auto-approval message was sent
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
it('auto-approves write operations when enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowWrite: true,
autoApprovalEnabled: true,
writeDelayMs: 0,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the write tool ask message
mockPostMessage({
alwaysAllowWrite: true,
autoApprovalEnabled: true,
writeDelayMs: 0,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'tool',
ts: Date.now(),
text: JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' }),
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('auto-approves browser actions when enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowBrowser: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the browser action ask message
mockPostMessage({
alwaysAllowBrowser: true,
autoApprovalEnabled: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'browser_action_launch',
ts: Date.now(),
text: JSON.stringify({ action: 'launch', url: 'http://example.com' }),
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
})

View File

@@ -46,6 +46,11 @@ jest.mock('../ChatRow', () => ({
} }
})) }))
jest.mock('../AutoApproveMenu', () => ({
__esModule: true,
default: () => null,
}))
interface ChatTextAreaProps { interface ChatTextAreaProps {
onSend: (value: string) => void; onSend: (value: string) => void;
inputValue?: string; inputValue?: string;
@@ -139,6 +144,187 @@ describe('ChatView - Auto Approval Tests', () => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
it('defaults autoApprovalEnabled to true if any individual auto-approval flags are true', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// Test cases with different individual flags
const testCases = [
{ alwaysAllowBrowser: true },
{ alwaysAllowReadOnly: true },
{ alwaysAllowWrite: true },
{ alwaysAllowExecute: true },
{ alwaysAllowMcp: true }
]
for (const flags of testCases) {
// Reset state
mockPostMessage({
...flags,
clineMessages: []
})
// Send an action that should be auto-approved
mockPostMessage({
...flags,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: flags.alwaysAllowBrowser ? 'browser_action_launch' :
flags.alwaysAllowReadOnly ? 'tool' :
flags.alwaysAllowWrite ? 'tool' :
flags.alwaysAllowExecute ? 'command' :
'use_mcp_server',
ts: Date.now(),
text: flags.alwaysAllowBrowser ? JSON.stringify({ action: 'launch', url: 'http://example.com' }) :
flags.alwaysAllowReadOnly ? JSON.stringify({ tool: 'readFile', path: 'test.txt' }) :
flags.alwaysAllowWrite ? JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' }) :
flags.alwaysAllowExecute ? 'npm test' :
JSON.stringify({ type: 'use_mcp_tool', serverName: 'test', toolName: 'test' }),
partial: false
}
]
})
// Wait for auto-approval
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
}
// Verify no auto-approval when no flags are true
jest.clearAllMocks()
mockPostMessage({
alwaysAllowBrowser: false,
alwaysAllowReadOnly: false,
alwaysAllowWrite: false,
alwaysAllowExecute: false,
alwaysAllowMcp: false,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'browser_action_launch',
ts: Date.now(),
text: JSON.stringify({ action: 'launch', url: 'http://example.com' }),
partial: false
}
]
})
// Wait a bit to ensure no auto-approval happens
await new Promise(resolve => setTimeout(resolve, 100))
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
it('does not auto-approve any actions when autoApprovalEnabled is false', () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: false,
alwaysAllowBrowser: true,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Test various types of actions that should not be auto-approved
const testCases = [
{
ask: 'browser_action_launch',
text: JSON.stringify({ action: 'launch', url: 'http://example.com' })
},
{
ask: 'tool',
text: JSON.stringify({ tool: 'readFile', path: 'test.txt' })
},
{
ask: 'tool',
text: JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' })
},
{
ask: 'command',
text: 'npm test'
}
]
testCases.forEach(testCase => {
mockPostMessage({
autoApprovalEnabled: false,
alwaysAllowBrowser: true,
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: testCase.ask,
ts: Date.now(),
text: testCase.text,
partial: false
}
]
})
// Verify no auto-approval message was sent
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('auto-approves browser actions when alwaysAllowBrowser is enabled', async () => { it('auto-approves browser actions when alwaysAllowBrowser is enabled', async () => {
render( render(
<ExtensionStateContextProvider> <ExtensionStateContextProvider>
@@ -153,6 +339,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task // First hydrate state with initial task
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true, alwaysAllowBrowser: true,
clineMessages: [ clineMessages: [
{ {
@@ -166,6 +353,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the browser action ask message // Then send the browser action ask message
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true, alwaysAllowBrowser: true,
clineMessages: [ clineMessages: [
{ {
@@ -207,6 +395,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task // First hydrate state with initial task
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: true, alwaysAllowReadOnly: true,
clineMessages: [ clineMessages: [
{ {
@@ -220,6 +409,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the read-only tool ask message // Then send the read-only tool ask message
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: true, alwaysAllowReadOnly: true,
clineMessages: [ clineMessages: [
{ {
@@ -262,6 +452,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task // First hydrate state with initial task
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true, alwaysAllowWrite: true,
writeDelayMs: 0, writeDelayMs: 0,
clineMessages: [ clineMessages: [
@@ -276,6 +467,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the write tool ask message // Then send the write tool ask message
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true, alwaysAllowWrite: true,
writeDelayMs: 0, writeDelayMs: 0,
clineMessages: [ clineMessages: [
@@ -318,6 +510,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task // First hydrate state with initial task
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true, alwaysAllowWrite: true,
clineMessages: [ clineMessages: [
{ {
@@ -331,6 +524,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send a non-tool write operation message // Then send a non-tool write operation message
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true, alwaysAllowWrite: true,
clineMessages: [ clineMessages: [
{ {
@@ -371,6 +565,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task // First hydrate state with initial task
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true, alwaysAllowExecute: true,
allowedCommands: ['npm test'], allowedCommands: ['npm test'],
clineMessages: [ clineMessages: [
@@ -385,6 +580,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the command ask message // Then send the command ask message
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true, alwaysAllowExecute: true,
allowedCommands: ['npm test'], allowedCommands: ['npm test'],
clineMessages: [ clineMessages: [
@@ -427,6 +623,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task // First hydrate state with initial task
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true, alwaysAllowExecute: true,
allowedCommands: ['npm test'], allowedCommands: ['npm test'],
clineMessages: [ clineMessages: [
@@ -441,6 +638,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the disallowed command ask message // Then send the disallowed command ask message
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true, alwaysAllowExecute: true,
allowedCommands: ['npm test'], allowedCommands: ['npm test'],
clineMessages: [ clineMessages: [
@@ -498,6 +696,7 @@ describe('ChatView - Auto Approval Tests', () => {
// First hydrate state with initial task // First hydrate state with initial task
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true, alwaysAllowExecute: true,
allowedCommands: ['npm test', 'npm run build', 'echo', 'Select-String'], allowedCommands: ['npm test', 'npm run build', 'echo', 'Select-String'],
clineMessages: [ clineMessages: [
@@ -512,6 +711,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the chained command ask message // Then send the chained command ask message
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true, alwaysAllowExecute: true,
allowedCommands: ['npm test', 'npm run build', 'echo', 'Select-String'], allowedCommands: ['npm test', 'npm run build', 'echo', 'Select-String'],
clineMessages: [ clineMessages: [
@@ -585,6 +785,7 @@ describe('ChatView - Auto Approval Tests', () => {
// Then send the chained command ask message // Then send the chained command ask message
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true, alwaysAllowExecute: true,
allowedCommands: ['npm test', 'Select-String'], allowedCommands: ['npm test', 'Select-String'],
clineMessages: [ clineMessages: [
@@ -643,6 +844,7 @@ describe('ChatView - Auto Approval Tests', () => {
jest.clearAllMocks() jest.clearAllMocks()
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true, alwaysAllowExecute: true,
allowedCommands: ['npm test', 'Select-String'], allowedCommands: ['npm test', 'Select-String'],
clineMessages: [ clineMessages: [
@@ -656,6 +858,7 @@ describe('ChatView - Auto Approval Tests', () => {
}) })
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true, alwaysAllowExecute: true,
allowedCommands: ['npm test', 'Select-String'], allowedCommands: ['npm test', 'Select-String'],
clineMessages: [ clineMessages: [
@@ -688,6 +891,7 @@ describe('ChatView - Auto Approval Tests', () => {
jest.clearAllMocks() jest.clearAllMocks()
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true, alwaysAllowExecute: true,
allowedCommands: ['npm test', 'Select-String'], allowedCommands: ['npm test', 'Select-String'],
clineMessages: [ clineMessages: [
@@ -701,6 +905,7 @@ describe('ChatView - Auto Approval Tests', () => {
}) })
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true, alwaysAllowExecute: true,
allowedCommands: ['npm test', 'Select-String'], allowedCommands: ['npm test', 'Select-String'],
clineMessages: [ clineMessages: [
@@ -748,6 +953,7 @@ describe('ChatView - Sound Playing Tests', () => {
// First hydrate state with initial task and streaming // First hydrate state with initial task and streaming
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true, alwaysAllowBrowser: true,
clineMessages: [ clineMessages: [
{ {
@@ -768,6 +974,7 @@ describe('ChatView - Sound Playing Tests', () => {
// Then send the browser action ask message (streaming finished) // Then send the browser action ask message (streaming finished)
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true, alwaysAllowBrowser: true,
clineMessages: [ clineMessages: [
{ {
@@ -807,6 +1014,7 @@ describe('ChatView - Sound Playing Tests', () => {
// First hydrate state with initial task and streaming // First hydrate state with initial task and streaming
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: false, alwaysAllowBrowser: false,
clineMessages: [ clineMessages: [
{ {
@@ -827,6 +1035,7 @@ describe('ChatView - Sound Playing Tests', () => {
// Then send the browser action ask message (streaming finished) // Then send the browser action ask message (streaming finished)
mockPostMessage({ mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: false, alwaysAllowBrowser: false,
clineMessages: [ clineMessages: [
{ {

View File

@@ -63,6 +63,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setCustomPrompts: (value: CustomPrompts) => void setCustomPrompts: (value: CustomPrompts) => void
enhancementApiConfigId?: string enhancementApiConfigId?: string
setEnhancementApiConfigId: (value: string) => void setEnhancementApiConfigId: (value: string) => void
autoApprovalEnabled?: boolean
setAutoApprovalEnabled: (value: boolean) => void
} }
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined) export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -121,11 +123,22 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const message: ExtensionMessage = event.data const message: ExtensionMessage = event.data
switch (message.type) { switch (message.type) {
case "state": { case "state": {
const newState = message.state!
// Set autoApprovalEnabled to true if undefined and any individual flag is true
if (newState.autoApprovalEnabled === undefined) {
newState.autoApprovalEnabled = !!(
newState.alwaysAllowBrowser ||
newState.alwaysAllowReadOnly ||
newState.alwaysAllowWrite ||
newState.alwaysAllowExecute ||
newState.alwaysAllowMcp ||
newState.alwaysApproveResubmit)
}
setState(prevState => ({ setState(prevState => ({
...prevState, ...prevState,
...message.state! ...newState
})) }))
const config = message.state?.apiConfiguration const config = newState.apiConfiguration
const hasKey = checkExistKey(config) const hasKey = checkExistKey(config)
setShowWelcome(!hasKey) setShowWelcome(!hasKey)
setDidHydrateState(true) setDidHydrateState(true)
@@ -237,6 +250,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })), setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })),
setCustomPrompts: (value) => setState((prevState) => ({ ...prevState, customPrompts: value })), setCustomPrompts: (value) => setState((prevState) => ({ ...prevState, customPrompts: value })),
setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })), setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })),
setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })),
} }
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider> return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>