Skip to content

Commit 95b6c2a

Browse files
committed
feat: Add 'Add & Run' button to command approval UI
- Add third button option to command approval dialog - Implement streamlined whitelisting workflow that adds command to whitelist and executes it - Update UI to show three buttons: 'Run Command', 'Add & Run', and 'Reject' - Add backend logic to detect 'Add & Run' selection and update allowedCommands setting - Enhance message passing system to support tertiary button interactions Fixes #5290
1 parent 3a8ba27 commit 95b6c2a

File tree

5 files changed

+222
-66
lines changed

5 files changed

+222
-66
lines changed

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,14 @@ export async function presentAssistantMessage(cline: Task) {
271271
isProtected || false,
272272
)
273273

274-
if (response !== "yesButtonClicked") {
274+
if (response === "yesButtonClicked" || response === "addAndRunButtonClicked") {
275+
// Handle yesButtonClicked or addAndRunButtonClicked with text.
276+
if (text) {
277+
await cline.say("user_feedback", text, images)
278+
pushToolResult(formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text), images))
279+
}
280+
return true
281+
} else {
275282
// Handle both messageResponse and noButtonClicked with text.
276283
if (text) {
277284
await cline.say("user_feedback", text, images)
@@ -282,14 +289,6 @@ export async function presentAssistantMessage(cline: Task) {
282289
cline.didRejectTool = true
283290
return false
284291
}
285-
286-
// Handle yesButtonClicked with text.
287-
if (text) {
288-
await cline.say("user_feedback", text, images)
289-
pushToolResult(formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text), images))
290-
}
291-
292-
return true
293292
}
294293

295294
const askFinishSubTaskApproval = async () => {

src/core/tools/executeCommandTool.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,42 @@ export async function executeCommandTool(
5151
cline.consecutiveMistakeCount = 0
5252

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

56-
if (!didApprove) {
55+
// We need to capture the actual response to check if "Add & Run" was clicked
56+
const { response, text, images } = await cline.ask("command", command)
57+
58+
if (response === "yesButtonClicked" || response === "addAndRunButtonClicked") {
59+
// Handle yesButtonClicked or addAndRunButtonClicked with text.
60+
if (text) {
61+
await cline.say("user_feedback", text, images)
62+
}
63+
64+
// Check if user selected "Add & Run" to add command to whitelist
65+
if (response === "addAndRunButtonClicked") {
66+
const clineProvider = await cline.providerRef.deref()
67+
if (clineProvider) {
68+
const state = await clineProvider.getState()
69+
const currentCommands = state.allowedCommands ?? []
70+
71+
// Add command to whitelist if not already present
72+
if (!currentCommands.includes(command)) {
73+
const newCommands = [...currentCommands, command]
74+
await clineProvider.setValue("allowedCommands", newCommands)
75+
76+
// Notify webview of the updated commands
77+
await clineProvider.postMessageToWebview({
78+
type: "invoke",
79+
invoke: "setChatBoxMessage",
80+
text: `Command "${command}" added to whitelist.`,
81+
})
82+
}
83+
}
84+
}
85+
} else {
86+
// Handle both messageResponse and noButtonClicked with text.
87+
if (text) {
88+
await cline.say("user_feedback", text, images)
89+
}
5790
return
5891
}
5992

src/shared/ExtensionMessage.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,13 @@ export interface ExtensionMessage {
112112
| "didBecomeVisible"
113113
| "focusInput"
114114
| "switchTab"
115-
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
115+
invoke?:
116+
| "newChat"
117+
| "sendMessage"
118+
| "primaryButtonClick"
119+
| "secondaryButtonClick"
120+
| "tertiaryButtonClick"
121+
| "setChatBoxMessage"
116122
state?: ExtensionState
117123
images?: string[]
118124
filePaths?: string[]

src/shared/WebviewMessage.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import { marketplaceItemSchema } from "@roo-code/types"
1212

1313
import { Mode } from "./modes"
1414

15-
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse"
15+
export type ClineAskResponse =
16+
| "yesButtonClicked"
17+
| "noButtonClicked"
18+
| "addAndRunButtonClicked"
19+
| "messageResponse"
20+
| "objectResponse"
1621

1722
export type PromptMode = Mode | "enhance"
1823

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 166 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
139139
const [enableButtons, setEnableButtons] = useState<boolean>(false)
140140
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
141141
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
142+
const [tertiaryButtonText, setTertiaryButtonText] = useState<string | undefined>(undefined)
142143
const [didClickCancel, setDidClickCancel] = useState(false)
143144
const virtuosoRef = useRef<VirtuosoHandle>(null)
144145
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
@@ -312,7 +313,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
312313
setClineAsk("command")
313314
setEnableButtons(!isPartial)
314315
setPrimaryButtonText(t("chat:runCommand.title"))
315-
setSecondaryButtonText(t("chat:reject.title"))
316+
setSecondaryButtonText("Add & Run")
317+
setTertiaryButtonText(t("chat:reject.title"))
316318
break
317319
case "command_output":
318320
setSendingDisabled(false)
@@ -609,6 +611,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
609611
startNewTask()
610612
break
611613
case "command":
614+
// For command case, secondary button is "Add & Run"
615+
// Only send text/images if they exist
616+
if (trimmedInput || (images && images.length > 0)) {
617+
vscode.postMessage({
618+
type: "askResponse",
619+
askResponse: "addAndRunButtonClicked",
620+
text: trimmedInput,
621+
images: images,
622+
})
623+
} else {
624+
vscode.postMessage({ type: "askResponse", askResponse: "addAndRunButtonClicked" })
625+
}
626+
// Clear input state after sending
627+
setInputValue("")
628+
setSelectedImages([])
629+
break
612630
case "tool":
613631
case "browser_action_launch":
614632
case "use_mcp_server":
@@ -639,6 +657,36 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
639657
[clineAsk, startNewTask, isStreaming],
640658
)
641659

660+
const handleTertiaryButtonClick = useCallback(
661+
(text?: string, images?: string[]) => {
662+
const trimmedInput = text?.trim()
663+
664+
switch (clineAsk) {
665+
case "command":
666+
// For command case, tertiary button is "Reject"
667+
// Only send text/images if they exist
668+
if (trimmedInput || (images && images.length > 0)) {
669+
vscode.postMessage({
670+
type: "askResponse",
671+
askResponse: "noButtonClicked",
672+
text: trimmedInput,
673+
images: images,
674+
})
675+
} else {
676+
vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" })
677+
}
678+
// Clear input state after sending
679+
setInputValue("")
680+
setSelectedImages([])
681+
break
682+
}
683+
setSendingDisabled(true)
684+
setClineAsk(undefined)
685+
setEnableButtons(false)
686+
},
687+
[clineAsk],
688+
)
689+
642690
const handleTaskCloseButtonClick = useCallback(() => startNewTask(), [startNewTask])
643691

644692
const { info: model } = useSelectedModel(apiConfiguration)
@@ -690,6 +738,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
690738
case "secondaryButtonClick":
691739
handleSecondaryButtonClick(message.text ?? "", message.images ?? [])
692740
break
741+
case "tertiaryButtonClick":
742+
handleTertiaryButtonClick(message.text ?? "", message.images ?? [])
743+
break
693744
}
694745
break
695746
case "condenseTaskContextResponse":
@@ -716,6 +767,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
716767
handleSetChatBoxMessage,
717768
handlePrimaryButtonClick,
718769
handleSecondaryButtonClick,
770+
handleTertiaryButtonClick,
719771
],
720772
)
721773

