Skip to content

Commit c4a9670

Browse files
committed
feat: Add terminal command permissions UI to chat interface (#5480)
1 parent 6cf376f commit c4a9670

File tree

24 files changed

+1513
-6
lines changed

24 files changed

+1513
-6
lines changed

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

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import { useExtensionState } from "@src/context/ExtensionStateContext"
1313
import { cn } from "@src/lib/utils"
1414
import { Button } from "@src/components/ui"
1515
import CodeBlock from "../common/CodeBlock"
16+
import { CommandPatternSelector } from "./CommandPatternSelector"
17+
import {
18+
extractCommandPatterns,
19+
getPatternDescription,
20+
parseCommandAndOutput as parseCommandAndOutputUtil,
21+
CommandPattern,
22+
} from "../../utils/commandPatterns"
1623

1724
interface CommandExecutionProps {
1825
executionId: string
@@ -22,21 +29,91 @@ interface CommandExecutionProps {
2229
}
2330

2431
export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => {
25-
const { terminalShellIntegrationDisabled = false } = useExtensionState()
26-
27-
const { command, output: parsedOutput } = useMemo(() => parseCommandAndOutput(text), [text])
32+
const {
33+
terminalShellIntegrationDisabled = false,
34+
allowedCommands = [],
35+
deniedCommands = [],
36+
setAllowedCommands,
37+
setDeniedCommands,
38+
} = useExtensionState()
39+
40+
const {
41+
command,
42+
output: parsedOutput,
43+
suggestions,
44+
} = useMemo(() => {
45+
// First try our enhanced parser
46+
const enhanced = parseCommandAndOutputUtil(text || "")
47+
// If it found a command, use it, otherwise fall back to the original parser
48+
if (enhanced.command && enhanced.command !== text) {
49+
return enhanced
50+
}
51+
// Fall back to original parser
52+
const original = parseCommandAndOutput(text)
53+
return { ...original, suggestions: [] }
54+
}, [text])
2855

2956
// If we aren't opening the VSCode terminal for this command then we default
3057
// to expanding the command execution output.
3158
const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled)
3259
const [streamingOutput, setStreamingOutput] = useState("")
3360
const [status, setStatus] = useState<CommandExecutionStatus | null>(null)
61+
const [showSuggestions] = useState(true)
3462

3563
// The command's output can either come from the text associated with the
3664
// task message (this is the case for completed commands) or from the
3765
// streaming output (this is the case for running commands).
3866
const output = streamingOutput || parsedOutput
3967

68+
// Extract command patterns
69+
const commandPatterns = useMemo<CommandPattern[]>(() => {
70+
const patterns: CommandPattern[] = []
71+
72+
// Use AI suggestions if available
73+
if (suggestions.length > 0) {
74+
suggestions.forEach((suggestion) => {
75+
patterns.push({
76+
pattern: suggestion,
77+
description: getPatternDescription(suggestion),
78+
})
79+
})
80+
} else {
81+
// Extract patterns programmatically
82+
const extractedPatterns = extractCommandPatterns(command)
83+
extractedPatterns.forEach((pattern) => {
84+
patterns.push({
85+
pattern,
86+
description: getPatternDescription(pattern),
87+
})
88+
})
89+
}
90+
91+
return patterns
92+
}, [command, suggestions])
93+
94+
// Handle pattern changes
95+
const handleAllowPatternChange = (pattern: string) => {
96+
const isAllowed = allowedCommands.includes(pattern)
97+
const newAllowed = isAllowed ? allowedCommands.filter((p) => p !== pattern) : [...allowedCommands, pattern]
98+
const newDenied = deniedCommands.filter((p) => p !== pattern)
99+
100+
setAllowedCommands(newAllowed)
101+
setDeniedCommands(newDenied)
102+
vscode.postMessage({ type: "allowedCommands", commands: newAllowed })
103+
vscode.postMessage({ type: "deniedCommands", commands: newDenied })
104+
}
105+
106+
const handleDenyPatternChange = (pattern: string) => {
107+
const isDenied = deniedCommands.includes(pattern)
108+
const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern]
109+
const newAllowed = allowedCommands.filter((p) => p !== pattern)
110+
111+
setAllowedCommands(newAllowed)
112+
setDeniedCommands(newDenied)
113+
vscode.postMessage({ type: "allowedCommands", commands: newAllowed })
114+
vscode.postMessage({ type: "deniedCommands", commands: newDenied })
115+
}
116+
40117
const onMessage = useCallback(
41118
(event: MessageEvent) => {
42119
const message: ExtensionMessage = event.data
@@ -121,9 +198,20 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
121198
</div>
122199
</div>
123200

124-
<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs p-2">
125-
<CodeBlock source={command} language="shell" />
126-
<OutputContainer isExpanded={isExpanded} output={output} />
201+
<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs">
202+
<div className="p-2">
203+
<CodeBlock source={command} language="shell" />
204+
<OutputContainer isExpanded={isExpanded} output={output} />
205+
</div>
206+
{showSuggestions && commandPatterns.length > 0 && (
207+
<CommandPatternSelector
208+
patterns={commandPatterns}
209+
allowedCommands={allowedCommands}
210+
deniedCommands={deniedCommands}
211+
onAllowPatternChange={handleAllowPatternChange}
212+
onDenyPatternChange={handleDenyPatternChange}
213+
/>
214+
)}
127215
</div>
128216
</>
129217
)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import React, { useState } from "react"
2+
import { Check, ChevronDown, Info, X } from "lucide-react"
3+
import { cn } from "../../lib/utils"
4+
import { useTranslation, Trans } from "react-i18next"
5+
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
6+
import { CommandPattern } from "../../utils/commandPatterns"
7+
import { StandardTooltip } from "../ui/standard-tooltip"
8+
9+
interface CommandPatternSelectorProps {
10+
patterns: CommandPattern[]
11+
allowedCommands: string[]
12+
deniedCommands: string[]
13+
onAllowPatternChange: (pattern: string) => void
14+
onDenyPatternChange: (pattern: string) => void
15+
}
16+
17+
export const CommandPatternSelector: React.FC<CommandPatternSelectorProps> = ({
18+
patterns,
19+
allowedCommands,
20+
deniedCommands,
21+
onAllowPatternChange,
22+
onDenyPatternChange,
23+
}) => {
24+
const { t } = useTranslation()
25+
const [isExpanded, setIsExpanded] = useState(false)
26+
27+
const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => {
28+
if (allowedCommands.includes(pattern)) return "allowed"
29+
if (deniedCommands.includes(pattern)) return "denied"
30+
return "none"
31+
}
32+
33+
return (
34+
<div className="border-t border-vscode-panel-border bg-vscode-sideBar-background/30">
35+
<button
36+
onClick={() => setIsExpanded(!isExpanded)}
37+
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"
38+
aria-expanded={isExpanded}
39+
aria-label={t(
40+
isExpanded ? "chat:commandExecution.collapseManagement" : "chat:commandExecution.expandManagement",
41+
)}>
42+
<ChevronDown
43+
className={cn("size-3 transition-transform duration-200", {
44+
"rotate-0": isExpanded,
45+
"-rotate-90": !isExpanded,
46+
})}
47+
/>
48+
<span className="font-medium">{t("chat:commandExecution.manageCommands")}</span>
49+
<StandardTooltip
50+
content={
51+
<Trans
52+
i18nKey="chat:commandExecution.commandManagementDescription"
53+
components={{
54+
settingsLink: (
55+
<VSCodeLink
56+
href="#"
57+
onClick={(e) => {
58+
e.preventDefault()
59+
window.postMessage(
60+
{
61+
type: "action",
62+
action: "settingsButtonClicked",
63+
values: { section: "autoApprove" },
64+
},
65+
"*",
66+
)
67+
}}
68+
className="inline"
69+
/>
70+
),
71+
}}
72+
/>
73+
}>
74+
<Info className="size-3 ml-1" />
75+
</StandardTooltip>
76+
</button>
77+
78+
{isExpanded && (
79+
<div className="px-3 pb-3 space-y-2">
80+
{patterns.map((item, index) => {
81+
const status = getPatternStatus(item.pattern)
82+
return (
83+
<div key={`${item.pattern}-${index}`} className="ml-5 flex items-center gap-2">
84+
<div className="flex-1">
85+
<span className="font-mono text-xs text-vscode-foreground">{item.pattern}</span>
86+
{item.description && (
87+
<span className="text-xs text-vscode-descriptionForeground ml-2">
88+
- {item.description}
89+
</span>
90+
)}
91+
</div>
92+
<div className="flex items-center gap-1">
93+
<button
94+
className={cn("p-1 rounded transition-all", {
95+
"bg-green-500/20 text-green-500 hover:bg-green-500/30":
96+
status === "allowed",
97+
"text-vscode-descriptionForeground hover:text-green-500 hover:bg-green-500/10":
98+
status !== "allowed",
99+
})}
100+
onClick={() => onAllowPatternChange(item.pattern)}
101+
aria-label={t(
102+
status === "allowed"
103+
? "chat:commandExecution.removeFromAllowed"
104+
: "chat:commandExecution.addToAllowed",
105+
)}>
106+
<Check className="size-3.5" />
107+
</button>
108+
<button
109+
className={cn("p-1 rounded transition-all", {
110+
"bg-red-500/20 text-red-500 hover:bg-red-500/30": status === "denied",
111+
"text-vscode-descriptionForeground hover:text-red-500 hover:bg-red-500/10":
112+
status !== "denied",
113+
})}
114+
onClick={() => onDenyPatternChange(item.pattern)}
115+
aria-label={t(
116+
status === "denied"
117+
? "chat:commandExecution.removeFromDenied"
118+
: "chat:commandExecution.addToDenied",
119+
)}>
120+
<X className="size-3.5" />
121+
</button>
122+
</div>
123+
</div>
124+
)
125+
})}
126+
</div>
127+
)}
128+
</div>
129+
)
130+
}

0 commit comments

Comments
 (0)