Skip to content

Commit c68f8f5

Browse files
hannesrudolphellipsis-dev[bot]Copilotroomotedaniel-lxs
authored
feat: Add terminal command permissions UI to chat interface (#5480) (#5798)
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: Copilot <[email protected]> Co-authored-by: Roo Code <[email protected]> Co-authored-by: Daniel Riccio <[email protected]> Co-authored-by: Daniel <[email protected]>
1 parent f45d9be commit c68f8f5

File tree

24 files changed

+1569
-4
lines changed

24 files changed

+1569
-4
lines changed

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

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,21 @@ import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/
66

77
import { ExtensionMessage } from "@roo/ExtensionMessage"
88
import { safeJsonParse } from "@roo/safeJsonParse"
9+
910
import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences"
1011

1112
import { vscode } from "@src/utils/vscode"
1213
import { useExtensionState } from "@src/context/ExtensionStateContext"
1314
import { cn } from "@src/lib/utils"
1415
import { Button } from "@src/components/ui"
1516
import CodeBlock from "../common/CodeBlock"
17+
import { CommandPatternSelector } from "./CommandPatternSelector"
18+
import { extractPatternsFromCommand } from "../../utils/command-parser"
19+
20+
interface CommandPattern {
21+
pattern: string
22+
description?: string
23+
}
1624

1725
interface CommandExecutionProps {
1826
executionId: string
@@ -22,7 +30,13 @@ interface CommandExecutionProps {
2230
}
2331

2432
export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => {
25-
const { terminalShellIntegrationDisabled = false } = useExtensionState()
33+
const {
34+
terminalShellIntegrationDisabled = false,
35+
allowedCommands = [],
36+
deniedCommands = [],
37+
setAllowedCommands,
38+
setDeniedCommands,
39+
} = useExtensionState()
2640

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

@@ -37,6 +51,37 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
3751
// streaming output (this is the case for running commands).
3852
const output = streamingOutput || parsedOutput
3953

54+
// Extract command patterns from the actual command that was executed
55+
const commandPatterns = useMemo<CommandPattern[]>(() => {
56+
const extractedPatterns = extractPatternsFromCommand(command)
57+
return extractedPatterns.map((pattern) => ({
58+
pattern,
59+
}))
60+
}, [command])
61+
62+
// Handle pattern changes
63+
const handleAllowPatternChange = (pattern: string) => {
64+
const isAllowed = allowedCommands.includes(pattern)
65+
const newAllowed = isAllowed ? allowedCommands.filter((p) => p !== pattern) : [...allowedCommands, pattern]
66+
const newDenied = deniedCommands.filter((p) => p !== pattern)
67+
68+
setAllowedCommands(newAllowed)
69+
setDeniedCommands(newDenied)
70+
vscode.postMessage({ type: "allowedCommands", commands: newAllowed })
71+
vscode.postMessage({ type: "deniedCommands", commands: newDenied })
72+
}
73+
74+
const handleDenyPatternChange = (pattern: string) => {
75+
const isDenied = deniedCommands.includes(pattern)
76+
const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern]
77+
const newAllowed = allowedCommands.filter((p) => p !== pattern)
78+
79+
setAllowedCommands(newAllowed)
80+
setDeniedCommands(newDenied)
81+
vscode.postMessage({ type: "allowedCommands", commands: newAllowed })
82+
vscode.postMessage({ type: "deniedCommands", commands: newDenied })
83+
}
84+
4085
const onMessage = useCallback(
4186
(event: MessageEvent) => {
4287
const message: ExtensionMessage = event.data
@@ -121,9 +166,21 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
121166
</div>
122167
</div>
123168

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} />
169+
<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs">
170+
<div className="p-2">
171+
<CodeBlock source={command} language="shell" />
172+
<OutputContainer isExpanded={isExpanded} output={output} />
173+
</div>
174+
{command && command.trim() && (
175+
<CommandPatternSelector
176+
command={command}
177+
patterns={commandPatterns}
178+
allowedCommands={allowedCommands}
179+
deniedCommands={deniedCommands}
180+
onAllowPatternChange={handleAllowPatternChange}
181+
onDenyPatternChange={handleDenyPatternChange}
182+
/>
183+
)}
127184
</div>
128185
</>
129186
)
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import React, { useState, useMemo } 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 { StandardTooltip } from "../ui/standard-tooltip"
7+
8+
interface CommandPattern {
9+
pattern: string
10+
description?: string
11+
}
12+
13+
interface CommandPatternSelectorProps {
14+
command: string
15+
patterns: CommandPattern[]
16+
allowedCommands: string[]
17+
deniedCommands: string[]
18+
onAllowPatternChange: (pattern: string) => void
19+
onDenyPatternChange: (pattern: string) => void
20+
}
21+
22+
export const CommandPatternSelector: React.FC<CommandPatternSelectorProps> = ({
23+
command,
24+
patterns,
25+
allowedCommands,
26+
deniedCommands,
27+
onAllowPatternChange,
28+
onDenyPatternChange,
29+
}) => {
30+
const { t } = useTranslation()
31+
const [isExpanded, setIsExpanded] = useState(false)
32+
const [editingStates, setEditingStates] = useState<Record<string, { isEditing: boolean; value: string }>>({})
33+
34+
// Create a combined list with full command first, then patterns
35+
const allPatterns = useMemo(() => {
36+
const fullCommandPattern: CommandPattern = { pattern: command }
37+
38+
// Create a set to track unique patterns we've already seen
39+
const seenPatterns = new Set<string>()
40+
seenPatterns.add(command) // Add the full command first
41+
42+
// Filter out any patterns that are duplicates or are the same as the full command
43+
const uniquePatterns = patterns.filter((p) => {
44+
if (seenPatterns.has(p.pattern)) {
45+
return false
46+
}
47+
seenPatterns.add(p.pattern)
48+
return true
49+
})
50+
51+
return [fullCommandPattern, ...uniquePatterns]
52+
}, [command, patterns])
53+
54+
const getPatternStatus = (pattern: string): "allowed" | "denied" | "none" => {
55+
if (allowedCommands.includes(pattern)) return "allowed"
56+
if (deniedCommands.includes(pattern)) return "denied"
57+
return "none"
58+
}
59+
60+
const getEditState = (pattern: string) => {
61+
return editingStates[pattern] || { isEditing: false, value: pattern }
62+
}
63+
64+
const setEditState = (pattern: string, isEditing: boolean, value?: string) => {
65+
setEditingStates((prev) => ({
66+
...prev,
67+
[pattern]: { isEditing, value: value ?? pattern },
68+
}))
69+
}
70+
71+
return (
72+
<div className="border-t border-vscode-panel-border bg-vscode-sideBar-background/30">
73+
<button
74+
onClick={() => setIsExpanded(!isExpanded)}
75+
className="w-full px-3 py-2 flex items-center justify-between hover:bg-vscode-list-hoverBackground transition-colors">
76+
<div className="flex items-center gap-2">
77+
<ChevronDown
78+
className={cn("size-4 transition-transform", {
79+
"-rotate-90": !isExpanded,
80+
})}
81+
/>
82+
<span className="text-sm font-medium">{t("chat:commandExecution.manageCommands")}</span>
83+
<StandardTooltip
84+
content={
85+
<div className="max-w-xs">
86+
<Trans
87+
i18nKey="chat:commandExecution.commandManagementDescription"
88+
components={{
89+
settingsLink: (
90+
<VSCodeLink
91+
href="command:workbench.action.openSettings?%5B%22roo-code%22%5D"
92+
className="text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground"
93+
/>
94+
),
95+
}}
96+
/>
97+
</div>
98+
}>
99+
<Info className="size-3.5 text-vscode-descriptionForeground" />
100+
</StandardTooltip>
101+
</div>
102+
</button>
103+
104+
{isExpanded && (
105+
<div className="px-3 pb-3 space-y-2">
106+
{allPatterns.map((item) => {
107+
const editState = getEditState(item.pattern)
108+
const status = getPatternStatus(editState.value)
109+
110+
return (
111+
<div key={item.pattern} className="ml-5 flex items-center gap-2">
112+
<div className="flex-1">
113+
{editState.isEditing ? (
114+
<input
115+
type="text"
116+
value={editState.value}
117+
onChange={(e) => setEditState(item.pattern, true, e.target.value)}
118+
onBlur={() => setEditState(item.pattern, false)}
119+
onKeyDown={(e) => {
120+
if (e.key === "Enter") {
121+
setEditState(item.pattern, false)
122+
}
123+
if (e.key === "Escape") {
124+
setEditState(item.pattern, false, item.pattern)
125+
}
126+
}}
127+
className="font-mono text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded px-2 py-1.5 w-full focus:outline-0 focus:ring-1 focus:ring-vscode-focusBorder"
128+
placeholder={item.pattern}
129+
autoFocus
130+
/>
131+
) : (
132+
<div
133+
onClick={() => setEditState(item.pattern, true)}
134+
className="font-mono text-xs text-vscode-foreground cursor-pointer hover:bg-vscode-list-hoverBackground px-2 py-1.5 rounded transition-colors border border-transparent"
135+
title="Click to edit pattern">
136+
<span>{editState.value}</span>
137+
{item.description && (
138+
<span className="text-vscode-descriptionForeground ml-2">
139+
- {item.description}
140+
</span>
141+
)}
142+
</div>
143+
)}
144+
</div>
145+
<div className="flex items-center gap-1">
146+
<button
147+
className={cn("p-1 rounded transition-all", {
148+
"bg-green-500/20 text-green-500 hover:bg-green-500/30":
149+
status === "allowed",
150+
"text-vscode-descriptionForeground hover:text-green-500 hover:bg-green-500/10":
151+
status !== "allowed",
152+
})}
153+
onClick={() => onAllowPatternChange(editState.value)}
154+
aria-label={t(
155+
status === "allowed"
156+
? "chat:commandExecution.removeFromAllowed"
157+
: "chat:commandExecution.addToAllowed",
158+
)}>
159+
<Check className="size-3.5" />
160+
</button>
161+
<button
162+
className={cn("p-1 rounded transition-all", {
163+
"bg-red-500/20 text-red-500 hover:bg-red-500/30": status === "denied",
164+
"text-vscode-descriptionForeground hover:text-red-500 hover:bg-red-500/10":
165+
status !== "denied",
166+
})}
167+
onClick={() => onDenyPatternChange(editState.value)}
168+
aria-label={t(
169+
status === "denied"
170+
? "chat:commandExecution.removeFromDenied"
171+
: "chat:commandExecution.addToDenied",
172+
)}>
173+
<X className="size-3.5" />
174+
</button>
175+
</div>
176+
</div>
177+
)
178+
})}
179+
</div>
180+
)}
181+
</div>
182+
)
183+
}

0 commit comments

Comments
 (0)