@@ -1490,67 +1542,128 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
14901542
</div>
14911543
) : (
14921544
<div
1493-
className={`flex ${
1545+
className={`${
14941546
primaryButtonText || secondaryButtonText || isStreaming ? "px-[15px] pt-[10px]" : "p-0"
14951547
} ${
14961548
primaryButtonText || secondaryButtonText || isStreaming
14971549
? enableButtons || (isStreaming && !didClickCancel)
14981550
? "opacity-100"
14991551
: "opacity-50"
15001552
: "opacity-0"
1501-
}`}>
1502-
{primaryButtonText && !isStreaming && (
1503-
<StandardTooltip
1504-
content={
1505-
primaryButtonText === t("chat:retry.title")
1506-
? t("chat:retry.tooltip")
1507-
: primaryButtonText === t("chat:save.title")
1508-
? t("chat:save.tooltip")
1509-
: primaryButtonText === t("chat:approve.title")
1510-
? t("chat:approve.tooltip")
1511-
: primaryButtonText === t("chat:runCommand.title")
1553+
} ${tertiaryButtonText ? "flex flex-col gap-[6px]" : "flex"}`}>
1554+
{/* Three-button layout for command approval */}
1555+
{tertiaryButtonText && !isStreaming ? (
1556+
<>
1557+
{/* Top row: Run and Add & Run buttons */}
1558+
<div className="flex gap-[6px]">
1559+
{primaryButtonText && (
1560+
<StandardTooltip
1561+
content={
1562+
primaryButtonText === t("chat:runCommand.title")
15121563
? t("chat:runCommand.tooltip")
1513-
: primaryButtonText === t("chat:startNewTask.title")
1514-
? t("chat:startNewTask.tooltip")
1515-
: primaryButtonText === t("chat:resumeTask.title")
1516-
? t("chat:resumeTask.tooltip")
1517-
: primaryButtonText === t("chat:proceedAnyways.title")
1518-
? t("chat:proceedAnyways.tooltip")
1519-
: primaryButtonText ===
1520-
t("chat:proceedWhileRunning.title")
1521-
? t("chat:proceedWhileRunning.tooltip")
1522-
: undefined
1523-
}>
1524-
<VSCodeButton
1525-
appearance="primary"
1526-
disabled={!enableButtons}
1527-
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
1528-
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
1529-
{primaryButtonText}
1530-
</VSCodeButton>
1531-
</StandardTooltip>
1532-
)}
1533-
{(secondaryButtonText || isStreaming) && (
1534-
<StandardTooltip
1535-
content={
1536-
isStreaming
1537-
? t("chat:cancel.tooltip")
1538-
: secondaryButtonText === t("chat:startNewTask.title")
1539-
? t("chat:startNewTask.tooltip")
1540-
: secondaryButtonText === t("chat:reject.title")
1541-
? t("chat:reject.tooltip")
1542-
: secondaryButtonText === t("chat:terminate.title")
1543-
? t("chat:terminate.tooltip")
15441564
: undefined
1545-
}>
1546-
<VSCodeButton
1547-
appearance="secondary"
1548-
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
1549-
className={isStreaming ? "flex-[2] ml-0" : "flex-1 ml-[6px]"}
1550-
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
1551-
{isStreaming ? t("chat:cancel.title") : secondaryButtonText}
1552-
</VSCodeButton>
1553-
</StandardTooltip>
1565+
}>
1566+
<VSCodeButton
1567+
appearance="primary"
1568+
disabled={!enableButtons}
1569+
className="flex-1"
1570+
onClick={() =>
1571+
handlePrimaryButtonClick(inputValue, selectedImages)
1572+
}>
1573+
{primaryButtonText}
1574+
</VSCodeButton>
1575+
</StandardTooltip>
1576+
)}
1577+
{secondaryButtonText && (
1578+
<StandardTooltip content="Add command to whitelist and run it">
1579+
<VSCodeButton
1580+
appearance="secondary"
1581+
disabled={!enableButtons}
1582+
className="flex-1"
1583+
onClick={() =>
1584+
handleSecondaryButtonClick(inputValue, selectedImages)
1585+
}>
1586+
{secondaryButtonText}
1587+
</VSCodeButton>
1588+
</StandardTooltip>
1589+
)}
1590+
</div>
1591+
{/* Bottom row: Reject button */}
1592+
<div className="flex">
1593+
<StandardTooltip
1594+
content={
1595+
tertiaryButtonText === t("chat:reject.title")
1596+
? t("chat:reject.tooltip")
1597+
: undefined
1598+
}>
1599+
<VSCodeButton
1600+
appearance="secondary"
1601+
disabled={!enableButtons}
1602+
className="flex-1"
1603+
onClick={() => handleTertiaryButtonClick(inputValue, selectedImages)}>
1604+
{tertiaryButtonText}
1605+
</VSCodeButton>
1606+
</StandardTooltip>
1607+
</div>
1608+
</>
1609+
) : (
1610+
/* Two-button layout for other cases */
1611+
<>
1612+
{primaryButtonText && !isStreaming && (
1613+
<StandardTooltip
1614+
content={
1615+
primaryButtonText === t("chat:retry.title")
1616+
? t("chat:retry.tooltip")
1617+
: primaryButtonText === t("chat:save.title")
1618+
? t("chat:save.tooltip")
1619+
: primaryButtonText === t("chat:approve.title")
1620+
? t("chat:approve.tooltip")
1621+
: primaryButtonText === t("chat:runCommand.title")
1622+
? t("chat:runCommand.tooltip")
1623+
: primaryButtonText === t("chat:startNewTask.title")
1624+
? t("chat:startNewTask.tooltip")
1625+
: primaryButtonText === t("chat:resumeTask.title")
1626+
? t("chat:resumeTask.tooltip")
1627+
: primaryButtonText ===
1628+
t("chat:proceedAnyways.title")
1629+
? t("chat:proceedAnyways.tooltip")
1630+
: primaryButtonText ===
1631+
t("chat:proceedWhileRunning.title")
1632+
? t("chat:proceedWhileRunning.tooltip")
1633+
: undefined
1634+
}>
1635+
<VSCodeButton
1636+
appearance="primary"
1637+
disabled={!enableButtons}
1638+
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
1639+
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
1640+
{primaryButtonText}
1641+
</VSCodeButton>
1642+
</StandardTooltip>
1643+
)}
1644+
{(secondaryButtonText || isStreaming) && !tertiaryButtonText && (
1645+
<StandardTooltip
1646+
content={
1647+
isStreaming
1648+
? t("chat:cancel.tooltip")
1649+
: secondaryButtonText === t("chat:startNewTask.title")
1650+
? t("chat:startNewTask.tooltip")
1651+
: secondaryButtonText === t("chat:reject.title")
1652+
? t("chat:reject.tooltip")
1653+
: secondaryButtonText === t("chat:terminate.title")
1654+
? t("chat:terminate.tooltip")
1655+
: undefined
1656+
}>
1657+
<VSCodeButton
1658+
appearance="secondary"
1659+
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
1660+
className={isStreaming ? "flex-[2] ml-0" : "flex-1 ml-[6px]"}
1661+
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
1662+
{isStreaming ? t("chat:cancel.title") : secondaryButtonText}
1663+
</VSCodeButton>
1664+
</StandardTooltip>
1665+
)}
1666+
</>
15541667
)}
15551668
</div>
15561669
)}

0 commit comments

Comments
 (0)