Compare commits

...
Author SHA1 Message Date
Jose R. Perez 32a931b0cc feat: new in chat stream approval ui 2026-01-28 15:06:49 -08:00
12 changed files with 591 additions and 18 deletions
+9
View File
@@ -42,6 +42,8 @@ service TaskService {
rpc deleteAllTaskHistory(EmptyRequest) returns (DeleteAllTaskHistoryCount);
// Explains changes with AI and adds inline comments to the diff view
rpc explainChanges(ExplainChangesRequest) returns (Empty);
// Sets the approval policy for a specific tool
rpc setToolApprovalPolicy(SetToolApprovalPolicyRequest) returns (Empty);
}
// Request message for creating a new task
@@ -132,3 +134,10 @@ message ExplainChangesRequest {
// Timestamp of the completion message to explain changes for
int64 message_ts = 2;
}
// Request for setting tool approval policy
message SetToolApprovalPolicyRequest {
Metadata metadata = 1;
string tool_name = 2;
string policy = 3; // "ask_everytime" | "auto_approve" | "never_allow"
}
@@ -28,6 +28,49 @@ export async function updateAutoApprovalSettings(controller: Controller, request
},
}
// Check if any action flags were disabled and reset corresponding tool policies
if (request.actions) {
const updatedToolPolicies = { ...currentSettings.toolPolicies }
// If readFiles was disabled, reset all read-related tool policies
if (request.actions.readFiles === false) {
updatedToolPolicies.readFile = "ask_everytime"
updatedToolPolicies.listFilesTopLevel = "ask_everytime"
updatedToolPolicies.listFilesRecursive = "ask_everytime"
updatedToolPolicies.listCodeDefinitionNames = "ask_everytime"
updatedToolPolicies.searchFiles = "ask_everytime"
}
// If editFiles was disabled, reset all edit-related tool policies
if (request.actions.editFiles === false) {
updatedToolPolicies.editedExistingFile = "ask_everytime"
updatedToolPolicies.newFileCreated = "ask_everytime"
}
// If executeSafeCommands was disabled, reset safe command policy
if (request.actions.executeSafeCommands === false) {
updatedToolPolicies.executeSafeCommand = "ask_everytime"
}
// If executeAllCommands was disabled, reset risky command policy
if (request.actions.executeAllCommands === false) {
updatedToolPolicies.executeRiskyCommand = "ask_everytime"
}
// If useBrowser was disabled, reset browser policy
if (request.actions.useBrowser === false) {
updatedToolPolicies.useBrowser = "ask_everytime"
}
// If useMcp was disabled, reset MCP policies
if (request.actions.useMcp === false) {
updatedToolPolicies.useMcpTool = "ask_everytime"
updatedToolPolicies.accessMcpResource = "ask_everytime"
}
settings.toolPolicies = updatedToolPolicies
}
controller.stateManager.setGlobalState("autoApprovalSettings", settings)
await controller.postStateToWebview()
@@ -0,0 +1,78 @@
import { ToolApprovalPolicy } from "../../../shared/AutoApprovalSettings"
import { Empty } from "../../../shared/proto/cline/common"
import { SetToolApprovalPolicyRequest } from "../../../shared/proto/cline/task"
import { Controller } from ".."
/**
* Sets the approval policy for a specific tool
* @param controller The controller instance
* @param request The request containing the tool name and policy
* @returns Empty response
*/
export async function setToolApprovalPolicy(controller: Controller, request: SetToolApprovalPolicyRequest): Promise<Empty> {
const { toolName, policy } = request
// Validate policy value
const validPolicies: ToolApprovalPolicy[] = ["ask_everytime", "auto_approve", "never_allow"]
if (!validPolicies.includes(policy as ToolApprovalPolicy)) {
throw new Error(`Invalid policy: ${policy}. Must be one of: ${validPolicies.join(", ")}`)
}
// Get current auto approval settings
const currentSettings = (await controller.getStateToPostToWebview()).autoApprovalSettings
// Map tool names to their corresponding action flags
const getActionFlag = (toolName: string): keyof typeof currentSettings.actions | null => {
switch (toolName) {
case "readFile":
case "listFilesTopLevel":
case "listFilesRecursive":
case "listCodeDefinitionNames":
case "searchFiles":
return "readFiles"
case "editedExistingFile":
case "newFileCreated":
return "editFiles"
case "executeSafeCommand":
return "executeSafeCommands"
case "executeRiskyCommand":
return "executeAllCommands"
case "useBrowser":
return "useBrowser"
case "useMcpTool":
case "accessMcpResource":
return "useMcp"
default:
return null
}
}
// Update tool policies
const updatedSettings = {
...currentSettings,
toolPolicies: {
...currentSettings.toolPolicies,
[toolName]: policy as ToolApprovalPolicy,
},
version: currentSettings.version + 1, // Increment version for race condition prevention
}
// If setting to auto_approve, also enable the corresponding legacy action flag
if (policy === "auto_approve") {
const actionFlag = getActionFlag(toolName)
if (actionFlag) {
updatedSettings.actions = {
...updatedSettings.actions,
[actionFlag]: true,
}
}
}
// Save to global state
controller.stateManager.setGlobalState("autoApprovalSettings", updatedSettings)
// Notify all webviews of the update
await controller.postStateToWebview()
return Empty.create()
}
+61
View File
@@ -64,6 +64,19 @@ export class AutoApprove {
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
// Check tool-specific policies first (granular control)
const toolPolicyKey = this.getToolPolicyKey(toolName)
if (toolPolicyKey && autoApprovalSettings.toolPolicies) {
const policy = autoApprovalSettings.toolPolicies[toolPolicyKey as keyof typeof autoApprovalSettings.toolPolicies]
if (policy === "auto_approve") {
return [true, true] // Auto-approve both local and external
} else if (policy === "never_allow") {
return [false, false] // Never auto-approve
}
// If "ask_everytime", fall through to legacy logic
}
// Fall back to legacy broad category flags
switch (toolName) {
case ClineDefaultTool.FILE_READ:
case ClineDefaultTool.LIST_FILES:
@@ -92,6 +105,54 @@ export class AutoApprove {
return false
}
/**
* Map ClineDefaultTool enum to toolPolicies key
*/
private getToolPolicyKey(
toolName: ClineDefaultTool,
):
| "readFile"
| "editedExistingFile"
| "newFileCreated"
| "fileDeleted"
| "listFilesTopLevel"
| "listFilesRecursive"
| "listCodeDefinitionNames"
| "searchFiles"
| "executeSafeCommand"
| "executeRiskyCommand"
| "useBrowser"
| "useMcpTool"
| "accessMcpResource"
| null {
switch (toolName) {
case ClineDefaultTool.FILE_READ:
return "readFile"
case ClineDefaultTool.FILE_EDIT:
case ClineDefaultTool.APPLY_PATCH:
return "editedExistingFile"
case ClineDefaultTool.FILE_NEW:
return "newFileCreated"
case ClineDefaultTool.LIST_FILES:
return "listFilesTopLevel" // Note: This covers both top-level and recursive
case ClineDefaultTool.LIST_CODE_DEF:
return "listCodeDefinitionNames"
case ClineDefaultTool.SEARCH:
return "searchFiles"
case ClineDefaultTool.BASH:
// Note: BASH tool returns tuple [safe, risky], so this is handled specially in shouldAutoApproveTool
return null
case ClineDefaultTool.BROWSER:
return "useBrowser"
case ClineDefaultTool.MCP_USE:
return "useMcpTool"
case ClineDefaultTool.MCP_ACCESS:
return "accessMcpResource"
default:
return null
}
}
// Check if the tool should be auto-approved based on the settings
// and the path of the action. Returns true if the tool should be auto-approved
// based on the user's settings and the path of the action.
+18
View File
@@ -1,3 +1,5 @@
export type ToolApprovalPolicy = "ask_everytime" | "auto_approve" | "never_allow"
export interface AutoApprovalSettings {
// Version for race condition prevention (incremented on every change)
version: number
@@ -21,6 +23,22 @@ export interface AutoApprovalSettings {
useBrowser: boolean // Use browser
useMcp: boolean // Use MCP servers
}
// Per-tool approval policies (granular control)
toolPolicies?: {
readFile?: ToolApprovalPolicy
editedExistingFile?: ToolApprovalPolicy
newFileCreated?: ToolApprovalPolicy
fileDeleted?: ToolApprovalPolicy
listFilesTopLevel?: ToolApprovalPolicy
listFilesRecursive?: ToolApprovalPolicy
listCodeDefinitionNames?: ToolApprovalPolicy
searchFiles?: ToolApprovalPolicy
executeSafeCommand?: ToolApprovalPolicy
executeRiskyCommand?: ToolApprovalPolicy
useBrowser?: ToolApprovalPolicy
useMcpTool?: ToolApprovalPolicy
accessMcpResource?: ToolApprovalPolicy
}
// Global settings
enableNotifications: boolean // Show notifications for approval and task completion
}
@@ -1,6 +1,7 @@
import { BROWSER_VIEWPORT_PRESETS } from "@shared/BrowserSettings"
import { BrowserAction, BrowserActionResult, ClineMessage, ClineSayBrowserAction } from "@shared/ExtensionMessage"
import { StringRequest } from "@shared/proto/cline/common"
import { AskResponseRequest, SetToolApprovalPolicyRequest } from "@shared/proto/cline/task"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import deepEqual from "fast-deep-equal"
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
@@ -12,7 +13,8 @@ import { ChatRowContent, ProgressIndicator } from "@/components/chat/ChatRow"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { cn } from "@/lib/utils"
import { FileServiceClient } from "@/services/grpc-client"
import { FileServiceClient, TaskServiceClient } from "@/services/grpc-client"
import { InlineApprovalCard } from "./InlineApprovalCard"
interface BrowserSessionRowProps {
messages: ClineMessage[]
@@ -106,11 +108,49 @@ const headerStyle: CSSProperties = {
const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
const { messages, isLast, onHeightChange, lastModifiedMessage, onSetQuote } = props
const { browserSettings } = useExtensionState()
const { browserSettings, autoApprovalSettings } = useExtensionState()
const prevHeightRef = useRef(0)
const [maxActionHeight, setMaxActionHeight] = useState(0)
const [consoleLogsExpanded, setConsoleLogsExpanded] = useState(false)
// Approval handlers
const handleApprove = useCallback(async () => {
try {
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
} catch (error) {
console.error("Error approving browser:", error)
}
}, [])
const handleReject = useCallback(async () => {
try {
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "noButtonClicked",
}),
)
} catch (error) {
console.error("Error rejecting browser:", error)
}
}, [])
const handlePolicyChange = useCallback(async (toolName: string, policy: "ask_everytime" | "auto_approve" | "never_allow") => {
try {
await TaskServiceClient.setToolApprovalPolicy(
SetToolApprovalPolicyRequest.create({
toolName,
policy,
}),
)
} catch (error) {
console.error("Error saving browser approval policy:", error)
}
}, [])
const isLastApiReqInterrupted = useMemo(() => {
// Check if last api_req_started is cancelled
const lastApiReqStarted = [...messages].reverse().find((m) => m.say === "api_req_started")
@@ -341,10 +381,11 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
// shouldShowCheckpoints = lastModifiedMessage?.ask === "resume_completed_task" || lastModifiedMessage?.ask === "resume_task"
// }
const _shouldShowSettings = useMemo(() => {
// Check if we should show approval (when last message is asking for browser launch AND it's the last message in chat)
const showBrowserApproval = useMemo(() => {
const lastMessage = messages[messages.length - 1]
return lastMessage?.ask === "browser_action_launch" || lastMessage?.say === "browser_action_launch"
}, [messages])
return lastMessage?.ask === "browser_action_launch" && isLast
}, [messages, isLast])
// Calculate maxWidth
const maxWidth = browserSettings.viewport.width < BROWSER_VIEWPORT_PRESETS["Small Desktop (900x600)"].width ? 200 : undefined
@@ -365,13 +406,15 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
</div>
<div
style={{
borderRadius: 3,
borderRadius: showBrowserApproval ? "3px 3px 0 0" : 3,
border: "1px solid var(--vscode-editorGroup-border)",
// overflow: "hidden",
backgroundColor: CODE_BLOCK_BG_COLOR,
// marginBottom: 10,
maxWidth,
margin: "0 auto 10px auto", // Center the container
marginLeft: "auto",
marginRight: "auto",
marginTop: 0,
marginBottom: showBrowserApproval ? 0 : 10, // Remove bottom margin when approval shown
}}>
{/* URL Bar */}
<div style={urlBarContainerStyle}>
@@ -451,6 +494,18 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
{/* Action content with min height */}
<div style={{ minHeight: maxActionHeight }}>{actionContent}</div>
{/* Inline approval card for browser launch */}
{showBrowserApproval && (
<InlineApprovalCard
approvalPolicy={autoApprovalSettings?.toolPolicies?.useBrowser || "ask_everytime"}
isEnabled={true}
onApprove={handleApprove}
onPolicyChange={handlePolicyChange}
onReject={handleReject}
toolName="useBrowser"
/>
)}
{/* Pagination moved to bottom */}
{pages.length > 1 && (
<div style={paginationContainerStyle}>
+197 -5
View File
@@ -1,4 +1,4 @@
import { COMMAND_OUTPUT_STRING } from "@shared/combineCommandSequences"
import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "@shared/combineCommandSequences"
import {
ClineApiReqInfo,
ClineAskQuestion,
@@ -10,6 +10,7 @@ import {
COMPLETION_RESULT_CHANGES_FLAG,
} from "@shared/ExtensionMessage"
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
import { AskResponseRequest, SetToolApprovalPolicyRequest } from "@shared/proto/cline/task"
import { Mode } from "@shared/storage/types"
import deepEqual from "fast-deep-equal"
import {
@@ -46,7 +47,7 @@ import McpResourceRow from "@/components/mcp/configuration/tabs/installed/server
import McpToolRow from "@/components/mcp/configuration/tabs/installed/server-row/McpToolRow"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { cn } from "@/lib/utils"
import { FileServiceClient, UiServiceClient } from "@/services/grpc-client"
import { FileServiceClient, TaskServiceClient, UiServiceClient } from "@/services/grpc-client"
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "@/utils/mcp"
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
import { CommandOutputContent, CommandOutputRow } from "./CommandOutputRow"
@@ -54,6 +55,7 @@ import { CompletionOutputRow } from "./CompletionOutputRow"
import { DiffEditRow } from "./DiffEditRow"
import ErrorRow from "./ErrorRow"
import HookMessage from "./HookMessage"
import { InlineApprovalCard } from "./InlineApprovalCard"
import { MarkdownRow } from "./MarkdownRow"
import NewTaskPreview from "./NewTaskPreview"
import PlanCompletionOutputRow from "./PlanCompletionOutputRow"
@@ -153,6 +155,7 @@ export const ChatRowContent = memo(
onRelinquishControl,
vscodeTerminalExecutionMode,
clineMessages,
autoApprovalSettings,
} = useExtensionState()
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
const [explainChangesDisabled, setExplainChangesDisabled] = useState(false)
@@ -397,6 +400,66 @@ export const ChatRowContent = memo(
return extension ? imageExtensions.includes(`.${extension}`) : false
}
// Helper function to get approval policy for a tool
const getToolPolicy = useCallback(
(toolName: string): "ask_everytime" | "auto_approve" | "never_allow" => {
// Map tool names to their keys in toolPolicies
const toolKey = toolName.replace(/ /g, "").replace(/\(|\)/g, "")
const policyKey = toolKey.charAt(0).toLowerCase() + toolKey.slice(1)
return (
autoApprovalSettings?.toolPolicies?.[policyKey as keyof typeof autoApprovalSettings.toolPolicies] ||
"ask_everytime"
)
},
[autoApprovalSettings],
)
// Approval handlers for inline approval cards
const [approvalPolicy, setApprovalPolicy] = useState<"ask_everytime" | "auto_approve" | "never_allow">("ask_everytime")
const handleApprove = useCallback(async () => {
try {
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
} catch (error) {
console.error("Error approving tool:", error)
}
}, [])
const handleReject = useCallback(async () => {
try {
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "noButtonClicked",
}),
)
} catch (error) {
console.error("Error rejecting tool:", error)
}
}, [])
const handlePolicyChange = useCallback(
async (toolName: string, policy: "ask_everytime" | "auto_approve" | "never_allow") => {
setApprovalPolicy(policy)
// Save policy to backend
try {
await TaskServiceClient.setToolApprovalPolicy(
SetToolApprovalPolicyRequest.create({
toolName,
policy,
}),
)
} catch (error) {
console.error("Error saving tool approval policy:", error)
}
},
[],
)
if (conditionalRulesInfo) {
const names = conditionalRulesInfo.rules.map((r: { name: string }) => r.name).join(", ")
return (
@@ -431,6 +494,7 @@ export const ChatRowContent = memo(
const editToolTitle = isApplyingPatch
? "Cline is creating patches to edit this file:"
: "Cline wants to edit this file:"
const showEditApproval = message.ask === "tool" && isLast
return (
<div>
<div className={HEADER_CLASSNAMES}>
@@ -441,6 +505,7 @@ export const ChatRowContent = memo(
</div>
{backgroundEditEnabled && tool.path && tool.content ? (
<DiffEditRow
hasApproval={showEditApproval}
isLoading={message.partial}
patch={tool.content}
path={tool.path}
@@ -450,14 +515,27 @@ export const ChatRowContent = memo(
<CodeAccordian
// isLoading={message.partial}
code={tool.content}
hasApproval={showEditApproval}
isExpanded={isExpanded}
onToggleExpand={handleToggle}
path={tool.path!}
/>
)}
{showEditApproval && (
<InlineApprovalCard
approvalPolicy={getToolPolicy("editedExistingFile")}
approveButtonLabel="Save"
isEnabled={true}
onApprove={handleApprove}
onPolicyChange={handlePolicyChange}
onReject={handleReject}
toolName="editedExistingFile"
/>
)}
</div>
)
case "fileDeleted":
const showDeleteApproval = message.ask === "tool" && isLast
return (
<div>
<div className={HEADER_CLASSNAMES}>
@@ -469,13 +547,25 @@ export const ChatRowContent = memo(
<CodeAccordian
// isLoading={message.partial}
code={tool.content}
hasApproval={showDeleteApproval}
isExpanded={isExpanded}
onToggleExpand={handleToggle}
path={tool.path!}
/>
{showDeleteApproval && (
<InlineApprovalCard
approvalPolicy={getToolPolicy("fileDeleted")}
isEnabled={true}
onApprove={handleApprove}
onPolicyChange={handlePolicyChange}
onReject={handleReject}
toolName="fileDeleted"
/>
)}
</div>
)
case "newFileCreated":
const showNewFileApproval = message.ask === "tool" && isLast
return (
<div>
<div className={HEADER_CLASSNAMES}>
@@ -485,20 +575,38 @@ export const ChatRowContent = memo(
<span className="font-bold">Cline wants to create a new file:</span>
</div>
{backgroundEditEnabled && tool.path && tool.content ? (
<DiffEditRow patch={tool.content} path={tool.path} startLineNumbers={tool.startLineNumbers} />
<DiffEditRow
hasApproval={showNewFileApproval}
patch={tool.content}
path={tool.path}
startLineNumbers={tool.startLineNumbers}
/>
) : (
<CodeAccordian
code={tool.content!}
hasApproval={showNewFileApproval}
isExpanded={isExpanded}
isLoading={message.partial}
onToggleExpand={handleToggle}
path={tool.path!}
/>
)}
{showNewFileApproval && (
<InlineApprovalCard
approvalPolicy={getToolPolicy("newFileCreated")}
approveButtonLabel="Save"
isEnabled={true}
onApprove={handleApprove}
onPolicyChange={handlePolicyChange}
onReject={handleReject}
toolName="newFileCreated"
/>
)}
</div>
)
case "readFile":
const isImage = isImageFile(tool.path || "")
const showReadApproval = message.ask === "tool" && isLast
return (
<div>
<div className={HEADER_CLASSNAMES}>
@@ -507,7 +615,11 @@ export const ChatRowContent = memo(
toolIcon("sign-out", "yellow", -90, "This file is outside of your workspace")}
<span className="font-bold">Cline wants to read this file:</span>
</div>
<div className="bg-code rounded-sm overflow-hidden border border-editor-group-border">
<div
className={cn("bg-code overflow-hidden border border-editor-group-border", {
"rounded-sm": !showReadApproval,
"rounded-t-sm": showReadApproval,
})}>
<div
className={cn("text-description flex items-center cursor-pointer select-none py-2 px-2.5", {
"cursor-default select-text": isImage,
@@ -528,9 +640,21 @@ export const ChatRowContent = memo(
{!isImage && <SquareArrowOutUpRightIcon className="size-2" />}
</div>
</div>
{showReadApproval && (
<InlineApprovalCard
approvalPolicy={getToolPolicy("readFile")}
approveButtonLabel="Read"
isEnabled={true}
onApprove={handleApprove}
onPolicyChange={handlePolicyChange}
onReject={handleReject}
toolName="readFile"
/>
)}
</div>
)
case "listFilesTopLevel":
const showListTopLevelApproval = message.ask === "tool" && isLast
return (
<div>
<div className={HEADER_CLASSNAMES}>
@@ -545,14 +669,26 @@ export const ChatRowContent = memo(
</div>
<CodeAccordian
code={tool.content!}
hasApproval={showListTopLevelApproval}
isExpanded={isExpanded}
language="shell-session"
onToggleExpand={handleToggle}
path={tool.path!}
/>
{showListTopLevelApproval && (
<InlineApprovalCard
approvalPolicy={getToolPolicy("listFilesTopLevel")}
isEnabled={true}
onApprove={handleApprove}
onPolicyChange={handlePolicyChange}
onReject={handleReject}
toolName="listFilesTopLevel"
/>
)}
</div>
)
case "listFilesRecursive":
const showListRecursiveApproval = message.ask === "tool" && isLast
return (
<div>
<div className={HEADER_CLASSNAMES}>
@@ -567,14 +703,26 @@ export const ChatRowContent = memo(
</div>
<CodeAccordian
code={tool.content!}
hasApproval={showListRecursiveApproval}
isExpanded={isExpanded}
language="shell-session"
onToggleExpand={handleToggle}
path={tool.path!}
/>
{showListRecursiveApproval && (
<InlineApprovalCard
approvalPolicy={getToolPolicy("listFilesRecursive")}
isEnabled={true}
onApprove={handleApprove}
onPolicyChange={handlePolicyChange}
onReject={handleReject}
toolName="listFilesRecursive"
/>
)}
</div>
)
case "listCodeDefinitionNames":
const showListCodeApproval = message.ask === "tool" && isLast
return (
<div>
<div className={HEADER_CLASSNAMES}>
@@ -589,10 +737,21 @@ export const ChatRowContent = memo(
</div>
<CodeAccordian
code={tool.content!}
hasApproval={showListCodeApproval}
isExpanded={isExpanded}
onToggleExpand={handleToggle}
path={tool.path!}
/>
{showListCodeApproval && (
<InlineApprovalCard
approvalPolicy={getToolPolicy("listCodeDefinitionNames")}
isEnabled={true}
onApprove={handleApprove}
onPolicyChange={handlePolicyChange}
onReject={handleReject}
toolName="listCodeDefinitionNames"
/>
)}
</div>
)
case "searchFiles":
@@ -747,17 +906,29 @@ export const ChatRowContent = memo(
}, [isCommandMessage, isCommandExecuting, isExpanded, onToggleExpand, message.ts])
if (message.ask === "command" || message.say === "command") {
const showCommandApproval = message.ask === "command" && isLast
const commandText = message.text || ""
// Check if command is risky by looking for COMMAND_REQ_APP_STRING marker
const isRiskyCommand = commandText.includes(COMMAND_REQ_APP_STRING)
const commandToolName = isRiskyCommand ? "executeRiskyCommand" : "executeSafeCommand"
return (
<CommandOutputRow
approvalPolicy={getToolPolicy(commandToolName)}
icon={icon}
isBackgroundExec={vscodeTerminalExecutionMode === "backgroundExec"}
isCommandCompleted={isCommandCompleted}
isCommandExecuting={isCommandExecuting}
isCommandPending={isCommandPending}
isOutputFullyExpanded={isOutputFullyExpanded}
isRiskyCommand={isRiskyCommand}
message={message}
onApprove={handleApprove}
onCancelCommand={onCancelCommand}
onPolicyChange={handlePolicyChange}
onReject={handleReject}
setIsOutputFullyExpanded={setIsOutputFullyExpanded}
showApproval={showCommandApproval}
title={title}
/>
)
@@ -766,6 +937,9 @@ export const ChatRowContent = memo(
if (message.ask === "use_mcp_server" || message.say === "use_mcp_server") {
const useMcpServer = JSON.parse(message.text || "{}") as ClineAskUseMcpServer
const server = mcpServers.find((server) => server.name === useMcpServer.serverName)
// Only show approval if this is the last message and it's asking for approval
const showMcpApproval = message.ask === "use_mcp_server" && isLast
return (
<div>
<div className={HEADER_CLASSNAMES}>
@@ -773,7 +947,11 @@ export const ChatRowContent = memo(
{title}
</div>
<div className="bg-code rounded-xs py-2 px-2.5 mt-2">
<div
className={cn("bg-code py-2 px-2.5 mt-2 border border-editor-group-border", {
"rounded-xs": !showMcpApproval,
"rounded-t-xs": showMcpApproval,
})}>
{useMcpServer.type === "access_mcp_resource" && (
<McpResourceRow
item={{
@@ -821,6 +999,20 @@ export const ChatRowContent = memo(
</div>
)}
</div>
{showMcpApproval && (
<InlineApprovalCard
approvalPolicy={
useMcpServer.type === "use_mcp_tool"
? autoApprovalSettings?.toolPolicies?.useMcpTool || "ask_everytime"
: autoApprovalSettings?.toolPolicies?.accessMcpResource || "ask_everytime"
}
isEnabled={true}
onApprove={handleApprove}
onPolicyChange={handlePolicyChange}
onReject={handleReject}
toolName={useMcpServer.type === "use_mcp_tool" ? "useMcpTool" : "accessMcpResource"}
/>
)}
</div>
)
}
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
import { FileServiceClient } from "@/services/grpc-client"
import CodeBlock from "../common/CodeBlock"
import ExpandHandle from "./ExpandHandle"
import { InlineApprovalCard } from "./InlineApprovalCard"
export const CommandOutputContent = memo(
({
@@ -120,6 +121,12 @@ export const CommandOutputRow = memo(
title,
isOutputFullyExpanded,
setIsOutputFullyExpanded,
showApproval = false,
approvalPolicy = "ask_everytime",
onApprove,
onReject,
onPolicyChange,
isRiskyCommand = false,
}: {
message: ClineMessage
isCommandExecuting?: boolean
@@ -131,6 +138,12 @@ export const CommandOutputRow = memo(
title?: JSX.Element | null
isOutputFullyExpanded: boolean
setIsOutputFullyExpanded: (expanded: boolean) => void
showApproval?: boolean
approvalPolicy?: "ask_everytime" | "auto_approve" | "never_allow"
onApprove?: () => void
onReject?: () => void
onPolicyChange?: (toolName: string, policy: "ask_everytime" | "auto_approve" | "never_allow") => void
isRiskyCommand?: boolean
}) => {
const splitMessage = (text: string) => {
const outputIndex = text.indexOf(COMMAND_OUTPUT_STRING)
@@ -209,7 +222,10 @@ export const CommandOutputRow = memo(
<>
{commandHeader}
<div
className="bg-code rounded-sm border border-editor-group-border"
className={cn("bg-code border border-editor-group-border", {
"rounded-sm": !showApproval,
"rounded-t-sm": showApproval,
})}
style={{
transition: "all 0.3s ease-in-out",
}}>
@@ -282,6 +298,17 @@ export const CommandOutputRow = memo(
<span>The model has determined this command requires explicit approval.</span>
</div>
)}
{showApproval && onApprove && onReject && onPolicyChange && (
<InlineApprovalCard
approvalPolicy={approvalPolicy}
approveButtonLabel="Run"
isEnabled={true}
onApprove={onApprove}
onPolicyChange={onPolicyChange}
onReject={onReject}
toolName={isRiskyCommand ? "executeRiskyCommand" : "executeSafeCommand"}
/>
)}
</>
)
},
@@ -34,9 +34,10 @@ interface DiffEditRowProps {
path: string
isLoading?: boolean
startLineNumbers?: number[]
hasApproval?: boolean
}
export const DiffEditRow = memo<DiffEditRowProps>(({ patch, path, isLoading, startLineNumbers }) => {
export const DiffEditRow = memo<DiffEditRowProps>(({ patch, path, isLoading, startLineNumbers, hasApproval }) => {
const { parsedFiles, isStreaming } = useMemo(() => {
const parsed = parsePatch(patch, path)
return {
@@ -50,7 +51,7 @@ export const DiffEditRow = memo<DiffEditRowProps>(({ patch, path, isLoading, sta
}
return (
<div className="space-y-4 rounded-xs">
<div className={cn("space-y-4", { "rounded-xs": !hasApproval, "rounded-t-xs": hasApproval })}>
{parsedFiles.map((file, index) => (
<FileBlock
file={file}
@@ -0,0 +1,79 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
interface InlineApprovalCardProps {
toolName: string
toolDescription?: string
approvalPolicy: "ask_everytime" | "auto_approve" | "never_allow"
onApprove: () => void
onReject: () => void
onPolicyChange: (toolName: string, policy: "ask_everytime" | "auto_approve" | "never_allow") => void
isEnabled: boolean
showDetails?: boolean
onToggleDetails?: () => void
approveButtonLabel?: string
}
export const InlineApprovalCard: React.FC<InlineApprovalCardProps> = ({
toolName,
toolDescription,
approvalPolicy,
onApprove,
onReject,
onPolicyChange,
isEnabled,
showDetails = false,
onToggleDetails,
approveButtonLabel = "Run",
}) => {
const policyLabels = {
ask_everytime: "Ask Everytime",
auto_approve: "Auto-approve",
never_allow: "Never allow",
}
return (
<div className="inline-approval-card bg-code border-x border-b border-editor-group-border rounded-b-sm overflow-hidden">
{/* Approval Actions */}
<div className="flex items-center gap-2 py-2 px-2.5">
{/* Policy Select */}
<Select
disabled={!isEnabled}
onValueChange={(policy) => onPolicyChange(toolName, policy as any)}
value={approvalPolicy}>
<SelectTrigger className="w-auto items-center [&_span]:-mt-px border-0" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ask_everytime">{policyLabels.ask_everytime}</SelectItem>
<SelectItem value="auto_approve">{policyLabels.auto_approve}</SelectItem>
<SelectItem value="never_allow">{policyLabels.never_allow}</SelectItem>
</SelectContent>
</Select>
{/* Spacer */}
<div className="flex-1" />
{/* Action Buttons */}
<button
className="text-foreground hover:text-link text-sm cursor-pointer border-0 bg-transparent px-2 py-[.2rem] disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!isEnabled}
onClick={onReject}>
Cancel
</button>
<button
className="bg-button-background text-button-foreground hover:bg-button-hover border-0 rounded-sm text-sm cursor-pointer px-2 py-[.2rem] disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!isEnabled}
onClick={onApprove}>
{approveButtonLabel}
</button>
</div>
{/* Optional Description */}
{toolDescription && showDetails && (
<div className="py-2 px-2.5 border-t border-editor-group-border text-sm text-muted-foreground">
{toolDescription}
</div>
)}
</div>
)
}
@@ -159,8 +159,12 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
const opacity = canInteract || isStreaming ? 1 : 0.5
// Hide approve/reject buttons but keep cancel button visible
const isApprovalButton =
primaryAction === "approve" || primaryAction === "reject" || secondaryAction === "approve" || secondaryAction === "reject"
return (
<div className="flex px-3.5" style={{ opacity }}>
<div className="flex px-3.5" style={{ opacity, display: isApprovalButton ? "none" : undefined }}>
{primaryText && primaryAction && (
<VSCodeButton
appearance="primary"
@@ -15,6 +15,7 @@ interface CodeAccordianProps {
isExpanded: boolean
onToggleExpand: () => void
isLoading?: boolean
hasApproval?: boolean
}
/*
@@ -35,6 +36,7 @@ const CodeAccordian = ({
isExpanded,
onToggleExpand,
isLoading,
hasApproval,
}: CodeAccordianProps) => {
const inferredLanguage = useMemo(
() => code && (language ?? (path ? getLanguageFromPath(path) : undefined)),
@@ -49,7 +51,11 @@ const CodeAccordian = ({
}, [code])
return (
<div className="bg-code overflow-hidden rounded-xs border border-editor-group-border">
<div
className={cn("bg-code overflow-hidden border border-editor-group-border", {
"rounded-xs": !hasApproval,
"rounded-t-xs": hasApproval,
})}>
{(path || isFeedback || isConsoleLogs) && (
<Button
aria-label={isExpanded ? "Collapse code block" : "Expand code block"}