Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions src/core/assistant-message/presentAssistantMessage.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're duplicating the approval logic in executeCommandTool.ts, could we extract a shared function here that handles the command approval flow? This would help maintain consistency and reduce code duplication.

For example:

export async function handleCommandApproval(
  cline: Cline,
  command: string,
  options?: { allowWhitelist?: boolean }
): Promise<{ approved: boolean; addToWhitelist?: boolean }>

Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,14 @@ export async function presentAssistantMessage(cline: Task) {
isProtected || false,
)

if (response !== "yesButtonClicked") {
if (response === "yesButtonClicked" || response === "addAndRunButtonClicked") {
// Handle yesButtonClicked or addAndRunButtonClicked with text.
if (text) {
await cline.say("user_feedback", text, images)
pushToolResult(formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text), images))
}
return true
} else {
// Handle both messageResponse and noButtonClicked with text.
if (text) {
await cline.say("user_feedback", text, images)
Expand All @@ -282,14 +289,6 @@ export async function presentAssistantMessage(cline: Task) {
cline.didRejectTool = true
return false
}

// Handle yesButtonClicked with text.
if (text) {
await cline.say("user_feedback", text, images)
pushToolResult(formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text), images))
}

return true
}

const askFinishSubTaskApproval = async () => {
Expand Down
37 changes: 35 additions & 2 deletions src/core/tools/executeCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,42 @@ export async function executeCommandTool(
cline.consecutiveMistakeCount = 0

command = unescapeHtmlEntities(command) // Unescape HTML entities.
const didApprove = await askApproval("command", command)

if (!didApprove) {
// We need to capture the actual response to check if "Add & Run" was clicked
const { response, text, images } = await cline.ask("command", command)

if (response === "yesButtonClicked" || response === "addAndRunButtonClicked") {
// Handle yesButtonClicked or addAndRunButtonClicked with text.
if (text) {
await cline.say("user_feedback", text, images)
}

// Check if user selected "Add & Run" to add command to whitelist
if (response === "addAndRunButtonClicked") {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates the approval logic from presentAssistantMessage.ts. Could we refactor to reuse the existing askApproval function instead?

The duplication could lead to maintenance issues if the approval flow needs to be updated in the future. Consider extracting the whitelist addition logic into a shared function that both places can call.

const clineProvider = await cline.providerRef.deref()
if (clineProvider) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing error handling here. What happens if providerRef.deref() returns undefined? The code continues to line 68 and would throw an error when trying to call getState() on undefined.

Consider adding proper error handling or early return:

if (!clineProvider) {
  // Log error or handle gracefully
  return;
}

const state = await clineProvider.getState()
const currentCommands = state.allowedCommands ?? []

// Add command to whitelist if not already present
if (!currentCommands.includes(command)) {
const newCommands = [...currentCommands, command]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation whitelists the entire command including arguments (e.g., "gh pr checkout 1234"), but shouldn't we be whitelisting just the base command pattern (e.g., "gh pr checkout")?

The current approach defeats the purpose of intelligent whitelisting since each specific command with different arguments would need separate approval. For example:

  • gh pr checkout 1234
  • gh pr checkout 5678
  • gh pr checkout 9999

Would all be treated as different commands requiring individual whitelisting.

Could we extract the base command pattern before adding to the whitelist? The validateCommand function in webview-ui/src/utils/command-validation.ts already uses prefix matching, so we should align with that approach.

await clineProvider.setValue("allowedCommands", newCommands)

// Notify webview of the updated commands
await clineProvider.postMessageToWebview({
type: "invoke",
invoke: "setChatBoxMessage",
text: `Command "${command}" added to whitelist.`,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This user-facing message should be internationalized. Consider using the t() function:

text: t('command.whitelist_added', { command }),

Also, is "whitelist" the best user-facing term here? Maybe "allowed commands" or "trusted commands" would be clearer?

})
}
}
}
} else {
// Handle both messageResponse and noButtonClicked with text.
if (text) {
await cline.say("user_feedback", text, images)
}
return
}

Expand Down
8 changes: 7 additions & 1 deletion src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,13 @@ export interface ExtensionMessage {
| "didBecomeVisible"
| "focusInput"
| "switchTab"
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
invoke?:
| "newChat"
| "sendMessage"
| "primaryButtonClick"
| "secondaryButtonClick"
| "tertiaryButtonClick"
| "setChatBoxMessage"
state?: ExtensionState
images?: string[]
filePaths?: string[]
Expand Down
7 changes: 6 additions & 1 deletion src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import { marketplaceItemSchema } from "@roo-code/types"

import { Mode } from "./modes"

export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse"
export type ClineAskResponse =
| "yesButtonClicked"
| "noButtonClicked"
| "addAndRunButtonClicked"
| "messageResponse"
| "objectResponse"

export type PromptMode = Mode | "enhance"

Expand Down
219 changes: 166 additions & 53 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const [enableButtons, setEnableButtons] = useState<boolean>(false)
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
const [tertiaryButtonText, setTertiaryButtonText] = useState<string | undefined>(undefined)
const [didClickCancel, setDidClickCancel] = useState(false)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
Expand Down Expand Up @@ -312,7 +313,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setClineAsk("command")
setEnableButtons(!isPartial)
setPrimaryButtonText(t("chat:runCommand.title"))
setSecondaryButtonText(t("chat:reject.title"))
setSecondaryButtonText("Add & Run")
setTertiaryButtonText(t("chat:reject.title"))
break
case "command_output":
setSendingDisabled(false)
Expand Down Expand Up @@ -609,6 +611,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
startNewTask()
break
case "command":
// For command case, secondary button is "Add & Run"
// Only send text/images if they exist
if (trimmedInput || (images && images.length > 0)) {
vscode.postMessage({
type: "askResponse",
askResponse: "addAndRunButtonClicked",
text: trimmedInput,
images: images,
})
} else {
vscode.postMessage({ type: "askResponse", askResponse: "addAndRunButtonClicked" })
}
// Clear input state after sending
setInputValue("")
setSelectedImages([])
break
case "tool":
case "browser_action_launch":
case "use_mcp_server":
Expand Down Expand Up @@ -639,6 +657,36 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
[clineAsk, startNewTask, isStreaming],
)

const handleTertiaryButtonClick = useCallback(
(text?: string, images?: string[]) => {
const trimmedInput = text?.trim()

switch (clineAsk) {
case "command":
// For command case, tertiary button is "Reject"
// Only send text/images if they exist
if (trimmedInput || (images && images.length > 0)) {
vscode.postMessage({
type: "askResponse",
askResponse: "noButtonClicked",
text: trimmedInput,
images: images,
})
} else {
vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" })
}
// Clear input state after sending
setInputValue("")
setSelectedImages([])
break
}
setSendingDisabled(true)
setClineAsk(undefined)
setEnableButtons(false)
},
[clineAsk],
)

const handleTaskCloseButtonClick = useCallback(() => startNewTask(), [startNewTask])

const { info: model } = useSelectedModel(apiConfiguration)
Expand Down Expand Up @@ -690,6 +738,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "secondaryButtonClick":
handleSecondaryButtonClick(message.text ?? "", message.images ?? [])
break
case "tertiaryButtonClick":
handleTertiaryButtonClick(message.text ?? "", message.images ?? [])
break
}
break
case "condenseTaskContextResponse":
Expand All @@ -716,6 +767,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleSetChatBoxMessage,
handlePrimaryButtonClick,
handleSecondaryButtonClick,
handleTertiaryButtonClick,
],
)

