Skip to content

Commit 3e5cc31

Browse files
committed
feat: improve command whitelisting UI/UX similar to MCP tools
- Remove 'Always Allow' button from initial command approval dialog - Add integrated whitelist functionality to CommandExecution component - Implement collapsible 'Add to Allowed Auto-Execute Commands' section - Support granular command patterns for npm and other commands - Handle chained commands with individual checkboxes - Update extractCommandPattern to avoid wildcards for better control - Add comprehensive tests for command pattern extraction
1 parent 69ac7ec commit 3e5cc31

File tree

4 files changed

+265
-126
lines changed

4 files changed

+265
-126
lines changed

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

Lines changed: 24 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import { ProfileValidator } from "@roo/ProfileValidator"
2525

2626
import { vscode } from "@src/utils/vscode"
2727
import { validateCommand } from "@src/utils/command-validation"
28-
import { extractCommandPattern, getPatternDescription } from "@src/utils/extract-command-pattern"
2928
import { buildDocLink } from "@src/utils/docLinks"
3029
import { useAppTranslation } from "@src/i18n/TranslationContext"
3130
import { useExtensionState } from "@src/context/ExtensionStateContext"
@@ -1657,87 +1656,31 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
16571656
</StandardTooltip>
16581657
) : (
16591658
<>
1660-
{/* Command approval with auto-approve pattern */}
1659+
{/* Command approval - simplified layout */}
16611660
{clineAsk === "command" && !isStreaming ? (
1662-
<div className="flex flex-col gap-[6px]">
1663-
{/* Top row: Run Command and Reject */}
1664-
<div className="flex gap-[6px]">
1665-
<StandardTooltip content={t("chat:runCommand.tooltip")}>
1666-
<VSCodeButton
1667-
appearance="primary"
1668-
disabled={!enableButtons}
1669-
className="flex-1"
1670-
onClick={() =>
1671-
handlePrimaryButtonClick(inputValue, selectedImages)
1672-
}>
1673-
{primaryButtonText}
1674-
</VSCodeButton>
1675-
</StandardTooltip>
1676-
<StandardTooltip content={t("chat:reject.tooltip")}>
1677-
<VSCodeButton
1678-
appearance="secondary"
1679-
disabled={!enableButtons}
1680-
className="flex-1"
1681-
onClick={() =>
1682-
handleSecondaryButtonClick(inputValue, selectedImages)
1683-
}>
1684-
{secondaryButtonText}
1685-
</VSCodeButton>
1686-
</StandardTooltip>
1687-
</div>
1688-
{/* Bottom row: Auto-approve pattern */}
1689-
<div className="flex items-center gap-[6px]">
1690-
<div className="flex-1 px-2 py-1 bg-vscode-input-background text-vscode-input-foreground rounded text-sm font-mono">
1691-
{(() => {
1692-
const commandMessage = findLast(
1693-
messagesRef.current,
1694-
(msg) => msg.type === "ask" && msg.ask === "command",
1695-
)
1696-
const commandText = commandMessage?.text || ""
1697-
const pattern = extractCommandPattern(commandText)
1698-
return pattern || commandText
1699-
})()}
1700-
</div>
1701-
<StandardTooltip
1702-
content={(() => {
1703-
const commandMessage = findLast(
1704-
messagesRef.current,
1705-
(msg) => msg.type === "ask" && msg.ask === "command",
1706-
)
1707-
const commandText = commandMessage?.text || ""
1708-
const pattern = extractCommandPattern(commandText)
1709-
const description = getPatternDescription(pattern)
1710-
return pattern
1711-
? `${t("chat:alwaysAllow.tooltip")} Will whitelist: "${pattern}" (${description})`
1712-
: t("chat:alwaysAllow.tooltip")
1713-
})()}>
1714-
<VSCodeButton
1715-
appearance="secondary"
1716-
disabled={!enableButtons}
1717-
onClick={() => {
1718-
// Extract the command pattern
1719-
const commandMessage = findLast(
1720-
messagesRef.current,
1721-
(msg) => msg.type === "ask" && msg.ask === "command",
1722-
)
1723-
const commandText = commandMessage?.text || ""
1724-
const pattern = extractCommandPattern(commandText)
1725-
1726-
// Add to whitelist without running
1727-
vscode.postMessage({
1728-
type: "addToWhitelist",
1729-
pattern: pattern,
1730-
})
1731-
1732-
// Clear the ask state
1733-
setSendingDisabled(true)
1734-
setClineAsk(undefined)
1735-
setEnableButtons(false)
1736-
}}>
1737-
{t("chat:alwaysAllow.title")}
1738-
</VSCodeButton>
1739-
</StandardTooltip>
1740-
</div>
1661+
<div className="flex gap-[6px]">
1662+
<StandardTooltip content={t("chat:runCommand.tooltip")}>
1663+
<VSCodeButton
1664+
appearance="primary"
1665+
disabled={!enableButtons}
1666+
className="flex-1"
1667+
onClick={() =>
1668+
handlePrimaryButtonClick(inputValue, selectedImages)
1669+
}>
1670+
{primaryButtonText}
1671+
</VSCodeButton>
1672+
</StandardTooltip>
1673+
<StandardTooltip content={t("chat:reject.tooltip")}>
1674+
<VSCodeButton
1675+
appearance="secondary"
1676+
disabled={!enableButtons}
1677+
className="flex-1"
1678+
onClick={() =>
1679+
handleSecondaryButtonClick(inputValue, selectedImages)
1680+
}>
1681+
{secondaryButtonText}
1682+
</VSCodeButton>
1683+
</StandardTooltip>
17411684
</div>
17421685
) : (
17431686
/* Standard two button layout */

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

Lines changed: 206 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useCallback, useState, memo, useMemo } from "react"
22
import { useEvent } from "react-use"
33
import { ChevronDown, Skull } from "lucide-react"
4+
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
45

56
import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/types"
67

@@ -9,6 +10,7 @@ import { safeJsonParse } from "@roo/safeJsonParse"
910
import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences"
1011

1112
import { vscode } from "@src/utils/vscode"
13+
import { extractCommandPattern, getPatternDescription } from "@src/utils/extract-command-pattern"
1214
import { useExtensionState } from "@src/context/ExtensionStateContext"
1315
import { cn } from "@src/lib/utils"
1416
import { Button } from "@src/components/ui"
@@ -22,7 +24,7 @@ interface CommandExecutionProps {
2224
}
2325

2426
export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => {
25-
const { terminalShellIntegrationDisabled = false } = useExtensionState()
27+
const { terminalShellIntegrationDisabled = false, allowedCommands = [] } = useExtensionState()
2628

2729
const { command, output: parsedOutput } = useMemo(() => parseCommandAndOutput(text), [text])
2830

@@ -31,6 +33,159 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
3133
const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled)
3234
const [streamingOutput, setStreamingOutput] = useState("")
3335
const [status, setStatus] = useState<CommandExecutionStatus | null>(null)
36+
const [isPatternSectionExpanded, setIsPatternSectionExpanded] = useState(false)
37+
38+
// Extract command patterns for whitelisting
39+
// For chained commands, extract individual patterns
40+
const commandPatterns = useMemo(() => {
41+
if (!command?.trim()) return []
42+
43+
// Check if this is a chained command
44+
const operators = ["&&", "||", ";", "|"]
45+
const patterns: Array<{ pattern: string; description: string }> = []
46+
47+
// Split by operators while respecting quotes
48+
let inSingleQuote = false
49+
let inDoubleQuote = false
50+
let escapeNext = false
51+
let currentCommand = ""
52+
let i = 0
53+
54+
while (i < command.length) {
55+
const char = command[i]
56+
57+
if (escapeNext) {
58+
currentCommand += char
59+
escapeNext = false
60+
i++
61+
continue
62+
}
63+
64+
if (char === "\\") {
65+
escapeNext = true
66+
currentCommand += char
67+
i++
68+
continue
69+
}
70+
71+
if (char === "'" && !inDoubleQuote) {
72+
inSingleQuote = !inSingleQuote
73+
currentCommand += char
74+
i++
75+
continue
76+
}
77+
78+
if (char === '"' && !inSingleQuote) {
79+
inDoubleQuote = !inDoubleQuote
80+
currentCommand += char
81+
i++
82+
continue
83+
}
84+
85+
// Check for operators outside quotes
86+
if (!inSingleQuote && !inDoubleQuote) {
87+
let foundOperator = false
88+
for (const op of operators) {
89+
if (command.substring(i, i + op.length) === op) {
90+
// Found an operator, process the current command
91+
const trimmedCommand = currentCommand.trim()
92+
if (trimmedCommand) {
93+
// For npm commands, generate multiple pattern options
94+
if (trimmedCommand.startsWith("npm ")) {
95+
// Add the specific pattern
96+
const specificPattern = extractCommandPattern(trimmedCommand)
97+
if (specificPattern) {
98+
patterns.push({
99+
pattern: specificPattern,
100+
description: getPatternDescription(specificPattern),
101+
})
102+
}
103+
104+
// Add broader npm patterns
105+
if (trimmedCommand.startsWith("npm run ")) {
106+
// Add "npm run" pattern
107+
patterns.push({
108+
pattern: "npm run",
109+
description: "Allow all npm run commands",
110+
})
111+
}
112+
113+
// Add "npm" pattern
114+
patterns.push({
115+
pattern: "npm",
116+
description: "Allow all npm commands",
117+
})
118+
} else {
119+
// For non-npm commands, just add the extracted pattern
120+
const pattern = extractCommandPattern(trimmedCommand)
121+
if (pattern) {
122+
patterns.push({
123+
pattern,
124+
description: getPatternDescription(pattern),
125+
})
126+
}
127+
}
128+
}
129+
currentCommand = ""
130+
i += op.length
131+
foundOperator = true
132+
break
133+
}
134+
}
135+
if (foundOperator) continue
136+
}
137+
138+
currentCommand += char
139+
i++
140+
}
141+
142+
// Process the last command
143+
const trimmedCommand = currentCommand.trim()
144+
if (trimmedCommand) {
145+
// For npm commands, generate multiple pattern options
146+
if (trimmedCommand.startsWith("npm ")) {
147+
// Add the specific pattern
148+
const specificPattern = extractCommandPattern(trimmedCommand)
149+
if (specificPattern) {
150+
patterns.push({
151+
pattern: specificPattern,
152+
description: getPatternDescription(specificPattern),
153+
})
154+
}
155+
156+
// Add broader npm patterns
157+
if (trimmedCommand.startsWith("npm run ")) {
158+
// Add "npm run" pattern
159+
patterns.push({
160+
pattern: "npm run",
161+
description: "Allow all npm run commands",
162+
})
163+
}
164+
165+
// Add "npm" pattern
166+
patterns.push({
167+
pattern: "npm",
168+
description: "Allow all npm commands",
169+
})
170+
} else {
171+
// For non-npm commands, just add the extracted pattern
172+
const pattern = extractCommandPattern(trimmedCommand)
173+
if (pattern) {
174+
patterns.push({
175+
pattern,
176+
description: getPatternDescription(pattern),
177+
})
178+
}
179+
}
180+
}
181+
182+
// Remove duplicates
183+
const uniquePatterns = patterns.filter(
184+
(item, index, self) => index === self.findIndex((p) => p.pattern === item.pattern),
185+
)
186+
187+
return uniquePatterns
188+
}, [command])
34189

35190
// The command's output can either come from the text associated with the
36191
// task message (this is the case for completed commands) or from the
@@ -73,6 +228,23 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
73228

74229
useEvent("message", onMessage)
75230

231+
const handleAllowPatternChange = useCallback(
232+
(pattern: string) => {
233+
if (!pattern) return
234+
235+
const isWhitelisted = allowedCommands.includes(pattern)
236+
const updatedAllowedCommands = isWhitelisted
237+
? allowedCommands.filter((p) => p !== pattern)
238+
: Array.from(new Set([...allowedCommands, pattern]))
239+
240+
vscode.postMessage({
241+
type: "allowedCommands",
242+
commands: updatedAllowedCommands,
243+
})
244+
},
245+
[allowedCommands],
246+
)
247+
76248
return (
77249
<>
78250
<div className="flex flex-row items-center justify-between gap-2 mb-1">
@@ -123,6 +295,39 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
123295

124296
<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs p-2">
125297
<CodeBlock source={command} language="shell" />
298+
299+
{/* Command pattern display and checkboxes */}
300+
{commandPatterns.length > 0 && (
301+
<div className="mt-2 pt-2 border-t border-border/25">
302+
<button
303+
onClick={() => setIsPatternSectionExpanded(!isPatternSectionExpanded)}
304+
className="flex items-center gap-1 text-xs text-vscode-descriptionForeground hover:text-vscode-foreground transition-colors w-full text-left">
305+
<ChevronDown
306+
className={cn("size-3 transition-transform duration-200", {
307+
"rotate-0": isPatternSectionExpanded,
308+
"-rotate-90": !isPatternSectionExpanded,
309+
})}
310+
/>
311+
<span>Add to Allowed Auto-Execute Commands</span>
312+
</button>
313+
{isPatternSectionExpanded && (
314+
<div className="mt-2 space-y-2">
315+
{commandPatterns.map((item, index) => (
316+
<VSCodeCheckbox
317+
key={`${item.pattern}-${index}`}
318+
checked={allowedCommands.includes(item.pattern)}
319+
onChange={() => handleAllowPatternChange(item.pattern)}
320+
className="text-xs ml-4">
321+
<span className="font-medium text-vscode-foreground whitespace-nowrap">
322+
{item.pattern}
323+
</span>
324+
</VSCodeCheckbox>
325+
))}
326+
</div>
327+
)}
328+
</div>
329+
)}
330+
126331
<OutputContainer isExpanded={isExpanded} output={output} />
127332
</div>
128333
</>

0 commit comments

Comments
 (0)