feat: add unified UI for managing allow/deny command lists

- Updated CommandPatternSelector to show both allow and deny options
- Added mutual exclusivity between allow and deny states
- Added visual indicators (green check for allow, red X for deny)
- Added translation strings for the new UI
- Integrated deny command handling in CommandExecution component
This commit is contained in:
hannesrudolph 2025-07-14 15:24:11 -06:00
parent a0e8c24352
commit 18e3cd1878
3 changed files with 124 additions and 22 deletions

View file

@ -26,7 +26,7 @@ interface CommandExecutionProps {
export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => {
const { t } = useAppTranslation()
const { terminalShellIntegrationDisabled = false, allowedCommands = [] } = useExtensionState()
const { terminalShellIntegrationDisabled = false, allowedCommands = [], deniedCommands = [] } = useExtensionState()
const { command, output: parsedOutput, suggestions } = useMemo(() => parseCommandAndOutput(text), [text])
@ -266,6 +266,30 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
[allowedCommands],
)
const handleDenyPatternChange = useCallback(
(pattern: string) => {
if (!pattern) return
const isDenied = deniedCommands.includes(pattern)
let updatedDeniedCommands: string[]
if (isDenied) {
// Remove from deny list
updatedDeniedCommands = deniedCommands.filter((p) => p !== pattern)
} else {
// Add to deny list
updatedDeniedCommands = [...deniedCommands, pattern]
}
// Send message to update denied commands
vscode.postMessage({
type: "deniedCommands",
commands: updatedDeniedCommands,
})
},
[deniedCommands],
)
return (
<div className="w-full">
{/* Header section */}
@ -337,12 +361,14 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
<CodeBlock source={command} language="shell" />
</div>
{/* Whitelist section */}
{/* Command management section */}
{showSuggestions && (
<CommandPatternSelector
patterns={commandPatterns}
allowedCommands={allowedCommands}
onPatternChange={handleAllowPatternChange}
deniedCommands={deniedCommands}
onAllowPatternChange={handleAllowPatternChange}
onDenyPatternChange={handleDenyPatternChange}
/>
)}

View file

@ -1,6 +1,5 @@
import { useState } from "react"
import { ChevronDown } from "lucide-react"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { ChevronDown, Check, X } from "lucide-react"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { cn } from "@src/lib/utils"
@ -12,10 +11,18 @@ interface CommandPattern {
interface CommandPatternSelectorProps {
patterns: CommandPattern[]
allowedCommands: string[]
onPatternChange: (pattern: string) => void
deniedCommands: string[]
onAllowPatternChange: (pattern: string) => void
onDenyPatternChange: (pattern: string) => void
}
export const CommandPatternSelector = ({ patterns, allowedCommands, onPatternChange }: CommandPatternSelectorProps) => {
export const CommandPatternSelector = ({
patterns,
allowedCommands,
deniedCommands,
onAllowPatternChange,
onDenyPatternChange,
}: CommandPatternSelectorProps) => {
const { t } = useAppTranslation()
const [isExpanded, setIsExpanded] = useState(false)
@ -23,12 +30,38 @@ export const CommandPatternSelector = ({ patterns, allowedCommands, onPatternCha
return null
}
const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => {
if (allowedCommands.includes(pattern)) return "allowed"
if (deniedCommands.includes(pattern)) return "denied"
return "none"
}
const handleAllowClick = (pattern: string) => {
const status = getPatternStatus(pattern)
if (status === "denied") {
// Remove from denied list first
onDenyPatternChange(pattern)
}
// Toggle allow status
onAllowPatternChange(pattern)
}
const handleDenyClick = (pattern: string) => {
const status = getPatternStatus(pattern)
if (status === "allowed") {
// Remove from allowed list first
onAllowPatternChange(pattern)
}
// Toggle deny status
onDenyPatternChange(pattern)
}
return (
<div className="border-t border-vscode-panel-border bg-vscode-sideBar-background/30">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-vscode-descriptionForeground hover:text-vscode-foreground hover:bg-vscode-list-hoverBackground transition-all"
aria-label={isExpanded ? "Collapse allowed commands section" : "Expand allowed commands section"}
aria-label={isExpanded ? "Collapse command management section" : "Expand command management section"}
aria-expanded={isExpanded}>
<ChevronDown
className={cn("size-3 transition-transform duration-200", {
@ -36,21 +69,62 @@ export const CommandPatternSelector = ({ patterns, allowedCommands, onPatternCha
"-rotate-90": !isExpanded,
})}
/>
<span className="font-medium">{t("chat:commandExecution.addToAllowedCommands")}</span>
<span className="font-medium">{t("chat:commandExecution.manageCommands")}</span>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-1.5">
{patterns.map((item, index) => (
<div key={`${item.pattern}-${index}`} className="ml-5">
<VSCodeCheckbox
checked={allowedCommands.includes(item.pattern)}
onChange={() => onPatternChange(item.pattern)}
className="text-xs"
aria-label={`Allow command pattern: ${item.pattern}`}>
<span className="font-mono text-vscode-foreground">{item.pattern}</span>
</VSCodeCheckbox>
</div>
))}
<div className="px-3 pb-3 space-y-2">
<div className="text-xs text-vscode-descriptionForeground mb-2 ml-5">
{t("chat:commandExecution.commandManagementDescription")}
</div>
{patterns.map((item, index) => {
const status = getPatternStatus(item.pattern)
return (
<div key={`${item.pattern}-${index}`} className="ml-5 flex items-center gap-2">
<div className="flex-1">
<span className="font-mono text-xs text-vscode-foreground">{item.pattern}</span>
{item.description && (
<span className="text-xs text-vscode-descriptionForeground ml-2">
- {item.description}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleAllowClick(item.pattern)}
className={cn(
"p-1 rounded transition-all",
status === "allowed"
? "bg-green-500/20 text-green-500 hover:bg-green-500/30"
: "text-vscode-descriptionForeground hover:text-green-500 hover:bg-green-500/10",
)}
aria-label={
status === "allowed"
? `Remove ${item.pattern} from allowed list`
: `Add ${item.pattern} to allowed list`
}
title={status === "allowed" ? "Remove from allowed" : "Add to allowed"}>
<Check className="size-3.5" />
</button>
<button
onClick={() => handleDenyClick(item.pattern)}
className={cn(
"p-1 rounded transition-all",
status === "denied"
? "bg-red-500/20 text-red-500 hover:bg-red-500/30"
: "text-vscode-descriptionForeground hover:text-red-500 hover:bg-red-500/10",
)}
aria-label={
status === "denied"
? `Remove ${item.pattern} from denied list`
: `Add ${item.pattern} to denied list`
}
title={status === "denied" ? "Remove from denied" : "Add to denied"}>
<X className="size-3.5" />
</button>
</div>
</div>
)
})}
</div>
)}
</div>

View file

@ -213,7 +213,9 @@
"exited": "Exited ({{exitCode}})",
"addToAllowedCommands": "Add to Allowed Auto-Execute Commands",
"allowAllNpmRun": "Allow all npm run commands",
"allowAllNpm": "Allow all npm commands"
"allowAllNpm": "Allow all npm commands",
"manageCommands": "Manage Command Permissions",
"commandManagementDescription": "Click ✓ to allow auto-execution, ✗ to deny execution"
},
"commandOutput": "Command Output",
"response": "Response",