diff --git a/.changeset/wise-phones-mate.md b/.changeset/wise-phones-mate.md new file mode 100644 index 0000000000..7be57b25e9 --- /dev/null +++ b/.changeset/wise-phones-mate.md @@ -0,0 +1,5 @@ +--- +"claude-dev": minor +--- + +Allowing the user to give feedback when approving a tool use. diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 127960f073..de73e7fb3a 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -1542,33 +1542,29 @@ export class Cline { const askApproval = async (type: ClineAsk, partialMessage?: string) => { const { response, text, images } = await this.ask(type, partialMessage, false) if (response !== "yesButtonClicked") { + // User did NOT approve (rejected) if (response === "messageResponse") { + // Rejection WITH feedback await this.say("user_feedback", text, images) pushToolResult(formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images)) - // this.userMessageContent.push({ - // type: "text", - // text: `${toolDescription()}`, - // }) - // this.toolResults.push({ - // type: "tool_result", - // tool_use_id: toolUseId, - // content: this.formatToolResponseWithImages( - // await this.formatToolDeniedFeedback(text), - // images - // ), - // }) + this.didRejectTool = true return false } + // Rejection WITHOUT explicit feedback pushToolResult(formatResponse.toolDenied()) - // this.toolResults.push({ - // type: "tool_result", - // tool_use_id: toolUseId, - // content: await this.formatToolDenied(), - // }) - this.didRejectTool = true + + this.didRejectTool = true // Prevent further tool uses in this message return false } + + // Handle yesButtonClicked with text (Acceptance WITH feedback) + if (text) { + await this.say("user_feedback", text, images) + pushToolResult(formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text), images)) // Structured feedback to model on approval + } + + // User approved without feedback return true } @@ -1808,11 +1804,14 @@ export class Cline { let didApprove = true const { response, text, images } = await this.ask("tool", completeMessage, false) if (response !== "yesButtonClicked") { + // User did NOT approve (rejected) + // TODO: add similar context for other tool denial responses, to emphasize ie that a command was not run const fileDeniedNote = fileExists ? "The file was not updated, and maintains its original contents." : "The file was not created." if (response === "messageResponse") { + // Rejection WITH feedback await this.say("user_feedback", text, images) pushToolResult( formatResponse.toolResult( @@ -1827,6 +1826,16 @@ export class Cline { this.didRejectTool = true didApprove = false } + } else { + // User approved + + // Handle yesButtonClicked with text (Acceptance WITH feedback) + if (text) { + await this.say("user_feedback", text, images) + pushToolResult( + formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text), images), + ) + } } if (!didApprove) { diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 623e3d8806..8a14acc02b 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -9,6 +9,9 @@ export const formatResponse = { toolDeniedWithFeedback: (feedback?: string) => `The user denied this operation and provided the following feedback:\n\n${feedback}\n`, + toolApprovedWithFeedback: (feedback?: string) => + `The user approved this operation and provided the following feedback:\n\n${feedback}\n`, + toolError: (error?: string) => `The tool execution failed with the following error:\n\n${error}\n`, clineIgnoreError: (path: string) => diff --git a/src/services/browser/BrowserSession.ts b/src/services/browser/BrowserSession.ts index 14b6ecef8b..183979d92a 100644 --- a/src/services/browser/BrowserSession.ts +++ b/src/services/browser/BrowserSession.ts @@ -43,8 +43,9 @@ export class BrowserSession { } const chromeExecutablePath = vscode.workspace.getConfiguration("cline").get("chromeExecutablePath") - if (chromeExecutablePath && !(await fileExistsAtPath(chromeExecutablePath))) + if (chromeExecutablePath && !(await fileExistsAtPath(chromeExecutablePath))) { throw new Error(`Chrome executable not found at path: ${chromeExecutablePath}`) + } const stats: PCRStats = chromeExecutablePath ? { puppeteer: require("puppeteer-core"), executablePath: chromeExecutablePath } : // if chromium doesn't exist, this will download it to path.join(puppeteerDir, ".chromium-browser-snapshots") diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index cad6e84aff..8b4290cd2a 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -324,67 +324,99 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie /* This logic depends on the useEffect[messages] above to set clineAsk, after which buttons are shown and we then send an askResponse to the extension. */ - const handlePrimaryButtonClick = useCallback(() => { - switch (clineAsk) { - case "api_req_failed": - case "command": - case "command_output": - case "tool": - case "browser_action_launch": - case "use_mcp_server": - case "resume_task": - case "mistake_limit_reached": - case "auto_approval_max_req_reached": - vscode.postMessage({ - type: "askResponse", - askResponse: "yesButtonClicked", - }) - break - case "completion_result": - case "resume_completed_task": - // extension waiting for feedback. but we can just present a new task button - startNewTask() - break - } - setTextAreaDisabled(true) - setClineAsk(undefined) - setEnableButtons(false) - // setPrimaryButtonText(undefined) - // setSecondaryButtonText(undefined) - disableAutoScrollRef.current = false - }, [clineAsk, startNewTask]) + const handlePrimaryButtonClick = useCallback( + (text?: string, images?: string[]) => { + const trimmedInput = text?.trim() + switch (clineAsk) { + case "api_req_failed": + case "command": + case "command_output": + case "tool": + case "browser_action_launch": + case "use_mcp_server": + case "resume_task": + case "mistake_limit_reached": + case "auto_approval_max_req_reached": + if (trimmedInput || (images && images.length > 0)) { + vscode.postMessage({ + type: "askResponse", + askResponse: "yesButtonClicked", + text: trimmedInput, + images: images, + }) + } else { + vscode.postMessage({ + type: "askResponse", + askResponse: "yesButtonClicked", + }) + } + // Clear input state after sending + setInputValue("") + setSelectedImages([]) + break + case "completion_result": + case "resume_completed_task": + // extension waiting for feedback. but we can just present a new task button + startNewTask() + break + } + setTextAreaDisabled(true) + setClineAsk(undefined) + setEnableButtons(false) + // setPrimaryButtonText(undefined) + // setSecondaryButtonText(undefined) + disableAutoScrollRef.current = false + }, + [clineAsk, startNewTask], + ) - const handleSecondaryButtonClick = useCallback(() => { - if (isStreaming) { - vscode.postMessage({ type: "cancelTask" }) - setDidClickCancel(true) - return - } + const handleSecondaryButtonClick = useCallback( + (text?: string, images?: string[]) => { + const trimmedInput = text?.trim() + if (isStreaming) { + vscode.postMessage({ type: "cancelTask" }) + setDidClickCancel(true) + return + } - switch (clineAsk) { - case "api_req_failed": - case "mistake_limit_reached": - case "auto_approval_max_req_reached": - startNewTask() - break - case "command": - case "tool": - case "browser_action_launch": - case "use_mcp_server": - // responds to the API with a "This operation failed" and lets it try again - vscode.postMessage({ - type: "askResponse", - askResponse: "noButtonClicked", - }) - break - } - setTextAreaDisabled(true) - setClineAsk(undefined) - setEnableButtons(false) - // setPrimaryButtonText(undefined) - // setSecondaryButtonText(undefined) - disableAutoScrollRef.current = false - }, [clineAsk, startNewTask, isStreaming]) + switch (clineAsk) { + case "api_req_failed": + case "mistake_limit_reached": + case "auto_approval_max_req_reached": + startNewTask() + break + case "command": + case "tool": + case "browser_action_launch": + case "use_mcp_server": + if (trimmedInput || (images && images.length > 0)) { + vscode.postMessage({ + type: "askResponse", + askResponse: "noButtonClicked", + text: trimmedInput, + images: images, + }) + } else { + // responds to the API with a "This operation failed" and lets it try again + vscode.postMessage({ + type: "askResponse", + askResponse: "noButtonClicked", + }) + } + // Clear input state after sending + setInputValue("") + setSelectedImages([]) + break + } + setTextAreaDisabled(true) + setClineAsk(undefined) + setEnableButtons(false) + // setPrimaryButtonText(undefined) + // setSecondaryButtonText(undefined) + disableAutoScrollRef.current = false + }, + [clineAsk, startNewTask, isStreaming], + ) const handleTaskCloseButtonClick = useCallback(() => { startNewTask() @@ -426,10 +458,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie handleSendMessage(message.text ?? "", message.images ?? []) break case "primaryButtonClick": - handlePrimaryButtonClick() + handlePrimaryButtonClick(message.text ?? "", message.images ?? []) break case "secondaryButtonClick": - handleSecondaryButtonClick() + handleSecondaryButtonClick(message.text ?? "", message.images ?? []) break } } @@ -869,7 +901,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie flex: secondaryButtonText ? 1 : 2, marginRight: secondaryButtonText ? "6px" : "0", }} - onClick={handlePrimaryButtonClick}> + onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}> {primaryButtonText} )} @@ -881,7 +913,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie flex: isStreaming ? 2 : 1, marginLeft: isStreaming ? 0 : "6px", }} - onClick={handleSecondaryButtonClick}> + onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}> {isStreaming ? "Cancel" : secondaryButtonText} )}