Expand Down Expand Up @@ -1490,67 +1542,128 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
</div>
) : (
<div
className={`flex ${
className={`${
primaryButtonText || secondaryButtonText || isStreaming ? "px-[15px] pt-[10px]" : "p-0"
} ${
primaryButtonText || secondaryButtonText || isStreaming
? enableButtons || (isStreaming && !didClickCancel)
? "opacity-100"
: "opacity-50"
: "opacity-0"
}`}>
{primaryButtonText && !isStreaming && (
<StandardTooltip
content={
primaryButtonText === t("chat:retry.title")
? t("chat:retry.tooltip")
: primaryButtonText === t("chat:save.title")
? t("chat:save.tooltip")
: primaryButtonText === t("chat:approve.title")
? t("chat:approve.tooltip")
: primaryButtonText === t("chat:runCommand.title")
} ${tertiaryButtonText ? "flex flex-col gap-[6px]" : "flex"}`}>
{/* Three-button layout for command approval */}
{tertiaryButtonText && !isStreaming ? (
<>
{/* Top row: Run and Add & Run buttons */}
<div className="flex gap-[6px]">
{primaryButtonText && (
<StandardTooltip
content={
primaryButtonText === t("chat:runCommand.title")
? t("chat:runCommand.tooltip")
: primaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: primaryButtonText === t("chat:resumeTask.title")
? t("chat:resumeTask.tooltip")
: primaryButtonText === t("chat:proceedAnyways.title")
? t("chat:proceedAnyways.tooltip")
: primaryButtonText ===
t("chat:proceedWhileRunning.title")
? t("chat:proceedWhileRunning.tooltip")
: undefined
}>
<VSCodeButton
appearance="primary"
disabled={!enableButtons}
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
{primaryButtonText}
</VSCodeButton>
</StandardTooltip>
)}
{(secondaryButtonText || isStreaming) && (
<StandardTooltip
content={
isStreaming
? t("chat:cancel.tooltip")
: secondaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: secondaryButtonText === t("chat:reject.title")
? t("chat:reject.tooltip")
: secondaryButtonText === t("chat:terminate.title")
? t("chat:terminate.tooltip")
: undefined
}>
<VSCodeButton
appearance="secondary"
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
className={isStreaming ? "flex-[2] ml-0" : "flex-1 ml-[6px]"}
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
{isStreaming ? t("chat:cancel.title") : secondaryButtonText}
</VSCodeButton>
</StandardTooltip>
}>
<VSCodeButton
appearance="primary"
disabled={!enableButtons}
className="flex-1"
onClick={() =>
handlePrimaryButtonClick(inputValue, selectedImages)
}>
{primaryButtonText}
</VSCodeButton>
</StandardTooltip>
)}
{secondaryButtonText && (
<StandardTooltip content="Add command to whitelist and run it">
<VSCodeButton
appearance="secondary"
disabled={!enableButtons}
className="flex-1"
onClick={() =>
handleSecondaryButtonClick(inputValue, selectedImages)
}>
{secondaryButtonText}
</VSCodeButton>
</StandardTooltip>
)}
</div>
{/* Bottom row: Reject button */}
<div className="flex">
<StandardTooltip
content={
tertiaryButtonText === t("chat:reject.title")
? t("chat:reject.tooltip")
: undefined
}>
<VSCodeButton
appearance="secondary"
disabled={!enableButtons}
className="flex-1"
onClick={() => handleTertiaryButtonClick(inputValue, selectedImages)}>
{tertiaryButtonText}
</VSCodeButton>
</StandardTooltip>
</div>
</>
) : (
/* Two-button layout for other cases */
<>
{primaryButtonText && !isStreaming && (
<StandardTooltip
content={
primaryButtonText === t("chat:retry.title")
? t("chat:retry.tooltip")
: primaryButtonText === t("chat:save.title")
? t("chat:save.tooltip")
: primaryButtonText === t("chat:approve.title")
? t("chat:approve.tooltip")
: primaryButtonText === t("chat:runCommand.title")
? t("chat:runCommand.tooltip")
: primaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: primaryButtonText === t("chat:resumeTask.title")
? t("chat:resumeTask.tooltip")
: primaryButtonText ===
t("chat:proceedAnyways.title")
? t("chat:proceedAnyways.tooltip")
: primaryButtonText ===
t("chat:proceedWhileRunning.title")
? t("chat:proceedWhileRunning.tooltip")
: undefined
}>
<VSCodeButton
appearance="primary"
disabled={!enableButtons}
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
{primaryButtonText}
</VSCodeButton>
</StandardTooltip>
)}
{(secondaryButtonText || isStreaming) && !tertiaryButtonText && (
<StandardTooltip
content={
isStreaming
? t("chat:cancel.tooltip")
: secondaryButtonText === t("chat:startNewTask.title")
? t("chat:startNewTask.tooltip")
: secondaryButtonText === t("chat:reject.title")
? t("chat:reject.tooltip")
: secondaryButtonText === t("chat:terminate.title")
? t("chat:terminate.tooltip")
: undefined
}>
<VSCodeButton
appearance="secondary"
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
className={isStreaming ? "flex-[2] ml-0" : "flex-1 ml-[6px]"}
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
{isStreaming ? t("chat:cancel.title") : secondaryButtonText}
</VSCodeButton>
</StandardTooltip>
)}
</>
)}
</div>
)}
Expand Down